AstrBot 4.3.3__py3-none-any.whl → 4.5.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/core/agent/mcp_client.py +18 -4
- astrbot/core/agent/runners/tool_loop_agent_runner.py +31 -2
- astrbot/core/astr_agent_context.py +1 -0
- astrbot/core/astrbot_config_mgr.py +23 -51
- astrbot/core/config/default.py +139 -14
- astrbot/core/conversation_mgr.py +36 -1
- astrbot/core/core_lifecycle.py +24 -5
- astrbot/core/db/migration/migra_45_to_46.py +44 -0
- astrbot/core/db/vec_db/base.py +33 -2
- astrbot/core/db/vec_db/faiss_impl/document_storage.py +310 -52
- astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +31 -3
- astrbot/core/db/vec_db/faiss_impl/vec_db.py +81 -23
- astrbot/core/file_token_service.py +6 -1
- astrbot/core/initial_loader.py +6 -3
- astrbot/core/knowledge_base/chunking/__init__.py +11 -0
- astrbot/core/knowledge_base/chunking/base.py +24 -0
- astrbot/core/knowledge_base/chunking/fixed_size.py +57 -0
- astrbot/core/knowledge_base/chunking/recursive.py +155 -0
- astrbot/core/knowledge_base/kb_db_sqlite.py +299 -0
- astrbot/core/knowledge_base/kb_helper.py +348 -0
- astrbot/core/knowledge_base/kb_mgr.py +287 -0
- astrbot/core/knowledge_base/models.py +114 -0
- astrbot/core/knowledge_base/parsers/__init__.py +15 -0
- astrbot/core/knowledge_base/parsers/base.py +50 -0
- astrbot/core/knowledge_base/parsers/markitdown_parser.py +25 -0
- astrbot/core/knowledge_base/parsers/pdf_parser.py +100 -0
- astrbot/core/knowledge_base/parsers/text_parser.py +41 -0
- astrbot/core/knowledge_base/parsers/util.py +13 -0
- astrbot/core/knowledge_base/retrieval/__init__.py +16 -0
- astrbot/core/knowledge_base/retrieval/hit_stopwords.txt +767 -0
- astrbot/core/knowledge_base/retrieval/manager.py +273 -0
- astrbot/core/knowledge_base/retrieval/rank_fusion.py +138 -0
- astrbot/core/knowledge_base/retrieval/sparse_retriever.py +130 -0
- astrbot/core/pipeline/process_stage/method/llm_request.py +61 -21
- astrbot/core/pipeline/process_stage/utils.py +80 -0
- astrbot/core/pipeline/scheduler.py +1 -1
- astrbot/core/platform/astr_message_event.py +8 -7
- astrbot/core/platform/manager.py +4 -0
- astrbot/core/platform/sources/misskey/misskey_adapter.py +380 -44
- astrbot/core/platform/sources/misskey/misskey_api.py +581 -45
- astrbot/core/platform/sources/misskey/misskey_event.py +76 -41
- astrbot/core/platform/sources/misskey/misskey_utils.py +254 -43
- astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +2 -1
- astrbot/core/platform/sources/satori/satori_adapter.py +27 -1
- astrbot/core/platform/sources/satori/satori_event.py +270 -77
- astrbot/core/platform/sources/webchat/webchat_adapter.py +0 -1
- astrbot/core/platform/sources/wecom_ai_bot/WXBizJsonMsgCrypt.py +289 -0
- astrbot/core/platform/sources/wecom_ai_bot/__init__.py +17 -0
- astrbot/core/platform/sources/wecom_ai_bot/ierror.py +20 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +445 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_api.py +378 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_event.py +149 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_queue_mgr.py +148 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py +166 -0
- astrbot/core/platform/sources/wecom_ai_bot/wecomai_utils.py +199 -0
- astrbot/core/provider/manager.py +14 -9
- astrbot/core/provider/provider.py +67 -0
- astrbot/core/provider/sources/anthropic_source.py +4 -4
- astrbot/core/provider/sources/dashscope_source.py +10 -9
- astrbot/core/provider/sources/dify_source.py +6 -8
- astrbot/core/provider/sources/gemini_embedding_source.py +1 -2
- astrbot/core/provider/sources/openai_embedding_source.py +1 -2
- astrbot/core/provider/sources/openai_source.py +18 -15
- astrbot/core/provider/sources/openai_tts_api_source.py +1 -1
- astrbot/core/star/context.py +3 -0
- astrbot/core/star/star.py +6 -0
- astrbot/core/star/star_manager.py +13 -7
- astrbot/core/umop_config_router.py +81 -0
- astrbot/core/updator.py +1 -1
- astrbot/core/utils/io.py +23 -12
- astrbot/dashboard/routes/__init__.py +2 -0
- astrbot/dashboard/routes/config.py +137 -9
- astrbot/dashboard/routes/knowledge_base.py +1065 -0
- astrbot/dashboard/routes/plugin.py +24 -5
- astrbot/dashboard/routes/tools.py +14 -0
- astrbot/dashboard/routes/update.py +1 -1
- astrbot/dashboard/server.py +6 -0
- astrbot/dashboard/utils.py +161 -0
- {astrbot-4.3.3.dist-info → astrbot-4.5.0.dist-info}/METADATA +91 -55
- {astrbot-4.3.3.dist-info → astrbot-4.5.0.dist-info}/RECORD +83 -50
- {astrbot-4.3.3.dist-info → astrbot-4.5.0.dist-info}/WHEEL +0 -0
- {astrbot-4.3.3.dist-info → astrbot-4.5.0.dist-info}/entry_points.txt +0 -0
- {astrbot-4.3.3.dist-info → astrbot-4.5.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import random
|
|
3
|
+
import asyncio
|
|
2
4
|
from typing import Any, Optional, Dict, List, Callable, Awaitable
|
|
3
5
|
import uuid
|
|
4
6
|
|
|
@@ -11,6 +13,7 @@ except ImportError as e:
|
|
|
11
13
|
) from e
|
|
12
14
|
|
|
13
15
|
from astrbot.api import logger
|
|
16
|
+
from .misskey_utils import FileIDExtractor
|
|
14
17
|
|
|
15
18
|
# Constants
|
|
16
19
|
API_MAX_RETRIES = 3
|
|
@@ -55,6 +58,7 @@ class StreamingClient:
|
|
|
55
58
|
self.is_connected = False
|
|
56
59
|
self.message_handlers: Dict[str, Callable] = {}
|
|
57
60
|
self.channels: Dict[str, str] = {}
|
|
61
|
+
self.desired_channels: Dict[str, Optional[Dict]] = {}
|
|
58
62
|
self._running = False
|
|
59
63
|
self._last_pong = None
|
|
60
64
|
|
|
@@ -72,6 +76,18 @@ class StreamingClient:
|
|
|
72
76
|
self._running = True
|
|
73
77
|
|
|
74
78
|
logger.info("[Misskey WebSocket] 已连接")
|
|
79
|
+
if self.desired_channels:
|
|
80
|
+
try:
|
|
81
|
+
desired = list(self.desired_channels.items())
|
|
82
|
+
for channel_type, params in desired:
|
|
83
|
+
try:
|
|
84
|
+
await self.subscribe_channel(channel_type, params)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.warning(
|
|
87
|
+
f"[Misskey WebSocket] 重新订阅 {channel_type} 失败: {e}"
|
|
88
|
+
)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
75
91
|
return True
|
|
76
92
|
|
|
77
93
|
except Exception as e:
|
|
@@ -112,9 +128,12 @@ class StreamingClient:
|
|
|
112
128
|
return
|
|
113
129
|
|
|
114
130
|
message = {"type": "disconnect", "body": {"id": channel_id}}
|
|
115
|
-
|
|
116
131
|
await self.websocket.send(json.dumps(message))
|
|
117
|
-
|
|
132
|
+
channel_type = self.channels.get(channel_id)
|
|
133
|
+
if channel_id in self.channels:
|
|
134
|
+
del self.channels[channel_id]
|
|
135
|
+
if channel_type and channel_type not in self.channels.values():
|
|
136
|
+
self.desired_channels.pop(channel_type, None)
|
|
118
137
|
|
|
119
138
|
def add_message_handler(
|
|
120
139
|
self, event_type: str, handler: Callable[[Dict], Awaitable[None]]
|
|
@@ -141,25 +160,67 @@ class StreamingClient:
|
|
|
141
160
|
except websockets.exceptions.ConnectionClosedError as e:
|
|
142
161
|
logger.warning(f"[Misskey WebSocket] 连接意外关闭: {e}")
|
|
143
162
|
self.is_connected = False
|
|
163
|
+
try:
|
|
164
|
+
await self.disconnect()
|
|
165
|
+
except Exception:
|
|
166
|
+
pass
|
|
144
167
|
except websockets.exceptions.ConnectionClosed as e:
|
|
145
168
|
logger.warning(
|
|
146
169
|
f"[Misskey WebSocket] 连接已关闭 (代码: {e.code}, 原因: {e.reason})"
|
|
147
170
|
)
|
|
148
171
|
self.is_connected = False
|
|
172
|
+
try:
|
|
173
|
+
await self.disconnect()
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
149
176
|
except websockets.exceptions.InvalidHandshake as e:
|
|
150
177
|
logger.error(f"[Misskey WebSocket] 握手失败: {e}")
|
|
151
178
|
self.is_connected = False
|
|
179
|
+
try:
|
|
180
|
+
await self.disconnect()
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
152
183
|
except Exception as e:
|
|
153
184
|
logger.error(f"[Misskey WebSocket] 监听消息失败: {e}")
|
|
154
185
|
self.is_connected = False
|
|
186
|
+
try:
|
|
187
|
+
await self.disconnect()
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
155
190
|
|
|
156
191
|
async def _handle_message(self, data: Dict[str, Any]):
|
|
157
192
|
message_type = data.get("type")
|
|
158
193
|
body = data.get("body", {})
|
|
159
194
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
195
|
+
def _build_channel_summary(message_type: Optional[str], body: Any) -> str:
|
|
196
|
+
try:
|
|
197
|
+
if not isinstance(body, dict):
|
|
198
|
+
return f"[Misskey WebSocket] 收到消息类型: {message_type}"
|
|
199
|
+
|
|
200
|
+
inner = body.get("body") if isinstance(body.get("body"), dict) else body
|
|
201
|
+
note = (
|
|
202
|
+
inner.get("note")
|
|
203
|
+
if isinstance(inner, dict) and isinstance(inner.get("note"), dict)
|
|
204
|
+
else None
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
text = note.get("text") if note else None
|
|
208
|
+
note_id = note.get("id") if note else None
|
|
209
|
+
files = note.get("files") or [] if note else []
|
|
210
|
+
has_files = bool(files)
|
|
211
|
+
is_hidden = bool(note.get("isHidden")) if note else False
|
|
212
|
+
user = note.get("user", {}) if note else None
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
f"[Misskey WebSocket] 收到消息类型: {message_type} | "
|
|
216
|
+
f"note_id={note_id} | user={user.get('username') if user else None} | "
|
|
217
|
+
f"text={text[:80] if text else '[no-text]'} | files={has_files} | hidden={is_hidden}"
|
|
218
|
+
)
|
|
219
|
+
except Exception:
|
|
220
|
+
return f"[Misskey WebSocket] 收到消息类型: {message_type}"
|
|
221
|
+
|
|
222
|
+
channel_summary = _build_channel_summary(message_type, body)
|
|
223
|
+
logger.info(channel_summary)
|
|
163
224
|
|
|
164
225
|
if message_type == "channel":
|
|
165
226
|
channel_id = body.get("id")
|
|
@@ -202,16 +263,60 @@ class StreamingClient:
|
|
|
202
263
|
await self.message_handlers["_debug"](data)
|
|
203
264
|
|
|
204
265
|
|
|
205
|
-
def retry_async(
|
|
266
|
+
def retry_async(
|
|
267
|
+
max_retries: int = 3,
|
|
268
|
+
retryable_exceptions: tuple = (APIConnectionError, APIRateLimitError),
|
|
269
|
+
backoff_base: float = 1.0,
|
|
270
|
+
max_backoff: float = 30.0,
|
|
271
|
+
):
|
|
272
|
+
"""
|
|
273
|
+
智能异步重试装饰器
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
max_retries: 最大重试次数
|
|
277
|
+
retryable_exceptions: 可重试的异常类型
|
|
278
|
+
backoff_base: 退避基数
|
|
279
|
+
max_backoff: 最大退避时间
|
|
280
|
+
"""
|
|
281
|
+
|
|
206
282
|
def decorator(func):
|
|
207
283
|
async def wrapper(*args, **kwargs):
|
|
208
284
|
last_exc = None
|
|
209
|
-
|
|
285
|
+
func_name = getattr(func, "__name__", "unknown")
|
|
286
|
+
|
|
287
|
+
for attempt in range(1, max_retries + 1):
|
|
210
288
|
try:
|
|
211
289
|
return await func(*args, **kwargs)
|
|
212
290
|
except retryable_exceptions as e:
|
|
213
291
|
last_exc = e
|
|
292
|
+
if attempt == max_retries:
|
|
293
|
+
logger.error(
|
|
294
|
+
f"[Misskey API] {func_name} 重试 {max_retries} 次后仍失败: {e}"
|
|
295
|
+
)
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
# 智能退避策略
|
|
299
|
+
if isinstance(e, APIRateLimitError):
|
|
300
|
+
# 频率限制用更长的退避时间
|
|
301
|
+
backoff = min(backoff_base * (3**attempt), max_backoff)
|
|
302
|
+
else:
|
|
303
|
+
# 其他错误用指数退避
|
|
304
|
+
backoff = min(backoff_base * (2**attempt), max_backoff)
|
|
305
|
+
|
|
306
|
+
jitter = random.uniform(0.1, 0.5) # 随机抖动
|
|
307
|
+
sleep_time = backoff + jitter
|
|
308
|
+
|
|
309
|
+
logger.warning(
|
|
310
|
+
f"[Misskey API] {func_name} 第 {attempt} 次重试失败: {e},"
|
|
311
|
+
f"{sleep_time:.1f}s后重试"
|
|
312
|
+
)
|
|
313
|
+
await asyncio.sleep(sleep_time)
|
|
214
314
|
continue
|
|
315
|
+
except Exception as e:
|
|
316
|
+
# 非可重试异常直接抛出
|
|
317
|
+
logger.error(f"[Misskey API] {func_name} 遇到不可重试异常: {e}")
|
|
318
|
+
raise
|
|
319
|
+
|
|
215
320
|
if last_exc:
|
|
216
321
|
raise last_exc
|
|
217
322
|
|
|
@@ -221,11 +326,27 @@ def retry_async(max_retries: int = 3, retryable_exceptions: tuple = ()):
|
|
|
221
326
|
|
|
222
327
|
|
|
223
328
|
class MisskeyAPI:
|
|
224
|
-
def __init__(
|
|
329
|
+
def __init__(
|
|
330
|
+
self,
|
|
331
|
+
instance_url: str,
|
|
332
|
+
access_token: str,
|
|
333
|
+
*,
|
|
334
|
+
allow_insecure_downloads: bool = False,
|
|
335
|
+
download_timeout: int = 15,
|
|
336
|
+
chunk_size: int = 64 * 1024,
|
|
337
|
+
max_download_bytes: Optional[int] = None,
|
|
338
|
+
):
|
|
225
339
|
self.instance_url = instance_url.rstrip("/")
|
|
226
340
|
self.access_token = access_token
|
|
227
341
|
self._session: Optional[aiohttp.ClientSession] = None
|
|
228
342
|
self.streaming: Optional[StreamingClient] = None
|
|
343
|
+
# download options
|
|
344
|
+
self.allow_insecure_downloads = allow_insecure_downloads
|
|
345
|
+
self.download_timeout = download_timeout
|
|
346
|
+
self.chunk_size = chunk_size
|
|
347
|
+
self.max_download_bytes = (
|
|
348
|
+
int(max_download_bytes) if max_download_bytes is not None else None
|
|
349
|
+
)
|
|
229
350
|
|
|
230
351
|
async def __aenter__(self):
|
|
231
352
|
return self
|
|
@@ -258,16 +379,37 @@ class MisskeyAPI:
|
|
|
258
379
|
def _handle_response_status(self, status: int, endpoint: str):
|
|
259
380
|
"""处理 HTTP 响应状态码"""
|
|
260
381
|
if status == 400:
|
|
261
|
-
logger.error(f"API
|
|
382
|
+
logger.error(f"[Misskey API] 请求参数错误: {endpoint} (HTTP {status})")
|
|
262
383
|
raise APIError(f"Bad request for {endpoint}")
|
|
263
|
-
elif status
|
|
264
|
-
logger.error(f"API
|
|
265
|
-
raise AuthenticationError(f"
|
|
384
|
+
elif status == 401:
|
|
385
|
+
logger.error(f"[Misskey API] 未授权访问: {endpoint} (HTTP {status})")
|
|
386
|
+
raise AuthenticationError(f"Unauthorized access for {endpoint}")
|
|
387
|
+
elif status == 403:
|
|
388
|
+
logger.error(f"[Misskey API] 访问被禁止: {endpoint} (HTTP {status})")
|
|
389
|
+
raise AuthenticationError(f"Forbidden access for {endpoint}")
|
|
390
|
+
elif status == 404:
|
|
391
|
+
logger.error(f"[Misskey API] 资源不存在: {endpoint} (HTTP {status})")
|
|
392
|
+
raise APIError(f"Resource not found for {endpoint}")
|
|
393
|
+
elif status == 413:
|
|
394
|
+
logger.error(f"[Misskey API] 请求体过大: {endpoint} (HTTP {status})")
|
|
395
|
+
raise APIError(f"Request entity too large for {endpoint}")
|
|
266
396
|
elif status == 429:
|
|
267
|
-
logger.warning(f"API
|
|
397
|
+
logger.warning(f"[Misskey API] 请求频率限制: {endpoint} (HTTP {status})")
|
|
268
398
|
raise APIRateLimitError(f"Rate limit exceeded for {endpoint}")
|
|
399
|
+
elif status == 500:
|
|
400
|
+
logger.error(f"[Misskey API] 服务器内部错误: {endpoint} (HTTP {status})")
|
|
401
|
+
raise APIConnectionError(f"Internal server error for {endpoint}")
|
|
402
|
+
elif status == 502:
|
|
403
|
+
logger.error(f"[Misskey API] 网关错误: {endpoint} (HTTP {status})")
|
|
404
|
+
raise APIConnectionError(f"Bad gateway for {endpoint}")
|
|
405
|
+
elif status == 503:
|
|
406
|
+
logger.error(f"[Misskey API] 服务不可用: {endpoint} (HTTP {status})")
|
|
407
|
+
raise APIConnectionError(f"Service unavailable for {endpoint}")
|
|
408
|
+
elif status == 504:
|
|
409
|
+
logger.error(f"[Misskey API] 网关超时: {endpoint} (HTTP {status})")
|
|
410
|
+
raise APIConnectionError(f"Gateway timeout for {endpoint}")
|
|
269
411
|
else:
|
|
270
|
-
logger.error(f"API
|
|
412
|
+
logger.error(f"[Misskey API] 未知错误: {endpoint} (HTTP {status})")
|
|
271
413
|
raise APIConnectionError(f"HTTP {status} for {endpoint}")
|
|
272
414
|
|
|
273
415
|
async def _process_response(
|
|
@@ -286,21 +428,25 @@ class MisskeyAPI:
|
|
|
286
428
|
else []
|
|
287
429
|
)
|
|
288
430
|
if notifications_data:
|
|
289
|
-
logger.debug(
|
|
431
|
+
logger.debug(
|
|
432
|
+
f"[Misskey API] 获取到 {len(notifications_data)} 条新通知"
|
|
433
|
+
)
|
|
290
434
|
else:
|
|
291
|
-
logger.debug(f"API 请求成功: {endpoint}")
|
|
435
|
+
logger.debug(f"[Misskey API] 请求成功: {endpoint}")
|
|
292
436
|
return result
|
|
293
437
|
except json.JSONDecodeError as e:
|
|
294
|
-
logger.error(f"
|
|
438
|
+
logger.error(f"[Misskey API] 响应格式错误: {e}")
|
|
295
439
|
raise APIConnectionError("Invalid JSON response") from e
|
|
296
440
|
else:
|
|
297
441
|
try:
|
|
298
442
|
error_text = await response.text()
|
|
299
443
|
logger.error(
|
|
300
|
-
f"API 请求失败: {endpoint} -
|
|
444
|
+
f"[Misskey API] 请求失败: {endpoint} - HTTP {response.status}, 响应: {error_text}"
|
|
301
445
|
)
|
|
302
446
|
except Exception:
|
|
303
|
-
logger.error(
|
|
447
|
+
logger.error(
|
|
448
|
+
f"[Misskey API] 请求失败: {endpoint} - HTTP {response.status}"
|
|
449
|
+
)
|
|
304
450
|
|
|
305
451
|
self._handle_response_status(response.status, endpoint)
|
|
306
452
|
raise APIConnectionError(f"Request failed for {endpoint}")
|
|
@@ -321,53 +467,307 @@ class MisskeyAPI:
|
|
|
321
467
|
async with self.session.post(url, json=payload) as response:
|
|
322
468
|
return await self._process_response(response, endpoint)
|
|
323
469
|
except aiohttp.ClientError as e:
|
|
324
|
-
logger.error(f"HTTP 请求错误: {e}")
|
|
470
|
+
logger.error(f"[Misskey API] HTTP 请求错误: {e}")
|
|
325
471
|
raise APIConnectionError(f"HTTP request failed: {e}") from e
|
|
326
472
|
|
|
327
473
|
async def create_note(
|
|
328
474
|
self,
|
|
329
|
-
text: str,
|
|
475
|
+
text: Optional[str] = None,
|
|
330
476
|
visibility: str = "public",
|
|
331
477
|
reply_id: Optional[str] = None,
|
|
332
478
|
visible_user_ids: Optional[List[str]] = None,
|
|
479
|
+
file_ids: Optional[List[str]] = None,
|
|
333
480
|
local_only: bool = False,
|
|
481
|
+
cw: Optional[str] = None,
|
|
482
|
+
poll: Optional[Dict[str, Any]] = None,
|
|
483
|
+
renote_id: Optional[str] = None,
|
|
484
|
+
channel_id: Optional[str] = None,
|
|
485
|
+
reaction_acceptance: Optional[str] = None,
|
|
486
|
+
no_extract_mentions: Optional[bool] = None,
|
|
487
|
+
no_extract_hashtags: Optional[bool] = None,
|
|
488
|
+
no_extract_emojis: Optional[bool] = None,
|
|
489
|
+
media_ids: Optional[List[str]] = None,
|
|
334
490
|
) -> Dict[str, Any]:
|
|
335
|
-
"""
|
|
336
|
-
data: Dict[str, Any] = {
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
"
|
|
340
|
-
|
|
491
|
+
"""Create a note (wrapper for notes/create). All additional fields are optional and passed through to the API."""
|
|
492
|
+
data: Dict[str, Any] = {}
|
|
493
|
+
|
|
494
|
+
if text is not None:
|
|
495
|
+
data["text"] = text
|
|
496
|
+
|
|
497
|
+
data["visibility"] = visibility
|
|
498
|
+
data["localOnly"] = local_only
|
|
499
|
+
|
|
341
500
|
if reply_id:
|
|
342
501
|
data["replyId"] = reply_id
|
|
502
|
+
|
|
343
503
|
if visible_user_ids and visibility == "specified":
|
|
344
504
|
data["visibleUserIds"] = visible_user_ids
|
|
345
505
|
|
|
506
|
+
if file_ids:
|
|
507
|
+
data["fileIds"] = file_ids
|
|
508
|
+
if media_ids:
|
|
509
|
+
data["mediaIds"] = media_ids
|
|
510
|
+
|
|
511
|
+
if cw is not None:
|
|
512
|
+
data["cw"] = cw
|
|
513
|
+
if poll is not None:
|
|
514
|
+
data["poll"] = poll
|
|
515
|
+
if renote_id is not None:
|
|
516
|
+
data["renoteId"] = renote_id
|
|
517
|
+
if channel_id is not None:
|
|
518
|
+
data["channelId"] = channel_id
|
|
519
|
+
if reaction_acceptance is not None:
|
|
520
|
+
data["reactionAcceptance"] = reaction_acceptance
|
|
521
|
+
if no_extract_mentions is not None:
|
|
522
|
+
data["noExtractMentions"] = bool(no_extract_mentions)
|
|
523
|
+
if no_extract_hashtags is not None:
|
|
524
|
+
data["noExtractHashtags"] = bool(no_extract_hashtags)
|
|
525
|
+
if no_extract_emojis is not None:
|
|
526
|
+
data["noExtractEmojis"] = bool(no_extract_emojis)
|
|
527
|
+
|
|
346
528
|
result = await self._make_request("notes/create", data)
|
|
347
|
-
note_id =
|
|
348
|
-
|
|
529
|
+
note_id = (
|
|
530
|
+
result.get("createdNote", {}).get("id", "unknown")
|
|
531
|
+
if isinstance(result, dict)
|
|
532
|
+
else "unknown"
|
|
533
|
+
)
|
|
534
|
+
logger.debug(f"[Misskey API] 发帖成功: {note_id}")
|
|
349
535
|
return result
|
|
350
536
|
|
|
537
|
+
async def upload_file(
|
|
538
|
+
self,
|
|
539
|
+
file_path: str,
|
|
540
|
+
name: Optional[str] = None,
|
|
541
|
+
folder_id: Optional[str] = None,
|
|
542
|
+
) -> Dict[str, Any]:
|
|
543
|
+
"""Upload a file to Misskey drive/files/create and return a dict containing id and raw result."""
|
|
544
|
+
if not file_path:
|
|
545
|
+
raise APIError("No file path provided for upload")
|
|
546
|
+
|
|
547
|
+
url = f"{self.instance_url}/api/drive/files/create"
|
|
548
|
+
form = aiohttp.FormData()
|
|
549
|
+
form.add_field("i", self.access_token)
|
|
550
|
+
|
|
551
|
+
try:
|
|
552
|
+
filename = name or file_path.split("/")[-1]
|
|
553
|
+
if folder_id:
|
|
554
|
+
form.add_field("folderId", str(folder_id))
|
|
555
|
+
|
|
556
|
+
try:
|
|
557
|
+
f = open(file_path, "rb")
|
|
558
|
+
except FileNotFoundError as e:
|
|
559
|
+
logger.error(f"[Misskey API] 本地文件不存在: {file_path}")
|
|
560
|
+
raise APIError(f"File not found: {file_path}") from e
|
|
561
|
+
|
|
562
|
+
try:
|
|
563
|
+
form.add_field("file", f, filename=filename)
|
|
564
|
+
async with self.session.post(url, data=form) as resp:
|
|
565
|
+
result = await self._process_response(resp, "drive/files/create")
|
|
566
|
+
file_id = FileIDExtractor.extract_file_id(result)
|
|
567
|
+
logger.debug(
|
|
568
|
+
f"[Misskey API] 本地文件上传成功: {filename} -> {file_id}"
|
|
569
|
+
)
|
|
570
|
+
return {"id": file_id, "raw": result}
|
|
571
|
+
finally:
|
|
572
|
+
f.close()
|
|
573
|
+
except aiohttp.ClientError as e:
|
|
574
|
+
logger.error(f"[Misskey API] 文件上传网络错误: {e}")
|
|
575
|
+
raise APIConnectionError(f"Upload failed: {e}") from e
|
|
576
|
+
|
|
577
|
+
async def find_files_by_hash(self, md5_hash: str) -> List[Dict[str, Any]]:
|
|
578
|
+
"""Find files by MD5 hash"""
|
|
579
|
+
if not md5_hash:
|
|
580
|
+
raise APIError("No MD5 hash provided for find-by-hash")
|
|
581
|
+
|
|
582
|
+
data = {"md5": md5_hash}
|
|
583
|
+
|
|
584
|
+
try:
|
|
585
|
+
logger.debug(f"[Misskey API] find-by-hash 请求: md5={md5_hash}")
|
|
586
|
+
result = await self._make_request("drive/files/find-by-hash", data)
|
|
587
|
+
logger.debug(
|
|
588
|
+
f"[Misskey API] find-by-hash 响应: 找到 {len(result) if isinstance(result, list) else 0} 个文件"
|
|
589
|
+
)
|
|
590
|
+
return result if isinstance(result, list) else []
|
|
591
|
+
except Exception as e:
|
|
592
|
+
logger.error(f"[Misskey API] 根据哈希查找文件失败: {e}")
|
|
593
|
+
raise
|
|
594
|
+
|
|
595
|
+
async def find_files_by_name(
|
|
596
|
+
self, name: str, folder_id: Optional[str] = None
|
|
597
|
+
) -> List[Dict[str, Any]]:
|
|
598
|
+
"""Find files by name"""
|
|
599
|
+
if not name:
|
|
600
|
+
raise APIError("No name provided for find")
|
|
601
|
+
|
|
602
|
+
data: Dict[str, Any] = {"name": name}
|
|
603
|
+
if folder_id:
|
|
604
|
+
data["folderId"] = folder_id
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
logger.debug(f"[Misskey API] find 请求: name={name}, folder_id={folder_id}")
|
|
608
|
+
result = await self._make_request("drive/files/find", data)
|
|
609
|
+
logger.debug(
|
|
610
|
+
f"[Misskey API] find 响应: 找到 {len(result) if isinstance(result, list) else 0} 个文件"
|
|
611
|
+
)
|
|
612
|
+
return result if isinstance(result, list) else []
|
|
613
|
+
except Exception as e:
|
|
614
|
+
logger.error(f"[Misskey API] 根据名称查找文件失败: {e}")
|
|
615
|
+
raise
|
|
616
|
+
|
|
617
|
+
async def find_files(
|
|
618
|
+
self,
|
|
619
|
+
limit: int = 10,
|
|
620
|
+
folder_id: Optional[str] = None,
|
|
621
|
+
type: Optional[str] = None,
|
|
622
|
+
) -> List[Dict[str, Any]]:
|
|
623
|
+
"""List files with optional filters"""
|
|
624
|
+
data: Dict[str, Any] = {"limit": limit}
|
|
625
|
+
if folder_id is not None:
|
|
626
|
+
data["folderId"] = folder_id
|
|
627
|
+
if type is not None:
|
|
628
|
+
data["type"] = type
|
|
629
|
+
|
|
630
|
+
try:
|
|
631
|
+
logger.debug(
|
|
632
|
+
f"[Misskey API] 列表文件请求: limit={limit}, folder_id={folder_id}, type={type}"
|
|
633
|
+
)
|
|
634
|
+
result = await self._make_request("drive/files", data)
|
|
635
|
+
logger.debug(
|
|
636
|
+
f"[Misskey API] 列表文件响应: 找到 {len(result) if isinstance(result, list) else 0} 个文件"
|
|
637
|
+
)
|
|
638
|
+
return result if isinstance(result, list) else []
|
|
639
|
+
except Exception as e:
|
|
640
|
+
logger.error(f"[Misskey API] 列表文件失败: {e}")
|
|
641
|
+
raise
|
|
642
|
+
|
|
643
|
+
async def _download_with_existing_session(
|
|
644
|
+
self, url: str, ssl_verify: bool = True
|
|
645
|
+
) -> Optional[bytes]:
|
|
646
|
+
"""使用现有会话下载文件"""
|
|
647
|
+
if not (hasattr(self, "session") and self.session):
|
|
648
|
+
raise APIConnectionError("No existing session available")
|
|
649
|
+
|
|
650
|
+
async with self.session.get(
|
|
651
|
+
url, timeout=aiohttp.ClientTimeout(total=15), ssl=ssl_verify
|
|
652
|
+
) as response:
|
|
653
|
+
if response.status == 200:
|
|
654
|
+
return await response.read()
|
|
655
|
+
return None
|
|
656
|
+
|
|
657
|
+
async def _download_with_temp_session(
|
|
658
|
+
self, url: str, ssl_verify: bool = True
|
|
659
|
+
) -> Optional[bytes]:
|
|
660
|
+
"""使用临时会话下载文件"""
|
|
661
|
+
connector = aiohttp.TCPConnector(ssl=ssl_verify)
|
|
662
|
+
async with aiohttp.ClientSession(connector=connector) as temp_session:
|
|
663
|
+
async with temp_session.get(
|
|
664
|
+
url, timeout=aiohttp.ClientTimeout(total=15)
|
|
665
|
+
) as response:
|
|
666
|
+
if response.status == 200:
|
|
667
|
+
return await response.read()
|
|
668
|
+
return None
|
|
669
|
+
|
|
670
|
+
async def upload_and_find_file(
|
|
671
|
+
self,
|
|
672
|
+
url: str,
|
|
673
|
+
name: Optional[str] = None,
|
|
674
|
+
folder_id: Optional[str] = None,
|
|
675
|
+
max_wait_time: float = 30.0,
|
|
676
|
+
check_interval: float = 2.0,
|
|
677
|
+
) -> Optional[Dict[str, Any]]:
|
|
678
|
+
"""
|
|
679
|
+
简化的文件上传:尝试 URL 上传,失败则下载后本地上传
|
|
680
|
+
|
|
681
|
+
Args:
|
|
682
|
+
url: 文件URL
|
|
683
|
+
name: 文件名(可选)
|
|
684
|
+
folder_id: 文件夹ID(可选)
|
|
685
|
+
max_wait_time: 保留参数(未使用)
|
|
686
|
+
check_interval: 保留参数(未使用)
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
包含文件ID和元信息的字典,失败时返回None
|
|
690
|
+
"""
|
|
691
|
+
if not url:
|
|
692
|
+
raise APIError("URL不能为空")
|
|
693
|
+
|
|
694
|
+
# 通过本地上传获取即时文件 ID(下载文件 → 上传 → 返回 ID)
|
|
695
|
+
try:
|
|
696
|
+
import tempfile
|
|
697
|
+
import os
|
|
698
|
+
|
|
699
|
+
# SSL 验证下载,失败则重试不验证 SSL
|
|
700
|
+
tmp_bytes = None
|
|
701
|
+
try:
|
|
702
|
+
tmp_bytes = await self._download_with_existing_session(
|
|
703
|
+
url, ssl_verify=True
|
|
704
|
+
) or await self._download_with_temp_session(url, ssl_verify=True)
|
|
705
|
+
except Exception as ssl_error:
|
|
706
|
+
logger.debug(
|
|
707
|
+
f"[Misskey API] SSL 验证下载失败: {ssl_error},重试不验证 SSL"
|
|
708
|
+
)
|
|
709
|
+
try:
|
|
710
|
+
tmp_bytes = await self._download_with_existing_session(
|
|
711
|
+
url, ssl_verify=False
|
|
712
|
+
) or await self._download_with_temp_session(url, ssl_verify=False)
|
|
713
|
+
except Exception:
|
|
714
|
+
pass
|
|
715
|
+
|
|
716
|
+
if tmp_bytes:
|
|
717
|
+
with tempfile.NamedTemporaryFile(delete=False) as tmpf:
|
|
718
|
+
tmpf.write(tmp_bytes)
|
|
719
|
+
tmp_path = tmpf.name
|
|
720
|
+
|
|
721
|
+
try:
|
|
722
|
+
result = await self.upload_file(tmp_path, name, folder_id)
|
|
723
|
+
logger.debug(f"[Misskey API] 本地上传成功: {result.get('id')}")
|
|
724
|
+
return result
|
|
725
|
+
finally:
|
|
726
|
+
try:
|
|
727
|
+
os.unlink(tmp_path)
|
|
728
|
+
except Exception:
|
|
729
|
+
pass
|
|
730
|
+
except Exception as e:
|
|
731
|
+
logger.error(f"[Misskey API] 本地上传失败: {e}")
|
|
732
|
+
|
|
733
|
+
return None
|
|
734
|
+
|
|
351
735
|
async def get_current_user(self) -> Dict[str, Any]:
|
|
352
736
|
"""获取当前用户信息"""
|
|
353
737
|
return await self._make_request("i", {})
|
|
354
738
|
|
|
355
|
-
async def send_message(
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
739
|
+
async def send_message(
|
|
740
|
+
self, user_id_or_payload: Any, text: Optional[str] = None
|
|
741
|
+
) -> Dict[str, Any]:
|
|
742
|
+
"""发送聊天消息。
|
|
743
|
+
|
|
744
|
+
Accepts either (user_id: str, text: str) or a single dict payload prepared by caller.
|
|
745
|
+
"""
|
|
746
|
+
if isinstance(user_id_or_payload, dict):
|
|
747
|
+
data = user_id_or_payload
|
|
748
|
+
else:
|
|
749
|
+
data = {"toUserId": user_id_or_payload, "text": text}
|
|
750
|
+
|
|
751
|
+
result = await self._make_request("chat/messages/create-to-user", data)
|
|
360
752
|
message_id = result.get("id", "unknown")
|
|
361
|
-
logger.debug(f"
|
|
753
|
+
logger.debug(f"[Misskey API] 聊天消息发送成功: {message_id}")
|
|
362
754
|
return result
|
|
363
755
|
|
|
364
|
-
async def send_room_message(
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
756
|
+
async def send_room_message(
|
|
757
|
+
self, room_id_or_payload: Any, text: Optional[str] = None
|
|
758
|
+
) -> Dict[str, Any]:
|
|
759
|
+
"""发送房间消息。
|
|
760
|
+
|
|
761
|
+
Accepts either (room_id: str, text: str) or a single dict payload.
|
|
762
|
+
"""
|
|
763
|
+
if isinstance(room_id_or_payload, dict):
|
|
764
|
+
data = room_id_or_payload
|
|
765
|
+
else:
|
|
766
|
+
data = {"toRoomId": room_id_or_payload, "text": text}
|
|
767
|
+
|
|
768
|
+
result = await self._make_request("chat/messages/create-to-room", data)
|
|
369
769
|
message_id = result.get("id", "unknown")
|
|
370
|
-
logger.debug(f"
|
|
770
|
+
logger.debug(f"[Misskey API] 房间消息发送成功: {message_id}")
|
|
371
771
|
return result
|
|
372
772
|
|
|
373
773
|
async def get_messages(
|
|
@@ -381,9 +781,8 @@ class MisskeyAPI:
|
|
|
381
781
|
result = await self._make_request("chat/messages/user-timeline", data)
|
|
382
782
|
if isinstance(result, list):
|
|
383
783
|
return result
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
return []
|
|
784
|
+
logger.warning(f"[Misskey API] 聊天消息响应格式异常: {type(result)}")
|
|
785
|
+
return []
|
|
387
786
|
|
|
388
787
|
async def get_mentions(
|
|
389
788
|
self, limit: int = 10, since_id: Optional[str] = None
|
|
@@ -400,5 +799,142 @@ class MisskeyAPI:
|
|
|
400
799
|
elif isinstance(result, dict) and "notifications" in result:
|
|
401
800
|
return result["notifications"]
|
|
402
801
|
else:
|
|
403
|
-
logger.warning(f"
|
|
802
|
+
logger.warning(f"[Misskey API] 提及通知响应格式异常: {type(result)}")
|
|
404
803
|
return []
|
|
804
|
+
|
|
805
|
+
async def send_message_with_media(
|
|
806
|
+
self,
|
|
807
|
+
message_type: str,
|
|
808
|
+
target_id: str,
|
|
809
|
+
text: Optional[str] = None,
|
|
810
|
+
media_urls: Optional[List[str]] = None,
|
|
811
|
+
local_files: Optional[List[str]] = None,
|
|
812
|
+
**kwargs,
|
|
813
|
+
) -> Dict[str, Any]:
|
|
814
|
+
"""
|
|
815
|
+
通用消息发送函数:统一处理文本+媒体发送
|
|
816
|
+
|
|
817
|
+
Args:
|
|
818
|
+
message_type: 消息类型 ('chat', 'room', 'note')
|
|
819
|
+
target_id: 目标ID (用户ID/房间ID/频道ID等)
|
|
820
|
+
text: 文本内容
|
|
821
|
+
media_urls: 媒体文件URL列表
|
|
822
|
+
local_files: 本地文件路径列表
|
|
823
|
+
**kwargs: 其他参数(如visibility等)
|
|
824
|
+
|
|
825
|
+
Returns:
|
|
826
|
+
发送结果字典
|
|
827
|
+
|
|
828
|
+
Raises:
|
|
829
|
+
APIError: 参数错误或发送失败
|
|
830
|
+
"""
|
|
831
|
+
if not text and not media_urls and not local_files:
|
|
832
|
+
raise APIError("消息内容不能为空:需要文本或媒体文件")
|
|
833
|
+
|
|
834
|
+
file_ids = []
|
|
835
|
+
|
|
836
|
+
# 处理远程媒体文件
|
|
837
|
+
if media_urls:
|
|
838
|
+
file_ids.extend(await self._process_media_urls(media_urls))
|
|
839
|
+
|
|
840
|
+
# 处理本地文件
|
|
841
|
+
if local_files:
|
|
842
|
+
file_ids.extend(await self._process_local_files(local_files))
|
|
843
|
+
|
|
844
|
+
# 根据消息类型发送
|
|
845
|
+
return await self._dispatch_message(
|
|
846
|
+
message_type, target_id, text, file_ids, **kwargs
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
async def _process_media_urls(self, urls: List[str]) -> List[str]:
|
|
850
|
+
"""处理远程媒体文件URL列表,返回文件ID列表"""
|
|
851
|
+
file_ids = []
|
|
852
|
+
for url in urls:
|
|
853
|
+
try:
|
|
854
|
+
result = await self.upload_and_find_file(url)
|
|
855
|
+
if result and result.get("id"):
|
|
856
|
+
file_ids.append(result["id"])
|
|
857
|
+
logger.debug(f"[Misskey API] URL媒体上传成功: {result['id']}")
|
|
858
|
+
else:
|
|
859
|
+
logger.error(f"[Misskey API] URL媒体上传失败: {url}")
|
|
860
|
+
except Exception as e:
|
|
861
|
+
logger.error(f"[Misskey API] URL媒体处理失败 {url}: {e}")
|
|
862
|
+
# 继续处理其他文件,不中断整个流程
|
|
863
|
+
continue
|
|
864
|
+
return file_ids
|
|
865
|
+
|
|
866
|
+
async def _process_local_files(self, file_paths: List[str]) -> List[str]:
|
|
867
|
+
"""处理本地文件路径列表,返回文件ID列表"""
|
|
868
|
+
file_ids = []
|
|
869
|
+
for file_path in file_paths:
|
|
870
|
+
try:
|
|
871
|
+
result = await self.upload_file(file_path)
|
|
872
|
+
if result and result.get("id"):
|
|
873
|
+
file_ids.append(result["id"])
|
|
874
|
+
logger.debug(f"[Misskey API] 本地文件上传成功: {result['id']}")
|
|
875
|
+
else:
|
|
876
|
+
logger.error(f"[Misskey API] 本地文件上传失败: {file_path}")
|
|
877
|
+
except Exception as e:
|
|
878
|
+
logger.error(f"[Misskey API] 本地文件处理失败 {file_path}: {e}")
|
|
879
|
+
continue
|
|
880
|
+
return file_ids
|
|
881
|
+
|
|
882
|
+
async def _dispatch_message(
|
|
883
|
+
self,
|
|
884
|
+
message_type: str,
|
|
885
|
+
target_id: str,
|
|
886
|
+
text: Optional[str],
|
|
887
|
+
file_ids: List[str],
|
|
888
|
+
**kwargs,
|
|
889
|
+
) -> Dict[str, Any]:
|
|
890
|
+
"""根据消息类型分发到对应的发送方法"""
|
|
891
|
+
if message_type == "chat":
|
|
892
|
+
# 聊天消息使用 fileId (单数)
|
|
893
|
+
payload = {"toUserId": target_id}
|
|
894
|
+
if text:
|
|
895
|
+
payload["text"] = text
|
|
896
|
+
if file_ids:
|
|
897
|
+
if len(file_ids) == 1:
|
|
898
|
+
payload["fileId"] = file_ids[0]
|
|
899
|
+
else:
|
|
900
|
+
# 多文件时逐个发送
|
|
901
|
+
results = []
|
|
902
|
+
for file_id in file_ids:
|
|
903
|
+
single_payload = payload.copy()
|
|
904
|
+
single_payload["fileId"] = file_id
|
|
905
|
+
result = await self.send_message(single_payload)
|
|
906
|
+
results.append(result)
|
|
907
|
+
return {"multiple": True, "results": results}
|
|
908
|
+
return await self.send_message(payload)
|
|
909
|
+
|
|
910
|
+
elif message_type == "room":
|
|
911
|
+
# 房间消息使用 fileId (单数)
|
|
912
|
+
payload = {"toRoomId": target_id}
|
|
913
|
+
if text:
|
|
914
|
+
payload["text"] = text
|
|
915
|
+
if file_ids:
|
|
916
|
+
if len(file_ids) == 1:
|
|
917
|
+
payload["fileId"] = file_ids[0]
|
|
918
|
+
else:
|
|
919
|
+
# 多文件时逐个发送
|
|
920
|
+
results = []
|
|
921
|
+
for file_id in file_ids:
|
|
922
|
+
single_payload = payload.copy()
|
|
923
|
+
single_payload["fileId"] = file_id
|
|
924
|
+
result = await self.send_room_message(single_payload)
|
|
925
|
+
results.append(result)
|
|
926
|
+
return {"multiple": True, "results": results}
|
|
927
|
+
return await self.send_room_message(payload)
|
|
928
|
+
|
|
929
|
+
elif message_type == "note":
|
|
930
|
+
# 发帖使用 fileIds (复数)
|
|
931
|
+
note_kwargs = {
|
|
932
|
+
"text": text,
|
|
933
|
+
"file_ids": file_ids or None,
|
|
934
|
+
}
|
|
935
|
+
# 合并其他参数
|
|
936
|
+
note_kwargs.update(kwargs)
|
|
937
|
+
return await self.create_note(**note_kwargs)
|
|
938
|
+
|
|
939
|
+
else:
|
|
940
|
+
raise APIError(f"不支持的消息类型: {message_type}")
|