astrbotmcp 0.3.0__py3-none-any.whl → 0.3.1__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.
@@ -1,380 +1,54 @@
1
1
  from __future__ import annotations
2
2
 
3
- import asyncio
4
3
  import os
5
- import textwrap
6
- from typing import Any, Dict, List, Literal, Optional, Tuple, TypedDict
7
-
8
- from ..astrbot_client import AstrBotClient
9
- from .helpers import (
10
- _as_file_uri,
11
- _attachment_download_url,
12
- _astrbot_connect_hint,
13
- _direct_media_mode,
14
- _httpx_error_detail,
15
- _resolve_local_file_path,
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Any, Dict, List, Literal, Optional
6
+
7
+ from ...astrbot_client import AstrBotClient
8
+ from ..helpers import _httpx_error_detail, _resolve_local_file_path
9
+ from ..types import MessagePart
10
+ from .cache import (
11
+ _LAST_SAVED_MESSAGE_ID_BY_SESSION,
12
+ _LAST_SAVED_MESSAGE_ID_LOCK,
13
+ _LAST_USER_MESSAGE_ID_BY_SESSION,
14
+ _LAST_USER_MESSAGE_ID_LOCK,
15
+ _SESSION_CACHE,
16
+ _SESSION_CACHE_LOCK,
17
+ _last_saved_key,
18
+ _session_cache_key,
16
19
  )
17
- from .types import MessagePart
20
+ from .direct import send_platform_message_direct
21
+ from .quote import _resolve_webchat_quotes
22
+ from .utils import _extract_plain_text_from_history_item, _normalize_history_message_id
18
23
 
19
24
 
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
-
25
+ async def _get_astrbot_log_tail(
26
+ client: AstrBotClient, *, limit: int = 120
27
+ ) -> Dict[str, Any] | None:
88
28
  try:
89
- resp = await client.get_platform_session(session_id=session_id)
29
+ hist = await client.get_log_history()
90
30
  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
-
138
- async def send_platform_message_direct(
139
- platform_id: str,
140
- target_id: str,
141
- message_chain: Optional[List[MessagePart]] = None,
142
- message: Optional[str] = None,
143
- images: Optional[List[str]] = None,
144
- files: Optional[List[str]] = None,
145
- videos: Optional[List[str]] = None,
146
- records: Optional[List[str]] = None,
147
- message_type: Literal["GroupMessage", "FriendMessage"] = "GroupMessage",
148
- ) -> Dict[str, Any]:
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.
155
- Directly send a message chain to a platform group/user (bypass LLM).
156
-
157
- This calls AstrBot dashboard endpoint: POST /api/platform/send_message
158
-
159
- Notes:
160
- - This is for sending to a real platform target (group/user), not WebChat.
161
- - Media parts:
162
- - If `file_path` is a local path, this tool will upload it to AstrBot first, then send it as an AstrBot-hosted URL.
163
- - If `file_path`/`url` is an http(s) URL, it will be forwarded as-is.
164
- """
165
- client = AstrBotClient.from_env()
166
- onebot_like = platform_id.strip().lower() in {
167
- "napcat",
168
- "onebot",
169
- "cqhttp",
170
- "gocqhttp",
171
- "llonebot",
172
- }
173
-
174
- if message_chain is None:
175
- message_chain = []
176
- if message:
177
- message_chain.append({"type": "plain", "text": message})
178
- for src in images or []:
179
- message_chain.append({"type": "image", "file_path": src})
180
- for src in files or []:
181
- message_chain.append({"type": "file", "file_path": src})
182
- for src in records or []:
183
- message_chain.append({"type": "record", "file_path": src})
184
- for src in videos or []:
185
- message_chain.append({"type": "video", "file_path": src})
186
-
187
- async def build_chain(mode: str) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
188
- normalized_chain: List[Dict[str, Any]] = []
189
- uploaded_attachments: List[Dict[str, Any]] = []
190
-
191
- for part in message_chain or []:
192
- p_type = part.get("type")
193
- if p_type in ("image", "file", "record", "video"):
194
- file_path = part.get("file_path")
195
- url = part.get("url")
196
- file_name = part.get("file_name")
197
- mime_type = part.get("mime_type")
198
- src = url or file_path
199
- if not src:
200
- continue
201
-
202
- normalized = dict(part)
203
- if not isinstance(src, str):
204
- raise ValueError(f"Invalid media source (expected str): {src!r}")
205
-
206
- if src.startswith(("http://", "https://")):
207
- normalized["file_path"] = src
208
- if onebot_like:
209
- normalized.setdefault("file", src)
210
- normalized.pop("url", None)
211
- normalized_chain.append(normalized)
212
- continue
213
-
214
- try:
215
- local_path = _resolve_local_file_path(client, src)
216
- except ValueError as e:
217
- raise ValueError(str(e)) from e
218
- except FileNotFoundError as e:
219
- raise FileNotFoundError(f"Local file_path does not exist: {src!r}") from e
220
-
221
- if mode == "local":
222
- normalized["file_path"] = local_path
223
- normalized.pop("url", None)
224
- if onebot_like:
225
- uri = _as_file_uri(local_path)
226
- normalized.setdefault("file", uri or local_path)
227
- normalized_chain.append(normalized)
228
- continue
229
-
230
- if mode != "upload":
231
- raise ValueError(f"Unknown direct media mode: {mode!r}")
232
-
233
- if not file_name:
234
- file_name = os.path.basename(local_path) or None
235
-
236
- attach_resp = await client.post_attachment_file(
237
- local_path,
238
- file_name=file_name,
239
- mime_type=mime_type,
240
- )
241
-
242
- if attach_resp.get("status") != "ok":
243
- raise RuntimeError(attach_resp.get("message") or "Attachment upload failed")
244
-
245
- attach_data = attach_resp.get("data") or {}
246
- attachment_id = attach_data.get("attachment_id")
247
- if not attachment_id:
248
- raise RuntimeError(
249
- "Attachment upload succeeded but attachment_id is missing"
250
- )
251
-
252
- download_url = _attachment_download_url(client, str(attachment_id))
253
- normalized["file_path"] = download_url
254
- if onebot_like:
255
- normalized.setdefault("file", download_url)
256
- normalized.pop("url", None)
257
- normalized.pop("file_name", None)
258
- normalized.pop("mime_type", None)
259
- uploaded_attachments.append(attach_data)
260
- normalized_chain.append(normalized)
261
- else:
262
- normalized_chain.append(dict(part))
263
-
264
- return normalized_chain, uploaded_attachments
265
-
266
- # Prefer local paths (more compatible with Napcat / Windows), but keep an upload fallback.
267
- try:
268
- mode = _direct_media_mode(client)
269
- except ValueError as e:
270
31
  return {
271
32
  "status": "error",
272
- "message": str(e),
273
- "platform_id": platform_id,
274
- "session_id": str(target_id),
275
- "message_type": message_type,
33
+ "message": f"AstrBot API error: {getattr(getattr(e, 'response', None), 'status_code', None) or 'Unknown'}",
34
+ "detail": _httpx_error_detail(e),
276
35
  }
277
- modes_to_try = ["local", "upload"] if mode == "auto" else [mode]
278
- last_error: Dict[str, Any] | None = None
279
-
280
- for attempt_mode in modes_to_try:
281
- try:
282
- normalized_chain, uploaded_attachments = await build_chain(attempt_mode)
283
- except FileNotFoundError as e:
284
- return {
285
- "status": "error",
286
- "message": str(e),
287
- "platform_id": platform_id,
288
- "session_id": str(target_id),
289
- "message_type": message_type,
290
- "hint": "If you passed a relative path, set ASTRBOTMCP_FILE_ROOT (or run the server in the correct working directory).",
291
- }
292
- except ValueError as e:
293
- return {
294
- "status": "error",
295
- "message": str(e),
296
- "platform_id": platform_id,
297
- "session_id": str(target_id),
298
- "message_type": message_type,
299
- "hint": "Set ASTRBOTMCP_FILE_ROOT to control how relative paths are resolved.",
300
- }
301
- except Exception as e:
302
- return {
303
- "status": "error",
304
- "message": str(e),
305
- "platform_id": platform_id,
306
- "session_id": str(target_id),
307
- "message_type": message_type,
308
- "attempt_mode": attempt_mode,
309
- }
310
-
311
- if not normalized_chain:
312
- return {
313
- "status": "error",
314
- "message": "message_chain did not produce any valid message parts",
315
- "platform_id": platform_id,
316
- "session_id": str(target_id),
317
- "message_type": message_type,
318
- }
319
-
320
- try:
321
- direct_resp = await client.send_platform_message_direct(
322
- platform_id=platform_id,
323
- message_type=message_type,
324
- session_id=str(target_id),
325
- message_chain=normalized_chain,
326
- )
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
- )
335
- return {
336
- "status": "error",
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
- ),
342
- "platform_id": platform_id,
343
- "session_id": str(target_id),
344
- "message_type": message_type,
345
- "attempt_mode": attempt_mode,
346
- "detail": _httpx_error_detail(e),
347
- "hint": hint,
348
- }
349
-
350
- status = direct_resp.get("status")
351
- if status == "ok":
352
- data = direct_resp.get("data") or {}
353
- return {
354
- "status": "ok",
355
- "platform_id": data.get("platform_id", platform_id),
356
- "session_id": data.get("session_id", str(target_id)),
357
- "message_type": data.get("message_type", message_type),
358
- "attempt_mode": attempt_mode,
359
- "uploaded_attachments": uploaded_attachments,
360
- }
361
-
362
- last_error = {
363
- "status": status,
364
- "platform_id": platform_id,
365
- "session_id": str(target_id),
366
- "message_type": message_type,
367
- "attempt_mode": attempt_mode,
368
- "message": direct_resp.get("message"),
369
- "raw": direct_resp,
36
+ if hist.get("status") != "ok":
37
+ return {
38
+ "status": hist.get("status"),
39
+ "message": hist.get("message"),
40
+ "raw": hist,
370
41
  }
371
-
372
- return last_error or {
373
- "status": "error",
374
- "message": "Failed to send message",
375
- "platform_id": platform_id,
376
- "session_id": str(target_id),
377
- "message_type": message_type,
42
+ logs = (hist.get("data") or {}).get("logs", [])
43
+ if not isinstance(logs, list):
44
+ return {
45
+ "status": "error",
46
+ "message": "Unexpected /api/log-history response shape (logs is not a list).",
47
+ "raw": hist,
48
+ }
49
+ return {
50
+ "status": "ok",
51
+ "logs": logs[-max(1, int(limit)) :],
378
52
  }
379
53
 
380
54
 
@@ -394,6 +68,7 @@ async def send_platform_message(
394
68
  new_session: bool = False,
395
69
  reply_to_message_id: Optional[str] = None,
396
70
  reply_to_last_saved_message: bool = False,
71
+ reply_to_last_user_message: bool = False,
397
72
  selected_provider: Optional[str] = None,
398
73
  selected_model: Optional[str] = None,
399
74
  enable_streaming: bool = True,
@@ -433,6 +108,7 @@ async def send_platform_message(
433
108
  mode = "webchat"
434
109
  session_platform_id = "webchat"
435
110
  routing_debug: Dict[str, Any] = {}
111
+ send_started_at = datetime.now(timezone.utc)
436
112
 
437
113
  if message_chain is None:
438
114
  message_chain = []
@@ -597,6 +273,14 @@ async def send_platform_message(
597
273
  else:
598
274
  routing_debug["skipped"] = "No ASTRBOT_USERNAME configured; cannot mirror dashboard session routing."
599
275
 
276
+ if reply_to_last_user_message and not reply_to_message_id:
277
+ async with _LAST_USER_MESSAGE_ID_LOCK:
278
+ reply_to_message_id = _LAST_USER_MESSAGE_ID_BY_SESSION.get(
279
+ _last_saved_key(client, used_session_id)
280
+ )
281
+
282
+ # reply_to_last_saved_message historically points to the last saved bot message (message_saved.id).
283
+ # With user_message_saved supported, callers can prefer last_user_message_id from the response.
600
284
  if reply_to_last_saved_message and not reply_to_message_id:
601
285
  async with _LAST_SAVED_MESSAGE_ID_LOCK:
602
286
  reply_to_message_id = _LAST_SAVED_MESSAGE_ID_BY_SESSION.get(
@@ -604,29 +288,28 @@ async def send_platform_message(
604
288
  )
605
289
 
606
290
  # 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
-
291
+ explicit_reply_present = False
611
292
  for part in message_chain:
612
293
  if not isinstance(part, dict):
613
294
  continue
614
295
  if part.get("type") in ("reply", "quote", "reference"):
615
296
  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
- )
297
+ if msg_id is not None and str(msg_id).strip():
298
+ explicit_reply_present = True
299
+ break
625
300
 
626
301
  message_parts: List[Dict[str, Any]] = []
627
- if quote_prefix:
628
- message_parts.append({"type": "plain", "text": quote_prefix})
302
+ reply_ids: List[str] = []
303
+ if reply_to_message_id and not explicit_reply_present:
304
+ message_parts.append(
305
+ {
306
+ "type": "reply",
307
+ "message_id": _normalize_history_message_id(reply_to_message_id),
308
+ }
309
+ )
310
+ reply_ids.append(str(reply_to_message_id))
629
311
 
312
+ quote_debug: Dict[str, Any] | None = None
630
313
  uploaded_attachments: List[Dict[str, Any]] = []
631
314
 
632
315
  for part in message_chain:
@@ -636,7 +319,16 @@ async def send_platform_message(
636
319
  text = part.get("text", "")
637
320
  message_parts.append({"type": "plain", "text": text})
638
321
  elif p_type in ("reply", "quote", "reference"):
639
- continue
322
+ msg_id = part.get("message_id") or part.get("id")
323
+ if msg_id is None:
324
+ continue
325
+ msg_id_str = str(msg_id).strip()
326
+ if not msg_id_str:
327
+ continue
328
+ message_parts.append(
329
+ {"type": "reply", "message_id": _normalize_history_message_id(msg_id)}
330
+ )
331
+ reply_ids.append(msg_id_str)
640
332
  elif p_type in ("image", "file", "record", "video"):
641
333
  file_path = part.get("file_path")
642
334
  url = part.get("url")
@@ -734,6 +426,14 @@ async def send_platform_message(
734
426
  # 忽略未知类型
735
427
  continue
736
428
 
429
+ if reply_ids:
430
+ try:
431
+ _ignored, quote_debug = await _resolve_webchat_quotes(
432
+ client, session_id=used_session_id, reply_ids=reply_ids
433
+ )
434
+ except Exception as e:
435
+ quote_debug = {"error": str(e), "resolved": {}, "missing": reply_ids}
436
+
737
437
  if not message_parts:
738
438
  return {
739
439
  "status": "error",
@@ -783,6 +483,7 @@ async def send_platform_message(
783
483
  "selected_model": effective_model,
784
484
  "request_message_parts": message_parts,
785
485
  "detail": _httpx_error_detail(e),
486
+ "astrbot_logs_tail": await _get_astrbot_log_tail(client),
786
487
  "hint": (
787
488
  "If you see 'has no provider supported' in AstrBot logs, "
788
489
  "set selected_provider/selected_model (or env ASTRBOT_DEFAULT_PROVIDER/ASTRBOT_DEFAULT_MODEL)."
@@ -794,6 +495,7 @@ async def send_platform_message(
794
495
  # 简单聚合文本回复(仅供参考,保留原始事件)
795
496
  reply_text_chunks: List[str] = []
796
497
  saved_message_ids: List[str] = []
498
+ user_message_ids: List[str] = []
797
499
  if not events:
798
500
  return {
799
501
  "status": "error",
@@ -805,12 +507,66 @@ async def send_platform_message(
805
507
  "selected_provider": effective_provider,
806
508
  "selected_model": effective_model,
807
509
  "request_message_parts": message_parts,
510
+ "astrbot_logs_tail": await _get_astrbot_log_tail(client),
808
511
  "hint": "Check AstrBot logs for the root cause (often provider/model config).",
809
512
  "quote_debug": quote_debug,
810
513
  "routing_debug": routing_debug,
811
514
  }
812
515
 
516
+ # If we only got bookkeeping events (e.g., user_message_saved) but no response stream at all,
517
+ # treat it as an error while still returning useful ids.
518
+ response_types = {
519
+ "plain",
520
+ "complete",
521
+ "image",
522
+ "record",
523
+ "file",
524
+ "message_saved",
525
+ "end",
526
+ "break",
527
+ "raw",
528
+ }
529
+ has_response = any(ev.get("type") in response_types for ev in events if isinstance(ev, dict))
530
+ if not has_response:
531
+ user_ids = []
532
+ for ev in events:
533
+ if not isinstance(ev, dict):
534
+ continue
535
+ if ev.get("type") == "user_message_saved":
536
+ data = ev.get("data") or {}
537
+ mid = data.get("id")
538
+ if mid is not None:
539
+ user_ids.append(str(mid))
540
+ # Some plugins reply by side effects (e.g., sending messages via adapters) and may only
541
+ # emit bookkeeping events on the WebChat SSE stream. Treat as ok but include a warning.
542
+ return {
543
+ "status": "ok",
544
+ "warning": "No reply events were observed on the /api/chat/send SSE stream; check AstrBot logs if you expected an LLM reply.",
545
+ "mode": mode,
546
+ "platform_id": session_platform_id,
547
+ "requested_platform_id": platform_id,
548
+ "session_id": used_session_id,
549
+ "selected_provider": effective_provider,
550
+ "selected_model": effective_model,
551
+ "request_message_parts": message_parts,
552
+ "user_message_ids": user_ids,
553
+ "last_user_message_id": (user_ids[-1] if user_ids else None),
554
+ "quote_debug": quote_debug,
555
+ "routing_debug": routing_debug,
556
+ "reply_events": events,
557
+ "astrbot_logs_tail": await _get_astrbot_log_tail(client),
558
+ }
559
+
813
560
  for ev in events:
561
+ if ev.get("type") == "user_message_saved":
562
+ data = ev.get("data") or {}
563
+ saved_id = data.get("id")
564
+ if saved_id is not None:
565
+ user_message_ids.append(str(saved_id))
566
+ async with _LAST_USER_MESSAGE_ID_LOCK:
567
+ _LAST_USER_MESSAGE_ID_BY_SESSION[_last_saved_key(client, used_session_id)] = str(
568
+ saved_id
569
+ )
814
570
  if ev.get("type") == "message_saved":
815
571
  data = ev.get("data") or {}
816
572
  saved_id = data.get("id")
@@ -821,9 +577,77 @@ async def send_platform_message(
821
577
  if isinstance(data, str):
822
578
  reply_text_chunks.append(data)
823
579
 
580
+ # Fallback: some AstrBot versions do not emit `user_message_saved`.
581
+ # Try to infer the latest user message id by fetching /api/chat/get_session and scanning history.
582
+ if not user_message_ids:
583
+ match_hint = None
584
+ for part in message_parts:
585
+ if not isinstance(part, dict):
586
+ continue
587
+ if part.get("type") == "plain":
588
+ txt = part.get("text")
589
+ if isinstance(txt, str) and txt.strip():
590
+ match_hint = txt.strip()
591
+ break
592
+
593
+ try:
594
+ sess = await client.get_platform_session(session_id=used_session_id)
595
+ if sess.get("status") == "ok":
596
+ history = (sess.get("data") or {}).get("history") or []
597
+ if isinstance(history, list):
598
+ expected_sender = (client.settings.username or "").strip() or None
599
+
600
+ def is_recent_user_record(item: Dict[str, Any]) -> bool:
601
+ if not isinstance(item, dict):
602
+ return False
603
+ content = item.get("content") or {}
604
+ if not isinstance(content, dict) or content.get("type") != "user":
605
+ return False
606
+ if expected_sender and item.get("sender_name") != expected_sender:
607
+ return False
608
+ if match_hint:
609
+ extracted = _extract_plain_text_from_history_item(item)
610
+ if match_hint[:32] not in extracted:
611
+ return False
612
+ created_at = item.get("created_at")
613
+ if isinstance(created_at, str) and created_at:
614
+ try:
615
+ # e.g. 2025-12-18T21:47:07.684801+08:00
616
+ dt = datetime.fromisoformat(created_at)
617
+ if dt.tzinfo is None:
618
+ dt = dt.replace(tzinfo=timezone.utc)
619
+ # accept a small clock skew window
620
+ return dt.astimezone(timezone.utc) >= send_started_at.replace(
621
+ microsecond=0
622
+ ) - timedelta(seconds=5)
623
+ except Exception:
624
+ pass
625
+ return True
626
+
627
+ # Look from newest to oldest for a likely match.
628
+ for item in reversed(history):
629
+ if not isinstance(item, dict):
630
+ continue
631
+ if not is_recent_user_record(item):
632
+ continue
633
+ mid = item.get("id")
634
+ if mid is None:
635
+ continue
636
+ user_message_ids.append(str(mid))
637
+ async with _LAST_USER_MESSAGE_ID_LOCK:
638
+ _LAST_USER_MESSAGE_ID_BY_SESSION[_last_saved_key(client, used_session_id)] = str(
639
+ mid
640
+ )
641
+ break
642
+ except Exception as e:
643
+ routing_debug["user_id_fallback_exception"] = str(e)
644
+
824
645
  last_saved_message_id: str | None = (
825
646
  saved_message_ids[-1] if saved_message_ids else None
826
647
  )
648
+ last_user_message_id: str | None = (
649
+ user_message_ids[-1] if user_message_ids else None
650
+ )
827
651
  if last_saved_message_id:
828
652
  async with _LAST_SAVED_MESSAGE_ID_LOCK:
829
653
  _LAST_SAVED_MESSAGE_ID_BY_SESSION[_last_saved_key(client, used_session_id)] = (
@@ -844,6 +668,8 @@ async def send_platform_message(
844
668
  "uploaded_attachments": uploaded_attachments,
845
669
  "reply_events": events,
846
670
  "reply_text": "".join(reply_text_chunks),
671
+ "user_message_ids": user_message_ids,
672
+ "last_user_message_id": last_user_message_id,
847
673
  "saved_message_ids": saved_message_ids,
848
674
  "last_saved_message_id": last_saved_message_id,
849
675
  "quote_debug": quote_debug,