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.
- astrbot/api/__init__.py +16 -4
- astrbot/api/all.py +2 -1
- astrbot/api/event/__init__.py +5 -6
- astrbot/api/event/filter/__init__.py +37 -34
- astrbot/api/platform/__init__.py +7 -8
- astrbot/api/provider/__init__.py +8 -7
- astrbot/api/star/__init__.py +3 -4
- astrbot/api/util/__init__.py +2 -2
- astrbot/cli/__init__.py +1 -0
- astrbot/cli/__main__.py +18 -197
- astrbot/cli/commands/__init__.py +6 -0
- astrbot/cli/commands/cmd_conf.py +209 -0
- astrbot/cli/commands/cmd_init.py +56 -0
- astrbot/cli/commands/cmd_plug.py +245 -0
- astrbot/cli/commands/cmd_run.py +62 -0
- astrbot/cli/utils/__init__.py +18 -0
- astrbot/cli/utils/basic.py +76 -0
- astrbot/cli/utils/plugin.py +246 -0
- astrbot/cli/utils/version_comparator.py +90 -0
- astrbot/core/__init__.py +17 -19
- astrbot/core/agent/agent.py +14 -0
- astrbot/core/agent/handoff.py +38 -0
- astrbot/core/agent/hooks.py +30 -0
- astrbot/core/agent/mcp_client.py +385 -0
- astrbot/core/agent/message.py +175 -0
- astrbot/core/agent/response.py +14 -0
- astrbot/core/agent/run_context.py +22 -0
- astrbot/core/agent/runners/__init__.py +3 -0
- astrbot/core/agent/runners/base.py +65 -0
- astrbot/core/agent/runners/coze/coze_agent_runner.py +367 -0
- astrbot/core/agent/runners/coze/coze_api_client.py +324 -0
- astrbot/core/agent/runners/dashscope/dashscope_agent_runner.py +403 -0
- astrbot/core/agent/runners/dify/dify_agent_runner.py +336 -0
- astrbot/core/agent/runners/dify/dify_api_client.py +195 -0
- astrbot/core/agent/runners/tool_loop_agent_runner.py +400 -0
- astrbot/core/agent/tool.py +285 -0
- astrbot/core/agent/tool_executor.py +17 -0
- astrbot/core/astr_agent_context.py +19 -0
- astrbot/core/astr_agent_hooks.py +36 -0
- astrbot/core/astr_agent_run_util.py +80 -0
- astrbot/core/astr_agent_tool_exec.py +246 -0
- astrbot/core/astrbot_config_mgr.py +275 -0
- astrbot/core/config/__init__.py +2 -2
- astrbot/core/config/astrbot_config.py +60 -20
- astrbot/core/config/default.py +1972 -453
- astrbot/core/config/i18n_utils.py +110 -0
- astrbot/core/conversation_mgr.py +285 -75
- astrbot/core/core_lifecycle.py +167 -62
- astrbot/core/db/__init__.py +305 -102
- astrbot/core/db/migration/helper.py +69 -0
- astrbot/core/db/migration/migra_3_to_4.py +357 -0
- astrbot/core/db/migration/migra_45_to_46.py +44 -0
- astrbot/core/db/migration/migra_webchat_session.py +131 -0
- astrbot/core/db/migration/shared_preferences_v3.py +48 -0
- astrbot/core/db/migration/sqlite_v3.py +497 -0
- astrbot/core/db/po.py +259 -55
- astrbot/core/db/sqlite.py +773 -528
- astrbot/core/db/vec_db/base.py +73 -0
- astrbot/core/db/vec_db/faiss_impl/__init__.py +3 -0
- astrbot/core/db/vec_db/faiss_impl/document_storage.py +392 -0
- astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +93 -0
- astrbot/core/db/vec_db/faiss_impl/sqlite_init.sql +17 -0
- astrbot/core/db/vec_db/faiss_impl/vec_db.py +204 -0
- astrbot/core/event_bus.py +26 -22
- astrbot/core/exceptions.py +9 -0
- astrbot/core/file_token_service.py +98 -0
- astrbot/core/initial_loader.py +19 -10
- astrbot/core/knowledge_base/chunking/__init__.py +9 -0
- astrbot/core/knowledge_base/chunking/base.py +25 -0
- astrbot/core/knowledge_base/chunking/fixed_size.py +59 -0
- astrbot/core/knowledge_base/chunking/recursive.py +161 -0
- astrbot/core/knowledge_base/kb_db_sqlite.py +301 -0
- astrbot/core/knowledge_base/kb_helper.py +642 -0
- astrbot/core/knowledge_base/kb_mgr.py +330 -0
- astrbot/core/knowledge_base/models.py +120 -0
- astrbot/core/knowledge_base/parsers/__init__.py +13 -0
- astrbot/core/knowledge_base/parsers/base.py +51 -0
- astrbot/core/knowledge_base/parsers/markitdown_parser.py +26 -0
- astrbot/core/knowledge_base/parsers/pdf_parser.py +101 -0
- astrbot/core/knowledge_base/parsers/text_parser.py +42 -0
- astrbot/core/knowledge_base/parsers/url_parser.py +103 -0
- astrbot/core/knowledge_base/parsers/util.py +13 -0
- astrbot/core/knowledge_base/prompts.py +65 -0
- astrbot/core/knowledge_base/retrieval/__init__.py +14 -0
- astrbot/core/knowledge_base/retrieval/hit_stopwords.txt +767 -0
- astrbot/core/knowledge_base/retrieval/manager.py +276 -0
- astrbot/core/knowledge_base/retrieval/rank_fusion.py +142 -0
- astrbot/core/knowledge_base/retrieval/sparse_retriever.py +136 -0
- astrbot/core/log.py +21 -15
- astrbot/core/message/components.py +413 -287
- astrbot/core/message/message_event_result.py +35 -24
- astrbot/core/persona_mgr.py +192 -0
- astrbot/core/pipeline/__init__.py +14 -14
- astrbot/core/pipeline/content_safety_check/stage.py +13 -9
- astrbot/core/pipeline/content_safety_check/strategies/__init__.py +1 -2
- astrbot/core/pipeline/content_safety_check/strategies/baidu_aip.py +13 -14
- astrbot/core/pipeline/content_safety_check/strategies/keywords.py +2 -1
- astrbot/core/pipeline/content_safety_check/strategies/strategy.py +6 -6
- astrbot/core/pipeline/context.py +7 -1
- astrbot/core/pipeline/context_utils.py +107 -0
- astrbot/core/pipeline/preprocess_stage/stage.py +63 -36
- astrbot/core/pipeline/process_stage/method/agent_request.py +48 -0
- astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +464 -0
- astrbot/core/pipeline/process_stage/method/agent_sub_stages/third_party.py +202 -0
- astrbot/core/pipeline/process_stage/method/star_request.py +26 -32
- astrbot/core/pipeline/process_stage/stage.py +21 -15
- astrbot/core/pipeline/process_stage/utils.py +125 -0
- astrbot/core/pipeline/rate_limit_check/stage.py +34 -36
- astrbot/core/pipeline/respond/stage.py +142 -101
- astrbot/core/pipeline/result_decorate/stage.py +124 -57
- astrbot/core/pipeline/scheduler.py +21 -16
- astrbot/core/pipeline/session_status_check/stage.py +37 -0
- astrbot/core/pipeline/stage.py +11 -76
- astrbot/core/pipeline/waking_check/stage.py +69 -33
- astrbot/core/pipeline/whitelist_check/stage.py +10 -7
- astrbot/core/platform/__init__.py +6 -6
- astrbot/core/platform/astr_message_event.py +107 -129
- astrbot/core/platform/astrbot_message.py +32 -12
- astrbot/core/platform/manager.py +62 -18
- astrbot/core/platform/message_session.py +30 -0
- astrbot/core/platform/platform.py +16 -24
- astrbot/core/platform/platform_metadata.py +9 -4
- astrbot/core/platform/register.py +12 -7
- astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py +136 -60
- astrbot/core/platform/sources/aiocqhttp/aiocqhttp_platform_adapter.py +126 -46
- astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +63 -31
- astrbot/core/platform/sources/dingtalk/dingtalk_event.py +30 -26
- astrbot/core/platform/sources/discord/client.py +129 -0
- astrbot/core/platform/sources/discord/components.py +139 -0
- astrbot/core/platform/sources/discord/discord_platform_adapter.py +473 -0
- astrbot/core/platform/sources/discord/discord_platform_event.py +313 -0
- astrbot/core/platform/sources/lark/lark_adapter.py +27 -18
- astrbot/core/platform/sources/lark/lark_event.py +39 -13
- astrbot/core/platform/sources/misskey/misskey_adapter.py +770 -0
- astrbot/core/platform/sources/misskey/misskey_api.py +964 -0
- astrbot/core/platform/sources/misskey/misskey_event.py +163 -0
- astrbot/core/platform/sources/misskey/misskey_utils.py +550 -0
- astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +149 -33
- astrbot/core/platform/sources/qqofficial/qqofficial_platform_adapter.py +41 -26
- astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_adapter.py +36 -17
- astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_event.py +3 -1
- astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +14 -8
- astrbot/core/platform/sources/satori/satori_adapter.py +792 -0
- astrbot/core/platform/sources/satori/satori_event.py +432 -0
- astrbot/core/platform/sources/slack/client.py +164 -0
- astrbot/core/platform/sources/slack/slack_adapter.py +416 -0
- astrbot/core/platform/sources/slack/slack_event.py +253 -0
- astrbot/core/platform/sources/telegram/tg_adapter.py +100 -43
- astrbot/core/platform/sources/telegram/tg_event.py +136 -36
- astrbot/core/platform/sources/webchat/webchat_adapter.py +72 -22
- astrbot/core/platform/sources/webchat/webchat_event.py +46 -22
- astrbot/core/platform/sources/webchat/webchat_queue_mgr.py +35 -0
- astrbot/core/platform/sources/wechatpadpro/wechatpadpro_adapter.py +926 -0
- astrbot/core/platform/sources/wechatpadpro/wechatpadpro_message_event.py +178 -0
- astrbot/core/platform/sources/wechatpadpro/xml_data_parser.py +159 -0
- astrbot/core/platform/sources/wecom/wecom_adapter.py +169 -27
- astrbot/core/platform/sources/wecom/wecom_event.py +162 -77
- astrbot/core/platform/sources/wecom/wecom_kf.py +279 -0
- astrbot/core/platform/sources/wecom/wecom_kf_message.py +196 -0
- astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py +297 -0
- astrbot/core/platform/sources/wecom_ai_bot/__init__.py +15 -0
- astrbot/core/platform/sources/wecom_ai_bot/ierror.py +19 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +472 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_api.py +417 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +152 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py +153 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py +168 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_utils.py +209 -0
- astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py +306 -0
- astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py +186 -0
- astrbot/core/platform_message_history_mgr.py +49 -0
- astrbot/core/provider/__init__.py +2 -3
- astrbot/core/provider/entites.py +8 -8
- astrbot/core/provider/entities.py +154 -98
- astrbot/core/provider/func_tool_manager.py +446 -458
- astrbot/core/provider/manager.py +345 -207
- astrbot/core/provider/provider.py +188 -73
- astrbot/core/provider/register.py +9 -7
- astrbot/core/provider/sources/anthropic_source.py +295 -115
- astrbot/core/provider/sources/azure_tts_source.py +224 -0
- astrbot/core/provider/sources/bailian_rerank_source.py +236 -0
- astrbot/core/provider/sources/dashscope_tts.py +138 -14
- astrbot/core/provider/sources/edge_tts_source.py +24 -19
- astrbot/core/provider/sources/fishaudio_tts_api_source.py +58 -13
- astrbot/core/provider/sources/gemini_embedding_source.py +61 -0
- astrbot/core/provider/sources/gemini_source.py +310 -132
- astrbot/core/provider/sources/gemini_tts_source.py +81 -0
- astrbot/core/provider/sources/groq_source.py +15 -0
- astrbot/core/provider/sources/gsv_selfhosted_source.py +151 -0
- astrbot/core/provider/sources/gsvi_tts_source.py +14 -7
- astrbot/core/provider/sources/minimax_tts_api_source.py +159 -0
- astrbot/core/provider/sources/openai_embedding_source.py +40 -0
- astrbot/core/provider/sources/openai_source.py +241 -145
- astrbot/core/provider/sources/openai_tts_api_source.py +18 -7
- astrbot/core/provider/sources/sensevoice_selfhosted_source.py +13 -11
- astrbot/core/provider/sources/vllm_rerank_source.py +71 -0
- astrbot/core/provider/sources/volcengine_tts.py +115 -0
- astrbot/core/provider/sources/whisper_api_source.py +18 -13
- astrbot/core/provider/sources/whisper_selfhosted_source.py +19 -12
- astrbot/core/provider/sources/xinference_rerank_source.py +116 -0
- astrbot/core/provider/sources/xinference_stt_provider.py +197 -0
- astrbot/core/provider/sources/zhipu_source.py +6 -73
- astrbot/core/star/__init__.py +43 -11
- astrbot/core/star/config.py +17 -18
- astrbot/core/star/context.py +362 -138
- astrbot/core/star/filter/__init__.py +4 -3
- astrbot/core/star/filter/command.py +111 -35
- astrbot/core/star/filter/command_group.py +46 -34
- astrbot/core/star/filter/custom_filter.py +6 -5
- astrbot/core/star/filter/event_message_type.py +4 -2
- astrbot/core/star/filter/permission.py +4 -2
- astrbot/core/star/filter/platform_adapter_type.py +45 -12
- astrbot/core/star/filter/regex.py +4 -2
- astrbot/core/star/register/__init__.py +19 -15
- astrbot/core/star/register/star.py +41 -13
- astrbot/core/star/register/star_handler.py +236 -86
- astrbot/core/star/session_llm_manager.py +280 -0
- astrbot/core/star/session_plugin_manager.py +170 -0
- astrbot/core/star/star.py +36 -43
- astrbot/core/star/star_handler.py +47 -85
- astrbot/core/star/star_manager.py +442 -260
- astrbot/core/star/star_tools.py +167 -45
- astrbot/core/star/updator.py +17 -20
- astrbot/core/umop_config_router.py +106 -0
- astrbot/core/updator.py +38 -13
- astrbot/core/utils/astrbot_path.py +39 -0
- astrbot/core/utils/command_parser.py +1 -1
- astrbot/core/utils/io.py +119 -60
- astrbot/core/utils/log_pipe.py +1 -1
- astrbot/core/utils/metrics.py +11 -10
- astrbot/core/utils/migra_helper.py +73 -0
- astrbot/core/utils/path_util.py +63 -62
- astrbot/core/utils/pip_installer.py +37 -15
- astrbot/core/utils/session_lock.py +29 -0
- astrbot/core/utils/session_waiter.py +19 -20
- astrbot/core/utils/shared_preferences.py +174 -34
- astrbot/core/utils/t2i/__init__.py +4 -1
- astrbot/core/utils/t2i/local_strategy.py +386 -238
- astrbot/core/utils/t2i/network_strategy.py +109 -49
- astrbot/core/utils/t2i/renderer.py +29 -14
- astrbot/core/utils/t2i/template/astrbot_powershell.html +184 -0
- astrbot/core/utils/t2i/template_manager.py +111 -0
- astrbot/core/utils/tencent_record_helper.py +115 -1
- astrbot/core/utils/version_comparator.py +10 -13
- astrbot/core/zip_updator.py +112 -65
- astrbot/dashboard/routes/__init__.py +20 -13
- astrbot/dashboard/routes/auth.py +20 -9
- astrbot/dashboard/routes/chat.py +297 -141
- astrbot/dashboard/routes/config.py +652 -55
- astrbot/dashboard/routes/conversation.py +107 -37
- astrbot/dashboard/routes/file.py +26 -0
- astrbot/dashboard/routes/knowledge_base.py +1244 -0
- astrbot/dashboard/routes/log.py +27 -2
- astrbot/dashboard/routes/persona.py +202 -0
- astrbot/dashboard/routes/plugin.py +197 -139
- astrbot/dashboard/routes/route.py +27 -7
- astrbot/dashboard/routes/session_management.py +354 -0
- astrbot/dashboard/routes/stat.py +85 -18
- astrbot/dashboard/routes/static_file.py +5 -2
- astrbot/dashboard/routes/t2i.py +233 -0
- astrbot/dashboard/routes/tools.py +184 -120
- astrbot/dashboard/routes/update.py +59 -36
- astrbot/dashboard/server.py +96 -36
- astrbot/dashboard/utils.py +165 -0
- astrbot-4.7.0.dist-info/METADATA +294 -0
- astrbot-4.7.0.dist-info/RECORD +274 -0
- {astrbot-3.5.6.dist-info → astrbot-4.7.0.dist-info}/WHEEL +1 -1
- astrbot/core/db/plugin/sqlite_impl.py +0 -112
- astrbot/core/db/sqlite_init.sql +0 -50
- astrbot/core/pipeline/platform_compatibility/stage.py +0 -56
- astrbot/core/pipeline/process_stage/method/llm_request.py +0 -606
- astrbot/core/platform/sources/gewechat/client.py +0 -806
- astrbot/core/platform/sources/gewechat/downloader.py +0 -55
- astrbot/core/platform/sources/gewechat/gewechat_event.py +0 -255
- astrbot/core/platform/sources/gewechat/gewechat_platform_adapter.py +0 -103
- astrbot/core/platform/sources/gewechat/xml_data_parser.py +0 -110
- astrbot/core/provider/sources/dashscope_source.py +0 -203
- astrbot/core/provider/sources/dify_source.py +0 -281
- astrbot/core/provider/sources/llmtuner_source.py +0 -132
- astrbot/core/rag/embedding/openai_source.py +0 -20
- astrbot/core/rag/knowledge_db_mgr.py +0 -94
- astrbot/core/rag/store/__init__.py +0 -9
- astrbot/core/rag/store/chroma_db.py +0 -42
- astrbot/core/utils/dify_api_client.py +0 -152
- astrbot-3.5.6.dist-info/METADATA +0 -249
- astrbot-3.5.6.dist-info/RECORD +0 -158
- {astrbot-3.5.6.dist-info → astrbot-4.7.0.dist-info}/entry_points.txt +0 -0
- {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}")
|