AstrBot 3.5.6__py3-none-any.whl → 4.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (288) hide show
  1. astrbot/api/__init__.py +16 -4
  2. astrbot/api/all.py +2 -1
  3. astrbot/api/event/__init__.py +5 -6
  4. astrbot/api/event/filter/__init__.py +37 -34
  5. astrbot/api/platform/__init__.py +7 -8
  6. astrbot/api/provider/__init__.py +8 -7
  7. astrbot/api/star/__init__.py +3 -4
  8. astrbot/api/util/__init__.py +2 -2
  9. astrbot/cli/__init__.py +1 -0
  10. astrbot/cli/__main__.py +18 -197
  11. astrbot/cli/commands/__init__.py +6 -0
  12. astrbot/cli/commands/cmd_conf.py +209 -0
  13. astrbot/cli/commands/cmd_init.py +56 -0
  14. astrbot/cli/commands/cmd_plug.py +245 -0
  15. astrbot/cli/commands/cmd_run.py +62 -0
  16. astrbot/cli/utils/__init__.py +18 -0
  17. astrbot/cli/utils/basic.py +76 -0
  18. astrbot/cli/utils/plugin.py +246 -0
  19. astrbot/cli/utils/version_comparator.py +90 -0
  20. astrbot/core/__init__.py +17 -19
  21. astrbot/core/agent/agent.py +14 -0
  22. astrbot/core/agent/handoff.py +38 -0
  23. astrbot/core/agent/hooks.py +30 -0
  24. astrbot/core/agent/mcp_client.py +385 -0
  25. astrbot/core/agent/message.py +175 -0
  26. astrbot/core/agent/response.py +14 -0
  27. astrbot/core/agent/run_context.py +22 -0
  28. astrbot/core/agent/runners/__init__.py +3 -0
  29. astrbot/core/agent/runners/base.py +65 -0
  30. astrbot/core/agent/runners/coze/coze_agent_runner.py +367 -0
  31. astrbot/core/agent/runners/coze/coze_api_client.py +324 -0
  32. astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py +403 -0
  33. astrbot/core/agent/runners/dify/dify_agent_runner.py +336 -0
  34. astrbot/core/agent/runners/dify/dify_api_client.py +195 -0
  35. astrbot/core/agent/runners/tool_loop_agent_runner.py +400 -0
  36. astrbot/core/agent/tool.py +285 -0
  37. astrbot/core/agent/tool_executor.py +17 -0
  38. astrbot/core/astr_agent_context.py +19 -0
  39. astrbot/core/astr_agent_hooks.py +36 -0
  40. astrbot/core/astr_agent_run_util.py +80 -0
  41. astrbot/core/astr_agent_tool_exec.py +246 -0
  42. astrbot/core/astrbot_config_mgr.py +275 -0
  43. astrbot/core/config/__init__.py +2 -2
  44. astrbot/core/config/astrbot_config.py +60 -20
  45. astrbot/core/config/default.py +1972 -453
  46. astrbot/core/config/i18n_utils.py +110 -0
  47. astrbot/core/conversation_mgr.py +285 -75
  48. astrbot/core/core_lifecycle.py +167 -62
  49. astrbot/core/db/__init__.py +305 -102
  50. astrbot/core/db/migration/helper.py +69 -0
  51. astrbot/core/db/migration/migra_3_to_4.py +357 -0
  52. astrbot/core/db/migration/migra_45_to_46.py +44 -0
  53. astrbot/core/db/migration/migra_webchat_session.py +131 -0
  54. astrbot/core/db/migration/shared_preferences_v3.py +48 -0
  55. astrbot/core/db/migration/sqlite_v3.py +497 -0
  56. astrbot/core/db/po.py +259 -55
  57. astrbot/core/db/sqlite.py +773 -528
  58. astrbot/core/db/vec_db/base.py +73 -0
  59. astrbot/core/db/vec_db/faiss_impl/__init__.py +3 -0
  60. astrbot/core/db/vec_db/faiss_impl/document_storage.py +392 -0
  61. astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +93 -0
  62. astrbot/core/db/vec_db/faiss_impl/sqlite_init.sql +17 -0
  63. astrbot/core/db/vec_db/faiss_impl/vec_db.py +204 -0
  64. astrbot/core/event_bus.py +26 -22
  65. astrbot/core/exceptions.py +9 -0
  66. astrbot/core/file_token_service.py +98 -0
  67. astrbot/core/initial_loader.py +19 -10
  68. astrbot/core/knowledge_base/chunking/__init__.py +9 -0
  69. astrbot/core/knowledge_base/chunking/base.py +25 -0
  70. astrbot/core/knowledge_base/chunking/fixed_size.py +59 -0
  71. astrbot/core/knowledge_base/chunking/recursive.py +161 -0
  72. astrbot/core/knowledge_base/kb_db_sqlite.py +301 -0
  73. astrbot/core/knowledge_base/kb_helper.py +642 -0
  74. astrbot/core/knowledge_base/kb_mgr.py +330 -0
  75. astrbot/core/knowledge_base/models.py +120 -0
  76. astrbot/core/knowledge_base/parsers/__init__.py +13 -0
  77. astrbot/core/knowledge_base/parsers/base.py +51 -0
  78. astrbot/core/knowledge_base/parsers/markitdown_parser.py +26 -0
  79. astrbot/core/knowledge_base/parsers/pdf_parser.py +101 -0
  80. astrbot/core/knowledge_base/parsers/text_parser.py +42 -0
  81. astrbot/core/knowledge_base/parsers/url_parser.py +103 -0
  82. astrbot/core/knowledge_base/parsers/util.py +13 -0
  83. astrbot/core/knowledge_base/prompts.py +65 -0
  84. astrbot/core/knowledge_base/retrieval/__init__.py +14 -0
  85. astrbot/core/knowledge_base/retrieval/hit_stopwords.txt +767 -0
  86. astrbot/core/knowledge_base/retrieval/manager.py +276 -0
  87. astrbot/core/knowledge_base/retrieval/rank_fusion.py +142 -0
  88. astrbot/core/knowledge_base/retrieval/sparse_retriever.py +136 -0
  89. astrbot/core/log.py +21 -15
  90. astrbot/core/message/components.py +413 -287
  91. astrbot/core/message/message_event_result.py +35 -24
  92. astrbot/core/persona_mgr.py +192 -0
  93. astrbot/core/pipeline/__init__.py +14 -14
  94. astrbot/core/pipeline/content_safety_check/stage.py +13 -9
  95. astrbot/core/pipeline/content_safety_check/strategies/__init__.py +1 -2
  96. astrbot/core/pipeline/content_safety_check/strategies/baidu_aip.py +13 -14
  97. astrbot/core/pipeline/content_safety_check/strategies/keywords.py +2 -1
  98. astrbot/core/pipeline/content_safety_check/strategies/strategy.py +6 -6
  99. astrbot/core/pipeline/context.py +7 -1
  100. astrbot/core/pipeline/context_utils.py +107 -0
  101. astrbot/core/pipeline/preprocess_stage/stage.py +63 -36
  102. astrbot/core/pipeline/process_stage/method/agent_request.py +48 -0
  103. astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +464 -0
  104. astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py +202 -0
  105. astrbot/core/pipeline/process_stage/method/star_request.py +26 -32
  106. astrbot/core/pipeline/process_stage/stage.py +21 -15
  107. astrbot/core/pipeline/process_stage/utils.py +125 -0
  108. astrbot/core/pipeline/rate_limit_check/stage.py +34 -36
  109. astrbot/core/pipeline/respond/stage.py +142 -101
  110. astrbot/core/pipeline/result_decorate/stage.py +124 -57
  111. astrbot/core/pipeline/scheduler.py +21 -16
  112. astrbot/core/pipeline/session_status_check/stage.py +37 -0
  113. astrbot/core/pipeline/stage.py +11 -76
  114. astrbot/core/pipeline/waking_check/stage.py +69 -33
  115. astrbot/core/pipeline/whitelist_check/stage.py +10 -7
  116. astrbot/core/platform/__init__.py +6 -6
  117. astrbot/core/platform/astr_message_event.py +107 -129
  118. astrbot/core/platform/astrbot_message.py +32 -12
  119. astrbot/core/platform/manager.py +62 -18
  120. astrbot/core/platform/message_session.py +30 -0
  121. astrbot/core/platform/platform.py +16 -24
  122. astrbot/core/platform/platform_metadata.py +9 -4
  123. astrbot/core/platform/register.py +12 -7
  124. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +136 -60
  125. astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +126 -46
  126. astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +63 -31
  127. astrbot/core/platform/sources/dingtalk/dingtalk_event.py +30 -26
  128. astrbot/core/platform/sources/discord/client.py +129 -0
  129. astrbot/core/platform/sources/discord/components.py +139 -0
  130. astrbot/core/platform/sources/discord/discord_platform_adapter.py +473 -0
  131. astrbot/core/platform/sources/discord/discord_platform_event.py +313 -0
  132. astrbot/core/platform/sources/lark/lark_adapter.py +27 -18
  133. astrbot/core/platform/sources/lark/lark_event.py +39 -13
  134. astrbot/core/platform/sources/misskey/misskey_adapter.py +770 -0
  135. astrbot/core/platform/sources/misskey/misskey_api.py +964 -0
  136. astrbot/core/platform/sources/misskey/misskey_event.py +163 -0
  137. astrbot/core/platform/sources/misskey/misskey_utils.py +550 -0
  138. astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +149 -33
  139. astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +41 -26
  140. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +36 -17
  141. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py +3 -1
  142. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +14 -8
  143. astrbot/core/platform/sources/satori/satori_adapter.py +792 -0
  144. astrbot/core/platform/sources/satori/satori_event.py +432 -0
  145. astrbot/core/platform/sources/slack/client.py +164 -0
  146. astrbot/core/platform/sources/slack/slack_adapter.py +416 -0
  147. astrbot/core/platform/sources/slack/slack_event.py +253 -0
  148. astrbot/core/platform/sources/telegram/tg_adapter.py +100 -43
  149. astrbot/core/platform/sources/telegram/tg_event.py +136 -36
  150. astrbot/core/platform/sources/webchat/webchat_adapter.py +72 -22
  151. astrbot/core/platform/sources/webchat/webchat_event.py +46 -22
  152. astrbot/core/platform/sources/webchat/webchat_queue_mgr.py +35 -0
  153. astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +926 -0
  154. astrbot/core/platform/sources/wechatpadpro/wechatpadpro_message_event.py +178 -0
  155. astrbot/core/platform/sources/wechatpadpro/xml_data_parser.py +159 -0
  156. astrbot/core/platform/sources/wecom/wecom_adapter.py +169 -27
  157. astrbot/core/platform/sources/wecom/wecom_event.py +162 -77
  158. astrbot/core/platform/sources/wecom/wecom_kf.py +279 -0
  159. astrbot/core/platform/sources/wecom/wecom_kf_message.py +196 -0
  160. astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py +297 -0
  161. astrbot/core/platform/sources/wecom_ai_bot/__init__.py +15 -0
  162. astrbot/core/platform/sources/wecom_ai_bot/ierror.py +19 -0
  163. astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +472 -0
  164. astrbot/core/platform/sources/wecom_ai_bot/wecomai_api.py +417 -0
  165. astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +152 -0
  166. astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py +153 -0
  167. astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py +168 -0
  168. astrbot/core/platform/sources/wecom_ai_bot/wecomai_utils.py +209 -0
  169. astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +306 -0
  170. astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py +186 -0
  171. astrbot/core/platform_message_history_mgr.py +49 -0
  172. astrbot/core/provider/__init__.py +2 -3
  173. astrbot/core/provider/entites.py +8 -8
  174. astrbot/core/provider/entities.py +154 -98
  175. astrbot/core/provider/func_tool_manager.py +446 -458
  176. astrbot/core/provider/manager.py +345 -207
  177. astrbot/core/provider/provider.py +188 -73
  178. astrbot/core/provider/register.py +9 -7
  179. astrbot/core/provider/sources/anthropic_source.py +295 -115
  180. astrbot/core/provider/sources/azure_tts_source.py +224 -0
  181. astrbot/core/provider/sources/bailian_rerank_source.py +236 -0
  182. astrbot/core/provider/sources/dashscope_tts.py +138 -14
  183. astrbot/core/provider/sources/edge_tts_source.py +24 -19
  184. astrbot/core/provider/sources/fishaudio_tts_api_source.py +58 -13
  185. astrbot/core/provider/sources/gemini_embedding_source.py +61 -0
  186. astrbot/core/provider/sources/gemini_source.py +310 -132
  187. astrbot/core/provider/sources/gemini_tts_source.py +81 -0
  188. astrbot/core/provider/sources/groq_source.py +15 -0
  189. astrbot/core/provider/sources/gsv_selfhosted_source.py +151 -0
  190. astrbot/core/provider/sources/gsvi_tts_source.py +14 -7
  191. astrbot/core/provider/sources/minimax_tts_api_source.py +159 -0
  192. astrbot/core/provider/sources/openai_embedding_source.py +40 -0
  193. astrbot/core/provider/sources/openai_source.py +241 -145
  194. astrbot/core/provider/sources/openai_tts_api_source.py +18 -7
  195. astrbot/core/provider/sources/sensevoice_selfhosted_source.py +13 -11
  196. astrbot/core/provider/sources/vllm_rerank_source.py +71 -0
  197. astrbot/core/provider/sources/volcengine_tts.py +115 -0
  198. astrbot/core/provider/sources/whisper_api_source.py +18 -13
  199. astrbot/core/provider/sources/whisper_selfhosted_source.py +19 -12
  200. astrbot/core/provider/sources/xinference_rerank_source.py +116 -0
  201. astrbot/core/provider/sources/xinference_stt_provider.py +197 -0
  202. astrbot/core/provider/sources/zhipu_source.py +6 -73
  203. astrbot/core/star/__init__.py +43 -11
  204. astrbot/core/star/config.py +17 -18
  205. astrbot/core/star/context.py +362 -138
  206. astrbot/core/star/filter/__init__.py +4 -3
  207. astrbot/core/star/filter/command.py +111 -35
  208. astrbot/core/star/filter/command_group.py +46 -34
  209. astrbot/core/star/filter/custom_filter.py +6 -5
  210. astrbot/core/star/filter/event_message_type.py +4 -2
  211. astrbot/core/star/filter/permission.py +4 -2
  212. astrbot/core/star/filter/platform_adapter_type.py +45 -12
  213. astrbot/core/star/filter/regex.py +4 -2
  214. astrbot/core/star/register/__init__.py +19 -15
  215. astrbot/core/star/register/star.py +41 -13
  216. astrbot/core/star/register/star_handler.py +236 -86
  217. astrbot/core/star/session_llm_manager.py +280 -0
  218. astrbot/core/star/session_plugin_manager.py +170 -0
  219. astrbot/core/star/star.py +36 -43
  220. astrbot/core/star/star_handler.py +47 -85
  221. astrbot/core/star/star_manager.py +442 -260
  222. astrbot/core/star/star_tools.py +167 -45
  223. astrbot/core/star/updator.py +17 -20
  224. astrbot/core/umop_config_router.py +106 -0
  225. astrbot/core/updator.py +38 -13
  226. astrbot/core/utils/astrbot_path.py +39 -0
  227. astrbot/core/utils/command_parser.py +1 -1
  228. astrbot/core/utils/io.py +119 -60
  229. astrbot/core/utils/log_pipe.py +1 -1
  230. astrbot/core/utils/metrics.py +11 -10
  231. astrbot/core/utils/migra_helper.py +73 -0
  232. astrbot/core/utils/path_util.py +63 -62
  233. astrbot/core/utils/pip_installer.py +37 -15
  234. astrbot/core/utils/session_lock.py +29 -0
  235. astrbot/core/utils/session_waiter.py +19 -20
  236. astrbot/core/utils/shared_preferences.py +174 -34
  237. astrbot/core/utils/t2i/__init__.py +4 -1
  238. astrbot/core/utils/t2i/local_strategy.py +386 -238
  239. astrbot/core/utils/t2i/network_strategy.py +109 -49
  240. astrbot/core/utils/t2i/renderer.py +29 -14
  241. astrbot/core/utils/t2i/template/astrbot_powershell.html +184 -0
  242. astrbot/core/utils/t2i/template_manager.py +111 -0
  243. astrbot/core/utils/tencent_record_helper.py +115 -1
  244. astrbot/core/utils/version_comparator.py +10 -13
  245. astrbot/core/zip_updator.py +112 -65
  246. astrbot/dashboard/routes/__init__.py +20 -13
  247. astrbot/dashboard/routes/auth.py +20 -9
  248. astrbot/dashboard/routes/chat.py +297 -141
  249. astrbot/dashboard/routes/config.py +652 -55
  250. astrbot/dashboard/routes/conversation.py +107 -37
  251. astrbot/dashboard/routes/file.py +26 -0
  252. astrbot/dashboard/routes/knowledge_base.py +1244 -0
  253. astrbot/dashboard/routes/log.py +27 -2
  254. astrbot/dashboard/routes/persona.py +202 -0
  255. astrbot/dashboard/routes/plugin.py +197 -139
  256. astrbot/dashboard/routes/route.py +27 -7
  257. astrbot/dashboard/routes/session_management.py +354 -0
  258. astrbot/dashboard/routes/stat.py +85 -18
  259. astrbot/dashboard/routes/static_file.py +5 -2
  260. astrbot/dashboard/routes/t2i.py +233 -0
  261. astrbot/dashboard/routes/tools.py +184 -120
  262. astrbot/dashboard/routes/update.py +59 -36
  263. astrbot/dashboard/server.py +96 -36
  264. astrbot/dashboard/utils.py +165 -0
  265. astrbot-4.7.0.dist-info/METADATA +294 -0
  266. astrbot-4.7.0.dist-info/RECORD +274 -0
  267. {astrbot-3.5.6.dist-info → astrbot-4.7.0.dist-info}/WHEEL +1 -1
  268. astrbot/core/db/plugin/sqlite_impl.py +0 -112
  269. astrbot/core/db/sqlite_init.sql +0 -50
  270. astrbot/core/pipeline/platform_compatibility/stage.py +0 -56
  271. astrbot/core/pipeline/process_stage/method/llm_request.py +0 -606
  272. astrbot/core/platform/sources/gewechat/client.py +0 -806
  273. astrbot/core/platform/sources/gewechat/downloader.py +0 -55
  274. astrbot/core/platform/sources/gewechat/gewechat_event.py +0 -255
  275. astrbot/core/platform/sources/gewechat/gewechat_platform_adapter.py +0 -103
  276. astrbot/core/platform/sources/gewechat/xml_data_parser.py +0 -110
  277. astrbot/core/provider/sources/dashscope_source.py +0 -203
  278. astrbot/core/provider/sources/dify_source.py +0 -281
  279. astrbot/core/provider/sources/llmtuner_source.py +0 -132
  280. astrbot/core/rag/embedding/openai_source.py +0 -20
  281. astrbot/core/rag/knowledge_db_mgr.py +0 -94
  282. astrbot/core/rag/store/__init__.py +0 -9
  283. astrbot/core/rag/store/chroma_db.py +0 -42
  284. astrbot/core/utils/dify_api_client.py +0 -152
  285. astrbot-3.5.6.dist-info/METADATA +0 -249
  286. astrbot-3.5.6.dist-info/RECORD +0 -158
  287. {astrbot-3.5.6.dist-info → astrbot-4.7.0.dist-info}/entry_points.txt +0 -0
  288. {astrbot-3.5.6.dist-info → astrbot-4.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,964 @@
1
+ import asyncio
2
+ import json
3
+ import random
4
+ import uuid
5
+ from collections.abc import Awaitable, Callable
6
+ from typing import Any
7
+
8
+ try:
9
+ import aiohttp
10
+ import websockets
11
+ except ImportError as e:
12
+ raise ImportError(
13
+ "aiohttp and websockets are required for Misskey API. Please install them with: pip install aiohttp websockets",
14
+ ) from e
15
+
16
+ from astrbot.api import logger
17
+
18
+ from .misskey_utils import FileIDExtractor
19
+
20
+ # Constants
21
+ API_MAX_RETRIES = 3
22
+ HTTP_OK = 200
23
+
24
+
25
+ class APIError(Exception):
26
+ """Misskey API 基础异常"""
27
+
28
+
29
+ class APIConnectionError(APIError):
30
+ """网络连接异常"""
31
+
32
+
33
+ class APIRateLimitError(APIError):
34
+ """API 频率限制异常"""
35
+
36
+
37
+ class AuthenticationError(APIError):
38
+ """认证失败异常"""
39
+
40
+
41
+ class WebSocketError(APIError):
42
+ """WebSocket 连接异常"""
43
+
44
+
45
+ class StreamingClient:
46
+ def __init__(self, instance_url: str, access_token: str):
47
+ self.instance_url = instance_url.rstrip("/")
48
+ self.access_token = access_token
49
+ self.websocket: Any | None = None
50
+ self.is_connected = False
51
+ self.message_handlers: dict[str, Callable] = {}
52
+ self.channels: dict[str, str] = {}
53
+ self.desired_channels: dict[str, dict | None] = {}
54
+ self._running = False
55
+ self._last_pong = None
56
+
57
+ async def connect(self) -> bool:
58
+ try:
59
+ ws_url = self.instance_url.replace("https://", "wss://").replace(
60
+ "http://",
61
+ "ws://",
62
+ )
63
+ ws_url += f"/streaming?i={self.access_token}"
64
+
65
+ self.websocket = await websockets.connect(
66
+ ws_url,
67
+ ping_interval=30,
68
+ ping_timeout=10,
69
+ )
70
+ self.is_connected = True
71
+ self._running = True
72
+
73
+ logger.info("[Misskey WebSocket] 已连接")
74
+ if self.desired_channels:
75
+ try:
76
+ desired = list(self.desired_channels.items())
77
+ for channel_type, params in desired:
78
+ try:
79
+ await self.subscribe_channel(channel_type, params)
80
+ except Exception as e:
81
+ logger.warning(
82
+ f"[Misskey WebSocket] 重新订阅 {channel_type} 失败: {e}",
83
+ )
84
+ except Exception:
85
+ pass
86
+ return True
87
+
88
+ except Exception as e:
89
+ logger.error(f"[Misskey WebSocket] 连接失败: {e}")
90
+ self.is_connected = False
91
+ return False
92
+
93
+ async def disconnect(self):
94
+ self._running = False
95
+ if self.websocket:
96
+ await self.websocket.close()
97
+ self.websocket = None
98
+ self.is_connected = False
99
+ logger.info("[Misskey WebSocket] 连接已断开")
100
+
101
+ async def subscribe_channel(
102
+ self,
103
+ channel_type: str,
104
+ params: dict | None = None,
105
+ ) -> str:
106
+ if not self.is_connected or not self.websocket:
107
+ raise WebSocketError("WebSocket 未连接")
108
+
109
+ channel_id = str(uuid.uuid4())
110
+ message = {
111
+ "type": "connect",
112
+ "body": {"channel": channel_type, "id": channel_id, "params": params or {}},
113
+ }
114
+
115
+ await self.websocket.send(json.dumps(message))
116
+ self.channels[channel_id] = channel_type
117
+ return channel_id
118
+
119
+ async def unsubscribe_channel(self, channel_id: str):
120
+ if (
121
+ not self.is_connected
122
+ or not self.websocket
123
+ or channel_id not in self.channels
124
+ ):
125
+ return
126
+
127
+ message = {"type": "disconnect", "body": {"id": channel_id}}
128
+ await self.websocket.send(json.dumps(message))
129
+ channel_type = self.channels.get(channel_id)
130
+ if channel_id in self.channels:
131
+ del self.channels[channel_id]
132
+ if channel_type and channel_type not in self.channels.values():
133
+ self.desired_channels.pop(channel_type, None)
134
+
135
+ def add_message_handler(
136
+ self,
137
+ event_type: str,
138
+ handler: Callable[[dict], Awaitable[None]],
139
+ ):
140
+ self.message_handlers[event_type] = handler
141
+
142
+ async def listen(self):
143
+ if not self.is_connected or not self.websocket:
144
+ raise WebSocketError("WebSocket 未连接")
145
+
146
+ try:
147
+ async for message in self.websocket:
148
+ if not self._running:
149
+ break
150
+
151
+ try:
152
+ data = json.loads(message)
153
+ await self._handle_message(data)
154
+ except json.JSONDecodeError as e:
155
+ logger.warning(f"[Misskey WebSocket] 无法解析消息: {e}")
156
+ except Exception as e:
157
+ logger.error(f"[Misskey WebSocket] 处理消息失败: {e}")
158
+
159
+ except websockets.exceptions.ConnectionClosedError as e:
160
+ logger.warning(f"[Misskey WebSocket] 连接意外关闭: {e}")
161
+ self.is_connected = False
162
+ try:
163
+ await self.disconnect()
164
+ except Exception:
165
+ pass
166
+ except websockets.exceptions.ConnectionClosed as e:
167
+ logger.warning(
168
+ f"[Misskey WebSocket] 连接已关闭 (代码: {e.code}, 原因: {e.reason})",
169
+ )
170
+ self.is_connected = False
171
+ try:
172
+ await self.disconnect()
173
+ except Exception:
174
+ pass
175
+ except websockets.exceptions.InvalidHandshake as e:
176
+ logger.error(f"[Misskey WebSocket] 握手失败: {e}")
177
+ self.is_connected = False
178
+ try:
179
+ await self.disconnect()
180
+ except Exception:
181
+ pass
182
+ except Exception as e:
183
+ logger.error(f"[Misskey WebSocket] 监听消息失败: {e}")
184
+ self.is_connected = False
185
+ try:
186
+ await self.disconnect()
187
+ except Exception:
188
+ pass
189
+
190
+ async def _handle_message(self, data: dict[str, Any]):
191
+ message_type = data.get("type")
192
+ body = data.get("body", {})
193
+
194
+ def _build_channel_summary(message_type: str | None, body: Any) -> str:
195
+ try:
196
+ if not isinstance(body, dict):
197
+ return f"[Misskey WebSocket] 收到消息类型: {message_type}"
198
+
199
+ inner = body.get("body") if isinstance(body.get("body"), dict) else body
200
+ note = (
201
+ inner.get("note")
202
+ if isinstance(inner, dict) and isinstance(inner.get("note"), dict)
203
+ else None
204
+ )
205
+
206
+ text = note.get("text") if note else None
207
+ note_id = note.get("id") if note else None
208
+ files = note.get("files") or [] if note else []
209
+ has_files = bool(files)
210
+ is_hidden = bool(note.get("isHidden")) if note else False
211
+ user = note.get("user", {}) if note else None
212
+
213
+ return (
214
+ f"[Misskey WebSocket] 收到消息类型: {message_type} | "
215
+ f"note_id={note_id} | user={user.get('username') if user else None} | "
216
+ f"text={text[:80] if text else '[no-text]'} | files={has_files} | hidden={is_hidden}"
217
+ )
218
+ except Exception:
219
+ return f"[Misskey WebSocket] 收到消息类型: {message_type}"
220
+
221
+ channel_summary = _build_channel_summary(message_type, body)
222
+ logger.info(channel_summary)
223
+
224
+ if message_type == "channel":
225
+ channel_id = body.get("id")
226
+ event_type = body.get("type")
227
+ event_body = body.get("body", {})
228
+
229
+ logger.debug(
230
+ f"[Misskey WebSocket] 频道消息: {channel_id}, 事件类型: {event_type}",
231
+ )
232
+
233
+ if channel_id in self.channels:
234
+ channel_type = self.channels[channel_id]
235
+ handler_key = f"{channel_type}:{event_type}"
236
+
237
+ if handler_key in self.message_handlers:
238
+ logger.debug(f"[Misskey WebSocket] 使用处理器: {handler_key}")
239
+ await self.message_handlers[handler_key](event_body)
240
+ elif event_type in self.message_handlers:
241
+ logger.debug(f"[Misskey WebSocket] 使用事件处理器: {event_type}")
242
+ await self.message_handlers[event_type](event_body)
243
+ else:
244
+ logger.debug(
245
+ f"[Misskey WebSocket] 未找到处理器: {handler_key} 或 {event_type}",
246
+ )
247
+ if "_debug" in self.message_handlers:
248
+ await self.message_handlers["_debug"](
249
+ {
250
+ "type": event_type,
251
+ "body": event_body,
252
+ "channel": channel_type,
253
+ },
254
+ )
255
+
256
+ elif message_type in self.message_handlers:
257
+ logger.debug(f"[Misskey WebSocket] 直接消息处理器: {message_type}")
258
+ await self.message_handlers[message_type](body)
259
+ else:
260
+ logger.debug(f"[Misskey WebSocket] 未处理的消息类型: {message_type}")
261
+ if "_debug" in self.message_handlers:
262
+ await self.message_handlers["_debug"](data)
263
+
264
+
265
+ def retry_async(
266
+ max_retries: int = 3,
267
+ retryable_exceptions: tuple = (APIConnectionError, APIRateLimitError),
268
+ backoff_base: float = 1.0,
269
+ max_backoff: float = 30.0,
270
+ ):
271
+ """智能异步重试装饰器
272
+
273
+ Args:
274
+ max_retries: 最大重试次数
275
+ retryable_exceptions: 可重试的异常类型
276
+ backoff_base: 退避基数
277
+ max_backoff: 最大退避时间
278
+
279
+ """
280
+
281
+ def decorator(func):
282
+ async def wrapper(*args, **kwargs):
283
+ last_exc = None
284
+ func_name = getattr(func, "__name__", "unknown")
285
+
286
+ for attempt in range(1, max_retries + 1):
287
+ try:
288
+ return await func(*args, **kwargs)
289
+ except retryable_exceptions as e:
290
+ last_exc = e
291
+ if attempt == max_retries:
292
+ logger.error(
293
+ f"[Misskey API] {func_name} 重试 {max_retries} 次后仍失败: {e}",
294
+ )
295
+ break
296
+
297
+ # 智能退避策略
298
+ if isinstance(e, APIRateLimitError):
299
+ # 频率限制用更长的退避时间
300
+ backoff = min(backoff_base * (3**attempt), max_backoff)
301
+ else:
302
+ # 其他错误用指数退避
303
+ backoff = min(backoff_base * (2**attempt), max_backoff)
304
+
305
+ jitter = random.uniform(0.1, 0.5) # 随机抖动
306
+ sleep_time = backoff + jitter
307
+
308
+ logger.warning(
309
+ f"[Misskey API] {func_name} 第 {attempt} 次重试失败: {e},"
310
+ f"{sleep_time:.1f}s后重试",
311
+ )
312
+ await asyncio.sleep(sleep_time)
313
+ continue
314
+ except Exception as e:
315
+ # 非可重试异常直接抛出
316
+ logger.error(f"[Misskey API] {func_name} 遇到不可重试异常: {e}")
317
+ raise
318
+
319
+ if last_exc:
320
+ raise last_exc
321
+
322
+ return wrapper
323
+
324
+ return decorator
325
+
326
+
327
+ class MisskeyAPI:
328
+ def __init__(
329
+ self,
330
+ instance_url: str,
331
+ access_token: str,
332
+ *,
333
+ allow_insecure_downloads: bool = False,
334
+ download_timeout: int = 15,
335
+ chunk_size: int = 64 * 1024,
336
+ max_download_bytes: int | None = None,
337
+ ):
338
+ self.instance_url = instance_url.rstrip("/")
339
+ self.access_token = access_token
340
+ self._session: aiohttp.ClientSession | None = None
341
+ self.streaming: StreamingClient | None = None
342
+ # download options
343
+ self.allow_insecure_downloads = allow_insecure_downloads
344
+ self.download_timeout = download_timeout
345
+ self.chunk_size = chunk_size
346
+ self.max_download_bytes = (
347
+ int(max_download_bytes) if max_download_bytes is not None else None
348
+ )
349
+
350
+ async def __aenter__(self):
351
+ return self
352
+
353
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
354
+ await self.close()
355
+ return False
356
+
357
+ async def close(self) -> None:
358
+ if self.streaming:
359
+ await self.streaming.disconnect()
360
+ self.streaming = None
361
+ if self._session:
362
+ await self._session.close()
363
+ self._session = None
364
+ logger.debug("[Misskey API] 客户端已关闭")
365
+
366
+ def get_streaming_client(self) -> StreamingClient:
367
+ if not self.streaming:
368
+ self.streaming = StreamingClient(self.instance_url, self.access_token)
369
+ return self.streaming
370
+
371
+ @property
372
+ def session(self) -> aiohttp.ClientSession:
373
+ if self._session is None or self._session.closed:
374
+ headers = {"Authorization": f"Bearer {self.access_token}"}
375
+ self._session = aiohttp.ClientSession(headers=headers)
376
+ return self._session
377
+
378
+ def _handle_response_status(self, status: int, endpoint: str):
379
+ """处理 HTTP 响应状态码"""
380
+ if status == 400:
381
+ logger.error(f"[Misskey API] 请求参数错误: {endpoint} (HTTP {status})")
382
+ raise APIError(f"Bad request for {endpoint}")
383
+ if status == 401:
384
+ logger.error(f"[Misskey API] 未授权访问: {endpoint} (HTTP {status})")
385
+ raise AuthenticationError(f"Unauthorized access for {endpoint}")
386
+ if status == 403:
387
+ logger.error(f"[Misskey API] 访问被禁止: {endpoint} (HTTP {status})")
388
+ raise AuthenticationError(f"Forbidden access for {endpoint}")
389
+ if status == 404:
390
+ logger.error(f"[Misskey API] 资源不存在: {endpoint} (HTTP {status})")
391
+ raise APIError(f"Resource not found for {endpoint}")
392
+ if status == 413:
393
+ logger.error(f"[Misskey API] 请求体过大: {endpoint} (HTTP {status})")
394
+ raise APIError(f"Request entity too large for {endpoint}")
395
+ if status == 429:
396
+ logger.warning(f"[Misskey API] 请求频率限制: {endpoint} (HTTP {status})")
397
+ raise APIRateLimitError(f"Rate limit exceeded for {endpoint}")
398
+ if status == 500:
399
+ logger.error(f"[Misskey API] 服务器内部错误: {endpoint} (HTTP {status})")
400
+ raise APIConnectionError(f"Internal server error for {endpoint}")
401
+ if status == 502:
402
+ logger.error(f"[Misskey API] 网关错误: {endpoint} (HTTP {status})")
403
+ raise APIConnectionError(f"Bad gateway for {endpoint}")
404
+ if status == 503:
405
+ logger.error(f"[Misskey API] 服务不可用: {endpoint} (HTTP {status})")
406
+ raise APIConnectionError(f"Service unavailable for {endpoint}")
407
+ if status == 504:
408
+ logger.error(f"[Misskey API] 网关超时: {endpoint} (HTTP {status})")
409
+ raise APIConnectionError(f"Gateway timeout for {endpoint}")
410
+ logger.error(f"[Misskey API] 未知错误: {endpoint} (HTTP {status})")
411
+ raise APIConnectionError(f"HTTP {status} for {endpoint}")
412
+
413
+ async def _process_response(
414
+ self,
415
+ response: aiohttp.ClientResponse,
416
+ endpoint: str,
417
+ ) -> Any:
418
+ """处理 API 响应"""
419
+ if response.status == HTTP_OK:
420
+ try:
421
+ result = await response.json()
422
+ if endpoint == "i/notifications":
423
+ notifications_data = (
424
+ result
425
+ if isinstance(result, list)
426
+ else result.get("notifications", [])
427
+ if isinstance(result, dict)
428
+ else []
429
+ )
430
+ if notifications_data:
431
+ logger.debug(
432
+ f"[Misskey API] 获取到 {len(notifications_data)} 条新通知",
433
+ )
434
+ else:
435
+ logger.debug(f"[Misskey API] 请求成功: {endpoint}")
436
+ return result
437
+ except json.JSONDecodeError as e:
438
+ logger.error(f"[Misskey API] 响应格式错误: {e}")
439
+ raise APIConnectionError("Invalid JSON response") from e
440
+ else:
441
+ try:
442
+ error_text = await response.text()
443
+ logger.error(
444
+ f"[Misskey API] 请求失败: {endpoint} - HTTP {response.status}, 响应: {error_text}",
445
+ )
446
+ except Exception:
447
+ logger.error(
448
+ f"[Misskey API] 请求失败: {endpoint} - HTTP {response.status}",
449
+ )
450
+
451
+ self._handle_response_status(response.status, endpoint)
452
+ raise APIConnectionError(f"Request failed for {endpoint}")
453
+
454
+ @retry_async(
455
+ max_retries=API_MAX_RETRIES,
456
+ retryable_exceptions=(APIConnectionError, APIRateLimitError),
457
+ )
458
+ async def _make_request(
459
+ self,
460
+ endpoint: str,
461
+ data: dict[str, Any] | None = None,
462
+ ) -> Any:
463
+ url = f"{self.instance_url}/api/{endpoint}"
464
+ payload = {"i": self.access_token}
465
+ if data:
466
+ payload.update(data)
467
+
468
+ try:
469
+ async with self.session.post(url, json=payload) as response:
470
+ return await self._process_response(response, endpoint)
471
+ except aiohttp.ClientError as e:
472
+ logger.error(f"[Misskey API] HTTP 请求错误: {e}")
473
+ raise APIConnectionError(f"HTTP request failed: {e}") from e
474
+
475
+ async def create_note(
476
+ self,
477
+ text: str | None = None,
478
+ visibility: str = "public",
479
+ reply_id: str | None = None,
480
+ visible_user_ids: list[str] | None = None,
481
+ file_ids: list[str] | None = None,
482
+ local_only: bool = False,
483
+ cw: str | None = None,
484
+ poll: dict[str, Any] | None = None,
485
+ renote_id: str | None = None,
486
+ channel_id: str | None = None,
487
+ reaction_acceptance: str | None = None,
488
+ no_extract_mentions: bool | None = None,
489
+ no_extract_hashtags: bool | None = None,
490
+ no_extract_emojis: bool | None = None,
491
+ media_ids: list[str] | None = None,
492
+ ) -> dict[str, Any]:
493
+ """Create a note (wrapper for notes/create). All additional fields are optional and passed through to the API."""
494
+ data: dict[str, Any] = {}
495
+
496
+ if text is not None:
497
+ data["text"] = text
498
+
499
+ data["visibility"] = visibility
500
+ data["localOnly"] = local_only
501
+
502
+ if reply_id:
503
+ data["replyId"] = reply_id
504
+
505
+ if visible_user_ids and visibility == "specified":
506
+ data["visibleUserIds"] = visible_user_ids
507
+
508
+ if file_ids:
509
+ data["fileIds"] = file_ids
510
+ if media_ids:
511
+ data["mediaIds"] = media_ids
512
+
513
+ if cw is not None:
514
+ data["cw"] = cw
515
+ if poll is not None:
516
+ data["poll"] = poll
517
+ if renote_id is not None:
518
+ data["renoteId"] = renote_id
519
+ if channel_id is not None:
520
+ data["channelId"] = channel_id
521
+ if reaction_acceptance is not None:
522
+ data["reactionAcceptance"] = reaction_acceptance
523
+ if no_extract_mentions is not None:
524
+ data["noExtractMentions"] = bool(no_extract_mentions)
525
+ if no_extract_hashtags is not None:
526
+ data["noExtractHashtags"] = bool(no_extract_hashtags)
527
+ if no_extract_emojis is not None:
528
+ data["noExtractEmojis"] = bool(no_extract_emojis)
529
+
530
+ result = await self._make_request("notes/create", data)
531
+ note_id = (
532
+ result.get("createdNote", {}).get("id", "unknown")
533
+ if isinstance(result, dict)
534
+ else "unknown"
535
+ )
536
+ logger.debug(f"[Misskey API] 发帖成功: {note_id}")
537
+ return result
538
+
539
+ async def upload_file(
540
+ self,
541
+ file_path: str,
542
+ name: str | None = None,
543
+ folder_id: str | None = None,
544
+ ) -> dict[str, Any]:
545
+ """Upload a file to Misskey drive/files/create and return a dict containing id and raw result."""
546
+ if not file_path:
547
+ raise APIError("No file path provided for upload")
548
+
549
+ url = f"{self.instance_url}/api/drive/files/create"
550
+ form = aiohttp.FormData()
551
+ form.add_field("i", self.access_token)
552
+
553
+ try:
554
+ filename = name or file_path.split("/")[-1]
555
+ if folder_id:
556
+ form.add_field("folderId", str(folder_id))
557
+
558
+ try:
559
+ f = open(file_path, "rb")
560
+ except FileNotFoundError as e:
561
+ logger.error(f"[Misskey API] 本地文件不存在: {file_path}")
562
+ raise APIError(f"File not found: {file_path}") from e
563
+
564
+ try:
565
+ form.add_field("file", f, filename=filename)
566
+ async with self.session.post(url, data=form) as resp:
567
+ result = await self._process_response(resp, "drive/files/create")
568
+ file_id = FileIDExtractor.extract_file_id(result)
569
+ logger.debug(
570
+ f"[Misskey API] 本地文件上传成功: {filename} -> {file_id}",
571
+ )
572
+ return {"id": file_id, "raw": result}
573
+ finally:
574
+ f.close()
575
+ except aiohttp.ClientError as e:
576
+ logger.error(f"[Misskey API] 文件上传网络错误: {e}")
577
+ raise APIConnectionError(f"Upload failed: {e}") from e
578
+
579
+ async def find_files_by_hash(self, md5_hash: str) -> list[dict[str, Any]]:
580
+ """Find files by MD5 hash"""
581
+ if not md5_hash:
582
+ raise APIError("No MD5 hash provided for find-by-hash")
583
+
584
+ data = {"md5": md5_hash}
585
+
586
+ try:
587
+ logger.debug(f"[Misskey API] find-by-hash 请求: md5={md5_hash}")
588
+ result = await self._make_request("drive/files/find-by-hash", data)
589
+ logger.debug(
590
+ f"[Misskey API] find-by-hash 响应: 找到 {len(result) if isinstance(result, list) else 0} 个文件",
591
+ )
592
+ return result if isinstance(result, list) else []
593
+ except Exception as e:
594
+ logger.error(f"[Misskey API] 根据哈希查找文件失败: {e}")
595
+ raise
596
+
597
+ async def find_files_by_name(
598
+ self,
599
+ name: str,
600
+ folder_id: str | None = None,
601
+ ) -> list[dict[str, Any]]:
602
+ """Find files by name"""
603
+ if not name:
604
+ raise APIError("No name provided for find")
605
+
606
+ data: dict[str, Any] = {"name": name}
607
+ if folder_id:
608
+ data["folderId"] = folder_id
609
+
610
+ try:
611
+ logger.debug(f"[Misskey API] find 请求: name={name}, folder_id={folder_id}")
612
+ result = await self._make_request("drive/files/find", data)
613
+ logger.debug(
614
+ f"[Misskey API] find 响应: 找到 {len(result) if isinstance(result, list) else 0} 个文件",
615
+ )
616
+ return result if isinstance(result, list) else []
617
+ except Exception as e:
618
+ logger.error(f"[Misskey API] 根据名称查找文件失败: {e}")
619
+ raise
620
+
621
+ async def find_files(
622
+ self,
623
+ limit: int = 10,
624
+ folder_id: str | None = None,
625
+ type: str | None = None,
626
+ ) -> list[dict[str, Any]]:
627
+ """List files with optional filters"""
628
+ data: dict[str, Any] = {"limit": limit}
629
+ if folder_id is not None:
630
+ data["folderId"] = folder_id
631
+ if type is not None:
632
+ data["type"] = type
633
+
634
+ try:
635
+ logger.debug(
636
+ f"[Misskey API] 列表文件请求: limit={limit}, folder_id={folder_id}, type={type}",
637
+ )
638
+ result = await self._make_request("drive/files", data)
639
+ logger.debug(
640
+ f"[Misskey API] 列表文件响应: 找到 {len(result) if isinstance(result, list) else 0} 个文件",
641
+ )
642
+ return result if isinstance(result, list) else []
643
+ except Exception as e:
644
+ logger.error(f"[Misskey API] 列表文件失败: {e}")
645
+ raise
646
+
647
+ async def _download_with_existing_session(
648
+ self,
649
+ url: str,
650
+ ssl_verify: bool = True,
651
+ ) -> bytes | None:
652
+ """使用现有会话下载文件"""
653
+ if not (hasattr(self, "session") and self.session):
654
+ raise APIConnectionError("No existing session available")
655
+
656
+ async with self.session.get(
657
+ url,
658
+ timeout=aiohttp.ClientTimeout(total=15),
659
+ ssl=ssl_verify,
660
+ ) as response:
661
+ if response.status == 200:
662
+ return await response.read()
663
+ return None
664
+
665
+ async def _download_with_temp_session(
666
+ self,
667
+ url: str,
668
+ ssl_verify: bool = True,
669
+ ) -> bytes | None:
670
+ """使用临时会话下载文件"""
671
+ connector = aiohttp.TCPConnector(ssl=ssl_verify)
672
+ async with aiohttp.ClientSession(connector=connector) as temp_session:
673
+ async with temp_session.get(
674
+ url,
675
+ timeout=aiohttp.ClientTimeout(total=15),
676
+ ) as response:
677
+ if response.status == 200:
678
+ return await response.read()
679
+ return None
680
+
681
+ async def upload_and_find_file(
682
+ self,
683
+ url: str,
684
+ name: str | None = None,
685
+ folder_id: str | None = None,
686
+ max_wait_time: float = 30.0,
687
+ check_interval: float = 2.0,
688
+ ) -> dict[str, Any] | None:
689
+ """简化的文件上传:尝试 URL 上传,失败则下载后本地上传
690
+
691
+ Args:
692
+ url: 文件URL
693
+ name: 文件名(可选)
694
+ folder_id: 文件夹ID(可选)
695
+ max_wait_time: 保留参数(未使用)
696
+ check_interval: 保留参数(未使用)
697
+
698
+ Returns:
699
+ 包含文件ID和元信息的字典,失败时返回None
700
+
701
+ """
702
+ if not url:
703
+ raise APIError("URL不能为空")
704
+
705
+ # 通过本地上传获取即时文件 ID(下载文件 → 上传 → 返回 ID)
706
+ try:
707
+ import os
708
+ import tempfile
709
+
710
+ # SSL 验证下载,失败则重试不验证 SSL
711
+ tmp_bytes = None
712
+ try:
713
+ tmp_bytes = await self._download_with_existing_session(
714
+ url,
715
+ ssl_verify=True,
716
+ ) or await self._download_with_temp_session(url, ssl_verify=True)
717
+ except Exception as ssl_error:
718
+ logger.debug(
719
+ f"[Misskey API] SSL 验证下载失败: {ssl_error},重试不验证 SSL",
720
+ )
721
+ try:
722
+ tmp_bytes = await self._download_with_existing_session(
723
+ url,
724
+ ssl_verify=False,
725
+ ) or await self._download_with_temp_session(url, ssl_verify=False)
726
+ except Exception:
727
+ pass
728
+
729
+ if tmp_bytes:
730
+ with tempfile.NamedTemporaryFile(delete=False) as tmpf:
731
+ tmpf.write(tmp_bytes)
732
+ tmp_path = tmpf.name
733
+
734
+ try:
735
+ result = await self.upload_file(tmp_path, name, folder_id)
736
+ logger.debug(f"[Misskey API] 本地上传成功: {result.get('id')}")
737
+ return result
738
+ finally:
739
+ try:
740
+ os.unlink(tmp_path)
741
+ except Exception:
742
+ pass
743
+ except Exception as e:
744
+ logger.error(f"[Misskey API] 本地上传失败: {e}")
745
+
746
+ return None
747
+
748
+ async def get_current_user(self) -> dict[str, Any]:
749
+ """获取当前用户信息"""
750
+ return await self._make_request("i", {})
751
+
752
+ async def send_message(
753
+ self,
754
+ user_id_or_payload: Any,
755
+ text: str | None = None,
756
+ ) -> dict[str, Any]:
757
+ """发送聊天消息。
758
+
759
+ Accepts either (user_id: str, text: str) or a single dict payload prepared by caller.
760
+ """
761
+ if isinstance(user_id_or_payload, dict):
762
+ data = user_id_or_payload
763
+ else:
764
+ data = {"toUserId": user_id_or_payload, "text": text}
765
+
766
+ result = await self._make_request("chat/messages/create-to-user", data)
767
+ message_id = result.get("id", "unknown")
768
+ logger.debug(f"[Misskey API] 聊天消息发送成功: {message_id}")
769
+ return result
770
+
771
+ async def send_room_message(
772
+ self,
773
+ room_id_or_payload: Any,
774
+ text: str | None = None,
775
+ ) -> dict[str, Any]:
776
+ """发送房间消息。
777
+
778
+ Accepts either (room_id: str, text: str) or a single dict payload.
779
+ """
780
+ if isinstance(room_id_or_payload, dict):
781
+ data = room_id_or_payload
782
+ else:
783
+ data = {"toRoomId": room_id_or_payload, "text": text}
784
+
785
+ result = await self._make_request("chat/messages/create-to-room", data)
786
+ message_id = result.get("id", "unknown")
787
+ logger.debug(f"[Misskey API] 房间消息发送成功: {message_id}")
788
+ return result
789
+
790
+ async def get_messages(
791
+ self,
792
+ user_id: str,
793
+ limit: int = 10,
794
+ since_id: str | None = None,
795
+ ) -> list[dict[str, Any]]:
796
+ """获取聊天消息历史"""
797
+ data: dict[str, Any] = {"userId": user_id, "limit": limit}
798
+ if since_id:
799
+ data["sinceId"] = since_id
800
+
801
+ result = await self._make_request("chat/messages/user-timeline", data)
802
+ if isinstance(result, list):
803
+ return result
804
+ logger.warning(f"[Misskey API] 聊天消息响应格式异常: {type(result)}")
805
+ return []
806
+
807
+ async def get_mentions(
808
+ self,
809
+ limit: int = 10,
810
+ since_id: str | None = None,
811
+ ) -> list[dict[str, Any]]:
812
+ """获取提及通知"""
813
+ data: dict[str, Any] = {"limit": limit}
814
+ if since_id:
815
+ data["sinceId"] = since_id
816
+ data["includeTypes"] = ["mention", "reply", "quote"]
817
+
818
+ result = await self._make_request("i/notifications", data)
819
+ if isinstance(result, list):
820
+ return result
821
+ if isinstance(result, dict) and "notifications" in result:
822
+ return result["notifications"]
823
+ logger.warning(f"[Misskey API] 提及通知响应格式异常: {type(result)}")
824
+ return []
825
+
826
+ async def send_message_with_media(
827
+ self,
828
+ message_type: str,
829
+ target_id: str,
830
+ text: str | None = None,
831
+ media_urls: list[str] | None = None,
832
+ local_files: list[str] | None = None,
833
+ **kwargs,
834
+ ) -> dict[str, Any]:
835
+ """通用消息发送函数:统一处理文本+媒体发送
836
+
837
+ Args:
838
+ message_type: 消息类型 ('chat', 'room', 'note')
839
+ target_id: 目标ID (用户ID/房间ID/频道ID等)
840
+ text: 文本内容
841
+ media_urls: 媒体文件URL列表
842
+ local_files: 本地文件路径列表
843
+ **kwargs: 其他参数(如visibility等)
844
+
845
+ Returns:
846
+ 发送结果字典
847
+
848
+ Raises:
849
+ APIError: 参数错误或发送失败
850
+
851
+ """
852
+ if not text and not media_urls and not local_files:
853
+ raise APIError("消息内容不能为空:需要文本或媒体文件")
854
+
855
+ file_ids = []
856
+
857
+ # 处理远程媒体文件
858
+ if media_urls:
859
+ file_ids.extend(await self._process_media_urls(media_urls))
860
+
861
+ # 处理本地文件
862
+ if local_files:
863
+ file_ids.extend(await self._process_local_files(local_files))
864
+
865
+ # 根据消息类型发送
866
+ return await self._dispatch_message(
867
+ message_type,
868
+ target_id,
869
+ text,
870
+ file_ids,
871
+ **kwargs,
872
+ )
873
+
874
+ async def _process_media_urls(self, urls: list[str]) -> list[str]:
875
+ """处理远程媒体文件URL列表,返回文件ID列表"""
876
+ file_ids = []
877
+ for url in urls:
878
+ try:
879
+ result = await self.upload_and_find_file(url)
880
+ if result and result.get("id"):
881
+ file_ids.append(result["id"])
882
+ logger.debug(f"[Misskey API] URL媒体上传成功: {result['id']}")
883
+ else:
884
+ logger.error(f"[Misskey API] URL媒体上传失败: {url}")
885
+ except Exception as e:
886
+ logger.error(f"[Misskey API] URL媒体处理失败 {url}: {e}")
887
+ # 继续处理其他文件,不中断整个流程
888
+ continue
889
+ return file_ids
890
+
891
+ async def _process_local_files(self, file_paths: list[str]) -> list[str]:
892
+ """处理本地文件路径列表,返回文件ID列表"""
893
+ file_ids = []
894
+ for file_path in file_paths:
895
+ try:
896
+ result = await self.upload_file(file_path)
897
+ if result and result.get("id"):
898
+ file_ids.append(result["id"])
899
+ logger.debug(f"[Misskey API] 本地文件上传成功: {result['id']}")
900
+ else:
901
+ logger.error(f"[Misskey API] 本地文件上传失败: {file_path}")
902
+ except Exception as e:
903
+ logger.error(f"[Misskey API] 本地文件处理失败 {file_path}: {e}")
904
+ continue
905
+ return file_ids
906
+
907
+ async def _dispatch_message(
908
+ self,
909
+ message_type: str,
910
+ target_id: str,
911
+ text: str | None,
912
+ file_ids: list[str],
913
+ **kwargs,
914
+ ) -> dict[str, Any]:
915
+ """根据消息类型分发到对应的发送方法"""
916
+ if message_type == "chat":
917
+ # 聊天消息使用 fileId (单数)
918
+ payload = {"toUserId": target_id}
919
+ if text:
920
+ payload["text"] = text
921
+ if file_ids:
922
+ if len(file_ids) == 1:
923
+ payload["fileId"] = file_ids[0]
924
+ else:
925
+ # 多文件时逐个发送
926
+ results = []
927
+ for file_id in file_ids:
928
+ single_payload = payload.copy()
929
+ single_payload["fileId"] = file_id
930
+ result = await self.send_message(single_payload)
931
+ results.append(result)
932
+ return {"multiple": True, "results": results}
933
+ return await self.send_message(payload)
934
+
935
+ if message_type == "room":
936
+ # 房间消息使用 fileId (单数)
937
+ payload = {"toRoomId": target_id}
938
+ if text:
939
+ payload["text"] = text
940
+ if file_ids:
941
+ if len(file_ids) == 1:
942
+ payload["fileId"] = file_ids[0]
943
+ else:
944
+ # 多文件时逐个发送
945
+ results = []
946
+ for file_id in file_ids:
947
+ single_payload = payload.copy()
948
+ single_payload["fileId"] = file_id
949
+ result = await self.send_room_message(single_payload)
950
+ results.append(result)
951
+ return {"multiple": True, "results": results}
952
+ return await self.send_room_message(payload)
953
+
954
+ if message_type == "note":
955
+ # 发帖使用 fileIds (复数)
956
+ note_kwargs = {
957
+ "text": text,
958
+ "file_ids": file_ids or None,
959
+ }
960
+ # 合并其他参数
961
+ note_kwargs.update(kwargs)
962
+ return await self.create_note(**note_kwargs)
963
+
964
+ raise APIError(f"不支持的消息类型: {message_type}")