From e1f2446ce34a8ae82a2f8d7183f710643a01b27b Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Tue, 16 Sep 2025 05:13:17 +0800 Subject: [PATCH 01/26] feat: add Misskey platform adapter --- astrbot/core/config/default.py | 17 + astrbot/core/platform/manager.py | 4 + .../sources/misskey/misskey_adapter.py | 328 ++++++++++++++++++ .../platform/sources/misskey/misskey_api.py | 243 +++++++++++++ .../platform/sources/misskey/misskey_event.py | 81 +++++ .../assets/images/platform_logos/misskey.png | Bin 0 -> 26431 bytes dashboard/src/views/PlatformPage.vue | 3 + 7 files changed, 676 insertions(+) create mode 100644 astrbot/core/platform/sources/misskey/misskey_adapter.py create mode 100644 astrbot/core/platform/sources/misskey/misskey_api.py create mode 100644 astrbot/core/platform/sources/misskey/misskey_event.py create mode 100644 dashboard/src/assets/images/platform_logos/misskey.png diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 7a4c2b6b0..880ac8de3 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -235,6 +235,13 @@ "discord_guild_id_for_debug": "", "discord_activity_name": "", }, + "Misskey": { + "id": "misskey", + "type": "misskey", + "enable": False, + "misskey_instance_url": "https://misskey.example", + "misskey_token": "", + }, "Slack": { "id": "slack", "type": "slack", @@ -336,6 +343,16 @@ "type": "string", "hint": "如果你的网络环境为中国大陆,请在 `其他配置` 处设置代理或更改 api_base。", }, + "misskey_instance_url": { + "description": "Misskey 实例 URL", + "type": "string", + "hint": "例如 https://misskey.example,填写你的 Misskey 实例地址", + }, + "misskey_token": { + "description": "Misskey Access Token", + "type": "string", + "hint": "用于 API 鉴权的 i(access token)", + }, "telegram_command_register": { "description": "Telegram 命令注册", "type": "bool", diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 1308c482a..f0d7c2e4a 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -90,6 +90,10 @@ async def load_platform(self, platform_config: dict): from .sources.discord.discord_platform_adapter import ( DiscordPlatformAdapter, # noqa: F401 ) + case "misskey": + from .sources.misskey.misskey_adapter import ( + MisskeyPlatformAdapter, # noqa: F401 + ) case "slack": from .sources.slack.slack_adapter import SlackAdapter # noqa: F401 case "satori": diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py new file mode 100644 index 000000000..7b0283e9d --- /dev/null +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -0,0 +1,328 @@ +import asyncio +from typing import Dict, Any, Optional, Awaitable + +from astrbot.api import logger +from astrbot.api.event import MessageChain +from astrbot.api.platform import ( + AstrBotMessage, + MessageMember, + MessageType, + Platform, + PlatformMetadata, + register_platform_adapter, +) +from astrbot.core.platform.astr_message_event import MessageSesion +import astrbot.api.message_components as Comp + +from .misskey_api import MisskeyAPI +from .misskey_event import MisskeyPlatformEvent + + +@register_platform_adapter("misskey", "Misskey 平台适配器") +class MisskeyPlatformAdapter(Platform): + def __init__( + self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue + ) -> None: + super().__init__(event_queue) + self.config = platform_config or {} + self.settings = platform_settings or {} + self.instance_url = self.config.get("misskey_instance_url", "") + self.access_token = self.config.get("misskey_token", "") + self.poll_interval = self.config.get("poll_interval", 5.0) + self.max_message_length = self.config.get("max_message_length", 3000) + + self.api: Optional[MisskeyAPI] = None + self._running = False + self.last_notification_id = None + self.client_self_id = "" + self._bot_username = "" + self._user_cache = {} + + def meta(self) -> PlatformMetadata: + return PlatformMetadata( + name="misskey", + description="Misskey 平台适配器", + default_config_tmpl=self.config, + ) + + async def run(self): + if not self.instance_url or not self.access_token: + logger.error("[Misskey] 配置不完整,无法启动") + return + + self.api = MisskeyAPI(self.instance_url, self.access_token) + self._running = True + + try: + user_info = await self.api.get_current_user() + self.client_self_id = str(user_info.get("id", "")) + self._bot_username = user_info.get("username", "") + logger.info( + f"[Misskey] 用户: {user_info.get('username', '')} (ID: {self.client_self_id})" + ) + except Exception as e: + logger.error(f"[Misskey] 获取用户信息失败: {e}") + return + + await self._start_polling() + + async def _start_polling(self): + if not self.api: + logger.error("[Misskey] API 客户端未初始化,无法开始轮询") + return + + is_first_poll = True + + try: + latest_notifications = await self.api.get_mentions(limit=1) + if latest_notifications: + self.last_notification_id = latest_notifications[0].get("id") + logger.debug(f"[Misskey] 起始通知 ID: {self.last_notification_id}") + except Exception as e: + logger.warning(f"[Misskey] 获取起始通知失败: {e}") + + while self._running: + if not self.api: + logger.error("[Misskey] API 客户端在轮询过程中变为 None") + break + + try: + notifications = await self.api.get_mentions( + limit=20, since_id=self.last_notification_id + ) + + if notifications: + if is_first_poll: + logger.debug(f"[Misskey] 跳过 {len(notifications)} 条历史通知") + is_first_poll = False + self.last_notification_id = notifications[0].get("id") + else: + notifications.reverse() + for notification in notifications: + await self._process_notification(notification) + self.last_notification_id = notifications[0].get("id") + else: + if is_first_poll: + is_first_poll = False + logger.info("[Misskey] 开始监听新消息") + + await asyncio.sleep(self.poll_interval) + + except Exception as e: + logger.error(f"[Misskey] 轮询错误: {e}") + await asyncio.sleep(5) + + async def _process_notification(self, notification: Dict[str, Any]): + notification_type = notification.get("type") + + if notification_type not in ["mention", "reply", "quote"]: + return + + note = notification.get("note") + if not note or not self._is_bot_mentioned(note): + return + + message = await self.convert_message(note) + event = MisskeyPlatformEvent( + message_str=message.message_str, + message_obj=message, + platform_meta=self.meta(), + session_id=message.session_id, + client=self.api, + ) + self.commit_event(event) + + def _is_bot_mentioned(self, note: Dict[str, Any]) -> bool: + text = note.get("text", "") + if not text: + return False + + bot_user_id = self.client_self_id + + if self._bot_username and f"@{self._bot_username}" in text: + return True + + reply_id = note.get("replyId") + mentions = note.get("mentions", []) + + if not reply_id: + return bot_user_id in [str(uid) for uid in mentions] + + reply = note.get("reply") + if reply and isinstance(reply, dict): + reply_user_id = str(reply.get("user", {}).get("id", "")) + if reply_user_id == str(bot_user_id): + return bool(self._bot_username and f"@{self._bot_username}" in text) + else: + return bot_user_id in [str(uid) for uid in mentions] + + return False + + async def send_by_session( + self, session: MessageSesion, message_chain: MessageChain + ) -> Awaitable[Any]: + if not self.api: + logger.error("[Misskey] API 客户端未初始化") + return super().send_by_session(session, message_chain) + + try: + # 首先解析session信息 + session_id = session.session_id + user_id = None + original_message_id = None + + if session_id and "|" in session_id: + parts = session_id.split("|", 1) + user_id = parts[0] + original_message_id = parts[1] if len(parts) > 1 else None + elif session_id: + user_id = session_id + + text = "" + has_at_user = False + + for component in message_chain.chain: + if isinstance(component, Comp.Plain): + text += component.text + elif isinstance(component, Comp.Image): + text += "[图片]" + elif isinstance(component, Comp.Node): + if component.content: + for node_comp in component.content: + if isinstance(node_comp, Comp.Plain): + text += node_comp.text + elif isinstance(node_comp, Comp.Image): + text += "[图片]" + else: + text += str(node_comp) + elif isinstance(component, Comp.At): + has_at_user = True + text += f"@{component.qq}" + else: + text += str(component) + + if not has_at_user and original_message_id and user_id: + user_info = self._user_cache.get(user_id) + if user_info: + username = user_info.get("username") + nickname = user_info.get("nickname") + if username: + text = f"@{username} {text}".strip() + elif nickname: + text = f"@{nickname} {text}".strip() + + if not text or not text.strip(): + logger.warning("[Misskey] 消息内容为空,跳过发送") + return await super().send_by_session(session, message_chain) + + if len(text) > self.max_message_length: + text = text[: self.max_message_length] + "..." + + visibility = "public" + visible_user_ids = None + if user_id: + user_info = self._user_cache.get(user_id) + if user_info: + original_visibility = user_info.get("visibility", "public") + if original_visibility == "specified": + visibility = "specified" + original_visible_users = user_info.get("visible_user_ids", []) + users_to_include = [user_id] + if self.client_self_id: + users_to_include.append(self.client_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 + + # 发送消息 + if original_message_id: + await self.api.create_note( + text, + visibility=visibility, + reply_id=original_message_id, + visible_user_ids=visible_user_ids, + ) + elif user_id and self._is_user_session(user_id): + await self.api.send_message(user_id, text) + else: + await self.api.create_note( + text, visibility=visibility, visible_user_ids=visible_user_ids + ) + + except Exception as e: + logger.error(f"[Misskey] 发送消息失败: {e}") + + return await super().send_by_session(session, message_chain) + + def _is_user_session(self, session_id: str) -> bool: + return 5 <= len(session_id) <= 64 and " " not in session_id + + async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: + message = AstrBotMessage() + message.raw_message = raw_data + message.message_str = raw_data.get("text", "") + message.message = [] + + sender = raw_data.get("user", {}) + sender_username = sender.get("username", "") + message.sender = MessageMember( + user_id=str(sender.get("id", "")), + nickname=sender.get("name", sender.get("username", "")), + ) + + user_id = message.sender.user_id + message_id = str(raw_data.get("id", "")) + message.session_id = f"{user_id}|{message_id}" + message.message_id = message_id + message.self_id = self.client_self_id + message.type = MessageType.FRIEND_MESSAGE + + self._user_cache[user_id] = { + "username": sender_username, + "nickname": message.sender.nickname, + "visibility": raw_data.get("visibility", "public"), + "visible_user_ids": raw_data.get("visibleUserIds", []), + } + + raw_text = message.message_str + if raw_text: + if self._bot_username: + at_mention = f"@{self._bot_username}" + if raw_text.startswith(at_mention): + message.message.append(Comp.At(qq=self.client_self_id)) + remaining_text = raw_text[len(at_mention) :].strip() + message.message_str = remaining_text + if remaining_text: + message.message.append(Comp.Plain(remaining_text)) + return message + + message.message.append(Comp.Plain(raw_text)) + + # 处理文件附件 + files = raw_data.get("files", []) + if files: + for file_info in files: + file_type = file_info.get("type", "").lower() + file_url = file_info.get("url", "") + file_name = file_info.get("name", "未知文件") + + if file_type.startswith("image/"): + # 图片文件 + message.message.append(Comp.Image(file_url)) + else: + # 其他文件类型,作为纯文本描述 + message.message.append(Comp.Plain(f"[文件: {file_name}]")) + + return message + + async def terminate(self): + logger.info("[Misskey] 终止适配器") + self._running = False + if self.api: + await self.api.close() + + def get_client(self) -> Any: + return self.api diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py new file mode 100644 index 000000000..18c48f43d --- /dev/null +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -0,0 +1,243 @@ +import json +from typing import Any, Optional + +try: + import aiohttp +except ImportError: + raise ImportError( + "aiohttp is required for Misskey API. Please install it with: pip install aiohttp" + ) + +try: + from loguru import logger # type: ignore +except ImportError: + try: + from astrbot import logger + except ImportError: + import logging + + logger = logging.getLogger(__name__) + +# Constants +API_MAX_RETRIES = 3 +HTTP_OK = 200 +HTTP_BAD_REQUEST = 400 +HTTP_UNAUTHORIZED = 401 +HTTP_FORBIDDEN = 403 +HTTP_TOO_MANY_REQUESTS = 429 + + +# Exceptions +class APIError(Exception): + pass + + +class APIBadRequestError(APIError): + pass + + +class APIConnectionError(APIError): + pass + + +class APIRateLimitError(APIError): + pass + + +class AuthenticationError(APIError): + pass + + +# HTTP Client Session Manager +class ClientSession: + session: aiohttp.ClientSession | None = None + _token: str | None = None + + @classmethod + def set_token(cls, token: str): + cls._token = token + + @classmethod + async def close_session(cls, silent: bool = False): + if cls.session is not None: + try: + await cls.session.close() + except Exception: + if not silent: + raise + finally: + cls.session = None + + @classmethod + def _ensure_session(cls): + if cls.session is None: + headers = {} + if cls._token: + headers["Authorization"] = f"Bearer {cls._token}" + cls.session = aiohttp.ClientSession(headers=headers) + + @classmethod + def post(cls, url, json=None): + cls._ensure_session() + if cls.session is None: + raise RuntimeError("Failed to create HTTP session") + return cls.session.post(url, json=json) + + +# Retry decorator for API requests +def retry_async(max_retries=3, retryable_exceptions=()): + def decorator(func): + async def wrapper(*args, **kwargs): + last_exc = None + for _ in range(max_retries): + try: + return await func(*args, **kwargs) + except retryable_exceptions as e: + last_exc = e + continue + if last_exc: + raise last_exc + + return wrapper + + return decorator + + +__all__ = ("MisskeyAPI",) + + +class MisskeyAPI: + """Misskey API 客户端,专为 AstrBot 适配器优化""" + + def __init__(self, instance_url: str, access_token: str): + self.instance_url = instance_url.rstrip("/") + self.access_token = access_token + self.transport = ClientSession + self.transport.set_token(access_token) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + return False + + async def close(self) -> None: + """关闭 API 客户端""" + await self.transport.close_session(silent=True) + logger.debug("Misskey API 客户端已关闭") + + @property + def session(self): + self.transport._ensure_session() + if self.transport.session is None: + raise RuntimeError("Failed to create HTTP session") + return self.transport.session + + def _handle_response_status(self, response, endpoint: str): + status = response.status + if status == HTTP_BAD_REQUEST: + logger.error(f"API 请求错误: {endpoint} (状态码: {status})") + raise APIBadRequestError() + if status == HTTP_UNAUTHORIZED: + logger.error(f"API 认证失败: {endpoint} (状态码: {status})") + raise AuthenticationError() + if status == HTTP_FORBIDDEN: + logger.error(f"API 权限不足: {endpoint} (状态码: {status})") + raise AuthenticationError() + if status == HTTP_TOO_MANY_REQUESTS: + logger.warning(f"API 频率限制: {endpoint} (状态码: {status})") + raise APIRateLimitError() + + async def _process_response(self, response, endpoint: str): + if response.status == HTTP_OK: + try: + result = await response.json() + logger.debug(f"Misskey API 请求成功: {endpoint}") + return result + except json.JSONDecodeError as e: + logger.error(f"响应不是有效的 JSON 格式: {e}") + raise APIConnectionError() + # 获取错误响应的详细内容 + try: + error_text = await response.text() + logger.error( + f"API 请求失败: {endpoint} - 状态码: {response.status}, 响应: {error_text}" + ) + except Exception: + logger.error( + f"API 请求失败: {endpoint} - 状态码: {response.status}, 无法读取错误响应" + ) + + self._handle_response_status(response, endpoint) + raise APIConnectionError() + + @retry_async( + max_retries=API_MAX_RETRIES, + retryable_exceptions=(APIConnectionError, APIRateLimitError), + ) + async def _make_request( + self, endpoint: str, data: Optional[dict[str, Any]] = None + ) -> dict[str, Any]: + """发送 API 请求""" + url = f"{self.instance_url}/api/{endpoint}" + payload = {"i": self.access_token} + if data: + payload.update(data) + try: + async with self.session.post(url, json=payload) as response: + return await self._process_response(response, endpoint) + except (aiohttp.ClientError, json.JSONDecodeError) as e: + logger.error(f"HTTP 请求错误: {e}") + raise APIConnectionError() from e + + async def create_note( + self, + text: str, + visibility: str = "public", + reply_id: Optional[str] = None, + visible_user_ids: Optional[list[str]] = None, + ) -> dict[str, Any]: + """创建帖子/回复""" + data: dict[str, Any] = {"text": text, "visibility": visibility} + if reply_id: + data["replyId"] = reply_id + if visible_user_ids and visibility == "specified": + data["visibleUserIds"] = visible_user_ids + + result = await self._make_request("notes/create", data) + note_id = result.get("createdNote", {}).get("id", "unknown") + logger.debug(f"Misskey 发帖成功,note_id: {note_id}") + return result + + 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} + ) + message_id = result.get("id", "unknown") + logger.debug(f"Misskey 聊天发送成功,message_id: {message_id}") + return result + + async def get_mentions( + self, limit: int = 10, since_id: Optional[str] = None + ) -> list[dict[str, Any]]: + """获取提及通知(包括回复和引用)""" + data: dict[str, Any] = {"limit": limit} + if since_id: + data["sinceId"] = since_id + data["includeTypes"] = ["mention", "reply", "quote"] + + result = await self._make_request("i/notifications", data) + # Misskey API 返回通知列表 + if isinstance(result, list): + return result + elif isinstance(result, dict) and "notifications" in result: + return result["notifications"] + else: + logger.warning(f"获取提及通知响应格式异常: {type(result)}") + return [] diff --git a/astrbot/core/platform/sources/misskey/misskey_event.py b/astrbot/core/platform/sources/misskey/misskey_event.py new file mode 100644 index 000000000..9cf4a6337 --- /dev/null +++ b/astrbot/core/platform/sources/misskey/misskey_event.py @@ -0,0 +1,81 @@ +from astrbot import logger +from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.api.platform import PlatformMetadata, AstrBotMessage +from astrbot.api.message_components import Plain +from astrbot.core.message.components import Node + + +class MisskeyPlatformEvent(AstrMessageEvent): + def __init__( + self, + message_str: str, + message_obj: AstrBotMessage, + platform_meta: PlatformMetadata, + session_id: str, + client, + ): + super().__init__(message_str, message_obj, platform_meta, session_id) + self.client = client + + async def send(self, message: MessageChain): + content = "" + for item in message.chain: + if isinstance(item, Plain): + content += item.text + elif isinstance(item, Node) and item.content: + for sub_item in item.content: + content += getattr(sub_item, "text", "") + else: + content += getattr(item, "text", "") + + 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: + user_data = raw_message.get("user", {}) + username = user_data.get("username", "") + if username and not content.startswith(f"@{username}"): + content = f"@{username} {content}" + + if original_message_id and hasattr(self.client, "create_note"): + visibility = "public" + visible_user_ids = None + if raw_message: + 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 = [sender_id] if sender_id else [] + 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 + + await self.client.create_note( + content, + reply_id=original_message_id, + visibility=visibility, + visible_user_ids=visible_user_ids, + ) + elif hasattr(self.client, "send_message") and self.session_id: + sid = str(self.session_id) + if 5 <= len(sid) <= 64 and " " not in sid: + logger.debug(f"[MisskeyEvent] 发送私信: {sid}") + await self.client.send_message(sid, content) + return + elif hasattr(self.client, "create_note"): + logger.debug("[MisskeyEvent] 创建新帖子") + await self.client.create_note(content) + + await super().send(message) + + except Exception as e: + logger.error(f"[MisskeyEvent] 发送失败: {e}") diff --git a/dashboard/src/assets/images/platform_logos/misskey.png b/dashboard/src/assets/images/platform_logos/misskey.png new file mode 100644 index 0000000000000000000000000000000000000000..396209aee38c6f367ae2a0cd04a48e9af11ca9a5 GIT binary patch literal 26431 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 literal 0 HcmV?d00001 diff --git a/dashboard/src/views/PlatformPage.vue b/dashboard/src/views/PlatformPage.vue index a1cf69068..54e0ff57d 100644 --- a/dashboard/src/views/PlatformPage.vue +++ b/dashboard/src/views/PlatformPage.vue @@ -313,6 +313,8 @@ export default { return new URL('@/assets/images/platform_logos/vocechat.png', import.meta.url).href } else if (name === 'satori' || name === 'Satori') { return new URL('@/assets/images/platform_logos/satori.png', import.meta.url).href + } else if (name === 'misskey') { + return new URL('@/assets/images/platform_logos/misskey.png', import.meta.url).href } }, @@ -332,6 +334,7 @@ export default { "kook": "https://docs.astrbot.app/deploy/platform/kook.html", "vocechat": "https://docs.astrbot.app/deploy/platform/vocechat.html", "satori": "https://docs.astrbot.app/deploy/platform/satori/llonebot.html", + "misskey": "https://docs.astrbot.app/deploy/platform/misskey.html", } return tutorial_map[platform_type] || "https://docs.astrbot.app"; }, From 54444b5eaede6e1f528cb2d5312261197048aee4 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Tue, 16 Sep 2025 05:34:06 +0800 Subject: [PATCH 02/26] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Misskey=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E9=A1=B9=E7=9A=84=E5=A4=A7=E5=B0=8F=E5=86=99?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 880ac8de3..d4371456d 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -235,7 +235,7 @@ "discord_guild_id_for_debug": "", "discord_activity_name": "", }, - "Misskey": { + "misskey": { "id": "misskey", "type": "misskey", "enable": False, From 8fb862c03bc4e5ce1582cb60d997b4ae90608956 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Tue, 16 Sep 2025 05:43:15 +0800 Subject: [PATCH 03/26] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E9=93=BE=E5=BA=8F=E5=88=97=E5=8C=96=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=92=8C=E5=8F=AF=E8=A7=81=E6=80=A7=E8=A7=A3=E6=9E=90=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sources/misskey/misskey_adapter.py | 122 +++++++---- .../platform/sources/misskey/misskey_api.py | 12 +- .../platform/sources/misskey/misskey_event.py | 36 +++- .../core/platform/sources/misskey/utils.py | 200 ++++++++++++++++++ 4 files changed, 308 insertions(+), 62 deletions(-) create mode 100644 astrbot/core/platform/sources/misskey/utils.py diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 7b0283e9d..2108b7b9b 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -18,6 +18,57 @@ from .misskey_event import MisskeyPlatformEvent +def _serialize_message_chain(chain): + """将消息链序列化为文本字符串""" + text_parts = [] + has_at = False + + for component in chain: + if isinstance(component, Comp.Plain): + text_parts.append(component.text) + elif isinstance(component, Comp.Image): + text_parts.append("[图片]") + elif isinstance(component, Comp.Node): + if component.content: + for node_comp in component.content: + if isinstance(node_comp, Comp.Plain): + text_parts.append(node_comp.text) + elif isinstance(node_comp, Comp.Image): + text_parts.append("[图片]") + else: + text_parts.append(str(node_comp)) + elif isinstance(component, Comp.At): + has_at = True + text_parts.append(f"@{component.qq}") + else: + text_parts.append(str(component)) + + return "".join(text_parts), has_at + + +def _resolve_visibility(user_id, user_cache, self_id): + """解析 Misskey 消息的可见性设置""" + visibility = "public" + visible_user_ids = None + + if user_id: + user_info = user_cache.get(user_id) + if user_info: + original_visibility = user_info.get("visibility", "public") + if original_visibility == "specified": + visibility = "specified" + original_visible_users = user_info.get("visible_user_ids", []) + users_to_include = [user_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 + + @register_platform_adapter("misskey", "Misskey 平台适配器") class MisskeyPlatformAdapter(Platform): def __init__( @@ -71,6 +122,12 @@ async def _start_polling(self): logger.error("[Misskey] API 客户端未初始化,无法开始轮询") return + # 指数退避参数 + initial_backoff = 1 # 秒 + max_backoff = 60 # 秒 + backoff_multiplier = 2 + current_backoff = initial_backoff + is_first_poll = True try: @@ -91,6 +148,9 @@ async def _start_polling(self): limit=20, since_id=self.last_notification_id ) + # 重置退避时间 + current_backoff = initial_backoff + if notifications: if is_first_poll: logger.debug(f"[Misskey] 跳过 {len(notifications)} 条历史通知") @@ -101,16 +161,19 @@ async def _start_polling(self): for notification in notifications: await self._process_notification(notification) self.last_notification_id = notifications[0].get("id") - else: - if is_first_poll: - is_first_poll = False - logger.info("[Misskey] 开始监听新消息") + elif is_first_poll: + is_first_poll = False + logger.info("[Misskey] 开始监听新消息") await asyncio.sleep(self.poll_interval) except Exception as e: - logger.error(f"[Misskey] 轮询错误: {e}") - await asyncio.sleep(5) + logger.warning(f"[Misskey] 获取通知失败: {e}") + logger.info( + f"[Misskey] 轮询将在 {current_backoff} 秒后重试(指数退避)" + ) + await asyncio.sleep(current_backoff) + current_backoff = min(current_backoff * backoff_multiplier, max_backoff) async def _process_notification(self, notification: Dict[str, Any]): notification_type = notification.get("type") @@ -178,29 +241,9 @@ async def send_by_session( elif session_id: user_id = session_id - text = "" - has_at_user = False - - for component in message_chain.chain: - if isinstance(component, Comp.Plain): - text += component.text - elif isinstance(component, Comp.Image): - text += "[图片]" - elif isinstance(component, Comp.Node): - if component.content: - for node_comp in component.content: - if isinstance(node_comp, Comp.Plain): - text += node_comp.text - elif isinstance(node_comp, Comp.Image): - text += "[图片]" - else: - text += str(node_comp) - elif isinstance(component, Comp.At): - has_at_user = True - text += f"@{component.qq}" - else: - text += str(component) + text, has_at_user = _serialize_message_chain(message_chain.chain) + # 如果没有@用户并且是回复消息,添加@用户 if not has_at_user and original_message_id and user_id: user_info = self._user_cache.get(user_id) if user_info: @@ -218,24 +261,11 @@ async def send_by_session( if len(text) > self.max_message_length: text = text[: self.max_message_length] + "..." - visibility = "public" - visible_user_ids = None - if user_id: - user_info = self._user_cache.get(user_id) - if user_info: - original_visibility = user_info.get("visibility", "public") - if original_visibility == "specified": - visibility = "specified" - original_visible_users = user_info.get("visible_user_ids", []) - users_to_include = [user_id] - if self.client_self_id: - users_to_include.append(self.client_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 + visibility, visible_user_ids = _resolve_visibility( + user_id=user_id, + user_cache=self._user_cache, + self_id=self.client_self_id, + ) # 发送消息 if original_message_id: diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 18c48f43d..cf409425c 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -3,10 +3,10 @@ try: import aiohttp -except ImportError: +except ImportError as e: raise ImportError( "aiohttp is required for Misskey API. Please install it with: pip install aiohttp" - ) + ) from e try: from loguru import logger # type: ignore @@ -157,7 +157,7 @@ async def _process_response(self, response, endpoint: str): return result except json.JSONDecodeError as e: logger.error(f"响应不是有效的 JSON 格式: {e}") - raise APIConnectionError() + raise APIConnectionError() from e # 获取错误响应的详细内容 try: error_text = await response.text() @@ -183,7 +183,7 @@ async def _make_request( url = f"{self.instance_url}/api/{endpoint}" payload = {"i": self.access_token} if data: - payload.update(data) + payload |= data try: async with self.session.post(url, json=payload) as response: return await self._process_response(response, endpoint) @@ -239,5 +239,7 @@ async def get_mentions( elif isinstance(result, dict) and "notifications" in result: return result["notifications"] else: - logger.warning(f"获取提及通知响应格式异常: {type(result)}") + logger.warning( + f"获取提及通知响应格式异常: {type(result)}, 响应内容: {result}" + ) return [] diff --git a/astrbot/core/platform/sources/misskey/misskey_event.py b/astrbot/core/platform/sources/misskey/misskey_event.py index 9cf4a6337..5dc6feded 100644 --- a/astrbot/core/platform/sources/misskey/misskey_event.py +++ b/astrbot/core/platform/sources/misskey/misskey_event.py @@ -2,7 +2,27 @@ from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.platform import PlatformMetadata, AstrBotMessage from astrbot.api.message_components import Plain -from astrbot.core.message.components import Node + + +def _serialize_message_chain_simple(chain): + """简单的消息链序列化""" + content = "" + has_at = False + + for item in chain: + if isinstance(item, Plain): + content += item.text + elif hasattr(item, "content") and item.content: + for sub_item in item.content: + content += getattr(sub_item, "text", "") + else: + text = getattr(item, "text", "") + if text: + content += text + if "@" in text: + has_at = True + + return content, has_at class MisskeyPlatformEvent(AstrMessageEvent): @@ -18,15 +38,7 @@ def __init__( self.client = client async def send(self, message: MessageChain): - content = "" - for item in message.chain: - if isinstance(item, Plain): - content += item.text - elif isinstance(item, Node) and item.content: - for sub_item in item.content: - content += getattr(sub_item, "text", "") - else: - content += getattr(item, "text", "") + content, has_at = _serialize_message_chain_simple(message.chain) if not content: logger.debug("[MisskeyEvent] 内容为空,跳过发送") @@ -36,15 +48,17 @@ async def send(self, message: MessageChain): original_message_id = getattr(self.message_obj, "message_id", None) raw_message = getattr(self.message_obj, "raw_message", {}) - if raw_message: + if raw_message and not has_at: user_data = raw_message.get("user", {}) username = user_data.get("username", "") if username and not content.startswith(f"@{username}"): content = f"@{username} {content}" if original_message_id and hasattr(self.client, "create_note"): + # 简化的可见性处理 visibility = "public" visible_user_ids = None + if raw_message: original_visibility = raw_message.get("visibility", "public") if original_visibility == "specified": diff --git a/astrbot/core/platform/sources/misskey/utils.py b/astrbot/core/platform/sources/misskey/utils.py new file mode 100644 index 000000000..861078653 --- /dev/null +++ b/astrbot/core/platform/sources/misskey/utils.py @@ -0,0 +1,200 @@ +""""""import asyncio + +Misskey 平台适配器专用工具函数 + +"""Misskey 平台适配器专用工具函数from typing import Callable + +from typing import Any, Dict, Iterable, List, Optional, Tuple + +""" + +from astrbot.api.message_components import Plain, Image, At + +from astrbot.core.message.components import Nodefrom typing import Any, Dict, Iterable, List, Optional, Tuple + + + +def retry_async(max_retries=3, retryable_exceptions=()): + +def serialize_message_chain(chain: Iterable[Any]) -> Tuple[str, bool]: + + """from astrbot.api.message_components import Plain, Image, At def decorator(func: Callable): + + 将消息链序列化为文本字符串。 + + from astrbot.core.message.components import Node async def wrapper(*args, **kwargs): + + Args: + + chain: 消息链组件列表 last_exc = None + + + + Returns: for _ in range(max_retries): + + Tuple[str, bool]: (文本内容, 是否包含@用户) + + """def serialize_message_chain(chain: Iterable[Any]) -> Tuple[str, bool]: try: + + text_parts = [] + + has_at = False """ return await func(*args, **kwargs) + + + + for component in chain: 将消息链序列化为文本字符串。 except retryable_exceptions as e: + + if isinstance(component, Plain): + + text_parts.append(component.text) last_exc = e + + elif isinstance(component, Image): + + text_parts.append("[图片]") Args: await asyncio.sleep(0.1) + + elif isinstance(component, Node): + + if component.content: chain: 消息链组件列表 continue + + for node_comp in component.content: + + if isinstance(node_comp, Plain): if last_exc: + + text_parts.append(node_comp.text) + + elif isinstance(node_comp, Image): Returns: raise last_exc + + text_parts.append("[图片]") + + else: Tuple[str, bool]: (文本内容, 是否包含@用户) + + text_parts.append(str(node_comp)) + + elif isinstance(component, At): """ return wrapper + + has_at = True + + text_parts.append(f"@{component.qq}") text_parts = [] + + else: + + text_parts.append(str(component)) has_at = False return decorator + + + + return "".join(text_parts), has_at + + for component in chain: + + if isinstance(component, Plain): + +def resolve_visibility( text_parts.append(component.text) + + user_id: Optional[str], elif isinstance(component, Image): + + user_cache: Dict[str, Any], text_parts.append("[图片]") + + self_id: str elif isinstance(component, Node): + +) -> Tuple[str, Optional[List[str]]]: if component.content: + + """ for node_comp in component.content: + + 解析 Misskey 消息的可见性设置。 if isinstance(node_comp, Plain): + + text_parts.append(node_comp.text) + + Args: elif isinstance(node_comp, Image): + + user_id: 用户 ID text_parts.append("[图片]") + + user_cache: 用户缓存信息 else: + + self_id: 机器人自己的用户 ID text_parts.append(str(node_comp)) + + elif isinstance(component, At): + + Returns: has_at = True + + Tuple[str, Optional[List[str]]]: (可见性级别, 可见用户ID列表) text_parts.append(f"@{component.qq}") + + """ else: + + visibility = "public" text_parts.append(str(component)) + + visible_user_ids = None + + return "".join(text_parts), has_at + + if user_id: + + user_info = user_cache.get(user_id) + + if user_info:def parse_session_id(username: str, host: str, user_id: str, platform_name: str) -> str: + + original_visibility = user_info.get("visibility", "public") """ + + if original_visibility == "specified": 构建标准化的会话 ID。 + + visibility = "specified" + + original_visible_users = user_info.get("visible_user_ids", []) Args: + + users_to_include = [user_id] username: 用户名 + + if self_id: host: 主机名 + + users_to_include.append(self_id) user_id: 用户 ID + + visible_user_ids = list( platform_name: 平台名称 + + set(original_visible_users + users_to_include) + + ) Returns: + + visible_user_ids = [uid for uid in visible_user_ids if uid] str: 格式化的会话 ID + + else: """ + + visibility = original_visibility return f"{platform_name}:{username}@{host}:{user_id}" + + + + return visibility, visible_user_ids +def resolve_visibility( + user_id: Optional[str], + user_cache: Dict[str, Any], + self_id: str +) -> Tuple[str, Optional[List[str]]]: + """ + 解析 Misskey 消息的可见性设置。 + + Args: + user_id: 用户 ID + user_cache: 用户缓存信息 + self_id: 机器人自己的用户 ID + + Returns: + Tuple[str, Optional[List[str]]]: (可见性级别, 可见用户ID列表) + """ + visibility = "public" + visible_user_ids = None + + if user_id: + user_info = user_cache.get(user_id) + if user_info: + original_visibility = user_info.get("visibility", "public") + if original_visibility == "specified": + visibility = "specified" + original_visible_users = user_info.get("visible_user_ids", []) + users_to_include = [user_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 \ No newline at end of file From 2737904e7c4a4186ce8c722d7bc1568116948c84 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Tue, 16 Sep 2025 06:23:41 +0800 Subject: [PATCH 04/26] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=E6=8D=9F?= =?UTF-8?q?=E5=9D=8F=E7=9A=84=20Misskey=20=E5=B9=B3=E5=8F=B0=E9=80=82?= =?UTF-8?q?=E9=85=8D=E5=99=A8=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0=E6=96=87?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/platform/sources/misskey/utils.py | 200 ------------------ 1 file changed, 200 deletions(-) delete mode 100644 astrbot/core/platform/sources/misskey/utils.py diff --git a/astrbot/core/platform/sources/misskey/utils.py b/astrbot/core/platform/sources/misskey/utils.py deleted file mode 100644 index 861078653..000000000 --- a/astrbot/core/platform/sources/misskey/utils.py +++ /dev/null @@ -1,200 +0,0 @@ -""""""import asyncio - -Misskey 平台适配器专用工具函数 - -"""Misskey 平台适配器专用工具函数from typing import Callable - -from typing import Any, Dict, Iterable, List, Optional, Tuple - -""" - -from astrbot.api.message_components import Plain, Image, At - -from astrbot.core.message.components import Nodefrom typing import Any, Dict, Iterable, List, Optional, Tuple - - - -def retry_async(max_retries=3, retryable_exceptions=()): - -def serialize_message_chain(chain: Iterable[Any]) -> Tuple[str, bool]: - - """from astrbot.api.message_components import Plain, Image, At def decorator(func: Callable): - - 将消息链序列化为文本字符串。 - - from astrbot.core.message.components import Node async def wrapper(*args, **kwargs): - - Args: - - chain: 消息链组件列表 last_exc = None - - - - Returns: for _ in range(max_retries): - - Tuple[str, bool]: (文本内容, 是否包含@用户) - - """def serialize_message_chain(chain: Iterable[Any]) -> Tuple[str, bool]: try: - - text_parts = [] - - has_at = False """ return await func(*args, **kwargs) - - - - for component in chain: 将消息链序列化为文本字符串。 except retryable_exceptions as e: - - if isinstance(component, Plain): - - text_parts.append(component.text) last_exc = e - - elif isinstance(component, Image): - - text_parts.append("[图片]") Args: await asyncio.sleep(0.1) - - elif isinstance(component, Node): - - if component.content: chain: 消息链组件列表 continue - - for node_comp in component.content: - - if isinstance(node_comp, Plain): if last_exc: - - text_parts.append(node_comp.text) - - elif isinstance(node_comp, Image): Returns: raise last_exc - - text_parts.append("[图片]") - - else: Tuple[str, bool]: (文本内容, 是否包含@用户) - - text_parts.append(str(node_comp)) - - elif isinstance(component, At): """ return wrapper - - has_at = True - - text_parts.append(f"@{component.qq}") text_parts = [] - - else: - - text_parts.append(str(component)) has_at = False return decorator - - - - return "".join(text_parts), has_at - - for component in chain: - - if isinstance(component, Plain): - -def resolve_visibility( text_parts.append(component.text) - - user_id: Optional[str], elif isinstance(component, Image): - - user_cache: Dict[str, Any], text_parts.append("[图片]") - - self_id: str elif isinstance(component, Node): - -) -> Tuple[str, Optional[List[str]]]: if component.content: - - """ for node_comp in component.content: - - 解析 Misskey 消息的可见性设置。 if isinstance(node_comp, Plain): - - text_parts.append(node_comp.text) - - Args: elif isinstance(node_comp, Image): - - user_id: 用户 ID text_parts.append("[图片]") - - user_cache: 用户缓存信息 else: - - self_id: 机器人自己的用户 ID text_parts.append(str(node_comp)) - - elif isinstance(component, At): - - Returns: has_at = True - - Tuple[str, Optional[List[str]]]: (可见性级别, 可见用户ID列表) text_parts.append(f"@{component.qq}") - - """ else: - - visibility = "public" text_parts.append(str(component)) - - visible_user_ids = None - - return "".join(text_parts), has_at - - if user_id: - - user_info = user_cache.get(user_id) - - if user_info:def parse_session_id(username: str, host: str, user_id: str, platform_name: str) -> str: - - original_visibility = user_info.get("visibility", "public") """ - - if original_visibility == "specified": 构建标准化的会话 ID。 - - visibility = "specified" - - original_visible_users = user_info.get("visible_user_ids", []) Args: - - users_to_include = [user_id] username: 用户名 - - if self_id: host: 主机名 - - users_to_include.append(self_id) user_id: 用户 ID - - visible_user_ids = list( platform_name: 平台名称 - - set(original_visible_users + users_to_include) - - ) Returns: - - visible_user_ids = [uid for uid in visible_user_ids if uid] str: 格式化的会话 ID - - else: """ - - visibility = original_visibility return f"{platform_name}:{username}@{host}:{user_id}" - - - - return visibility, visible_user_ids -def resolve_visibility( - user_id: Optional[str], - user_cache: Dict[str, Any], - self_id: str -) -> Tuple[str, Optional[List[str]]]: - """ - 解析 Misskey 消息的可见性设置。 - - Args: - user_id: 用户 ID - user_cache: 用户缓存信息 - self_id: 机器人自己的用户 ID - - Returns: - Tuple[str, Optional[List[str]]]: (可见性级别, 可见用户ID列表) - """ - visibility = "public" - visible_user_ids = None - - if user_id: - user_info = user_cache.get(user_id) - if user_info: - original_visibility = user_info.get("visibility", "public") - if original_visibility == "specified": - visibility = "specified" - original_visible_users = user_info.get("visible_user_ids", []) - users_to_include = [user_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 \ No newline at end of file From 7041dfe589054b3c1cfaaa18a4fd4ab22edf6245 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Tue, 16 Sep 2025 14:02:13 +0800 Subject: [PATCH 05/26] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=20Misskey=20?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E9=80=82=E9=85=8D=E5=99=A8=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E6=8F=8F=E8=BF=B0=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index d4371456d..6bbb3a004 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -346,12 +346,12 @@ "misskey_instance_url": { "description": "Misskey 实例 URL", "type": "string", - "hint": "例如 https://misskey.example,填写你的 Misskey 实例地址", + "hint": "例如 https://misskey.example,填写 Bot 账号所在的 Misskey 实例地址", }, "misskey_token": { "description": "Misskey Access Token", "type": "string", - "hint": "用于 API 鉴权的 i(access token)", + "hint": "连接服务设置生成的 API 鉴权访问令牌(Access token)", }, "telegram_command_register": { "description": "Telegram 命令注册", From 83808cef0378b110345aa2efb9275073119a075f Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Tue, 16 Sep 2025 08:23:57 +0000 Subject: [PATCH 06/26] =?UTF-8?q?feat:=20Misskey=20=E5=8D=95=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=BF=9E=E7=BB=AD=E4=B8=8A=E4=B8=8B=E6=96=87=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=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 | 28 +++++-------------- .../core/star/filter/platform_adapter_type.py | 3 ++ 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 2108b7b9b..52072d263 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -229,22 +229,14 @@ async def send_by_session( return super().send_by_session(session, message_chain) try: - # 首先解析session信息 + # 解析session信息 - 现在session_id就是用户ID session_id = session.session_id - user_id = None - original_message_id = None - - if session_id and "|" in session_id: - parts = session_id.split("|", 1) - user_id = parts[0] - original_message_id = parts[1] if len(parts) > 1 else None - elif session_id: - user_id = session_id + user_id = session_id # 直接使用session_id作为用户ID text, has_at_user = _serialize_message_chain(message_chain.chain) - # 如果没有@用户并且是回复消息,添加@用户 - if not has_at_user and original_message_id and user_id: + # 如果没有@用户,添加@用户 + if not has_at_user and user_id: user_info = self._user_cache.get(user_id) if user_info: username = user_info.get("username") @@ -268,14 +260,7 @@ async def send_by_session( ) # 发送消息 - if original_message_id: - await self.api.create_note( - text, - visibility=visibility, - reply_id=original_message_id, - visible_user_ids=visible_user_ids, - ) - elif user_id and self._is_user_session(user_id): + if user_id and self._is_user_session(user_id): await self.api.send_message(user_id, text) else: await self.api.create_note( @@ -305,7 +290,8 @@ async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: user_id = message.sender.user_id message_id = str(raw_data.get("id", "")) - message.session_id = f"{user_id}|{message_id}" + # 使用 AstrBot 标准的会话ID格式: platform_name:message_type:session_id + message.session_id = user_id # 使用用户ID作为基础session_id message.message_id = message_id message.self_id = self.client_self_id message.type = MessageType.FRIEND_MESSAGE diff --git a/astrbot/core/star/filter/platform_adapter_type.py b/astrbot/core/star/filter/platform_adapter_type.py index 1634001f3..ec422e52e 100644 --- a/astrbot/core/star/filter/platform_adapter_type.py +++ b/astrbot/core/star/filter/platform_adapter_type.py @@ -19,6 +19,7 @@ class PlatformAdapterType(enum.Flag): VOCECHAT = enum.auto() WEIXIN_OFFICIAL_ACCOUNT = enum.auto() SATORI = enum.auto() + MISSKEY = enum.auto() ALL = ( AIOCQHTTP | QQOFFICIAL @@ -33,6 +34,7 @@ class PlatformAdapterType(enum.Flag): | VOCECHAT | WEIXIN_OFFICIAL_ACCOUNT | SATORI + | MISSKEY ) @@ -50,6 +52,7 @@ class PlatformAdapterType(enum.Flag): "vocechat": PlatformAdapterType.VOCECHAT, "weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT, "satori": PlatformAdapterType.SATORI, + "misskey": PlatformAdapterType.MISSKEY, } From 837e354d21e771628ceaee655d425f877ceff569 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Tue, 16 Sep 2025 08:27:53 +0000 Subject: [PATCH 07/26] =?UTF-8?q?feat:=20=E4=B8=BA=20Astrbot=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20Misskey=20=E5=B9=B3=E5=8F=B0=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8=E7=9A=84=20ID=20=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/misskey/misskey_adapter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 52072d263..c5a7200fd 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -93,6 +93,7 @@ def meta(self) -> PlatformMetadata: return PlatformMetadata( name="misskey", description="Misskey 平台适配器", + id=self.config.get("id"), default_config_tmpl=self.config, ) From abda76adc196143cfff350afabd5160a175f288f Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Tue, 16 Sep 2025 09:17:36 +0000 Subject: [PATCH 08/26] =?UTF-8?q?feat:=20=E9=87=8D=E6=9E=84=20Misskey=20?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E9=80=82=E9=85=8D=E5=99=A8=EF=BC=8C=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E9=80=9A=E7=94=A8=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=B9=B6=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 | 172 +++++++---------- .../platform/sources/misskey/misskey_api.py | 163 +++++++---------- .../platform/sources/misskey/misskey_event.py | 81 +++----- .../platform/sources/misskey/misskey_utils.py | 173 ++++++++++++++++++ 4 files changed, 336 insertions(+), 253 deletions(-) create mode 100644 astrbot/core/platform/sources/misskey/misskey_utils.py diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index c5a7200fd..c89559841 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -16,57 +16,12 @@ from .misskey_api import MisskeyAPI from .misskey_event import MisskeyPlatformEvent - - -def _serialize_message_chain(chain): - """将消息链序列化为文本字符串""" - text_parts = [] - has_at = False - - for component in chain: - if isinstance(component, Comp.Plain): - text_parts.append(component.text) - elif isinstance(component, Comp.Image): - text_parts.append("[图片]") - elif isinstance(component, Comp.Node): - if component.content: - for node_comp in component.content: - if isinstance(node_comp, Comp.Plain): - text_parts.append(node_comp.text) - elif isinstance(node_comp, Comp.Image): - text_parts.append("[图片]") - else: - text_parts.append(str(node_comp)) - elif isinstance(component, Comp.At): - has_at = True - text_parts.append(f"@{component.qq}") - else: - text_parts.append(str(component)) - - return "".join(text_parts), has_at - - -def _resolve_visibility(user_id, user_cache, self_id): - """解析 Misskey 消息的可见性设置""" - visibility = "public" - visible_user_ids = None - - if user_id: - user_info = user_cache.get(user_id) - if user_info: - original_visibility = user_info.get("visibility", "public") - if original_visibility == "specified": - visibility = "specified" - original_visible_users = user_info.get("visible_user_ids", []) - users_to_include = [user_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 +from .misskey_utils import ( + serialize_message_chain, + resolve_message_visibility, + is_valid_user_session_id, + add_at_mention_if_needed, +) @register_platform_adapter("misskey", "Misskey 平台适配器") @@ -93,7 +48,7 @@ def meta(self) -> PlatformMetadata: return PlatformMetadata( name="misskey", description="Misskey 平台适配器", - id=self.config.get("id"), + id=self.config.get("id", "misskey"), # 提供默认值防止 None default_config_tmpl=self.config, ) @@ -114,23 +69,25 @@ async def run(self): ) except Exception as e: logger.error(f"[Misskey] 获取用户信息失败: {e}") + self._running = False return await self._start_polling() async def _start_polling(self): + """开始轮询通知""" if not self.api: logger.error("[Misskey] API 客户端未初始化,无法开始轮询") return # 指数退避参数 - initial_backoff = 1 # 秒 - max_backoff = 60 # 秒 + initial_backoff = 1 + max_backoff = 60 backoff_multiplier = 2 current_backoff = initial_backoff - is_first_poll = True + # 获取起始通知ID try: latest_notifications = await self.api.get_mentions(limit=1) if latest_notifications: @@ -139,6 +96,7 @@ async def _start_polling(self): except Exception as e: logger.warning(f"[Misskey] 获取起始通知失败: {e}") + # 主轮询循环 while self._running: if not self.api: logger.error("[Misskey] API 客户端在轮询过程中变为 None") @@ -152,49 +110,57 @@ async def _start_polling(self): # 重置退避时间 current_backoff = initial_backoff - if notifications: - if is_first_poll: - logger.debug(f"[Misskey] 跳过 {len(notifications)} 条历史通知") - is_first_poll = False - self.last_notification_id = notifications[0].get("id") - else: - notifications.reverse() - for notification in notifications: - await self._process_notification(notification) - self.last_notification_id = notifications[0].get("id") - elif is_first_poll: - is_first_poll = False - logger.info("[Misskey] 开始监听新消息") + await self._process_notifications(notifications, is_first_poll) + is_first_poll = False await asyncio.sleep(self.poll_interval) except Exception as e: - logger.warning(f"[Misskey] 获取通知失败: {e}") - logger.info( - f"[Misskey] 轮询将在 {current_backoff} 秒后重试(指数退避)" - ) + logger.warning(f"[Misskey] 轮询失败: {e}") + logger.info(f"[Misskey] 将在 {current_backoff} 秒后重试") await asyncio.sleep(current_backoff) current_backoff = min(current_backoff * backoff_multiplier, max_backoff) - async def _process_notification(self, notification: Dict[str, Any]): - notification_type = notification.get("type") - - if notification_type not in ["mention", "reply", "quote"]: + async def _process_notifications(self, notifications: list, is_first_poll: bool): + """处理通知列表""" + if not notifications: + if is_first_poll: + logger.info("[Misskey] 开始监听新消息") return - note = notification.get("note") - if not note or not self._is_bot_mentioned(note): - return + if is_first_poll: + logger.debug(f"[Misskey] 跳过 {len(notifications)} 条历史通知") + self.last_notification_id = notifications[0].get("id") + else: + # 按时间顺序处理通知 + notifications.reverse() + for notification in notifications: + await self._process_notification(notification) + self.last_notification_id = notifications[0].get("id") - message = await self.convert_message(note) - event = MisskeyPlatformEvent( - message_str=message.message_str, - message_obj=message, - platform_meta=self.meta(), - session_id=message.session_id, - client=self.api, - ) - self.commit_event(event) + async def _process_notification(self, notification: Dict[str, Any]): + """处理单个通知""" + try: + notification_type = notification.get("type") + if notification_type not in ["mention", "reply", "quote"]: + return + + note = notification.get("note") + if not note or not self._is_bot_mentioned(note): + return + + message = await self.convert_message(note) + event = MisskeyPlatformEvent( + message_str=message.message_str, + message_obj=message, + platform_meta=self.meta(), + session_id=message.session_id, + client=self.api, + ) + self.commit_event(event) + + except Exception as e: + logger.error(f"[Misskey] 处理通知失败: {e}") def _is_bot_mentioned(self, note: Dict[str, Any]) -> bool: text = note.get("text", "") @@ -225,28 +191,22 @@ def _is_bot_mentioned(self, note: Dict[str, Any]) -> bool: async def send_by_session( self, session: MessageSesion, message_chain: MessageChain ) -> Awaitable[Any]: + """通过会话发送消息""" if not self.api: logger.error("[Misskey] API 客户端未初始化") - return super().send_by_session(session, message_chain) + return await super().send_by_session(session, message_chain) try: # 解析session信息 - 现在session_id就是用户ID - session_id = session.session_id - user_id = session_id # 直接使用session_id作为用户ID + user_id = session.session_id + text, has_at_user = serialize_message_chain(message_chain.chain) - text, has_at_user = _serialize_message_chain(message_chain.chain) - - # 如果没有@用户,添加@用户 + # 添加@用户提及 if not has_at_user and user_id: user_info = self._user_cache.get(user_id) - if user_info: - username = user_info.get("username") - nickname = user_info.get("nickname") - if username: - text = f"@{username} {text}".strip() - elif nickname: - text = f"@{nickname} {text}".strip() + text = add_at_mention_if_needed(text, user_info, has_at_user) + # 验证和处理消息内容 if not text or not text.strip(): logger.warning("[Misskey] 消息内容为空,跳过发送") return await super().send_by_session(session, message_chain) @@ -254,28 +214,28 @@ async def send_by_session( if len(text) > self.max_message_length: text = text[: self.max_message_length] + "..." - visibility, visible_user_ids = _resolve_visibility( + # 确定可见性设置 + visibility, visible_user_ids = resolve_message_visibility( user_id=user_id, user_cache=self._user_cache, self_id=self.client_self_id, ) # 发送消息 - if user_id and self._is_user_session(user_id): + if user_id and is_valid_user_session_id(user_id): await self.api.send_message(user_id, text) + logger.debug(f"[Misskey] 私信发送成功: {user_id}") else: await self.api.create_note( text, visibility=visibility, visible_user_ids=visible_user_ids ) + logger.debug(f"[Misskey] 帖子发送成功: {visibility}") except Exception as e: logger.error(f"[Misskey] 发送消息失败: {e}") return await super().send_by_session(session, message_chain) - def _is_user_session(self, session_id: str) -> bool: - return 5 <= len(session_id) <= 64 and " " not in session_id - async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: message = AstrBotMessage() message.raw_message = raw_data diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index cf409425c..d51d84259 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -1,5 +1,5 @@ import json -from typing import Any, Optional +from typing import Any, Optional, Dict, List try: import aiohttp @@ -8,15 +8,7 @@ "aiohttp is required for Misskey API. Please install it with: pip install aiohttp" ) from e -try: - from loguru import logger # type: ignore -except ImportError: - try: - from astrbot import logger - except ImportError: - import logging - - logger = logging.getLogger(__name__) +from astrbot.api import logger # Constants API_MAX_RETRIES = 3 @@ -29,63 +21,39 @@ # Exceptions class APIError(Exception): + """Misskey API 基础异常""" + pass class APIBadRequestError(APIError): + """请求参数错误""" + pass class APIConnectionError(APIError): + """连接错误""" + pass class APIRateLimitError(APIError): - pass + """频率限制错误""" - -class AuthenticationError(APIError): pass -# HTTP Client Session Manager -class ClientSession: - session: aiohttp.ClientSession | None = None - _token: str | None = None - - @classmethod - def set_token(cls, token: str): - cls._token = token +class AuthenticationError(APIError): + """认证错误""" - @classmethod - async def close_session(cls, silent: bool = False): - if cls.session is not None: - try: - await cls.session.close() - except Exception: - if not silent: - raise - finally: - cls.session = None - - @classmethod - def _ensure_session(cls): - if cls.session is None: - headers = {} - if cls._token: - headers["Authorization"] = f"Bearer {cls._token}" - cls.session = aiohttp.ClientSession(headers=headers) - - @classmethod - def post(cls, url, json=None): - cls._ensure_session() - if cls.session is None: - raise RuntimeError("Failed to create HTTP session") - return cls.session.post(url, json=json) + pass # Retry decorator for API requests -def retry_async(max_retries=3, retryable_exceptions=()): +def retry_async(max_retries: int = 3, retryable_exceptions: tuple = ()): + """异步重试装饰器""" + def decorator(func): async def wrapper(*args, **kwargs): last_exc = None @@ -103,17 +71,13 @@ async def wrapper(*args, **kwargs): return decorator -__all__ = ("MisskeyAPI",) - - class MisskeyAPI: """Misskey API 客户端,专为 AstrBot 适配器优化""" def __init__(self, instance_url: str, access_token: str): self.instance_url = instance_url.rstrip("/") self.access_token = access_token - self.transport = ClientSession - self.transport.set_token(access_token) + self._session: Optional[aiohttp.ClientSession] = None async def __aenter__(self): return self @@ -124,32 +88,38 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): async def close(self) -> None: """关闭 API 客户端""" - await self.transport.close_session(silent=True) + if self._session: + await self._session.close() + self._session = None logger.debug("Misskey API 客户端已关闭") @property - def session(self): - self.transport._ensure_session() - if self.transport.session is None: - raise RuntimeError("Failed to create HTTP session") - return self.transport.session - - def _handle_response_status(self, response, endpoint: str): - status = response.status + def session(self) -> aiohttp.ClientSession: + """获取或创建 HTTP 会话""" + if self._session is None or self._session.closed: + headers = {"Authorization": f"Bearer {self.access_token}"} + self._session = aiohttp.ClientSession(headers=headers) + return self._session + + def _handle_response_status(self, status: int, endpoint: str): + """处理 HTTP 响应状态码""" if status == HTTP_BAD_REQUEST: logger.error(f"API 请求错误: {endpoint} (状态码: {status})") - raise APIBadRequestError() - if status == HTTP_UNAUTHORIZED: + raise APIBadRequestError(f"Bad request for {endpoint}") + elif status in (HTTP_UNAUTHORIZED, HTTP_FORBIDDEN): logger.error(f"API 认证失败: {endpoint} (状态码: {status})") - raise AuthenticationError() - if status == HTTP_FORBIDDEN: - logger.error(f"API 权限不足: {endpoint} (状态码: {status})") - raise AuthenticationError() - if status == HTTP_TOO_MANY_REQUESTS: + raise AuthenticationError(f"Authentication failed for {endpoint}") + elif status == HTTP_TOO_MANY_REQUESTS: logger.warning(f"API 频率限制: {endpoint} (状态码: {status})") - raise APIRateLimitError() + raise APIRateLimitError(f"Rate limit exceeded for {endpoint}") + else: + logger.error(f"API 请求失败: {endpoint} (状态码: {status})") + raise APIConnectionError(f"HTTP {status} for {endpoint}") - async def _process_response(self, response, endpoint: str): + async def _process_response( + self, response: aiohttp.ClientResponse, endpoint: str + ) -> Dict[str, Any]: + """处理 API 响应""" if response.status == HTTP_OK: try: result = await response.json() @@ -157,49 +127,52 @@ async def _process_response(self, response, endpoint: str): return result except json.JSONDecodeError as e: logger.error(f"响应不是有效的 JSON 格式: {e}") - raise APIConnectionError() from e - # 获取错误响应的详细内容 - try: - error_text = await response.text() - logger.error( - f"API 请求失败: {endpoint} - 状态码: {response.status}, 响应: {error_text}" - ) - except Exception: - logger.error( - f"API 请求失败: {endpoint} - 状态码: {response.status}, 无法读取错误响应" - ) + raise APIConnectionError("Invalid JSON response") from e + else: + # 记录错误响应详情 + try: + error_text = await response.text() + logger.error( + f"API 请求失败: {endpoint} - 状态码: {response.status}, 响应: {error_text}" + ) + except Exception: + logger.error( + f"API 请求失败: {endpoint} - 状态码: {response.status}, 无法读取错误响应" + ) - self._handle_response_status(response, endpoint) - raise APIConnectionError() + self._handle_response_status(response.status, endpoint) + # 这行不会被执行,因为上面的方法总是抛出异常 + raise APIConnectionError(f"Request failed for {endpoint}") @retry_async( max_retries=API_MAX_RETRIES, retryable_exceptions=(APIConnectionError, APIRateLimitError), ) async def _make_request( - self, endpoint: str, data: Optional[dict[str, Any]] = None - ) -> dict[str, Any]: + self, endpoint: str, data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: """发送 API 请求""" url = f"{self.instance_url}/api/{endpoint}" payload = {"i": self.access_token} if data: - payload |= data + payload.update(data) + try: async with self.session.post(url, json=payload) as response: return await self._process_response(response, endpoint) - except (aiohttp.ClientError, json.JSONDecodeError) as e: + except aiohttp.ClientError as e: logger.error(f"HTTP 请求错误: {e}") - raise APIConnectionError() from e + raise APIConnectionError(f"HTTP request failed: {e}") from e async def create_note( self, text: str, visibility: str = "public", reply_id: Optional[str] = None, - visible_user_ids: Optional[list[str]] = None, - ) -> dict[str, Any]: + visible_user_ids: Optional[List[str]] = None, + ) -> Dict[str, Any]: """创建帖子/回复""" - data: dict[str, Any] = {"text": text, "visibility": visibility} + data: Dict[str, Any] = {"text": text, "visibility": visibility} if reply_id: data["replyId"] = reply_id if visible_user_ids and visibility == "specified": @@ -210,11 +183,11 @@ async def create_note( logger.debug(f"Misskey 发帖成功,note_id: {note_id}") return result - async def get_current_user(self) -> dict[str, Any]: + 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]: + 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} @@ -225,9 +198,9 @@ async def send_message(self, user_id: str, text: str) -> dict[str, Any]: async def get_mentions( self, limit: int = 10, since_id: Optional[str] = None - ) -> list[dict[str, Any]]: + ) -> List[Dict[str, Any]]: """获取提及通知(包括回复和引用)""" - data: dict[str, Any] = {"limit": limit} + data: Dict[str, Any] = {"limit": limit} if since_id: data["sinceId"] = since_id data["includeTypes"] = ["mention", "reply", "quote"] diff --git a/astrbot/core/platform/sources/misskey/misskey_event.py b/astrbot/core/platform/sources/misskey/misskey_event.py index 5dc6feded..f70d615d8 100644 --- a/astrbot/core/platform/sources/misskey/misskey_event.py +++ b/astrbot/core/platform/sources/misskey/misskey_event.py @@ -1,28 +1,13 @@ from astrbot import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.platform import PlatformMetadata, AstrBotMessage -from astrbot.api.message_components import Plain - -def _serialize_message_chain_simple(chain): - """简单的消息链序列化""" - content = "" - has_at = False - - for item in chain: - if isinstance(item, Plain): - content += item.text - elif hasattr(item, "content") and item.content: - for sub_item in item.content: - content += getattr(sub_item, "text", "") - else: - text = getattr(item, "text", "") - if text: - content += text - if "@" in text: - has_at = True - - return content, has_at +from .misskey_utils import ( + serialize_message_chain, + resolve_visibility_from_raw_message, + is_valid_user_session_id, + add_at_mention_if_needed, +) class MisskeyPlatformEvent(AstrMessageEvent): @@ -38,7 +23,7 @@ def __init__( self.client = client async def send(self, message: MessageChain): - content, has_at = _serialize_message_chain_simple(message.chain) + content, has_at = serialize_message_chain(message.chain) if not content: logger.debug("[MisskeyEvent] 内容为空,跳过发送") @@ -46,32 +31,22 @@ async def send(self, message: MessageChain): 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", {}) - username = user_data.get("username", "") - if username and not content.startswith(f"@{username}"): - content = f"@{username} {content}" + 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 original_message_id and hasattr(self.client, "create_note"): - # 简化的可见性处理 - visibility = "public" - visible_user_ids = None - - if raw_message: - 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 = [sender_id] if sender_id else [] - 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 + visibility, visible_user_ids = resolve_visibility_from_raw_message( + raw_message + ) await self.client.create_note( content, @@ -79,15 +54,17 @@ async def send(self, message: MessageChain): visibility=visibility, visible_user_ids=visible_user_ids, ) - elif hasattr(self.client, "send_message") and self.session_id: - sid = str(self.session_id) - if 5 <= len(sid) <= 64 and " " not in sid: - logger.debug(f"[MisskeyEvent] 发送私信: {sid}") - await self.client.send_message(sid, content) - return - elif hasattr(self.client, "create_note"): - logger.debug("[MisskeyEvent] 创建新帖子") - await self.client.create_note(content) + # 处理私信 + elif hasattr(self.client, "send_message") and is_valid_user_session_id( + self.session_id + ): + logger.debug(f"[MisskeyEvent] 发送私信: {self.session_id}") + await self.client.send_message(str(self.session_id), content) + return + # 创建新帖子 + 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 new file mode 100644 index 000000000..32dde9aa5 --- /dev/null +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -0,0 +1,173 @@ +""" +Misskey 平台适配器通用工具函数 +""" + +from typing import Dict, Any, List, Tuple, Optional, Union +import astrbot.api.message_components as Comp + + +def serialize_message_chain(chain: List[Any]) -> Tuple[str, bool]: + """ + 将消息链序列化为文本字符串 + + Args: + chain: 消息链 + + Returns: + Tuple[str, bool]: (序列化后的文本, 是否包含@用户) + """ + text_parts = [] + has_at = False + + for component in chain: + if isinstance(component, Comp.Plain): + text_parts.append(component.text) + elif isinstance(component, Comp.Image): + text_parts.append("[图片]") + elif isinstance(component, Comp.Node): + if component.content: + for node_comp in component.content: + if isinstance(node_comp, Comp.Plain): + text_parts.append(node_comp.text) + elif isinstance(node_comp, Comp.Image): + text_parts.append("[图片]") + else: + text_parts.append(str(node_comp)) + elif isinstance(component, Comp.At): + has_at = True + text_parts.append(f"@{component.qq}") + else: + # 通用处理:检查是否有 text 属性 + if hasattr(component, "text"): + text = getattr(component, "text", "") + if text: + text_parts.append(text) + if "@" in text: + has_at = True + else: + text_parts.append(str(component)) + + return "".join(text_parts), has_at + + +def resolve_message_visibility( + user_id: Optional[str], user_cache: Dict[str, Any], self_id: Optional[str] +) -> Tuple[str, Optional[List[str]]]: + """ + 解析 Misskey 消息的可见性设置 + + Args: + user_id: 目标用户ID + user_cache: 用户缓存 + self_id: 机器人自身ID + + Returns: + Tuple[str, Optional[List[str]]]: (可见性设置, 可见用户ID列表) + """ + visibility = "public" + visible_user_ids = None + + if user_id and user_cache: + user_info = user_cache.get(user_id) + if user_info: + original_visibility = user_info.get("visibility", "public") + if original_visibility == "specified": + visibility = "specified" + original_visible_users = user_info.get("visible_user_ids", []) + users_to_include = [user_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]]]: + """ + 从原始消息数据中解析可见性设置 + + Args: + raw_message: 原始消息数据 + self_id: 机器人自身ID + + Returns: + Tuple[str, Optional[List[str]]]: (可见性设置, 可见用户ID列表) + """ + 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 + + +def is_valid_user_session_id(session_id: Union[str, Any]) -> bool: + """ + 检查是否为有效的用户会话ID + + Args: + session_id: 会话ID + + Returns: + bool: 是否为有效的用户会话ID + """ + if not isinstance(session_id, str): + return False + return 5 <= len(session_id) <= 64 and " " not in session_id + + +def add_at_mention_if_needed( + text: str, user_info: Optional[Dict[str, Any]], has_at: bool = False +) -> str: + """ + 如果需要且没有@用户,则添加@用户 + + Args: + text: 原始文本 + user_info: 用户信息 + has_at: 是否已包含@用户 + + Returns: + str: 处理后的文本 + """ + if has_at or not user_info: + return text + + username = user_info.get("username") + nickname = user_info.get("nickname") + + if username: + mention = f"@{username}" + if not text.startswith(mention): + text = f"{mention} {text}".strip() + elif nickname: + mention = f"@{nickname}" + if not text.startswith(mention): + text = f"{mention} {text}".strip() + + return text From 9faca7f350635cf3783b3ef7ca6e9eb6539354ca Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Tue, 16 Sep 2025 09:24:31 +0000 Subject: [PATCH 09/26] =?UTF-8?q?refactor:=20=E6=B8=85=E7=90=86=20Misskey?= =?UTF-8?q?=20=E5=B9=B3=E5=8F=B0=E9=80=82=E9=85=8D=E5=99=A8=E5=92=8C=20API?= =?UTF-8?q?=20=E4=BB=A3=E7=A0=81=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=86=97?= =?UTF-8?q?=E4=BD=99=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sources/misskey/misskey_adapter.py | 23 ++-------------- .../platform/sources/misskey/misskey_api.py | 27 ------------------- .../platform/sources/misskey/misskey_event.py | 4 --- 3 files changed, 2 insertions(+), 52 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index c89559841..ae9140950 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -48,7 +48,7 @@ def meta(self) -> PlatformMetadata: return PlatformMetadata( name="misskey", description="Misskey 平台适配器", - id=self.config.get("id", "misskey"), # 提供默认值防止 None + id=self.config.get("id", "misskey"), default_config_tmpl=self.config, ) @@ -75,19 +75,16 @@ async def run(self): await self._start_polling() async def _start_polling(self): - """开始轮询通知""" if not self.api: logger.error("[Misskey] API 客户端未初始化,无法开始轮询") return - # 指数退避参数 initial_backoff = 1 max_backoff = 60 backoff_multiplier = 2 current_backoff = initial_backoff is_first_poll = True - # 获取起始通知ID try: latest_notifications = await self.api.get_mentions(limit=1) if latest_notifications: @@ -96,7 +93,6 @@ async def _start_polling(self): except Exception as e: logger.warning(f"[Misskey] 获取起始通知失败: {e}") - # 主轮询循环 while self._running: if not self.api: logger.error("[Misskey] API 客户端在轮询过程中变为 None") @@ -107,9 +103,7 @@ async def _start_polling(self): limit=20, since_id=self.last_notification_id ) - # 重置退避时间 current_backoff = initial_backoff - await self._process_notifications(notifications, is_first_poll) is_first_poll = False @@ -122,7 +116,6 @@ async def _start_polling(self): current_backoff = min(current_backoff * backoff_multiplier, max_backoff) async def _process_notifications(self, notifications: list, is_first_poll: bool): - """处理通知列表""" if not notifications: if is_first_poll: logger.info("[Misskey] 开始监听新消息") @@ -132,14 +125,12 @@ async def _process_notifications(self, notifications: list, is_first_poll: bool) logger.debug(f"[Misskey] 跳过 {len(notifications)} 条历史通知") self.last_notification_id = notifications[0].get("id") else: - # 按时间顺序处理通知 notifications.reverse() for notification in notifications: await self._process_notification(notification) self.last_notification_id = notifications[0].get("id") async def _process_notification(self, notification: Dict[str, Any]): - """处理单个通知""" try: notification_type = notification.get("type") if notification_type not in ["mention", "reply", "quote"]: @@ -191,22 +182,18 @@ def _is_bot_mentioned(self, note: Dict[str, Any]) -> bool: async def send_by_session( self, session: MessageSesion, message_chain: MessageChain ) -> Awaitable[Any]: - """通过会话发送消息""" if not self.api: logger.error("[Misskey] API 客户端未初始化") return await super().send_by_session(session, message_chain) try: - # 解析session信息 - 现在session_id就是用户ID user_id = session.session_id text, has_at_user = serialize_message_chain(message_chain.chain) - # 添加@用户提及 if not has_at_user and user_id: user_info = self._user_cache.get(user_id) text = add_at_mention_if_needed(text, user_info, has_at_user) - # 验证和处理消息内容 if not text or not text.strip(): logger.warning("[Misskey] 消息内容为空,跳过发送") return await super().send_by_session(session, message_chain) @@ -214,14 +201,12 @@ async def send_by_session( if len(text) > self.max_message_length: text = text[: self.max_message_length] + "..." - # 确定可见性设置 visibility, visible_user_ids = resolve_message_visibility( user_id=user_id, user_cache=self._user_cache, self_id=self.client_self_id, ) - # 发送消息 if user_id and is_valid_user_session_id(user_id): await self.api.send_message(user_id, text) logger.debug(f"[Misskey] 私信发送成功: {user_id}") @@ -251,8 +236,7 @@ async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: user_id = message.sender.user_id message_id = str(raw_data.get("id", "")) - # 使用 AstrBot 标准的会话ID格式: platform_name:message_type:session_id - message.session_id = user_id # 使用用户ID作为基础session_id + message.session_id = user_id message.message_id = message_id message.self_id = self.client_self_id message.type = MessageType.FRIEND_MESSAGE @@ -278,7 +262,6 @@ async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: message.message.append(Comp.Plain(raw_text)) - # 处理文件附件 files = raw_data.get("files", []) if files: for file_info in files: @@ -287,10 +270,8 @@ async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: file_name = file_info.get("name", "未知文件") if file_type.startswith("image/"): - # 图片文件 message.message.append(Comp.Image(file_url)) else: - # 其他文件类型,作为纯文本描述 message.message.append(Comp.Plain(f"[文件: {file_name}]")) return message diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index d51d84259..ed037bc9b 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -21,39 +21,26 @@ # Exceptions class APIError(Exception): - """Misskey API 基础异常""" - pass class APIBadRequestError(APIError): - """请求参数错误""" - pass class APIConnectionError(APIError): - """连接错误""" - pass class APIRateLimitError(APIError): - """频率限制错误""" - pass class AuthenticationError(APIError): - """认证错误""" - pass -# Retry decorator for API requests def retry_async(max_retries: int = 3, retryable_exceptions: tuple = ()): - """异步重试装饰器""" - def decorator(func): async def wrapper(*args, **kwargs): last_exc = None @@ -72,8 +59,6 @@ async def wrapper(*args, **kwargs): class MisskeyAPI: - """Misskey API 客户端,专为 AstrBot 适配器优化""" - def __init__(self, instance_url: str, access_token: str): self.instance_url = instance_url.rstrip("/") self.access_token = access_token @@ -87,7 +72,6 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): return False async def close(self) -> None: - """关闭 API 客户端""" if self._session: await self._session.close() self._session = None @@ -95,14 +79,12 @@ async def close(self) -> None: @property def session(self) -> aiohttp.ClientSession: - """获取或创建 HTTP 会话""" if self._session is None or self._session.closed: headers = {"Authorization": f"Bearer {self.access_token}"} self._session = aiohttp.ClientSession(headers=headers) return self._session def _handle_response_status(self, status: int, endpoint: str): - """处理 HTTP 响应状态码""" if status == HTTP_BAD_REQUEST: logger.error(f"API 请求错误: {endpoint} (状态码: {status})") raise APIBadRequestError(f"Bad request for {endpoint}") @@ -119,7 +101,6 @@ def _handle_response_status(self, status: int, endpoint: str): async def _process_response( self, response: aiohttp.ClientResponse, endpoint: str ) -> Dict[str, Any]: - """处理 API 响应""" if response.status == HTTP_OK: try: result = await response.json() @@ -129,7 +110,6 @@ async def _process_response( logger.error(f"响应不是有效的 JSON 格式: {e}") raise APIConnectionError("Invalid JSON response") from e else: - # 记录错误响应详情 try: error_text = await response.text() logger.error( @@ -141,7 +121,6 @@ async def _process_response( ) self._handle_response_status(response.status, endpoint) - # 这行不会被执行,因为上面的方法总是抛出异常 raise APIConnectionError(f"Request failed for {endpoint}") @retry_async( @@ -151,7 +130,6 @@ async def _process_response( async def _make_request( self, endpoint: str, data: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: - """发送 API 请求""" url = f"{self.instance_url}/api/{endpoint}" payload = {"i": self.access_token} if data: @@ -171,7 +149,6 @@ async def create_note( reply_id: Optional[str] = None, visible_user_ids: Optional[List[str]] = None, ) -> Dict[str, Any]: - """创建帖子/回复""" data: Dict[str, Any] = {"text": text, "visibility": visibility} if reply_id: data["replyId"] = reply_id @@ -184,11 +161,9 @@ async def create_note( return result 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} ) @@ -199,14 +174,12 @@ async def send_message(self, user_id: str, text: str) -> Dict[str, Any]: async def get_mentions( self, limit: int = 10, since_id: Optional[str] = None ) -> List[Dict[str, Any]]: - """获取提及通知(包括回复和引用)""" data: Dict[str, Any] = {"limit": limit} if since_id: data["sinceId"] = since_id data["includeTypes"] = ["mention", "reply", "quote"] result = await self._make_request("i/notifications", data) - # Misskey API 返回通知列表 if isinstance(result, list): return result elif isinstance(result, dict) and "notifications" in result: diff --git a/astrbot/core/platform/sources/misskey/misskey_event.py b/astrbot/core/platform/sources/misskey/misskey_event.py index f70d615d8..475bd81cb 100644 --- a/astrbot/core/platform/sources/misskey/misskey_event.py +++ b/astrbot/core/platform/sources/misskey/misskey_event.py @@ -33,7 +33,6 @@ async def send(self, message: MessageChain): 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 = { @@ -42,7 +41,6 @@ async def send(self, message: MessageChain): } content = add_at_mention_if_needed(content, user_info, has_at) - # 处理回复消息 if original_message_id and hasattr(self.client, "create_note"): visibility, visible_user_ids = resolve_visibility_from_raw_message( raw_message @@ -54,14 +52,12 @@ async def send(self, message: MessageChain): visibility=visibility, visible_user_ids=visible_user_ids, ) - # 处理私信 elif hasattr(self.client, "send_message") and is_valid_user_session_id( self.session_id ): logger.debug(f"[MisskeyEvent] 发送私信: {self.session_id}") await self.client.send_message(str(self.session_id), content) return - # 创建新帖子 elif hasattr(self.client, "create_note"): logger.debug("[MisskeyEvent] 创建新帖子") await self.client.create_note(content) From 7dac790a15aa283200ecd384e57b08c9601d2dbb Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Wed, 17 Sep 2025 00:37:30 +0800 Subject: [PATCH 10/26] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BA=86?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E4=B8=AD=E5=92=8C=E4=BD=BF=E7=94=A8=E8=80=85?= =?UTF-8?q?=E5=8F=8D=E9=A6=88=E7=9A=84=E5=A4=9A=E4=B8=AA=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sources/misskey/misskey_adapter.py | 103 +++++++++++------ .../platform/sources/misskey/misskey_api.py | 23 +++- .../platform/sources/misskey/misskey_utils.py | 108 +++++------------- 3 files changed, 118 insertions(+), 116 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index ae9140950..4a9ada773 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 json from typing import Dict, Any, Optional, Awaitable from astrbot.api import logger @@ -141,6 +142,10 @@ async def _process_notification(self, notification: Dict[str, Any]): return message = await self.convert_message(note) + logger.debug( + f"[Misskey] 收到消息 - {message.sender.nickname}: {message.message_str}" + ) + event = MisskeyPlatformEvent( message_str=message.message_str, message_obj=message, @@ -154,28 +159,29 @@ async def _process_notification(self, notification: Dict[str, Any]): logger.error(f"[Misskey] 处理通知失败: {e}") def _is_bot_mentioned(self, note: Dict[str, Any]) -> bool: + """检查机器人是否被提及""" text = note.get("text", "") if not text: return False bot_user_id = self.client_self_id + mentions = note.get("mentions", []) + # 检查文本中是否直接@了机器人 if self._bot_username and f"@{self._bot_username}" in text: return True - reply_id = note.get("replyId") - mentions = note.get("mentions", []) - - if not reply_id: - return bot_user_id in [str(uid) for uid in mentions] + # 检查是否在提及列表中 + if bot_user_id in [str(uid) for uid in mentions]: + return True + # 检查回复情况 reply = note.get("reply") if reply and isinstance(reply, dict): reply_user_id = str(reply.get("user", {}).get("id", "")) + # 如果回复的是机器人的消息,需要文本中有@机器人 if reply_user_id == str(bot_user_id): return bool(self._bot_username and f"@{self._bot_username}" in text) - else: - return bot_user_id in [str(uid) for uid in mentions] return False @@ -209,71 +215,98 @@ async def send_by_session( if user_id and is_valid_user_session_id(user_id): await self.api.send_message(user_id, text) - logger.debug(f"[Misskey] 私信发送成功: {user_id}") + logger.debug("[Misskey] 私信发送成功") else: await self.api.create_note( text, visibility=visibility, visible_user_ids=visible_user_ids ) - logger.debug(f"[Misskey] 帖子发送成功: {visibility}") + logger.debug("[Misskey] 帖子发送成功") except Exception as e: logger.error(f"[Misskey] 发送消息失败: {e}") return await super().send_by_session(session, message_chain) + def _create_file_component(self, file_info: Dict[str, Any]): + """根据文件类型创建相应的消息组件""" + file_url = file_info.get("url", "") + file_name = file_info.get("name", "未知文件") + file_type = file_info.get("type", "") + + if file_type.startswith("image/"): + return Comp.Image(url=file_url, file=file_name), f"图片[{file_name}]" + elif file_type.startswith("audio/"): + return Comp.Record(url=file_url, file=file_name), f"音频[{file_name}]" + elif file_type.startswith("video/"): + return Comp.Video(url=file_url, file=file_name), f"视频[{file_name}]" + else: + return Comp.File(name=file_name, url=file_url), f"文件[{file_name}]" + async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: + logger.debug( + f"[Misskey] 接收到完整帖子数据:\n{json.dumps(raw_data, ensure_ascii=False, indent=2)}" + ) + message = AstrBotMessage() message.raw_message = raw_data - message.message_str = raw_data.get("text", "") message.message = [] sender = raw_data.get("user", {}) - sender_username = sender.get("username", "") message.sender = MessageMember( user_id=str(sender.get("id", "")), nickname=sender.get("name", sender.get("username", "")), ) - user_id = message.sender.user_id - message_id = str(raw_data.get("id", "")) - message.session_id = user_id - message.message_id = message_id + message.session_id = message.sender.user_id + message.message_id = str(raw_data.get("id", "")) message.self_id = self.client_self_id message.type = MessageType.FRIEND_MESSAGE - self._user_cache[user_id] = { - "username": sender_username, + self._user_cache[message.sender.user_id] = { + "username": sender.get("username", ""), "nickname": message.sender.nickname, "visibility": raw_data.get("visibility", "public"), "visible_user_ids": raw_data.get("visibleUserIds", []), } - raw_text = message.message_str + message_parts = [] + raw_text = raw_data.get("text", "") + if raw_text: - if self._bot_username: + if self._bot_username and raw_text.startswith(f"@{self._bot_username}"): at_mention = f"@{self._bot_username}" - if raw_text.startswith(at_mention): - message.message.append(Comp.At(qq=self.client_self_id)) - remaining_text = raw_text[len(at_mention) :].strip() - message.message_str = remaining_text - if remaining_text: - message.message.append(Comp.Plain(remaining_text)) - return message - - message.message.append(Comp.Plain(raw_text)) + message.message.append(Comp.At(qq=self.client_self_id)) + remaining_text = raw_text[len(at_mention) :].strip() + if remaining_text: + message.message.append(Comp.Plain(remaining_text)) + message_parts.append(remaining_text) + else: + message.message.append(Comp.Plain(raw_text)) + message_parts.append(raw_text) files = raw_data.get("files", []) if files: - for file_info in files: - file_type = file_info.get("type", "").lower() - file_url = file_info.get("url", "") + logger.debug(f"[Misskey] 检测到 {len(files)} 个附件") + for i, file_info in enumerate(files): + file_type = file_info.get("type", "") file_name = file_info.get("name", "未知文件") + logger.debug( + f"[Misskey] 附件 {i + 1}: 名称={file_name}, 类型={file_type}" + ) - if file_type.startswith("image/"): - message.message.append(Comp.Image(file_url)) - else: - message.message.append(Comp.Plain(f"[文件: {file_name}]")) + component, part_text = self._create_file_component(file_info) + message.message.append(component) + message_parts.append(part_text) + logger.debug(f"[Misskey] 最终消息组件数量: {len(message.message)}") + for i, comp in enumerate(message.message): + logger.debug(f"[Misskey] 组件 {i}: {type(comp).__name__} - {comp}") + + message.message_str = ( + " ".join(part for part in message_parts if part.strip()) + if message_parts + else "" + ) return message async def terminate(self): diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index ed037bc9b..374bb44a9 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -100,11 +100,28 @@ def _handle_response_status(self, status: int, endpoint: str): async def _process_response( self, response: aiohttp.ClientResponse, endpoint: str - ) -> Dict[str, Any]: + ) -> Any: if response.status == HTTP_OK: try: result = await response.json() - logger.debug(f"Misskey API 请求成功: {endpoint}") + # 减少轮询接口的重复日志输出 + if endpoint == "i/notifications": + # 只在有新通知时才输出日志 + notifications_data = ( + result + if isinstance(result, list) + else result.get("notifications", []) + if isinstance(result, dict) + else [] + ) + if len(notifications_data) > 0: + logger.debug( + f"Misskey API 获取到 {len(notifications_data)} 条新通知" + ) + # 空结果不输出日志,避免频繁的轮询日志 + else: + # 其他接口正常输出成功日志 + logger.debug(f"Misskey API 请求成功: {endpoint}") return result except json.JSONDecodeError as e: logger.error(f"响应不是有效的 JSON 格式: {e}") @@ -129,7 +146,7 @@ async def _process_response( ) async def _make_request( self, endpoint: str, data: Optional[Dict[str, Any]] = None - ) -> Dict[str, Any]: + ) -> Any: url = f"{self.instance_url}/api/{endpoint}" payload = {"i": self.access_token} if data: diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index 32dde9aa5..60b2e84be 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -1,51 +1,42 @@ -""" -Misskey 平台适配器通用工具函数 -""" +"""Misskey 平台适配器通用工具函数""" from typing import Dict, Any, List, Tuple, Optional, Union import astrbot.api.message_components as Comp def serialize_message_chain(chain: List[Any]) -> Tuple[str, bool]: - """ - 将消息链序列化为文本字符串 - - Args: - chain: 消息链 - - Returns: - Tuple[str, bool]: (序列化后的文本, 是否包含@用户) - """ + """将消息链序列化为文本字符串""" text_parts = [] has_at = False - for component in chain: + def process_component(component): + nonlocal has_at if isinstance(component, Comp.Plain): - text_parts.append(component.text) - elif isinstance(component, Comp.Image): - text_parts.append("[图片]") - elif isinstance(component, Comp.Node): - if component.content: - for node_comp in component.content: - if isinstance(node_comp, Comp.Plain): - text_parts.append(node_comp.text) - elif isinstance(node_comp, Comp.Image): - text_parts.append("[图片]") - else: - text_parts.append(str(node_comp)) + return component.text + elif isinstance(component, Comp.File): + file_name = getattr(component, "name", "文件") + return f"[文件: {file_name}]" elif isinstance(component, Comp.At): has_at = True - text_parts.append(f"@{component.qq}") + return f"@{component.qq}" + elif hasattr(component, "text"): + text = getattr(component, "text", "") + if text and "@" in text: + has_at = True + return text else: - # 通用处理:检查是否有 text 属性 - if hasattr(component, "text"): - text = getattr(component, "text", "") - if text: - text_parts.append(text) - if "@" in text: - has_at = True - else: - text_parts.append(str(component)) + return str(component) + + for component in chain: + if isinstance(component, Comp.Node) and component.content: + for node_comp in component.content: + result = process_component(node_comp) + if result: + text_parts.append(result) + else: + result = process_component(component) + if result: + text_parts.append(result) return "".join(text_parts), has_at @@ -53,17 +44,7 @@ def serialize_message_chain(chain: List[Any]) -> Tuple[str, bool]: def resolve_message_visibility( user_id: Optional[str], user_cache: Dict[str, Any], self_id: Optional[str] ) -> Tuple[str, Optional[List[str]]]: - """ - 解析 Misskey 消息的可见性设置 - - Args: - user_id: 目标用户ID - user_cache: 用户缓存 - self_id: 机器人自身ID - - Returns: - Tuple[str, Optional[List[str]]]: (可见性设置, 可见用户ID列表) - """ + """解析 Misskey 消息的可见性设置""" visibility = "public" visible_user_ids = None @@ -78,7 +59,6 @@ def resolve_message_visibility( 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 @@ -89,16 +69,7 @@ def resolve_message_visibility( def resolve_visibility_from_raw_message( raw_message: Dict[str, Any], self_id: Optional[str] = None ) -> Tuple[str, Optional[List[str]]]: - """ - 从原始消息数据中解析可见性设置 - - Args: - raw_message: 原始消息数据 - self_id: 机器人自身ID - - Returns: - Tuple[str, Optional[List[str]]]: (可见性设置, 可见用户ID列表) - """ + """从原始消息数据中解析可见性设置""" visibility = "public" visible_user_ids = None @@ -118,7 +89,6 @@ def resolve_visibility_from_raw_message( 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 @@ -127,15 +97,7 @@ def resolve_visibility_from_raw_message( def is_valid_user_session_id(session_id: Union[str, Any]) -> bool: - """ - 检查是否为有效的用户会话ID - - Args: - session_id: 会话ID - - Returns: - bool: 是否为有效的用户会话ID - """ + """检查是否为有效的用户会话ID""" if not isinstance(session_id, str): return False return 5 <= len(session_id) <= 64 and " " not in session_id @@ -144,17 +106,7 @@ def is_valid_user_session_id(session_id: Union[str, Any]) -> bool: def add_at_mention_if_needed( text: str, user_info: Optional[Dict[str, Any]], has_at: bool = False ) -> str: - """ - 如果需要且没有@用户,则添加@用户 - - Args: - text: 原始文本 - user_info: 用户信息 - has_at: 是否已包含@用户 - - Returns: - str: 处理后的文本 - """ + """如果需要且没有@用户,则添加@用户""" if has_at or not user_info: return text From e0aa6ef794cd0621f4c9d2f084bfdcad9d6aeafe Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Wed, 17 Sep 2025 00:43:40 +0800 Subject: [PATCH 11/26] =?UTF-8?q?fix:=20=E4=BF=AE=E6=94=B9=E6=8F=90?= =?UTF-8?q?=E5=8F=8A=E6=A0=BC=E5=BC=8F=EF=BC=8C=E7=A1=AE=E4=BF=9D=E6=8F=90?= =?UTF-8?q?=E5=8F=8A=E5=9C=A8=E6=96=B0=E8=A1=8C=E5=BC=80=E5=A7=8B=EF=BC=8C?= =?UTF-8?q?=E6=8F=90=E5=8D=87=E5=B8=96=E5=AD=90=E7=BE=8E=E8=A7=82=E5=92=8C?= =?UTF-8?q?=E6=98=93=E8=AF=BB=E6=80=A7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/misskey/misskey_adapter.py | 4 ---- astrbot/core/platform/sources/misskey/misskey_utils.py | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 4a9ada773..958010ae6 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -167,19 +167,15 @@ def _is_bot_mentioned(self, note: Dict[str, Any]) -> bool: bot_user_id = self.client_self_id mentions = note.get("mentions", []) - # 检查文本中是否直接@了机器人 if self._bot_username and f"@{self._bot_username}" in text: return True - # 检查是否在提及列表中 if bot_user_id in [str(uid) for uid in mentions]: return True - # 检查回复情况 reply = note.get("reply") if reply and isinstance(reply, dict): reply_user_id = str(reply.get("user", {}).get("id", "")) - # 如果回复的是机器人的消息,需要文本中有@机器人 if reply_user_id == str(bot_user_id): return bool(self._bot_username and f"@{self._bot_username}" in text) diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index 60b2e84be..62504d622 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -116,10 +116,10 @@ def add_at_mention_if_needed( if username: mention = f"@{username}" if not text.startswith(mention): - text = f"{mention} {text}".strip() + text = f"{mention}\n{text}".strip() elif nickname: mention = f"@{nickname}" if not text.startswith(mention): - text = f"{mention} {text}".strip() + text = f"{mention}\n{text}".strip() return text From 5bec5cd7cea1b1fd4b005cbd0938657a553579a8 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Wed, 17 Sep 2025 01:04:19 +0800 Subject: [PATCH 12/26] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=8F=AF=E8=A7=81=E6=80=A7=E5=92=8C=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E4=BB=85=E9=99=90=E8=AE=BE=E7=BD=AE=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=20Misskey=20=E5=B9=B3=E5=8F=B0=E9=80=82=E9=85=8D=E5=99=A8?= =?UTF-8?q?=E7=9A=84=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 13 ++++++++++++ .../sources/misskey/misskey_adapter.py | 21 +++++++++++++++++-- .../platform/sources/misskey/misskey_api.py | 11 +++++----- .../platform/sources/misskey/misskey_utils.py | 13 ++++++++---- 4 files changed, 47 insertions(+), 11 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 39d88ed48..f6c34f087 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -242,6 +242,8 @@ "enable": False, "misskey_instance_url": "https://misskey.example", "misskey_token": "", + "default_visibility": "public", + "local_only": False, }, "Slack": { "id": "slack", @@ -354,6 +356,17 @@ "type": "string", "hint": "连接服务设置生成的 API 鉴权访问令牌(Access token)", }, + "default_visibility": { + "description": "默认帖子可见性", + "type": "string", + "options": ["public", "home", "followers"], + "hint": "机器人发帖时的默认可见性设置。public:公开,home:主页时间线,followers:仅关注者。", + }, + "local_only": { + "description": "仅限本站(不参与联合)", + "type": "bool", + "hint": "启用后,机器人发出的帖子将仅在本实例可见,不会联合到其他实例", + }, "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 958010ae6..2319e6d65 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -38,6 +38,9 @@ def __init__( self.poll_interval = self.config.get("poll_interval", 5.0) self.max_message_length = self.config.get("max_message_length", 3000) + self.default_visibility = self.config.get("default_visibility", "public") + self.local_only = self.config.get("local_only", False) + self.api: Optional[MisskeyAPI] = None self._running = False self.last_notification_id = None @@ -46,11 +49,21 @@ def __init__( self._user_cache = {} def meta(self) -> PlatformMetadata: + default_config = { + "misskey_instance_url": "", + "misskey_token": "", + "poll_interval": 5.0, + "max_message_length": 3000, + "default_visibility": "public", + "local_only": False, + } + default_config.update(self.config) + return PlatformMetadata( name="misskey", description="Misskey 平台适配器", id=self.config.get("id", "misskey"), - default_config_tmpl=self.config, + default_config_tmpl=default_config, ) async def run(self): @@ -207,6 +220,7 @@ async def send_by_session( user_id=user_id, user_cache=self._user_cache, self_id=self.client_self_id, + default_visibility=self.default_visibility, ) if user_id and is_valid_user_session_id(user_id): @@ -214,7 +228,10 @@ async def send_by_session( logger.debug("[Misskey] 私信发送成功") else: await self.api.create_note( - text, visibility=visibility, visible_user_ids=visible_user_ids + text, + visibility=visibility, + visible_user_ids=visible_user_ids, + local_only=self.local_only, ) logger.debug("[Misskey] 帖子发送成功") diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 374bb44a9..b7611c101 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -104,9 +104,7 @@ async def _process_response( if response.status == HTTP_OK: try: result = await response.json() - # 减少轮询接口的重复日志输出 if endpoint == "i/notifications": - # 只在有新通知时才输出日志 notifications_data = ( result if isinstance(result, list) @@ -118,9 +116,7 @@ async def _process_response( logger.debug( f"Misskey API 获取到 {len(notifications_data)} 条新通知" ) - # 空结果不输出日志,避免频繁的轮询日志 else: - # 其他接口正常输出成功日志 logger.debug(f"Misskey API 请求成功: {endpoint}") return result except json.JSONDecodeError as e: @@ -165,8 +161,13 @@ async def create_note( visibility: str = "public", reply_id: Optional[str] = None, visible_user_ids: Optional[List[str]] = None, + local_only: bool = False, ) -> Dict[str, Any]: - data: Dict[str, Any] = {"text": text, "visibility": visibility} + data: Dict[str, Any] = { + "text": text, + "visibility": visibility, + "localOnly": local_only, + } if reply_id: data["replyId"] = reply_id if visible_user_ids and visibility == "specified": diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index 62504d622..1d79b68d9 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -42,16 +42,21 @@ 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], + user_cache: Dict[str, Any], + self_id: Optional[str], + default_visibility: str = "public", ) -> Tuple[str, Optional[List[str]]]: - """解析 Misskey 消息的可见性设置""" - visibility = "public" + """ + 解析 Misskey 消息的可见性设置 + """ + visibility = default_visibility visible_user_ids = None if user_id and user_cache: user_info = user_cache.get(user_id) if user_info: - original_visibility = user_info.get("visibility", "public") + original_visibility = user_info.get("visibility", default_visibility) if original_visibility == "specified": visibility = "specified" original_visible_users = user_info.get("visible_user_ids", []) From be32999ed3d41864017737b46f71c3dfee6b82cf Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Wed, 17 Sep 2025 03:32:38 +0800 Subject: [PATCH 13/26] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0=20Misskey=20?= =?UTF-8?q?=E5=B9=B3=E5=8F=B0=E9=80=82=E9=85=8D=E5=99=A8=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=EF=BC=8C=E4=BD=BF=E7=94=A8=E5=89=8D=E7=BC=80=E4=BB=A5=E9=98=B2?= =?UTF-8?q?=E6=AD=A2=E5=92=8C=E5=85=B6=E4=BB=96=E9=80=82=E9=85=8D=E5=99=A8?= =?UTF-8?q?=E6=9C=AA=E6=9D=A5=E5=8F=AF=E8=83=BD=E7=9A=84=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 8 ++++---- .../core/platform/sources/misskey/misskey_adapter.py | 10 ++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index f6c34f087..cced73d4f 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -242,8 +242,8 @@ "enable": False, "misskey_instance_url": "https://misskey.example", "misskey_token": "", - "default_visibility": "public", - "local_only": False, + "misskey_default_visibility": "public", + "misskey_local_only": False, }, "Slack": { "id": "slack", @@ -356,13 +356,13 @@ "type": "string", "hint": "连接服务设置生成的 API 鉴权访问令牌(Access token)", }, - "default_visibility": { + "misskey_default_visibility": { "description": "默认帖子可见性", "type": "string", "options": ["public", "home", "followers"], "hint": "机器人发帖时的默认可见性设置。public:公开,home:主页时间线,followers:仅关注者。", }, - "local_only": { + "misskey_local_only": { "description": "仅限本站(不参与联合)", "type": "bool", "hint": "启用后,机器人发出的帖子将仅在本实例可见,不会联合到其他实例", diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 2319e6d65..c1df3dbdc 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -38,8 +38,10 @@ def __init__( self.poll_interval = self.config.get("poll_interval", 5.0) self.max_message_length = self.config.get("max_message_length", 3000) - self.default_visibility = self.config.get("default_visibility", "public") - self.local_only = self.config.get("local_only", False) + self.default_visibility = self.config.get( + "misskey_default_visibility", "public" + ) + self.local_only = self.config.get("misskey_local_only", False) self.api: Optional[MisskeyAPI] = None self._running = False @@ -54,8 +56,8 @@ def meta(self) -> PlatformMetadata: "misskey_token": "", "poll_interval": 5.0, "max_message_length": 3000, - "default_visibility": "public", - "local_only": False, + "misskey_default_visibility": "public", + "misskey_local_only": False, } default_config.update(self.config) From 0f764959214e3768a560128338329794e317b938 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:37:52 +0800 Subject: [PATCH 14/26] chore: rename 'misskey' to 'Misskey' in config --- astrbot/core/config/default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index cced73d4f..3148b846b 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -236,7 +236,7 @@ "discord_guild_id_for_debug": "", "discord_activity_name": "", }, - "misskey": { + "Misskey": { "id": "misskey", "type": "misskey", "enable": False, From c7fad4bee5a4988ceb9789a8c37c1eff8e1bef34 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 18 Sep 2025 14:54:20 +0800 Subject: [PATCH 15/26] =?UTF-8?q?feat:=20Misskey=20=E9=80=82=E9=85=8D?= =?UTF-8?q?=E5=99=A8=E6=B7=BB=E5=8A=A0=E8=81=8A=E5=A4=A9=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=8A=9F=E8=83=BD=EF=BC=8C=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=8E=A5=E6=94=B6=E5=92=8C=E5=8F=91=E9=80=81=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E4=B8=BA=20Websockets=20=E5=A4=84=E7=90=86?= 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 | 212 ++++++++++-------- .../platform/sources/misskey/misskey_api.py | 173 +++++++++++++- .../platform/sources/misskey/misskey_event.py | 36 ++- .../platform/sources/misskey/misskey_utils.py | 21 +- 5 files changed, 344 insertions(+), 104 deletions(-) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 3148b846b..d0b96fd2f 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -244,6 +244,7 @@ "misskey_token": "", "misskey_default_visibility": "public", "misskey_local_only": False, + "misskey_enable_chat": True, }, "Slack": { "id": "slack", @@ -367,6 +368,11 @@ "type": "bool", "hint": "启用后,机器人发出的帖子将仅在本实例可见,不会联合到其他实例", }, + "misskey_enable_chat": { + "description": "启用聊天消息响应", + "type": "bool", + "hint": "启用后,机器人将会监听和响应私信聊天消息", + }, "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 c1df3dbdc..1e0727c38 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -35,17 +35,15 @@ def __init__( self.settings = platform_settings or {} self.instance_url = self.config.get("misskey_instance_url", "") self.access_token = self.config.get("misskey_token", "") - self.poll_interval = self.config.get("poll_interval", 5.0) self.max_message_length = self.config.get("max_message_length", 3000) - self.default_visibility = self.config.get( "misskey_default_visibility", "public" ) self.local_only = self.config.get("misskey_local_only", False) + self.enable_chat = self.config.get("misskey_enable_chat", True) self.api: Optional[MisskeyAPI] = None self._running = False - self.last_notification_id = None self.client_self_id = "" self._bot_username = "" self._user_cache = {} @@ -54,10 +52,10 @@ def meta(self) -> PlatformMetadata: default_config = { "misskey_instance_url": "", "misskey_token": "", - "poll_interval": 5.0, "max_message_length": 3000, "misskey_default_visibility": "public", "misskey_local_only": False, + "misskey_enable_chat": True, } default_config.update(self.config) @@ -88,79 +86,84 @@ async def run(self): self._running = False return - await self._start_polling() - - async def _start_polling(self): - if not self.api: - logger.error("[Misskey] API 客户端未初始化,无法开始轮询") - return - - initial_backoff = 1 - max_backoff = 60 - backoff_multiplier = 2 - current_backoff = initial_backoff - is_first_poll = True + await self._start_websocket_connection() - try: - latest_notifications = await self.api.get_mentions(limit=1) - if latest_notifications: - self.last_notification_id = latest_notifications[0].get("id") - logger.debug(f"[Misskey] 起始通知 ID: {self.last_notification_id}") - except Exception as e: - logger.warning(f"[Misskey] 获取起始通知失败: {e}") + async def _start_websocket_connection(self): + backoff_delay = 1.0 + max_backoff = 300.0 + backoff_multiplier = 1.5 while self._running: - if not self.api: - logger.error("[Misskey] API 客户端在轮询过程中变为 None") - break - try: - notifications = await self.api.get_mentions( - limit=20, since_id=self.last_notification_id - ) - - current_backoff = initial_backoff - await self._process_notifications(notifications, is_first_poll) - is_first_poll = False - - await asyncio.sleep(self.poll_interval) + if not self.api: + logger.error("[Misskey] API 客户端未初始化") + break + + streaming = self.api.get_streaming_client() + streaming.add_message_handler("notification", self._handle_notification) + if self.enable_chat: + # Misskey 聊天消息的正确事件名称 + streaming.add_message_handler( + "newChatMessage", self._handle_chat_message + ) + # 添加通用事件处理器来调试 + streaming.add_message_handler("_debug", self._debug_handler) + + if await streaming.connect(): + logger.info("[Misskey] WebSocket 连接成功") + 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 + await streaming.listen() + else: + logger.error("[Misskey] WebSocket 连接失败") except Exception as e: - logger.warning(f"[Misskey] 轮询失败: {e}") - logger.info(f"[Misskey] 将在 {current_backoff} 秒后重试") - await asyncio.sleep(current_backoff) - current_backoff = min(current_backoff * backoff_multiplier, max_backoff) - - async def _process_notifications(self, notifications: list, is_first_poll: bool): - if not notifications: - if is_first_poll: - logger.info("[Misskey] 开始监听新消息") - return + logger.error(f"[Misskey] WebSocket 异常: {e}") - if is_first_poll: - logger.debug(f"[Misskey] 跳过 {len(notifications)} 条历史通知") - self.last_notification_id = notifications[0].get("id") - else: - notifications.reverse() - for notification in notifications: - await self._process_notification(notification) - self.last_notification_id = notifications[0].get("id") + if self._running: + logger.info(f"[Misskey] {backoff_delay:.1f}秒后重连") + await asyncio.sleep(backoff_delay) + backoff_delay = min(backoff_delay * backoff_multiplier, max_backoff) - async def _process_notification(self, notification: Dict[str, Any]): + async def _handle_notification(self, data: Dict[str, Any]): try: - notification_type = notification.get("type") - if notification_type not in ["mention", "reply", "quote"]: - return - - note = notification.get("note") - if not note or not self._is_bot_mentioned(note): - return + logger.debug( + f"[Misskey] 收到通知事件:\n{json.dumps(data, indent=2, ensure_ascii=False)}" + ) + notification_type = data.get("type") + if notification_type in ["mention", "reply", "quote"]: + note = data.get("note") + if note and self._is_bot_mentioned(note): + logger.info(f"[Misskey] 处理贴文提及: {note.get('text', '')}") + message = await self.convert_message(note) + event = MisskeyPlatformEvent( + message_str=message.message_str, + message_obj=message, + platform_meta=self.meta(), + session_id=message.session_id, + client=self.api, + ) + self.commit_event(event) + except Exception as e: + logger.error(f"[Misskey] 处理通知失败: {e}") - message = await self.convert_message(note) + async def _handle_chat_message(self, data: Dict[str, Any]): + try: logger.debug( - f"[Misskey] 收到消息 - {message.sender.nickname}: {message.message_str}" + f"[Misskey] 收到聊天事件数据:\n{json.dumps(data, indent=2, ensure_ascii=False)}" ) + sender_id = str(data.get("user", {}).get("id", "")) + if sender_id == self.client_self_id: + return + message = await self.convert_chat_message(data) + logger.info(f"[Misskey] 处理聊天消息: {message.message_str}") event = MisskeyPlatformEvent( message_str=message.message_str, message_obj=message, @@ -169,29 +172,29 @@ async def _process_notification(self, notification: Dict[str, Any]): client=self.api, ) self.commit_event(event) - except Exception as e: - logger.error(f"[Misskey] 处理通知失败: {e}") + logger.error(f"[Misskey] 处理聊天消息失败: {e}") + + async def _debug_handler(self, data: Dict[str, Any]): + logger.debug( + f"[Misskey] 收到未处理事件:\n{json.dumps(data, indent=2, ensure_ascii=False)}" + ) def _is_bot_mentioned(self, note: Dict[str, Any]) -> bool: - """检查机器人是否被提及""" text = note.get("text", "") if not text: return False - bot_user_id = self.client_self_id mentions = note.get("mentions", []) - if self._bot_username and f"@{self._bot_username}" in text: return True - - if bot_user_id in [str(uid) for uid in mentions]: + if self.client_self_id in [str(uid) for uid in mentions]: return True reply = note.get("reply") if reply and isinstance(reply, dict): reply_user_id = str(reply.get("user", {}).get("id", "")) - if reply_user_id == str(bot_user_id): + if reply_user_id == self.client_self_id: return bool(self._bot_username and f"@{self._bot_username}" in text) return False @@ -227,7 +230,6 @@ async def send_by_session( if user_id and is_valid_user_session_id(user_id): await self.api.send_message(user_id, text) - logger.debug("[Misskey] 私信发送成功") else: await self.api.create_note( text, @@ -235,7 +237,6 @@ async def send_by_session( visible_user_ids=visible_user_ids, local_only=self.local_only, ) - logger.debug("[Misskey] 帖子发送成功") except Exception as e: logger.error(f"[Misskey] 发送消息失败: {e}") @@ -243,7 +244,6 @@ async def send_by_session( return await super().send_by_session(session, message_chain) def _create_file_component(self, file_info: Dict[str, Any]): - """根据文件类型创建相应的消息组件""" file_url = file_info.get("url", "") file_name = file_info.get("name", "未知文件") file_type = file_info.get("type", "") @@ -258,10 +258,6 @@ def _create_file_component(self, file_info: Dict[str, Any]): return Comp.File(name=file_name, url=file_url), f"文件[{file_name}]" async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: - logger.debug( - f"[Misskey] 接收到完整帖子数据:\n{json.dumps(raw_data, ensure_ascii=False, indent=2)}" - ) - message = AstrBotMessage() message.raw_message = raw_data message.message = [] @@ -272,7 +268,11 @@ async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: nickname=sender.get("name", sender.get("username", "")), ) - message.session_id = message.sender.user_id + message.session_id = ( + f"note:{message.sender.user_id}" + if message.sender.user_id + else "note:unknown" + ) message.message_id = str(raw_data.get("id", "")) message.self_id = self.client_self_id message.type = MessageType.FRIEND_MESSAGE @@ -301,22 +301,11 @@ async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: files = raw_data.get("files", []) if files: - logger.debug(f"[Misskey] 检测到 {len(files)} 个附件") - for i, file_info in enumerate(files): - file_type = file_info.get("type", "") - file_name = file_info.get("name", "未知文件") - logger.debug( - f"[Misskey] 附件 {i + 1}: 名称={file_name}, 类型={file_type}" - ) - + for file_info in files: component, part_text = self._create_file_component(file_info) message.message.append(component) message_parts.append(part_text) - logger.debug(f"[Misskey] 最终消息组件数量: {len(message.message)}") - for i, comp in enumerate(message.message): - logger.debug(f"[Misskey] 组件 {i}: {type(comp).__name__} - {comp}") - message.message_str = ( " ".join(part for part in message_parts if part.strip()) if message_parts @@ -324,8 +313,45 @@ async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: ) return message + async def convert_chat_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: + message = AstrBotMessage() + message.raw_message = raw_data + message.message = [] + + sender = raw_data.get("fromUser", {}) + sender_id = str(sender.get("id", "") or raw_data.get("fromUserId", "")) + + message.sender = MessageMember( + user_id=sender_id, + nickname=sender.get("name", sender.get("username", "")), + ) + + message.session_id = f"chat:{sender_id}" if sender_id else "chat:unknown" + message.message_id = str(raw_data.get("id", "")) + message.self_id = self.client_self_id + message.type = MessageType.FRIEND_MESSAGE + + self._user_cache[message.sender.user_id] = { + "username": sender.get("username", ""), + "nickname": message.sender.nickname, + "visibility": "specified", + "visible_user_ids": [self.client_self_id, message.sender.user_id], + } + + raw_text = raw_data.get("text", "") + if raw_text: + message.message.append(Comp.Plain(raw_text)) + + files = raw_data.get("files", []) + if files: + for file_info in files: + component, _ = self._create_file_component(file_info) + message.message.append(component) + + message.message_str = raw_text if raw_text else "" + return message + async def terminate(self): - logger.info("[Misskey] 终止适配器") self._running = False if self.api: await self.api.close() diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index b7611c101..71e347ba2 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -1,11 +1,13 @@ import json -from typing import Any, Optional, Dict, List +from typing import Any, Optional, Dict, List, Callable, Awaitable +import uuid try: import aiohttp + import websockets except ImportError as e: raise ImportError( - "aiohttp is required for Misskey API. Please install it with: pip install aiohttp" + "aiohttp and websockets are required for Misskey API. Please install them with: pip install aiohttp websockets" ) from e from astrbot.api import logger @@ -40,6 +42,150 @@ class AuthenticationError(APIError): pass +class WebSocketError(APIError): + pass + + +class StreamingClient: + def __init__(self, instance_url: str, access_token: str): + self.instance_url = instance_url.rstrip("/") + self.access_token = access_token + self.websocket: Optional[Any] = None + self.is_connected = False + self.message_handlers: Dict[str, Callable] = {} + self.channels: Dict[str, str] = {} + self._running = False + + async def connect(self) -> bool: + try: + ws_url = self.instance_url.replace("https://", "wss://").replace( + "http://", "ws://" + ) + ws_url += f"/streaming?i={self.access_token}" + + self.websocket = await websockets.connect(ws_url) + self.is_connected = True + self._running = True + + logger.info("[Misskey WebSocket] 连接成功") + return True + + except Exception as e: + logger.error(f"[Misskey WebSocket] 连接失败: {e}") + self.is_connected = False + return False + + async def disconnect(self): + self._running = False + if self.websocket: + await self.websocket.close() + self.websocket = None + self.is_connected = False + logger.info("[Misskey WebSocket] 连接已断开") + + async def subscribe_channel( + self, channel_type: str, params: Optional[Dict] = None + ) -> str: + if not self.is_connected or not self.websocket: + raise WebSocketError("WebSocket 未连接") + + channel_id = str(uuid.uuid4()) + message = { + "type": "connect", + "body": {"channel": channel_type, "id": channel_id, "params": params or {}}, + } + + await self.websocket.send(json.dumps(message)) + self.channels[channel_id] = channel_type + 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 + ): + return + + message = {"type": "disconnect", "body": {"id": channel_id}} + + await self.websocket.send(json.dumps(message)) + del self.channels[channel_id] + + def add_message_handler( + self, event_type: str, handler: Callable[[Dict], Awaitable[None]] + ): + self.message_handlers[event_type] = handler + + async def listen(self): + if not self.is_connected or not self.websocket: + raise WebSocketError("WebSocket 未连接") + + try: + async for message in self.websocket: + if not self._running: + break + + try: + data = json.loads(message) + await self._handle_message(data) + except json.JSONDecodeError as e: + logger.warning(f"[Misskey WebSocket] 无法解析消息: {e}") + except Exception as e: + logger.error(f"[Misskey WebSocket] 处理消息失败: {e}") + + except websockets.exceptions.ConnectionClosed: + logger.warning("[Misskey WebSocket] 连接已关闭") + self.is_connected = False + except Exception as e: + logger.error(f"[Misskey WebSocket] 监听消息失败: {e}") + self.is_connected = False + + async def _handle_message(self, data: Dict[str, Any]): + message_type = data.get("type") + body = data.get("body", {}) + + logger.debug( + f"[Misskey WebSocket] 收到消息:\n{json.dumps(data, indent=2, ensure_ascii=False)}" + ) + + if message_type == "channel": + channel_id = body.get("id") + event_type = body.get("type") + event_body = body.get("body", {}) + + if channel_id in self.channels: + channel_type = self.channels[channel_id] + handler_key = f"{channel_type}:{event_type}" + + if handler_key in self.message_handlers: + logger.debug(f"[Misskey WebSocket] 使用处理器: {handler_key}") + await self.message_handlers[handler_key](event_body) + elif event_type in self.message_handlers: + logger.debug(f"[Misskey WebSocket] 使用事件处理器: {event_type}") + await self.message_handlers[event_type](event_body) + else: + logger.debug( + f"[Misskey WebSocket] 未找到处理器: {handler_key} 或 {event_type}" + ) + if "_debug" in self.message_handlers: + await self.message_handlers["_debug"]( + { + "type": event_type, + "body": event_body, + "channel": channel_type, + } + ) + + elif message_type in self.message_handlers: + logger.debug(f"[Misskey WebSocket] 直接消息处理器: {message_type}") + await self.message_handlers[message_type](body) + else: + logger.debug(f"[Misskey WebSocket] 未处理的消息类型: {message_type}") + if "_debug" in self.message_handlers: + await self.message_handlers["_debug"](data) + + def retry_async(max_retries: int = 3, retryable_exceptions: tuple = ()): def decorator(func): async def wrapper(*args, **kwargs): @@ -63,6 +209,7 @@ def __init__(self, instance_url: str, access_token: str): self.instance_url = instance_url.rstrip("/") self.access_token = access_token self._session: Optional[aiohttp.ClientSession] = None + self.streaming: Optional[StreamingClient] = None async def __aenter__(self): return self @@ -72,11 +219,19 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): return False async def close(self) -> None: + if self.streaming: + await self.streaming.disconnect() + self.streaming = None if self._session: await self._session.close() self._session = None logger.debug("Misskey API 客户端已关闭") + def get_streaming_client(self) -> StreamingClient: + if not self.streaming: + self.streaming = StreamingClient(self.instance_url, self.access_token) + return self.streaming + @property def session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: @@ -189,6 +344,20 @@ async def send_message(self, user_id: str, text: str) -> Dict[str, Any]: logger.debug(f"Misskey 聊天发送成功,message_id: {message_id}") return result + async def get_messages( + self, user_id: str, limit: int = 10, since_id: Optional[str] = None + ) -> List[Dict[str, Any]]: + data: Dict[str, Any] = {"userId": user_id, "limit": limit} + if since_id: + data["sinceId"] = since_id + + result = await self._make_request("chat/messages/user-timeline", data) + if isinstance(result, list): + return result + else: + logger.warning(f"获取聊天消息响应格式异常: {type(result)}") + return [] + async def get_mentions( self, limit: int = 10, since_id: Optional[str] = None ) -> List[Dict[str, Any]]: diff --git a/astrbot/core/platform/sources/misskey/misskey_event.py b/astrbot/core/platform/sources/misskey/misskey_event.py index 475bd81cb..9dd3dc3ac 100644 --- a/astrbot/core/platform/sources/misskey/misskey_event.py +++ b/astrbot/core/platform/sources/misskey/misskey_event.py @@ -7,6 +7,7 @@ resolve_visibility_from_raw_message, is_valid_user_session_id, add_at_mention_if_needed, + extract_user_id_from_session_id, ) @@ -22,6 +23,26 @@ def __init__( super().__init__(message_str, message_obj, platform_meta, session_id) self.client = client + # 检测系统指令,如果是系统指令则阻止进入LLM对话历史 + if self._is_system_command(message_str): + self.should_call_llm(True) + + def _is_system_command(self, message_str: str) -> bool: + """检测是否为系统指令""" + if not message_str or not message_str.strip(): + return False + + # 常见的系统指令前缀 + system_prefixes = ["/", "!", "#", ".", "^"] + message_trimmed = message_str.strip() + + # 检查是否以系统指令前缀开头 + for prefix in system_prefixes: + if message_trimmed.startswith(prefix): + return True + + return False + async def send(self, message: MessageChain): content, has_at = serialize_message_chain(message.chain) @@ -41,7 +62,14 @@ async def send(self, message: MessageChain): } content = add_at_mention_if_needed(content, user_info, has_at) - if original_message_id and hasattr(self.client, "create_note"): + # 对于聊天消息(私信),优先使用聊天API + 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) + return + elif original_message_id and hasattr(self.client, "create_note"): visibility, visible_user_ids = resolve_visibility_from_raw_message( raw_message ) @@ -52,12 +80,6 @@ async def send(self, message: MessageChain): visibility=visibility, visible_user_ids=visible_user_ids, ) - elif hasattr(self.client, "send_message") and is_valid_user_session_id( - self.session_id - ): - logger.debug(f"[MisskeyEvent] 发送私信: {self.session_id}") - await self.client.send_message(str(self.session_id), content) - return elif hasattr(self.client, "create_note"): logger.debug("[MisskeyEvent] 创建新帖子") await self.client.create_note(content) diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index 1d79b68d9..1ce047b4b 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -102,10 +102,27 @@ def resolve_visibility_from_raw_message( def is_valid_user_session_id(session_id: Union[str, Any]) -> bool: - """检查是否为有效的用户会话ID""" + """检查 session_id 是否是有效的聊天用户 session_id (仅限chat:前缀)""" if not isinstance(session_id, str): return False - return 5 <= len(session_id) <= 64 and " " not in session_id + + if ":" in session_id: + parts = session_id.split(":") + # 只有聊天格式 chat:user_id 才返回True + if len(parts) == 2 and parts[0] == "chat": + return bool(parts[1] and parts[1] != "unknown") + + return False + + +def extract_user_id_from_session_id(session_id: str) -> str: + """从 session_id 中提取用户 ID""" + if ":" in session_id: + parts = session_id.split(":") + if len(parts) >= 2: + # 支持 chat:user_id, note:user_id 等格式 + return parts[1] + return session_id def add_at_mention_if_needed( From 4a629f49ece97fb8dc5cbeadc5cc03548842f9fb Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 18 Sep 2025 14:59:29 +0800 Subject: [PATCH 16/26] =?UTF-8?q?fix:=20=E5=A2=9E=E5=BC=BA=20Misskey=20Web?= =?UTF-8?q?Socket=20=E6=B6=88=E6=81=AF=E6=97=A5=E5=BF=97=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/misskey/misskey_api.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 71e347ba2..585bf25db 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -146,7 +146,7 @@ async def _handle_message(self, data: Dict[str, Any]): body = data.get("body", {}) logger.debug( - f"[Misskey WebSocket] 收到消息:\n{json.dumps(data, indent=2, ensure_ascii=False)}" + f"[Misskey WebSocket] 收到消息类型: {message_type}\n数据: {json.dumps(data, indent=2, ensure_ascii=False)}" ) if message_type == "channel": @@ -154,6 +154,10 @@ async def _handle_message(self, data: Dict[str, Any]): event_type = body.get("type") event_body = body.get("body", {}) + logger.debug( + f"[Misskey WebSocket] 频道消息: {channel_id}, 事件类型: {event_type}" + ) + if channel_id in self.channels: channel_type = self.channels[channel_id] handler_key = f"{channel_type}:{event_type}" From a818fe6e12350072c9e6374763e0aed0cd863335 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 18 Sep 2025 15:44:42 +0800 Subject: [PATCH 17/26] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=20Misskey?= =?UTF-8?q?=20=E9=80=82=E9=85=8D=E5=99=A8=E7=9A=84=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=92=8C=E6=97=A5=E5=BF=97=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sources/misskey/misskey_adapter.py | 117 +++++++++++------- .../platform/sources/misskey/misskey_api.py | 57 +++++---- .../platform/sources/misskey/misskey_event.py | 2 +- .../platform/sources/misskey/misskey_utils.py | 19 ++- 4 files changed, 113 insertions(+), 82 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 1e0727c38..13562e5d9 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -79,7 +79,7 @@ async def run(self): self.client_self_id = str(user_info.get("id", "")) self._bot_username = user_info.get("username", "") logger.info( - f"[Misskey] 用户: {user_info.get('username', '')} (ID: {self.client_self_id})" + f"[Misskey] 已连接用户: {self._bot_username} (ID: {self.client_self_id})" ) except Exception as e: logger.error(f"[Misskey] 获取用户信息失败: {e}") @@ -102,21 +102,18 @@ async def _start_websocket_connection(self): streaming = self.api.get_streaming_client() streaming.add_message_handler("notification", self._handle_notification) if self.enable_chat: - # Misskey 聊天消息的正确事件名称 streaming.add_message_handler( "newChatMessage", self._handle_chat_message ) - # 添加通用事件处理器来调试 streaming.add_message_handler("_debug", self._debug_handler) if await streaming.connect(): - logger.info("[Misskey] WebSocket 连接成功") + logger.info("[Misskey] WebSocket 已连接") await streaming.subscribe_channel("main") if self.enable_chat: - # 尝试订阅多个可能的聊天频道 await streaming.subscribe_channel("messaging") await streaming.subscribe_channel("messagingIndex") - logger.info("[Misskey] 已订阅聊天频道") + logger.info("[Misskey] 聊天频道已订阅") backoff_delay = 1.0 await streaming.listen() @@ -140,7 +137,9 @@ async def _handle_notification(self, data: Dict[str, Any]): if notification_type in ["mention", "reply", "quote"]: note = data.get("note") if note and self._is_bot_mentioned(note): - logger.info(f"[Misskey] 处理贴文提及: {note.get('text', '')}") + logger.info( + f"[Misskey] 处理贴文提及: {note.get('text', '')[:50]}..." + ) message = await self.convert_message(note) event = MisskeyPlatformEvent( message_str=message.message_str, @@ -163,7 +162,7 @@ async def _handle_chat_message(self, data: Dict[str, Any]): return message = await self.convert_chat_message(data) - logger.info(f"[Misskey] 处理聊天消息: {message.message_str}") + logger.info(f"[Misskey] 处理聊天消息: {message.message_str[:50]}...") event = MisskeyPlatformEvent( message_str=message.message_str, message_obj=message, @@ -244,6 +243,7 @@ async def send_by_session( return await super().send_by_session(session, message_chain) def _create_file_component(self, file_info: Dict[str, Any]): + """创建文件组件和描述文本""" file_url = file_info.get("url", "") file_name = file_info.get("name", "未知文件") file_type = file_info.get("type", "") @@ -257,32 +257,80 @@ def _create_file_component(self, file_info: Dict[str, Any]): else: return Comp.File(name=file_name, url=file_url), f"文件[{file_name}]" - async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: + def _extract_sender_info(self, raw_data: Dict[str, Any], is_chat: bool = False): + """提取发送者信息""" + if is_chat: + sender = raw_data.get("fromUser", {}) + sender_id = str(sender.get("id", "") or raw_data.get("fromUserId", "")) + else: + sender = raw_data.get("user", {}) + sender_id = str(sender.get("id", "")) + + return { + "sender": sender, + "sender_id": sender_id, + "nickname": sender.get("name", sender.get("username", "")), + "username": sender.get("username", ""), + } + + def _create_base_message( + self, + raw_data: Dict[str, Any], + sender_info: Dict[str, Any], + is_chat: bool = False, + ) -> AstrBotMessage: + """创建基础消息对象""" message = AstrBotMessage() message.raw_message = raw_data message.message = [] - sender = raw_data.get("user", {}) message.sender = MessageMember( - user_id=str(sender.get("id", "")), - nickname=sender.get("name", sender.get("username", "")), + user_id=sender_info["sender_id"], + nickname=sender_info["nickname"], ) + session_prefix = "chat" if is_chat else "note" message.session_id = ( - f"note:{message.sender.user_id}" - if message.sender.user_id - else "note:unknown" + f"{session_prefix}:{sender_info['sender_id']}" + if sender_info["sender_id"] + else f"{session_prefix}:unknown" ) + message.message_id = str(raw_data.get("id", "")) message.self_id = self.client_self_id message.type = MessageType.FRIEND_MESSAGE - self._user_cache[message.sender.user_id] = { - "username": sender.get("username", ""), - "nickname": message.sender.nickname, - "visibility": raw_data.get("visibility", "public"), - "visible_user_ids": raw_data.get("visibleUserIds", []), - } + return message + + def _cache_user_info( + self, + sender_info: Dict[str, Any], + raw_data: Dict[str, Any], + is_chat: bool = False, + ): + """缓存用户信息""" + if is_chat: + user_cache_data = { + "username": sender_info["username"], + "nickname": sender_info["nickname"], + "visibility": "specified", + "visible_user_ids": [self.client_self_id, sender_info["sender_id"]], + } + else: + user_cache_data = { + "username": sender_info["username"], + "nickname": sender_info["nickname"], + "visibility": raw_data.get("visibility", "public"), + "visible_user_ids": raw_data.get("visibleUserIds", []), + } + + self._user_cache[sender_info["sender_id"]] = user_cache_data + + async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: + """转换贴文消息""" + sender_info = self._extract_sender_info(raw_data, is_chat=False) + message = self._create_base_message(raw_data, sender_info, is_chat=False) + self._cache_user_info(sender_info, raw_data, is_chat=False) message_parts = [] raw_text = raw_data.get("text", "") @@ -314,29 +362,10 @@ async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: return message async def convert_chat_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: - message = AstrBotMessage() - message.raw_message = raw_data - message.message = [] - - sender = raw_data.get("fromUser", {}) - sender_id = str(sender.get("id", "") or raw_data.get("fromUserId", "")) - - message.sender = MessageMember( - user_id=sender_id, - nickname=sender.get("name", sender.get("username", "")), - ) - - message.session_id = f"chat:{sender_id}" if sender_id else "chat:unknown" - message.message_id = str(raw_data.get("id", "")) - message.self_id = self.client_self_id - message.type = MessageType.FRIEND_MESSAGE - - self._user_cache[message.sender.user_id] = { - "username": sender.get("username", ""), - "nickname": message.sender.nickname, - "visibility": "specified", - "visible_user_ids": [self.client_self_id, message.sender.user_id], - } + """转换聊天消息""" + sender_info = self._extract_sender_info(raw_data, is_chat=True) + message = self._create_base_message(raw_data, sender_info, is_chat=True) + self._cache_user_info(sender_info, raw_data, is_chat=True) raw_text = raw_data.get("text", "") if raw_text: diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 585bf25db..fd66ef46e 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -15,34 +15,35 @@ # Constants API_MAX_RETRIES = 3 HTTP_OK = 200 -HTTP_BAD_REQUEST = 400 -HTTP_UNAUTHORIZED = 401 -HTTP_FORBIDDEN = 403 -HTTP_TOO_MANY_REQUESTS = 429 -# Exceptions class APIError(Exception): - pass - + """Misskey API 基础异常""" -class APIBadRequestError(APIError): pass class APIConnectionError(APIError): + """网络连接异常""" + pass class APIRateLimitError(APIError): + """API 频率限制异常""" + pass class AuthenticationError(APIError): + """认证失败异常""" + pass class WebSocketError(APIError): + """WebSocket 连接异常""" + pass @@ -67,7 +68,7 @@ async def connect(self) -> bool: self.is_connected = True self._running = True - logger.info("[Misskey WebSocket] 连接成功") + logger.info("[Misskey WebSocket] 已连接") return True except Exception as e: @@ -229,7 +230,7 @@ async def close(self) -> None: if self._session: await self._session.close() self._session = None - logger.debug("Misskey API 客户端已关闭") + logger.debug("[Misskey API] 客户端已关闭") def get_streaming_client(self) -> StreamingClient: if not self.streaming: @@ -244,13 +245,14 @@ def session(self) -> aiohttp.ClientSession: return self._session def _handle_response_status(self, status: int, endpoint: str): - if status == HTTP_BAD_REQUEST: + """处理 HTTP 响应状态码""" + if status == 400: logger.error(f"API 请求错误: {endpoint} (状态码: {status})") - raise APIBadRequestError(f"Bad request for {endpoint}") - elif status in (HTTP_UNAUTHORIZED, HTTP_FORBIDDEN): + 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 == HTTP_TOO_MANY_REQUESTS: + elif status == 429: logger.warning(f"API 频率限制: {endpoint} (状态码: {status})") raise APIRateLimitError(f"Rate limit exceeded for {endpoint}") else: @@ -260,9 +262,11 @@ def _handle_response_status(self, status: int, endpoint: str): async def _process_response( self, response: aiohttp.ClientResponse, endpoint: str ) -> Any: + """处理 API 响应""" if response.status == HTTP_OK: try: result = await response.json() + # 特殊处理通知接口的响应格式 if endpoint == "i/notifications": notifications_data = ( result @@ -271,12 +275,10 @@ async def _process_response( if isinstance(result, dict) else [] ) - if len(notifications_data) > 0: - logger.debug( - f"Misskey API 获取到 {len(notifications_data)} 条新通知" - ) + if notifications_data: + logger.debug(f"获取到 {len(notifications_data)} 条新通知") else: - logger.debug(f"Misskey API 请求成功: {endpoint}") + logger.debug(f"API 请求成功: {endpoint}") return result except json.JSONDecodeError as e: logger.error(f"响应不是有效的 JSON 格式: {e}") @@ -288,9 +290,7 @@ async def _process_response( f"API 请求失败: {endpoint} - 状态码: {response.status}, 响应: {error_text}" ) except Exception: - logger.error( - f"API 请求失败: {endpoint} - 状态码: {response.status}, 无法读取错误响应" - ) + logger.error(f"API 请求失败: {endpoint} - 状态码: {response.status}") self._handle_response_status(response.status, endpoint) raise APIConnectionError(f"Request failed for {endpoint}") @@ -322,6 +322,7 @@ async def create_note( visible_user_ids: Optional[List[str]] = None, local_only: bool = False, ) -> Dict[str, Any]: + """创建新贴文""" data: Dict[str, Any] = { "text": text, "visibility": visibility, @@ -334,23 +335,26 @@ async def create_note( result = await self._make_request("notes/create", data) note_id = result.get("createdNote", {}).get("id", "unknown") - logger.debug(f"Misskey 发帖成功,note_id: {note_id}") + logger.debug(f"发帖成功,note_id: {note_id}") return result 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} ) message_id = result.get("id", "unknown") - logger.debug(f"Misskey 聊天发送成功,message_id: {message_id}") + logger.debug(f"聊天发送成功,message_id: {message_id}") return result async def get_messages( self, user_id: str, limit: int = 10, since_id: Optional[str] = None ) -> List[Dict[str, Any]]: + """获取聊天消息历史""" data: Dict[str, Any] = {"userId": user_id, "limit": limit} if since_id: data["sinceId"] = since_id @@ -365,6 +369,7 @@ async def get_messages( async def get_mentions( self, limit: int = 10, since_id: Optional[str] = None ) -> List[Dict[str, Any]]: + """获取提及通知""" data: Dict[str, Any] = {"limit": limit} if since_id: data["sinceId"] = since_id @@ -376,7 +381,5 @@ async def get_mentions( elif isinstance(result, dict) and "notifications" in result: return result["notifications"] else: - logger.warning( - f"获取提及通知响应格式异常: {type(result)}, 响应内容: {result}" - ) + logger.warning(f"获取提及通知响应格式异常: {type(result)}") return [] diff --git a/astrbot/core/platform/sources/misskey/misskey_event.py b/astrbot/core/platform/sources/misskey/misskey_event.py index 9dd3dc3ac..65c32300c 100644 --- a/astrbot/core/platform/sources/misskey/misskey_event.py +++ b/astrbot/core/platform/sources/misskey/misskey_event.py @@ -1,4 +1,4 @@ -from astrbot import logger +from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.platform import PlatformMetadata, AstrBotMessage diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index 1ce047b4b..c901f7027 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -21,7 +21,7 @@ def process_component(component): return f"@{component.qq}" elif hasattr(component, "text"): text = getattr(component, "text", "") - if text and "@" in text: + if "@" in text: has_at = True return text else: @@ -103,16 +103,16 @@ def resolve_visibility_from_raw_message( def is_valid_user_session_id(session_id: Union[str, Any]) -> bool: """检查 session_id 是否是有效的聊天用户 session_id (仅限chat:前缀)""" - if not isinstance(session_id, str): + if not isinstance(session_id, str) or ":" not in session_id: return False - if ":" in session_id: - parts = session_id.split(":") - # 只有聊天格式 chat:user_id 才返回True - if len(parts) == 2 and parts[0] == "chat": - return bool(parts[1] and parts[1] != "unknown") - - 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: @@ -120,7 +120,6 @@ def extract_user_id_from_session_id(session_id: str) -> str: if ":" in session_id: parts = session_id.split(":") if len(parts) >= 2: - # 支持 chat:user_id, note:user_id 等格式 return parts[1] return session_id From a71fd9bf9a09869f1aaaad9f4e99690e3553ae21 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 18 Sep 2025 15:51:24 +0800 Subject: [PATCH 18/26] =?UTF-8?q?fix:=20=E5=A2=9E=E5=BC=BA=20Misskey=20Web?= =?UTF-8?q?Socket=20=E9=87=8D=E8=BF=9E=E6=8E=A5=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 | 21 ++++++++++++++----- .../platform/sources/misskey/misskey_api.py | 17 ++++++++++++--- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 13562e5d9..ff374b439 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -92,9 +92,11 @@ async def _start_websocket_connection(self): backoff_delay = 1.0 max_backoff = 300.0 backoff_multiplier = 1.5 + connection_attempts = 0 while self._running: try: + connection_attempts += 1 if not self.api: logger.error("[Misskey] API 客户端未初始化") break @@ -108,23 +110,32 @@ async def _start_websocket_connection(self): streaming.add_message_handler("_debug", self._debug_handler) if await streaming.connect(): - logger.info("[Misskey] WebSocket 已连接") + logger.info( + f"[Misskey] WebSocket 已连接 (尝试 #{connection_attempts})" + ) + 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("[Misskey] WebSocket 连接失败") + logger.error( + f"[Misskey] WebSocket 连接失败 (尝试 #{connection_attempts})" + ) except Exception as e: - logger.error(f"[Misskey] WebSocket 异常: {e}") + logger.error( + f"[Misskey] WebSocket 异常 (尝试 #{connection_attempts}): {e}" + ) if self._running: - logger.info(f"[Misskey] {backoff_delay:.1f}秒后重连") + logger.info( + f"[Misskey] {backoff_delay:.1f}秒后重连 (下次尝试 #{connection_attempts + 1})" + ) await asyncio.sleep(backoff_delay) backoff_delay = min(backoff_delay * backoff_multiplier, max_backoff) diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index fd66ef46e..3dc0c8c42 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -56,6 +56,7 @@ def __init__(self, instance_url: str, access_token: str): self.message_handlers: Dict[str, Callable] = {} self.channels: Dict[str, str] = {} self._running = False + self._last_pong = None async def connect(self) -> bool: try: @@ -64,7 +65,9 @@ async def connect(self) -> bool: ) ws_url += f"/streaming?i={self.access_token}" - self.websocket = await websockets.connect(ws_url) + self.websocket = await websockets.connect( + ws_url, ping_interval=30, ping_timeout=10 + ) self.is_connected = True self._running = True @@ -135,8 +138,16 @@ async def listen(self): except Exception as e: logger.error(f"[Misskey WebSocket] 处理消息失败: {e}") - except websockets.exceptions.ConnectionClosed: - logger.warning("[Misskey WebSocket] 连接已关闭") + except websockets.exceptions.ConnectionClosedError as e: + logger.warning(f"[Misskey WebSocket] 连接意外关闭: {e}") + self.is_connected = False + except websockets.exceptions.ConnectionClosed as e: + logger.warning( + f"[Misskey WebSocket] 连接已关闭 (代码: {e.code}, 原因: {e.reason})" + ) + self.is_connected = False + except websockets.exceptions.InvalidHandshake as e: + logger.error(f"[Misskey WebSocket] 握手失败: {e}") self.is_connected = False except Exception as e: logger.error(f"[Misskey WebSocket] 监听消息失败: {e}") From 30b76da6e8bc200f6572f10c1f5275c489794956 Mon Sep 17 00:00:00 2001 From: PaloMiku Date: Thu, 18 Sep 2025 22:08:29 +0800 Subject: [PATCH 19/26] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20Misskey=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E7=9A=84=E6=B6=88=E6=81=AF=E5=A4=84?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E6=94=AF=E6=8C=81=E6=88=BF=E9=97=B4=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=92=8C=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=80=9A=E7=94=A8=E5=87=BD=E6=95=B0=EF=BC=8C?= =?UTF-8?q?=E6=B8=85=E7=90=86=E4=BB=A3=E7=A0=81=E9=87=8D=E5=A4=8D=E5=86=97?= =?UTF-8?q?=E4=BD=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sources/misskey/misskey_adapter.py | 228 ++++++++---------- .../platform/sources/misskey/misskey_api.py | 10 +- .../platform/sources/misskey/misskey_event.py | 18 +- .../platform/sources/misskey/misskey_utils.py | 181 +++++++++++++- 4 files changed, 300 insertions(+), 137 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index ff374b439..9f2a03b3b 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -6,8 +6,6 @@ from astrbot.api.event import MessageChain from astrbot.api.platform import ( AstrBotMessage, - MessageMember, - MessageType, Platform, PlatformMetadata, register_platform_adapter, @@ -21,7 +19,14 @@ serialize_message_chain, resolve_message_visibility, is_valid_user_session_id, + is_valid_room_session_id, add_at_mention_if_needed, + process_files, + extract_sender_info, + create_base_message, + process_at_mention, + cache_user_info, + cache_room_info, ) @@ -168,12 +173,31 @@ async def _handle_chat_message(self, data: Dict[str, Any]): logger.debug( f"[Misskey] 收到聊天事件数据:\n{json.dumps(data, indent=2, ensure_ascii=False)}" ) - sender_id = str(data.get("user", {}).get("id", "")) + + sender_id = str( + data.get("fromUserId", "") or data.get("fromUser", {}).get("id", "") + ) if sender_id == self.client_self_id: return - message = await self.convert_chat_message(data) - logger.info(f"[Misskey] 处理聊天消息: {message.message_str[:50]}...") + room_id = data.get("toRoomId") + if room_id: + raw_text = data.get("text", "") + logger.debug( + f"[Misskey] 检查群聊消息: '{raw_text}', 机器人用户名: '{self._bot_username}'" + ) + if not (self._bot_username and f"@{self._bot_username}" in raw_text): + logger.debug( + f"[Misskey] 群聊消息未@机器人,跳过处理: {raw_text[:30]}..." + ) + return + + message = await self.convert_room_message(data) + logger.info(f"[Misskey] 处理群聊消息: {message.message_str[:50]}...") + else: + message = await self.convert_chat_message(data) + logger.info(f"[Misskey] 处理私聊消息: {message.message_str[:50]}...") + event = MisskeyPlatformEvent( message_str=message.message_str, message_obj=message, @@ -217,11 +241,11 @@ async def send_by_session( return await super().send_by_session(session, message_chain) try: - user_id = session.session_id + session_id = session.session_id text, has_at_user = serialize_message_chain(message_chain.chain) - if not has_at_user and user_id: - user_info = self._user_cache.get(user_id) + 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) if not text or not text.strip(): @@ -231,16 +255,24 @@ async def send_by_session( if len(text) > self.max_message_length: text = text[: self.max_message_length] + "..." - visibility, visible_user_ids = resolve_message_visibility( - user_id=user_id, - user_cache=self._user_cache, - self_id=self.client_self_id, - default_visibility=self.default_visibility, - ) + if session_id and is_valid_user_session_id(session_id): + from .misskey_utils import extract_user_id_from_session_id - if user_id and is_valid_user_session_id(user_id): + user_id = extract_user_id_from_session_id(session_id) await self.api.send_message(user_id, text) + 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) 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, visibility=visibility, @@ -253,117 +285,27 @@ async def send_by_session( return await super().send_by_session(session, message_chain) - def _create_file_component(self, file_info: Dict[str, Any]): - """创建文件组件和描述文本""" - file_url = file_info.get("url", "") - file_name = file_info.get("name", "未知文件") - file_type = file_info.get("type", "") - - if file_type.startswith("image/"): - return Comp.Image(url=file_url, file=file_name), f"图片[{file_name}]" - elif file_type.startswith("audio/"): - return Comp.Record(url=file_url, file=file_name), f"音频[{file_name}]" - elif file_type.startswith("video/"): - return Comp.Video(url=file_url, file=file_name), f"视频[{file_name}]" - else: - return Comp.File(name=file_name, url=file_url), f"文件[{file_name}]" - - def _extract_sender_info(self, raw_data: Dict[str, Any], is_chat: bool = False): - """提取发送者信息""" - if is_chat: - sender = raw_data.get("fromUser", {}) - sender_id = str(sender.get("id", "") or raw_data.get("fromUserId", "")) - else: - sender = raw_data.get("user", {}) - sender_id = str(sender.get("id", "")) - - return { - "sender": sender, - "sender_id": sender_id, - "nickname": sender.get("name", sender.get("username", "")), - "username": sender.get("username", ""), - } - - def _create_base_message( - self, - raw_data: Dict[str, Any], - sender_info: Dict[str, Any], - is_chat: bool = False, - ) -> AstrBotMessage: - """创建基础消息对象""" - message = AstrBotMessage() - message.raw_message = raw_data - message.message = [] - - message.sender = MessageMember( - user_id=sender_info["sender_id"], - nickname=sender_info["nickname"], + async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: + sender_info = extract_sender_info(raw_data, is_chat=False) + message = create_base_message( + raw_data, sender_info, self.client_self_id, is_chat=False ) - - session_prefix = "chat" if is_chat else "note" - message.session_id = ( - f"{session_prefix}:{sender_info['sender_id']}" - if sender_info["sender_id"] - else f"{session_prefix}:unknown" + cache_user_info( + self._user_cache, sender_info, raw_data, self.client_self_id, is_chat=False ) - message.message_id = str(raw_data.get("id", "")) - message.self_id = self.client_self_id - message.type = MessageType.FRIEND_MESSAGE - - return message - - def _cache_user_info( - self, - sender_info: Dict[str, Any], - raw_data: Dict[str, Any], - is_chat: bool = False, - ): - """缓存用户信息""" - if is_chat: - user_cache_data = { - "username": sender_info["username"], - "nickname": sender_info["nickname"], - "visibility": "specified", - "visible_user_ids": [self.client_self_id, sender_info["sender_id"]], - } - else: - user_cache_data = { - "username": sender_info["username"], - "nickname": sender_info["nickname"], - "visibility": raw_data.get("visibility", "public"), - "visible_user_ids": raw_data.get("visibleUserIds", []), - } - - self._user_cache[sender_info["sender_id"]] = user_cache_data - - async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: - """转换贴文消息""" - sender_info = self._extract_sender_info(raw_data, is_chat=False) - message = self._create_base_message(raw_data, sender_info, is_chat=False) - self._cache_user_info(sender_info, raw_data, is_chat=False) - message_parts = [] raw_text = raw_data.get("text", "") if raw_text: - if self._bot_username and raw_text.startswith(f"@{self._bot_username}"): - at_mention = f"@{self._bot_username}" - message.message.append(Comp.At(qq=self.client_self_id)) - remaining_text = raw_text[len(at_mention) :].strip() - if remaining_text: - message.message.append(Comp.Plain(remaining_text)) - message_parts.append(remaining_text) - else: - message.message.append(Comp.Plain(raw_text)) - message_parts.append(raw_text) + text_parts, processed_text = process_at_mention( + message, raw_text, self._bot_username, self.client_self_id + ) + message_parts.extend(text_parts) files = raw_data.get("files", []) - if files: - for file_info in files: - component, part_text = self._create_file_component(file_info) - message.message.append(component) - message_parts.append(part_text) + file_parts = process_files(message, files) + message_parts.extend(file_parts) message.message_str = ( " ".join(part for part in message_parts if part.strip()) @@ -373,24 +315,60 @@ async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: return message async def convert_chat_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: - """转换聊天消息""" - sender_info = self._extract_sender_info(raw_data, is_chat=True) - message = self._create_base_message(raw_data, sender_info, is_chat=True) - self._cache_user_info(sender_info, raw_data, is_chat=True) + sender_info = extract_sender_info(raw_data, is_chat=True) + message = create_base_message( + raw_data, sender_info, self.client_self_id, is_chat=True + ) + cache_user_info( + self._user_cache, sender_info, raw_data, self.client_self_id, is_chat=True + ) raw_text = raw_data.get("text", "") if raw_text: message.message.append(Comp.Plain(raw_text)) files = raw_data.get("files", []) - if files: - for file_info in files: - component, _ = self._create_file_component(file_info) - message.message.append(component) + process_files(message, files, include_text_parts=False) message.message_str = raw_text if raw_text else "" return message + async def convert_room_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: + sender_info = extract_sender_info(raw_data, is_chat=True) + room_id = raw_data.get("toRoomId", "") + message = create_base_message( + raw_data, sender_info, self.client_self_id, is_chat=False, room_id=room_id + ) + + cache_user_info( + self._user_cache, sender_info, raw_data, self.client_self_id, is_chat=False + ) + cache_room_info(self._user_cache, raw_data, self.client_self_id) + + raw_text = raw_data.get("text", "") + message_parts = [] + + if raw_text: + if self._bot_username and f"@{self._bot_username}" in raw_text: + text_parts, processed_text = process_at_mention( + message, raw_text, self._bot_username, self.client_self_id + ) + message_parts.extend(text_parts) + else: + message.message.append(Comp.Plain(raw_text)) + message_parts.append(raw_text) + + files = raw_data.get("files", []) + file_parts = process_files(message, files) + message_parts.extend(file_parts) + + message.message_str = ( + " ".join(part for part in message_parts if part.strip()) + if message_parts + else "" + ) + return message + async def terminate(self): self._running = False if self.api: diff --git a/astrbot/core/platform/sources/misskey/misskey_api.py b/astrbot/core/platform/sources/misskey/misskey_api.py index 3dc0c8c42..dc4adcdd0 100644 --- a/astrbot/core/platform/sources/misskey/misskey_api.py +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -277,7 +277,6 @@ async def _process_response( if response.status == HTTP_OK: try: result = await response.json() - # 特殊处理通知接口的响应格式 if endpoint == "i/notifications": notifications_data = ( result @@ -362,6 +361,15 @@ async def send_message(self, user_id: str, text: str) -> Dict[str, Any]: 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} + ) + message_id = result.get("id", "unknown") + logger.debug(f"房间消息发送成功,message_id: {message_id}") + return result + async def get_messages( self, user_id: str, limit: int = 10, since_id: Optional[str] = None ) -> List[Dict[str, Any]]: diff --git a/astrbot/core/platform/sources/misskey/misskey_event.py b/astrbot/core/platform/sources/misskey/misskey_event.py index 65c32300c..bcd277f22 100644 --- a/astrbot/core/platform/sources/misskey/misskey_event.py +++ b/astrbot/core/platform/sources/misskey/misskey_event.py @@ -6,8 +6,10 @@ serialize_message_chain, resolve_visibility_from_raw_message, is_valid_user_session_id, + is_valid_room_session_id, add_at_mention_if_needed, extract_user_id_from_session_id, + extract_room_id_from_session_id, ) @@ -32,16 +34,10 @@ def _is_system_command(self, message_str: str) -> bool: if not message_str or not message_str.strip(): return False - # 常见的系统指令前缀 system_prefixes = ["/", "!", "#", ".", "^"] message_trimmed = message_str.strip() - # 检查是否以系统指令前缀开头 - for prefix in system_prefixes: - if message_trimmed.startswith(prefix): - return True - - return False + 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) @@ -62,13 +58,19 @@ async def send(self, message: MessageChain): } content = add_at_mention_if_needed(content, user_info, has_at) - # 对于聊天消息(私信),优先使用聊天API + # 根据会话类型选择发送方式 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) return + 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) + return elif original_message_id and hasattr(self.client, "create_note"): visibility, visible_user_ids = resolve_visibility_from_raw_message( raw_message diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index c901f7027..d73995716 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -2,6 +2,7 @@ from typing import Dict, Any, List, Tuple, Optional, Union import astrbot.api.message_components as Comp +from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType def serialize_message_chain(chain: List[Any]) -> Tuple[str, bool]: @@ -47,9 +48,7 @@ def resolve_message_visibility( self_id: Optional[str], default_visibility: str = "public", ) -> Tuple[str, Optional[List[str]]]: - """ - 解析 Misskey 消息的可见性设置 - """ + """解析 Misskey 消息的可见性设置""" visibility = default_visibility visible_user_ids = None @@ -115,6 +114,20 @@ def is_valid_user_session_id(session_id: Union[str, Any]) -> bool: ) +def is_valid_room_session_id(session_id: Union[str, Any]) -> bool: + """检查 session_id 是否是有效的房间 session_id (仅限room:前缀)""" + if not isinstance(session_id, str) or ":" not in session_id: + return False + + parts = session_id.split(":") + return ( + len(parts) == 2 + and parts[0] == "room" + 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: @@ -124,6 +137,15 @@ def extract_user_id_from_session_id(session_id: str) -> str: return session_id +def extract_room_id_from_session_id(session_id: str) -> str: + """从 session_id 中提取房间 ID""" + if ":" in session_id: + parts = session_id.split(":") + if len(parts) >= 2 and parts[0] == "room": + return parts[1] + return session_id + + def add_at_mention_if_needed( text: str, user_info: Optional[Dict[str, Any]], has_at: bool = False ) -> str: @@ -144,3 +166,156 @@ def add_at_mention_if_needed( text = f"{mention}\n{text}".strip() return text + + +def create_file_component(file_info: Dict[str, Any]) -> Tuple[Any, str]: + """创建文件组件和描述文本""" + file_url = file_info.get("url", "") + file_name = file_info.get("name", "未知文件") + file_type = file_info.get("type", "") + + if file_type.startswith("image/"): + return Comp.Image(url=file_url, file=file_name), f"图片[{file_name}]" + elif file_type.startswith("audio/"): + return Comp.Record(url=file_url, file=file_name), f"音频[{file_name}]" + elif file_type.startswith("video/"): + return Comp.Video(url=file_url, file=file_name), f"视频[{file_name}]" + else: + return Comp.File(name=file_name, url=file_url), f"文件[{file_name}]" + + +def process_files( + message: AstrBotMessage, files: list, include_text_parts: bool = True +) -> list: + """处理文件列表,添加到消息组件中并返回文本描述""" + file_parts = [] + for file_info in files: + component, part_text = create_file_component(file_info) + message.message.append(component) + if include_text_parts: + file_parts.append(part_text) + return file_parts + + +def extract_sender_info( + raw_data: Dict[str, Any], is_chat: bool = False +) -> Dict[str, Any]: + """提取发送者信息""" + if is_chat: + sender = raw_data.get("fromUser", {}) + sender_id = str(sender.get("id", "") or raw_data.get("fromUserId", "")) + else: + sender = raw_data.get("user", {}) + sender_id = str(sender.get("id", "")) + + return { + "sender": sender, + "sender_id": sender_id, + "nickname": sender.get("name", sender.get("username", "")), + "username": sender.get("username", ""), + } + + +def create_base_message( + raw_data: Dict[str, Any], + sender_info: Dict[str, Any], + client_self_id: str, + is_chat: bool = False, + room_id: Optional[str] = None, +) -> AstrBotMessage: + """创建基础消息对象""" + message = AstrBotMessage() + message.raw_message = raw_data + message.message = [] + + message.sender = MessageMember( + user_id=sender_info["sender_id"], + nickname=sender_info["nickname"], + ) + + if room_id: + session_prefix = "room" + session_id = f"{session_prefix}:{room_id}" + elif is_chat: + session_prefix = "chat" + session_id = f"{session_prefix}:{sender_info['sender_id']}" + else: + session_prefix = "note" + session_id = f"{session_prefix}:{sender_info['sender_id']}" + + message.session_id = ( + session_id if sender_info["sender_id"] else f"{session_prefix}:unknown" + ) + message.message_id = str(raw_data.get("id", "")) + message.self_id = client_self_id + message.type = MessageType.FRIEND_MESSAGE + + return message + + +def process_at_mention( + message: AstrBotMessage, raw_text: str, bot_username: str, client_self_id: str +) -> Tuple[List[str], str]: + """处理@提及逻辑,返回消息部分列表和处理后的文本""" + message_parts = [] + + if not raw_text: + return message_parts, "" + + if bot_username and raw_text.startswith(f"@{bot_username}"): + at_mention = f"@{bot_username}" + message.message.append(Comp.At(qq=client_self_id)) + remaining_text = raw_text[len(at_mention) :].strip() + if remaining_text: + message.message.append(Comp.Plain(remaining_text)) + message_parts.append(remaining_text) + return message_parts, remaining_text + else: + message.message.append(Comp.Plain(raw_text)) + message_parts.append(raw_text) + return message_parts, raw_text + + +def cache_user_info( + user_cache: Dict[str, Any], + sender_info: Dict[str, Any], + raw_data: Dict[str, Any], + client_self_id: str, + is_chat: bool = False, +): + """缓存用户信息""" + if is_chat: + user_cache_data = { + "username": sender_info["username"], + "nickname": sender_info["nickname"], + "visibility": "specified", + "visible_user_ids": [client_self_id, sender_info["sender_id"]], + } + else: + user_cache_data = { + "username": sender_info["username"], + "nickname": sender_info["nickname"], + "visibility": raw_data.get("visibility", "public"), + "visible_user_ids": raw_data.get("visibleUserIds", []), + } + + user_cache[sender_info["sender_id"]] = user_cache_data + + +def cache_room_info( + user_cache: Dict[str, Any], raw_data: Dict[str, Any], client_self_id: str +): + """缓存房间信息""" + room_data = raw_data.get("toRoom") + room_id = raw_data.get("toRoomId") + + if room_data and room_id: + room_cache_key = f"room:{room_id}" + user_cache[room_cache_key] = { + "room_id": room_id, + "room_name": room_data.get("name", ""), + "room_description": room_data.get("description", ""), + "owner_id": room_data.get("ownerId", ""), + "visibility": "specified", + "visible_user_ids": [client_self_id], + } From 2366ed54f4bd9fa966edba895b1490c81f342792 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 18 Sep 2025 22:49:40 +0800 Subject: [PATCH 20/26] =?UTF-8?q?fix:=20=E4=B8=8D=E5=B1=8F=E8=94=BD?= =?UTF-8?q?=E5=94=A4=E9=86=92=E5=89=8D=E7=BC=80=E5=AF=B9=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=20LLM=20=E7=9A=84=E5=94=A4=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/misskey/misskey_event.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_event.py b/astrbot/core/platform/sources/misskey/misskey_event.py index bcd277f22..04412f9fb 100644 --- a/astrbot/core/platform/sources/misskey/misskey_event.py +++ b/astrbot/core/platform/sources/misskey/misskey_event.py @@ -25,10 +25,6 @@ def __init__( super().__init__(message_str, message_obj, platform_meta, session_id) self.client = client - # 检测系统指令,如果是系统指令则阻止进入LLM对话历史 - if self._is_system_command(message_str): - self.should_call_llm(True) - def _is_system_command(self, message_str: str) -> bool: """检测是否为系统指令""" if not message_str or not message_str.strip(): From 3f42c7a15185aa51052878663f36b77a681a0f2c Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 18 Sep 2025 22:50:17 +0800 Subject: [PATCH 21/26] =?UTF-8?q?fix:=20=E9=80=8F=E4=BC=A0=E6=89=80?= =?UTF-8?q?=E6=9C=89=E7=9A=84=E7=BE=A4=E8=81=8A=E6=B6=88=E6=81=AF=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/misskey/misskey_adapter.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 9f2a03b3b..18fc2e2e4 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -186,11 +186,6 @@ async def _handle_chat_message(self, data: Dict[str, Any]): logger.debug( f"[Misskey] 检查群聊消息: '{raw_text}', 机器人用户名: '{self._bot_username}'" ) - if not (self._bot_username and f"@{self._bot_username}" in raw_text): - logger.debug( - f"[Misskey] 群聊消息未@机器人,跳过处理: {raw_text[:30]}..." - ) - return message = await self.convert_room_message(data) logger.info(f"[Misskey] 处理群聊消息: {message.message_str[:50]}...") From f59b44ecdf06602c5c1085bb5f3f1829171fd108 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 18 Sep 2025 22:50:41 +0800 Subject: [PATCH 22/26] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20message=5Ftyp?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/misskey/misskey_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index d73995716..0016bceb6 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -236,19 +236,21 @@ def create_base_message( if room_id: session_prefix = "room" session_id = f"{session_prefix}:{room_id}" + message.type = MessageType.GROUP_MESSAGE elif is_chat: session_prefix = "chat" session_id = f"{session_prefix}:{sender_info['sender_id']}" + message.type = MessageType.FRIEND_MESSAGE else: session_prefix = "note" session_id = f"{session_prefix}:{sender_info['sender_id']}" + message.type = MessageType.FRIEND_MESSAGE message.session_id = ( session_id if sender_info["sender_id"] else f"{session_prefix}:unknown" ) message.message_id = str(raw_data.get("id", "")) message.self_id = client_self_id - message.type = MessageType.FRIEND_MESSAGE return message From 15c4581e7979fe70a65850803b336fc7ce245a3a Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 18 Sep 2025 22:53:53 +0800 Subject: [PATCH 23/26] =?UTF-8?q?perf:=20=E5=AE=9E=E7=8E=B0=20send=5Fstrea?= =?UTF-8?q?ming=20=E4=BB=A5=E6=94=AF=E6=8F=B4=E6=B5=81=E5=BC=8F=E8=AF=B7?= =?UTF-8?q?=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../platform/sources/misskey/misskey_event.py | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/astrbot/core/platform/sources/misskey/misskey_event.py b/astrbot/core/platform/sources/misskey/misskey_event.py index 04412f9fb..ae241f4c9 100644 --- a/astrbot/core/platform/sources/misskey/misskey_event.py +++ b/astrbot/core/platform/sources/misskey/misskey_event.py @@ -1,6 +1,10 @@ +import asyncio +import re +from typing import AsyncGenerator from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, MessageChain from astrbot.api.platform import PlatformMetadata, AstrBotMessage +from astrbot.api.message_components import Plain from .misskey_utils import ( serialize_message_chain, @@ -86,3 +90,37 @@ async def send(self, message: MessageChain): except Exception as e: logger.error(f"[MisskeyEvent] 发送失败: {e}") + + async def send_streaming( + self, generator: AsyncGenerator[MessageChain, None], use_fallback: bool = False + ): + if not use_fallback: + buffer = None + async for chain in generator: + if not buffer: + buffer = chain + else: + buffer.chain.extend(chain.chain) + if not buffer: + return + buffer.squash_plain() + await self.send(buffer) + return await super().send_streaming(generator, use_fallback) + + buffer = "" + pattern = re.compile(r"[^。?!~…]+[。?!~…]+") + + async for chain in generator: + if isinstance(chain, MessageChain): + for comp in chain.chain: + if isinstance(comp, Plain): + buffer += comp.text + if any(p in buffer for p in "。?!~…"): + buffer = await self.process_buffer(buffer, pattern) + else: + await self.send(MessageChain(chain=[comp])) + await asyncio.sleep(1.5) # 限速 + + if buffer.strip(): + await self.send(MessageChain([Plain(buffer)])) + return await super().send_streaming(generator, use_fallback) From cddc0d0ba40aeb2a3234b883f78af5b57fd795ad Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 18 Sep 2025 22:59:31 +0800 Subject: [PATCH 24/26] docs(README): update README.md --- README.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7482c0cd2..6491a1b8b 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,6 @@ uv run main.py Discord_community - ## ⚡ 消息平台支持情况 | 平台 | 支持性 | @@ -127,6 +126,8 @@ uv run main.py | Discord | ✔ | | [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | ✔ | | [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ | +| Satori | ✔ | +| Misskey | ✔ | ## ⚡ 提供商支持情况 @@ -172,7 +173,6 @@ pip install pre-commit pre-commit install ``` - ## ❤️ Special Thanks 特别感谢所有 Contributors 和插件开发者对 AstrBot 的贡献 ❤️ @@ -200,14 +200,11 @@ pre-commit install > 如果本项目对您的生活 / 工作产生了帮助,或者您关注本项目的未来发展,请给项目 Star,这是我维护这个开源项目的动力 <3
- + [![Star History Chart](https://api.star-history.com/svg?repos=soulter/astrbot&type=Date)](https://star-history.com/#soulter/astrbot&Date)
- - _私は、高性能ですから!_ - From e75b7893ddf9e41a07cc69c844c2a6058e1912c1 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 18 Sep 2025 23:24:43 +0800 Subject: [PATCH 25/26] =?UTF-8?q?fix:=20super().send(message)=20=E8=A2=AB?= =?UTF-8?q?=E5=BF=BD=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/platform/sources/misskey/misskey_event.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_event.py b/astrbot/core/platform/sources/misskey/misskey_event.py index ae241f4c9..391d10b52 100644 --- a/astrbot/core/platform/sources/misskey/misskey_event.py +++ b/astrbot/core/platform/sources/misskey/misskey_event.py @@ -64,18 +64,15 @@ async def send(self, message: MessageChain): ): user_id = extract_user_id_from_session_id(self.session_id) await self.client.send_message(user_id, content) - return 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) - return 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, From ed8b96997bf03592074e9cca76417073323655a0 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Thu, 18 Sep 2025 23:27:53 +0800 Subject: [PATCH 26/26] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=20session=20?= =?UTF-8?q?=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit : 作为分隔符可能会导致 umo 组装出现问题 --- .../sources/misskey/misskey_adapter.py | 28 +++++++++++++--- .../platform/sources/misskey/misskey_utils.py | 32 +++++++++++-------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/astrbot/core/platform/sources/misskey/misskey_adapter.py b/astrbot/core/platform/sources/misskey/misskey_adapter.py index 18fc2e2e4..84608b54a 100644 --- a/astrbot/core/platform/sources/misskey/misskey_adapter.py +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -10,7 +10,7 @@ PlatformMetadata, register_platform_adapter, ) -from astrbot.core.platform.astr_message_event import MessageSesion +from astrbot.core.platform.astr_message_event import MessageSession import astrbot.api.message_components as Comp from .misskey_api import MisskeyAPI @@ -47,6 +47,8 @@ def __init__( self.local_only = self.config.get("misskey_local_only", False) self.enable_chat = self.config.get("misskey_enable_chat", True) + self.unique_session = platform_settings["unique_session"] + self.api: Optional[MisskeyAPI] = None self._running = False self.client_self_id = "" @@ -229,7 +231,7 @@ def _is_bot_mentioned(self, note: Dict[str, Any]) -> bool: return False async def send_by_session( - self, session: MessageSesion, message_chain: MessageChain + self, session: MessageSession, message_chain: MessageChain ) -> Awaitable[Any]: if not self.api: logger.error("[Misskey] API 客户端未初始化") @@ -281,9 +283,14 @@ async def send_by_session( return await super().send_by_session(session, message_chain) async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: + """将 Misskey 贴文数据转换为 AstrBotMessage 对象""" sender_info = extract_sender_info(raw_data, is_chat=False) message = create_base_message( - raw_data, sender_info, self.client_self_id, is_chat=False + raw_data, + sender_info, + self.client_self_id, + is_chat=False, + unique_session=self.unique_session, ) cache_user_info( self._user_cache, sender_info, raw_data, self.client_self_id, is_chat=False @@ -310,9 +317,14 @@ async def convert_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: return message async def convert_chat_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: + """将 Misskey 聊天消息数据转换为 AstrBotMessage 对象""" sender_info = extract_sender_info(raw_data, is_chat=True) message = create_base_message( - raw_data, sender_info, self.client_self_id, is_chat=True + raw_data, + sender_info, + self.client_self_id, + is_chat=True, + unique_session=self.unique_session, ) cache_user_info( self._user_cache, sender_info, raw_data, self.client_self_id, is_chat=True @@ -329,10 +341,16 @@ async def convert_chat_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage return message async def convert_room_message(self, raw_data: Dict[str, Any]) -> AstrBotMessage: + """将 Misskey 群聊消息数据转换为 AstrBotMessage 对象""" sender_info = extract_sender_info(raw_data, is_chat=True) room_id = raw_data.get("toRoomId", "") message = create_base_message( - raw_data, sender_info, self.client_self_id, is_chat=False, room_id=room_id + raw_data, + sender_info, + self.client_self_id, + is_chat=False, + room_id=room_id, + unique_session=self.unique_session, ) cache_user_info( diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py index 0016bceb6..9a96b453f 100644 --- a/astrbot/core/platform/sources/misskey/misskey_utils.py +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -101,11 +101,11 @@ def resolve_visibility_from_raw_message( def is_valid_user_session_id(session_id: Union[str, Any]) -> bool: - """检查 session_id 是否是有效的聊天用户 session_id (仅限chat:前缀)""" - if not isinstance(session_id, str) or ":" not in session_id: + """检查 session_id 是否是有效的聊天用户 session_id (仅限chat%前缀)""" + if not isinstance(session_id, str) or "%" not in session_id: return False - parts = session_id.split(":") + parts = session_id.split("%") return ( len(parts) == 2 and parts[0] == "chat" @@ -115,11 +115,11 @@ def is_valid_user_session_id(session_id: Union[str, Any]) -> bool: def is_valid_room_session_id(session_id: Union[str, Any]) -> bool: - """检查 session_id 是否是有效的房间 session_id (仅限room:前缀)""" - if not isinstance(session_id, str) or ":" not in session_id: + """检查 session_id 是否是有效的房间 session_id (仅限room%前缀)""" + if not isinstance(session_id, str) or "%" not in session_id: return False - parts = session_id.split(":") + parts = session_id.split("%") return ( len(parts) == 2 and parts[0] == "room" @@ -130,8 +130,8 @@ def is_valid_room_session_id(session_id: Union[str, Any]) -> bool: def extract_user_id_from_session_id(session_id: str) -> str: """从 session_id 中提取用户 ID""" - if ":" in session_id: - parts = session_id.split(":") + if "%" in session_id: + parts = session_id.split("%") if len(parts) >= 2: return parts[1] return session_id @@ -139,8 +139,8 @@ def extract_user_id_from_session_id(session_id: str) -> str: def extract_room_id_from_session_id(session_id: str) -> str: """从 session_id 中提取房间 ID""" - if ":" in session_id: - parts = session_id.split(":") + if "%" in session_id: + parts = session_id.split("%") if len(parts) >= 2 and parts[0] == "room": return parts[1] return session_id @@ -222,6 +222,7 @@ def create_base_message( client_self_id: str, is_chat: bool = False, room_id: Optional[str] = None, + unique_session: bool = False, ) -> AstrBotMessage: """创建基础消息对象""" message = AstrBotMessage() @@ -235,19 +236,22 @@ def create_base_message( if room_id: session_prefix = "room" - session_id = f"{session_prefix}:{room_id}" + session_id = f"{session_prefix}%{room_id}" + if unique_session: + session_id += f"_{sender_info['sender_id']}" message.type = MessageType.GROUP_MESSAGE + message.group_id = room_id elif is_chat: session_prefix = "chat" - session_id = f"{session_prefix}:{sender_info['sender_id']}" + session_id = f"{session_prefix}%{sender_info['sender_id']}" message.type = MessageType.FRIEND_MESSAGE else: session_prefix = "note" - session_id = f"{session_prefix}:{sender_info['sender_id']}" + session_id = f"{session_prefix}%{sender_info['sender_id']}" message.type = MessageType.FRIEND_MESSAGE message.session_id = ( - session_id if sender_info["sender_id"] else f"{session_prefix}:unknown" + session_id if sender_info["sender_id"] else f"{session_prefix}%unknown" ) message.message_id = str(raw_data.get("id", "")) message.self_id = client_self_id