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)
- - _私は、高性能ですから!_ - diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 207ba9102..d0b96fd2f 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -236,6 +236,16 @@ "discord_guild_id_for_debug": "", "discord_activity_name": "", }, + "Misskey": { + "id": "misskey", + "type": "misskey", + "enable": False, + "misskey_instance_url": "https://misskey.example", + "misskey_token": "", + "misskey_default_visibility": "public", + "misskey_local_only": False, + "misskey_enable_chat": True, + }, "Slack": { "id": "slack", "type": "slack", @@ -337,6 +347,32 @@ "type": "string", "hint": "如果你的网络环境为中国大陆,请在 `其他配置` 处设置代理或更改 api_base。", }, + "misskey_instance_url": { + "description": "Misskey 实例 URL", + "type": "string", + "hint": "例如 https://misskey.example,填写 Bot 账号所在的 Misskey 实例地址", + }, + "misskey_token": { + "description": "Misskey Access Token", + "type": "string", + "hint": "连接服务设置生成的 API 鉴权访问令牌(Access token)", + }, + "misskey_default_visibility": { + "description": "默认帖子可见性", + "type": "string", + "options": ["public", "home", "followers"], + "hint": "机器人发帖时的默认可见性设置。public:公开,home:主页时间线,followers:仅关注者。", + }, + "misskey_local_only": { + "description": "仅限本站(不参与联合)", + "type": "bool", + "hint": "启用后,机器人发出的帖子将仅在本实例可见,不会联合到其他实例", + }, + "misskey_enable_chat": { + "description": "启用聊天消息响应", + "type": "bool", + "hint": "启用后,机器人将会监听和响应私信聊天消息", + }, "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..84608b54a --- /dev/null +++ b/astrbot/core/platform/sources/misskey/misskey_adapter.py @@ -0,0 +1,391 @@ +import asyncio +import json +from typing import Dict, Any, Optional, Awaitable + +from astrbot.api import logger +from astrbot.api.event import MessageChain +from astrbot.api.platform import ( + AstrBotMessage, + Platform, + PlatformMetadata, + register_platform_adapter, +) +from astrbot.core.platform.astr_message_event import MessageSession +import astrbot.api.message_components as Comp + +from .misskey_api import MisskeyAPI +from .misskey_event import MisskeyPlatformEvent +from .misskey_utils import ( + 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, +) + + +@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.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.unique_session = platform_settings["unique_session"] + + self.api: Optional[MisskeyAPI] = None + self._running = False + self.client_self_id = "" + self._bot_username = "" + self._user_cache = {} + + def meta(self) -> PlatformMetadata: + default_config = { + "misskey_instance_url": "", + "misskey_token": "", + "max_message_length": 3000, + "misskey_default_visibility": "public", + "misskey_local_only": False, + "misskey_enable_chat": True, + } + default_config.update(self.config) + + return PlatformMetadata( + name="misskey", + description="Misskey 平台适配器", + id=self.config.get("id", "misskey"), + default_config_tmpl=default_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] 已连接用户: {self._bot_username} (ID: {self.client_self_id})" + ) + except Exception as e: + logger.error(f"[Misskey] 获取用户信息失败: {e}") + self._running = False + return + + await self._start_websocket_connection() + + 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 + + streaming = self.api.get_streaming_client() + streaming.add_message_handler("notification", self._handle_notification) + if self.enable_chat: + streaming.add_message_handler( + "newChatMessage", self._handle_chat_message + ) + streaming.add_message_handler("_debug", self._debug_handler) + + if await streaming.connect(): + 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 # 重置延迟 + await streaming.listen() + else: + logger.error( + f"[Misskey] WebSocket 连接失败 (尝试 #{connection_attempts})" + ) + + except Exception as e: + logger.error( + f"[Misskey] WebSocket 异常 (尝试 #{connection_attempts}): {e}" + ) + + if self._running: + logger.info( + f"[Misskey] {backoff_delay:.1f}秒后重连 (下次尝试 #{connection_attempts + 1})" + ) + await asyncio.sleep(backoff_delay) + backoff_delay = min(backoff_delay * backoff_multiplier, max_backoff) + + async def _handle_notification(self, data: Dict[str, Any]): + try: + 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', '')[:50]}..." + ) + 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}") + + async def _handle_chat_message(self, data: Dict[str, Any]): + try: + logger.debug( + f"[Misskey] 收到聊天事件数据:\n{json.dumps(data, indent=2, ensure_ascii=False)}" + ) + + sender_id = str( + data.get("fromUserId", "") or data.get("fromUser", {}).get("id", "") + ) + if sender_id == self.client_self_id: + return + + room_id = data.get("toRoomId") + if room_id: + raw_text = data.get("text", "") + logger.debug( + f"[Misskey] 检查群聊消息: '{raw_text}', 机器人用户名: '{self._bot_username}'" + ) + + 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, + 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}") + + 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 + + mentions = note.get("mentions", []) + if self._bot_username and f"@{self._bot_username}" in text: + return True + 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 == self.client_self_id: + return bool(self._bot_username and f"@{self._bot_username}" in text) + + return False + + async def send_by_session( + self, session: MessageSession, message_chain: MessageChain + ) -> Awaitable[Any]: + if not self.api: + logger.error("[Misskey] API 客户端未初始化") + return await super().send_by_session(session, message_chain) + + try: + session_id = session.session_id + text, has_at_user = serialize_message_chain(message_chain.chain) + + 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(): + 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] + "..." + + if session_id and is_valid_user_session_id(session_id): + from .misskey_utils import extract_user_id_from_session_id + + user_id = extract_user_id_from_session_id(session_id) + await self.api.send_message(user_id, text) + 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, + visible_user_ids=visible_user_ids, + local_only=self.local_only, + ) + + except Exception as e: + logger.error(f"[Misskey] 发送消息失败: {e}") + + 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, + unique_session=self.unique_session, + ) + cache_user_info( + self._user_cache, sender_info, raw_data, self.client_self_id, is_chat=False + ) + + message_parts = [] + raw_text = raw_data.get("text", "") + + if 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", []) + 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 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, + unique_session=self.unique_session, + ) + 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", []) + 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: + """将 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, + unique_session=self.unique_session, + ) + + 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: + 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..dc4adcdd0 --- /dev/null +++ b/astrbot/core/platform/sources/misskey/misskey_api.py @@ -0,0 +1,404 @@ +import json +from typing import Any, Optional, Dict, List, Callable, Awaitable +import uuid + +try: + import aiohttp + import websockets +except ImportError as e: + raise ImportError( + "aiohttp and websockets are required for Misskey API. Please install them with: pip install aiohttp websockets" + ) from e + +from astrbot.api import logger + +# Constants +API_MAX_RETRIES = 3 +HTTP_OK = 200 + + +class APIError(Exception): + """Misskey API 基础异常""" + + pass + + +class APIConnectionError(APIError): + """网络连接异常""" + + pass + + +class APIRateLimitError(APIError): + """API 频率限制异常""" + + pass + + +class AuthenticationError(APIError): + """认证失败异常""" + + pass + + +class WebSocketError(APIError): + """WebSocket 连接异常""" + + 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 + self._last_pong = None + + 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, ping_interval=30, ping_timeout=10 + ) + 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.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}") + 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] 收到消息类型: {message_type}\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", {}) + + 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}" + + 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): + 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 + + +class MisskeyAPI: + 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 + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + 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: + 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 == 400: + logger.error(f"API 请求错误: {endpoint} (状态码: {status})") + raise APIError(f"Bad request for {endpoint}") + elif status in (401, 403): + logger.error(f"API 认证失败: {endpoint} (状态码: {status})") + raise AuthenticationError(f"Authentication failed for {endpoint}") + elif status == 429: + logger.warning(f"API 频率限制: {endpoint} (状态码: {status})") + 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: aiohttp.ClientResponse, endpoint: str + ) -> Any: + """处理 API 响应""" + if response.status == HTTP_OK: + try: + result = await response.json() + if endpoint == "i/notifications": + notifications_data = ( + result + if isinstance(result, list) + else result.get("notifications", []) + if isinstance(result, dict) + else [] + ) + if notifications_data: + logger.debug(f"获取到 {len(notifications_data)} 条新通知") + else: + logger.debug(f"API 请求成功: {endpoint}") + return result + except json.JSONDecodeError as e: + logger.error(f"响应不是有效的 JSON 格式: {e}") + 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.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 + ) -> Any: + 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 as e: + logger.error(f"HTTP 请求错误: {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, + local_only: bool = False, + ) -> Dict[str, Any]: + """创建新贴文""" + 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": + data["visibleUserIds"] = visible_user_ids + + result = await self._make_request("notes/create", data) + note_id = result.get("createdNote", {}).get("id", "unknown") + logger.debug(f"发帖成功,note_id: {note_id}") + return result + + async def 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"聊天发送成功,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]]: + """获取聊天消息历史""" + 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]]: + """获取提及通知""" + 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) + 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..391d10b52 --- /dev/null +++ b/astrbot/core/platform/sources/misskey/misskey_event.py @@ -0,0 +1,123 @@ +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, + 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, +) + + +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 + + 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() + + return any(message_trimmed.startswith(prefix) for prefix in system_prefixes) + + async def send(self, message: MessageChain): + content, has_at = serialize_message_chain(message.chain) + + if not content: + logger.debug("[MisskeyEvent] 内容为空,跳过发送") + return + + try: + original_message_id = getattr(self.message_obj, "message_id", None) + raw_message = getattr(self.message_obj, "raw_message", {}) + + if raw_message and not has_at: + user_data = raw_message.get("user", {}) + user_info = { + "username": user_data.get("username", ""), + "nickname": user_data.get("name", user_data.get("username", "")), + } + content = add_at_mention_if_needed(content, user_info, has_at) + + # 根据会话类型选择发送方式 + if hasattr(self.client, "send_message") and is_valid_user_session_id( + self.session_id + ): + user_id = extract_user_id_from_session_id(self.session_id) + await self.client.send_message(user_id, content) + elif hasattr(self.client, "send_room_message") and is_valid_room_session_id( + self.session_id + ): + room_id = extract_room_id_from_session_id(self.session_id) + await self.client.send_room_message(room_id, content) + elif original_message_id and hasattr(self.client, "create_note"): + visibility, visible_user_ids = resolve_visibility_from_raw_message( + raw_message + ) + await self.client.create_note( + content, + reply_id=original_message_id, + visibility=visibility, + visible_user_ids=visible_user_ids, + ) + elif hasattr(self.client, "create_note"): + logger.debug("[MisskeyEvent] 创建新帖子") + await self.client.create_note(content) + + await super().send(message) + + 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) 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..9a96b453f --- /dev/null +++ b/astrbot/core/platform/sources/misskey/misskey_utils.py @@ -0,0 +1,327 @@ +"""Misskey 平台适配器通用工具函数""" + +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]: + """将消息链序列化为文本字符串""" + text_parts = [] + has_at = False + + def process_component(component): + nonlocal has_at + if isinstance(component, Comp.Plain): + 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 + return f"@{component.qq}" + elif hasattr(component, "text"): + text = getattr(component, "text", "") + if "@" in text: + has_at = True + return text + else: + 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 + + +def resolve_message_visibility( + user_id: Optional[str], + user_cache: Dict[str, Any], + self_id: Optional[str], + default_visibility: str = "public", +) -> Tuple[str, Optional[List[str]]]: + """解析 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", default_visibility) + 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]]]: + """从原始消息数据中解析可见性设置""" + 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: + """检查 session_id 是否是有效的聊天用户 session_id (仅限chat%前缀)""" + if not isinstance(session_id, str) or "%" not in session_id: + return False + + parts = session_id.split("%") + return ( + len(parts) == 2 + and parts[0] == "chat" + and bool(parts[1]) + and parts[1] != "unknown" + ) + + +def 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: + parts = session_id.split("%") + if len(parts) >= 2: + return parts[1] + 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: + """如果需要且没有@用户,则添加@用户""" + 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}\n{text}".strip() + elif nickname: + mention = f"@{nickname}" + if not text.startswith(mention): + 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, + unique_session: 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"], + ) + + if room_id: + session_prefix = "room" + 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']}" + 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 + + 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], + } 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, } 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 000000000..396209aee Binary files /dev/null and b/dashboard/src/assets/images/platform_logos/misskey.png differ 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"; },