astrbotmcp 0.2.8__tar.gz → 0.3.0__tar.gz

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 (28) hide show
  1. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/PKG-INFO +1 -1
  2. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/astrbot_client.py +56 -0
  3. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/tools/message_tools.py +378 -28
  4. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/tools/types.py +14 -2
  5. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbotmcp.egg-info/PKG-INFO +1 -1
  6. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/pyproject.toml +1 -1
  7. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/LICENSE.txt +0 -0
  8. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/README.md +0 -0
  9. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/__init__.py +0 -0
  10. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/config.py +0 -0
  11. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/server.py +0 -0
  12. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/tools/__init__.py +0 -0
  13. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/tools/config_search_tool.py +0 -0
  14. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/tools/config_tools.py +0 -0
  15. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/tools/control_tools.py +0 -0
  16. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/tools/helpers.py +0 -0
  17. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/tools/log_tools.py +0 -0
  18. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/tools/platform_tools.py +0 -0
  19. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/tools/plugin_market_tools.py +0 -0
  20. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/tools/session_tools.py +0 -0
  21. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbot_mcp/tools.py +0 -0
  22. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbotmcp.egg-info/SOURCES.txt +0 -0
  23. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbotmcp.egg-info/dependency_links.txt +0 -0
  24. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbotmcp.egg-info/entry_points.txt +0 -0
  25. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbotmcp.egg-info/requires.txt +0 -0
  26. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/astrbotmcp.egg-info/top_level.txt +0 -0
  27. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/setup.cfg +0 -0
  28. {astrbotmcp-0.2.8 → astrbotmcp-0.3.0}/tests/test_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: astrbotmcp
3
- Version: 0.2.8
3
+ Version: 0.3.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -293,6 +293,29 @@ class AstrBotClient:
293
293
  response = await self._request("GET", "/api/config/platform/list")
294
294
  return response.json()
295
295
 
296
+ async def get_umo_abconf_routes(self) -> Dict[str, Any]:
297
+ """Call /api/config/umo_abconf_routes and return the parsed JSON."""
298
+ response = await self._request("GET", "/api/config/umo_abconf_routes")
299
+ return response.json()
300
+
301
+ async def update_umo_abconf_route(
302
+ self,
303
+ *,
304
+ umo: str,
305
+ conf_id: str,
306
+ ) -> Dict[str, Any]:
307
+ """Update UMOP config routing via POST /api/config/umo_abconf_route/update."""
308
+ payload: Dict[str, Any] = {
309
+ "umo": umo,
310
+ "conf_id": conf_id,
311
+ }
312
+ response = await self._request(
313
+ "POST",
314
+ "/api/config/umo_abconf_route/update",
315
+ json_body=payload,
316
+ )
317
+ return response.json()
318
+
296
319
  async def get_abconf_list(self) -> Dict[str, Any]:
297
320
  """Call /api/config/abconfs and return the parsed JSON."""
298
321
  response = await self._request("GET", "/api/config/abconfs")
@@ -335,6 +358,39 @@ class AstrBotClient:
335
358
  response = await self._request("POST", "/api/config/astrbot/update", json_body=payload)
336
359
  return response.json()
337
360
 
361
+ async def list_session_rules(
362
+ self,
363
+ *,
364
+ page: int = 1,
365
+ page_size: int = 100,
366
+ search: str | None = None,
367
+ ) -> Dict[str, Any]:
368
+ """Call /api/session/list-rule (dashboard API) and return the parsed JSON."""
369
+ params: Dict[str, Any] = {
370
+ "page": page,
371
+ "page_size": page_size,
372
+ }
373
+ if search:
374
+ params["search"] = search
375
+ response = await self._request("GET", "/api/session/list-rule", params=params)
376
+ return response.json()
377
+
378
+ async def update_session_rule(
379
+ self,
380
+ *,
381
+ umo: str,
382
+ rule_key: str,
383
+ rule_value: Any,
384
+ ) -> Dict[str, Any]:
385
+ """Call POST /api/session/update-rule to persist a UMO rule."""
386
+ payload: Dict[str, Any] = {
387
+ "umo": umo,
388
+ "rule_key": rule_key,
389
+ "rule_value": rule_value,
390
+ }
391
+ response = await self._request("POST", "/api/session/update-rule", json_body=payload)
392
+ return response.json()
393
+
338
394
  # ---- Plugin / market APIs ----------------------------------------
339
395
 
340
396
  async def get_plugin_market_list(
@@ -2,7 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import os
5
- from typing import Any, Dict, List, Literal, Optional, TypedDict
5
+ import textwrap
6
+ from typing import Any, Dict, List, Literal, Optional, Tuple, TypedDict
6
7
 
7
8
  from ..astrbot_client import AstrBotClient
8
9
  from .helpers import (
@@ -16,6 +17,124 @@ from .helpers import (
16
17
  from .types import MessagePart
17
18
 
18
19
 
20
+ _SESSION_CACHE_LOCK = asyncio.Lock()
21
+ _SESSION_CACHE: Dict[Tuple[str, str, str], str] = {}
22
+
23
+ _LAST_SAVED_MESSAGE_ID_LOCK = asyncio.Lock()
24
+ _LAST_SAVED_MESSAGE_ID_BY_SESSION: Dict[Tuple[str, str, str], str] = {}
25
+
26
+
27
+ def _session_cache_key(client: AstrBotClient, platform_id: str) -> Tuple[str, str, str]:
28
+ return (client.base_url, client.settings.username or "", platform_id)
29
+
30
+
31
+ def _last_saved_key(client: AstrBotClient, session_id: str) -> Tuple[str, str, str]:
32
+ return (client.base_url, client.settings.username or "", session_id)
33
+
34
+
35
+ def _extract_plain_text_from_history_item(item: Dict[str, Any]) -> str:
36
+ content = item.get("content") or {}
37
+ if not isinstance(content, dict):
38
+ return str(content)
39
+ message = content.get("message") or []
40
+ if not isinstance(message, list):
41
+ return str(message)
42
+
43
+ chunks: List[str] = []
44
+ for part in message:
45
+ if not isinstance(part, dict):
46
+ continue
47
+ p_type = part.get("type")
48
+ if p_type == "plain":
49
+ txt = part.get("text")
50
+ if isinstance(txt, str) and txt:
51
+ chunks.append(txt)
52
+ elif p_type in ("image", "file", "record", "video"):
53
+ name = part.get("filename") or part.get("attachment_id") or ""
54
+ if name:
55
+ chunks.append(f"[{p_type}:{name}]")
56
+ else:
57
+ chunks.append(f"[{p_type}]")
58
+ else:
59
+ if p_type:
60
+ chunks.append(f"[{p_type}]")
61
+ return "".join(chunks).strip()
62
+
63
+
64
+ def _format_quote_block(*, message_id: str, sender: str, text: str) -> str:
65
+ sender = (sender or "unknown").strip() or "unknown"
66
+ text = (text or "").strip()
67
+ if not text:
68
+ text = "<empty>"
69
+ text = textwrap.shorten(text, width=800, placeholder="…")
70
+ return f"[引用消息 {message_id} | {sender}] {text}\n"
71
+
72
+
73
+ async def _resolve_webchat_quotes(
74
+ client: AstrBotClient, *, session_id: str, reply_ids: List[str]
75
+ ) -> Tuple[str, Dict[str, Any]]:
76
+ """
77
+ Resolve WebChat `message_saved.id` -> quoted text by calling /api/chat/get_session.
78
+ Best-effort: returns a quote prefix text and debug info.
79
+ """
80
+ cleaned: List[str] = []
81
+ for rid in reply_ids:
82
+ s = str(rid).strip()
83
+ if s:
84
+ cleaned.append(s)
85
+ if not cleaned:
86
+ return "", {"resolved": {}, "missing": []}
87
+
88
+ try:
89
+ resp = await client.get_platform_session(session_id=session_id)
90
+ except Exception as e:
91
+ return "", {"error": str(e), "resolved": {}, "missing": cleaned}
92
+
93
+ if resp.get("status") != "ok":
94
+ return "", {"status": resp.get("status"), "message": resp.get("message"), "raw": resp}
95
+
96
+ data = resp.get("data") or {}
97
+ history = data.get("history") or []
98
+ if not isinstance(history, list):
99
+ return "", {"resolved": {}, "missing": cleaned, "raw_history_type": str(type(history))}
100
+
101
+ index: Dict[str, Dict[str, Any]] = {}
102
+ for item in history:
103
+ if not isinstance(item, dict):
104
+ continue
105
+ mid = item.get("id")
106
+ if mid is None:
107
+ continue
108
+ index[str(mid)] = item
109
+
110
+ resolved: Dict[str, str] = {}
111
+ missing: List[str] = []
112
+ blocks: List[str] = []
113
+ for rid in cleaned:
114
+ item = index.get(str(rid))
115
+ if not item:
116
+ missing.append(rid)
117
+ blocks.append(
118
+ _format_quote_block(
119
+ message_id=str(rid),
120
+ sender="missing",
121
+ text="<not found in /api/chat/get_session history>",
122
+ )
123
+ )
124
+ continue
125
+ sender = (
126
+ item.get("sender_name")
127
+ or item.get("sender_id")
128
+ or "unknown"
129
+ )
130
+ txt = _extract_plain_text_from_history_item(item)
131
+ block = _format_quote_block(message_id=str(rid), sender=str(sender), text=txt)
132
+ resolved[str(rid)] = block
133
+ blocks.append(block)
134
+
135
+ return "".join(blocks), {"resolved": resolved, "missing": missing}
136
+
137
+
19
138
  async def send_platform_message_direct(
20
139
  platform_id: str,
21
140
  target_id: str,
@@ -28,6 +147,11 @@ async def send_platform_message_direct(
28
147
  message_type: Literal["GroupMessage", "FriendMessage"] = "GroupMessage",
29
148
  ) -> Dict[str, Any]:
30
149
  """
150
+ NOTE:
151
+ - If you provide `target_id`, this tool will call AstrBot dashboard API `/api/platform/send_message`
152
+ (bypass LLM) to send to a real group/user.
153
+ - If you do NOT provide `target_id`, this tool uses AstrBot WebChat `/api/chat/send` (LLM required),
154
+ and will always create/reuse a `webchat` session even if you pass another platform_id.
31
155
  Directly send a message chain to a platform group/user (bypass LLM).
32
156
 
33
157
  This calls AstrBot dashboard endpoint: POST /api/platform/send_message
@@ -201,15 +325,26 @@ async def send_platform_message_direct(
201
325
  message_chain=normalized_chain,
202
326
  )
203
327
  except Exception as e:
328
+ status_code = getattr(getattr(e, "response", None), "status_code", None)
329
+ hint = "Ensure AstrBot includes /api/platform/send_message and you are authenticated."
330
+ if status_code in (404, 405):
331
+ hint = (
332
+ "Your AstrBot may not expose /api/platform/send_message (some versions only provide "
333
+ "/api/platform/stats and /api/platform/webhook). Upgrade AstrBot or add an HTTP route for sending."
334
+ )
204
335
  return {
205
336
  "status": "error",
206
- "message": f"AstrBot API error: {e.response.status_code if hasattr(e, 'response') else 'Unknown'}",
337
+ "message": (
338
+ f"AstrBot API error: HTTP {status_code}"
339
+ if status_code is not None
340
+ else f"AstrBot API error: {e}"
341
+ ),
207
342
  "platform_id": platform_id,
208
343
  "session_id": str(target_id),
209
344
  "message_type": message_type,
210
345
  "attempt_mode": attempt_mode,
211
346
  "detail": _httpx_error_detail(e),
212
- "hint": "Ensure AstrBot includes /api/platform/send_message and you are authenticated.",
347
+ "hint": hint,
213
348
  }
214
349
 
215
350
  status = direct_resp.get("status")
@@ -251,7 +386,14 @@ async def send_platform_message(
251
386
  files: Optional[List[str]] = None,
252
387
  videos: Optional[List[str]] = None,
253
388
  records: Optional[List[str]] = None,
389
+ target_id: Optional[str] = None,
390
+ message_type: Literal["GroupMessage", "FriendMessage"] = "GroupMessage",
254
391
  session_id: Optional[str] = None,
392
+ conversation_id: Optional[str] = None,
393
+ use_last_session: bool = True,
394
+ new_session: bool = False,
395
+ reply_to_message_id: Optional[str] = None,
396
+ reply_to_last_saved_message: bool = False,
255
397
  selected_provider: Optional[str] = None,
256
398
  selected_model: Optional[str] = None,
257
399
  enable_streaming: bool = True,
@@ -272,6 +414,26 @@ async def send_platform_message(
272
414
  """
273
415
  client = AstrBotClient.from_env()
274
416
 
417
+ if target_id:
418
+ direct_result = await send_platform_message_direct(
419
+ platform_id=platform_id,
420
+ target_id=str(target_id),
421
+ message_chain=message_chain,
422
+ message=message,
423
+ images=images,
424
+ files=files,
425
+ videos=videos,
426
+ records=records,
427
+ message_type=message_type,
428
+ )
429
+ if isinstance(direct_result, dict):
430
+ direct_result.setdefault("mode", "direct")
431
+ return direct_result
432
+
433
+ mode = "webchat"
434
+ session_platform_id = "webchat"
435
+ routing_debug: Dict[str, Any] = {}
436
+
275
437
  if message_chain is None:
276
438
  message_chain = []
277
439
  if message:
@@ -286,15 +448,37 @@ async def send_platform_message(
286
448
  message_chain.append({"type": "video", "file_path": src})
287
449
 
288
450
  # 1. 确保有 session_id
289
- used_session_id = session_id
290
- if not used_session_id:
451
+ explicit_session_id = session_id or conversation_id
452
+ used_session_id: str | None = None
453
+ session_reused = False
454
+
455
+ if (
456
+ explicit_session_id
457
+ and isinstance(explicit_session_id, str)
458
+ and explicit_session_id.strip()
459
+ ):
460
+ used_session_id = explicit_session_id.strip()
461
+ async with _SESSION_CACHE_LOCK:
462
+ _SESSION_CACHE[_session_cache_key(client, session_platform_id)] = used_session_id
463
+ elif use_last_session and not new_session:
464
+ async with _SESSION_CACHE_LOCK:
465
+ cached = _SESSION_CACHE.get(_session_cache_key(client, session_platform_id))
466
+ if cached:
467
+ used_session_id = cached
468
+ session_reused = True
469
+
470
+ if new_session or not used_session_id:
291
471
  try:
292
- session_resp = await client.create_platform_session(platform_id=platform_id)
472
+ session_resp = await client.create_platform_session(
473
+ platform_id=session_platform_id
474
+ )
293
475
  except Exception as e:
294
476
  return {
295
477
  "status": "error",
296
478
  "message": f"AstrBot API error: {e.response.status_code if hasattr(e, 'response') else 'Unknown'}",
297
- "platform_id": platform_id,
479
+ "mode": mode,
480
+ "platform_id": session_platform_id,
481
+ "requested_platform_id": platform_id,
298
482
  "base_url": client.base_url,
299
483
  "detail": _httpx_error_detail(e),
300
484
  }
@@ -312,9 +496,137 @@ async def send_platform_message(
312
496
  "message": "Failed to create platform session: missing session_id",
313
497
  "raw": session_resp,
314
498
  }
499
+ used_session_id = str(used_session_id)
500
+ async with _SESSION_CACHE_LOCK:
501
+ _SESSION_CACHE[_session_cache_key(client, session_platform_id)] = used_session_id
502
+ session_reused = False
503
+
504
+ used_session_id = str(used_session_id)
505
+
506
+ if client.settings.username:
507
+ username = client.settings.username.strip() or "astrbot"
508
+ umo = f"webchat:FriendMessage:webchat!{username}!{used_session_id}"
509
+ routing_debug["umo"] = umo
510
+
511
+ # 1) Ensure UMO -> abconf route exists (the dashboard does this automatically).
512
+ try:
513
+ ucr_resp = await client.get_umo_abconf_routes()
514
+ routing_debug["ucr_get"] = ucr_resp if ucr_resp.get("status") != "ok" else None
515
+ if ucr_resp.get("status") == "ok":
516
+ routing = (ucr_resp.get("data") or {}).get("routing") or {}
517
+ if isinstance(routing, dict):
518
+ if umo in routing:
519
+ routing_debug["ucr_has_route"] = True
520
+ else:
521
+ routing_debug["ucr_has_route"] = False
522
+ prefix = f"webchat:FriendMessage:webchat!{username}!"
523
+ conf_id: str | None = None
524
+ for k, v in routing.items():
525
+ if isinstance(k, str) and k.startswith(prefix):
526
+ conf_id = str(v)
527
+ break
528
+
529
+ if not conf_id:
530
+ abconfs = await client.get_abconf_list()
531
+ info_list = (abconfs.get("data") or {}).get("info_list") or []
532
+ if isinstance(info_list, list):
533
+ # Prefer an active/current config if present.
534
+ for item in info_list:
535
+ if not isinstance(item, dict):
536
+ continue
537
+ if item.get("active") or item.get("current") or item.get("is_current"):
538
+ cid = item.get("id") or item.get("conf_id")
539
+ if cid:
540
+ conf_id = str(cid)
541
+ break
542
+ if not conf_id:
543
+ for item in info_list:
544
+ if not isinstance(item, dict):
545
+ continue
546
+ cid = item.get("id") or item.get("conf_id")
547
+ if cid:
548
+ conf_id = str(cid)
549
+ break
550
+ routing_debug["abconf_pick"] = conf_id
551
+
552
+ if conf_id:
553
+ upd = await client.update_umo_abconf_route(umo=umo, conf_id=conf_id)
554
+ routing_debug["ucr_update"] = upd
555
+ except Exception as e:
556
+ routing_debug["ucr_exception"] = str(e)
557
+
558
+ # 2) Copy provider_perf rule from an existing webchat UMO (avoids "no provider supported" on fresh sessions).
559
+ try:
560
+ rules_resp = await client.list_session_rules(
561
+ page=1, page_size=100, search=f"webchat!{username}!"
562
+ )
563
+ routing_debug["session_rules_get"] = (
564
+ rules_resp if rules_resp.get("status") != "ok" else None
565
+ )
566
+ if rules_resp.get("status") == "ok":
567
+ data = rules_resp.get("data") or {}
568
+ rules_list = data.get("rules") or []
569
+ if isinstance(rules_list, list):
570
+ source_umo = None
571
+ source_key = None
572
+ source_val = None
573
+ for item in rules_list:
574
+ if not isinstance(item, dict):
575
+ continue
576
+ rules = item.get("rules") or {}
577
+ if not isinstance(rules, dict):
578
+ continue
579
+ for k, v in rules.items():
580
+ if isinstance(k, str) and k.startswith("provider_perf_") and "chat" in k:
581
+ source_umo = item.get("umo")
582
+ source_key = k
583
+ source_val = v
584
+ break
585
+ if source_key:
586
+ break
587
+
588
+ if source_key and source_val is not None:
589
+ upd = await client.update_session_rule(
590
+ umo=umo, rule_key=source_key, rule_value=source_val
591
+ )
592
+ routing_debug["provider_rule_copied_from"] = source_umo
593
+ routing_debug["provider_rule_key"] = source_key
594
+ routing_debug["provider_rule_update"] = upd
595
+ except Exception as e:
596
+ routing_debug["session_rules_exception"] = str(e)
597
+ else:
598
+ routing_debug["skipped"] = "No ASTRBOT_USERNAME configured; cannot mirror dashboard session routing."
599
+
600
+ if reply_to_last_saved_message and not reply_to_message_id:
601
+ async with _LAST_SAVED_MESSAGE_ID_LOCK:
602
+ reply_to_message_id = _LAST_SAVED_MESSAGE_ID_BY_SESSION.get(
603
+ _last_saved_key(client, used_session_id)
604
+ )
315
605
 
316
606
  # 2. 把 message_chain 转成 AstrBot chat/send 需要的 message_parts
607
+ reply_ids: List[str] = []
608
+ if reply_to_message_id:
609
+ reply_ids.append(str(reply_to_message_id))
610
+
611
+ for part in message_chain:
612
+ if not isinstance(part, dict):
613
+ continue
614
+ if part.get("type") in ("reply", "quote", "reference"):
615
+ msg_id = part.get("message_id") or part.get("id")
616
+ if msg_id:
617
+ reply_ids.append(str(msg_id))
618
+
619
+ quote_prefix = ""
620
+ quote_debug: Dict[str, Any] | None = None
621
+ if reply_ids:
622
+ quote_prefix, quote_debug = await _resolve_webchat_quotes(
623
+ client, session_id=used_session_id, reply_ids=reply_ids
624
+ )
625
+
317
626
  message_parts: List[Dict[str, Any]] = []
627
+ if quote_prefix:
628
+ message_parts.append({"type": "plain", "text": quote_prefix})
629
+
318
630
  uploaded_attachments: List[Dict[str, Any]] = []
319
631
 
320
632
  for part in message_chain:
@@ -323,10 +635,8 @@ async def send_platform_message(
323
635
  if p_type == "plain":
324
636
  text = part.get("text", "")
325
637
  message_parts.append({"type": "plain", "text": text})
326
- elif p_type == "reply":
327
- msg_id = part.get("message_id")
328
- if msg_id:
329
- message_parts.append({"type": "reply", "message_id": msg_id})
638
+ elif p_type in ("reply", "quote", "reference"):
639
+ continue
330
640
  elif p_type in ("image", "file", "record", "video"):
331
641
  file_path = part.get("file_path")
332
642
  url = part.get("url")
@@ -428,11 +738,25 @@ async def send_platform_message(
428
738
  return {
429
739
  "status": "error",
430
740
  "message": "message_chain did not produce any valid message parts",
741
+ "mode": mode,
742
+ "platform_id": session_platform_id,
743
+ "requested_platform_id": platform_id,
744
+ "quote_debug": quote_debug,
745
+ "routing_debug": routing_debug,
431
746
  }
432
747
 
433
748
  # 3. 调用 /api/chat/send 并消费 SSE 回复
434
- effective_provider = selected_provider or client.settings.default_provider
435
- effective_model = selected_model or client.settings.default_model
749
+ # Mirror dashboard behavior: prefer session rules and UMO routing.
750
+ # If we cannot infer/copy provider rules for a brand-new session, fall back to env defaults.
751
+ effective_provider = selected_provider
752
+ effective_model = selected_model
753
+ if (
754
+ effective_provider is None
755
+ and effective_model is None
756
+ and not routing_debug.get("provider_rule_key")
757
+ ):
758
+ effective_provider = client.settings.default_provider
759
+ effective_model = client.settings.default_model
436
760
 
437
761
  try:
438
762
  events = await client.send_chat_message_sse(
@@ -443,10 +767,17 @@ async def send_platform_message(
443
767
  enable_streaming=enable_streaming,
444
768
  )
445
769
  except Exception as e:
770
+ status_code = getattr(getattr(e, "response", None), "status_code", None)
446
771
  return {
447
772
  "status": "error",
448
- "message": f"AstrBot API error: {e.response.status_code if hasattr(e, 'response') else 'Unknown'}",
449
- "platform_id": platform_id,
773
+ "message": (
774
+ f"AstrBot API error: HTTP {status_code}"
775
+ if status_code is not None
776
+ else f"AstrBot API error: {e}"
777
+ ),
778
+ "mode": mode,
779
+ "platform_id": session_platform_id,
780
+ "requested_platform_id": platform_id,
450
781
  "session_id": used_session_id,
451
782
  "selected_provider": effective_provider,
452
783
  "selected_model": effective_model,
@@ -456,46 +787,65 @@ async def send_platform_message(
456
787
  "If you see 'has no provider supported' in AstrBot logs, "
457
788
  "set selected_provider/selected_model (or env ASTRBOT_DEFAULT_PROVIDER/ASTRBOT_DEFAULT_MODEL)."
458
789
  ),
459
- }
460
- except Exception as e:
461
- return {
462
- "status": "error",
463
- "message": str(e),
464
- "platform_id": platform_id,
465
- "session_id": used_session_id,
466
- "selected_provider": effective_provider,
467
- "selected_model": effective_model,
468
- "request_message_parts": message_parts,
790
+ "quote_debug": quote_debug,
791
+ "routing_debug": routing_debug,
469
792
  }
470
793
 
471
794
  # 简单聚合文本回复(仅供参考,保留原始事件)
472
795
  reply_text_chunks: List[str] = []
796
+ saved_message_ids: List[str] = []
473
797
  if not events:
474
798
  return {
475
799
  "status": "error",
476
800
  "message": "AstrBot returned no SSE events for /api/chat/send",
477
- "platform_id": platform_id,
801
+ "mode": mode,
802
+ "platform_id": session_platform_id,
803
+ "requested_platform_id": platform_id,
478
804
  "session_id": used_session_id,
479
805
  "selected_provider": effective_provider,
480
806
  "selected_model": effective_model,
481
807
  "request_message_parts": message_parts,
482
808
  "hint": "Check AstrBot logs for the root cause (often provider/model config).",
809
+ "quote_debug": quote_debug,
810
+ "routing_debug": routing_debug,
483
811
  }
484
812
 
485
813
  for ev in events:
814
+ if ev.get("type") == "message_saved":
815
+ data = ev.get("data") or {}
816
+ saved_id = data.get("id")
817
+ if saved_id is not None:
818
+ saved_message_ids.append(str(saved_id))
486
819
  if ev.get("type") in ("plain", "complete"):
487
820
  data = ev.get("data")
488
821
  if isinstance(data, str):
489
822
  reply_text_chunks.append(data)
490
823
 
824
+ last_saved_message_id: str | None = (
825
+ saved_message_ids[-1] if saved_message_ids else None
826
+ )
827
+ if last_saved_message_id:
828
+ async with _LAST_SAVED_MESSAGE_ID_LOCK:
829
+ _LAST_SAVED_MESSAGE_ID_BY_SESSION[_last_saved_key(client, used_session_id)] = (
830
+ last_saved_message_id
831
+ )
832
+
491
833
  return {
492
834
  "status": "ok",
493
- "platform_id": platform_id,
835
+ "mode": mode,
836
+ "platform_id": session_platform_id,
837
+ "requested_platform_id": platform_id,
494
838
  "session_id": used_session_id,
839
+ "conversation_id": used_session_id,
840
+ "session_reused": session_reused,
495
841
  "selected_provider": effective_provider,
496
842
  "selected_model": effective_model,
497
843
  "request_message_parts": message_parts,
498
844
  "uploaded_attachments": uploaded_attachments,
499
845
  "reply_events": events,
500
846
  "reply_text": "".join(reply_text_chunks),
501
- }
847
+ "saved_message_ids": saved_message_ids,
848
+ "last_saved_message_id": last_saved_message_id,
849
+ "quote_debug": quote_debug,
850
+ "routing_debug": routing_debug,
851
+ }
@@ -16,16 +16,28 @@ class MessagePart(TypedDict, total=False):
16
16
  Types:
17
17
  - plain: {"type": "plain", "text": "..."}
18
18
  - reply: {"type": "reply", "message_id": "..."}
19
+ - quote: {"type": "quote", "message_id": "..."} (alias of reply)
20
+ - reference: {"type": "reference", "message_id": "..."} (alias of reply)
19
21
  - image: {"type": "image", "file_path": "..."} or {"type": "image", "url": "https://..."}
20
22
  - file: {"type": "file", "file_path": "..."} or {"type": "file", "url": "https://..."}
21
23
  - record: {"type": "record", "file_path": "..."} or {"type": "record", "url": "https://..."}
22
24
  - video: {"type": "video", "file_path": "..."} or {"type": "video", "url": "https://..."}
23
25
  """
24
26
 
25
- type: Literal["plain", "reply", "image", "file", "record", "video"]
27
+ type: Literal[
28
+ "plain",
29
+ "reply",
30
+ "quote",
31
+ "reference",
32
+ "image",
33
+ "file",
34
+ "record",
35
+ "video",
36
+ ]
26
37
  text: str
27
38
  message_id: str
39
+ id: str
28
40
  file_path: str
29
41
  url: str
30
42
  file_name: str
31
- mime_type: str
43
+ mime_type: str
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: astrbotmcp
3
- Version: 0.2.8
3
+ Version: 0.3.0
4
4
  Summary: Add your description here
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "astrbotmcp"
3
- version = "0.2.8"
3
+ version = "0.3.0"
4
4
  description = "Add your description here"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
File without changes
File without changes
File without changes