From fad4cd94e5f60100920274aa74aad1c7008e3a5f Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 9 Oct 2025 01:12:39 +0800 Subject: [PATCH 01/18] =?UTF-8?q?feat:=20=E4=B8=BA=20Misskey=20=E9=80=82?= =?UTF-8?q?=E9=85=8D=E5=99=A8=E4=BF=AE=E6=AD=A3=E4=B8=80=E4=BA=9B=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=8C=E6=B7=BB=E5=8A=A0=E6=8A=95=E7=A5=A8=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E8=AF=BB=E5=8F=96=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sources/misskey/misskey_adapter.py | 26 +++++++++++++ .../platform/sources/misskey/misskey_api.py | 37 ++++++++++++++++++- .../platform/sources/misskey/misskey_utils.py | 23 +++++++++++- 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 84608b54a..8978235a1 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -25,6 +25,7 @@ extract_sender_info, create_base_message, process_at_mention, + format_poll, cache_user_info, cache_room_info, ) @@ -309,12 +310,37 @@ async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: file_parts = process_files(message, files) message_parts.extend(file_parts) + # poll 支持:将 poll 结构保存在 message.raw_message / message.poll 中,并将格式化文本追加到消息链 + poll = raw_data.get("poll") + if not poll and isinstance(raw_data.get("note"), dict): + poll = raw_data["note"].get("poll") + if poll and isinstance(poll, dict): + try: + # 保证 raw_message 是可写字典 + if not isinstance(message.raw_message, dict): + message.raw_message = dict(message.raw_message or {}) + message.raw_message["poll"] = poll + except Exception: + # 忽略设置失败 + pass + # 方便插件直接读取 + try: + message.poll = poll + except Exception: + setattr(message, "poll", poll) + + poll_text = format_poll(poll) + if poll_text: + message.message.append(Comp.Plain(poll_text)) + message_parts.append(poll_text) + message.message_str = ( " ".join(part for part in message_parts if part.strip()) if message_parts else "" ) return message + return message async def convert_chat_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: """将 Misskey 聊天消息数据转换为 AstrBotMessage 对象""" diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index dc4adcdd0..1da8f0659 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -157,8 +157,43 @@ async def _handle_message(self, data: Dict[str, Any]): message_type = data.get("type") body = data.get("body", {}) + # 简洁摘要(在 INFO 级别下可见),完整 JSON 仍保留在 DEBUG 级别 + try: + body = data.get("body") or {} + channel_summary = None + if isinstance(body, dict): + # 尝试提取 note/类型/用户/是否含文件/是否隐藏等关键信息 + inner = body.get("body") if isinstance(body.get("body"), dict) else body + note = None + if isinstance(inner, dict) and isinstance(inner.get("note"), dict): + note = inner.get("note") + text = None + has_files = False + is_hidden = False + note_id = None + user = None + if note: + text = note.get("text") + note_id = note.get("id") + files = note.get("files") or [] + has_files = bool(files) + is_hidden = bool(note.get("isHidden")) + user = note.get("user", {}) + channel_summary = ( + f"[Misskey WebSocket] 收到消息类型: {message_type} | " + f"note_id={note_id} | user={user.get('username') if user else None} | " + f"text={'[no-text]' if not text else text[:80]} | files={has_files} | hidden={is_hidden}" + ) + else: + channel_summary = f"[Misskey WebSocket] 收到消息类型: {message_type}" + + logger.info(channel_summary) + except Exception: + logger.info(f"[Misskey WebSocket] 收到消息类型: {message_type}") + + # 仅在 DEBUG 级别打印完整 JSON logger.debug( - f"[Misskey WebSocket] 收到消息类型: {message_type}\n数据: {json.dumps(data, indent=2, ensure_ascii=False)}" + f"[Misskey WebSocket] 收到完整消息: {json.dumps(data, indent=2, ensure_ascii=False)}" ) if message_type == "channel": diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index 9a96b453f..c74e49b6a 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -197,6 +197,25 @@ def process_files( return file_parts +def format_poll(poll: Dict[str, Any]) -> str: + """将 Misskey 的 poll 对象格式化为可读字符串。""" + if not poll or not isinstance(poll, dict): + return "" + parts = [] + multiple = poll.get("multiple", False) + choices = poll.get("choices", []) + parts.append("[投票]") + parts.append("允许多选" if multiple else "单选") + text_choices = [] + for idx, c in enumerate(choices, start=1): + text = c.get("text", "") + votes = c.get("votes", 0) + text_choices.append(f"({idx}) {text} [{votes}票]") + if text_choices: + parts.append("选项: "+ ", ".join(text_choices)) + return " ".join(parts) + + def extract_sender_info( raw_data: Dict[str, Any], is_chat: bool = False ) -> Dict[str, Any]: @@ -248,7 +267,7 @@ def create_base_message( else: session_prefix = "note" session_id = f"{session_prefix}%{sender_info['sender_id']}" - message.type = MessageType.FRIEND_MESSAGE + message.type = MessageType.OTHER_MESSAGE message.session_id = ( session_id if sender_info["sender_id"] else f"{session_prefix}%unknown" @@ -258,6 +277,8 @@ def create_base_message( return message + return message + def process_at_mention( message: AstrBotMessage, raw_text: str, bot_username: str, client_self_id: str From 3d0e2690318c968830819f805603b54bf8b3babd Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 9 Oct 2025 01:23:33 +0800 Subject: [PATCH 02/18] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20Misskey=20?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E9=80=82=E9=85=8D=E5=99=A8=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E9=9A=8F=E6=9C=BA=E9=87=8D=E8=BF=9E=E5=BB=B6=E8=BF=9F?= =?UTF-8?q?=E5=92=8C=E9=80=9A=E9=81=93=E9=87=8D=E6=96=B0=E8=AE=A2=E9=98=85?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sources/misskey/misskey_adapter.py | 14 ++-- .../platform/sources/misskey/misskey_api.py | 65 +++++++++++++++---- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 8978235a1..5e24e553f 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -1,4 +1,5 @@ import asyncio +import random import json from typing import Dict, Any, Optional, Awaitable @@ -110,11 +111,12 @@ async def _start_websocket_connection(self): break streaming = self.api.get_streaming_client() + # register handlers for both plain event types and channel-prefixed variants streaming.add_message_handler("notification", self._handle_notification) + streaming.add_message_handler("main:notification", self._handle_notification) if self.enable_chat: - streaming.add_message_handler( - "newChatMessage", self._handle_chat_message - ) + streaming.add_message_handler("newChatMessage", self._handle_chat_message) + streaming.add_message_handler("messaging:newChatMessage", self._handle_chat_message) streaming.add_message_handler("_debug", self._debug_handler) if await streaming.connect(): @@ -141,10 +143,12 @@ async def _start_websocket_connection(self): ) if self._running: + jitter = random.uniform(0, 1.0) + sleep_time = backoff_delay + jitter logger.info( - f"[Misskey] {backoff_delay:.1f}秒后重连 (下次尝试 #{connection_attempts + 1})" + f"[Misskey] {sleep_time:.1f}秒后重连 (下次尝试 #{connection_attempts + 1})" ) - await asyncio.sleep(backoff_delay) + await asyncio.sleep(sleep_time) backoff_delay = min(backoff_delay * backoff_multiplier, max_backoff) async def _handle_notification(self, data: Dict[str, Any]): diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 1da8f0659..39f2159ff 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -1,4 +1,6 @@ import json +import random +import asyncio from typing import Any, Optional, Dict, List, Callable, Awaitable import uuid @@ -54,7 +56,10 @@ def __init__(self, instance_url: str, access_token: str): self.websocket: Optional[Any] = None self.is_connected = False self.message_handlers: Dict[str, Callable] = {} + # map channel_id -> channel_type self.channels: Dict[str, str] = {} + # desired channel types to keep subscribed across reconnects + self.desired_channels: Dict[str, Optional[Dict]] = {} self._running = False self._last_pong = None @@ -72,6 +77,19 @@ async def connect(self) -> bool: self._running = True logger.info("[Misskey WebSocket] 已连接") + # If we had desired channels from a previous session, resubscribe them + if self.desired_channels: + try: + # make a copy to avoid mutation during iteration + desired = list(self.desired_channels.items()) + for channel_type, params in desired: + try: + await self.subscribe_channel(channel_type, params) + except Exception as e: + logger.warning(f"[Misskey WebSocket] 重新订阅 {channel_type} 失败: {e}") + except Exception: + # never fail the connect flow for resubscribe problems + pass return True except Exception as e: @@ -104,21 +122,24 @@ async def subscribe_channel( return channel_id async def unsubscribe_channel(self, channel_id: str): - if ( - not self.is_connected - or not self.websocket - or channel_id not in self.channels - ): + if not self.is_connected or not self.websocket or channel_id not in self.channels: return message = {"type": "disconnect", "body": {"id": channel_id}} - await self.websocket.send(json.dumps(message)) - del self.channels[channel_id] + channel_type = self.channels.get(channel_id) + # remove the channel mapping + if channel_id in self.channels: + del self.channels[channel_id] + # if no more subscriptions of this type exist, drop the desired channel entry + if channel_type and channel_type not in self.channels.values(): + self.desired_channels.pop(channel_type, None) def add_message_handler( self, event_type: str, handler: Callable[[Dict], Awaitable[None]] ): + # register both the raw event type and the channel-prefixed variant + # e.g. 'main:notification' and 'notification' -> both map to handler self.message_handlers[event_type] = handler async def listen(self): @@ -141,28 +162,43 @@ async def listen(self): except websockets.exceptions.ConnectionClosedError as e: logger.warning(f"[Misskey WebSocket] 连接意外关闭: {e}") self.is_connected = False + try: + await self.disconnect() + except Exception: + pass except websockets.exceptions.ConnectionClosed as e: logger.warning( f"[Misskey WebSocket] 连接已关闭 (代码: {e.code}, 原因: {e.reason})" ) self.is_connected = False + try: + await self.disconnect() + except Exception: + pass except websockets.exceptions.InvalidHandshake as e: logger.error(f"[Misskey WebSocket] 握手失败: {e}") self.is_connected = False + try: + await self.disconnect() + except Exception: + pass except Exception as e: logger.error(f"[Misskey WebSocket] 监听消息失败: {e}") self.is_connected = False + try: + await self.disconnect() + except Exception: + pass async def _handle_message(self, data: Dict[str, Any]): message_type = data.get("type") body = data.get("body", {}) - # 简洁摘要(在 INFO 级别下可见),完整 JSON 仍保留在 DEBUG 级别 + # concise summary for INFO, full payload is logged at DEBUG try: body = data.get("body") or {} channel_summary = None if isinstance(body, dict): - # 尝试提取 note/类型/用户/是否含文件/是否隐藏等关键信息 inner = body.get("body") if isinstance(body.get("body"), dict) else body note = None if isinstance(inner, dict) and isinstance(inner.get("note"), dict): @@ -179,6 +215,7 @@ async def _handle_message(self, data: Dict[str, Any]): has_files = bool(files) is_hidden = bool(note.get("isHidden")) user = note.get("user", {}) + channel_summary = ( f"[Misskey WebSocket] 收到消息类型: {message_type} | " f"note_id={note_id} | user={user.get('username') if user else None} | " @@ -191,7 +228,7 @@ async def _handle_message(self, data: Dict[str, Any]): except Exception: logger.info(f"[Misskey WebSocket] 收到消息类型: {message_type}") - # 仅在 DEBUG 级别打印完整 JSON + # full payload only in DEBUG logger.debug( f"[Misskey WebSocket] 收到完整消息: {json.dumps(data, indent=2, ensure_ascii=False)}" ) @@ -237,15 +274,19 @@ async def _handle_message(self, data: Dict[str, Any]): await self.message_handlers["_debug"](data) -def retry_async(max_retries: int = 3, retryable_exceptions: tuple = ()): +def retry_async(max_retries: int = 3, retryable_exceptions: tuple = (())): def decorator(func): async def wrapper(*args, **kwargs): last_exc = None - for _ in range(max_retries): + for attempt in range(1, max_retries + 1): try: return await func(*args, **kwargs) except retryable_exceptions as e: last_exc = e + # exponential backoff with jitter + backoff = min(2 ** (attempt - 1), 30) + jitter = random.uniform(0, 1) + await asyncio.sleep(backoff + jitter) continue if last_exc: raise last_exc From a778ab64c807412cb497b099161772dbdae53da6 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 9 Oct 2025 01:31:58 +0800 Subject: [PATCH 03/18] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8A=E4=BC=A0=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E6=B6=88=E6=81=AF=E5=8F=91=E9=80=81=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=90=8C=E6=97=B6=E5=8F=91=E9=80=81?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=92=8C=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sources/misskey/misskey_adapter.py | 77 ++++++++++++++++++- .../platform/sources/misskey/misskey_api.py | 66 +++++++++++++--- 2 files changed, 130 insertions(+), 13 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 5e24e553f..dfa5746a7 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -1,7 +1,7 @@ import asyncio import random import json -from typing import Dict, Any, Optional, Awaitable +from typing import Dict, Any, Optional, Awaitable, List from astrbot.api import logger from astrbot.api.event import MessageChain @@ -30,6 +30,8 @@ cache_user_info, cache_room_info, ) +from astrbot.core.utils.astrbot_path import get_astrbot_data_path +import os @register_platform_adapter("misskey", "Misskey 平台适配器") @@ -257,16 +259,84 @@ async def send_by_session( if len(text) > self.max_message_length: text = text[: self.max_message_length] + "..." + # handle file uploads concurrently with a semaphore limit + file_ids: List[str] = [] + upload_concurrency = int(self.config.get("misskey_upload_concurrency", 3)) + sem = asyncio.Semaphore(upload_concurrency) + + async def _upload_comp(comp) -> Optional[str]: + upload_path = None + tmp_created = False + try: + if hasattr(comp, "convert_to_file_path"): + try: + upload_path = await comp.convert_to_file_path() + except Exception: + pass + if not upload_path and hasattr(comp, "get_file"): + try: + upload_path = await comp.get_file() + except Exception: + pass + + if not upload_path: + return None + + # upload under semaphore + async with sem: + if not self.api: + return None + try: + upload_result = await self.api.upload_file(upload_path, getattr(comp, "name", None) or getattr(comp, "file", None)) + fid = None + if isinstance(upload_result, dict): + fid = upload_result.get("id") or (upload_result.get("raw") or {}).get("createdFile", {}).get("id") + # cleanup temporary file if it was created under data/temp + try: + data_temp = os.path.join(get_astrbot_data_path(), "temp") + # normalize paths + if upload_path.startswith(data_temp): + try: + os.remove(upload_path) + logger.debug(f"[Misskey] 已清理临时文件: {upload_path}") + except Exception: + pass + except Exception: + pass + return str(fid) if fid else None + except Exception as e: + logger.error(f"[Misskey] 文件上传失败: {e}") + return None + finally: + # nothing to do here for now + pass + + upload_tasks = [ _upload_comp(comp) for comp in message_chain.chain ] + try: + results = await asyncio.gather(*upload_tasks) + for r in results: + if r: + file_ids.append(r) + except Exception: + logger.debug("[Misskey] 并发上传过程中出现异常,继续发送文本") + if session_id and is_valid_user_session_id(session_id): from .misskey_utils import extract_user_id_from_session_id user_id = extract_user_id_from_session_id(session_id) - await self.api.send_message(user_id, text) + # if file_ids exist and API supports chat attachments, pass them + payload = {"toUserId": user_id, "text": text} + if file_ids: + payload["fileIds"] = file_ids + await self.api.send_message(payload) elif session_id and is_valid_room_session_id(session_id): from .misskey_utils import extract_room_id_from_session_id room_id = extract_room_id_from_session_id(session_id) - await self.api.send_room_message(room_id, text) + payload = {"toRoomId": room_id, "text": text} + if file_ids: + payload["fileIds"] = file_ids + await self.api.send_room_message(payload) else: visibility, visible_user_ids = resolve_message_visibility( user_id=session_id, @@ -279,6 +349,7 @@ async def send_by_session( text, visibility=visibility, visible_user_ids=visible_user_ids, + file_ids=file_ids if file_ids else None, local_only=self.local_only, ) diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 39f2159ff..d34c7d7dc 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -406,6 +406,7 @@ async def create_note( visibility: str = "public", reply_id: Optional[str] = None, visible_user_ids: Optional[List[str]] = None, + file_ids: Optional[List[str]] = None, local_only: bool = False, ) -> Dict[str, Any]: """创建新贴文""" @@ -418,30 +419,75 @@ async def create_note( data["replyId"] = reply_id if visible_user_ids and visibility == "specified": data["visibleUserIds"] = visible_user_ids + if file_ids: + data["fileIds"] = file_ids result = await self._make_request("notes/create", data) note_id = result.get("createdNote", {}).get("id", "unknown") logger.debug(f"发帖成功,note_id: {note_id}") return result + async def upload_file(self, file_path: str, name: Optional[str] = None) -> Dict[str, Any]: + if not file_path: + raise APIError("No file path provided for upload") + + url = f"{self.instance_url}/api/drive/files/create" + form = aiohttp.FormData() + form.add_field("i", self.access_token) + + try: + with open(file_path, "rb") as f: + filename = name or file_path.split("/")[-1] + form.add_field("file", f, filename=filename) + async with self.session.post(url, data=form) as resp: + result = await self._process_response(resp, "drive/files/create") + logger.debug(f"上传文件到 Misskey 成功: {filename}") + # try to extract an id in a few common places for caller convenience + fid = None + if isinstance(result, dict): + fid = ( + result.get("createdFile", {}).get("id") + or result.get("id") + or (result.get("file") or {}).get("id") + ) + return {"id": fid, "raw": result} + except FileNotFoundError as e: + logger.error(f"上传文件失败,本地文件未找到: {file_path}") + raise APIError(f"File not found: {file_path}") from e + except aiohttp.ClientError as e: + logger.error(f"上传文件 HTTP 错误: {e}") + raise APIConnectionError(f"Upload failed: {e}") from e + async def get_current_user(self) -> Dict[str, Any]: """获取当前用户信息""" return await self._make_request("i", {}) - async def send_message(self, user_id: str, text: str) -> Dict[str, Any]: - """发送聊天消息""" - result = await self._make_request( - "chat/messages/create-to-user", {"toUserId": user_id, "text": text} - ) + async def send_message(self, user_id_or_payload: Any, text: Optional[str] = None) -> Dict[str, Any]: + """发送聊天消息。 + + Accepts either (user_id: str, text: str) or a single dict payload prepared by caller. + """ + if isinstance(user_id_or_payload, dict): + data = user_id_or_payload + else: + data = {"toUserId": user_id_or_payload, "text": text} + + result = await self._make_request("chat/messages/create-to-user", data) message_id = result.get("id", "unknown") logger.debug(f"聊天发送成功,message_id: {message_id}") return result - async def send_room_message(self, room_id: str, text: str) -> Dict[str, Any]: - """发送房间消息""" - result = await self._make_request( - "chat/messages/create-to-room", {"toRoomId": room_id, "text": text} - ) + async def send_room_message(self, room_id_or_payload: Any, text: Optional[str] = None) -> Dict[str, Any]: + """发送房间消息。 + + Accepts either (room_id: str, text: str) or a single dict payload. + """ + if isinstance(room_id_or_payload, dict): + data = room_id_or_payload + else: + data = {"toRoomId": room_id_or_payload, "text": text} + + result = await self._make_request("chat/messages/create-to-room", data) message_id = result.get("id", "unknown") logger.debug(f"房间消息发送成功,message_id: {message_id}") return result From c032ef3fbd033ed44f6bea439a67b53156075bde Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 9 Oct 2025 01:49:03 +0800 Subject: [PATCH 04/18] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8A=E4=BC=A0=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20MIME=20=E7=B1=BB=E5=9E=8B=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E5=92=8C=E5=A4=96=E9=83=A8=20URL=20=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sources/misskey/misskey_adapter.py | 135 +++++++++++++++--- 1 file changed, 117 insertions(+), 18 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index dfa5746a7..00689fcdf 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -14,7 +14,12 @@ from astrbot.core.platform.astr_message_event import MessageSession import astrbot.api.message_components as Comp -from .misskey_api import MisskeyAPI +from .misskey_api import MisskeyAPI, APIError +import mimetypes +try: + import magic # type: ignore +except Exception: + magic = None from .misskey_event import MisskeyPlatformEvent from .misskey_utils import ( serialize_message_chain, @@ -264,9 +269,30 @@ async def send_by_session( upload_concurrency = int(self.config.get("misskey_upload_concurrency", 3)) sem = asyncio.Semaphore(upload_concurrency) - async def _upload_comp(comp) -> Optional[str]: + async def _upload_comp(comp) -> Optional[object]: upload_path = None - tmp_created = False + def _detect_mime_and_ext(path: str) -> Optional[str]: + # Try python-magic first (from buffer), fallback to mimetypes + try: + if magic: + m = magic.Magic(mime=True) + mime = m.from_file(path) + else: + mime, _ = mimetypes.guess_type(path) + except Exception: + mime = None + if not mime: + return None + # map common mime to ext + mapping = { + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "text/plain": ".txt", + "application/pdf": ".pdf", + } + return mapping.get(mime, mimetypes.guess_extension(mime) or None) try: if hasattr(comp, "convert_to_file_path"): try: @@ -287,35 +313,101 @@ async def _upload_comp(comp) -> Optional[str]: if not self.api: return None try: - upload_result = await self.api.upload_file(upload_path, getattr(comp, "name", None) or getattr(comp, "file", None)) + upload_result = await self.api.upload_file( + upload_path, getattr(comp, "name", None) or getattr(comp, "file", None) + ) fid = None if isinstance(upload_result, dict): - fid = upload_result.get("id") or (upload_result.get("raw") or {}).get("createdFile", {}).get("id") - # cleanup temporary file if it was created under data/temp + fid = ( + upload_result.get("id") + or (upload_result.get("raw") or {}).get("createdFile", {}).get("id") + ) + return str(fid) if fid else None + except Exception as e: + logger.error(f"[Misskey] 文件上传失败: {e}") + # If it's an unallowed file type, try detecting mime and retry with a suitable extension + tried_names = [] + try: + msg = str(e).lower() + if "unallowed" in msg or "unallowed_file_type" in msg or ( + isinstance(e, APIError) and "unallowed" in str(e).lower() + ): + base_name = os.path.basename(upload_path) + name_root, ext = os.path.splitext(base_name) + # try detect mime -> extension + try_ext = _detect_mime_and_ext(upload_path) + candidates = [] + if try_ext: + candidates.append(try_ext) + # fall back to a small set + candidates.extend([".jpg", ".png", ".txt", ".bin"]) + # if ext is non-empty and short, include it first + if ext and len(ext) <= 5 and ext not in candidates: + candidates.insert(0, ext) + for c in candidates: + try_name = name_root + c + if try_name in tried_names: + continue + tried_names.append(try_name) + try: + upload_result = await self.api.upload_file(upload_path, try_name) + fid = None + if isinstance(upload_result, dict): + fid = ( + upload_result.get("id") + or (upload_result.get("raw") or {}).get("createdFile", {}).get("id") + ) + if fid: + logger.debug(f"[Misskey] 通过重试上传成功,使用文件名: {try_name}") + return str(fid) + except Exception: + pass + except Exception: + pass + + # fallback: try register_to_file_service or get_file(allow_return_url=True) try: - data_temp = os.path.join(get_astrbot_data_path(), "temp") - # normalize paths - if upload_path.startswith(data_temp): + if hasattr(comp, "register_to_file_service"): try: - os.remove(upload_path) - logger.debug(f"[Misskey] 已清理临时文件: {upload_path}") + url = await comp.register_to_file_service() + if url: + return {"fallback_url": url} + except Exception: + pass + if hasattr(comp, "get_file"): + try: + url_or_path = await comp.get_file(True) + if url_or_path and str(url_or_path).startswith("http"): + return {"fallback_url": url_or_path} except Exception: pass except Exception: pass - return str(fid) if fid else None - except Exception as e: - logger.error(f"[Misskey] 文件上传失败: {e}") return None finally: - # nothing to do here for now - pass + # cleanup temporary file if it was created under data/temp + try: + if upload_path: + data_temp = os.path.join(get_astrbot_data_path(), "temp") + if upload_path.startswith(data_temp) and os.path.exists(upload_path): + try: + os.remove(upload_path) + logger.debug(f"[Misskey] 已清理临时文件: {upload_path}") + except Exception: + pass + except Exception: + pass upload_tasks = [ _upload_comp(comp) for comp in message_chain.chain ] + fallback_urls: List[str] = [] try: results = await asyncio.gather(*upload_tasks) for r in results: - if r: + if not r: + continue + if isinstance(r, dict) and r.get("fallback_url"): + fallback_urls.append(r.get("fallback_url")) + else: file_ids.append(r) except Exception: logger.debug("[Misskey] 并发上传过程中出现异常,继续发送文本") @@ -324,7 +416,11 @@ async def _upload_comp(comp) -> Optional[str]: from .misskey_utils import extract_user_id_from_session_id user_id = extract_user_id_from_session_id(session_id) - # if file_ids exist and API supports chat attachments, pass them + # if some uploads fell back to external URLs, append them to the text + if fallback_urls: + appended = "\n" + "\n".join(fallback_urls) + text = (text or "") + appended + payload = {"toUserId": user_id, "text": text} if file_ids: payload["fileIds"] = file_ids @@ -333,6 +429,9 @@ async def _upload_comp(comp) -> Optional[str]: from .misskey_utils import extract_room_id_from_session_id room_id = extract_room_id_from_session_id(session_id) + if fallback_urls: + appended = "\n" + "\n".join(fallback_urls) + text = (text or "") + appended payload = {"toRoomId": room_id, "text": text} if file_ids: payload["fileIds"] = file_ids From 0368face1ba7eaa71a7811b996ed7cf2c3088dd8 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 9 Oct 2025 01:53:37 +0800 Subject: [PATCH 05/18] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20Misskey=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E5=8A=9F=E8=83=BD=E5=BC=80?= =?UTF-8?q?=E5=85=B3=EF=BC=8C=E6=94=AF=E6=8C=81=E9=85=8D=E7=BD=AE=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8A=E4=BC=A0=E5=90=AF=E7=94=A8=E4=B8=8E=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 12 +++++++++++ .../sources/misskey/misskey_adapter.py | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 8f06667c3..3d27431a6 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -254,6 +254,8 @@ "misskey_default_visibility": "public", "misskey_local_only": False, "misskey_enable_chat": True, + "misskey_enable_file_upload": True, + "misskey_upload_concurrency": 3, }, "Slack": { "id": "slack", @@ -382,6 +384,16 @@ "type": "bool", "hint": "启用后,机器人将会监听和响应私信聊天消息", }, + "misskey_enable_file_upload": { + "description": "启用文件上传到 Misskey", + "type": "bool", + "hint": "启用后,适配器会尝试将消息链中的文件上传到 Misskey 并在消息中附加 media(fileIds)。", + }, + "misskey_upload_concurrency": { + "description": "并发上传限制", + "type": "int", + "hint": "同时进行的文件上传任务上限(整数,默认 3)。", + }, "telegram_command_register": { "description": "Telegram 命令注册", "type": "bool", diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 00689fcdf..a9fad7e02 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -55,6 +55,8 @@ def __init__( ) self.local_only = self.config.get("misskey_local_only", False) self.enable_chat = self.config.get("misskey_enable_chat", True) + # whether to enable file upload to Misskey (drive/files/create) + self.enable_file_upload = self.config.get("misskey_enable_file_upload", True) self.unique_session = platform_settings["unique_session"] @@ -266,6 +268,25 @@ async def send_by_session( # handle file uploads concurrently with a semaphore limit file_ids: List[str] = [] + fallback_urls: List[str] = [] + + if not self.enable_file_upload: + logger.debug("[Misskey] 文件上传已在配置中禁用,跳过上传流程") + # skip to sending text-only payloads + if session_id and is_valid_user_session_id(session_id): + from .misskey_utils import extract_user_id_from_session_id + + user_id = extract_user_id_from_session_id(session_id) + payload = {"toUserId": user_id, "text": text} + await self.api.send_message(payload) + return await super().send_by_session(session, message_chain) + elif session_id and is_valid_room_session_id(session_id): + from .misskey_utils import extract_room_id_from_session_id + + room_id = extract_room_id_from_session_id(session_id) + payload = {"toRoomId": room_id, "text": text} + await self.api.send_room_message(payload) + return await super().send_by_session(session, message_chain) upload_concurrency = int(self.config.get("misskey_upload_concurrency", 3)) sem = asyncio.Semaphore(upload_concurrency) From 7be77ebc1949607828149152420b011291ed727c Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 9 Oct 2025 02:01:47 +0800 Subject: [PATCH 06/18] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20Misskey=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E7=9B=AE=E6=A0=87=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=B9=E9=85=8D=E7=BD=AE=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=B0=86=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E5=88=B0=E6=8C=87?= =?UTF-8?q?=E5=AE=9A=E6=96=87=E4=BB=B6=E5=A4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 6 +++ .../sources/misskey/misskey_adapter.py | 54 ++++++++++--------- .../platform/sources/misskey/misskey_api.py | 5 +- 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 3d27431a6..d9ad47b88 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -256,6 +256,7 @@ "misskey_enable_chat": True, "misskey_enable_file_upload": True, "misskey_upload_concurrency": 3, + "misskey_upload_folder": "", }, "Slack": { "id": "slack", @@ -394,6 +395,11 @@ "type": "int", "hint": "同时进行的文件上传任务上限(整数,默认 3)。", }, + "misskey_upload_folder": { + "description": "上传到网盘的目标文件夹 ID", + "type": "string", + "hint": "可选:填写 Misskey 网盘中目标文件夹的 ID,上传的文件将放置到该文件夹内以避免账号网盘根目录混乱。留空则使用默认位置。", + }, "telegram_command_register": { "description": "Telegram 命令注册", "type": "bool", diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index a9fad7e02..371202929 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -57,6 +57,7 @@ def __init__( self.enable_chat = self.config.get("misskey_enable_chat", True) # whether to enable file upload to Misskey (drive/files/create) self.enable_file_upload = self.config.get("misskey_enable_file_upload", True) + self.upload_folder = self.config.get("misskey_upload_folder") self.unique_session = platform_settings["unique_session"] @@ -277,7 +278,7 @@ async def send_by_session( from .misskey_utils import extract_user_id_from_session_id user_id = extract_user_id_from_session_id(session_id) - payload = {"toUserId": user_id, "text": text} + payload: Dict[str, Any] = {"toUserId": user_id, "text": text} await self.api.send_message(payload) return await super().send_by_session(session, message_chain) elif session_id and is_valid_room_session_id(session_id): @@ -335,7 +336,9 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: return None try: upload_result = await self.api.upload_file( - upload_path, getattr(comp, "name", None) or getattr(comp, "file", None) + upload_path, + getattr(comp, "name", None) or getattr(comp, "file", None), + folder_id=self.upload_folder, ) fid = None if isinstance(upload_result, dict): @@ -371,7 +374,7 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: continue tried_names.append(try_name) try: - upload_result = await self.api.upload_file(upload_path, try_name) + upload_result = await self.api.upload_file(upload_path, try_name, folder_id=self.upload_folder) fid = None if isinstance(upload_result, dict): fid = ( @@ -427,33 +430,28 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: if not r: continue if isinstance(r, dict) and r.get("fallback_url"): - fallback_urls.append(r.get("fallback_url")) + url = r.get("fallback_url") + if url: + fallback_urls.append(str(url)) else: - file_ids.append(r) + # ensure we only append string file ids + try: + fid_str = str(r) + except Exception: + fid_str = None + if fid_str: + file_ids.append(fid_str) except Exception: logger.debug("[Misskey] 并发上传过程中出现异常,继续发送文本") if session_id and is_valid_user_session_id(session_id): - from .misskey_utils import extract_user_id_from_session_id - - user_id = extract_user_id_from_session_id(session_id) - # if some uploads fell back to external URLs, append them to the text - if fallback_urls: - appended = "\n" + "\n".join(fallback_urls) - text = (text or "") + appended - - payload = {"toUserId": user_id, "text": text} - if file_ids: - payload["fileIds"] = file_ids - await self.api.send_message(payload) - elif session_id and is_valid_room_session_id(session_id): from .misskey_utils import extract_room_id_from_session_id room_id = extract_room_id_from_session_id(session_id) if fallback_urls: appended = "\n" + "\n".join(fallback_urls) text = (text or "") + appended - payload = {"toRoomId": room_id, "text": text} + payload: Dict[str, Any] = {"toRoomId": room_id, "text": text} if file_ids: payload["fileIds"] = file_ids await self.api.send_room_message(payload) @@ -510,19 +508,23 @@ async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: if not poll and isinstance(raw_data.get("note"), dict): poll = raw_data["note"].get("poll") if poll and isinstance(poll, dict): + # 保证 raw_message 是一个可写字典 try: - # 保证 raw_message 是可写字典 if not isinstance(message.raw_message, dict): - message.raw_message = dict(message.raw_message or {}) + message.raw_message = {} message.raw_message["poll"] = poll except Exception: - # 忽略设置失败 - pass - # 方便插件直接读取 + # 忽略设置失败,确保 raw_message 最少为 dict + try: + message.raw_message = {} + message.raw_message["poll"] = poll + except Exception: + pass + # 方便插件直接读取,使用 setattr 以兼容不同 message 类型 try: - message.poll = poll - except Exception: setattr(message, "poll", poll) + except Exception: + pass poll_text = format_poll(poll) if poll_text: diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index d34c7d7dc..4f0267117 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -427,7 +427,7 @@ async def create_note( logger.debug(f"发帖成功,note_id: {note_id}") return result - async def upload_file(self, file_path: str, name: Optional[str] = None) -> Dict[str, Any]: + async def upload_file(self, file_path: str, name: Optional[str] = None, folder_id: Optional[str] = None) -> Dict[str, Any]: if not file_path: raise APIError("No file path provided for upload") @@ -439,6 +439,9 @@ async def upload_file(self, file_path: str, name: Optional[str] = None) -> Dict[ with open(file_path, "rb") as f: filename = name or file_path.split("/")[-1] form.add_field("file", f, filename=filename) + # If a folder id was provided, include it so the file is placed into that folder + if folder_id: + form.add_field("folderId", str(folder_id)) async with self.session.post(url, data=form) as resp: result = await self._process_response(resp, "drive/files/create") logger.debug(f"上传文件到 Misskey 成功: {filename}") From 1cf7a76010b19ca001aa0106678d4d2dedb5dc15 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 9 Oct 2025 02:14:59 +0800 Subject: [PATCH 07/18] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=20Misskey=20?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E9=80=82=E9=85=8D=E5=99=A8=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E5=92=8C=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=8F=91=E9=80=81=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=9B=B4=E5=A4=9A=E5=8F=AF=E9=80=89=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sources/misskey/misskey_adapter.py | 106 ++++++++++++++---- .../platform/sources/misskey/misskey_api.py | 86 +++++++++++--- .../platform/sources/misskey/misskey_utils.py | 2 +- 3 files changed, 155 insertions(+), 39 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 371202929..d21c0e6b2 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -16,6 +16,7 @@ from .misskey_api import MisskeyAPI, APIError import mimetypes + try: import magic # type: ignore except Exception: @@ -31,7 +32,7 @@ extract_sender_info, create_base_message, process_at_mention, - format_poll, + format_poll, cache_user_info, cache_room_info, ) @@ -123,10 +124,16 @@ async def _start_websocket_connection(self): streaming = self.api.get_streaming_client() # register handlers for both plain event types and channel-prefixed variants streaming.add_message_handler("notification", self._handle_notification) - streaming.add_message_handler("main:notification", self._handle_notification) + streaming.add_message_handler( + "main:notification", self._handle_notification + ) if self.enable_chat: - streaming.add_message_handler("newChatMessage", self._handle_chat_message) - streaming.add_message_handler("messaging:newChatMessage", self._handle_chat_message) + streaming.add_message_handler( + "newChatMessage", self._handle_chat_message + ) + streaming.add_message_handler( + "messaging:newChatMessage", self._handle_chat_message + ) streaming.add_message_handler("_debug", self._debug_handler) if await streaming.connect(): @@ -293,6 +300,7 @@ async def send_by_session( async def _upload_comp(comp) -> Optional[object]: upload_path = None + def _detect_mime_and_ext(path: str) -> Optional[str]: # Try python-magic first (from buffer), fallback to mimetypes try: @@ -315,6 +323,7 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: "application/pdf": ".pdf", } return mapping.get(mime, mimetypes.guess_extension(mime) or None) + try: if hasattr(comp, "convert_to_file_path"): try: @@ -337,15 +346,15 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: try: upload_result = await self.api.upload_file( upload_path, - getattr(comp, "name", None) or getattr(comp, "file", None), + getattr(comp, "name", None) + or getattr(comp, "file", None), folder_id=self.upload_folder, ) fid = None if isinstance(upload_result, dict): - fid = ( - upload_result.get("id") - or (upload_result.get("raw") or {}).get("createdFile", {}).get("id") - ) + fid = upload_result.get("id") or ( + upload_result.get("raw") or {} + ).get("createdFile", {}).get("id") return str(fid) if fid else None except Exception as e: logger.error(f"[Misskey] 文件上传失败: {e}") @@ -353,8 +362,13 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: tried_names = [] try: msg = str(e).lower() - if "unallowed" in msg or "unallowed_file_type" in msg or ( - isinstance(e, APIError) and "unallowed" in str(e).lower() + if ( + "unallowed" in msg + or "unallowed_file_type" in msg + or ( + isinstance(e, APIError) + and "unallowed" in str(e).lower() + ) ): base_name = os.path.basename(upload_path) name_root, ext = os.path.splitext(base_name) @@ -374,15 +388,20 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: continue tried_names.append(try_name) try: - upload_result = await self.api.upload_file(upload_path, try_name, folder_id=self.upload_folder) + upload_result = await self.api.upload_file( + upload_path, + try_name, + folder_id=self.upload_folder, + ) fid = None if isinstance(upload_result, dict): - fid = ( - upload_result.get("id") - or (upload_result.get("raw") or {}).get("createdFile", {}).get("id") - ) + fid = upload_result.get("id") or ( + upload_result.get("raw") or {} + ).get("createdFile", {}).get("id") if fid: - logger.debug(f"[Misskey] 通过重试上传成功,使用文件名: {try_name}") + logger.debug( + f"[Misskey] 通过重试上传成功,使用文件名: {try_name}" + ) return str(fid) except Exception: pass @@ -401,7 +420,9 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: if hasattr(comp, "get_file"): try: url_or_path = await comp.get_file(True) - if url_or_path and str(url_or_path).startswith("http"): + if url_or_path and str(url_or_path).startswith( + "http" + ): return {"fallback_url": url_or_path} except Exception: pass @@ -413,16 +434,20 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: try: if upload_path: data_temp = os.path.join(get_astrbot_data_path(), "temp") - if upload_path.startswith(data_temp) and os.path.exists(upload_path): + if upload_path.startswith(data_temp) and os.path.exists( + upload_path + ): try: os.remove(upload_path) - logger.debug(f"[Misskey] 已清理临时文件: {upload_path}") + logger.debug( + f"[Misskey] 已清理临时文件: {upload_path}" + ) except Exception: pass except Exception: pass - upload_tasks = [ _upload_comp(comp) for comp in message_chain.chain ] + upload_tasks = [_upload_comp(comp) for comp in message_chain.chain] fallback_urls: List[str] = [] try: results = await asyncio.gather(*upload_tasks) @@ -463,12 +488,49 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: default_visibility=self.default_visibility, ) + # Extract additional fields from message chain or session context + cw = None + poll = None + renote_id = None + channel_id = None + + # Try to extract CW from message components (safe attribute check) + for comp in message_chain.chain: + if hasattr(comp, "cw") and getattr(comp, "cw", None): + cw = getattr(comp, "cw") + break + + # Try to extract poll from session data (safe attribute check) + if hasattr(session, "extra_data") and isinstance( + getattr(session, "extra_data", None), dict + ): + extra_data = getattr(session, "extra_data") + poll = extra_data.get("poll") + renote_id = extra_data.get("renote_id") + channel_id = extra_data.get("channel_id") + + # Limit file_ids to 16 (Misskey server limit) + if file_ids and len(file_ids) > 16: + logger.warning( + f"[Misskey] 文件数量超过限制 ({len(file_ids)} > 16),只上传前16个文件" + ) + file_ids = file_ids[:16] + + # Add fallback URLs to text if we have them + if fallback_urls: + appended = "\n" + "\n".join(fallback_urls) + text = (text or "") + appended + await self.api.create_note( - text, + text=text, visibility=visibility, visible_user_ids=visible_user_ids, file_ids=file_ids if file_ids else None, local_only=self.local_only, + cw=cw, + poll=poll, + renote_id=renote_id, + channel_id=channel_id, ) except Exception as e: diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 4f0267117..da9035c33 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -86,7 +86,9 @@ async def connect(self) -> bool: try: await self.subscribe_channel(channel_type, params) except Exception as e: - logger.warning(f"[Misskey WebSocket] 重新订阅 {channel_type} 失败: {e}") + logger.warning( + f"[Misskey WebSocket] 重新订阅 {channel_type} 失败: {e}" + ) except Exception: # never fail the connect flow for resubscribe problems pass @@ -122,7 +124,11 @@ async def subscribe_channel( return channel_id async def unsubscribe_channel(self, channel_id: str): - if not self.is_connected or not self.websocket or channel_id not in self.channels: + if ( + not self.is_connected + or not self.websocket + or channel_id not in self.channels + ): return message = {"type": "disconnect", "body": {"id": channel_id}} @@ -402,32 +408,78 @@ async def _make_request( async def create_note( self, - text: str, + text: Optional[str] = None, visibility: str = "public", reply_id: Optional[str] = None, visible_user_ids: Optional[List[str]] = None, file_ids: Optional[List[str]] = None, local_only: bool = False, + # Additional optional Misskey create fields + cw: Optional[str] = None, + poll: Optional[Dict[str, Any]] = None, + renote_id: Optional[str] = None, + channel_id: Optional[str] = None, + reaction_acceptance: Optional[str] = None, + no_extract_mentions: Optional[bool] = None, + no_extract_hashtags: Optional[bool] = None, + no_extract_emojis: Optional[bool] = None, + media_ids: Optional[List[str]] = None, ) -> Dict[str, Any]: - """创建新贴文""" - data: Dict[str, Any] = { - "text": text, - "visibility": visibility, - "localOnly": local_only, - } + """Create a note (wrapper for notes/create). All additional fields are optional and passed through to the API.""" + data: Dict[str, Any] = {} + + # only include text if provided (server allows null in some flows) + if text is not None: + data["text"] = text + + data["visibility"] = visibility + data["localOnly"] = local_only + if reply_id: data["replyId"] = reply_id + if visible_user_ids and visibility == "specified": data["visibleUserIds"] = visible_user_ids + + # support both fileIds and mediaIds (server accepts either) if file_ids: data["fileIds"] = file_ids + if media_ids: + data["mediaIds"] = media_ids + + if cw is not None: + data["cw"] = cw + if poll is not None: + data["poll"] = poll + if renote_id is not None: + data["renoteId"] = renote_id + if channel_id is not None: + data["channelId"] = channel_id + if reaction_acceptance is not None: + data["reactionAcceptance"] = reaction_acceptance + if no_extract_mentions is not None: + data["noExtractMentions"] = bool(no_extract_mentions) + if no_extract_hashtags is not None: + data["noExtractHashtags"] = bool(no_extract_hashtags) + if no_extract_emojis is not None: + data["noExtractEmojis"] = bool(no_extract_emojis) result = await self._make_request("notes/create", data) - note_id = result.get("createdNote", {}).get("id", "unknown") + note_id = ( + result.get("createdNote", {}).get("id", "unknown") + if isinstance(result, dict) + else "unknown" + ) logger.debug(f"发帖成功,note_id: {note_id}") return result - async def upload_file(self, file_path: str, name: Optional[str] = None, folder_id: Optional[str] = None) -> Dict[str, Any]: + async def upload_file( + self, + file_path: str, + name: Optional[str] = None, + folder_id: Optional[str] = None, + ) -> Dict[str, Any]: + """Upload a file to Misskey drive/files/create and return a dict containing id and raw result.""" if not file_path: raise APIError("No file path provided for upload") @@ -439,17 +491,15 @@ async def upload_file(self, file_path: str, name: Optional[str] = None, folder_i with open(file_path, "rb") as f: filename = name or file_path.split("/")[-1] form.add_field("file", f, filename=filename) - # If a folder id was provided, include it so the file is placed into that folder if folder_id: form.add_field("folderId", str(folder_id)) async with self.session.post(url, data=form) as resp: result = await self._process_response(resp, "drive/files/create") logger.debug(f"上传文件到 Misskey 成功: {filename}") - # try to extract an id in a few common places for caller convenience fid = None if isinstance(result, dict): fid = ( - result.get("createdFile", {}).get("id") + (result.get("createdFile") or {}).get("id") or result.get("id") or (result.get("file") or {}).get("id") ) @@ -465,7 +515,9 @@ async def get_current_user(self) -> Dict[str, Any]: """获取当前用户信息""" return await self._make_request("i", {}) - async def send_message(self, user_id_or_payload: Any, text: Optional[str] = None) -> Dict[str, Any]: + async def send_message( + self, user_id_or_payload: Any, text: Optional[str] = None + ) -> Dict[str, Any]: """发送聊天消息。 Accepts either (user_id: str, text: str) or a single dict payload prepared by caller. @@ -480,7 +532,9 @@ async def send_message(self, user_id_or_payload: Any, text: Optional[str] = None logger.debug(f"聊天发送成功,message_id: {message_id}") return result - async def send_room_message(self, room_id_or_payload: Any, text: Optional[str] = None) -> Dict[str, Any]: + async def send_room_message( + self, room_id_or_payload: Any, text: Optional[str] = None + ) -> Dict[str, Any]: """发送房间消息。 Accepts either (room_id: str, text: str) or a single dict payload. diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index c74e49b6a..700e1b638 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -212,7 +212,7 @@ def format_poll(poll: Dict[str, Any]) -> str: votes = c.get("votes", 0) text_choices.append(f"({idx}) {text} [{votes}票]") if text_choices: - parts.append("选项: "+ ", ".join(text_choices)) + parts.append("选项: " + ", ".join(text_choices)) return " ".join(parts) From c911500918514601cfa6c030cddf8db25ffe46a8 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 9 Oct 2025 02:33:46 +0800 Subject: [PATCH 08/18] =?UTF-8?q?feat:=20=E4=BB=A3=E7=A0=81=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=BB=93=E6=9E=84=E4=B8=8E=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sources/misskey/misskey_adapter.py | 279 +++++++++--------- .../platform/sources/misskey/misskey_api.py | 15 - .../platform/sources/misskey/misskey_utils.py | 2 - 3 files changed, 146 insertions(+), 150 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index d21c0e6b2..6e78d67fc 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -16,11 +16,13 @@ from .misskey_api import MisskeyAPI, APIError import mimetypes +import os try: import magic # type: ignore except Exception: magic = None + from .misskey_event import MisskeyPlatformEvent from .misskey_utils import ( serialize_message_chain, @@ -37,7 +39,10 @@ cache_room_info, ) from astrbot.core.utils.astrbot_path import get_astrbot_data_path -import os + +# Constants +MAX_FILE_UPLOAD_COUNT = 16 +DEFAULT_UPLOAD_CONCURRENCY = 3 @register_platform_adapter("misskey", "Misskey 平台适配器") @@ -56,7 +61,6 @@ def __init__( ) self.local_only = self.config.get("misskey_local_only", False) self.enable_chat = self.config.get("misskey_enable_chat", True) - # whether to enable file upload to Misskey (drive/files/create) self.enable_file_upload = self.config.get("misskey_enable_file_upload", True) self.upload_folder = self.config.get("misskey_upload_folder") @@ -108,6 +112,104 @@ async def run(self): await self._start_websocket_connection() + def _register_event_handlers(self, streaming): + """注册事件处理器""" + streaming.add_message_handler("notification", self._handle_notification) + streaming.add_message_handler("main:notification", self._handle_notification) + + if self.enable_chat: + streaming.add_message_handler("newChatMessage", self._handle_chat_message) + streaming.add_message_handler( + "messaging:newChatMessage", self._handle_chat_message + ) + streaming.add_message_handler("_debug", self._debug_handler) + + async def _send_text_only_message( + self, session_id: str, text: str, session, message_chain + ): + """发送纯文本消息(无文件上传)""" + if not self.api: + return await super().send_by_session(session, message_chain) + + if session_id and is_valid_user_session_id(session_id): + from .misskey_utils import extract_user_id_from_session_id + + user_id = extract_user_id_from_session_id(session_id) + payload: Dict[str, Any] = {"toUserId": user_id, "text": text} + await self.api.send_message(payload) + elif session_id and is_valid_room_session_id(session_id): + from .misskey_utils import extract_room_id_from_session_id + + room_id = extract_room_id_from_session_id(session_id) + payload = {"toRoomId": room_id, "text": text} + await self.api.send_room_message(payload) + + return await super().send_by_session(session, message_chain) + + def _detect_mime_and_ext(self, path: str) -> Optional[str]: + """检测文件MIME类型并返回对应扩展名""" + try: + if magic: + m = magic.Magic(mime=True) + mime = m.from_file(path) + else: + mime, _ = mimetypes.guess_type(path) + except Exception: + mime = None + + if not mime: + return None + + mapping = { + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "text/plain": ".txt", + "application/pdf": ".pdf", + } + return mapping.get(mime, mimetypes.guess_extension(mime) or None) + + def _process_poll_data( + self, message: AstrBotMessage, poll: Dict[str, Any], message_parts: List[str] + ): + """处理投票数据,将其添加到消息中""" + try: + if not isinstance(message.raw_message, dict): + message.raw_message = {} + message.raw_message["poll"] = poll + setattr(message, "poll", poll) + except Exception: + pass + + poll_text = format_poll(poll) + if poll_text: + message.message.append(Comp.Plain(poll_text)) + message_parts.append(poll_text) + + def _extract_additional_fields(self, session, message_chain) -> Dict[str, Any]: + """从会话和消息链中提取额外字段""" + fields = {"cw": None, "poll": None, "renote_id": None, "channel_id": None} + + for comp in message_chain.chain: + if hasattr(comp, "cw") and getattr(comp, "cw", None): + fields["cw"] = getattr(comp, "cw") + break + + if hasattr(session, "extra_data") and isinstance( + getattr(session, "extra_data", None), dict + ): + extra_data = getattr(session, "extra_data") + fields.update( + { + "poll": extra_data.get("poll"), + "renote_id": extra_data.get("renote_id"), + "channel_id": extra_data.get("channel_id"), + } + ) + + return fields + async def _start_websocket_connection(self): backoff_delay = 1.0 max_backoff = 300.0 @@ -122,32 +224,20 @@ async def _start_websocket_connection(self): break streaming = self.api.get_streaming_client() - # register handlers for both plain event types and channel-prefixed variants - streaming.add_message_handler("notification", self._handle_notification) - streaming.add_message_handler( - "main:notification", self._handle_notification - ) - if self.enable_chat: - streaming.add_message_handler( - "newChatMessage", self._handle_chat_message - ) - streaming.add_message_handler( - "messaging:newChatMessage", self._handle_chat_message - ) - streaming.add_message_handler("_debug", self._debug_handler) + self._register_event_handlers(streaming) if await streaming.connect(): logger.info( f"[Misskey] WebSocket 已连接 (尝试 #{connection_attempts})" ) - connection_attempts = 0 # 重置计数器 + connection_attempts = 0 await streaming.subscribe_channel("main") if self.enable_chat: await streaming.subscribe_channel("messaging") await streaming.subscribe_channel("messagingIndex") logger.info("[Misskey] 聊天频道已订阅") - backoff_delay = 1.0 # 重置延迟 + backoff_delay = 1.0 await streaming.listen() else: logger.error( @@ -274,56 +364,25 @@ async def send_by_session( if len(text) > self.max_message_length: text = text[: self.max_message_length] + "..." - # handle file uploads concurrently with a semaphore limit file_ids: List[str] = [] fallback_urls: List[str] = [] if not self.enable_file_upload: logger.debug("[Misskey] 文件上传已在配置中禁用,跳过上传流程") - # skip to sending text-only payloads - if session_id and is_valid_user_session_id(session_id): - from .misskey_utils import extract_user_id_from_session_id - - user_id = extract_user_id_from_session_id(session_id) - payload: Dict[str, Any] = {"toUserId": user_id, "text": text} - await self.api.send_message(payload) - return await super().send_by_session(session, message_chain) - elif session_id and is_valid_room_session_id(session_id): - from .misskey_utils import extract_room_id_from_session_id - - room_id = extract_room_id_from_session_id(session_id) - payload = {"toRoomId": room_id, "text": text} - await self.api.send_room_message(payload) - return await super().send_by_session(session, message_chain) - upload_concurrency = int(self.config.get("misskey_upload_concurrency", 3)) + return await self._send_text_only_message( + session_id, text, session, message_chain + ) + + upload_concurrency = int( + self.config.get( + "misskey_upload_concurrency", DEFAULT_UPLOAD_CONCURRENCY + ) + ) sem = asyncio.Semaphore(upload_concurrency) async def _upload_comp(comp) -> Optional[object]: upload_path = None - def _detect_mime_and_ext(path: str) -> Optional[str]: - # Try python-magic first (from buffer), fallback to mimetypes - try: - if magic: - m = magic.Magic(mime=True) - mime = m.from_file(path) - else: - mime, _ = mimetypes.guess_type(path) - except Exception: - mime = None - if not mime: - return None - # map common mime to ext - mapping = { - "image/jpeg": ".jpg", - "image/jpg": ".jpg", - "image/png": ".png", - "image/gif": ".gif", - "text/plain": ".txt", - "application/pdf": ".pdf", - } - return mapping.get(mime, mimetypes.guess_extension(mime) or None) - try: if hasattr(comp, "convert_to_file_path"): try: @@ -339,7 +398,6 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: if not upload_path: return None - # upload under semaphore async with sem: if not self.api: return None @@ -358,7 +416,6 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: return str(fid) if fid else None except Exception as e: logger.error(f"[Misskey] 文件上传失败: {e}") - # If it's an unallowed file type, try detecting mime and retry with a suitable extension tried_names = [] try: msg = str(e).lower() @@ -372,14 +429,11 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: ): base_name = os.path.basename(upload_path) name_root, ext = os.path.splitext(base_name) - # try detect mime -> extension - try_ext = _detect_mime_and_ext(upload_path) + try_ext = self._detect_mime_and_ext(upload_path) candidates = [] if try_ext: candidates.append(try_ext) - # fall back to a small set candidates.extend([".jpg", ".png", ".txt", ".bin"]) - # if ext is non-empty and short, include it first if ext and len(ext) <= 5 and ext not in candidates: candidates.insert(0, ext) for c in candidates: @@ -408,7 +462,6 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: except Exception: pass - # fallback: try register_to_file_service or get_file(allow_return_url=True) try: if hasattr(comp, "register_to_file_service"): try: @@ -430,7 +483,6 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: pass return None finally: - # cleanup temporary file if it was created under data/temp try: if upload_path: data_temp = os.path.join(get_astrbot_data_path(), "temp") @@ -447,10 +499,21 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: except Exception: pass - upload_tasks = [_upload_comp(comp) for comp in message_chain.chain] - fallback_urls: List[str] = [] + file_components = [ + comp + for comp in message_chain.chain + if hasattr(comp, "convert_to_file_path") or hasattr(comp, "get_file") + ] + if len(file_components) > MAX_FILE_UPLOAD_COUNT: + logger.warning( + f"[Misskey] 文件数量超过限制 ({len(file_components)} > {MAX_FILE_UPLOAD_COUNT}),只上传前{MAX_FILE_UPLOAD_COUNT}个文件" + ) + file_components = file_components[:MAX_FILE_UPLOAD_COUNT] + + upload_tasks = [_upload_comp(comp) for comp in file_components] + try: - results = await asyncio.gather(*upload_tasks) + results = await asyncio.gather(*upload_tasks) if upload_tasks else [] for r in results: if not r: continue @@ -459,17 +522,16 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: if url: fallback_urls.append(str(url)) else: - # ensure we only append string file ids try: fid_str = str(r) + if fid_str: + file_ids.append(fid_str) except Exception: - fid_str = None - if fid_str: - file_ids.append(fid_str) + pass except Exception: logger.debug("[Misskey] 并发上传过程中出现异常,继续发送文本") - if session_id and is_valid_user_session_id(session_id): + if session_id and is_valid_room_session_id(session_id): from .misskey_utils import extract_room_id_from_session_id room_id = extract_room_id_from_session_id(session_id) @@ -488,35 +550,7 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: default_visibility=self.default_visibility, ) - # Extract additional fields from message chain or session context - cw = None - poll = None - renote_id = None - channel_id = None - - # Try to extract CW from message components (safe attribute check) - for comp in message_chain.chain: - if hasattr(comp, "cw") and getattr(comp, "cw", None): - cw = getattr(comp, "cw") - break - - # Try to extract poll from session data (safe attribute check) - if hasattr(session, "extra_data") and isinstance( - getattr(session, "extra_data", None), dict - ): - extra_data = getattr(session, "extra_data") - poll = extra_data.get("poll") - renote_id = extra_data.get("renote_id") - channel_id = extra_data.get("channel_id") - - # Limit file_ids to 16 (Misskey server limit) - if file_ids and len(file_ids) > 16: - logger.warning( - f"[Misskey] 文件数量超过限制 ({len(file_ids)} > 16),只上传前16个文件" - ) - file_ids = file_ids[:16] - - # Add fallback URLs to text if we have them + fields = self._extract_additional_fields(session, message_chain) if fallback_urls: appended = "\n" + "\n".join(fallback_urls) text = (text or "") + appended @@ -527,10 +561,10 @@ def _detect_mime_and_ext(path: str) -> Optional[str]: visible_user_ids=visible_user_ids, file_ids=file_ids if file_ids else None, local_only=self.local_only, - cw=cw, - poll=poll, - renote_id=renote_id, - channel_id=channel_id, + cw=fields["cw"], + poll=fields["poll"], + renote_id=fields["renote_id"], + channel_id=fields["channel_id"], ) except Exception as e: @@ -565,33 +599,13 @@ async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: file_parts = process_files(message, files) message_parts.extend(file_parts) - # poll 支持:将 poll 结构保存在 message.raw_message / message.poll 中,并将格式化文本追加到消息链 - poll = raw_data.get("poll") - if not poll and isinstance(raw_data.get("note"), dict): - poll = raw_data["note"].get("poll") + poll = raw_data.get("poll") or ( + raw_data.get("note", {}).get("poll") + if isinstance(raw_data.get("note"), dict) + else None + ) if poll and isinstance(poll, dict): - # 保证 raw_message 是一个可写字典 - try: - if not isinstance(message.raw_message, dict): - message.raw_message = {} - message.raw_message["poll"] = poll - except Exception: - # 忽略设置失败,确保 raw_message 最少为 dict - try: - message.raw_message = {} - message.raw_message["poll"] = poll - except Exception: - pass - # 方便插件直接读取,使用 setattr 以兼容不同 message 类型 - try: - setattr(message, "poll", poll) - except Exception: - pass - - poll_text = format_poll(poll) - if poll_text: - message.message.append(Comp.Plain(poll_text)) - message_parts.append(poll_text) + self._process_poll_data(message, poll, message_parts) message.message_str = ( " ".join(part for part in message_parts if part.strip()) @@ -599,7 +613,6 @@ async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: else "" ) return message - return message async def convert_chat_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: """将 Misskey 聊天消息数据转换为 AstrBotMessage 对象""" diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index da9035c33..493615b34 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -56,9 +56,7 @@ def __init__(self, instance_url: str, access_token: str): self.websocket: Optional[Any] = None self.is_connected = False self.message_handlers: Dict[str, Callable] = {} - # map channel_id -> channel_type self.channels: Dict[str, str] = {} - # desired channel types to keep subscribed across reconnects self.desired_channels: Dict[str, Optional[Dict]] = {} self._running = False self._last_pong = None @@ -77,10 +75,8 @@ async def connect(self) -> bool: self._running = True logger.info("[Misskey WebSocket] 已连接") - # If we had desired channels from a previous session, resubscribe them if self.desired_channels: try: - # make a copy to avoid mutation during iteration desired = list(self.desired_channels.items()) for channel_type, params in desired: try: @@ -90,7 +86,6 @@ async def connect(self) -> bool: f"[Misskey WebSocket] 重新订阅 {channel_type} 失败: {e}" ) except Exception: - # never fail the connect flow for resubscribe problems pass return True @@ -134,18 +129,14 @@ async def unsubscribe_channel(self, channel_id: str): message = {"type": "disconnect", "body": {"id": channel_id}} await self.websocket.send(json.dumps(message)) channel_type = self.channels.get(channel_id) - # remove the channel mapping if channel_id in self.channels: del self.channels[channel_id] - # if no more subscriptions of this type exist, drop the desired channel entry if channel_type and channel_type not in self.channels.values(): self.desired_channels.pop(channel_type, None) def add_message_handler( self, event_type: str, handler: Callable[[Dict], Awaitable[None]] ): - # register both the raw event type and the channel-prefixed variant - # e.g. 'main:notification' and 'notification' -> both map to handler self.message_handlers[event_type] = handler async def listen(self): @@ -200,7 +191,6 @@ async def _handle_message(self, data: Dict[str, Any]): message_type = data.get("type") body = data.get("body", {}) - # concise summary for INFO, full payload is logged at DEBUG try: body = data.get("body") or {} channel_summary = None @@ -234,7 +224,6 @@ async def _handle_message(self, data: Dict[str, Any]): except Exception: logger.info(f"[Misskey WebSocket] 收到消息类型: {message_type}") - # full payload only in DEBUG logger.debug( f"[Misskey WebSocket] 收到完整消息: {json.dumps(data, indent=2, ensure_ascii=False)}" ) @@ -289,7 +278,6 @@ async def wrapper(*args, **kwargs): return await func(*args, **kwargs) except retryable_exceptions as e: last_exc = e - # exponential backoff with jitter backoff = min(2 ** (attempt - 1), 30) jitter = random.uniform(0, 1) await asyncio.sleep(backoff + jitter) @@ -414,7 +402,6 @@ async def create_note( visible_user_ids: Optional[List[str]] = None, file_ids: Optional[List[str]] = None, local_only: bool = False, - # Additional optional Misskey create fields cw: Optional[str] = None, poll: Optional[Dict[str, Any]] = None, renote_id: Optional[str] = None, @@ -428,7 +415,6 @@ async def create_note( """Create a note (wrapper for notes/create). All additional fields are optional and passed through to the API.""" data: Dict[str, Any] = {} - # only include text if provided (server allows null in some flows) if text is not None: data["text"] = text @@ -441,7 +427,6 @@ async def create_note( if visible_user_ids and visibility == "specified": data["visibleUserIds"] = visible_user_ids - # support both fileIds and mediaIds (server accepts either) if file_ids: data["fileIds"] = file_ids if media_ids: diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index 700e1b638..90833aaf6 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -277,8 +277,6 @@ def create_base_message( return message - return message - def process_at_mention( message: AstrBotMessage, raw_text: str, bot_username: str, client_self_id: str From f1d125c5f762200df9bce45b17ff460b7bfaec8b Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 9 Oct 2025 21:30:01 +0800 Subject: [PATCH 09/18] =?UTF-8?q?feat(misskey):=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=8F=91=E9=80=81=E9=80=BB=E8=BE=91=E5=92=8C?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构了 `misskey_event.py` 中的 `send` 方法,使用新的适配器方法 `send_by_session`,以改进消息处理(包括文件上传)。 - 添加了详细的日志记录,以提高消息发送过程的可追溯性。 - 在 `misskey_utils.py` 中引入了 `FileIDExtractor` 和 `MessagePayloadBuilder` 类,以简化文件 ID 提取和消息载荷构建。 - 在 `misskey_utils.py` 中实现了 MIME 类型检测和文件扩展名解析,以支持多种文件上传。 - 增强了 `resolve_component_url_or_path`,以更好地处理不同类型的组件上传文件。 - 在 `upload_local_with_retries` 中添加了重试逻辑,以优雅地处理不允许的文件类型。 --- astrbot/core/config/default.py | 25 + .../sources/misskey/misskey_adapter.py | 419 ++++++++-- .../platform/sources/misskey/misskey_api.py | 755 +++++++++++++++++- .../platform/sources/misskey/misskey_event.py | 117 ++- .../platform/sources/misskey/misskey_utils.py | 257 +++++- 5 files changed, 1432 insertions(+), 141 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index d9ad47b88..caea1b934 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -254,6 +254,11 @@ "misskey_default_visibility": "public", "misskey_local_only": False, "misskey_enable_chat": True, + # download / security options + "misskey_allow_insecure_downloads": False, + "misskey_download_timeout": 15, + "misskey_download_chunk_size": 65536, + "misskey_max_download_bytes": None, "misskey_enable_file_upload": True, "misskey_upload_concurrency": 3, "misskey_upload_folder": "", @@ -390,6 +395,26 @@ "type": "bool", "hint": "启用后,适配器会尝试将消息链中的文件上传到 Misskey 并在消息中附加 media(fileIds)。", }, + "misskey_allow_insecure_downloads": { + "description": "允许不安全下载(禁用 SSL 验证)", + "type": "bool", + "hint": "仅作为最后回退手段:当远端服务器存在证书问题导致无法正常下载时,允许临时禁用 SSL 验证以获取文件。启用有安全风险,请谨慎使用。", + }, + "misskey_download_timeout": { + "description": "远端下载超时时间(秒)", + "type": "int", + "hint": "用于计算 URL 文件 MD5 或回退下载时的总体超时时间(秒)。", + }, + "misskey_download_chunk_size": { + "description": "流式下载分块大小(字节)", + "type": "int", + "hint": "流式下载和计算 MD5 时使用的每次读取字节数,过小会增加开销,过大会占用内存。", + }, + "misskey_max_download_bytes": { + "description": "最大允许下载字节数(超出则中止)", + "type": "int", + "hint": "如果希望限制下载文件的最大大小以防止 OOM,请填写最大字节数;留空或 null 表示不限制。", + }, "misskey_upload_concurrency": { "description": "并发上传限制", "type": "int", diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 6e78d67fc..7f406d663 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -15,7 +15,6 @@ import astrbot.api.message_components as Comp from .misskey_api import MisskeyAPI, APIError -import mimetypes import os try: @@ -64,6 +63,29 @@ def __init__( self.enable_file_upload = self.config.get("misskey_enable_file_upload", True) self.upload_folder = self.config.get("misskey_upload_folder") + # download / security related options (exposed to platform_config) + self.allow_insecure_downloads = bool( + self.config.get("misskey_allow_insecure_downloads", False) + ) + # parse download timeout and chunk size safely + _dt = self.config.get("misskey_download_timeout") + try: + self.download_timeout = int(_dt) if _dt is not None else 15 + except Exception: + self.download_timeout = 15 + + _chunk = self.config.get("misskey_download_chunk_size") + try: + self.download_chunk_size = int(_chunk) if _chunk is not None else 64 * 1024 + except Exception: + self.download_chunk_size = 64 * 1024 + # parse max download bytes safely + _md_bytes = self.config.get("misskey_max_download_bytes") + try: + self.max_download_bytes = int(_md_bytes) if _md_bytes is not None else None + except Exception: + self.max_download_bytes = None + self.unique_session = platform_settings["unique_session"] self.api: Optional[MisskeyAPI] = None @@ -80,6 +102,11 @@ def meta(self) -> PlatformMetadata: "misskey_default_visibility": "public", "misskey_local_only": False, "misskey_enable_chat": True, + # download / security options + "misskey_allow_insecure_downloads": False, + "misskey_download_timeout": 15, + "misskey_download_chunk_size": 65536, + "misskey_max_download_bytes": None, } default_config.update(self.config) @@ -95,7 +122,14 @@ async def run(self): logger.error("[Misskey] 配置不完整,无法启动") return - self.api = MisskeyAPI(self.instance_url, self.access_token) + self.api = MisskeyAPI( + self.instance_url, + self.access_token, + allow_insecure_downloads=self.allow_insecure_downloads, + download_timeout=self.download_timeout, + chunk_size=self.download_chunk_size, + max_download_bytes=self.max_download_bytes, + ) self._running = True try: @@ -147,28 +181,10 @@ async def _send_text_only_message( return await super().send_by_session(session, message_chain) def _detect_mime_and_ext(self, path: str) -> Optional[str]: - """检测文件MIME类型并返回对应扩展名""" - try: - if magic: - m = magic.Magic(mime=True) - mime = m.from_file(path) - else: - mime, _ = mimetypes.guess_type(path) - except Exception: - mime = None - - if not mime: - return None - - mapping = { - "image/jpeg": ".jpg", - "image/jpg": ".jpg", - "image/png": ".png", - "image/gif": ".gif", - "text/plain": ".txt", - "application/pdf": ".pdf", - } - return mapping.get(mime, mimetypes.guess_extension(mime) or None) + """Delegates MIME detection to utils.detect_mime_ext.""" + from .misskey_utils import detect_mime_ext + + return detect_mime_ext(path) def _process_poll_data( self, message: AstrBotMessage, poll: Dict[str, Any], message_parts: List[str] @@ -276,7 +292,7 @@ async def _handle_notification(self, data: Dict[str, Any]): message_obj=message, platform_meta=self.meta(), session_id=message.session_id, - client=self.api, + client=self, ) self.commit_event(event) except Exception as e: @@ -312,7 +328,7 @@ async def _handle_chat_message(self, data: Dict[str, Any]): message_obj=message, platform_meta=self.meta(), session_id=message.session_id, - client=self.api, + client=self, ) self.commit_event(event) except Exception as e: @@ -351,15 +367,53 @@ async def send_by_session( try: session_id = session.session_id + + # 添加消息链组件调试日志 + logger.debug( + f"[Misskey] 收到消息链,包含 {len(message_chain.chain)} 个组件:" + ) + for i, comp in enumerate(message_chain.chain): + try: + comp_info = f"类型:{type(comp).__name__}" + if hasattr(comp, "text"): + comp_info += f" 文本:'{getattr(comp, 'text', '')[:50]}'" + for attr in ["file", "url", "path"]: + if hasattr(comp, attr): + val = getattr(comp, attr, None) + if val: + comp_info += f" {attr}:'{str(val)[:100]}'" + logger.debug(f"[Misskey] 组件 {i + 1}: {comp_info}") + except Exception as e: + logger.debug(f"[Misskey] 组件 {i + 1}: 无法获取信息 - {e}") + text, has_at_user = serialize_message_chain(message_chain.chain) + logger.debug( + f"[Misskey] serialize_message_chain 返回文本: '{text}', has_at_user: {has_at_user}" + ) if not has_at_user and session_id: user_info = self._user_cache.get(session_id) text = add_at_mention_if_needed(text, user_info, has_at_user) + # 检查是否有文件组件,即使文本为空或只有占位符也要处理 + has_file_components = any( + isinstance(comp, Comp.Image) + or isinstance(comp, Comp.File) + or hasattr(comp, "convert_to_file_path") + or hasattr(comp, "get_file") + or any( + hasattr(comp, a) for a in ("file", "url", "path", "src", "source") + ) + for comp in message_chain.chain + ) + logger.debug(f"[Misskey] 检测到文件组件: {has_file_components}") + if not text or not text.strip(): - logger.warning("[Misskey] 消息内容为空,跳过发送") - return await super().send_by_session(session, message_chain) + if not has_file_components: + logger.warning("[Misskey] 消息内容为空且无文件组件,跳过发送") + return await super().send_by_session(session, message_chain) + else: + text = "" # 清空占位符文本,只发送文件 if len(text) > self.max_message_length: text = text[: self.max_message_length] + "..." @@ -367,6 +421,25 @@ async def send_by_session( file_ids: List[str] = [] fallback_urls: List[str] = [] + # 添加详细的组件日志以调试插件返回的组件结构 + try: + logger.debug(f"[Misskey] 消息链包含 {len(message_chain.chain)} 个组件") + for i, comp in enumerate(message_chain.chain): + comp_type = type(comp).__name__ + comp_attrs = {} + for attr in ["file", "url", "path", "src", "source", "name"]: + try: + val = getattr(comp, attr, None) + if val is not None: + comp_attrs[attr] = str(val)[:100] # 截断长URL + except Exception: + pass + logger.debug( + f"[Misskey] 组件 {i}: {comp_type} - 属性: {comp_attrs}" + ) + except Exception as e: + logger.debug(f"[Misskey] 组件日志失败: {e}") + if not self.enable_file_upload: logger.debug("[Misskey] 文件上传已在配置中禁用,跳过上传流程") return await self._send_text_only_message( @@ -382,6 +455,8 @@ async def send_by_session( async def _upload_comp(comp) -> Optional[object]: upload_path = None + upload_path_local = None + url_candidate = None try: if hasattr(comp, "convert_to_file_path"): @@ -395,15 +470,135 @@ async def _upload_comp(comp) -> Optional[object]: except Exception: pass - if not upload_path: + # 不再在此处直接返回,改为先尝试从组件字段中推断 URL,再决定是否进入并发上传 + + # 先推断可能的 URL 或本地路径 + url_candidate = None + upload_path_local = None + + if ( + upload_path + and isinstance(upload_path, str) + and str(upload_path).startswith("http") + ): + url_candidate = upload_path + else: + upload_path_local = upload_path + + # 尝试通过注册到文件服务获取 URL(插件接口)或通过 get_file(True) 获取可直接访问的 URL + try: + if not url_candidate and hasattr( + comp, "register_to_file_service" + ): + try: + url_candidate = await comp.register_to_file_service() + except Exception: + url_candidate = None + if not url_candidate and hasattr(comp, "get_file"): + try: + maybe = await comp.get_file(True) + if ( + maybe + and isinstance(maybe, str) + and maybe.startswith("http") + ): + url_candidate = maybe + except Exception: + url_candidate = None + except Exception: + url_candidate = None + + # 如果上述都失败,检查常见的同步字段(file/url/path等) + if not url_candidate: + for attr in ("file", "url", "path", "src", "source"): + try: + val = getattr(comp, attr, None) + except Exception: + val = None + if val and isinstance(val, str) and val.startswith("http"): + url_candidate = val + break + + # 如果既没有 URL,也没有本地路径,则无可上传内容 + if not url_candidate and not upload_path_local: return None async with sem: if not self.api: return None + + # 优先尝试通过 URL 上传(使用新的查找方法) + if url_candidate: + try: + logger.debug( + f"[Misskey] 发现 URL candidate,尝试 upload-and-find: {url_candidate}" + ) + upload_result = await self.api.upload_and_find_file( + str(url_candidate), + getattr(comp, "name", None) + or getattr(comp, "file", None), + folder_id=self.upload_folder, + max_wait_time=30.0, # 最多等待30秒 + check_interval=2.0, # 每2秒检查一次 + ) + + if isinstance(upload_result, dict): + # 检查各种上传结果 + if upload_result.get("id"): + # 成功获得文件ID(同步、异步找到、或现有文件) + fid = upload_result.get("id") + result_type = ( + "existing" + if upload_result.get("existing") + else ( + "async_found" + if upload_result.get("async_found") + else "sync" + ) + ) + logger.debug( + f"[Misskey] upload-and-find 成功 ({result_type}),fid={fid}, URL={url_candidate}" + ) + return str(fid) + elif upload_result.get("status") == "timeout": + # 异步上传超时,回退到URL + logger.warning( + f"[Misskey] upload-and-find 超时,回退到URL: {url_candidate}" + ) + return {"fallback_url": url_candidate} + elif upload_result.get("fallback_url"): + # 其他情况需要回退 + logger.debug( + f"[Misskey] upload-and-find 回退到URL: {url_candidate}" + ) + return {"fallback_url": url_candidate} + + # 如果 upload_result 为 None,表示上传完全失败 + if upload_result is None: + logger.warning( + "[Misskey] upload-and-find 返回 None,尝试本地上传" + ) + if not upload_path_local: + return None + else: + # 未知的响应格式 + logger.warning( + f"[Misskey] upload-and-find 返回未知格式: {upload_result}" + ) + if not upload_path_local: + return None + + except Exception as e: + logger.debug( + f"[Misskey] upload-and-find 失败,回退为本地上传: {e}" + ) + + # 再尝试本地上传(如果有) + if not upload_path_local: + return None try: upload_result = await self.api.upload_file( - upload_path, + str(upload_path_local), getattr(comp, "name", None) or getattr(comp, "file", None), folder_id=self.upload_folder, @@ -427,9 +622,13 @@ async def _upload_comp(comp) -> Optional[object]: and "unallowed" in str(e).lower() ) ): - base_name = os.path.basename(upload_path) + if not upload_path_local: + raise + base_name = os.path.basename(upload_path_local) name_root, ext = os.path.splitext(base_name) - try_ext = self._detect_mime_and_ext(upload_path) + try_ext = self._detect_mime_and_ext( + upload_path_local + ) candidates = [] if try_ext: candidates.append(try_ext) @@ -443,7 +642,7 @@ async def _upload_comp(comp) -> Optional[object]: tried_names.append(try_name) try: upload_result = await self.api.upload_file( - upload_path, + str(upload_path_local), try_name, folder_id=self.upload_folder, ) @@ -476,7 +675,37 @@ async def _upload_comp(comp) -> Optional[object]: if url_or_path and str(url_or_path).startswith( "http" ): - return {"fallback_url": url_or_path} + # 优先尝试通过 Misskey 的 upload-and-find 接口上传远程 URL + try: + if self.api: + upload_result = await self.api.upload_and_find_file( + str(url_or_path), + getattr(comp, "name", None) + or getattr(comp, "file", None), + folder_id=self.upload_folder, + max_wait_time=30.0, + check_interval=2.0, + ) + if isinstance(upload_result, dict): + fid = upload_result.get("id") + if fid: + return str(fid) + elif ( + upload_result.get( + "fallback_url" + ) + or upload_result.get( + "status" + ) + == "timeout" + ): + # 回退到URL显示 + return { + "fallback_url": url_or_path + } + except Exception: + # 如果 upload-from-url 失败,则回退为返回 URL + return {"fallback_url": url_or_path} except Exception: pass except Exception: @@ -484,26 +713,65 @@ async def _upload_comp(comp) -> Optional[object]: return None finally: try: - if upload_path: + if upload_path_local: data_temp = os.path.join(get_astrbot_data_path(), "temp") - if upload_path.startswith(data_temp) and os.path.exists( - upload_path + if ( + isinstance(upload_path_local, str) + and upload_path_local.startswith(data_temp) + and os.path.exists(upload_path_local) ): try: - os.remove(upload_path) + os.remove(upload_path_local) logger.debug( - f"[Misskey] 已清理临时文件: {upload_path}" + f"[Misskey] 已清理临时文件: {upload_path_local}" ) except Exception: pass except Exception: pass - file_components = [ - comp - for comp in message_chain.chain - if hasattr(comp, "convert_to_file_path") or hasattr(comp, "get_file") - ] + # 收集所有可能包含文件/URL信息的组件:支持异步接口或同步字段 + file_components = [] + for comp in message_chain.chain: + try: + if ( + isinstance(comp, Comp.Image) + or isinstance(comp, Comp.File) + or hasattr(comp, "convert_to_file_path") + or hasattr(comp, "get_file") + or any( + hasattr(comp, a) + for a in ("file", "url", "path", "src", "source") + ) + ): + file_components.append(comp) + except Exception: + # 保守跳过无法访问属性的组件 + continue + + # 打印组件摘要,便于调试插件返回的结构 + try: + logger.debug( + f"[Misskey] 检测到 {len(file_components)} 个可能的文件组件:" + ) + for i, comp in enumerate(file_components): + try: + comp_type = type(comp).__name__ + comp_attrs = {} + for attr in ["file", "url", "path", "src", "source", "name"]: + try: + val = getattr(comp, attr, None) + if val is not None: + comp_attrs[attr] = val + except Exception: + pass + logger.debug( + f"[Misskey] 组件 {i + 1}: {comp_type} - {comp_attrs}" + ) + except Exception as e: + logger.debug(f"[Misskey] 组件 {i + 1}: 无法获取属性 - {e}") + except Exception: + pass if len(file_components) > MAX_FILE_UPLOAD_COUNT: logger.warning( f"[Misskey] 文件数量超过限制 ({len(file_components)} > {MAX_FILE_UPLOAD_COUNT}),只上传前{MAX_FILE_UPLOAD_COUNT}个文件" @@ -542,30 +810,51 @@ async def _upload_comp(comp) -> Optional[object]: if file_ids: payload["fileIds"] = file_ids await self.api.send_room_message(payload) - else: - visibility, visible_user_ids = resolve_message_visibility( - user_id=session_id, - user_cache=self._user_cache, - self_id=self.client_self_id, - default_visibility=self.default_visibility, + elif session_id: + from .misskey_utils import ( + extract_user_id_from_session_id, + is_valid_chat_session_id, ) - fields = self._extract_additional_fields(session, message_chain) - if fallback_urls: - appended = "\n" + "\n".join(fallback_urls) - text = (text or "") + appended + if is_valid_chat_session_id(session_id): + user_id = extract_user_id_from_session_id(session_id) + if fallback_urls: + appended = "\n" + "\n".join(fallback_urls) + text = (text or "") + appended + payload: Dict[str, Any] = {"toUserId": user_id, "text": text} + if file_ids: + # 聊天消息只支持单个文件,使用 fileId 而不是 fileIds + payload["fileId"] = file_ids[0] + if len(file_ids) > 1: + logger.warning( + f"[Misskey] 聊天消息只支持单个文件,忽略其余 {len(file_ids) - 1} 个文件" + ) + await self.api.send_message(payload) + else: + # 回退到发帖逻辑 + visibility, visible_user_ids = resolve_message_visibility( + user_id=session_id, + user_cache=self._user_cache, + self_id=self.client_self_id, + default_visibility=self.default_visibility, + ) - await self.api.create_note( - text=text, - visibility=visibility, - visible_user_ids=visible_user_ids, - file_ids=file_ids if file_ids else None, - local_only=self.local_only, - cw=fields["cw"], - poll=fields["poll"], - renote_id=fields["renote_id"], - channel_id=fields["channel_id"], - ) + fields = self._extract_additional_fields(session, message_chain) + if fallback_urls: + appended = "\n" + "\n".join(fallback_urls) + text = (text or "") + appended + + await self.api.create_note( + text=text, + visibility=visibility, + visible_user_ids=visible_user_ids, + file_ids=file_ids if file_ids else None, + local_only=self.local_only, + cw=fields["cw"], + poll=fields["poll"], + renote_id=fields["renote_id"], + channel_id=fields["channel_id"], + ) except Exception as e: logger.error(f"[Misskey] 发送消息失败: {e}") diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 493615b34..4652fcfca 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -1,6 +1,9 @@ import json import random import asyncio +import hashlib +import base64 +import re from typing import Any, Optional, Dict, List, Callable, Awaitable import uuid @@ -13,6 +16,7 @@ ) from e from astrbot.api import logger +from .misskey_utils import FileIDExtractor # Constants API_MAX_RETRIES = 3 @@ -269,19 +273,60 @@ async def _handle_message(self, data: Dict[str, Any]): await self.message_handlers["_debug"](data) -def retry_async(max_retries: int = 3, retryable_exceptions: tuple = (())): +def retry_async( + max_retries: int = 3, + retryable_exceptions: tuple = (APIConnectionError, APIRateLimitError), + backoff_base: float = 1.0, + max_backoff: float = 30.0, +): + """ + 智能异步重试装饰器 + + Args: + max_retries: 最大重试次数 + retryable_exceptions: 可重试的异常类型 + backoff_base: 退避基数 + max_backoff: 最大退避时间 + """ + def decorator(func): async def wrapper(*args, **kwargs): last_exc = None + func_name = getattr(func, "__name__", "unknown") + for attempt in range(1, max_retries + 1): try: return await func(*args, **kwargs) except retryable_exceptions as e: last_exc = e - backoff = min(2 ** (attempt - 1), 30) - jitter = random.uniform(0, 1) - await asyncio.sleep(backoff + jitter) + if attempt == max_retries: + logger.error( + f"[Misskey API] {func_name} 重试 {max_retries} 次后仍失败: {e}" + ) + break + + # 智能退避策略 + if isinstance(e, APIRateLimitError): + # 频率限制用更长的退避时间 + backoff = min(backoff_base * (3**attempt), max_backoff) + else: + # 其他错误用指数退避 + backoff = min(backoff_base * (2**attempt), max_backoff) + + jitter = random.uniform(0.1, 0.5) # 随机抖动 + sleep_time = backoff + jitter + + logger.warning( + f"[Misskey API] {func_name} 第 {attempt} 次重试失败: {e}," + f"{sleep_time:.1f}s后重试" + ) + await asyncio.sleep(sleep_time) continue + except Exception as e: + # 非可重试异常直接抛出 + logger.error(f"[Misskey API] {func_name} 遇到不可重试异常: {e}") + raise + if last_exc: raise last_exc @@ -291,11 +336,27 @@ async def wrapper(*args, **kwargs): class MisskeyAPI: - def __init__(self, instance_url: str, access_token: str): + def __init__( + self, + instance_url: str, + access_token: str, + *, + allow_insecure_downloads: bool = False, + download_timeout: int = 15, + chunk_size: int = 64 * 1024, + max_download_bytes: Optional[int] = None, + ): self.instance_url = instance_url.rstrip("/") self.access_token = access_token self._session: Optional[aiohttp.ClientSession] = None self.streaming: Optional[StreamingClient] = None + # download options + self.allow_insecure_downloads = bool(allow_insecure_downloads) + self.download_timeout = int(download_timeout) + self.chunk_size = int(chunk_size) + self.max_download_bytes = ( + int(max_download_bytes) if max_download_bytes is not None else None + ) async def __aenter__(self): return self @@ -328,16 +389,37 @@ def session(self) -> aiohttp.ClientSession: def _handle_response_status(self, status: int, endpoint: str): """处理 HTTP 响应状态码""" if status == 400: - logger.error(f"API 请求错误: {endpoint} (状态码: {status})") + logger.error(f"[Misskey API] 请求参数错误: {endpoint} (HTTP {status})") raise APIError(f"Bad request for {endpoint}") - elif status in (401, 403): - logger.error(f"API 认证失败: {endpoint} (状态码: {status})") - raise AuthenticationError(f"Authentication failed for {endpoint}") + elif status == 401: + logger.error(f"[Misskey API] 未授权访问: {endpoint} (HTTP {status})") + raise AuthenticationError(f"Unauthorized access for {endpoint}") + elif status == 403: + logger.error(f"[Misskey API] 访问被禁止: {endpoint} (HTTP {status})") + raise AuthenticationError(f"Forbidden access for {endpoint}") + elif status == 404: + logger.error(f"[Misskey API] 资源不存在: {endpoint} (HTTP {status})") + raise APIError(f"Resource not found for {endpoint}") + elif status == 413: + logger.error(f"[Misskey API] 请求体过大: {endpoint} (HTTP {status})") + raise APIError(f"Request entity too large for {endpoint}") elif status == 429: - logger.warning(f"API 频率限制: {endpoint} (状态码: {status})") + logger.warning(f"[Misskey API] 请求频率限制: {endpoint} (HTTP {status})") raise APIRateLimitError(f"Rate limit exceeded for {endpoint}") + elif status == 500: + logger.error(f"[Misskey API] 服务器内部错误: {endpoint} (HTTP {status})") + raise APIConnectionError(f"Internal server error for {endpoint}") + elif status == 502: + logger.error(f"[Misskey API] 网关错误: {endpoint} (HTTP {status})") + raise APIConnectionError(f"Bad gateway for {endpoint}") + elif status == 503: + logger.error(f"[Misskey API] 服务不可用: {endpoint} (HTTP {status})") + raise APIConnectionError(f"Service unavailable for {endpoint}") + elif status == 504: + logger.error(f"[Misskey API] 网关超时: {endpoint} (HTTP {status})") + raise APIConnectionError(f"Gateway timeout for {endpoint}") else: - logger.error(f"API 请求失败: {endpoint} (状态码: {status})") + logger.error(f"[Misskey API] 未知错误: {endpoint} (HTTP {status})") raise APIConnectionError(f"HTTP {status} for {endpoint}") async def _process_response( @@ -356,21 +438,28 @@ async def _process_response( else [] ) if notifications_data: - logger.debug(f"获取到 {len(notifications_data)} 条新通知") + logger.debug( + f"[Misskey API] 获取到 {len(notifications_data)} 条新通知" + ) else: - logger.debug(f"API 请求成功: {endpoint}") + logger.debug(f"[Misskey API] 请求成功: {endpoint}") return result except json.JSONDecodeError as e: - logger.error(f"响应不是有效的 JSON 格式: {e}") + logger.error(f"[Misskey API] 响应格式错误: {e}") raise APIConnectionError("Invalid JSON response") from e + elif response.status == 204 and endpoint == "drive/files/upload-from-url": + logger.debug(f"[Misskey API] 异步上传请求已接受: {endpoint}") + return {"status": "accepted", "async": True} else: try: error_text = await response.text() logger.error( - f"API 请求失败: {endpoint} - 状态码: {response.status}, 响应: {error_text}" + f"[Misskey API] 请求失败: {endpoint} - HTTP {response.status}, 响应: {error_text}" ) except Exception: - logger.error(f"API 请求失败: {endpoint} - 状态码: {response.status}") + logger.error( + f"[Misskey API] 请求失败: {endpoint} - HTTP {response.status}" + ) self._handle_response_status(response.status, endpoint) raise APIConnectionError(f"Request failed for {endpoint}") @@ -391,7 +480,7 @@ async def _make_request( async with self.session.post(url, json=payload) as response: return await self._process_response(response, endpoint) except aiohttp.ClientError as e: - logger.error(f"HTTP 请求错误: {e}") + logger.error(f"[Misskey API] HTTP 请求错误: {e}") raise APIConnectionError(f"HTTP request failed: {e}") from e async def create_note( @@ -455,7 +544,7 @@ async def create_note( if isinstance(result, dict) else "unknown" ) - logger.debug(f"发帖成功,note_id: {note_id}") + logger.debug(f"[Misskey API] 发帖成功: {note_id}") return result async def upload_file( @@ -480,22 +569,440 @@ async def upload_file( form.add_field("folderId", str(folder_id)) async with self.session.post(url, data=form) as resp: result = await self._process_response(resp, "drive/files/create") - logger.debug(f"上传文件到 Misskey 成功: {filename}") - fid = None - if isinstance(result, dict): - fid = ( - (result.get("createdFile") or {}).get("id") - or result.get("id") - or (result.get("file") or {}).get("id") - ) - return {"id": fid, "raw": result} + file_id = FileIDExtractor.extract_file_id(result) + logger.debug( + f"[Misskey API] 本地文件上传成功: {filename} -> {file_id}" + ) + return {"id": file_id, "raw": result} except FileNotFoundError as e: - logger.error(f"上传文件失败,本地文件未找到: {file_path}") + logger.error(f"[Misskey API] 本地文件不存在: {file_path}") raise APIError(f"File not found: {file_path}") from e except aiohttp.ClientError as e: - logger.error(f"上传文件 HTTP 错误: {e}") + logger.error(f"[Misskey API] 文件上传网络错误: {e}") raise APIConnectionError(f"Upload failed: {e}") from e + async def upload_file_from_url( + self, url: str, name: Optional[str] = None, folder_id: Optional[str] = None + ) -> Dict[str, Any]: + """Upload a file to Misskey using a remote URL (drive/files/upload-from-url). + + Returns a dict containing id and raw result on success. + """ + if not url: + raise APIError("No URL provided for upload-from-url") + + data: Dict[str, Any] = {"url": url} + if name: + data["name"] = name + if folder_id: + data["folderId"] = str(folder_id) + + try: + logger.debug( + f"[Misskey API] upload-from-url 请求: url={url}, name={name}, folder_id={folder_id}" + ) + result = await self._make_request("drive/files/upload-from-url", data) + logger.debug(f"[Misskey API] upload-from-url 响应: {result}") + + # 检查是否是异步上传响应 (HTTP 204) + if ( + isinstance(result, dict) + and result.get("status") == "accepted" + and result.get("async") + ): + logger.debug( + "[Misskey API] upload-from-url 异步请求已接受,文件将在后台上传" + ) + return {"status": "accepted", "async": True, "url": url} + + # 同步上传响应,提取文件ID + fid = None + if isinstance(result, dict): + fid = ( + (result.get("createdFile") or {}).get("id") + or result.get("id") + or (result.get("file") or {}).get("id") + ) + logger.debug(f"[Misskey API] upload-from-url 得到 fid: {fid}") + return {"id": fid, "raw": result} + except Exception as e: + logger.error(f"上传 URL 文件失败: {e}") + raise + + async def find_files_by_hash(self, md5_hash: str) -> List[Dict[str, Any]]: + """Find files by MD5 hash""" + if not md5_hash: + raise APIError("No MD5 hash provided for find-by-hash") + + data = {"md5": md5_hash} + + try: + logger.debug(f"[Misskey API] find-by-hash 请求: md5={md5_hash}") + result = await self._make_request("drive/files/find-by-hash", data) + logger.debug( + f"[Misskey API] find-by-hash 响应: 找到 {len(result) if isinstance(result, list) else 0} 个文件" + ) + return result if isinstance(result, list) else [] + except Exception as e: + logger.error(f"[Misskey API] 根据哈希查找文件失败: {e}") + raise + + async def find_files_by_name( + self, name: str, folder_id: Optional[str] = None + ) -> List[Dict[str, Any]]: + """Find files by name""" + if not name: + raise APIError("No name provided for find") + + data: Dict[str, Any] = {"name": name} + if folder_id: + data["folderId"] = folder_id + + try: + logger.debug(f"[Misskey API] find 请求: name={name}, folder_id={folder_id}") + result = await self._make_request("drive/files/find", data) + logger.debug( + f"[Misskey API] find 响应: 找到 {len(result) if isinstance(result, list) else 0} 个文件" + ) + return result if isinstance(result, list) else [] + except Exception as e: + logger.error(f"[Misskey API] 根据名称查找文件失败: {e}") + raise + + async def find_files( + self, + limit: int = 10, + folder_id: Optional[str] = None, + type: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """List files with optional filters""" + data: Dict[str, Any] = {"limit": limit} + if folder_id is not None: + data["folderId"] = folder_id + if type is not None: + data["type"] = type + + try: + logger.debug( + f"[Misskey API] 列表文件请求: limit={limit}, folder_id={folder_id}, type={type}" + ) + result = await self._make_request("drive/files", data) + logger.debug( + f"[Misskey API] 列表文件响应: 找到 {len(result) if isinstance(result, list) else 0} 个文件" + ) + return result if isinstance(result, list) else [] + except Exception as e: + logger.error(f"[Misskey API] 列表文件失败: {e}") + raise + + async def check_file_existence(self, md5_hash: str) -> bool: + """Check if a file exists by MD5 hash""" + if not md5_hash: + raise APIError("No MD5 hash provided for check-existence") + + data = {"md5": md5_hash} + + try: + logger.debug(f"[Misskey API] check-existence 请求: md5={md5_hash}") + result = await self._make_request("drive/files/check-existence", data) + exists = bool(result) if result is not None else False + logger.debug(f"[Misskey API] check-existence 响应: 存在={exists}") + return exists + except Exception as e: + logger.error(f"[Misskey API] 检查文件存在性失败: {e}") + raise + + async def calculate_url_md5(self, url: str) -> Optional[str]: + """Calculate MD5 hash of file from URL with fallback strategies""" + if not url: + return None + # 1) 尝试 HEAD 查找 Content-MD5 / ETag(轻量) + try: + async with self.session.head( + url, timeout=aiohttp.ClientTimeout(total=self.download_timeout) + ) as head_resp: + if head_resp.status == 200: + # Content-MD5 header 通常是 base64(md5) + content_md5 = head_resp.headers.get("Content-MD5") + if content_md5: + try: + raw = base64.b64decode(content_md5) + hex_md5 = raw.hex() + logger.debug( + f"[Misskey API] 从 HEAD Content-MD5 获取 md5: {hex_md5}" + ) + return hex_md5 + except Exception: + logger.debug( + "[Misskey API] 无法解析 Content-MD5 header,继续流式下载" + ) + + # 尝试 ETag(有些服务直接返回十六进制 MD5) + etag = head_resp.headers.get("ETag") + if etag: + etag_val = etag.strip('"') + if re.fullmatch(r"[0-9a-fA-F]{32}", etag_val): + logger.debug( + f"[Misskey API] 从 HEAD ETag 获取 md5: {etag_val}" + ) + return etag_val.lower() + except Exception: + logger.debug("[Misskey API] HEAD 请求失败,继续使用流式 GET") + + # 2) 流式 GET(使用现有 session) + try: + md5 = await self._stream_md5_with_session(url, ssl_verify=True) + if md5: + logger.debug(f"[Misskey API] 流式 MD5 计算成功 (ssl): {md5}") + return md5 + except Exception as e: + logger.debug(f"[Misskey API] 流式下载(ssl)失败: {e}") + + # 3) 可选:不安全下载(受配置控制,仅做最后退路) + if self.allow_insecure_downloads: + try: + md5 = await self._stream_md5_with_session(url, ssl_verify=False) + if md5: + logger.warning( + f"[Misskey API] 使用不安全下载获取 MD5(ssl 验证已禁用): {url}" + ) + return md5 + except Exception as e: + logger.debug(f"[Misskey API] 不安全流式下载失败: {e}") + + logger.warning(f"[Misskey API] 无法计算 MD5: {url}") + return None + + async def _stream_md5_with_session( + self, url: str, ssl_verify: bool = True + ) -> Optional[str]: + """使用 session 流式读取并计算 MD5,支持下载大小限制""" + total = 0 + m = hashlib.md5() + async with self.session.get( + url, + timeout=aiohttp.ClientTimeout(total=self.download_timeout), + ssl=ssl_verify, + ) as resp: + if resp.status != 200: + return None + async for chunk in resp.content.iter_chunked(self.chunk_size): + if not chunk: + continue + m.update(chunk) + total += len(chunk) + if self.max_download_bytes and total > self.max_download_bytes: + raise APIError("下载文件超出允许的最大字节数") + return m.hexdigest() + + async def _download_with_existing_session( + self, url: str, ssl_verify: bool = True + ) -> Optional[bytes]: + """使用现有会话下载文件""" + if not (hasattr(self, "session") and self.session): + raise Exception("No existing session available") + + async with self.session.get( + url, timeout=aiohttp.ClientTimeout(total=15), ssl=ssl_verify + ) as response: + if response.status == 200: + return await response.read() + return None + + async def _download_with_temp_session( + self, url: str, ssl_verify: bool = True + ) -> Optional[bytes]: + """使用临时会话下载文件""" + connector = aiohttp.TCPConnector(ssl=ssl_verify) + async with aiohttp.ClientSession(connector=connector) as temp_session: + async with temp_session.get( + url, timeout=aiohttp.ClientTimeout(total=15) + ) as response: + if response.status == 200: + return await response.read() + return None + + async def upload_and_find_file( + self, + url: str, + name: Optional[str] = None, + folder_id: Optional[str] = None, + max_wait_time: float = 30.0, + check_interval: float = 2.0, + ) -> Optional[Dict[str, Any]]: + """ + 智能文件上传:先检查重复,再上传,最后轮询查找 + + Args: + url: 文件URL + name: 文件名(可选) + folder_id: 文件夹ID(可选) + max_wait_time: 最大等待时间 + check_interval: 轮询间隔 + + Returns: + 包含文件ID和元信息的字典,失败时返回None + """ + if not url: + raise APIError("URL不能为空") + + # 优先按文件名在已有文件中查找(避免重复上传) + try: + filename = name or url.split("/")[-1].split("?")[0] + if filename: + matches = await self.find_files_by_name(filename, folder_id) + if matches: + file_id = matches[0].get("id") + logger.debug(f"[Misskey API] 通过名称找到已存在文件: {file_id}") + return {"id": file_id, "raw": matches[0], "name_match": True} + except Exception: + # 名称查找失败时继续尝试 upload-from-url + logger.debug("[Misskey API] 名称查找失败或异常,继续处理") + + # 尝试使用 Misskey 的 upload-from-url 接口(服务器端处理远程 URL) + try: + upload_result = await self.upload_file_from_url(url, name, folder_id) + # 处理 upload-from-url 的返回(可能为 accepted 或直接返回文件) + md5_hash = await self.calculate_url_md5(url) + return await self._handle_upload_result( + upload_result, md5_hash, url, max_wait_time, check_interval + ) + except Exception as e: + logger.warning( + f"[Misskey API] upload-from-url 失败,准备回退到下载并本地上传: {e}" + ) + + # 回退:下载远端文件并做本地上传 + try: + # 使用现有 session 下载内容到临时文件 + tmp_bytes = await self._download_with_existing_session(url) + if not tmp_bytes: + tmp_bytes = await self._download_with_temp_session(url) + + if tmp_bytes: + # 写入临时文件并上传本地文件 + import tempfile + + with tempfile.NamedTemporaryFile(delete=False) as tmpf: + tmpf.write(tmp_bytes) + tmp_path = tmpf.name + + try: + result = await self.upload_file(tmp_path, name, folder_id) + return result + finally: + import os + + try: + os.unlink(tmp_path) + except Exception: + pass + except Exception as e: + logger.error(f"[Misskey API] 下载并本地上传回退失败: {e}") + + return None + + async def _check_existing_file(self, md5_hash: str) -> Optional[Dict[str, Any]]: + """检查文件是否已存在""" + try: + existing_files = await self.find_files_by_hash(md5_hash) + if existing_files: + file_id = existing_files[0].get("id") + logger.debug(f"[Misskey API] 发现已存在文件: {file_id}") + return { + "id": file_id, + "raw": existing_files[0], + "existing": True, + } + except Exception as e: + logger.debug(f"[Misskey API] 检查已存在文件失败: {e}") + return None + + async def _handle_upload_result( + self, + upload_result: Any, + md5_hash: Optional[str], + url: str, + max_wait_time: float, + check_interval: float, + ) -> Optional[Dict[str, Any]]: + """处理上传结果""" + # 同步上传成功 + if isinstance(upload_result, dict) and upload_result.get("id"): + return upload_result + + # 异步上传 + if ( + isinstance(upload_result, dict) + and upload_result.get("status") == "accepted" + ): + if md5_hash: + return await self._poll_by_hash( + md5_hash, max_wait_time, check_interval, url + ) + else: + return await self._poll_by_name(url, max_wait_time) + + logger.error(f"[Misskey API] 文件上传失败: {url}") + return None + + async def _poll_by_hash( + self, md5_hash: str, max_wait_time: float, check_interval: float, url: str + ) -> Optional[Dict[str, Any]]: + """通过MD5哈希轮询查找文件""" + logger.debug(f"[Misskey API] 开始轮询查找文件: {md5_hash}") + + waited_time = 0.0 + while waited_time < max_wait_time: + try: + files = await self.find_files_by_hash(md5_hash) + if files: + file_id = files[0].get("id") + if file_id: + logger.debug(f"[Misskey API] 异步上传完成: {file_id}") + return {"id": file_id, "raw": files[0], "async_found": True} + except Exception as e: + logger.debug(f"[Misskey API] 轮询查找出错: {e}") + + await asyncio.sleep(check_interval) + waited_time += check_interval + + # MD5轮询超时,尝试名称匹配 + return await self._fallback_name_search(url) + + async def _poll_by_name( + self, url: str, max_wait_time: float + ) -> Optional[Dict[str, Any]]: + """通过文件名轮询查找""" + logger.debug("[Misskey API] 无MD5哈希,等待后按名称查找") + + # 等待异步上传完成 + await asyncio.sleep(min(3.0, max_wait_time / 2)) + return await self._fallback_name_search(url) + + async def _fallback_name_search(self, url: str) -> Optional[Dict[str, Any]]: + """回退到名称匹配搜索""" + try: + recent_files = await self.find_files(limit=20) + filename = url.split("/")[-1].split("?")[0] + + # 精确匹配 + for file in recent_files: + if file.get("name") == filename: + logger.debug(f"[Misskey API] 精确名称匹配: {file.get('id')}") + return {"id": file.get("id"), "raw": file, "name_match": True} + + # 模糊匹配 + for file in recent_files: + if file.get("name") and filename in file["name"]: + logger.debug(f"[Misskey API] 模糊名称匹配: {file.get('id')}") + return {"id": file.get("id"), "raw": file, "name_match": True} + + except Exception as e: + logger.error(f"[Misskey API] 名称搜索失败: {e}") + + return None + async def get_current_user(self) -> Dict[str, Any]: """获取当前用户信息""" return await self._make_request("i", {}) @@ -514,7 +1021,7 @@ async def send_message( result = await self._make_request("chat/messages/create-to-user", data) message_id = result.get("id", "unknown") - logger.debug(f"聊天发送成功,message_id: {message_id}") + logger.debug(f"[Misskey API] 聊天消息发送成功: {message_id}") return result async def send_room_message( @@ -531,7 +1038,7 @@ async def send_room_message( result = await self._make_request("chat/messages/create-to-room", data) message_id = result.get("id", "unknown") - logger.debug(f"房间消息发送成功,message_id: {message_id}") + logger.debug(f"[Misskey API] 房间消息发送成功: {message_id}") return result async def get_messages( @@ -546,7 +1053,7 @@ async def get_messages( if isinstance(result, list): return result else: - logger.warning(f"获取聊天消息响应格式异常: {type(result)}") + logger.warning(f"[Misskey API] 聊天消息响应格式异常: {type(result)}") return [] async def get_mentions( @@ -564,5 +1071,187 @@ async def get_mentions( elif isinstance(result, dict) and "notifications" in result: return result["notifications"] else: - logger.warning(f"获取提及通知响应格式异常: {type(result)}") + logger.warning(f"[Misskey API] 提及通知响应格式异常: {type(result)}") return [] + + async def send_message_with_media( + self, + message_type: str, + target_id: str, + text: Optional[str] = None, + media_urls: Optional[List[str]] = None, + local_files: Optional[List[str]] = None, + **kwargs, + ) -> Dict[str, Any]: + """ + 通用消息发送函数:统一处理文本+媒体发送 + + Args: + message_type: 消息类型 ('chat', 'room', 'note') + target_id: 目标ID (用户ID/房间ID/频道ID等) + text: 文本内容 + media_urls: 媒体文件URL列表 + local_files: 本地文件路径列表 + **kwargs: 其他参数(如visibility等) + + Returns: + 发送结果字典 + + Raises: + APIError: 参数错误或发送失败 + """ + if not text and not media_urls and not local_files: + raise APIError("消息内容不能为空:需要文本或媒体文件") + + file_ids = [] + + # 处理远程媒体文件 + if media_urls: + file_ids.extend(await self._process_media_urls(media_urls)) + + # 处理本地文件 + if local_files: + file_ids.extend(await self._process_local_files(local_files)) + + # 根据消息类型发送 + return await self._dispatch_message( + message_type, target_id, text, file_ids, **kwargs + ) + + async def _process_media_urls(self, urls: List[str]) -> List[str]: + """处理远程媒体文件URL列表,返回文件ID列表""" + file_ids = [] + for url in urls: + try: + result = await self.upload_and_find_file(url) + if result and result.get("id"): + file_ids.append(result["id"]) + logger.debug(f"[Misskey API] URL媒体上传成功: {result['id']}") + else: + logger.error(f"[Misskey API] URL媒体上传失败: {url}") + except Exception as e: + logger.error(f"[Misskey API] URL媒体处理失败 {url}: {e}") + # 继续处理其他文件,不中断整个流程 + continue + return file_ids + + async def _process_local_files(self, file_paths: List[str]) -> List[str]: + """处理本地文件路径列表,返回文件ID列表""" + file_ids = [] + for file_path in file_paths: + try: + result = await self.upload_file(file_path) + if result and result.get("id"): + file_ids.append(result["id"]) + logger.debug(f"[Misskey API] 本地文件上传成功: {result['id']}") + else: + logger.error(f"[Misskey API] 本地文件上传失败: {file_path}") + except Exception as e: + logger.error(f"[Misskey API] 本地文件处理失败 {file_path}: {e}") + continue + return file_ids + + async def _dispatch_message( + self, + message_type: str, + target_id: str, + text: Optional[str], + file_ids: List[str], + **kwargs, + ) -> Dict[str, Any]: + """根据消息类型分发到对应的发送方法""" + if message_type == "chat": + # 聊天消息使用 fileId (单数) + payload = {"toUserId": target_id} + if text: + payload["text"] = text + if file_ids: + if len(file_ids) == 1: + payload["fileId"] = file_ids[0] + else: + # 多文件时逐个发送 + results = [] + for file_id in file_ids: + single_payload = payload.copy() + single_payload["fileId"] = file_id + result = await self.send_message(single_payload) + results.append(result) + return {"multiple": True, "results": results} + return await self.send_message(payload) + + elif message_type == "room": + # 房间消息使用 fileId (单数) + payload = {"toRoomId": target_id} + if text: + payload["text"] = text + if file_ids: + if len(file_ids) == 1: + payload["fileId"] = file_ids[0] + else: + # 多文件时逐个发送 + results = [] + for file_id in file_ids: + single_payload = payload.copy() + single_payload["fileId"] = file_id + result = await self.send_room_message(single_payload) + results.append(result) + return {"multiple": True, "results": results} + return await self.send_room_message(payload) + + elif message_type == "note": + # 发帖使用 fileIds (复数) + note_kwargs = { + "text": text, + "file_ids": file_ids if file_ids else None, + } + # 合并其他参数 + note_kwargs.update(kwargs) + return await self.create_note(**note_kwargs) + + else: + raise APIError(f"不支持的消息类型: {message_type}") + + async def upload_and_find_file_with_fallback( + self, + url: str, + local_backup_path: Optional[str] = None, + name: Optional[str] = None, + folder_id: Optional[str] = None, + max_wait_time: float = 30.0, + check_interval: float = 2.0, + ) -> Optional[Dict[str, Any]]: + """ + 增强版文件上传,支持本地文件回退 + + Args: + url: 远程文件URL + local_backup_path: 本地备份文件路径(URL失败时使用) + name: 文件名 + folder_id: 文件夹ID + max_wait_time: 最大等待时间 + check_interval: 轮询间隔 + + Returns: + 上传结果或None + """ + # 首先尝试URL上传 + try: + result = await self.upload_and_find_file( + url, name, folder_id, max_wait_time, check_interval + ) + if result: + return result + except Exception as e: + logger.warning(f"[Misskey API] URL上传失败,尝试本地回退: {e}") + + # URL上传失败,尝试本地文件回退 + if local_backup_path: + try: + result = await self.upload_file(local_backup_path, name, folder_id) + if result and result.get("id"): + logger.info(f"[Misskey API] 本地文件回退上传成功: {result['id']}") + return result + except Exception as e: + logger.error(f"[Misskey API] 本地文件回退也失败: {e}") + + return None diff --git a/astrbot/core/platform/sources/misskey/misskey_event.py b/astrbot/core/platform/sources/misskey/misskey_event.py index 391d10b52..cd737f78e 100644 --- a/astrbot/core/platform/sources/misskey/misskey_event.py +++ b/astrbot/core/platform/sources/misskey/misskey_event.py @@ -40,48 +40,83 @@ def _is_system_command(self, message_str: str) -> bool: return any(message_trimmed.startswith(prefix) for prefix in system_prefixes) async def send(self, message: MessageChain): - content, has_at = serialize_message_chain(message.chain) - - if not content: - logger.debug("[MisskeyEvent] 内容为空,跳过发送") - return - + """发送消息,使用适配器的完整上传和发送逻辑""" try: - original_message_id = getattr(self.message_obj, "message_id", None) - raw_message = getattr(self.message_obj, "raw_message", {}) - - if raw_message and not has_at: - user_data = raw_message.get("user", {}) - user_info = { - "username": user_data.get("username", ""), - "nickname": user_data.get("name", user_data.get("username", "")), - } - content = add_at_mention_if_needed(content, user_info, has_at) - - # 根据会话类型选择发送方式 - if hasattr(self.client, "send_message") and is_valid_user_session_id( - self.session_id - ): - user_id = extract_user_id_from_session_id(self.session_id) - await self.client.send_message(user_id, content) - elif hasattr(self.client, "send_room_message") and is_valid_room_session_id( - self.session_id - ): - room_id = extract_room_id_from_session_id(self.session_id) - await self.client.send_room_message(room_id, content) - elif original_message_id and hasattr(self.client, "create_note"): - visibility, visible_user_ids = resolve_visibility_from_raw_message( - raw_message - ) - await self.client.create_note( - content, - reply_id=original_message_id, - visibility=visibility, - visible_user_ids=visible_user_ids, - ) - elif hasattr(self.client, "create_note"): - logger.debug("[MisskeyEvent] 创建新帖子") - await self.client.create_note(content) + logger.debug( + f"[MisskeyEvent] send 方法被调用,消息链包含 {len(message.chain)} 个组件" + ) + + # 使用适配器的 send_by_session 方法,它包含文件上传逻辑 + from astrbot.core.platform.message_session import MessageSession + from astrbot.core.platform.message_type import MessageType + + # 根据session_id类型确定消息类型 + if is_valid_user_session_id(self.session_id): + message_type = MessageType.FRIEND_MESSAGE + elif is_valid_room_session_id(self.session_id): + message_type = MessageType.GROUP_MESSAGE + else: + message_type = MessageType.FRIEND_MESSAGE # 默认 + + session = MessageSession( + platform_name=self.platform_meta.name, + message_type=message_type, + session_id=self.session_id, + ) + + logger.debug( + f"[MisskeyEvent] 检查适配器方法: hasattr(self.client, 'send_by_session') = {hasattr(self.client, 'send_by_session')}" + ) + + # 调用适配器的 send_by_session 方法 + if hasattr(self.client, "send_by_session"): + logger.debug("[MisskeyEvent] 调用适配器的 send_by_session 方法") + await self.client.send_by_session(session, message) + else: + # 回退到原来的简化发送逻辑 + content, has_at = serialize_message_chain(message.chain) + + if not content: + logger.debug("[MisskeyEvent] 内容为空,跳过发送") + return + + original_message_id = getattr(self.message_obj, "message_id", None) + raw_message = getattr(self.message_obj, "raw_message", {}) + + if raw_message and not has_at: + user_data = raw_message.get("user", {}) + user_info = { + "username": user_data.get("username", ""), + "nickname": user_data.get( + "name", user_data.get("username", "") + ), + } + content = add_at_mention_if_needed(content, user_info, has_at) + + # 根据会话类型选择发送方式 + if hasattr(self.client, "send_message") and is_valid_user_session_id( + self.session_id + ): + user_id = extract_user_id_from_session_id(self.session_id) + await self.client.send_message(user_id, content) + elif hasattr( + self.client, "send_room_message" + ) and is_valid_room_session_id(self.session_id): + room_id = extract_room_id_from_session_id(self.session_id) + await self.client.send_room_message(room_id, content) + elif original_message_id and hasattr(self.client, "create_note"): + visibility, visible_user_ids = resolve_visibility_from_raw_message( + raw_message + ) + await self.client.create_note( + content, + reply_id=original_message_id, + visibility=visibility, + visible_user_ids=visible_user_ids, + ) + elif hasattr(self.client, "create_note"): + logger.debug("[MisskeyEvent] 创建新帖子") + await self.client.create_note(content) await super().send(message) diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index 90833aaf6..4e7ec4044 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -1,10 +1,75 @@ """Misskey 平台适配器通用工具函数""" +import mimetypes +import os from typing import Dict, Any, List, Tuple, Optional, Union import astrbot.api.message_components as Comp from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType +class FileIDExtractor: + """从 API 响应中提取文件 ID 的帮助类(无状态)。""" + + @staticmethod + def extract_file_id(result: Any) -> Optional[str]: + if not isinstance(result, dict): + return None + + id_paths = [ + lambda r: r.get("createdFile", {}).get("id"), + lambda r: r.get("file", {}).get("id"), + lambda r: r.get("id"), + ] + + for p in id_paths: + try: + fid = p(result) + if fid: + return fid + except Exception: + continue + + return None + + +class MessagePayloadBuilder: + """构建不同类型消息负载的帮助类(无状态)。""" + + @staticmethod + def build_chat_payload( + user_id: str, text: Optional[str], file_id: Optional[str] = None + ) -> Dict[str, Any]: + payload = {"toUserId": user_id} + if text: + payload["text"] = text + if file_id: + payload["fileId"] = file_id + return payload + + @staticmethod + def build_room_payload( + room_id: str, text: Optional[str], file_id: Optional[str] = None + ) -> Dict[str, Any]: + payload = {"toRoomId": room_id} + if text: + payload["text"] = text + if file_id: + payload["fileId"] = file_id + return payload + + @staticmethod + def build_note_payload( + text: Optional[str], file_ids: Optional[List[str]] = None, **kwargs + ) -> Dict[str, Any]: + payload: Dict[str, Any] = {} + if text: + payload["text"] = text + if file_ids: + payload["fileIds"] = file_ids + payload.update(kwargs) + return payload + + def serialize_message_chain(chain: List[Any]) -> Tuple[str, bool]: """将消息链序列化为文本字符串""" text_parts = [] @@ -15,8 +80,11 @@ def process_component(component): if isinstance(component, Comp.Plain): return component.text elif isinstance(component, Comp.File): - file_name = getattr(component, "name", "文件") - return f"[文件: {file_name}]" + # 为文件组件返回占位符,但适配器仍会处理原组件 + return "[文件]" + elif isinstance(component, Comp.Image): + # 为图片组件返回占位符,但适配器仍会处理原组件 + return "[图片]" elif isinstance(component, Comp.At): has_at = True return f"@{component.qq}" @@ -128,6 +196,20 @@ def is_valid_room_session_id(session_id: Union[str, Any]) -> bool: ) +def is_valid_chat_session_id(session_id: Union[str, Any]) -> bool: + """检查 session_id 是否是有效的聊天 session_id (仅限chat%前缀)""" + if not isinstance(session_id, str) or "%" not in session_id: + return False + + parts = session_id.split("%") + return ( + len(parts) == 2 + and parts[0] == "chat" + and bool(parts[1]) + and parts[1] != "unknown" + ) + + def extract_user_id_from_session_id(session_id: str) -> str: """从 session_id 中提取用户 ID""" if "%" in session_id: @@ -344,3 +426,174 @@ def cache_room_info( "visibility": "specified", "visible_user_ids": [client_self_id], } + + +def detect_mime_ext(path: str) -> Optional[str]: + """检测文件 MIME 并返回常用扩展,作为 adapter 的可复用工具。""" + try: + try: + from magic import Magic # type: ignore + + m = Magic(mime=True) + mime = m.from_file(path) + except Exception: + import mimetypes as _m + + mime, _ = _m.guess_type(path) + except Exception: + mime = None + + if not mime: + return None + + mapping = { + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/png": ".png", + "image/gif": ".gif", + "audio/mpeg": ".mp3", + "audio/mp4": ".m4a", + "audio/ogg": ".ogg", + "audio/wav": ".wav", + "audio/x-wav": ".wav", + "audio/webm": ".webm", + "video/mp4": ".mp4", + "video/webm": ".webm", + "video/x-matroska": ".mkv", + "video/quicktime": ".mov", + "video/avi": ".avi", + "video/mpeg": ".mpeg", + "text/plain": ".txt", + "application/pdf": ".pdf", + } + return mapping.get(mime, mimetypes.guess_extension(mime) or None) + + +async def resolve_component_url_or_path( + comp: Any, +) -> Tuple[Optional[str], Optional[str]]: + """尝试从组件解析可上传的远程 URL 或本地路径。 + + 返回 (url_candidate, local_path)。两者可能都为 None。 + 这个函数尽量不抛异常,调用方可按需处理 None。 + """ + url_candidate = None + local_path = None + + try: + if hasattr(comp, "convert_to_file_path"): + try: + p = await comp.convert_to_file_path() + if isinstance(p, str): + if p.startswith("http"): + url_candidate = p + else: + local_path = p + except Exception: + pass + + if not local_path and hasattr(comp, "get_file"): + try: + p = await comp.get_file() + if isinstance(p, str): + if p.startswith("http"): + url_candidate = p + else: + local_path = p + except Exception: + pass + + # register_to_file_service or get_file(True) may provide a URL + if not url_candidate and hasattr(comp, "register_to_file_service"): + try: + r = await comp.register_to_file_service() + if isinstance(r, str) and r.startswith("http"): + url_candidate = r + except Exception: + pass + + if not url_candidate and hasattr(comp, "get_file"): + try: + maybe = await comp.get_file(True) + if isinstance(maybe, str) and maybe.startswith("http"): + url_candidate = maybe + except Exception: + pass + + # fallback to common attributes + if not url_candidate and not local_path: + for attr in ("file", "url", "path", "src", "source"): + try: + val = getattr(comp, attr, None) + except Exception: + val = None + if val and isinstance(val, str): + if val.startswith("http"): + url_candidate = val + break + else: + local_path = val + break + except Exception: + return None, None + + return url_candidate, local_path + + +def summarize_component_for_log(comp: Any) -> Dict[str, Any]: + """生成适合日志的组件属性字典(尽量不抛异常)。""" + attrs = {} + for a in ("file", "url", "path", "src", "source", "name"): + try: + v = getattr(comp, a, None) + if v is not None: + attrs[a] = v + except Exception: + continue + return attrs + + +async def upload_local_with_retries( + api: Any, + local_path: str, + preferred_name: Optional[str], + folder_id: Optional[str], +) -> Optional[str]: + """尝试本地上传并在遇到 unallowed 错误时按扩展名重试,返回 file id 或 None。""" + try: + res = await api.upload_file(local_path, preferred_name, folder_id) + if isinstance(res, dict): + fid = res.get("id") or (res.get("raw") or {}).get("createdFile", {}).get( + "id" + ) + if fid: + return str(fid) + except Exception as e: + msg = str(e).lower() + if "unallowed" in msg or "unallowed_file_type" in msg: + base = os.path.basename(local_path) + name_root, ext = os.path.splitext(base) + try_ext = detect_mime_ext(local_path) + candidates = [] + if try_ext: + candidates.append(try_ext) + candidates.extend([".jpg", ".png", ".txt", ".bin"]) + if ext and len(ext) <= 5 and ext not in candidates: + candidates.insert(0, ext) + tried = set() + for c in candidates: + try_name = name_root + c + if try_name in tried: + continue + tried.add(try_name) + try: + r = await api.upload_file(local_path, try_name, folder_id) + if isinstance(r, dict): + fid = r.get("id") or (r.get("raw") or {}).get( + "createdFile", {} + ).get("id") + if fid: + return str(fid) + except Exception: + continue + return None From dedbf6d1114974014e32ccf898398fa2c8c2d204 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 9 Oct 2025 21:57:28 +0800 Subject: [PATCH 10/18] =?UTF-8?q?feat(misskey):=20=E9=99=90=E5=88=B6?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E5=B9=B6=E5=8F=91=E6=95=B0?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E6=B6=88=E6=81=AF=E5=A4=84=E7=90=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sources/misskey/misskey_adapter.py | 4 +- .../platform/sources/misskey/misskey_api.py | 123 +++++++++--------- .../platform/sources/misskey/misskey_utils.py | 93 ++++++------- 3 files changed, 109 insertions(+), 111 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 7f406d663..b4b781c30 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -446,11 +446,13 @@ async def send_by_session( session_id, text, session, message_chain ) + MAX_UPLOAD_CONCURRENCY = 10 upload_concurrency = int( self.config.get( "misskey_upload_concurrency", DEFAULT_UPLOAD_CONCURRENCY ) ) + upload_concurrency = min(upload_concurrency, MAX_UPLOAD_CONCURRENCY) sem = asyncio.Semaphore(upload_concurrency) async def _upload_comp(comp) -> Optional[object]: @@ -848,7 +850,7 @@ async def _upload_comp(comp) -> Optional[object]: text=text, visibility=visibility, visible_user_ids=visible_user_ids, - file_ids=file_ids if file_ids else None, + file_ids=file_ids or None, local_only=self.local_only, cw=fields["cw"], poll=fields["poll"], diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 4652fcfca..105ee1479 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -195,38 +195,31 @@ async def _handle_message(self, data: Dict[str, Any]): message_type = data.get("type") body = data.get("body", {}) - try: - body = data.get("body") or {} - channel_summary = None - if isinstance(body, dict): + def _build_channel_summary(message_type: Optional[str], body: Any) -> str: + try: + if not isinstance(body, dict): + return f"[Misskey WebSocket] 收到消息类型: {message_type}" + inner = body.get("body") if isinstance(body.get("body"), dict) else body - note = None - if isinstance(inner, dict) and isinstance(inner.get("note"), dict): - note = inner.get("note") - text = None - has_files = False - is_hidden = False - note_id = None - user = None - if note: - text = note.get("text") - note_id = note.get("id") - files = note.get("files") or [] - has_files = bool(files) - is_hidden = bool(note.get("isHidden")) - user = note.get("user", {}) - - channel_summary = ( + note = inner.get("note") if isinstance(inner, dict) and isinstance(inner.get("note"), dict) else None + + text = note.get("text") if note else None + note_id = note.get("id") if note else None + files = note.get("files") or [] if note else [] + has_files = bool(files) + is_hidden = bool(note.get("isHidden")) if note else False + user = note.get("user", {}) if note else None + + return ( f"[Misskey WebSocket] 收到消息类型: {message_type} | " f"note_id={note_id} | user={user.get('username') if user else None} | " - f"text={'[no-text]' if not text else text[:80]} | files={has_files} | hidden={is_hidden}" + f"text={text[:80] if text else '[no-text]'} | files={has_files} | hidden={is_hidden}" ) - else: - channel_summary = f"[Misskey WebSocket] 收到消息类型: {message_type}" + except Exception: + return f"[Misskey WebSocket] 收到消息类型: {message_type}" - logger.info(channel_summary) - except Exception: - logger.info(f"[Misskey WebSocket] 收到消息类型: {message_type}") + channel_summary = _build_channel_summary(message_type, body) + logger.info(channel_summary) logger.debug( f"[Misskey WebSocket] 收到完整消息: {json.dumps(data, indent=2, ensure_ascii=False)}" @@ -351,12 +344,10 @@ def __init__( self._session: Optional[aiohttp.ClientSession] = None self.streaming: Optional[StreamingClient] = None # download options - self.allow_insecure_downloads = bool(allow_insecure_downloads) - self.download_timeout = int(download_timeout) - self.chunk_size = int(chunk_size) - self.max_download_bytes = ( - int(max_download_bytes) if max_download_bytes is not None else None - ) + self.allow_insecure_downloads = allow_insecure_downloads + self.download_timeout = download_timeout + self.chunk_size = chunk_size + self.max_download_bytes = int(max_download_bytes) if max_download_bytes is not None else None async def __aenter__(self): return self @@ -562,21 +553,30 @@ async def upload_file( form.add_field("i", self.access_token) try: - with open(file_path, "rb") as f: - filename = name or file_path.split("/")[-1] - form.add_field("file", f, filename=filename) - if folder_id: - form.add_field("folderId", str(folder_id)) - async with self.session.post(url, data=form) as resp: - result = await self._process_response(resp, "drive/files/create") - file_id = FileIDExtractor.extract_file_id(result) - logger.debug( - f"[Misskey API] 本地文件上传成功: {filename} -> {file_id}" - ) - return {"id": file_id, "raw": result} - except FileNotFoundError as e: - logger.error(f"[Misskey API] 本地文件不存在: {file_path}") - raise APIError(f"File not found: {file_path}") from e + # Read file bytes using thread executor to avoid adding new dependencies + loop = asyncio.get_running_loop() + def _read_file_bytes(path: str) -> bytes: + with open(path, "rb") as f: + return f.read() + + filename = name or file_path.split("/")[-1] + if folder_id: + form.add_field("folderId", str(folder_id)) + + try: + file_bytes = await loop.run_in_executor(None, _read_file_bytes, file_path) + except FileNotFoundError as e: + logger.error(f"[Misskey API] 本地文件不存在: {file_path}") + raise APIError(f"File not found: {file_path}") from e + + form.add_field("file", file_bytes, filename=filename) + async with self.session.post(url, data=form) as resp: + result = await self._process_response(resp, "drive/files/create") + file_id = FileIDExtractor.extract_file_id(result) + logger.debug( + f"[Misskey API] 本地文件上传成功: {filename} -> {file_id}" + ) + return {"id": file_id, "raw": result} except aiohttp.ClientError as e: logger.error(f"[Misskey API] 文件上传网络错误: {e}") raise APIConnectionError(f"Upload failed: {e}") from e @@ -773,10 +773,12 @@ async def calculate_url_md5(self, url: str) -> Optional[str]: logger.warning(f"[Misskey API] 无法计算 MD5: {url}") return None + MAX_STREAM_MD5_BYTES = 100 * 1024 * 1024 # 100MB safeguard + async def _stream_md5_with_session( self, url: str, ssl_verify: bool = True ) -> Optional[str]: - """使用 session 流式读取并计算 MD5,支持下载大小限制""" + """使用 session 流式读取并计算 MD5,支持下载大小限制和硬性最大下载字节数""" total = 0 m = hashlib.md5() async with self.session.get( @@ -789,10 +791,15 @@ async def _stream_md5_with_session( async for chunk in resp.content.iter_chunked(self.chunk_size): if not chunk: continue - m.update(chunk) total += len(chunk) + # enforce configured max_download_bytes first if self.max_download_bytes and total > self.max_download_bytes: raise APIError("下载文件超出允许的最大字节数") + # enforce a hard upper limit to avoid pathological cases + if total > self.MAX_STREAM_MD5_BYTES: + logger.warning(f"[Misskey API] 文件过大,已超过最大流式 MD5 限制: {url}") + return None + m.update(chunk) return m.hexdigest() async def _download_with_existing_session( @@ -800,7 +807,7 @@ async def _download_with_existing_session( ) -> Optional[bytes]: """使用现有会话下载文件""" if not (hasattr(self, "session") and self.session): - raise Exception("No existing session available") + raise APIConnectionError("No existing session available") async with self.session.get( url, timeout=aiohttp.ClientTimeout(total=15), ssl=ssl_verify @@ -875,9 +882,7 @@ async def upload_and_find_file( # 回退:下载远端文件并做本地上传 try: # 使用现有 session 下载内容到临时文件 - tmp_bytes = await self._download_with_existing_session(url) - if not tmp_bytes: - tmp_bytes = await self._download_with_temp_session(url) + tmp_bytes = await self._download_with_existing_session(url) or await self._download_with_temp_session(url) if tmp_bytes: # 写入临时文件并上传本地文件 @@ -957,8 +962,7 @@ async def _poll_by_hash( try: files = await self.find_files_by_hash(md5_hash) if files: - file_id = files[0].get("id") - if file_id: + if file_id := files[0].get("id"): logger.debug(f"[Misskey API] 异步上传完成: {file_id}") return {"id": file_id, "raw": files[0], "async_found": True} except Exception as e: @@ -1052,9 +1056,8 @@ async def get_messages( result = await self._make_request("chat/messages/user-timeline", data) if isinstance(result, list): return result - else: - logger.warning(f"[Misskey API] 聊天消息响应格式异常: {type(result)}") - return [] + logger.warning(f"[Misskey API] 聊天消息响应格式异常: {type(result)}") + return [] async def get_mentions( self, limit: int = 10, since_id: Optional[str] = None @@ -1202,7 +1205,7 @@ async def _dispatch_message( # 发帖使用 fileIds (复数) note_kwargs = { "text": text, - "file_ids": file_ids if file_ids else None, + "file_ids": file_ids or None, } # 合并其他参数 note_kwargs.update(kwargs) diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index 4e7ec4044..3c7564cca 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -23,8 +23,7 @@ def extract_file_id(result: Any) -> Optional[str]: for p in id_paths: try: - fid = p(result) - if fid: + if fid := p(result): return fid except Exception: continue @@ -66,7 +65,7 @@ def build_note_payload( payload["text"] = text if file_ids: payload["fileIds"] = file_ids - payload.update(kwargs) + payload |= kwargs return payload @@ -283,18 +282,10 @@ def format_poll(poll: Dict[str, Any]) -> str: """将 Misskey 的 poll 对象格式化为可读字符串。""" if not poll or not isinstance(poll, dict): return "" - parts = [] multiple = poll.get("multiple", False) choices = poll.get("choices", []) - parts.append("[投票]") - parts.append("允许多选" if multiple else "单选") - text_choices = [] - for idx, c in enumerate(choices, start=1): - text = c.get("text", "") - votes = c.get("votes", 0) - text_choices.append(f"({idx}) {text} [{votes}票]") - if text_choices: - parts.append("选项: " + ", ".join(text_choices)) + text_choices = [f"({idx}) {c.get('text','')} [{c.get('votes',0)}票]" for idx, c in enumerate(choices, start=1)] + parts = ["[投票]", ("允许多选" if multiple else "单选")] + (["选项: " + ", ".join(text_choices)] if text_choices else []) return " ".join(parts) @@ -481,46 +472,52 @@ async def resolve_component_url_or_path( local_path = None try: - if hasattr(comp, "convert_to_file_path"): + # helper to normalize a candidate string + async def _maybe_str_source(coro_or_val): try: - p = await comp.convert_to_file_path() - if isinstance(p, str): - if p.startswith("http"): - url_candidate = p - else: - local_path = p + v = coro_or_val + if hasattr(coro_or_val, "__await__"): + v = await coro_or_val + if isinstance(v, str): + return v except Exception: - pass + return None + return None + + # check async helpers first + if hasattr(comp, "convert_to_file_path"): + p = await _maybe_str_source(comp.convert_to_file_path()) + else: + p = None + if p: + if p.startswith("http"): + url_candidate = p + else: + local_path = p if not local_path and hasattr(comp, "get_file"): - try: - p = await comp.get_file() - if isinstance(p, str): - if p.startswith("http"): - url_candidate = p - else: - local_path = p - except Exception: - pass + p = await _maybe_str_source(comp.get_file()) + else: + p = None + if p: + if p.startswith("http"): + url_candidate = p + else: + local_path = p - # register_to_file_service or get_file(True) may provide a URL if not url_candidate and hasattr(comp, "register_to_file_service"): - try: - r = await comp.register_to_file_service() - if isinstance(r, str) and r.startswith("http"): - url_candidate = r - except Exception: - pass + r = await _maybe_str_source(comp.register_to_file_service()) + if r and r.startswith("http"): + url_candidate = r if not url_candidate and hasattr(comp, "get_file"): - try: - maybe = await comp.get_file(True) - if isinstance(maybe, str) and maybe.startswith("http"): - url_candidate = maybe - except Exception: - pass + p = await _maybe_str_source(comp.get_file(True)) + else: + p = None + if p and p.startswith("http"): + url_candidate = p - # fallback to common attributes + # fallback to sync attributes if not url_candidate and not local_path: for attr in ("file", "url", "path", "src", "source"): try: @@ -588,12 +585,8 @@ async def upload_local_with_retries( tried.add(try_name) try: r = await api.upload_file(local_path, try_name, folder_id) - if isinstance(r, dict): - fid = r.get("id") or (r.get("raw") or {}).get( - "createdFile", {} - ).get("id") - if fid: - return str(fid) + if isinstance(r, dict) and (fid := (r.get("id") or (r.get("raw") or {}).get("createdFile", {}).get("id"))): + return str(fid) except Exception: continue return None From d49faab57ae34937424d8e9d6235a7502bbed11d Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 9 Oct 2025 22:06:18 +0800 Subject: [PATCH 11/18] feat(misskey): ruff formatted --- .../platform/sources/misskey/misskey_api.py | 27 +++++++++++++------ .../platform/sources/misskey/misskey_utils.py | 16 ++++++++--- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 105ee1479..06980940f 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -201,7 +201,11 @@ def _build_channel_summary(message_type: Optional[str], body: Any) -> str: return f"[Misskey WebSocket] 收到消息类型: {message_type}" inner = body.get("body") if isinstance(body.get("body"), dict) else body - note = inner.get("note") if isinstance(inner, dict) and isinstance(inner.get("note"), dict) else None + note = ( + inner.get("note") + if isinstance(inner, dict) and isinstance(inner.get("note"), dict) + else None + ) text = note.get("text") if note else None note_id = note.get("id") if note else None @@ -347,7 +351,9 @@ def __init__( self.allow_insecure_downloads = allow_insecure_downloads self.download_timeout = download_timeout self.chunk_size = chunk_size - self.max_download_bytes = int(max_download_bytes) if max_download_bytes is not None else None + self.max_download_bytes = ( + int(max_download_bytes) if max_download_bytes is not None else None + ) async def __aenter__(self): return self @@ -555,6 +561,7 @@ async def upload_file( try: # Read file bytes using thread executor to avoid adding new dependencies loop = asyncio.get_running_loop() + def _read_file_bytes(path: str) -> bytes: with open(path, "rb") as f: return f.read() @@ -564,7 +571,9 @@ def _read_file_bytes(path: str) -> bytes: form.add_field("folderId", str(folder_id)) try: - file_bytes = await loop.run_in_executor(None, _read_file_bytes, file_path) + file_bytes = await loop.run_in_executor( + None, _read_file_bytes, file_path + ) except FileNotFoundError as e: logger.error(f"[Misskey API] 本地文件不存在: {file_path}") raise APIError(f"File not found: {file_path}") from e @@ -573,9 +582,7 @@ def _read_file_bytes(path: str) -> bytes: async with self.session.post(url, data=form) as resp: result = await self._process_response(resp, "drive/files/create") file_id = FileIDExtractor.extract_file_id(result) - logger.debug( - f"[Misskey API] 本地文件上传成功: {filename} -> {file_id}" - ) + logger.debug(f"[Misskey API] 本地文件上传成功: {filename} -> {file_id}") return {"id": file_id, "raw": result} except aiohttp.ClientError as e: logger.error(f"[Misskey API] 文件上传网络错误: {e}") @@ -797,7 +804,9 @@ async def _stream_md5_with_session( raise APIError("下载文件超出允许的最大字节数") # enforce a hard upper limit to avoid pathological cases if total > self.MAX_STREAM_MD5_BYTES: - logger.warning(f"[Misskey API] 文件过大,已超过最大流式 MD5 限制: {url}") + logger.warning( + f"[Misskey API] 文件过大,已超过最大流式 MD5 限制: {url}" + ) return None m.update(chunk) return m.hexdigest() @@ -882,7 +891,9 @@ async def upload_and_find_file( # 回退:下载远端文件并做本地上传 try: # 使用现有 session 下载内容到临时文件 - tmp_bytes = await self._download_with_existing_session(url) or await self._download_with_temp_session(url) + tmp_bytes = await self._download_with_existing_session( + url + ) or await self._download_with_temp_session(url) if tmp_bytes: # 写入临时文件并上传本地文件 diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index 3c7564cca..477cbd221 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -284,8 +284,13 @@ def format_poll(poll: Dict[str, Any]) -> str: return "" multiple = poll.get("multiple", False) choices = poll.get("choices", []) - text_choices = [f"({idx}) {c.get('text','')} [{c.get('votes',0)}票]" for idx, c in enumerate(choices, start=1)] - parts = ["[投票]", ("允许多选" if multiple else "单选")] + (["选项: " + ", ".join(text_choices)] if text_choices else []) + text_choices = [ + f"({idx}) {c.get('text', '')} [{c.get('votes', 0)}票]" + for idx, c in enumerate(choices, start=1) + ] + parts = ["[投票]", ("允许多选" if multiple else "单选")] + ( + ["选项: " + ", ".join(text_choices)] if text_choices else [] + ) return " ".join(parts) @@ -585,7 +590,12 @@ async def upload_local_with_retries( tried.add(try_name) try: r = await api.upload_file(local_path, try_name, folder_id) - if isinstance(r, dict) and (fid := (r.get("id") or (r.get("raw") or {}).get("createdFile", {}).get("id"))): + if isinstance(r, dict) and ( + fid := ( + r.get("id") + or (r.get("raw") or {}).get("createdFile", {}).get("id") + ) + ): return str(fid) except Exception: continue From 8cf55bee85cb5b1633904b47dbf647003f856044 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Wed, 15 Oct 2025 14:05:02 +0800 Subject: [PATCH 12/18] =?UTF-8?q?feat:=20=E5=A4=A7=E5=B9=85=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20misskey=20=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E7=AE=80=E5=8C=96=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E5=B9=B6=E5=A2=9E=E5=BC=BA=E5=8F=AF=E8=A7=81?= =?UTF-8?q?=E6=80=A7=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 8 +- .../sources/misskey/misskey_adapter.py | 311 +++------------- .../platform/sources/misskey/misskey_api.py | 331 ++---------------- .../platform/sources/misskey/misskey_utils.py | 234 +++++-------- 4 files changed, 180 insertions(+), 704 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index caea1b934..baa7805f3 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -393,17 +393,17 @@ "misskey_enable_file_upload": { "description": "启用文件上传到 Misskey", "type": "bool", - "hint": "启用后,适配器会尝试将消息链中的文件上传到 Misskey 并在消息中附加 media(fileIds)。", + "hint": "启用后,适配器会尝试将消息链中的文件上传到 Misskey。URL 文件会先尝试服务器端上传,异步上传失败时会回退到下载后本地上传。", }, "misskey_allow_insecure_downloads": { "description": "允许不安全下载(禁用 SSL 验证)", "type": "bool", - "hint": "仅作为最后回退手段:当远端服务器存在证书问题导致无法正常下载时,允许临时禁用 SSL 验证以获取文件。启用有安全风险,请谨慎使用。", + "hint": "当远端服务器存在证书问题导致无法正常下载时,自动禁用 SSL 验证作为回退方案。适用于某些图床的证书配置问题。启用有安全风险,仅在必要时使用。", }, "misskey_download_timeout": { "description": "远端下载超时时间(秒)", "type": "int", - "hint": "用于计算 URL 文件 MD5 或回退下载时的总体超时时间(秒)。", + "hint": "下载远程文件时的超时时间(秒),用于异步上传回退到本地上传的场景。", }, "misskey_download_chunk_size": { "description": "流式下载分块大小(字节)", @@ -423,7 +423,7 @@ "misskey_upload_folder": { "description": "上传到网盘的目标文件夹 ID", "type": "string", - "hint": "可选:填写 Misskey 网盘中目标文件夹的 ID,上传的文件将放置到该文件夹内以避免账号网盘根目录混乱。留空则使用默认位置。", + "hint": "可选:填写 Misskey 网盘中目标文件夹的 ID,上传的文件将放置到该文件夹内。留空则使用账号网盘根目录。", }, "telegram_command_register": { "description": "Telegram 命令注册", diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index b4b781c30..e506ac80f 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -14,7 +14,7 @@ from astrbot.core.platform.astr_message_event import MessageSession import astrbot.api.message_components as Comp -from .misskey_api import MisskeyAPI, APIError +from .misskey_api import MisskeyAPI import os try: @@ -180,12 +180,6 @@ async def _send_text_only_message( return await super().send_by_session(session, message_chain) - def _detect_mime_and_ext(self, path: str) -> Optional[str]: - """Delegates MIME detection to utils.detect_mime_ext.""" - from .misskey_utils import detect_mime_ext - - return detect_mime_ext(path) - def _process_poll_data( self, message: AstrBotMessage, poll: Dict[str, Any], message_parts: List[str] ): @@ -456,281 +450,90 @@ async def send_by_session( sem = asyncio.Semaphore(upload_concurrency) async def _upload_comp(comp) -> Optional[object]: - upload_path = None - upload_path_local = None - url_candidate = None + """简化的组件上传函数,使用工具函数处理""" + from .misskey_utils import ( + resolve_component_url_or_path, + upload_local_with_retries, + ) + local_path = None # 初始化变量以供finally块使用 try: - if hasattr(comp, "convert_to_file_path"): - try: - upload_path = await comp.convert_to_file_path() - except Exception: - pass - if not upload_path and hasattr(comp, "get_file"): - try: - upload_path = await comp.get_file() - except Exception: - pass - - # 不再在此处直接返回,改为先尝试从组件字段中推断 URL,再决定是否进入并发上传 - - # 先推断可能的 URL 或本地路径 - url_candidate = None - upload_path_local = None - - if ( - upload_path - and isinstance(upload_path, str) - and str(upload_path).startswith("http") - ): - url_candidate = upload_path - else: - upload_path_local = upload_path - - # 尝试通过注册到文件服务获取 URL(插件接口)或通过 get_file(True) 获取可直接访问的 URL - try: - if not url_candidate and hasattr( - comp, "register_to_file_service" - ): - try: - url_candidate = await comp.register_to_file_service() - except Exception: - url_candidate = None - if not url_candidate and hasattr(comp, "get_file"): - try: - maybe = await comp.get_file(True) - if ( - maybe - and isinstance(maybe, str) - and maybe.startswith("http") - ): - url_candidate = maybe - except Exception: - url_candidate = None - except Exception: - url_candidate = None - - # 如果上述都失败,检查常见的同步字段(file/url/path等) - if not url_candidate: - for attr in ("file", "url", "path", "src", "source"): - try: - val = getattr(comp, attr, None) - except Exception: - val = None - if val and isinstance(val, str) and val.startswith("http"): - url_candidate = val - break - - # 如果既没有 URL,也没有本地路径,则无可上传内容 - if not url_candidate and not upload_path_local: - return None - async with sem: if not self.api: return None - # 优先尝试通过 URL 上传(使用新的查找方法) + # 1. 解析组件的 URL 或本地路径 + url_candidate, local_path = await resolve_component_url_or_path( + comp + ) + + if not url_candidate and not local_path: + return None + + preferred_name = getattr(comp, "name", None) or getattr( + comp, "file", None + ) + + # 2. 优先尝试 URL 上传 if url_candidate: try: logger.debug( - f"[Misskey] 发现 URL candidate,尝试 upload-and-find: {url_candidate}" + f"[Misskey] 尝试 URL 上传: {url_candidate[:100]}" ) upload_result = await self.api.upload_and_find_file( str(url_candidate), - getattr(comp, "name", None) - or getattr(comp, "file", None), + preferred_name, folder_id=self.upload_folder, - max_wait_time=30.0, # 最多等待30秒 - check_interval=2.0, # 每2秒检查一次 ) - if isinstance(upload_result, dict): - # 检查各种上传结果 - if upload_result.get("id"): - # 成功获得文件ID(同步、异步找到、或现有文件) - fid = upload_result.get("id") - result_type = ( - "existing" - if upload_result.get("existing") - else ( - "async_found" - if upload_result.get("async_found") - else "sync" - ) - ) - logger.debug( - f"[Misskey] upload-and-find 成功 ({result_type}),fid={fid}, URL={url_candidate}" - ) - return str(fid) - elif upload_result.get("status") == "timeout": - # 异步上传超时,回退到URL - logger.warning( - f"[Misskey] upload-and-find 超时,回退到URL: {url_candidate}" - ) - return {"fallback_url": url_candidate} - elif upload_result.get("fallback_url"): - # 其他情况需要回退 - logger.debug( - f"[Misskey] upload-and-find 回退到URL: {url_candidate}" - ) - return {"fallback_url": url_candidate} - - # 如果 upload_result 为 None,表示上传完全失败 - if upload_result is None: - logger.warning( - "[Misskey] upload-and-find 返回 None,尝试本地上传" - ) - if not upload_path_local: - return None - else: - # 未知的响应格式 - logger.warning( - f"[Misskey] upload-and-find 返回未知格式: {upload_result}" + if isinstance( + upload_result, dict + ) and upload_result.get("id"): + logger.debug( + f"[Misskey] URL 上传成功: {upload_result['id']}" ) - if not upload_path_local: - return None - + return str(upload_result["id"]) except Exception as e: logger.debug( - f"[Misskey] upload-and-find 失败,回退为本地上传: {e}" + f"[Misskey] URL 上传失败: {e},尝试本地上传" ) - # 再尝试本地上传(如果有) - if not upload_path_local: - return None - try: - upload_result = await self.api.upload_file( - str(upload_path_local), - getattr(comp, "name", None) - or getattr(comp, "file", None), - folder_id=self.upload_folder, + # 3. 回退到本地上传(使用扩展名重试逻辑) + if local_path: + logger.debug(f"[Misskey] 尝试本地上传: {local_path}") + file_id = await upload_local_with_retries( + self.api, + str(local_path), + preferred_name, + self.upload_folder, ) - fid = None - if isinstance(upload_result, dict): - fid = upload_result.get("id") or ( - upload_result.get("raw") or {} - ).get("createdFile", {}).get("id") - return str(fid) if fid else None - except Exception as e: - logger.error(f"[Misskey] 文件上传失败: {e}") - tried_names = [] + if file_id: + logger.debug(f"[Misskey] 本地上传成功: {file_id}") + return file_id + + # 4. 所有上传都失败,尝试获取 URL 作为回退 + if hasattr(comp, "register_to_file_service"): try: - msg = str(e).lower() - if ( - "unallowed" in msg - or "unallowed_file_type" in msg - or ( - isinstance(e, APIError) - and "unallowed" in str(e).lower() - ) - ): - if not upload_path_local: - raise - base_name = os.path.basename(upload_path_local) - name_root, ext = os.path.splitext(base_name) - try_ext = self._detect_mime_and_ext( - upload_path_local - ) - candidates = [] - if try_ext: - candidates.append(try_ext) - candidates.extend([".jpg", ".png", ".txt", ".bin"]) - if ext and len(ext) <= 5 and ext not in candidates: - candidates.insert(0, ext) - for c in candidates: - try_name = name_root + c - if try_name in tried_names: - continue - tried_names.append(try_name) - try: - upload_result = await self.api.upload_file( - str(upload_path_local), - try_name, - folder_id=self.upload_folder, - ) - fid = None - if isinstance(upload_result, dict): - fid = upload_result.get("id") or ( - upload_result.get("raw") or {} - ).get("createdFile", {}).get("id") - if fid: - logger.debug( - f"[Misskey] 通过重试上传成功,使用文件名: {try_name}" - ) - return str(fid) - except Exception: - pass + url = await comp.register_to_file_service() + if url: + return {"fallback_url": url} except Exception: pass + return None + + finally: + # 清理临时文件 + if local_path and isinstance(local_path, str): + data_temp = os.path.join(get_astrbot_data_path(), "temp") + if local_path.startswith(data_temp) and os.path.exists( + local_path + ): try: - if hasattr(comp, "register_to_file_service"): - try: - url = await comp.register_to_file_service() - if url: - return {"fallback_url": url} - except Exception: - pass - if hasattr(comp, "get_file"): - try: - url_or_path = await comp.get_file(True) - if url_or_path and str(url_or_path).startswith( - "http" - ): - # 优先尝试通过 Misskey 的 upload-and-find 接口上传远程 URL - try: - if self.api: - upload_result = await self.api.upload_and_find_file( - str(url_or_path), - getattr(comp, "name", None) - or getattr(comp, "file", None), - folder_id=self.upload_folder, - max_wait_time=30.0, - check_interval=2.0, - ) - if isinstance(upload_result, dict): - fid = upload_result.get("id") - if fid: - return str(fid) - elif ( - upload_result.get( - "fallback_url" - ) - or upload_result.get( - "status" - ) - == "timeout" - ): - # 回退到URL显示 - return { - "fallback_url": url_or_path - } - except Exception: - # 如果 upload-from-url 失败,则回退为返回 URL - return {"fallback_url": url_or_path} - except Exception: - pass + os.remove(local_path) + logger.debug(f"[Misskey] 已清理临时文件: {local_path}") except Exception: pass - return None - finally: - try: - if upload_path_local: - data_temp = os.path.join(get_astrbot_data_path(), "temp") - if ( - isinstance(upload_path_local, str) - and upload_path_local.startswith(data_temp) - and os.path.exists(upload_path_local) - ): - try: - os.remove(upload_path_local) - logger.debug( - f"[Misskey] 已清理临时文件: {upload_path_local}" - ) - except Exception: - pass - except Exception: - pass # 收集所有可能包含文件/URL信息的组件:支持异步接口或同步字段 file_components = [] diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 06980940f..1cf0e37b3 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -1,9 +1,6 @@ import json import random import asyncio -import hashlib -import base64 -import re from typing import Any, Optional, Dict, List, Callable, Awaitable import uuid @@ -702,115 +699,6 @@ async def find_files( logger.error(f"[Misskey API] 列表文件失败: {e}") raise - async def check_file_existence(self, md5_hash: str) -> bool: - """Check if a file exists by MD5 hash""" - if not md5_hash: - raise APIError("No MD5 hash provided for check-existence") - - data = {"md5": md5_hash} - - try: - logger.debug(f"[Misskey API] check-existence 请求: md5={md5_hash}") - result = await self._make_request("drive/files/check-existence", data) - exists = bool(result) if result is not None else False - logger.debug(f"[Misskey API] check-existence 响应: 存在={exists}") - return exists - except Exception as e: - logger.error(f"[Misskey API] 检查文件存在性失败: {e}") - raise - - async def calculate_url_md5(self, url: str) -> Optional[str]: - """Calculate MD5 hash of file from URL with fallback strategies""" - if not url: - return None - # 1) 尝试 HEAD 查找 Content-MD5 / ETag(轻量) - try: - async with self.session.head( - url, timeout=aiohttp.ClientTimeout(total=self.download_timeout) - ) as head_resp: - if head_resp.status == 200: - # Content-MD5 header 通常是 base64(md5) - content_md5 = head_resp.headers.get("Content-MD5") - if content_md5: - try: - raw = base64.b64decode(content_md5) - hex_md5 = raw.hex() - logger.debug( - f"[Misskey API] 从 HEAD Content-MD5 获取 md5: {hex_md5}" - ) - return hex_md5 - except Exception: - logger.debug( - "[Misskey API] 无法解析 Content-MD5 header,继续流式下载" - ) - - # 尝试 ETag(有些服务直接返回十六进制 MD5) - etag = head_resp.headers.get("ETag") - if etag: - etag_val = etag.strip('"') - if re.fullmatch(r"[0-9a-fA-F]{32}", etag_val): - logger.debug( - f"[Misskey API] 从 HEAD ETag 获取 md5: {etag_val}" - ) - return etag_val.lower() - except Exception: - logger.debug("[Misskey API] HEAD 请求失败,继续使用流式 GET") - - # 2) 流式 GET(使用现有 session) - try: - md5 = await self._stream_md5_with_session(url, ssl_verify=True) - if md5: - logger.debug(f"[Misskey API] 流式 MD5 计算成功 (ssl): {md5}") - return md5 - except Exception as e: - logger.debug(f"[Misskey API] 流式下载(ssl)失败: {e}") - - # 3) 可选:不安全下载(受配置控制,仅做最后退路) - if self.allow_insecure_downloads: - try: - md5 = await self._stream_md5_with_session(url, ssl_verify=False) - if md5: - logger.warning( - f"[Misskey API] 使用不安全下载获取 MD5(ssl 验证已禁用): {url}" - ) - return md5 - except Exception as e: - logger.debug(f"[Misskey API] 不安全流式下载失败: {e}") - - logger.warning(f"[Misskey API] 无法计算 MD5: {url}") - return None - - MAX_STREAM_MD5_BYTES = 100 * 1024 * 1024 # 100MB safeguard - - async def _stream_md5_with_session( - self, url: str, ssl_verify: bool = True - ) -> Optional[str]: - """使用 session 流式读取并计算 MD5,支持下载大小限制和硬性最大下载字节数""" - total = 0 - m = hashlib.md5() - async with self.session.get( - url, - timeout=aiohttp.ClientTimeout(total=self.download_timeout), - ssl=ssl_verify, - ) as resp: - if resp.status != 200: - return None - async for chunk in resp.content.iter_chunked(self.chunk_size): - if not chunk: - continue - total += len(chunk) - # enforce configured max_download_bytes first - if self.max_download_bytes and total > self.max_download_bytes: - raise APIError("下载文件超出允许的最大字节数") - # enforce a hard upper limit to avoid pathological cases - if total > self.MAX_STREAM_MD5_BYTES: - logger.warning( - f"[Misskey API] 文件过大,已超过最大流式 MD5 限制: {url}" - ) - return None - m.update(chunk) - return m.hexdigest() - async def _download_with_existing_session( self, url: str, ssl_verify: bool = True ) -> Optional[bytes]: @@ -847,14 +735,14 @@ async def upload_and_find_file( check_interval: float = 2.0, ) -> Optional[Dict[str, Any]]: """ - 智能文件上传:先检查重复,再上传,最后轮询查找 + 简化的文件上传:尝试 URL 上传,失败则下载后本地上传 Args: url: 文件URL name: 文件名(可选) folder_id: 文件夹ID(可选) - max_wait_time: 最大等待时间 - check_interval: 轮询间隔 + max_wait_time: 保留参数(未使用) + check_interval: 保留参数(未使用) Returns: 包含文件ID和元信息的字典,失败时返回None @@ -862,159 +750,61 @@ async def upload_and_find_file( if not url: raise APIError("URL不能为空") - # 优先按文件名在已有文件中查找(避免重复上传) - try: - filename = name or url.split("/")[-1].split("?")[0] - if filename: - matches = await self.find_files_by_name(filename, folder_id) - if matches: - file_id = matches[0].get("id") - logger.debug(f"[Misskey API] 通过名称找到已存在文件: {file_id}") - return {"id": file_id, "raw": matches[0], "name_match": True} - except Exception: - # 名称查找失败时继续尝试 upload-from-url - logger.debug("[Misskey API] 名称查找失败或异常,继续处理") - - # 尝试使用 Misskey 的 upload-from-url 接口(服务器端处理远程 URL) + # 1. 尝试使用 Misskey 的 upload-from-url 接口(服务器端处理远程 URL) try: upload_result = await self.upload_file_from_url(url, name, folder_id) - # 处理 upload-from-url 的返回(可能为 accepted 或直接返回文件) - md5_hash = await self.calculate_url_md5(url) - return await self._handle_upload_result( - upload_result, md5_hash, url, max_wait_time, check_interval - ) + if isinstance(upload_result, dict): + # 只有同步上传成功才返回,异步上传需要回退到本地上传 + if upload_result.get("id"): + logger.debug(f"[Misskey API] URL上传成功: {upload_result['id']}") + return upload_result + elif upload_result.get("status") == "accepted": + logger.debug( + "[Misskey API] 异步上传已接受,回退到本地上传以获取即时文件ID" + ) + # 不返回,继续执行本地上传逻辑 except Exception as e: - logger.warning( - f"[Misskey API] upload-from-url 失败,准备回退到下载并本地上传: {e}" - ) + logger.debug(f"[Misskey API] upload-from-url 失败: {e},尝试本地上传") - # 回退:下载远端文件并做本地上传 + # 2. 回退:下载文件并本地上传 try: - # 使用现有 session 下载内容到临时文件 - tmp_bytes = await self._download_with_existing_session( - url - ) or await self._download_with_temp_session(url) + import tempfile + import os - if tmp_bytes: - # 写入临时文件并上传本地文件 - import tempfile + # 首先尝试使用 SSL 验证下载 + tmp_bytes = None + try: + tmp_bytes = await self._download_with_existing_session( + url, ssl_verify=True + ) or await self._download_with_temp_session(url, ssl_verify=True) + except Exception as ssl_error: + # SSL 验证失败,重试不验证 SSL + logger.debug( + f"[Misskey API] SSL 验证下载失败: {ssl_error},重试不验证 SSL" + ) + try: + tmp_bytes = await self._download_with_existing_session( + url, ssl_verify=False + ) or await self._download_with_temp_session(url, ssl_verify=False) + except Exception: + pass + if tmp_bytes: with tempfile.NamedTemporaryFile(delete=False) as tmpf: tmpf.write(tmp_bytes) tmp_path = tmpf.name try: result = await self.upload_file(tmp_path, name, folder_id) + logger.debug(f"[Misskey API] 本地上传成功: {result.get('id')}") return result finally: - import os - try: os.unlink(tmp_path) except Exception: pass except Exception as e: - logger.error(f"[Misskey API] 下载并本地上传回退失败: {e}") - - return None - - async def _check_existing_file(self, md5_hash: str) -> Optional[Dict[str, Any]]: - """检查文件是否已存在""" - try: - existing_files = await self.find_files_by_hash(md5_hash) - if existing_files: - file_id = existing_files[0].get("id") - logger.debug(f"[Misskey API] 发现已存在文件: {file_id}") - return { - "id": file_id, - "raw": existing_files[0], - "existing": True, - } - except Exception as e: - logger.debug(f"[Misskey API] 检查已存在文件失败: {e}") - return None - - async def _handle_upload_result( - self, - upload_result: Any, - md5_hash: Optional[str], - url: str, - max_wait_time: float, - check_interval: float, - ) -> Optional[Dict[str, Any]]: - """处理上传结果""" - # 同步上传成功 - if isinstance(upload_result, dict) and upload_result.get("id"): - return upload_result - - # 异步上传 - if ( - isinstance(upload_result, dict) - and upload_result.get("status") == "accepted" - ): - if md5_hash: - return await self._poll_by_hash( - md5_hash, max_wait_time, check_interval, url - ) - else: - return await self._poll_by_name(url, max_wait_time) - - logger.error(f"[Misskey API] 文件上传失败: {url}") - return None - - async def _poll_by_hash( - self, md5_hash: str, max_wait_time: float, check_interval: float, url: str - ) -> Optional[Dict[str, Any]]: - """通过MD5哈希轮询查找文件""" - logger.debug(f"[Misskey API] 开始轮询查找文件: {md5_hash}") - - waited_time = 0.0 - while waited_time < max_wait_time: - try: - files = await self.find_files_by_hash(md5_hash) - if files: - if file_id := files[0].get("id"): - logger.debug(f"[Misskey API] 异步上传完成: {file_id}") - return {"id": file_id, "raw": files[0], "async_found": True} - except Exception as e: - logger.debug(f"[Misskey API] 轮询查找出错: {e}") - - await asyncio.sleep(check_interval) - waited_time += check_interval - - # MD5轮询超时,尝试名称匹配 - return await self._fallback_name_search(url) - - async def _poll_by_name( - self, url: str, max_wait_time: float - ) -> Optional[Dict[str, Any]]: - """通过文件名轮询查找""" - logger.debug("[Misskey API] 无MD5哈希,等待后按名称查找") - - # 等待异步上传完成 - await asyncio.sleep(min(3.0, max_wait_time / 2)) - return await self._fallback_name_search(url) - - async def _fallback_name_search(self, url: str) -> Optional[Dict[str, Any]]: - """回退到名称匹配搜索""" - try: - recent_files = await self.find_files(limit=20) - filename = url.split("/")[-1].split("?")[0] - - # 精确匹配 - for file in recent_files: - if file.get("name") == filename: - logger.debug(f"[Misskey API] 精确名称匹配: {file.get('id')}") - return {"id": file.get("id"), "raw": file, "name_match": True} - - # 模糊匹配 - for file in recent_files: - if file.get("name") and filename in file["name"]: - logger.debug(f"[Misskey API] 模糊名称匹配: {file.get('id')}") - return {"id": file.get("id"), "raw": file, "name_match": True} - - except Exception as e: - logger.error(f"[Misskey API] 名称搜索失败: {e}") + logger.error(f"[Misskey API] 本地上传回退失败: {e}") return None @@ -1224,48 +1014,3 @@ async def _dispatch_message( else: raise APIError(f"不支持的消息类型: {message_type}") - - async def upload_and_find_file_with_fallback( - self, - url: str, - local_backup_path: Optional[str] = None, - name: Optional[str] = None, - folder_id: Optional[str] = None, - max_wait_time: float = 30.0, - check_interval: float = 2.0, - ) -> Optional[Dict[str, Any]]: - """ - 增强版文件上传,支持本地文件回退 - - Args: - url: 远程文件URL - local_backup_path: 本地备份文件路径(URL失败时使用) - name: 文件名 - folder_id: 文件夹ID - max_wait_time: 最大等待时间 - check_interval: 轮询间隔 - - Returns: - 上传结果或None - """ - # 首先尝试URL上传 - try: - result = await self.upload_and_find_file( - url, name, folder_id, max_wait_time, check_interval - ) - if result: - return result - except Exception as e: - logger.warning(f"[Misskey API] URL上传失败,尝试本地回退: {e}") - - # URL上传失败,尝试本地文件回退 - if local_backup_path: - try: - result = await self.upload_file(local_backup_path, name, folder_id) - if result and result.get("id"): - logger.info(f"[Misskey API] 本地文件回退上传成功: {result['id']}") - return result - except Exception as e: - logger.error(f"[Misskey API] 本地文件回退也失败: {e}") - - return None diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index 477cbd221..d10b29431 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -1,7 +1,5 @@ """Misskey 平台适配器通用工具函数""" -import mimetypes -import os from typing import Dict, Any, List, Tuple, Optional, Union import astrbot.api.message_components as Comp from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType @@ -110,15 +108,22 @@ def process_component(component): def resolve_message_visibility( - user_id: Optional[str], - user_cache: Dict[str, Any], - self_id: Optional[str], + user_id: Optional[str] = None, + user_cache: Optional[Dict[str, Any]] = None, + self_id: Optional[str] = None, + raw_message: Optional[Dict[str, Any]] = None, default_visibility: str = "public", ) -> Tuple[str, Optional[List[str]]]: - """解析 Misskey 消息的可见性设置""" + """解析 Misskey 消息的可见性设置 + + 可以从 user_cache 或 raw_message 中解析,支持两种调用方式: + 1. 基于 user_cache: resolve_message_visibility(user_id, user_cache, self_id) + 2. 基于 raw_message: resolve_message_visibility(raw_message=raw_message, self_id=self_id) + """ visibility = default_visibility visible_user_ids = None + # 优先从 user_cache 解析 if user_id and user_cache: user_info = user_cache.get(user_id) if user_info: @@ -133,38 +138,36 @@ def resolve_message_visibility( visible_user_ids = [uid for uid in visible_user_ids if uid] else: visibility = original_visibility + return visibility, visible_user_ids + + # 回退到从 raw_message 解析 + if raw_message: + original_visibility = raw_message.get("visibility", default_visibility) + if original_visibility == "specified": + visibility = "specified" + original_visible_users = raw_message.get("visibleUserIds", []) + sender_id = raw_message.get("userId", "") + + users_to_include = [] + if sender_id: + users_to_include.append(sender_id) + if self_id: + users_to_include.append(self_id) + + visible_user_ids = list(set(original_visible_users + users_to_include)) + visible_user_ids = [uid for uid in visible_user_ids if uid] + else: + visibility = original_visibility return visibility, visible_user_ids +# 保留旧函数名作为向后兼容的别名 def resolve_visibility_from_raw_message( raw_message: Dict[str, Any], self_id: Optional[str] = None ) -> Tuple[str, Optional[List[str]]]: - """从原始消息数据中解析可见性设置""" - visibility = "public" - visible_user_ids = None - - if not raw_message: - return visibility, visible_user_ids - - original_visibility = raw_message.get("visibility", "public") - if original_visibility == "specified": - visibility = "specified" - original_visible_users = raw_message.get("visibleUserIds", []) - sender_id = raw_message.get("userId", "") - - users_to_include = [] - if sender_id: - users_to_include.append(sender_id) - if self_id: - users_to_include.append(self_id) - - visible_user_ids = list(set(original_visible_users + users_to_include)) - visible_user_ids = [uid for uid in visible_user_ids if uid] - else: - visibility = original_visibility - - return visibility, visible_user_ids + """从原始消息数据中解析可见性设置(已弃用,使用 resolve_message_visibility 替代)""" + return resolve_message_visibility(raw_message=raw_message, self_id=self_id) def is_valid_user_session_id(session_id: Union[str, Any]) -> bool: @@ -424,47 +427,6 @@ def cache_room_info( } -def detect_mime_ext(path: str) -> Optional[str]: - """检测文件 MIME 并返回常用扩展,作为 adapter 的可复用工具。""" - try: - try: - from magic import Magic # type: ignore - - m = Magic(mime=True) - mime = m.from_file(path) - except Exception: - import mimetypes as _m - - mime, _ = _m.guess_type(path) - except Exception: - mime = None - - if not mime: - return None - - mapping = { - "image/jpeg": ".jpg", - "image/jpg": ".jpg", - "image/png": ".png", - "image/gif": ".gif", - "audio/mpeg": ".mp3", - "audio/mp4": ".m4a", - "audio/ogg": ".ogg", - "audio/wav": ".wav", - "audio/x-wav": ".wav", - "audio/webm": ".webm", - "video/mp4": ".mp4", - "video/webm": ".webm", - "video/x-matroska": ".mkv", - "video/quicktime": ".mov", - "video/avi": ".avi", - "video/mpeg": ".mpeg", - "text/plain": ".txt", - "application/pdf": ".pdf", - } - return mapping.get(mime, mimetypes.guess_extension(mime) or None) - - async def resolve_component_url_or_path( comp: Any, ) -> Tuple[Optional[str], Optional[str]]: @@ -476,68 +438,59 @@ async def resolve_component_url_or_path( url_candidate = None local_path = None + async def _get_str_value(coro_or_val): + """辅助函数:统一处理协程或普通值""" + try: + if hasattr(coro_or_val, "__await__"): + result = await coro_or_val + else: + result = coro_or_val + return result if isinstance(result, str) else None + except Exception: + return None + try: - # helper to normalize a candidate string - async def _maybe_str_source(coro_or_val): + # 1. 尝试异步方法 + for method in ["convert_to_file_path", "get_file", "register_to_file_service"]: + if not hasattr(comp, method): + continue try: - v = coro_or_val - if hasattr(coro_or_val, "__await__"): - v = await coro_or_val - if isinstance(v, str): - return v + value = await _get_str_value(getattr(comp, method)()) + if value: + if value.startswith("http"): + url_candidate = value + break + else: + local_path = value except Exception: - return None - return None - - # check async helpers first - if hasattr(comp, "convert_to_file_path"): - p = await _maybe_str_source(comp.convert_to_file_path()) - else: - p = None - if p: - if p.startswith("http"): - url_candidate = p - else: - local_path = p - - if not local_path and hasattr(comp, "get_file"): - p = await _maybe_str_source(comp.get_file()) - else: - p = None - if p: - if p.startswith("http"): - url_candidate = p - else: - local_path = p - - if not url_candidate and hasattr(comp, "register_to_file_service"): - r = await _maybe_str_source(comp.register_to_file_service()) - if r and r.startswith("http"): - url_candidate = r + continue + # 2. 尝试 get_file(True) 获取可直接访问的 URL if not url_candidate and hasattr(comp, "get_file"): - p = await _maybe_str_source(comp.get_file(True)) - else: - p = None - if p and p.startswith("http"): - url_candidate = p + try: + value = await _get_str_value(comp.get_file(True)) + if value and value.startswith("http"): + url_candidate = value + except Exception: + pass - # fallback to sync attributes + # 3. 回退到同步属性 if not url_candidate and not local_path: for attr in ("file", "url", "path", "src", "source"): try: - val = getattr(comp, attr, None) + value = getattr(comp, attr, None) + if value and isinstance(value, str): + if value.startswith("http"): + url_candidate = value + break + else: + local_path = value + break except Exception: - val = None - if val and isinstance(val, str): - if val.startswith("http"): - url_candidate = val - break - else: - local_path = val - break + continue + except Exception: - return None, None + pass return url_candidate, local_path @@ -561,7 +514,7 @@ async def upload_local_with_retries( preferred_name: Optional[str], folder_id: Optional[str], ) -> Optional[str]: - """尝试本地上传并在遇到 unallowed 错误时按扩展名重试,返回 file id 或 None。""" + """尝试本地上传,返回 file id 或 None。如果文件类型不允许则直接失败。""" try: res = await api.upload_file(local_path, preferred_name, folder_id) if isinstance(res, dict): @@ -570,33 +523,8 @@ async def upload_local_with_retries( ) if fid: return str(fid) - except Exception as e: - msg = str(e).lower() - if "unallowed" in msg or "unallowed_file_type" in msg: - base = os.path.basename(local_path) - name_root, ext = os.path.splitext(base) - try_ext = detect_mime_ext(local_path) - candidates = [] - if try_ext: - candidates.append(try_ext) - candidates.extend([".jpg", ".png", ".txt", ".bin"]) - if ext and len(ext) <= 5 and ext not in candidates: - candidates.insert(0, ext) - tried = set() - for c in candidates: - try_name = name_root + c - if try_name in tried: - continue - tried.add(try_name) - try: - r = await api.upload_file(local_path, try_name, folder_id) - if isinstance(r, dict) and ( - fid := ( - r.get("id") - or (r.get("raw") or {}).get("createdFile", {}).get("id") - ) - ): - return str(fid) - except Exception: - continue + except Exception: + # 上传失败,直接返回 None,让上层处理错误 + return None + return None From 5d69256982bae15d2bd3719f3b897ed48827e25e Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Wed, 15 Oct 2025 14:20:21 +0800 Subject: [PATCH 13/18] =?UTF-8?q?feat(misskey):=20=E7=A7=BB=E9=99=A4=20Url?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=96=B9=E5=BC=8F=EF=BC=8C=E7=B2=BE=E7=AE=80?= =?UTF-8?q?=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sources/misskey/misskey_adapter.py | 113 +++--------------- .../platform/sources/misskey/misskey_api.py | 74 +----------- 2 files changed, 19 insertions(+), 168 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index e506ac80f..9a91d7300 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -362,34 +362,13 @@ async def send_by_session( try: session_id = session.session_id - # 添加消息链组件调试日志 - logger.debug( - f"[Misskey] 收到消息链,包含 {len(message_chain.chain)} 个组件:" - ) - for i, comp in enumerate(message_chain.chain): - try: - comp_info = f"类型:{type(comp).__name__}" - if hasattr(comp, "text"): - comp_info += f" 文本:'{getattr(comp, 'text', '')[:50]}'" - for attr in ["file", "url", "path"]: - if hasattr(comp, attr): - val = getattr(comp, attr, None) - if val: - comp_info += f" {attr}:'{str(val)[:100]}'" - logger.debug(f"[Misskey] 组件 {i + 1}: {comp_info}") - except Exception as e: - logger.debug(f"[Misskey] 组件 {i + 1}: 无法获取信息 - {e}") - text, has_at_user = serialize_message_chain(message_chain.chain) - logger.debug( - f"[Misskey] serialize_message_chain 返回文本: '{text}', has_at_user: {has_at_user}" - ) if not has_at_user and session_id: user_info = self._user_cache.get(session_id) text = add_at_mention_if_needed(text, user_info, has_at_user) - # 检查是否有文件组件,即使文本为空或只有占位符也要处理 + # 检查是否有文件组件 has_file_components = any( isinstance(comp, Comp.Image) or isinstance(comp, Comp.File) @@ -400,14 +379,13 @@ async def send_by_session( ) for comp in message_chain.chain ) - logger.debug(f"[Misskey] 检测到文件组件: {has_file_components}") if not text or not text.strip(): if not has_file_components: logger.warning("[Misskey] 消息内容为空且无文件组件,跳过发送") return await super().send_by_session(session, message_chain) else: - text = "" # 清空占位符文本,只发送文件 + text = "" if len(text) > self.max_message_length: text = text[: self.max_message_length] + "..." @@ -415,27 +393,7 @@ async def send_by_session( file_ids: List[str] = [] fallback_urls: List[str] = [] - # 添加详细的组件日志以调试插件返回的组件结构 - try: - logger.debug(f"[Misskey] 消息链包含 {len(message_chain.chain)} 个组件") - for i, comp in enumerate(message_chain.chain): - comp_type = type(comp).__name__ - comp_attrs = {} - for attr in ["file", "url", "path", "src", "source", "name"]: - try: - val = getattr(comp, attr, None) - if val is not None: - comp_attrs[attr] = str(val)[:100] # 截断长URL - except Exception: - pass - logger.debug( - f"[Misskey] 组件 {i}: {comp_type} - 属性: {comp_attrs}" - ) - except Exception as e: - logger.debug(f"[Misskey] 组件日志失败: {e}") - if not self.enable_file_upload: - logger.debug("[Misskey] 文件上传已在配置中禁用,跳过上传流程") return await self._send_text_only_message( session_id, text, session, message_chain ) @@ -450,19 +408,19 @@ async def send_by_session( sem = asyncio.Semaphore(upload_concurrency) async def _upload_comp(comp) -> Optional[object]: - """简化的组件上传函数,使用工具函数处理""" + """组件上传函数:下载文件后本地上传""" from .misskey_utils import ( resolve_component_url_or_path, upload_local_with_retries, ) - local_path = None # 初始化变量以供finally块使用 + local_path = None try: async with sem: if not self.api: return None - # 1. 解析组件的 URL 或本地路径 + # 解析组件的 URL 或本地路径 url_candidate, local_path = await resolve_component_url_or_path( comp ) @@ -474,33 +432,18 @@ async def _upload_comp(comp) -> Optional[object]: comp, "file", None ) - # 2. 优先尝试 URL 上传 + # URL 上传:下载后本地上传 if url_candidate: - try: - logger.debug( - f"[Misskey] 尝试 URL 上传: {url_candidate[:100]}" - ) - upload_result = await self.api.upload_and_find_file( - str(url_candidate), - preferred_name, - folder_id=self.upload_folder, - ) - - if isinstance( - upload_result, dict - ) and upload_result.get("id"): - logger.debug( - f"[Misskey] URL 上传成功: {upload_result['id']}" - ) - return str(upload_result["id"]) - except Exception as e: - logger.debug( - f"[Misskey] URL 上传失败: {e},尝试本地上传" - ) - - # 3. 回退到本地上传(使用扩展名重试逻辑) + result = await self.api.upload_and_find_file( + str(url_candidate), + preferred_name, + folder_id=self.upload_folder, + ) + if isinstance(result, dict) and result.get("id"): + return str(result["id"]) + + # 本地文件上传 if local_path: - logger.debug(f"[Misskey] 尝试本地上传: {local_path}") file_id = await upload_local_with_retries( self.api, str(local_path), @@ -508,10 +451,9 @@ async def _upload_comp(comp) -> Optional[object]: self.upload_folder, ) if file_id: - logger.debug(f"[Misskey] 本地上传成功: {file_id}") return file_id - # 4. 所有上传都失败,尝试获取 URL 作为回退 + # 所有上传都失败,尝试获取 URL 作为回退 if hasattr(comp, "register_to_file_service"): try: url = await comp.register_to_file_service() @@ -554,29 +496,6 @@ async def _upload_comp(comp) -> Optional[object]: # 保守跳过无法访问属性的组件 continue - # 打印组件摘要,便于调试插件返回的结构 - try: - logger.debug( - f"[Misskey] 检测到 {len(file_components)} 个可能的文件组件:" - ) - for i, comp in enumerate(file_components): - try: - comp_type = type(comp).__name__ - comp_attrs = {} - for attr in ["file", "url", "path", "src", "source", "name"]: - try: - val = getattr(comp, attr, None) - if val is not None: - comp_attrs[attr] = val - except Exception: - pass - logger.debug( - f"[Misskey] 组件 {i + 1}: {comp_type} - {comp_attrs}" - ) - except Exception as e: - logger.debug(f"[Misskey] 组件 {i + 1}: 无法获取属性 - {e}") - except Exception: - pass if len(file_components) > MAX_FILE_UPLOAD_COUNT: logger.warning( f"[Misskey] 文件数量超过限制 ({len(file_components)} > {MAX_FILE_UPLOAD_COUNT}),只上传前{MAX_FILE_UPLOAD_COUNT}个文件" diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 1cf0e37b3..70b474d7e 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -441,9 +441,6 @@ async def _process_response( except json.JSONDecodeError as e: logger.error(f"[Misskey API] 响应格式错误: {e}") raise APIConnectionError("Invalid JSON response") from e - elif response.status == 204 and endpoint == "drive/files/upload-from-url": - logger.debug(f"[Misskey API] 异步上传请求已接受: {endpoint}") - return {"status": "accepted", "async": True} else: try: error_text = await response.text() @@ -585,54 +582,6 @@ def _read_file_bytes(path: str) -> bytes: logger.error(f"[Misskey API] 文件上传网络错误: {e}") raise APIConnectionError(f"Upload failed: {e}") from e - async def upload_file_from_url( - self, url: str, name: Optional[str] = None, folder_id: Optional[str] = None - ) -> Dict[str, Any]: - """Upload a file to Misskey using a remote URL (drive/files/upload-from-url). - - Returns a dict containing id and raw result on success. - """ - if not url: - raise APIError("No URL provided for upload-from-url") - - data: Dict[str, Any] = {"url": url} - if name: - data["name"] = name - if folder_id: - data["folderId"] = str(folder_id) - - try: - logger.debug( - f"[Misskey API] upload-from-url 请求: url={url}, name={name}, folder_id={folder_id}" - ) - result = await self._make_request("drive/files/upload-from-url", data) - logger.debug(f"[Misskey API] upload-from-url 响应: {result}") - - # 检查是否是异步上传响应 (HTTP 204) - if ( - isinstance(result, dict) - and result.get("status") == "accepted" - and result.get("async") - ): - logger.debug( - "[Misskey API] upload-from-url 异步请求已接受,文件将在后台上传" - ) - return {"status": "accepted", "async": True, "url": url} - - # 同步上传响应,提取文件ID - fid = None - if isinstance(result, dict): - fid = ( - (result.get("createdFile") or {}).get("id") - or result.get("id") - or (result.get("file") or {}).get("id") - ) - logger.debug(f"[Misskey API] upload-from-url 得到 fid: {fid}") - return {"id": fid, "raw": result} - except Exception as e: - logger.error(f"上传 URL 文件失败: {e}") - raise - async def find_files_by_hash(self, md5_hash: str) -> List[Dict[str, Any]]: """Find files by MD5 hash""" if not md5_hash: @@ -750,35 +699,18 @@ async def upload_and_find_file( if not url: raise APIError("URL不能为空") - # 1. 尝试使用 Misskey 的 upload-from-url 接口(服务器端处理远程 URL) - try: - upload_result = await self.upload_file_from_url(url, name, folder_id) - if isinstance(upload_result, dict): - # 只有同步上传成功才返回,异步上传需要回退到本地上传 - if upload_result.get("id"): - logger.debug(f"[Misskey API] URL上传成功: {upload_result['id']}") - return upload_result - elif upload_result.get("status") == "accepted": - logger.debug( - "[Misskey API] 异步上传已接受,回退到本地上传以获取即时文件ID" - ) - # 不返回,继续执行本地上传逻辑 - except Exception as e: - logger.debug(f"[Misskey API] upload-from-url 失败: {e},尝试本地上传") - - # 2. 回退:下载文件并本地上传 + # 通过本地上传获取即时文件 ID(下载文件 → 上传 → 返回 ID) try: import tempfile import os - # 首先尝试使用 SSL 验证下载 + # SSL 验证下载,失败则重试不验证 SSL tmp_bytes = None try: tmp_bytes = await self._download_with_existing_session( url, ssl_verify=True ) or await self._download_with_temp_session(url, ssl_verify=True) except Exception as ssl_error: - # SSL 验证失败,重试不验证 SSL logger.debug( f"[Misskey API] SSL 验证下载失败: {ssl_error},重试不验证 SSL" ) @@ -804,7 +736,7 @@ async def upload_and_find_file( except Exception: pass except Exception as e: - logger.error(f"[Misskey API] 本地上传回退失败: {e}") + logger.error(f"[Misskey API] 本地上传失败: {e}") return None From 5b6f2df18dba114f38324bccc80eb09f1f85ecb4 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Wed, 15 Oct 2025 14:21:03 +0800 Subject: [PATCH 14/18] =?UTF-8?q?fix(misskey):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=94=99=E6=8A=8AURL=E6=96=87=E4=BB=B6=E5=BD=93=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E6=96=87=E4=BB=B6=E4=B8=8A=E4=BC=A0=E7=9A=84=E9=97=AE?= =?UTF-8?q?=E9=A2=98=EF=BC=8C=E6=98=8E=E7=A1=AE=E5=A4=84=E7=90=86=20URL=20?= =?UTF-8?q?=E5=92=8C=E6=9C=AC=E5=9C=B0=E6=96=87=E4=BB=B6=E7=9A=84=E6=96=B9?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/misskey/misskey_adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 9a91d7300..91a39eba6 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -408,7 +408,7 @@ async def send_by_session( sem = asyncio.Semaphore(upload_concurrency) async def _upload_comp(comp) -> Optional[object]: - """组件上传函数:下载文件后本地上传""" + """组件上传函数:处理 URL(下载后上传)或本地文件(直接上传)""" from .misskey_utils import ( resolve_component_url_or_path, upload_local_with_retries, From dbe2baecefd083994ba64cb529c6059a1e766f22 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Wed, 15 Oct 2025 14:30:30 +0800 Subject: [PATCH 15/18] =?UTF-8?q?fix(misskey):=20=E4=BF=AE=E5=A4=8D=20sess?= =?UTF-8?q?ion=5Fid=20=E8=A7=A3=E6=9E=90=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E7=A1=AE=E4=BF=9D=E4=B8=8E=20user=5Fcache=20=E9=94=AE=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/misskey/misskey_adapter.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 91a39eba6..8c7f1b42f 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -556,12 +556,19 @@ async def _upload_comp(comp) -> Optional[object]: await self.api.send_message(payload) else: # 回退到发帖逻辑 + # 去掉 session_id 中的 note% 前缀以匹配 user_cache 的键格式 + user_id_for_cache = ( + session_id.split("%")[1] if "%" in session_id else session_id + ) visibility, visible_user_ids = resolve_message_visibility( - user_id=session_id, + user_id=user_id_for_cache, user_cache=self._user_cache, self_id=self.client_self_id, default_visibility=self.default_visibility, ) + logger.debug( + f"[Misskey] 解析可见性: visibility={visibility}, visible_user_ids={visible_user_ids}, session_id={session_id}, user_id_for_cache={user_id_for_cache}" + ) fields = self._extract_additional_fields(session, message_chain) if fallback_urls: From 61fd02f8e3956ccf8e2f9f73c5c8b2dc2aade688 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 16 Oct 2025 10:20:33 +0800 Subject: [PATCH 16/18] perf: streaming the file with a file object in FormData to reduce peak memory usage. Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../platform/sources/misskey/misskey_api.py | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 70b474d7e..c98891792 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -556,28 +556,25 @@ async def upload_file( # Read file bytes using thread executor to avoid adding new dependencies loop = asyncio.get_running_loop() - def _read_file_bytes(path: str) -> bytes: - with open(path, "rb") as f: - return f.read() - filename = name or file_path.split("/")[-1] if folder_id: form.add_field("folderId", str(folder_id)) try: - file_bytes = await loop.run_in_executor( - None, _read_file_bytes, file_path - ) + f = open(file_path, "rb") except FileNotFoundError as e: logger.error(f"[Misskey API] 本地文件不存在: {file_path}") raise APIError(f"File not found: {file_path}") from e - form.add_field("file", file_bytes, filename=filename) - async with self.session.post(url, data=form) as resp: - result = await self._process_response(resp, "drive/files/create") - file_id = FileIDExtractor.extract_file_id(result) - logger.debug(f"[Misskey API] 本地文件上传成功: {filename} -> {file_id}") - return {"id": file_id, "raw": result} + try: + form.add_field("file", f, filename=filename) + async with self.session.post(url, data=form) as resp: + result = await self._process_response(resp, "drive/files/create") + file_id = FileIDExtractor.extract_file_id(result) + logger.debug(f"[Misskey API] 本地文件上传成功: {filename} -> {file_id}") + return {"id": file_id, "raw": result} + finally: + f.close() except aiohttp.ClientError as e: logger.error(f"[Misskey API] 文件上传网络错误: {e}") raise APIConnectionError(f"Upload failed: {e}") from e From 74b6421a74f72af931930a660255f34064741ff4 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 16 Oct 2025 10:25:04 +0800 Subject: [PATCH 17/18] style: format debug log message for local file upload in MisskeyAPI --- astrbot/core/platform/sources/misskey/misskey_api.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index c98891792..759dd8bf1 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -571,7 +571,9 @@ async def upload_file( async with self.session.post(url, data=form) as resp: result = await self._process_response(resp, "drive/files/create") file_id = FileIDExtractor.extract_file_id(result) - logger.debug(f"[Misskey API] 本地文件上传成功: {filename} -> {file_id}") + logger.debug( + f"[Misskey API] 本地文件上传成功: {filename} -> {file_id}" + ) return {"id": file_id, "raw": result} finally: f.close() From 3a4cb55bb4d2360b5b36a7b90ddae3c1544e2bb0 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 16 Oct 2025 10:26:24 +0800 Subject: [PATCH 18/18] refactor: remove unnecessary thread executor for reading file bytes in MisskeyAPI --- astrbot/core/platform/sources/misskey/misskey_api.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 759dd8bf1..0c3334ef6 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -553,9 +553,6 @@ async def upload_file( form.add_field("i", self.access_token) try: - # Read file bytes using thread executor to avoid adding new dependencies - loop = asyncio.get_running_loop() - filename = name or file_path.split("/")[-1] if folder_id: form.add_field("folderId", str(folder_id))