AstrBot 4.3.5__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.
Files changed (68) hide show
  1. astrbot/core/agent/runners/tool_loop_agent_runner.py +31 -2
  2. astrbot/core/astrbot_config_mgr.py +23 -51
  3. astrbot/core/config/default.py +92 -12
  4. astrbot/core/conversation_mgr.py +36 -1
  5. astrbot/core/core_lifecycle.py +24 -5
  6. astrbot/core/db/migration/migra_45_to_46.py +44 -0
  7. astrbot/core/db/vec_db/base.py +33 -2
  8. astrbot/core/db/vec_db/faiss_impl/document_storage.py +310 -52
  9. astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +31 -3
  10. astrbot/core/db/vec_db/faiss_impl/vec_db.py +81 -23
  11. astrbot/core/file_token_service.py +6 -1
  12. astrbot/core/initial_loader.py +6 -3
  13. astrbot/core/knowledge_base/chunking/__init__.py +11 -0
  14. astrbot/core/knowledge_base/chunking/base.py +24 -0
  15. astrbot/core/knowledge_base/chunking/fixed_size.py +57 -0
  16. astrbot/core/knowledge_base/chunking/recursive.py +155 -0
  17. astrbot/core/knowledge_base/kb_db_sqlite.py +299 -0
  18. astrbot/core/knowledge_base/kb_helper.py +348 -0
  19. astrbot/core/knowledge_base/kb_mgr.py +287 -0
  20. astrbot/core/knowledge_base/models.py +114 -0
  21. astrbot/core/knowledge_base/parsers/__init__.py +15 -0
  22. astrbot/core/knowledge_base/parsers/base.py +50 -0
  23. astrbot/core/knowledge_base/parsers/markitdown_parser.py +25 -0
  24. astrbot/core/knowledge_base/parsers/pdf_parser.py +100 -0
  25. astrbot/core/knowledge_base/parsers/text_parser.py +41 -0
  26. astrbot/core/knowledge_base/parsers/util.py +13 -0
  27. astrbot/core/knowledge_base/retrieval/__init__.py +16 -0
  28. astrbot/core/knowledge_base/retrieval/hit_stopwords.txt +767 -0
  29. astrbot/core/knowledge_base/retrieval/manager.py +273 -0
  30. astrbot/core/knowledge_base/retrieval/rank_fusion.py +138 -0
  31. astrbot/core/knowledge_base/retrieval/sparse_retriever.py +130 -0
  32. astrbot/core/pipeline/process_stage/method/llm_request.py +29 -7
  33. astrbot/core/pipeline/process_stage/utils.py +80 -0
  34. astrbot/core/platform/astr_message_event.py +8 -7
  35. astrbot/core/platform/sources/misskey/misskey_adapter.py +380 -44
  36. astrbot/core/platform/sources/misskey/misskey_api.py +581 -45
  37. astrbot/core/platform/sources/misskey/misskey_event.py +76 -41
  38. astrbot/core/platform/sources/misskey/misskey_utils.py +254 -43
  39. astrbot/core/platform/sources/qqofficial_webhook/qo_webhook_server.py +2 -1
  40. astrbot/core/platform/sources/satori/satori_adapter.py +27 -1
  41. astrbot/core/platform/sources/satori/satori_event.py +270 -99
  42. astrbot/core/provider/manager.py +14 -9
  43. astrbot/core/provider/provider.py +67 -0
  44. astrbot/core/provider/sources/anthropic_source.py +4 -4
  45. astrbot/core/provider/sources/dashscope_source.py +10 -9
  46. astrbot/core/provider/sources/dify_source.py +6 -8
  47. astrbot/core/provider/sources/gemini_embedding_source.py +1 -2
  48. astrbot/core/provider/sources/openai_embedding_source.py +1 -2
  49. astrbot/core/provider/sources/openai_source.py +18 -15
  50. astrbot/core/provider/sources/openai_tts_api_source.py +1 -1
  51. astrbot/core/star/context.py +3 -0
  52. astrbot/core/star/star.py +6 -0
  53. astrbot/core/star/star_manager.py +13 -7
  54. astrbot/core/umop_config_router.py +81 -0
  55. astrbot/core/updator.py +1 -1
  56. astrbot/core/utils/io.py +23 -12
  57. astrbot/dashboard/routes/__init__.py +2 -0
  58. astrbot/dashboard/routes/config.py +137 -9
  59. astrbot/dashboard/routes/knowledge_base.py +1065 -0
  60. astrbot/dashboard/routes/plugin.py +24 -5
  61. astrbot/dashboard/routes/update.py +1 -1
  62. astrbot/dashboard/server.py +6 -0
  63. astrbot/dashboard/utils.py +161 -0
  64. {astrbot-4.3.5.dist-info → astrbot-4.5.0.dist-info}/METADATA +29 -13
  65. {astrbot-4.3.5.dist-info → astrbot-4.5.0.dist-info}/RECORD +68 -44
  66. {astrbot-4.3.5.dist-info → astrbot-4.5.0.dist-info}/WHEEL +0 -0
  67. {astrbot-4.3.5.dist-info → astrbot-4.5.0.dist-info}/entry_points.txt +0 -0
  68. {astrbot-4.3.5.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
- del self.channels[channel_id]
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
- logger.debug(
161
- f"[Misskey WebSocket] 收到消息类型: {message_type}\n数据: {json.dumps(data, indent=2, ensure_ascii=False)}"
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(max_retries: int = 3, retryable_exceptions: tuple = ()):
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
- for _ in range(max_retries):
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__(self, instance_url: str, access_token: str):
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 请求错误: {endpoint} (状态码: {status})")
382
+ logger.error(f"[Misskey API] 请求参数错误: {endpoint} (HTTP {status})")
262
383
  raise APIError(f"Bad request for {endpoint}")
263
- elif status in (401, 403):
264
- logger.error(f"API 认证失败: {endpoint} (状态码: {status})")
265
- raise AuthenticationError(f"Authentication failed for {endpoint}")
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 频率限制: {endpoint} (状态码: {status})")
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 请求失败: {endpoint} (状态码: {status})")
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(f"获取到 {len(notifications_data)} 条新通知")
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"响应不是有效的 JSON 格式: {e}")
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} - 状态码: {response.status}, 响应: {error_text}"
444
+ f"[Misskey API] 请求失败: {endpoint} - HTTP {response.status}, 响应: {error_text}"
301
445
  )
302
446
  except Exception:
303
- logger.error(f"API 请求失败: {endpoint} - 状态码: {response.status}")
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
- "text": text,
338
- "visibility": visibility,
339
- "localOnly": local_only,
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 = result.get("createdNote", {}).get("id", "unknown")
348
- logger.debug(f"发帖成功,note_id: {note_id}")
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(self, user_id: str, text: str) -> Dict[str, Any]:
356
- """发送聊天消息"""
357
- result = await self._make_request(
358
- "chat/messages/create-to-user", {"toUserId": user_id, "text": text}
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"聊天发送成功,message_id: {message_id}")
753
+ logger.debug(f"[Misskey API] 聊天消息发送成功: {message_id}")
362
754
  return result
363
755
 
364
- async def send_room_message(self, room_id: str, text: str) -> Dict[str, Any]:
365
- """发送房间消息"""
366
- result = await self._make_request(
367
- "chat/messages/create-to-room", {"toRoomId": room_id, "text": text}
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"房间消息发送成功,message_id: {message_id}")
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
- else:
385
- logger.warning(f"获取聊天消息响应格式异常: {type(result)}")
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"获取提及通知响应格式异常: {type(result)}")
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}")