@researai/deepscientist 1.5.2 → 1.5.4
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.
- package/README.md +22 -0
- package/bin/ds.js +399 -175
- package/docs/en/00_QUICK_START.md +22 -0
- package/docs/en/01_SETTINGS_REFERENCE.md +13 -4
- package/docs/en/99_ACKNOWLEDGEMENTS.md +1 -0
- package/docs/images/connectors/discord-setup-overview.svg +52 -0
- package/docs/images/connectors/feishu-setup-overview.svg +53 -0
- package/docs/images/connectors/slack-setup-overview.svg +51 -0
- package/docs/images/connectors/telegram-setup-overview.svg +55 -0
- package/docs/images/connectors/whatsapp-setup-overview.svg +51 -0
- package/docs/images/lingzhu/lingzhu-openclaw-config.svg +17 -0
- package/docs/images/lingzhu/lingzhu-platform-values.svg +16 -0
- package/docs/images/lingzhu/lingzhu-settings-overview.svg +30 -0
- package/docs/images/qq/tencent-cloud-qq-chat.png +0 -0
- package/docs/images/qq/tencent-cloud-qq-register.png +0 -0
- package/docs/images/quickstart/00-home.png +0 -0
- package/docs/images/quickstart/01-start-research.png +0 -0
- package/docs/images/quickstart/02-list-quest.png +0 -0
- package/docs/zh/00_QUICK_START.md +22 -0
- package/docs/zh/01_SETTINGS_REFERENCE.md +14 -5
- package/docs/zh/99_ACKNOWLEDGEMENTS.md +1 -0
- package/install.sh +120 -4
- package/package.json +8 -4
- package/pyproject.toml +1 -1
- package/src/deepscientist/__init__.py +1 -1
- package/src/deepscientist/artifact/service.py +1 -1
- package/src/deepscientist/bash_exec/monitor.py +23 -4
- package/src/deepscientist/bash_exec/runtime.py +3 -0
- package/src/deepscientist/bash_exec/service.py +132 -4
- package/src/deepscientist/bridges/base.py +12 -20
- package/src/deepscientist/bridges/connectors.py +2 -1
- package/src/deepscientist/channels/discord_gateway.py +27 -4
- package/src/deepscientist/channels/feishu_long_connection.py +41 -3
- package/src/deepscientist/channels/qq.py +524 -64
- package/src/deepscientist/channels/qq_gateway.py +24 -5
- package/src/deepscientist/channels/relay.py +429 -90
- package/src/deepscientist/channels/slack_socket.py +31 -7
- package/src/deepscientist/channels/telegram_polling.py +27 -3
- package/src/deepscientist/channels/whatsapp_local_session.py +32 -4
- package/src/deepscientist/cli.py +31 -1
- package/src/deepscientist/config/models.py +13 -43
- package/src/deepscientist/config/service.py +216 -157
- package/src/deepscientist/connector_profiles.py +346 -0
- package/src/deepscientist/connector_runtime.py +88 -43
- package/src/deepscientist/daemon/api/handlers.py +53 -16
- package/src/deepscientist/daemon/api/router.py +2 -2
- package/src/deepscientist/daemon/app.py +747 -228
- package/src/deepscientist/mcp/server.py +60 -7
- package/src/deepscientist/migration.py +114 -0
- package/src/deepscientist/network.py +78 -0
- package/src/deepscientist/prompts/builder.py +50 -4
- package/src/deepscientist/qq_profiles.py +186 -0
- package/src/deepscientist/quest/service.py +1 -1
- package/src/deepscientist/skills/installer.py +77 -1
- package/src/prompts/connectors/qq.md +42 -2
- package/src/prompts/system.md +162 -6
- package/src/skills/analysis-campaign/SKILL.md +19 -5
- package/src/skills/baseline/SKILL.md +66 -31
- package/src/skills/decision/SKILL.md +1 -1
- package/src/skills/experiment/SKILL.md +11 -5
- package/src/skills/finalize/SKILL.md +1 -1
- package/src/skills/idea/SKILL.md +246 -4
- package/src/skills/intake-audit/SKILL.md +1 -1
- package/src/skills/rebuttal/SKILL.md +1 -1
- package/src/skills/review/SKILL.md +1 -1
- package/src/skills/scout/SKILL.md +1 -1
- package/src/skills/write/SKILL.md +152 -2
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/{AiManusChatView-CZpg376x.js → AiManusChatView-BGLArZRn.js} +14 -37
- package/src/ui/dist/assets/{AnalysisPlugin-CtHA22g3.js → AnalysisPlugin-BgDGSigG.js} +1 -1
- package/src/ui/dist/assets/{AutoFigurePlugin-BSWmLMmF.js → AutoFigurePlugin-B65HD7L4.js} +5 -5
- package/src/ui/dist/assets/{CliPlugin-CJ7jdm_s.js → CliPlugin-CUqgsFHC.js} +17 -110
- package/src/ui/dist/assets/{CodeEditorPlugin-DhInVGFf.js → CodeEditorPlugin-CF5EdvaS.js} +8 -8
- package/src/ui/dist/assets/{CodeViewerPlugin-D1n8S9r5.js → CodeViewerPlugin-DEeU063D.js} +5 -5
- package/src/ui/dist/assets/{DocViewerPlugin-C4XM_kqk.js → DocViewerPlugin-Df-FuDlZ.js} +3 -3
- package/src/ui/dist/assets/{GitDiffViewerPlugin-W6kS9r6v.js → GitDiffViewerPlugin-RAnNaRxM.js} +1 -1
- package/src/ui/dist/assets/{ImageViewerPlugin-DPeUx_Oz.js → ImageViewerPlugin-DXJ0ZJGg.js} +5 -5
- package/src/ui/dist/assets/{LabCopilotPanel-eAelUaub.js → LabCopilotPanel-BlO-sKsj.js} +10 -10
- package/src/ui/dist/assets/{LabPlugin-BbOrBxKY.js → LabPlugin-BajPZW5v.js} +1 -1
- package/src/ui/dist/assets/{LatexPlugin-C-HhkVXY.js → LatexPlugin-F1OEol8D.js} +7 -7
- package/src/ui/dist/assets/{MarkdownViewerPlugin-BDIzIBfh.js → MarkdownViewerPlugin-MhUupqwT.js} +4 -4
- package/src/ui/dist/assets/{MarketplacePlugin-DAOJphwr.js → MarketplacePlugin-DxhIEsv0.js} +3 -3
- package/src/ui/dist/assets/{NotebookEditor-BsoMvDoU.js → NotebookEditor-q7TkhewC.js} +1 -1
- package/src/ui/dist/assets/{PdfLoader-fiC7RtHf.js → PdfLoader-B8ZOTKFc.js} +1 -1
- package/src/ui/dist/assets/{PdfMarkdownPlugin-C5OxZBFK.js → PdfMarkdownPlugin-xFPvzvWh.js} +3 -3
- package/src/ui/dist/assets/{PdfViewerPlugin-CAbxQebk.js → PdfViewerPlugin-EjEcsIB8.js} +10 -10
- package/src/ui/dist/assets/{SearchPlugin-SE33Lb9B.js → SearchPlugin-ixY-1lgW.js} +1 -1
- package/src/ui/dist/assets/{Stepper-0Av7GfV7.js → Stepper-gYFK2Pgz.js} +1 -1
- package/src/ui/dist/assets/{TextViewerPlugin-Daf2gJDI.js → TextViewerPlugin-Cym6pv_n.js} +4 -4
- package/src/ui/dist/assets/{VNCViewer-BKrMUIOX.js → VNCViewer-BPmIHcmK.js} +9 -9
- package/src/ui/dist/assets/{bibtex-JBdOEe45.js → bibtex-Btv6Wi7f.js} +1 -1
- package/src/ui/dist/assets/{code-B0TDFCZz.js → code-BlG7g85c.js} +1 -1
- package/src/ui/dist/assets/{file-content-3YtrSacz.js → file-content-DBT5OfTZ.js} +1 -1
- package/src/ui/dist/assets/{file-diff-panel-CJEg5OG1.js → file-diff-panel-BWXYzqHk.js} +1 -1
- package/src/ui/dist/assets/{file-socket-CYQYdmB1.js → file-socket-wDlx6byM.js} +1 -1
- package/src/ui/dist/assets/{file-utils-Cd1C9Ppl.js → file-utils-Ba3nJmH0.js} +1 -1
- package/src/ui/dist/assets/{image-B33ctrvC.js → image-BwtCyguk.js} +1 -1
- package/src/ui/dist/assets/{index-BNQWqmJ2.js → index-B-2scqCJ.js} +11 -11
- package/src/ui/dist/assets/{index-BVXsmS7V.js → index-Bz5AaWL7.js} +52383 -51440
- package/src/ui/dist/assets/{index-Buw_N1VQ.js → index-CfRpE209.js} +2 -2
- package/src/ui/dist/assets/{index-9CLPVeZh.js → index-DcqvKzeJ.js} +1 -1
- package/src/ui/dist/assets/{index-SwmFAld3.css → index-DpMZw8aM.css} +49 -2
- package/src/ui/dist/assets/{message-square-D0cUJ9yU.js → message-square-BnlyWVH0.js} +1 -1
- package/src/ui/dist/assets/{monaco-UZLYkp2n.js → monaco-CXe0pAVe.js} +1 -1
- package/src/ui/dist/assets/{popover-CTeiY-dK.js → popover-BCHmVhHj.js} +1 -1
- package/src/ui/dist/assets/{project-sync-Dbs01Xky.js → project-sync-Brk6kaOD.js} +1 -1
- package/src/ui/dist/assets/{sigma-CM08S-xT.js → sigma-D72eSUep.js} +1 -1
- package/src/ui/dist/assets/{tooltip-pDtzvU9p.js → tooltip-BMWd0dqX.js} +1 -1
- package/src/ui/dist/assets/{trash-YvPCP-da.js → trash-BIt_eWIS.js} +1 -1
- package/src/ui/dist/assets/{useCliAccess-Bavi74Ac.js → useCliAccess-N1hkTRrR.js} +1 -1
- package/src/ui/dist/assets/{useFileDiffOverlay-CVXY6oeg.js → useFileDiffOverlay-DPRPv6rv.js} +1 -1
- package/src/ui/dist/assets/{wrap-text-Cf4flRW7.js → wrap-text-E5-UheyP.js} +1 -1
- package/src/ui/dist/assets/{zoom-out-Hb0Z1YpT.js → zoom-out-D4TR-ZZ_.js} +1 -1
- package/src/ui/dist/index.html +2 -2
|
@@ -3,8 +3,9 @@ import os
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import Any
|
|
5
5
|
|
|
6
|
-
from ..connector_runtime import build_discovered_target, conversation_identity_key, merge_discovered_targets, parse_conversation_id
|
|
6
|
+
from ..connector_runtime import build_discovered_target, conversation_identity_key, format_conversation_id, merge_discovered_targets, parse_conversation_id
|
|
7
7
|
from ..bridges import get_connector_bridge
|
|
8
|
+
from ..qq_profiles import find_qq_profile, list_qq_profiles, merge_qq_profile_config, qq_profile_label
|
|
8
9
|
from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, utc_now, write_json
|
|
9
10
|
from .base import BaseChannel
|
|
10
11
|
|
|
@@ -25,6 +26,119 @@ class QQRelayChannel(BaseChannel):
|
|
|
25
26
|
self.bindings_path = self.root / "bindings.json"
|
|
26
27
|
self.state_path = self.root / "state.json"
|
|
27
28
|
|
|
29
|
+
def _profiles(self) -> list[dict[str, Any]]:
|
|
30
|
+
return list_qq_profiles(self.config)
|
|
31
|
+
|
|
32
|
+
def _should_encode_profile_id(self) -> bool:
|
|
33
|
+
return len(self._profiles()) > 1
|
|
34
|
+
|
|
35
|
+
def _conversation_id(self, chat_type: str, chat_id: str, *, profile_id: str | None = None) -> str:
|
|
36
|
+
return format_conversation_id(
|
|
37
|
+
"qq",
|
|
38
|
+
chat_type,
|
|
39
|
+
chat_id,
|
|
40
|
+
profile_id=profile_id if self._should_encode_profile_id() else None,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def _profile(self, profile_id: str | None) -> dict[str, Any] | None:
|
|
44
|
+
normalized = str(profile_id or "").strip() or None
|
|
45
|
+
if normalized:
|
|
46
|
+
return find_qq_profile(self.config, profile_id=normalized)
|
|
47
|
+
profiles = self._profiles()
|
|
48
|
+
if len(profiles) == 1:
|
|
49
|
+
return profiles[0]
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def _infer_profile_id_for_chat(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
chat_type: str,
|
|
56
|
+
chat_id: str,
|
|
57
|
+
profile_id: str | None = None,
|
|
58
|
+
) -> str | None:
|
|
59
|
+
normalized_profile_id = str(profile_id or "").strip() or None
|
|
60
|
+
if normalized_profile_id:
|
|
61
|
+
return normalized_profile_id
|
|
62
|
+
profiles = self._profiles()
|
|
63
|
+
if not profiles:
|
|
64
|
+
return None
|
|
65
|
+
if len(profiles) == 1:
|
|
66
|
+
return str(profiles[0].get("profile_id") or "").strip() or None
|
|
67
|
+
normalized_chat_type = str(chat_type or "").strip().lower()
|
|
68
|
+
normalized_chat_id = str(chat_id or "").strip()
|
|
69
|
+
if normalized_chat_type != "direct" or not normalized_chat_id:
|
|
70
|
+
return None
|
|
71
|
+
matched_profile_ids = [
|
|
72
|
+
str(profile.get("profile_id") or "").strip()
|
|
73
|
+
for profile in profiles
|
|
74
|
+
if str(profile.get("profile_id") or "").strip()
|
|
75
|
+
and str(profile.get("main_chat_id") or "").strip() == normalized_chat_id
|
|
76
|
+
]
|
|
77
|
+
if len(matched_profile_ids) == 1:
|
|
78
|
+
return matched_profile_ids[0]
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def _canonicalize_conversation_id(self, conversation_id: Any) -> str:
|
|
82
|
+
parsed = parse_conversation_id(conversation_id)
|
|
83
|
+
if parsed is None:
|
|
84
|
+
return str(conversation_id or "").strip()
|
|
85
|
+
resolved_profile_id = self._infer_profile_id_for_chat(
|
|
86
|
+
chat_type=parsed["chat_type"],
|
|
87
|
+
chat_id=parsed["chat_id"],
|
|
88
|
+
profile_id=str(parsed.get("profile_id") or "").strip() or None,
|
|
89
|
+
)
|
|
90
|
+
return self._conversation_id(parsed["chat_type"], parsed["chat_id"], profile_id=resolved_profile_id)
|
|
91
|
+
|
|
92
|
+
def _resolved_profile_label(self, profile_id: str | None, existing_label: str | None = None) -> str | None:
|
|
93
|
+
normalized_existing = str(existing_label or "").strip() or None
|
|
94
|
+
if normalized_existing:
|
|
95
|
+
return normalized_existing
|
|
96
|
+
normalized_profile_id = str(profile_id or "").strip() or None
|
|
97
|
+
if not normalized_profile_id:
|
|
98
|
+
return None
|
|
99
|
+
normalized_label = str(qq_profile_label(self._profile(normalized_profile_id)) or "").strip()
|
|
100
|
+
return normalized_label or None
|
|
101
|
+
|
|
102
|
+
def _normalize_conversation_entry(self, raw: dict[str, Any]) -> dict[str, Any]:
|
|
103
|
+
current = dict(raw)
|
|
104
|
+
canonical_conversation_id = self._canonicalize_conversation_id(current.get("conversation_id"))
|
|
105
|
+
parsed = parse_conversation_id(canonical_conversation_id)
|
|
106
|
+
if parsed is None:
|
|
107
|
+
return current
|
|
108
|
+
sender_name = str(current.get("sender_name") or "").strip() or None
|
|
109
|
+
resolved_profile_id = self._infer_profile_id_for_chat(
|
|
110
|
+
chat_type=parsed["chat_type"],
|
|
111
|
+
chat_id=parsed["chat_id"],
|
|
112
|
+
profile_id=str(current.get("profile_id") or parsed.get("profile_id") or "").strip() or None,
|
|
113
|
+
)
|
|
114
|
+
resolved_profile_label = self._resolved_profile_label(
|
|
115
|
+
resolved_profile_id,
|
|
116
|
+
str(current.get("profile_label") or "").strip() or None,
|
|
117
|
+
)
|
|
118
|
+
current.update(parsed)
|
|
119
|
+
current["conversation_id"] = canonical_conversation_id
|
|
120
|
+
current["label"] = self._conversation_label(
|
|
121
|
+
chat_type=parsed["chat_type"],
|
|
122
|
+
chat_id=parsed["chat_id"],
|
|
123
|
+
sender_name=sender_name,
|
|
124
|
+
)
|
|
125
|
+
if resolved_profile_id:
|
|
126
|
+
current["profile_id"] = resolved_profile_id
|
|
127
|
+
else:
|
|
128
|
+
current.pop("profile_id", None)
|
|
129
|
+
if resolved_profile_label:
|
|
130
|
+
current["profile_label"] = resolved_profile_label
|
|
131
|
+
else:
|
|
132
|
+
current.pop("profile_label", None)
|
|
133
|
+
return current
|
|
134
|
+
|
|
135
|
+
def _profile_gateway_state(self, profile_id: str | None) -> dict[str, Any]:
|
|
136
|
+
normalized = str(profile_id or "").strip()
|
|
137
|
+
if not normalized:
|
|
138
|
+
return {}
|
|
139
|
+
payload = read_json(self.root / "profiles" / normalized / "gateway.json", {})
|
|
140
|
+
return payload if isinstance(payload, dict) else {}
|
|
141
|
+
|
|
28
142
|
def send(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
29
143
|
formatted = self._format_outbound(payload)
|
|
30
144
|
record = {"sent_at": utc_now(), **formatted}
|
|
@@ -73,27 +187,61 @@ class QQRelayChannel(BaseChannel):
|
|
|
73
187
|
def status(self) -> dict[str, Any]:
|
|
74
188
|
bindings = self.list_bindings()
|
|
75
189
|
state = self._read_state()
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
str((
|
|
79
|
-
|
|
80
|
-
|
|
190
|
+
profiles = self._profiles()
|
|
191
|
+
profile_states = {
|
|
192
|
+
str(profile.get("profile_id") or "").strip(): self._profile_gateway_state(str(profile.get("profile_id") or "").strip())
|
|
193
|
+
for profile in profiles
|
|
194
|
+
}
|
|
195
|
+
profile_default_conversation_ids: list[str] = []
|
|
196
|
+
for profile in profiles:
|
|
197
|
+
profile_id = str(profile.get("profile_id") or "").strip()
|
|
198
|
+
main_chat_id = str(profile.get("main_chat_id") or "").strip()
|
|
199
|
+
if main_chat_id:
|
|
200
|
+
profile_default_conversation_ids.append(
|
|
201
|
+
self._conversation_id("direct", main_chat_id, profile_id=profile_id or None)
|
|
202
|
+
)
|
|
203
|
+
gateway_last_conversation_candidates = [
|
|
204
|
+
str((profile_states.get(str(profile.get("profile_id") or "").strip(), {}) or {}).get("last_conversation_id") or "").strip()
|
|
205
|
+
for profile in profiles
|
|
206
|
+
]
|
|
207
|
+
gateway_last_conversation_id = next((item for item in gateway_last_conversation_candidates if item), None)
|
|
208
|
+
last_conversation_id = (
|
|
209
|
+
self._canonicalize_conversation_id(str((state or {}).get("last_conversation_id") or gateway_last_conversation_id or "").strip())
|
|
210
|
+
or None
|
|
81
211
|
)
|
|
82
|
-
last_conversation_id = str((state or {}).get("last_conversation_id") or gateway_last_conversation_id or "").strip() or None
|
|
83
|
-
main_chat_id = str(self.config.get("main_chat_id") or "").strip() or None
|
|
84
212
|
default_conversation_id = (
|
|
85
|
-
|
|
86
|
-
if
|
|
213
|
+
profile_default_conversation_ids[0]
|
|
214
|
+
if profile_default_conversation_ids
|
|
87
215
|
else (last_conversation_id or (bindings[0]["conversation_id"] if bindings else None))
|
|
88
216
|
)
|
|
89
217
|
recent_conversations = self._recent_conversations(state)
|
|
218
|
+
known_targets = self._known_targets(state)
|
|
90
219
|
discovered_targets = merge_discovered_targets(
|
|
91
220
|
[
|
|
92
221
|
build_discovered_target(
|
|
93
|
-
default_conversation_id if
|
|
222
|
+
default_conversation_id if profile_default_conversation_ids else None,
|
|
94
223
|
source="saved_main_chat",
|
|
95
|
-
is_default=bool(
|
|
224
|
+
is_default=bool(profile_default_conversation_ids),
|
|
96
225
|
),
|
|
226
|
+
*[
|
|
227
|
+
{
|
|
228
|
+
**(
|
|
229
|
+
build_discovered_target(
|
|
230
|
+
item.get("conversation_id"),
|
|
231
|
+
source=str(item.get("source") or "known_target"),
|
|
232
|
+
is_default=item.get("conversation_id") == default_conversation_id,
|
|
233
|
+
label=str(item.get("label") or "").strip() or None,
|
|
234
|
+
quest_id=str(item.get("quest_id") or "").strip() or None,
|
|
235
|
+
updated_at=str(item.get("updated_at") or "").strip() or None,
|
|
236
|
+
profile_id=str(item.get("profile_id") or "").strip() or None,
|
|
237
|
+
profile_label=str(item.get("profile_label") or "").strip() or None,
|
|
238
|
+
)
|
|
239
|
+
or {}
|
|
240
|
+
),
|
|
241
|
+
"first_seen_at": str(item.get("first_seen_at") or "").strip() or None,
|
|
242
|
+
}
|
|
243
|
+
for item in known_targets
|
|
244
|
+
],
|
|
97
245
|
*[
|
|
98
246
|
build_discovered_target(
|
|
99
247
|
item.get("conversation_id"),
|
|
@@ -102,15 +250,25 @@ class QQRelayChannel(BaseChannel):
|
|
|
102
250
|
label=str(item.get("label") or "").strip() or None,
|
|
103
251
|
quest_id=str(item.get("quest_id") or "").strip() or None,
|
|
104
252
|
updated_at=str(item.get("updated_at") or "").strip() or None,
|
|
253
|
+
profile_id=str(item.get("profile_id") or "").strip() or None,
|
|
254
|
+
profile_label=str(item.get("profile_label") or "").strip() or None,
|
|
105
255
|
)
|
|
106
256
|
for item in recent_conversations
|
|
107
257
|
],
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
258
|
+
*[
|
|
259
|
+
build_discovered_target(
|
|
260
|
+
self._canonicalize_conversation_id(str((gateway_state or {}).get("last_conversation_id") or "").strip()) or None,
|
|
261
|
+
source="recent_runtime_activity",
|
|
262
|
+
is_default=self._canonicalize_conversation_id(
|
|
263
|
+
str((gateway_state or {}).get("last_conversation_id") or "").strip()
|
|
264
|
+
)
|
|
265
|
+
== default_conversation_id,
|
|
266
|
+
updated_at=str((gateway_state or {}).get("updated_at") or "").strip() or None,
|
|
267
|
+
profile_id=profile_id or None,
|
|
268
|
+
profile_label=qq_profile_label(self._profile(profile_id)),
|
|
269
|
+
)
|
|
270
|
+
for profile_id, gateway_state in profile_states.items()
|
|
271
|
+
],
|
|
114
272
|
*[
|
|
115
273
|
build_discovered_target(
|
|
116
274
|
item.get("conversation_id"),
|
|
@@ -118,23 +276,88 @@ class QQRelayChannel(BaseChannel):
|
|
|
118
276
|
is_default=item.get("conversation_id") == default_conversation_id,
|
|
119
277
|
quest_id=str(item.get("quest_id") or "").strip() or None,
|
|
120
278
|
updated_at=str(item.get("updated_at") or "").strip() or None,
|
|
279
|
+
profile_id=str(item.get("profile_id") or "").strip() or None,
|
|
280
|
+
profile_label=str(item.get("profile_label") or "").strip() or None,
|
|
121
281
|
)
|
|
122
282
|
for item in bindings
|
|
123
283
|
],
|
|
124
284
|
]
|
|
125
285
|
)
|
|
126
286
|
default_target = next((item for item in discovered_targets if item.get("is_default")), None)
|
|
127
|
-
connection_state = self._connection_state(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
287
|
+
connection_state = self._connection_state(
|
|
288
|
+
profiles=profiles,
|
|
289
|
+
profile_states=profile_states,
|
|
290
|
+
last_conversation_id=last_conversation_id,
|
|
291
|
+
)
|
|
292
|
+
auth_state = self._auth_state(profiles=profiles)
|
|
293
|
+
def matches_profile(item: dict[str, Any], profile_id: str) -> bool:
|
|
294
|
+
item_profile_id = str(item.get("profile_id") or "").strip()
|
|
295
|
+
return item_profile_id == profile_id or (not item_profile_id and len(profiles) == 1)
|
|
296
|
+
profile_snapshots = []
|
|
297
|
+
for profile in profiles:
|
|
298
|
+
profile_id = str(profile.get("profile_id") or "").strip()
|
|
299
|
+
gateway_state = profile_states.get(profile_id, {})
|
|
300
|
+
profile_main_chat_id = str(profile.get("main_chat_id") or "").strip() or None
|
|
301
|
+
profile_default_conversation_id = (
|
|
302
|
+
self._conversation_id("direct", profile_main_chat_id, profile_id=profile_id or None)
|
|
303
|
+
if profile_main_chat_id
|
|
304
|
+
else None
|
|
305
|
+
)
|
|
306
|
+
profile_targets = [
|
|
307
|
+
dict(item)
|
|
308
|
+
for item in discovered_targets
|
|
309
|
+
if matches_profile(item, profile_id)
|
|
310
|
+
]
|
|
311
|
+
profile_recent_conversations = [
|
|
312
|
+
dict(item)
|
|
313
|
+
for item in recent_conversations
|
|
314
|
+
if matches_profile(item, profile_id)
|
|
315
|
+
]
|
|
316
|
+
profile_bindings = [
|
|
317
|
+
dict(item)
|
|
318
|
+
for item in bindings
|
|
319
|
+
if matches_profile(item, profile_id)
|
|
320
|
+
]
|
|
321
|
+
profile_snapshots.append(
|
|
322
|
+
{
|
|
323
|
+
"profile_id": profile_id,
|
|
324
|
+
"label": qq_profile_label(profile),
|
|
325
|
+
"bot_name": str(profile.get("bot_name") or "").strip() or None,
|
|
326
|
+
"app_id": str(profile.get("app_id") or "").strip() or None,
|
|
327
|
+
"main_chat_id": profile_main_chat_id,
|
|
328
|
+
"default_conversation_id": profile_default_conversation_id,
|
|
329
|
+
"last_conversation_id": self._canonicalize_conversation_id(
|
|
330
|
+
str(gateway_state.get("last_conversation_id") or "").strip()
|
|
331
|
+
)
|
|
332
|
+
or None,
|
|
333
|
+
"connection_state": self._profile_connection_state(
|
|
334
|
+
profile=profile,
|
|
335
|
+
gateway_state=gateway_state,
|
|
336
|
+
last_conversation_id=self._canonicalize_conversation_id(
|
|
337
|
+
str(gateway_state.get("last_conversation_id") or "").strip()
|
|
338
|
+
)
|
|
339
|
+
or None,
|
|
340
|
+
),
|
|
341
|
+
"auth_state": self._profile_auth_state(profile),
|
|
342
|
+
"discovered_targets": profile_targets,
|
|
343
|
+
"recent_conversations": profile_recent_conversations,
|
|
344
|
+
"bindings": profile_bindings,
|
|
345
|
+
"target_count": len(profile_targets),
|
|
346
|
+
"binding_count": len(profile_bindings),
|
|
347
|
+
"last_error": gateway_state.get("last_error") if isinstance(gateway_state, dict) else None,
|
|
348
|
+
}
|
|
349
|
+
)
|
|
350
|
+
main_chat_id = str(self.config.get("main_chat_id") or "").strip() or None
|
|
351
|
+
if not main_chat_id and len(profiles) == 1:
|
|
352
|
+
main_chat_id = str(profiles[0].get("main_chat_id") or "").strip() or None
|
|
353
|
+
last_error = next(
|
|
354
|
+
(
|
|
355
|
+
str((gateway_state or {}).get("last_error") or "").strip()
|
|
356
|
+
for gateway_state in profile_states.values()
|
|
357
|
+
if str((gateway_state or {}).get("last_error") or "").strip()
|
|
358
|
+
),
|
|
359
|
+
None,
|
|
360
|
+
)
|
|
138
361
|
return {
|
|
139
362
|
"name": self.name,
|
|
140
363
|
"display_mode": self.display_mode,
|
|
@@ -146,17 +369,19 @@ class QQRelayChannel(BaseChannel):
|
|
|
146
369
|
"auth_state": auth_state,
|
|
147
370
|
"main_chat_id": main_chat_id,
|
|
148
371
|
"last_conversation_id": last_conversation_id,
|
|
149
|
-
"last_error":
|
|
372
|
+
"last_error": last_error,
|
|
150
373
|
"inbox_count": len(read_jsonl(self.inbox_path)),
|
|
151
374
|
"outbox_count": len(read_jsonl(self.outbox_path)),
|
|
152
375
|
"ignored_count": len(read_jsonl(self.ignored_path)),
|
|
153
376
|
"binding_count": len(bindings),
|
|
154
377
|
"bindings": bindings,
|
|
378
|
+
"known_targets": known_targets,
|
|
155
379
|
"recent_conversations": recent_conversations,
|
|
156
380
|
"recent_events": self._recent_events(),
|
|
157
381
|
"target_count": len(discovered_targets),
|
|
158
382
|
"default_target": default_target,
|
|
159
383
|
"discovered_targets": discovered_targets,
|
|
384
|
+
"profiles": profile_snapshots,
|
|
160
385
|
}
|
|
161
386
|
|
|
162
387
|
def ingest(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -173,6 +398,8 @@ class QQRelayChannel(BaseChannel):
|
|
|
173
398
|
sender_name=str(normalized.get("sender_name") or "").strip() or None,
|
|
174
399
|
quest_id=str(normalized.get("quest_id") or "").strip() or None,
|
|
175
400
|
message_id=str(normalized.get("message_id") or "").strip() or None,
|
|
401
|
+
profile_id=str(normalized.get("profile_id") or "").strip() or None,
|
|
402
|
+
profile_label=str(normalized.get("profile_label") or "").strip() or None,
|
|
176
403
|
)
|
|
177
404
|
return {"ok": True, "accepted": True, "normalized": normalized}
|
|
178
405
|
|
|
@@ -204,6 +431,8 @@ class QQRelayChannel(BaseChannel):
|
|
|
204
431
|
or ""
|
|
205
432
|
).strip()
|
|
206
433
|
|
|
434
|
+
profile_id = str(payload.get("profile_id") or data.get("profile_id") or "").strip() or None
|
|
435
|
+
|
|
207
436
|
group_id = str(
|
|
208
437
|
payload.get("group_id")
|
|
209
438
|
or data.get("group_id")
|
|
@@ -223,7 +452,16 @@ class QQRelayChannel(BaseChannel):
|
|
|
223
452
|
if chat_type not in {"group", "direct"}:
|
|
224
453
|
chat_type = "group" if group_id else "direct"
|
|
225
454
|
chat_key = group_id if chat_type == "group" else direct_id
|
|
226
|
-
|
|
455
|
+
profile_id = self._infer_profile_id_for_chat(chat_type=chat_type, chat_id=chat_key, profile_id=profile_id)
|
|
456
|
+
profile_config = self._profile(profile_id)
|
|
457
|
+
profile_label = self._resolved_profile_label(profile_id) or qq_profile_label(profile_config)
|
|
458
|
+
conversation_id = self._canonicalize_conversation_id(
|
|
459
|
+
str(
|
|
460
|
+
payload.get("conversation_id")
|
|
461
|
+
or data.get("conversation_id")
|
|
462
|
+
or self._conversation_id(chat_type, chat_key or "unknown", profile_id=profile_id)
|
|
463
|
+
)
|
|
464
|
+
)
|
|
227
465
|
message_id = str(payload.get("message_id") or data.get("message_id") or data.get("id") or generate_id("qqmsg"))
|
|
228
466
|
attachments = self._normalize_inbound_attachments(payload.get("attachments") or data.get("attachments"))
|
|
229
467
|
|
|
@@ -231,9 +469,9 @@ class QQRelayChannel(BaseChannel):
|
|
|
231
469
|
payload.get("mentioned")
|
|
232
470
|
or payload.get("at_bot")
|
|
233
471
|
or data.get("mentioned")
|
|
234
|
-
or self._looks_like_mention(text)
|
|
472
|
+
or self._looks_like_mention(text, profile=profile_config)
|
|
235
473
|
)
|
|
236
|
-
normalized_text = self._strip_mention_prefix(text)
|
|
474
|
+
normalized_text = self._strip_mention_prefix(text, profile=profile_config)
|
|
237
475
|
is_command = normalized_text.startswith(self.command_prefix())
|
|
238
476
|
|
|
239
477
|
if chat_type == "group" and self.config.get("require_at_in_groups", True) and not (mentioned or is_command):
|
|
@@ -246,6 +484,8 @@ class QQRelayChannel(BaseChannel):
|
|
|
246
484
|
"text": text,
|
|
247
485
|
"sender_id": sender_id,
|
|
248
486
|
"sender_name": sender_name,
|
|
487
|
+
"profile_id": profile_id,
|
|
488
|
+
"profile_label": profile_label,
|
|
249
489
|
}
|
|
250
490
|
|
|
251
491
|
return {
|
|
@@ -254,6 +494,8 @@ class QQRelayChannel(BaseChannel):
|
|
|
254
494
|
"conversation_id": conversation_id,
|
|
255
495
|
"chat_type": chat_type,
|
|
256
496
|
"chat_id": chat_key,
|
|
497
|
+
"profile_id": profile_id,
|
|
498
|
+
"profile_label": profile_label,
|
|
257
499
|
"text": normalized_text,
|
|
258
500
|
"raw_text": text,
|
|
259
501
|
"sender_id": sender_id,
|
|
@@ -268,9 +510,18 @@ class QQRelayChannel(BaseChannel):
|
|
|
268
510
|
def bind_conversation(self, conversation_id: str, quest_id: str) -> dict[str, Any]:
|
|
269
511
|
bindings = read_json(self.bindings_path, {"bindings": {}})
|
|
270
512
|
binding_map = dict(bindings.get("bindings") or {})
|
|
513
|
+
conversation_id = self._canonicalize_conversation_id(conversation_id)
|
|
514
|
+
parsed = parse_conversation_id(conversation_id)
|
|
515
|
+
resolved_profile_id = self._infer_profile_id_for_chat(
|
|
516
|
+
chat_type=str((parsed or {}).get("chat_type") or "").strip(),
|
|
517
|
+
chat_id=str((parsed or {}).get("chat_id") or "").strip(),
|
|
518
|
+
profile_id=str((parsed or {}).get("profile_id") or "").strip() or None,
|
|
519
|
+
)
|
|
271
520
|
binding_map[conversation_id] = {
|
|
272
521
|
"quest_id": quest_id,
|
|
273
522
|
"updated_at": utc_now(),
|
|
523
|
+
"profile_id": resolved_profile_id,
|
|
524
|
+
"profile_label": self._resolved_profile_label(resolved_profile_id),
|
|
274
525
|
}
|
|
275
526
|
bindings["bindings"] = binding_map
|
|
276
527
|
write_json(self.bindings_path, bindings)
|
|
@@ -279,17 +530,21 @@ class QQRelayChannel(BaseChannel):
|
|
|
279
530
|
updated_at=str(binding_map[conversation_id].get("updated_at") or utc_now()),
|
|
280
531
|
source="quest_binding",
|
|
281
532
|
quest_id=quest_id,
|
|
533
|
+
profile_id=str(binding_map[conversation_id].get("profile_id") or "").strip() or None,
|
|
534
|
+
profile_label=str(binding_map[conversation_id].get("profile_label") or "").strip() or None,
|
|
282
535
|
)
|
|
283
536
|
return binding_map[conversation_id]
|
|
284
537
|
|
|
285
538
|
def unbind_conversation(self, conversation_id: str, *, quest_id: str | None = None) -> bool:
|
|
286
539
|
bindings = read_json(self.bindings_path, {"bindings": {}})
|
|
287
540
|
binding_map = dict(bindings.get("bindings") or {})
|
|
288
|
-
|
|
541
|
+
canonical_conversation_id = self._canonicalize_conversation_id(conversation_id)
|
|
542
|
+
existing = binding_map.get(canonical_conversation_id) or binding_map.get(conversation_id)
|
|
289
543
|
if quest_id and isinstance(existing, dict) and str(existing.get("quest_id") or "").strip() != quest_id:
|
|
290
544
|
return False
|
|
291
|
-
if conversation_id not in binding_map:
|
|
545
|
+
if canonical_conversation_id not in binding_map and conversation_id not in binding_map:
|
|
292
546
|
return False
|
|
547
|
+
binding_map.pop(canonical_conversation_id, None)
|
|
293
548
|
binding_map.pop(conversation_id, None)
|
|
294
549
|
bindings["bindings"] = binding_map
|
|
295
550
|
write_json(self.bindings_path, bindings)
|
|
@@ -297,7 +552,9 @@ class QQRelayChannel(BaseChannel):
|
|
|
297
552
|
|
|
298
553
|
def resolve_bound_quest(self, conversation_id: str) -> str | None:
|
|
299
554
|
bindings = read_json(self.bindings_path, {"bindings": {}})
|
|
300
|
-
|
|
555
|
+
binding_map = bindings.get("bindings") or {}
|
|
556
|
+
canonical_conversation_id = self._canonicalize_conversation_id(conversation_id)
|
|
557
|
+
item = binding_map.get(canonical_conversation_id) or binding_map.get(conversation_id)
|
|
301
558
|
if not isinstance(item, dict):
|
|
302
559
|
return None
|
|
303
560
|
quest_id = item.get("quest_id")
|
|
@@ -305,29 +562,54 @@ class QQRelayChannel(BaseChannel):
|
|
|
305
562
|
|
|
306
563
|
def list_bindings(self) -> list[dict[str, Any]]:
|
|
307
564
|
bindings = read_json(self.bindings_path, {"bindings": {}})
|
|
308
|
-
|
|
565
|
+
merged: dict[str, dict[str, Any]] = {}
|
|
309
566
|
for conversation_id, payload in sorted((bindings.get("bindings") or {}).items()):
|
|
310
567
|
if not isinstance(payload, dict):
|
|
311
568
|
continue
|
|
312
|
-
|
|
313
|
-
|
|
569
|
+
canonical_conversation_id = self._canonicalize_conversation_id(conversation_id)
|
|
570
|
+
parsed = parse_conversation_id(canonical_conversation_id)
|
|
571
|
+
resolved_profile_id = self._infer_profile_id_for_chat(
|
|
572
|
+
chat_type=str((parsed or {}).get("chat_type") or "").strip(),
|
|
573
|
+
chat_id=str((parsed or {}).get("chat_id") or "").strip(),
|
|
574
|
+
profile_id=str((parsed or {}).get("profile_id") or payload.get("profile_id") or "").strip() or None,
|
|
575
|
+
)
|
|
576
|
+
entry = {
|
|
577
|
+
"conversation_id": canonical_conversation_id,
|
|
578
|
+
"profile_id": resolved_profile_id,
|
|
579
|
+
"profile_label": self._resolved_profile_label(
|
|
580
|
+
resolved_profile_id,
|
|
581
|
+
str(payload.get("profile_label") or "").strip() or None,
|
|
582
|
+
),
|
|
583
|
+
**payload,
|
|
584
|
+
}
|
|
585
|
+
identity = conversation_identity_key(canonical_conversation_id)
|
|
586
|
+
existing = merged.get(identity)
|
|
587
|
+
if existing is None or str(entry.get("updated_at") or "") >= str(existing.get("updated_at") or ""):
|
|
588
|
+
merged[identity] = entry
|
|
589
|
+
return sorted(
|
|
590
|
+
merged.values(),
|
|
591
|
+
key=lambda item: (str(item.get("updated_at") or ""), str(item.get("conversation_id") or "")),
|
|
592
|
+
reverse=True,
|
|
593
|
+
)
|
|
314
594
|
|
|
315
595
|
def command_prefix(self) -> str:
|
|
316
596
|
return str(self.config.get("command_prefix") or "/").strip() or "/"
|
|
317
597
|
|
|
318
|
-
def _looks_like_mention(self, text: str) -> bool:
|
|
598
|
+
def _looks_like_mention(self, text: str, *, profile: dict[str, Any] | None = None) -> bool:
|
|
319
599
|
lowered = (text or "").lower()
|
|
320
|
-
|
|
321
|
-
|
|
600
|
+
profile_config = profile or {}
|
|
601
|
+
bot_name = str(profile_config.get("bot_name") or self.config.get("bot_name") or "DeepScientist").strip().lower()
|
|
602
|
+
app_id = str(profile_config.get("app_id") or self.config.get("app_id") or "").strip()
|
|
322
603
|
candidates = [f"@{bot_name.lower()}"]
|
|
323
604
|
if app_id:
|
|
324
605
|
candidates.extend([f"<@!{app_id}>", f"<@{app_id}>"])
|
|
325
606
|
return any(candidate in lowered for candidate in candidates)
|
|
326
607
|
|
|
327
|
-
def _strip_mention_prefix(self, text: str) -> str:
|
|
608
|
+
def _strip_mention_prefix(self, text: str, *, profile: dict[str, Any] | None = None) -> str:
|
|
328
609
|
cleaned = str(text or "").strip()
|
|
329
|
-
|
|
330
|
-
|
|
610
|
+
profile_config = profile or {}
|
|
611
|
+
bot_name = str(profile_config.get("bot_name") or self.config.get("bot_name") or "DeepScientist").strip()
|
|
612
|
+
app_id = str(profile_config.get("app_id") or self.config.get("app_id") or "").strip()
|
|
331
613
|
prefixes = [f"@{bot_name}"]
|
|
332
614
|
if app_id:
|
|
333
615
|
prefixes.extend([f"<@!{app_id}>", f"<@{app_id}>"])
|
|
@@ -352,9 +634,11 @@ class QQRelayChannel(BaseChannel):
|
|
|
352
634
|
fragments.append(f"Reason: {reason}")
|
|
353
635
|
text = "\n".join(fragments)
|
|
354
636
|
attachments = self._normalize_attachments(payload.get("attachments"))
|
|
355
|
-
conversation_id = str(payload.get("conversation_id") or "").strip()
|
|
637
|
+
conversation_id = self._canonicalize_conversation_id(str(payload.get("conversation_id") or "").strip())
|
|
638
|
+
parsed = parse_conversation_id(conversation_id)
|
|
356
639
|
return {
|
|
357
640
|
"conversation_id": conversation_id,
|
|
641
|
+
"profile_id": str((parsed or {}).get("profile_id") or "").strip() or None,
|
|
358
642
|
"reply_to_message_id": payload.get("reply_to_message_id") or self._reply_to_message_id_for(conversation_id),
|
|
359
643
|
"kind": kind,
|
|
360
644
|
"text": text,
|
|
@@ -422,7 +706,17 @@ class QQRelayChannel(BaseChannel):
|
|
|
422
706
|
def _deliver(self, record: dict[str, Any]) -> dict[str, Any] | None:
|
|
423
707
|
bridge = get_connector_bridge(self.name)
|
|
424
708
|
if bridge is not None:
|
|
425
|
-
|
|
709
|
+
parsed = parse_conversation_id(record.get("conversation_id"))
|
|
710
|
+
profile_id = str((parsed or {}).get("profile_id") or record.get("profile_id") or "").strip() or None
|
|
711
|
+
profile = self._profile(profile_id)
|
|
712
|
+
if profile is None:
|
|
713
|
+
return {
|
|
714
|
+
"ok": False,
|
|
715
|
+
"queued": False,
|
|
716
|
+
"error": "QQ outbound delivery cannot resolve a configured profile for this conversation.",
|
|
717
|
+
"transport": "qq-http",
|
|
718
|
+
}
|
|
719
|
+
return bridge.deliver({**record, "profile_id": profile_id}, merge_qq_profile_config(self.config, profile))
|
|
426
720
|
return None
|
|
427
721
|
|
|
428
722
|
def _read_state(self) -> dict[str, Any]:
|
|
@@ -440,12 +734,12 @@ class QQRelayChannel(BaseChannel):
|
|
|
440
734
|
for raw in items:
|
|
441
735
|
if not isinstance(raw, dict):
|
|
442
736
|
continue
|
|
443
|
-
|
|
737
|
+
current = self._normalize_conversation_entry(raw)
|
|
738
|
+
conversation_id = str(current.get("conversation_id") or "").strip()
|
|
444
739
|
if not conversation_id:
|
|
445
740
|
continue
|
|
446
741
|
identity = conversation_identity_key(conversation_id)
|
|
447
742
|
existing = merged.get(identity)
|
|
448
|
-
current = dict(raw)
|
|
449
743
|
if existing is None:
|
|
450
744
|
merged[identity] = current
|
|
451
745
|
continue
|
|
@@ -470,6 +764,8 @@ class QQRelayChannel(BaseChannel):
|
|
|
470
764
|
sender_name: str | None = None,
|
|
471
765
|
quest_id: str | None = None,
|
|
472
766
|
message_id: str | None = None,
|
|
767
|
+
profile_id: str | None = None,
|
|
768
|
+
profile_label: str | None = None,
|
|
473
769
|
) -> None:
|
|
474
770
|
entry = self._build_recent_conversation_entry(
|
|
475
771
|
conversation_id=conversation_id,
|
|
@@ -479,6 +775,8 @@ class QQRelayChannel(BaseChannel):
|
|
|
479
775
|
sender_name=sender_name,
|
|
480
776
|
quest_id=quest_id,
|
|
481
777
|
message_id=message_id,
|
|
778
|
+
profile_id=profile_id,
|
|
779
|
+
profile_label=profile_label,
|
|
482
780
|
)
|
|
483
781
|
if entry is None:
|
|
484
782
|
return
|
|
@@ -492,6 +790,7 @@ class QQRelayChannel(BaseChannel):
|
|
|
492
790
|
"recent_conversations": [entry, *list(state.get("recent_conversations") or [])],
|
|
493
791
|
}
|
|
494
792
|
)
|
|
793
|
+
state["known_targets"] = self._upsert_known_targets(state, entry)
|
|
495
794
|
self._write_state(state)
|
|
496
795
|
|
|
497
796
|
def _build_recent_conversation_entry(
|
|
@@ -504,12 +803,25 @@ class QQRelayChannel(BaseChannel):
|
|
|
504
803
|
sender_name: str | None = None,
|
|
505
804
|
quest_id: str | None = None,
|
|
506
805
|
message_id: str | None = None,
|
|
806
|
+
profile_id: str | None = None,
|
|
807
|
+
profile_label: str | None = None,
|
|
507
808
|
) -> dict[str, Any] | None:
|
|
508
|
-
|
|
809
|
+
canonical_conversation_id = self._canonicalize_conversation_id(conversation_id)
|
|
810
|
+
parsed = parse_conversation_id(canonical_conversation_id)
|
|
509
811
|
if parsed is None:
|
|
510
812
|
return None
|
|
813
|
+
resolved_profile_id = self._infer_profile_id_for_chat(
|
|
814
|
+
chat_type=parsed["chat_type"],
|
|
815
|
+
chat_id=parsed["chat_id"],
|
|
816
|
+
profile_id=str(profile_id or parsed.get("profile_id") or "").strip() or None,
|
|
817
|
+
)
|
|
818
|
+
resolved_profile_label = self._resolved_profile_label(
|
|
819
|
+
resolved_profile_id,
|
|
820
|
+
str(profile_label or "").strip() or None,
|
|
821
|
+
)
|
|
511
822
|
payload: dict[str, Any] = {
|
|
512
823
|
**parsed,
|
|
824
|
+
"conversation_id": canonical_conversation_id,
|
|
513
825
|
"label": self._conversation_label(
|
|
514
826
|
chat_type=parsed["chat_type"],
|
|
515
827
|
chat_id=parsed["chat_id"],
|
|
@@ -518,6 +830,10 @@ class QQRelayChannel(BaseChannel):
|
|
|
518
830
|
"updated_at": updated_at,
|
|
519
831
|
"source": source,
|
|
520
832
|
}
|
|
833
|
+
if resolved_profile_id:
|
|
834
|
+
payload["profile_id"] = resolved_profile_id
|
|
835
|
+
if resolved_profile_label:
|
|
836
|
+
payload["profile_label"] = resolved_profile_label
|
|
521
837
|
if sender_id:
|
|
522
838
|
payload["sender_id"] = sender_id
|
|
523
839
|
if sender_name:
|
|
@@ -529,10 +845,88 @@ class QQRelayChannel(BaseChannel):
|
|
|
529
845
|
return payload
|
|
530
846
|
|
|
531
847
|
@staticmethod
|
|
532
|
-
def _conversation_label(
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
848
|
+
def _conversation_label(
|
|
849
|
+
*,
|
|
850
|
+
chat_type: str,
|
|
851
|
+
chat_id: str,
|
|
852
|
+
sender_name: str | None = None,
|
|
853
|
+
) -> str:
|
|
854
|
+
normalized_chat_type = str(chat_type or "").strip().lower()
|
|
855
|
+
normalized_chat_id = str(chat_id or "").strip()
|
|
856
|
+
normalized_sender_name = str(sender_name or "").strip()
|
|
857
|
+
parts: list[str] = []
|
|
858
|
+
if normalized_chat_type == "direct":
|
|
859
|
+
if normalized_sender_name and normalized_sender_name != normalized_chat_id:
|
|
860
|
+
parts.append(normalized_sender_name)
|
|
861
|
+
else:
|
|
862
|
+
parts.append("direct")
|
|
863
|
+
else:
|
|
864
|
+
parts.append(normalized_chat_type or "group")
|
|
865
|
+
parts.append(normalized_chat_id)
|
|
866
|
+
return " · ".join(item for item in parts if item)
|
|
867
|
+
|
|
868
|
+
def _known_targets(self, state: dict[str, Any]) -> list[dict[str, Any]]:
|
|
869
|
+
items = state.get("known_targets")
|
|
870
|
+
if not isinstance(items, list):
|
|
871
|
+
return []
|
|
872
|
+
merged: dict[str, dict[str, Any]] = {}
|
|
873
|
+
for raw in items:
|
|
874
|
+
if not isinstance(raw, dict):
|
|
875
|
+
continue
|
|
876
|
+
current = self._normalize_conversation_entry(raw)
|
|
877
|
+
conversation_id = str(current.get("conversation_id") or "").strip()
|
|
878
|
+
if not conversation_id:
|
|
879
|
+
continue
|
|
880
|
+
identity = conversation_identity_key(conversation_id)
|
|
881
|
+
current["conversation_id"] = conversation_id
|
|
882
|
+
existing = merged.get(identity)
|
|
883
|
+
if existing is None:
|
|
884
|
+
merged[identity] = current
|
|
885
|
+
continue
|
|
886
|
+
merged_entry = {**existing, **current}
|
|
887
|
+
merged_entry["first_seen_at"] = (
|
|
888
|
+
str(existing.get("first_seen_at") or "").strip()
|
|
889
|
+
or str(current.get("first_seen_at") or "").strip()
|
|
890
|
+
or str(existing.get("updated_at") or "").strip()
|
|
891
|
+
or str(current.get("updated_at") or "").strip()
|
|
892
|
+
)
|
|
893
|
+
if str(existing.get("updated_at") or "") > str(current.get("updated_at") or ""):
|
|
894
|
+
merged_entry["updated_at"] = existing.get("updated_at")
|
|
895
|
+
merged[identity] = merged_entry
|
|
896
|
+
return sorted(
|
|
897
|
+
merged.values(),
|
|
898
|
+
key=lambda item: (str(item.get("updated_at") or ""), str(item.get("conversation_id") or "")),
|
|
899
|
+
reverse=True,
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
def _upsert_known_targets(self, state: dict[str, Any], entry: dict[str, Any]) -> list[dict[str, Any]]:
|
|
903
|
+
identity = conversation_identity_key(entry.get("conversation_id"))
|
|
904
|
+
items = list(state.get("known_targets") or [])
|
|
905
|
+
next_items: list[dict[str, Any]] = []
|
|
906
|
+
replaced = False
|
|
907
|
+
for raw in items:
|
|
908
|
+
if not isinstance(raw, dict):
|
|
909
|
+
continue
|
|
910
|
+
if conversation_identity_key(raw.get("conversation_id")) != identity:
|
|
911
|
+
next_items.append(dict(raw))
|
|
912
|
+
continue
|
|
913
|
+
merged = {**raw, **entry}
|
|
914
|
+
merged["first_seen_at"] = (
|
|
915
|
+
str(raw.get("first_seen_at") or "").strip()
|
|
916
|
+
or str(entry.get("first_seen_at") or "").strip()
|
|
917
|
+
or str(raw.get("updated_at") or "").strip()
|
|
918
|
+
or str(entry.get("updated_at") or "").strip()
|
|
919
|
+
)
|
|
920
|
+
next_items.append(merged)
|
|
921
|
+
replaced = True
|
|
922
|
+
if not replaced:
|
|
923
|
+
next_items.append(
|
|
924
|
+
{
|
|
925
|
+
**entry,
|
|
926
|
+
"first_seen_at": str(entry.get("updated_at") or "").strip() or utc_now(),
|
|
927
|
+
}
|
|
928
|
+
)
|
|
929
|
+
return self._known_targets({"known_targets": next_items})
|
|
536
930
|
|
|
537
931
|
def _recent_events(self) -> list[dict[str, Any]]:
|
|
538
932
|
events: list[dict[str, Any]] = []
|
|
@@ -552,7 +946,7 @@ class QQRelayChannel(BaseChannel):
|
|
|
552
946
|
return events[: self.recent_event_limit]
|
|
553
947
|
|
|
554
948
|
def _reply_to_message_id_for(self, conversation_id: str) -> str | None:
|
|
555
|
-
normalized =
|
|
949
|
+
normalized = self._canonicalize_conversation_id(conversation_id)
|
|
556
950
|
if not normalized:
|
|
557
951
|
return None
|
|
558
952
|
identity = conversation_identity_key(normalized)
|
|
@@ -568,17 +962,28 @@ class QQRelayChannel(BaseChannel):
|
|
|
568
962
|
def _build_recent_event(self, event_type: str, record: dict[str, Any]) -> dict[str, Any] | None:
|
|
569
963
|
if not isinstance(record, dict):
|
|
570
964
|
return None
|
|
571
|
-
conversation_id = str(record.get("conversation_id") or "").strip()
|
|
965
|
+
conversation_id = self._canonicalize_conversation_id(str(record.get("conversation_id") or "").strip())
|
|
572
966
|
parsed = parse_conversation_id(conversation_id) if conversation_id else None
|
|
573
967
|
delivery = record.get("delivery") if isinstance(record.get("delivery"), dict) else {}
|
|
574
968
|
chat_type = str((parsed or {}).get("chat_type") or record.get("chat_type") or "direct")
|
|
575
969
|
chat_id = str((parsed or {}).get("chat_id") or record.get("chat_id") or "unknown")
|
|
970
|
+
profile_id = self._infer_profile_id_for_chat(
|
|
971
|
+
chat_type=chat_type,
|
|
972
|
+
chat_id=chat_id,
|
|
973
|
+
profile_id=str((parsed or {}).get("profile_id") or record.get("profile_id") or "").strip() or None,
|
|
974
|
+
)
|
|
975
|
+
profile_label = self._resolved_profile_label(
|
|
976
|
+
profile_id,
|
|
977
|
+
str(record.get("profile_label") or "").strip() or None,
|
|
978
|
+
)
|
|
576
979
|
return {
|
|
577
980
|
"event_type": event_type,
|
|
578
981
|
"created_at": str(record.get("received_at") or record.get("sent_at") or record.get("created_at") or record.get("updated_at") or utc_now()),
|
|
579
982
|
"conversation_id": conversation_id or None,
|
|
580
983
|
"chat_type": chat_type,
|
|
581
984
|
"chat_id": chat_id,
|
|
985
|
+
"profile_id": profile_id,
|
|
986
|
+
"profile_label": profile_label,
|
|
582
987
|
"label": self._conversation_label(
|
|
583
988
|
chat_type=chat_type,
|
|
584
989
|
chat_id=chat_id,
|
|
@@ -602,27 +1007,82 @@ class QQRelayChannel(BaseChannel):
|
|
|
602
1007
|
return normalized
|
|
603
1008
|
return f"{normalized[: max(limit - 1, 0)].rstrip()}…"
|
|
604
1009
|
|
|
605
|
-
def
|
|
1010
|
+
def _profile_connection_state(
|
|
1011
|
+
self,
|
|
1012
|
+
*,
|
|
1013
|
+
profile: dict[str, Any],
|
|
1014
|
+
gateway_state: dict[str, Any],
|
|
1015
|
+
last_conversation_id: str | None,
|
|
1016
|
+
) -> str:
|
|
1017
|
+
if not bool(self.config.get("enabled", False)) or not bool(profile.get("enabled", True)):
|
|
1018
|
+
return "disabled"
|
|
1019
|
+
if not str(profile.get("app_id") or "").strip() or not self._secret("app_secret", "app_secret_env", config=profile):
|
|
1020
|
+
return "needs_credentials"
|
|
1021
|
+
if isinstance(gateway_state, dict):
|
|
1022
|
+
if gateway_state.get("connected") is True:
|
|
1023
|
+
return "connected"
|
|
1024
|
+
if gateway_state.get("last_error"):
|
|
1025
|
+
return "error"
|
|
1026
|
+
if gateway_state.get("enabled"):
|
|
1027
|
+
return "connecting"
|
|
1028
|
+
if str(profile.get("main_chat_id") or "").strip() or last_conversation_id:
|
|
1029
|
+
return "ready"
|
|
1030
|
+
return "awaiting_first_message"
|
|
1031
|
+
|
|
1032
|
+
def _connection_state(
|
|
1033
|
+
self,
|
|
1034
|
+
*,
|
|
1035
|
+
profiles: list[dict[str, Any]],
|
|
1036
|
+
profile_states: dict[str, dict[str, Any]],
|
|
1037
|
+
last_conversation_id: str | None,
|
|
1038
|
+
) -> str:
|
|
606
1039
|
if not bool(self.config.get("enabled", False)):
|
|
607
1040
|
return "disabled"
|
|
608
|
-
if not
|
|
1041
|
+
if not profiles:
|
|
609
1042
|
return "needs_credentials"
|
|
610
|
-
|
|
1043
|
+
states = [
|
|
1044
|
+
self._profile_connection_state(
|
|
1045
|
+
profile=profile,
|
|
1046
|
+
gateway_state=profile_states.get(str(profile.get("profile_id") or "").strip(), {}),
|
|
1047
|
+
last_conversation_id=last_conversation_id,
|
|
1048
|
+
)
|
|
1049
|
+
for profile in profiles
|
|
1050
|
+
]
|
|
1051
|
+
if any(item == "connected" for item in states):
|
|
1052
|
+
return "connected"
|
|
1053
|
+
if any(item == "error" for item in states):
|
|
1054
|
+
return "error"
|
|
1055
|
+
if any(item == "connecting" for item in states):
|
|
1056
|
+
return "connecting"
|
|
1057
|
+
if any(item == "ready" for item in states):
|
|
611
1058
|
return "ready"
|
|
1059
|
+
if any(item == "awaiting_first_message" for item in states):
|
|
1060
|
+
return "awaiting_first_message"
|
|
612
1061
|
return "awaiting_first_message"
|
|
613
1062
|
|
|
614
|
-
def
|
|
1063
|
+
def _profile_auth_state(self, profile: dict[str, Any]) -> str:
|
|
1064
|
+
if not bool(self.config.get("enabled", False)) or not bool(profile.get("enabled", True)):
|
|
1065
|
+
return "disabled"
|
|
1066
|
+
if str(profile.get("app_id") or "").strip() and self._secret("app_secret", "app_secret_env", config=profile):
|
|
1067
|
+
return "ready"
|
|
1068
|
+
return "missing_credentials"
|
|
1069
|
+
|
|
1070
|
+
def _auth_state(self, *, profiles: list[dict[str, Any]]) -> str:
|
|
615
1071
|
if not bool(self.config.get("enabled", False)):
|
|
616
1072
|
return "disabled"
|
|
617
|
-
if
|
|
1073
|
+
if not profiles:
|
|
1074
|
+
return "missing_credentials"
|
|
1075
|
+
states = [self._profile_auth_state(profile) for profile in profiles]
|
|
1076
|
+
if any(item == "ready" for item in states):
|
|
618
1077
|
return "ready"
|
|
619
1078
|
return "missing_credentials"
|
|
620
1079
|
|
|
621
|
-
def _secret(self, key: str, env_key: str) -> str:
|
|
622
|
-
|
|
1080
|
+
def _secret(self, key: str, env_key: str, *, config: dict[str, Any] | None = None) -> str:
|
|
1081
|
+
payload = config or self.config
|
|
1082
|
+
direct = str(payload.get(key) or "").strip()
|
|
623
1083
|
if direct:
|
|
624
1084
|
return direct
|
|
625
|
-
env_name = str(
|
|
1085
|
+
env_name = str(payload.get(env_key) or "").strip()
|
|
626
1086
|
if not env_name:
|
|
627
1087
|
return ""
|
|
628
1088
|
return str(os.environ.get(env_name) or "").strip()
|