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
-
## ⚡ 消息平台支持情况
| 平台 | 支持性 |
@@ -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
-
+
[](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";
},