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.
- astrbot_mcp/astrbot_client.py +61 -17
- astrbot_mcp/tools/__init__.py +1 -1
- astrbot_mcp/tools/message/__init__.py +4 -0
- astrbot_mcp/tools/message/cache.py +23 -0
- astrbot_mcp/tools/message/direct.py +252 -0
- astrbot_mcp/tools/message/quote.py +71 -0
- astrbot_mcp/tools/message/utils.py +62 -0
- astrbot_mcp/tools/{message_tools.py → message/webchat.py} +207 -381
- {astrbotmcp-0.3.0.dist-info → astrbotmcp-0.3.1.dist-info}/METADATA +40 -4
- {astrbotmcp-0.3.0.dist-info → astrbotmcp-0.3.1.dist-info}/RECORD +14 -9
- {astrbotmcp-0.3.0.dist-info → astrbotmcp-0.3.1.dist-info}/WHEEL +0 -0
- {astrbotmcp-0.3.0.dist-info → astrbotmcp-0.3.1.dist-info}/entry_points.txt +0 -0
- {astrbotmcp-0.3.0.dist-info → astrbotmcp-0.3.1.dist-info}/licenses/LICENSE.txt +0 -0
- {astrbotmcp-0.3.0.dist-info → astrbotmcp-0.3.1.dist-info}/top_level.txt +0 -0
|
@@ -1,380 +1,54 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import asyncio
|
|
4
3
|
import os
|
|
5
|
-
import
|
|
6
|
-
from typing import Any, Dict, List, Literal, Optional
|
|
7
|
-
|
|
8
|
-
from
|
|
9
|
-
from
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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 .
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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":
|
|
273
|
-
"
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
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,
|