astrbotmcp 0.2.7__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- astrbot_mcp/astrbot_client.py +84 -8
- astrbot_mcp/config.py +8 -0
- astrbot_mcp/tools/message_tools.py +378 -28
- astrbot_mcp/tools/types.py +14 -2
- {astrbotmcp-0.2.7.dist-info → astrbotmcp-0.3.0.dist-info}/METADATA +32 -1
- {astrbotmcp-0.2.7.dist-info → astrbotmcp-0.3.0.dist-info}/RECORD +10 -10
- {astrbotmcp-0.2.7.dist-info → astrbotmcp-0.3.0.dist-info}/WHEEL +0 -0
- {astrbotmcp-0.2.7.dist-info → astrbotmcp-0.3.0.dist-info}/entry_points.txt +0 -0
- {astrbotmcp-0.2.7.dist-info → astrbotmcp-0.3.0.dist-info}/licenses/LICENSE.txt +0 -0
- {astrbotmcp-0.2.7.dist-info → astrbotmcp-0.3.0.dist-info}/top_level.txt +0 -0
astrbot_mcp/astrbot_client.py
CHANGED
|
@@ -98,7 +98,11 @@ class AstrBotClient:
|
|
|
98
98
|
pwd = hashlib.md5(pwd.encode("utf-8")).hexdigest()
|
|
99
99
|
|
|
100
100
|
url = f"{self.base_url}/api/auth/login"
|
|
101
|
-
|
|
101
|
+
client_kwargs = {"timeout": self.timeout}
|
|
102
|
+
if self.settings.disable_proxy:
|
|
103
|
+
client_kwargs["trust_env"] = False # 禁用代理,忽略环境变量设置
|
|
104
|
+
|
|
105
|
+
async with httpx.AsyncClient(**client_kwargs) as client:
|
|
102
106
|
resp = await client.post(
|
|
103
107
|
url,
|
|
104
108
|
json={"username": username, "password": pwd},
|
|
@@ -142,7 +146,11 @@ class AstrBotClient:
|
|
|
142
146
|
auth_headers = await self._get_auth_headers()
|
|
143
147
|
headers = {**headers, **auth_headers}
|
|
144
148
|
|
|
145
|
-
|
|
149
|
+
client_kwargs = {"timeout": self.timeout}
|
|
150
|
+
if self.settings.disable_proxy:
|
|
151
|
+
client_kwargs["trust_env"] = False # 禁用代理,忽略环境变量设置
|
|
152
|
+
|
|
153
|
+
async with httpx.AsyncClient(**client_kwargs) as client:
|
|
146
154
|
if stream:
|
|
147
155
|
return await client.build_request(method, url, params=params, json=json_body, files=files) # type: ignore[return-value]
|
|
148
156
|
response = await client.request(
|
|
@@ -181,7 +189,11 @@ class AstrBotClient:
|
|
|
181
189
|
|
|
182
190
|
headers = await self._get_auth_headers()
|
|
183
191
|
|
|
184
|
-
|
|
192
|
+
client_kwargs = {"timeout": self.timeout}
|
|
193
|
+
if self.settings.disable_proxy:
|
|
194
|
+
client_kwargs["trust_env"] = False # 禁用代理,忽略环境变量设置
|
|
195
|
+
|
|
196
|
+
async with httpx.AsyncClient(**client_kwargs) as client:
|
|
185
197
|
async with client.stream(
|
|
186
198
|
method,
|
|
187
199
|
url,
|
|
@@ -281,6 +293,29 @@ class AstrBotClient:
|
|
|
281
293
|
response = await self._request("GET", "/api/config/platform/list")
|
|
282
294
|
return response.json()
|
|
283
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
|
+
|
|
284
319
|
async def get_abconf_list(self) -> Dict[str, Any]:
|
|
285
320
|
"""Call /api/config/abconfs and return the parsed JSON."""
|
|
286
321
|
response = await self._request("GET", "/api/config/abconfs")
|
|
@@ -323,6 +358,39 @@ class AstrBotClient:
|
|
|
323
358
|
response = await self._request("POST", "/api/config/astrbot/update", json_body=payload)
|
|
324
359
|
return response.json()
|
|
325
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
|
+
|
|
326
394
|
# ---- Plugin / market APIs ----------------------------------------
|
|
327
395
|
|
|
328
396
|
async def get_plugin_market_list(
|
|
@@ -451,7 +519,11 @@ class AstrBotClient:
|
|
|
451
519
|
}
|
|
452
520
|
url = f"{self.base_url}/api/chat/post_file"
|
|
453
521
|
headers = await self._get_auth_headers()
|
|
454
|
-
|
|
522
|
+
client_kwargs = {"timeout": self.timeout}
|
|
523
|
+
if self.settings.disable_proxy:
|
|
524
|
+
client_kwargs["trust_env"] = False # 禁用代理,忽略环境变量设置
|
|
525
|
+
|
|
526
|
+
async with httpx.AsyncClient(**client_kwargs) as client:
|
|
455
527
|
response = await client.post(url, files=files, headers=headers)
|
|
456
528
|
response.raise_for_status()
|
|
457
529
|
return response.json()
|
|
@@ -471,10 +543,14 @@ class AstrBotClient:
|
|
|
471
543
|
"""
|
|
472
544
|
temp_path: str | None = None
|
|
473
545
|
try:
|
|
474
|
-
|
|
475
|
-
timeout
|
|
476
|
-
follow_redirects
|
|
477
|
-
|
|
546
|
+
client_kwargs = {
|
|
547
|
+
"timeout": self.timeout,
|
|
548
|
+
"follow_redirects": True
|
|
549
|
+
}
|
|
550
|
+
if self.settings.disable_proxy:
|
|
551
|
+
client_kwargs["trust_env"] = False # 禁用代理,忽略环境变量设置
|
|
552
|
+
|
|
553
|
+
async with httpx.AsyncClient(**client_kwargs) as http_client:
|
|
478
554
|
async with http_client.stream("GET", url) as response:
|
|
479
555
|
response.raise_for_status()
|
|
480
556
|
|
astrbot_mcp/config.py
CHANGED
|
@@ -16,6 +16,7 @@ class AstrBotSettings:
|
|
|
16
16
|
default_model: str | None = None
|
|
17
17
|
file_root: str | None = None
|
|
18
18
|
direct_media_mode: str | None = None
|
|
19
|
+
disable_proxy: bool = True # 默认禁用代理,防止本地请求被代理拦截
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
def _get_env(name: str) -> str | None:
|
|
@@ -72,6 +73,12 @@ def get_settings() -> AstrBotSettings:
|
|
|
72
73
|
"ASTRBOT_MCP_DIRECT_MEDIA_MODE"
|
|
73
74
|
)
|
|
74
75
|
|
|
76
|
+
# 默认禁用代理,除非明确设置为false
|
|
77
|
+
disable_proxy_str = _get_env("ASTRBOTMCP_DISABLE_PROXY")
|
|
78
|
+
disable_proxy = True # 默认值
|
|
79
|
+
if disable_proxy_str is not None:
|
|
80
|
+
disable_proxy = disable_proxy_str.lower() not in ("false", "0", "no", "n")
|
|
81
|
+
|
|
75
82
|
return AstrBotSettings(
|
|
76
83
|
base_url=base_url,
|
|
77
84
|
timeout=timeout,
|
|
@@ -81,4 +88,5 @@ def get_settings() -> AstrBotSettings:
|
|
|
81
88
|
default_model=default_model,
|
|
82
89
|
file_root=file_root,
|
|
83
90
|
direct_media_mode=direct_media_mode,
|
|
91
|
+
disable_proxy=disable_proxy,
|
|
84
92
|
)
|
|
@@ -2,7 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import os
|
|
5
|
-
|
|
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":
|
|
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":
|
|
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
|
-
|
|
290
|
-
|
|
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(
|
|
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
|
-
"
|
|
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
|
|
327
|
-
|
|
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
|
-
|
|
435
|
-
|
|
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":
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
+
}
|
astrbot_mcp/tools/types.py
CHANGED
|
@@ -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[
|
|
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.
|
|
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
|
|
@@ -93,6 +93,37 @@ uv add astrbotmcp
|
|
|
93
93
|
| `ASTRBOT_USERNAME` | Dashboard 用户名 | - |
|
|
94
94
|
| `ASTRBOT_PASSWORD` | Dashboard 密码 | - |
|
|
95
95
|
| `ASTRBOT_LOG_LEVEL` | 日志级别 | `INFO` |
|
|
96
|
+
| `ASTRBOTMCP_DISABLE_PROXY` | 是否禁用代理(防止本地请求被代理拦截) | `true` |
|
|
97
|
+
|
|
98
|
+
#### 代理配置说明
|
|
99
|
+
|
|
100
|
+
如果你在使用代理工具(如 Clash、V2Ray 等),可能会遇到 502 Bad Gateway 错误,这是因为本地请求被代理拦截导致的。
|
|
101
|
+
|
|
102
|
+
**解决方案:**
|
|
103
|
+
|
|
104
|
+
1. **默认行为**:AstrBot MCP 默认禁用代理(`ASTRBOTMCP_DISABLE_PROXY=true`),确保本地请求直接发送到 AstrBot。
|
|
105
|
+
|
|
106
|
+
2. **如果需要使用代理**:设置 `ASTRBOTMCP_DISABLE_PROXY=false`,但请注意这可能导致本地 API 请求失败。
|
|
107
|
+
|
|
108
|
+
3. **推荐配置**:对于本地 AstrBot 实例,始终禁用代理:
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"mcpServers": {
|
|
112
|
+
"astrbot-mcp": {
|
|
113
|
+
"command": "uvx",
|
|
114
|
+
"args": [
|
|
115
|
+
"--from",
|
|
116
|
+
"astrbotmcp",
|
|
117
|
+
"astrbot-mcp"
|
|
118
|
+
],
|
|
119
|
+
"env": {
|
|
120
|
+
"ASTRBOT_BASE_URL": "http://127.0.0.1:6185",
|
|
121
|
+
"ASTRBOTMCP_DISABLE_PROXY": "true"
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
96
127
|
|
|
97
128
|
---
|
|
98
129
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
astrbot_mcp/__init__.py,sha256=nDTFGuA6IlvCywUVliWHmmFBkQQsnLVgnZDFYfdALbo,370
|
|
2
|
-
astrbot_mcp/astrbot_client.py,sha256=
|
|
3
|
-
astrbot_mcp/config.py,sha256=
|
|
2
|
+
astrbot_mcp/astrbot_client.py,sha256=jHOvOxp5qnY6aza4w6YEu0XCqnlZSdldIT7JENzl9HI,22423
|
|
3
|
+
astrbot_mcp/config.py,sha256=yRAlOaKuLyM7uMz3zA4EVEDjqK5j5i_uMZ2Ecv6Fs4o,3221
|
|
4
4
|
astrbot_mcp/server.py,sha256=wfSrDHXeX5QUjIJ70WL5niEt4l9klF6JDgNS3HX6G3I,3293
|
|
5
5
|
astrbot_mcp/tools.py,sha256=rBL89W_4B7djcuGwBekLp-6pqi8dbmic8P3LJUIForI,1261
|
|
6
6
|
astrbot_mcp/tools/__init__.py,sha256=gk-_Y8gN9CIjuBNl9z5oJXP6V7LJb5FO_UnDN1L3uwc,2084
|
|
@@ -9,14 +9,14 @@ astrbot_mcp/tools/config_tools.py,sha256=-AcXQ76rydpEHBH_RrBE8QCG6nV_xVLSSTQ9XKA
|
|
|
9
9
|
astrbot_mcp/tools/control_tools.py,sha256=MaCP20AaQbTbFPB3oExTj7VOVUM8Xz7UyMhklKMhHfw,2309
|
|
10
10
|
astrbot_mcp/tools/helpers.py,sha256=XGsAF1z1dLnGAs9ARyNynAilYgqOuw1hluP9ojYq5rA,3064
|
|
11
11
|
astrbot_mcp/tools/log_tools.py,sha256=iDmBPKrZuu4YLD4o6f7v_s9zGe9sILDXLiYmRaCZWlo,1958
|
|
12
|
-
astrbot_mcp/tools/message_tools.py,sha256=
|
|
12
|
+
astrbot_mcp/tools/message_tools.py,sha256=kUlz0kWF5bBCIf3yq2xZii57wgYT3pBWKr5wIhJLHDE,34742
|
|
13
13
|
astrbot_mcp/tools/platform_tools.py,sha256=61dTxf2T6BYh9Z5Wos3MiJLCMNEwV5RdSSCAxapWeNQ,986
|
|
14
14
|
astrbot_mcp/tools/plugin_market_tools.py,sha256=IfTeM7B0X_6gW1QhCPIQn1oa1alPhw1h6mjq1uteSD0,7349
|
|
15
15
|
astrbot_mcp/tools/session_tools.py,sha256=isEAydi3cM9IwdPjqZkD5PoFI5R_-jL_lBYBSCpxk48,21149
|
|
16
|
-
astrbot_mcp/tools/types.py,sha256=
|
|
17
|
-
astrbotmcp-0.
|
|
18
|
-
astrbotmcp-0.
|
|
19
|
-
astrbotmcp-0.
|
|
20
|
-
astrbotmcp-0.
|
|
21
|
-
astrbotmcp-0.
|
|
22
|
-
astrbotmcp-0.
|
|
16
|
+
astrbot_mcp/tools/types.py,sha256=rT0izWeUxgO_qoHW1w0xU4iwYciUT2eQ5-vxT26oQWo,1243
|
|
17
|
+
astrbotmcp-0.3.0.dist-info/licenses/LICENSE.txt,sha256=5AYBumh99nqD7WWRY18ySSOIUKrj3bkAhVAiY-k8ZRo,1061
|
|
18
|
+
astrbotmcp-0.3.0.dist-info/METADATA,sha256=m_CAU8GDbghZVTUNlYL3NN3oowul-3ttUYSjV7-eFfo,6173
|
|
19
|
+
astrbotmcp-0.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
20
|
+
astrbotmcp-0.3.0.dist-info/entry_points.txt,sha256=XmfseRwldB3CJKlViESKuZNmw37qV2B57to8EQqvd5Q,56
|
|
21
|
+
astrbotmcp-0.3.0.dist-info/top_level.txt,sha256=yi4CO_u3RImIkeQ562K9EbEc0nnKVgHQupSZ_X1GEO0,12
|
|
22
|
+
astrbotmcp-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|