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,770 @@
1
+ import asyncio
2
+ import os
3
+ import random
4
+ from collections.abc import Awaitable
5
+ from typing import Any
6
+
7
+ import astrbot.api.message_components as Comp
8
+ from astrbot.api import logger
9
+ from astrbot.api.event import MessageChain
10
+ from astrbot.api.platform import (
11
+ AstrBotMessage,
12
+ Platform,
13
+ PlatformMetadata,
14
+ register_platform_adapter,
15
+ )
16
+ from astrbot.core.platform.astr_message_event import MessageSession
17
+
18
+ from .misskey_api import MisskeyAPI
19
+
20
+ try:
21
+ import magic # type: ignore
22
+ except Exception:
23
+ magic = None
24
+
25
+ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
26
+
27
+ from .misskey_event import MisskeyPlatformEvent
28
+ from .misskey_utils import (
29
+ add_at_mention_if_needed,
30
+ cache_room_info,
31
+ cache_user_info,
32
+ create_base_message,
33
+ extract_sender_info,
34
+ format_poll,
35
+ is_valid_room_session_id,
36
+ is_valid_user_session_id,
37
+ process_at_mention,
38
+ process_files,
39
+ resolve_message_visibility,
40
+ serialize_message_chain,
41
+ )
42
+
43
+ # Constants
44
+ MAX_FILE_UPLOAD_COUNT = 16
45
+ DEFAULT_UPLOAD_CONCURRENCY = 3
46
+
47
+
48
+ @register_platform_adapter(
49
+ "misskey", "Misskey 平台适配器", support_streaming_message=False
50
+ )
51
+ class MisskeyPlatformAdapter(Platform):
52
+ def __init__(
53
+ self,
54
+ platform_config: dict,
55
+ platform_settings: dict,
56
+ event_queue: asyncio.Queue,
57
+ ) -> None:
58
+ super().__init__(event_queue)
59
+ self.config = platform_config or {}
60
+ self.settings = platform_settings or {}
61
+ self.instance_url = self.config.get("misskey_instance_url", "")
62
+ self.access_token = self.config.get("misskey_token", "")
63
+ self.max_message_length = self.config.get("max_message_length", 3000)
64
+ self.default_visibility = self.config.get(
65
+ "misskey_default_visibility",
66
+ "public",
67
+ )
68
+ self.local_only = self.config.get("misskey_local_only", False)
69
+ self.enable_chat = self.config.get("misskey_enable_chat", True)
70
+ self.enable_file_upload = self.config.get("misskey_enable_file_upload", True)
71
+ self.upload_folder = self.config.get("misskey_upload_folder")
72
+
73
+ # download / security related options (exposed to platform_config)
74
+ self.allow_insecure_downloads = bool(
75
+ self.config.get("misskey_allow_insecure_downloads", False),
76
+ )
77
+ # parse download timeout and chunk size safely
78
+ _dt = self.config.get("misskey_download_timeout")
79
+ try:
80
+ self.download_timeout = int(_dt) if _dt is not None else 15
81
+ except Exception:
82
+ self.download_timeout = 15
83
+
84
+ _chunk = self.config.get("misskey_download_chunk_size")
85
+ try:
86
+ self.download_chunk_size = int(_chunk) if _chunk is not None else 64 * 1024
87
+ except Exception:
88
+ self.download_chunk_size = 64 * 1024
89
+ # parse max download bytes safely
90
+ _md_bytes = self.config.get("misskey_max_download_bytes")
91
+ try:
92
+ self.max_download_bytes = int(_md_bytes) if _md_bytes is not None else None
93
+ except Exception:
94
+ self.max_download_bytes = None
95
+
96
+ self.unique_session = platform_settings["unique_session"]
97
+
98
+ self.api: MisskeyAPI | None = None
99
+ self._running = False
100
+ self.client_self_id = ""
101
+ self._bot_username = ""
102
+ self._user_cache = {}
103
+
104
+ def meta(self) -> PlatformMetadata:
105
+ default_config = {
106
+ "misskey_instance_url": "",
107
+ "misskey_token": "",
108
+ "max_message_length": 3000,
109
+ "misskey_default_visibility": "public",
110
+ "misskey_local_only": False,
111
+ "misskey_enable_chat": True,
112
+ # download / security options
113
+ "misskey_allow_insecure_downloads": False,
114
+ "misskey_download_timeout": 15,
115
+ "misskey_download_chunk_size": 65536,
116
+ "misskey_max_download_bytes": None,
117
+ }
118
+ default_config.update(self.config)
119
+
120
+ return PlatformMetadata(
121
+ name="misskey",
122
+ description="Misskey 平台适配器",
123
+ id=self.config.get("id", "misskey"),
124
+ default_config_tmpl=default_config,
125
+ support_streaming_message=False,
126
+ )
127
+
128
+ async def run(self):
129
+ if not self.instance_url or not self.access_token:
130
+ logger.error("[Misskey] 配置不完整,无法启动")
131
+ return
132
+
133
+ self.api = MisskeyAPI(
134
+ self.instance_url,
135
+ self.access_token,
136
+ allow_insecure_downloads=self.allow_insecure_downloads,
137
+ download_timeout=self.download_timeout,
138
+ chunk_size=self.download_chunk_size,
139
+ max_download_bytes=self.max_download_bytes,
140
+ )
141
+ self._running = True
142
+
143
+ try:
144
+ user_info = await self.api.get_current_user()
145
+ self.client_self_id = str(user_info.get("id", ""))
146
+ self._bot_username = user_info.get("username", "")
147
+ logger.info(
148
+ f"[Misskey] 已连接用户: {self._bot_username} (ID: {self.client_self_id})",
149
+ )
150
+ except Exception as e:
151
+ logger.error(f"[Misskey] 获取用户信息失败: {e}")
152
+ self._running = False
153
+ return
154
+
155
+ await self._start_websocket_connection()
156
+
157
+ def _register_event_handlers(self, streaming):
158
+ """注册事件处理器"""
159
+ streaming.add_message_handler("notification", self._handle_notification)
160
+ streaming.add_message_handler("main:notification", self._handle_notification)
161
+
162
+ if self.enable_chat:
163
+ streaming.add_message_handler("newChatMessage", self._handle_chat_message)
164
+ streaming.add_message_handler(
165
+ "messaging:newChatMessage",
166
+ self._handle_chat_message,
167
+ )
168
+ streaming.add_message_handler("_debug", self._debug_handler)
169
+
170
+ async def _send_text_only_message(
171
+ self,
172
+ session_id: str,
173
+ text: str,
174
+ session,
175
+ message_chain,
176
+ ):
177
+ """发送纯文本消息(无文件上传)"""
178
+ if not self.api:
179
+ return await super().send_by_session(session, message_chain)
180
+
181
+ if session_id and is_valid_user_session_id(session_id):
182
+ from .misskey_utils import extract_user_id_from_session_id
183
+
184
+ user_id = extract_user_id_from_session_id(session_id)
185
+ payload: dict[str, Any] = {"toUserId": user_id, "text": text}
186
+ await self.api.send_message(payload)
187
+ elif session_id and is_valid_room_session_id(session_id):
188
+ from .misskey_utils import extract_room_id_from_session_id
189
+
190
+ room_id = extract_room_id_from_session_id(session_id)
191
+ payload = {"toRoomId": room_id, "text": text}
192
+ await self.api.send_room_message(payload)
193
+
194
+ return await super().send_by_session(session, message_chain)
195
+
196
+ def _process_poll_data(
197
+ self,
198
+ message: AstrBotMessage,
199
+ poll: dict[str, Any],
200
+ message_parts: list[str],
201
+ ):
202
+ """处理投票数据,将其添加到消息中"""
203
+ try:
204
+ if not isinstance(message.raw_message, dict):
205
+ message.raw_message = {}
206
+ message.raw_message["poll"] = poll
207
+ message.poll = poll
208
+ except Exception:
209
+ pass
210
+
211
+ poll_text = format_poll(poll)
212
+ if poll_text:
213
+ message.message.append(Comp.Plain(poll_text))
214
+ message_parts.append(poll_text)
215
+
216
+ def _extract_additional_fields(self, session, message_chain) -> dict[str, Any]:
217
+ """从会话和消息链中提取额外字段"""
218
+ fields = {"cw": None, "poll": None, "renote_id": None, "channel_id": None}
219
+
220
+ for comp in message_chain.chain:
221
+ if hasattr(comp, "cw") and getattr(comp, "cw", None):
222
+ fields["cw"] = comp.cw
223
+ break
224
+
225
+ if hasattr(session, "extra_data") and isinstance(
226
+ getattr(session, "extra_data", None),
227
+ dict,
228
+ ):
229
+ extra_data = session.extra_data
230
+ fields.update(
231
+ {
232
+ "poll": extra_data.get("poll"),
233
+ "renote_id": extra_data.get("renote_id"),
234
+ "channel_id": extra_data.get("channel_id"),
235
+ },
236
+ )
237
+
238
+ return fields
239
+
240
+ async def _start_websocket_connection(self):
241
+ backoff_delay = 1.0
242
+ max_backoff = 300.0
243
+ backoff_multiplier = 1.5
244
+ connection_attempts = 0
245
+
246
+ while self._running:
247
+ try:
248
+ connection_attempts += 1
249
+ if not self.api:
250
+ logger.error("[Misskey] API 客户端未初始化")
251
+ break
252
+
253
+ streaming = self.api.get_streaming_client()
254
+ self._register_event_handlers(streaming)
255
+
256
+ if await streaming.connect():
257
+ logger.info(
258
+ f"[Misskey] WebSocket 已连接 (尝试 #{connection_attempts})",
259
+ )
260
+ connection_attempts = 0
261
+ await streaming.subscribe_channel("main")
262
+ if self.enable_chat:
263
+ await streaming.subscribe_channel("messaging")
264
+ await streaming.subscribe_channel("messagingIndex")
265
+ logger.info("[Misskey] 聊天频道已订阅")
266
+
267
+ backoff_delay = 1.0
268
+ await streaming.listen()
269
+ else:
270
+ logger.error(
271
+ f"[Misskey] WebSocket 连接失败 (尝试 #{connection_attempts})",
272
+ )
273
+
274
+ except Exception as e:
275
+ logger.error(
276
+ f"[Misskey] WebSocket 异常 (尝试 #{connection_attempts}): {e}",
277
+ )
278
+
279
+ if self._running:
280
+ jitter = random.uniform(0, 1.0)
281
+ sleep_time = backoff_delay + jitter
282
+ logger.info(
283
+ f"[Misskey] {sleep_time:.1f}秒后重连 (下次尝试 #{connection_attempts + 1})",
284
+ )
285
+ await asyncio.sleep(sleep_time)
286
+ backoff_delay = min(backoff_delay * backoff_multiplier, max_backoff)
287
+
288
+ async def _handle_notification(self, data: dict[str, Any]):
289
+ try:
290
+ notification_type = data.get("type")
291
+ logger.debug(
292
+ f"[Misskey] 收到通知事件: type={notification_type}, user_id={data.get('userId', 'unknown')}",
293
+ )
294
+ if notification_type in ["mention", "reply", "quote"]:
295
+ note = data.get("note")
296
+ if note and self._is_bot_mentioned(note):
297
+ logger.info(
298
+ f"[Misskey] 处理贴文提及: {note.get('text', '')[:50]}...",
299
+ )
300
+ message = await self.convert_message(note)
301
+ event = MisskeyPlatformEvent(
302
+ message_str=message.message_str,
303
+ message_obj=message,
304
+ platform_meta=self.meta(),
305
+ session_id=message.session_id,
306
+ client=self,
307
+ )
308
+ self.commit_event(event)
309
+ except Exception as e:
310
+ logger.error(f"[Misskey] 处理通知失败: {e}")
311
+
312
+ async def _handle_chat_message(self, data: dict[str, Any]):
313
+ try:
314
+ sender_id = str(
315
+ data.get("fromUserId", "") or data.get("fromUser", {}).get("id", ""),
316
+ )
317
+ room_id = data.get("toRoomId")
318
+ logger.debug(
319
+ f"[Misskey] 收到聊天事件: sender_id={sender_id}, room_id={room_id}, is_self={sender_id == self.client_self_id}",
320
+ )
321
+ if sender_id == self.client_self_id:
322
+ return
323
+
324
+ if room_id:
325
+ raw_text = data.get("text", "")
326
+ logger.debug(
327
+ f"[Misskey] 检查群聊消息: '{raw_text}', 机器人用户名: '{self._bot_username}'",
328
+ )
329
+
330
+ message = await self.convert_room_message(data)
331
+ logger.info(f"[Misskey] 处理群聊消息: {message.message_str[:50]}...")
332
+ else:
333
+ message = await self.convert_chat_message(data)
334
+ logger.info(f"[Misskey] 处理私聊消息: {message.message_str[:50]}...")
335
+
336
+ event = MisskeyPlatformEvent(
337
+ message_str=message.message_str,
338
+ message_obj=message,
339
+ platform_meta=self.meta(),
340
+ session_id=message.session_id,
341
+ client=self,
342
+ )
343
+ self.commit_event(event)
344
+ except Exception as e:
345
+ logger.error(f"[Misskey] 处理聊天消息失败: {e}")
346
+
347
+ async def _debug_handler(self, data: dict[str, Any]):
348
+ event_type = data.get("type", "unknown")
349
+ logger.debug(
350
+ f"[Misskey] 收到未处理事件: type={event_type}, channel={data.get('channel', 'unknown')}",
351
+ )
352
+
353
+ def _is_bot_mentioned(self, note: dict[str, Any]) -> bool:
354
+ text = note.get("text", "")
355
+ if not text:
356
+ return False
357
+
358
+ mentions = note.get("mentions", [])
359
+ if self._bot_username and f"@{self._bot_username}" in text:
360
+ return True
361
+ if self.client_self_id in [str(uid) for uid in mentions]:
362
+ return True
363
+
364
+ reply = note.get("reply")
365
+ if reply and isinstance(reply, dict):
366
+ reply_user_id = str(reply.get("user", {}).get("id", ""))
367
+ if reply_user_id == self.client_self_id:
368
+ return bool(self._bot_username and f"@{self._bot_username}" in text)
369
+
370
+ return False
371
+
372
+ async def send_by_session(
373
+ self,
374
+ session: MessageSession,
375
+ message_chain: MessageChain,
376
+ ) -> Awaitable[Any]:
377
+ if not self.api:
378
+ logger.error("[Misskey] API 客户端未初始化")
379
+ return await super().send_by_session(session, message_chain)
380
+
381
+ try:
382
+ session_id = session.session_id
383
+
384
+ text, has_at_user = serialize_message_chain(message_chain.chain)
385
+
386
+ if not has_at_user and session_id:
387
+ # 从session_id中提取用户ID用于缓存查询
388
+ # session_id格式为: "chat%<user_id>" 或 "room%<room_id>" 或 "note%<user_id>"
389
+ user_id_for_cache = None
390
+ if "%" in session_id:
391
+ parts = session_id.split("%")
392
+ if len(parts) >= 2:
393
+ user_id_for_cache = parts[1]
394
+
395
+ user_info = None
396
+ if user_id_for_cache:
397
+ user_info = self._user_cache.get(user_id_for_cache)
398
+
399
+ text = add_at_mention_if_needed(text, user_info, has_at_user)
400
+
401
+ # 检查是否有文件组件
402
+ has_file_components = any(
403
+ isinstance(comp, Comp.Image)
404
+ or isinstance(comp, Comp.File)
405
+ or hasattr(comp, "convert_to_file_path")
406
+ or hasattr(comp, "get_file")
407
+ or any(
408
+ hasattr(comp, a) for a in ("file", "url", "path", "src", "source")
409
+ )
410
+ for comp in message_chain.chain
411
+ )
412
+
413
+ if not text or not text.strip():
414
+ if not has_file_components:
415
+ logger.warning("[Misskey] 消息内容为空且无文件组件,跳过发送")
416
+ return await super().send_by_session(session, message_chain)
417
+ text = ""
418
+
419
+ if len(text) > self.max_message_length:
420
+ text = text[: self.max_message_length] + "..."
421
+
422
+ file_ids: list[str] = []
423
+ fallback_urls: list[str] = []
424
+
425
+ if not self.enable_file_upload:
426
+ return await self._send_text_only_message(
427
+ session_id,
428
+ text,
429
+ session,
430
+ message_chain,
431
+ )
432
+
433
+ MAX_UPLOAD_CONCURRENCY = 10
434
+ upload_concurrency = int(
435
+ self.config.get(
436
+ "misskey_upload_concurrency",
437
+ DEFAULT_UPLOAD_CONCURRENCY,
438
+ ),
439
+ )
440
+ upload_concurrency = min(upload_concurrency, MAX_UPLOAD_CONCURRENCY)
441
+ sem = asyncio.Semaphore(upload_concurrency)
442
+
443
+ async def _upload_comp(comp) -> object | None:
444
+ """组件上传函数:处理 URL(下载后上传)或本地文件(直接上传)"""
445
+ from .misskey_utils import (
446
+ resolve_component_url_or_path,
447
+ upload_local_with_retries,
448
+ )
449
+
450
+ local_path = None
451
+ try:
452
+ async with sem:
453
+ if not self.api:
454
+ return None
455
+
456
+ # 解析组件的 URL 或本地路径
457
+ url_candidate, local_path = await resolve_component_url_or_path(
458
+ comp,
459
+ )
460
+
461
+ if not url_candidate and not local_path:
462
+ return None
463
+
464
+ preferred_name = getattr(comp, "name", None) or getattr(
465
+ comp,
466
+ "file",
467
+ None,
468
+ )
469
+
470
+ # URL 上传:下载后本地上传
471
+ if url_candidate:
472
+ result = await self.api.upload_and_find_file(
473
+ str(url_candidate),
474
+ preferred_name,
475
+ folder_id=self.upload_folder,
476
+ )
477
+ if isinstance(result, dict) and result.get("id"):
478
+ return str(result["id"])
479
+
480
+ # 本地文件上传
481
+ if local_path:
482
+ file_id = await upload_local_with_retries(
483
+ self.api,
484
+ str(local_path),
485
+ preferred_name,
486
+ self.upload_folder,
487
+ )
488
+ if file_id:
489
+ return file_id
490
+
491
+ # 所有上传都失败,尝试获取 URL 作为回退
492
+ if hasattr(comp, "register_to_file_service"):
493
+ try:
494
+ url = await comp.register_to_file_service()
495
+ if url:
496
+ return {"fallback_url": url}
497
+ except Exception:
498
+ pass
499
+
500
+ return None
501
+
502
+ finally:
503
+ # 清理临时文件
504
+ if local_path and isinstance(local_path, str):
505
+ data_temp = os.path.join(get_astrbot_data_path(), "temp")
506
+ if local_path.startswith(data_temp) and os.path.exists(
507
+ local_path,
508
+ ):
509
+ try:
510
+ os.remove(local_path)
511
+ logger.debug(f"[Misskey] 已清理临时文件: {local_path}")
512
+ except Exception:
513
+ pass
514
+
515
+ # 收集所有可能包含文件/URL信息的组件:支持异步接口或同步字段
516
+ file_components = []
517
+ for comp in message_chain.chain:
518
+ try:
519
+ if (
520
+ isinstance(comp, Comp.Image)
521
+ or isinstance(comp, Comp.File)
522
+ or hasattr(comp, "convert_to_file_path")
523
+ or hasattr(comp, "get_file")
524
+ or any(
525
+ hasattr(comp, a)
526
+ for a in ("file", "url", "path", "src", "source")
527
+ )
528
+ ):
529
+ file_components.append(comp)
530
+ except Exception:
531
+ # 保守跳过无法访问属性的组件
532
+ continue
533
+
534
+ if len(file_components) > MAX_FILE_UPLOAD_COUNT:
535
+ logger.warning(
536
+ f"[Misskey] 文件数量超过限制 ({len(file_components)} > {MAX_FILE_UPLOAD_COUNT}),只上传前{MAX_FILE_UPLOAD_COUNT}个文件",
537
+ )
538
+ file_components = file_components[:MAX_FILE_UPLOAD_COUNT]
539
+
540
+ upload_tasks = [_upload_comp(comp) for comp in file_components]
541
+
542
+ try:
543
+ results = await asyncio.gather(*upload_tasks) if upload_tasks else []
544
+ for r in results:
545
+ if not r:
546
+ continue
547
+ if isinstance(r, dict) and r.get("fallback_url"):
548
+ url = r.get("fallback_url")
549
+ if url:
550
+ fallback_urls.append(str(url))
551
+ else:
552
+ try:
553
+ fid_str = str(r)
554
+ if fid_str:
555
+ file_ids.append(fid_str)
556
+ except Exception:
557
+ pass
558
+ except Exception:
559
+ logger.debug("[Misskey] 并发上传过程中出现异常,继续发送文本")
560
+
561
+ if session_id and is_valid_room_session_id(session_id):
562
+ from .misskey_utils import extract_room_id_from_session_id
563
+
564
+ room_id = extract_room_id_from_session_id(session_id)
565
+ if fallback_urls:
566
+ appended = "\n" + "\n".join(fallback_urls)
567
+ text = (text or "") + appended
568
+ payload: dict[str, Any] = {"toRoomId": room_id, "text": text}
569
+ if file_ids:
570
+ payload["fileIds"] = file_ids
571
+ await self.api.send_room_message(payload)
572
+ elif session_id:
573
+ from .misskey_utils import (
574
+ extract_user_id_from_session_id,
575
+ is_valid_chat_session_id,
576
+ )
577
+
578
+ if is_valid_chat_session_id(session_id):
579
+ user_id = extract_user_id_from_session_id(session_id)
580
+ if fallback_urls:
581
+ appended = "\n" + "\n".join(fallback_urls)
582
+ text = (text or "") + appended
583
+ payload: dict[str, Any] = {"toUserId": user_id, "text": text}
584
+ if file_ids:
585
+ # 聊天消息只支持单个文件,使用 fileId 而不是 fileIds
586
+ payload["fileId"] = file_ids[0]
587
+ if len(file_ids) > 1:
588
+ logger.warning(
589
+ f"[Misskey] 聊天消息只支持单个文件,忽略其余 {len(file_ids) - 1} 个文件",
590
+ )
591
+ await self.api.send_message(payload)
592
+ else:
593
+ # 回退到发帖逻辑
594
+ # 去掉 session_id 中的 note% 前缀以匹配 user_cache 的键格式
595
+ user_id_for_cache = (
596
+ session_id.split("%")[1] if "%" in session_id else session_id
597
+ )
598
+
599
+ # 获取用户缓存信息(包含reply_to_note_id)
600
+ user_info_for_reply = self._user_cache.get(user_id_for_cache, {})
601
+
602
+ visibility, visible_user_ids = resolve_message_visibility(
603
+ user_id=user_id_for_cache,
604
+ user_cache=self._user_cache,
605
+ self_id=self.client_self_id,
606
+ default_visibility=self.default_visibility,
607
+ )
608
+ logger.debug(
609
+ f"[Misskey] 解析可见性: visibility={visibility}, visible_user_ids={visible_user_ids}, session_id={session_id}, user_id_for_cache={user_id_for_cache}",
610
+ )
611
+
612
+ fields = self._extract_additional_fields(session, message_chain)
613
+ if fallback_urls:
614
+ appended = "\n" + "\n".join(fallback_urls)
615
+ text = (text or "") + appended
616
+
617
+ # 从缓存中获取原消息ID作为reply_id
618
+ reply_id = user_info_for_reply.get("reply_to_note_id")
619
+
620
+ await self.api.create_note(
621
+ text=text,
622
+ visibility=visibility,
623
+ visible_user_ids=visible_user_ids,
624
+ file_ids=file_ids or None,
625
+ local_only=self.local_only,
626
+ reply_id=reply_id, # 添加reply_id参数
627
+ cw=fields["cw"],
628
+ poll=fields["poll"],
629
+ renote_id=fields["renote_id"],
630
+ channel_id=fields["channel_id"],
631
+ )
632
+
633
+ except Exception as e:
634
+ logger.error(f"[Misskey] 发送消息失败: {e}")
635
+
636
+ return await super().send_by_session(session, message_chain)
637
+
638
+ async def convert_message(self, raw_data: dict[str, Any]) -> AstrBotMessage:
639
+ """将 Misskey 贴文数据转换为 AstrBotMessage 对象"""
640
+ sender_info = extract_sender_info(raw_data, is_chat=False)
641
+ message = create_base_message(
642
+ raw_data,
643
+ sender_info,
644
+ self.client_self_id,
645
+ is_chat=False,
646
+ unique_session=self.unique_session,
647
+ )
648
+ cache_user_info(
649
+ self._user_cache,
650
+ sender_info,
651
+ raw_data,
652
+ self.client_self_id,
653
+ is_chat=False,
654
+ )
655
+
656
+ message_parts = []
657
+ raw_text = raw_data.get("text", "")
658
+
659
+ if raw_text:
660
+ text_parts, processed_text = process_at_mention(
661
+ message,
662
+ raw_text,
663
+ self._bot_username,
664
+ self.client_self_id,
665
+ )
666
+ message_parts.extend(text_parts)
667
+
668
+ files = raw_data.get("files", [])
669
+ file_parts = process_files(message, files)
670
+ message_parts.extend(file_parts)
671
+
672
+ poll = raw_data.get("poll") or (
673
+ raw_data.get("note", {}).get("poll")
674
+ if isinstance(raw_data.get("note"), dict)
675
+ else None
676
+ )
677
+ if poll and isinstance(poll, dict):
678
+ self._process_poll_data(message, poll, message_parts)
679
+
680
+ message.message_str = (
681
+ " ".join(part for part in message_parts if part.strip())
682
+ if message_parts
683
+ else ""
684
+ )
685
+ return message
686
+
687
+ async def convert_chat_message(self, raw_data: dict[str, Any]) -> AstrBotMessage:
688
+ """将 Misskey 聊天消息数据转换为 AstrBotMessage 对象"""
689
+ sender_info = extract_sender_info(raw_data, is_chat=True)
690
+ message = create_base_message(
691
+ raw_data,
692
+ sender_info,
693
+ self.client_self_id,
694
+ is_chat=True,
695
+ unique_session=self.unique_session,
696
+ )
697
+ cache_user_info(
698
+ self._user_cache,
699
+ sender_info,
700
+ raw_data,
701
+ self.client_self_id,
702
+ is_chat=True,
703
+ )
704
+
705
+ raw_text = raw_data.get("text", "")
706
+ if raw_text:
707
+ message.message.append(Comp.Plain(raw_text))
708
+
709
+ files = raw_data.get("files", [])
710
+ process_files(message, files, include_text_parts=False)
711
+
712
+ message.message_str = raw_text if raw_text else ""
713
+ return message
714
+
715
+ async def convert_room_message(self, raw_data: dict[str, Any]) -> AstrBotMessage:
716
+ """将 Misskey 群聊消息数据转换为 AstrBotMessage 对象"""
717
+ sender_info = extract_sender_info(raw_data, is_chat=True)
718
+ room_id = raw_data.get("toRoomId", "")
719
+ message = create_base_message(
720
+ raw_data,
721
+ sender_info,
722
+ self.client_self_id,
723
+ is_chat=False,
724
+ room_id=room_id,
725
+ unique_session=self.unique_session,
726
+ )
727
+
728
+ cache_user_info(
729
+ self._user_cache,
730
+ sender_info,
731
+ raw_data,
732
+ self.client_self_id,
733
+ is_chat=False,
734
+ )
735
+ cache_room_info(self._user_cache, raw_data, self.client_self_id)
736
+
737
+ raw_text = raw_data.get("text", "")
738
+ message_parts = []
739
+
740
+ if raw_text:
741
+ if self._bot_username and f"@{self._bot_username}" in raw_text:
742
+ text_parts, processed_text = process_at_mention(
743
+ message,
744
+ raw_text,
745
+ self._bot_username,
746
+ self.client_self_id,
747
+ )
748
+ message_parts.extend(text_parts)
749
+ else:
750
+ message.message.append(Comp.Plain(raw_text))
751
+ message_parts.append(raw_text)
752
+
753
+ files = raw_data.get("files", [])
754
+ file_parts = process_files(message, files)
755
+ message_parts.extend(file_parts)
756
+
757
+ message.message_str = (
758
+ " ".join(part for part in message_parts if part.strip())
759
+ if message_parts
760
+ else ""
761
+ )
762
+ return message
763
+
764
+ async def terminate(self):
765
+ self._running = False
766
+ if self.api:
767
+ await self.api.close()
768
+
769
+ def get_client(self) -> Any:
770
+ return self.api