@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.
Files changed (114) hide show
  1. package/README.md +22 -0
  2. package/bin/ds.js +399 -175
  3. package/docs/en/00_QUICK_START.md +22 -0
  4. package/docs/en/01_SETTINGS_REFERENCE.md +13 -4
  5. package/docs/en/99_ACKNOWLEDGEMENTS.md +1 -0
  6. package/docs/images/connectors/discord-setup-overview.svg +52 -0
  7. package/docs/images/connectors/feishu-setup-overview.svg +53 -0
  8. package/docs/images/connectors/slack-setup-overview.svg +51 -0
  9. package/docs/images/connectors/telegram-setup-overview.svg +55 -0
  10. package/docs/images/connectors/whatsapp-setup-overview.svg +51 -0
  11. package/docs/images/lingzhu/lingzhu-openclaw-config.svg +17 -0
  12. package/docs/images/lingzhu/lingzhu-platform-values.svg +16 -0
  13. package/docs/images/lingzhu/lingzhu-settings-overview.svg +30 -0
  14. package/docs/images/qq/tencent-cloud-qq-chat.png +0 -0
  15. package/docs/images/qq/tencent-cloud-qq-register.png +0 -0
  16. package/docs/images/quickstart/00-home.png +0 -0
  17. package/docs/images/quickstart/01-start-research.png +0 -0
  18. package/docs/images/quickstart/02-list-quest.png +0 -0
  19. package/docs/zh/00_QUICK_START.md +22 -0
  20. package/docs/zh/01_SETTINGS_REFERENCE.md +14 -5
  21. package/docs/zh/99_ACKNOWLEDGEMENTS.md +1 -0
  22. package/install.sh +120 -4
  23. package/package.json +8 -4
  24. package/pyproject.toml +1 -1
  25. package/src/deepscientist/__init__.py +1 -1
  26. package/src/deepscientist/artifact/service.py +1 -1
  27. package/src/deepscientist/bash_exec/monitor.py +23 -4
  28. package/src/deepscientist/bash_exec/runtime.py +3 -0
  29. package/src/deepscientist/bash_exec/service.py +132 -4
  30. package/src/deepscientist/bridges/base.py +12 -20
  31. package/src/deepscientist/bridges/connectors.py +2 -1
  32. package/src/deepscientist/channels/discord_gateway.py +27 -4
  33. package/src/deepscientist/channels/feishu_long_connection.py +41 -3
  34. package/src/deepscientist/channels/qq.py +524 -64
  35. package/src/deepscientist/channels/qq_gateway.py +24 -5
  36. package/src/deepscientist/channels/relay.py +429 -90
  37. package/src/deepscientist/channels/slack_socket.py +31 -7
  38. package/src/deepscientist/channels/telegram_polling.py +27 -3
  39. package/src/deepscientist/channels/whatsapp_local_session.py +32 -4
  40. package/src/deepscientist/cli.py +31 -1
  41. package/src/deepscientist/config/models.py +13 -43
  42. package/src/deepscientist/config/service.py +216 -157
  43. package/src/deepscientist/connector_profiles.py +346 -0
  44. package/src/deepscientist/connector_runtime.py +88 -43
  45. package/src/deepscientist/daemon/api/handlers.py +53 -16
  46. package/src/deepscientist/daemon/api/router.py +2 -2
  47. package/src/deepscientist/daemon/app.py +747 -228
  48. package/src/deepscientist/mcp/server.py +60 -7
  49. package/src/deepscientist/migration.py +114 -0
  50. package/src/deepscientist/network.py +78 -0
  51. package/src/deepscientist/prompts/builder.py +50 -4
  52. package/src/deepscientist/qq_profiles.py +186 -0
  53. package/src/deepscientist/quest/service.py +1 -1
  54. package/src/deepscientist/skills/installer.py +77 -1
  55. package/src/prompts/connectors/qq.md +42 -2
  56. package/src/prompts/system.md +162 -6
  57. package/src/skills/analysis-campaign/SKILL.md +19 -5
  58. package/src/skills/baseline/SKILL.md +66 -31
  59. package/src/skills/decision/SKILL.md +1 -1
  60. package/src/skills/experiment/SKILL.md +11 -5
  61. package/src/skills/finalize/SKILL.md +1 -1
  62. package/src/skills/idea/SKILL.md +246 -4
  63. package/src/skills/intake-audit/SKILL.md +1 -1
  64. package/src/skills/rebuttal/SKILL.md +1 -1
  65. package/src/skills/review/SKILL.md +1 -1
  66. package/src/skills/scout/SKILL.md +1 -1
  67. package/src/skills/write/SKILL.md +152 -2
  68. package/src/tui/package.json +1 -1
  69. package/src/ui/dist/assets/{AiManusChatView-CZpg376x.js → AiManusChatView-BGLArZRn.js} +14 -37
  70. package/src/ui/dist/assets/{AnalysisPlugin-CtHA22g3.js → AnalysisPlugin-BgDGSigG.js} +1 -1
  71. package/src/ui/dist/assets/{AutoFigurePlugin-BSWmLMmF.js → AutoFigurePlugin-B65HD7L4.js} +5 -5
  72. package/src/ui/dist/assets/{CliPlugin-CJ7jdm_s.js → CliPlugin-CUqgsFHC.js} +17 -110
  73. package/src/ui/dist/assets/{CodeEditorPlugin-DhInVGFf.js → CodeEditorPlugin-CF5EdvaS.js} +8 -8
  74. package/src/ui/dist/assets/{CodeViewerPlugin-D1n8S9r5.js → CodeViewerPlugin-DEeU063D.js} +5 -5
  75. package/src/ui/dist/assets/{DocViewerPlugin-C4XM_kqk.js → DocViewerPlugin-Df-FuDlZ.js} +3 -3
  76. package/src/ui/dist/assets/{GitDiffViewerPlugin-W6kS9r6v.js → GitDiffViewerPlugin-RAnNaRxM.js} +1 -1
  77. package/src/ui/dist/assets/{ImageViewerPlugin-DPeUx_Oz.js → ImageViewerPlugin-DXJ0ZJGg.js} +5 -5
  78. package/src/ui/dist/assets/{LabCopilotPanel-eAelUaub.js → LabCopilotPanel-BlO-sKsj.js} +10 -10
  79. package/src/ui/dist/assets/{LabPlugin-BbOrBxKY.js → LabPlugin-BajPZW5v.js} +1 -1
  80. package/src/ui/dist/assets/{LatexPlugin-C-HhkVXY.js → LatexPlugin-F1OEol8D.js} +7 -7
  81. package/src/ui/dist/assets/{MarkdownViewerPlugin-BDIzIBfh.js → MarkdownViewerPlugin-MhUupqwT.js} +4 -4
  82. package/src/ui/dist/assets/{MarketplacePlugin-DAOJphwr.js → MarketplacePlugin-DxhIEsv0.js} +3 -3
  83. package/src/ui/dist/assets/{NotebookEditor-BsoMvDoU.js → NotebookEditor-q7TkhewC.js} +1 -1
  84. package/src/ui/dist/assets/{PdfLoader-fiC7RtHf.js → PdfLoader-B8ZOTKFc.js} +1 -1
  85. package/src/ui/dist/assets/{PdfMarkdownPlugin-C5OxZBFK.js → PdfMarkdownPlugin-xFPvzvWh.js} +3 -3
  86. package/src/ui/dist/assets/{PdfViewerPlugin-CAbxQebk.js → PdfViewerPlugin-EjEcsIB8.js} +10 -10
  87. package/src/ui/dist/assets/{SearchPlugin-SE33Lb9B.js → SearchPlugin-ixY-1lgW.js} +1 -1
  88. package/src/ui/dist/assets/{Stepper-0Av7GfV7.js → Stepper-gYFK2Pgz.js} +1 -1
  89. package/src/ui/dist/assets/{TextViewerPlugin-Daf2gJDI.js → TextViewerPlugin-Cym6pv_n.js} +4 -4
  90. package/src/ui/dist/assets/{VNCViewer-BKrMUIOX.js → VNCViewer-BPmIHcmK.js} +9 -9
  91. package/src/ui/dist/assets/{bibtex-JBdOEe45.js → bibtex-Btv6Wi7f.js} +1 -1
  92. package/src/ui/dist/assets/{code-B0TDFCZz.js → code-BlG7g85c.js} +1 -1
  93. package/src/ui/dist/assets/{file-content-3YtrSacz.js → file-content-DBT5OfTZ.js} +1 -1
  94. package/src/ui/dist/assets/{file-diff-panel-CJEg5OG1.js → file-diff-panel-BWXYzqHk.js} +1 -1
  95. package/src/ui/dist/assets/{file-socket-CYQYdmB1.js → file-socket-wDlx6byM.js} +1 -1
  96. package/src/ui/dist/assets/{file-utils-Cd1C9Ppl.js → file-utils-Ba3nJmH0.js} +1 -1
  97. package/src/ui/dist/assets/{image-B33ctrvC.js → image-BwtCyguk.js} +1 -1
  98. package/src/ui/dist/assets/{index-BNQWqmJ2.js → index-B-2scqCJ.js} +11 -11
  99. package/src/ui/dist/assets/{index-BVXsmS7V.js → index-Bz5AaWL7.js} +52383 -51440
  100. package/src/ui/dist/assets/{index-Buw_N1VQ.js → index-CfRpE209.js} +2 -2
  101. package/src/ui/dist/assets/{index-9CLPVeZh.js → index-DcqvKzeJ.js} +1 -1
  102. package/src/ui/dist/assets/{index-SwmFAld3.css → index-DpMZw8aM.css} +49 -2
  103. package/src/ui/dist/assets/{message-square-D0cUJ9yU.js → message-square-BnlyWVH0.js} +1 -1
  104. package/src/ui/dist/assets/{monaco-UZLYkp2n.js → monaco-CXe0pAVe.js} +1 -1
  105. package/src/ui/dist/assets/{popover-CTeiY-dK.js → popover-BCHmVhHj.js} +1 -1
  106. package/src/ui/dist/assets/{project-sync-Dbs01Xky.js → project-sync-Brk6kaOD.js} +1 -1
  107. package/src/ui/dist/assets/{sigma-CM08S-xT.js → sigma-D72eSUep.js} +1 -1
  108. package/src/ui/dist/assets/{tooltip-pDtzvU9p.js → tooltip-BMWd0dqX.js} +1 -1
  109. package/src/ui/dist/assets/{trash-YvPCP-da.js → trash-BIt_eWIS.js} +1 -1
  110. package/src/ui/dist/assets/{useCliAccess-Bavi74Ac.js → useCliAccess-N1hkTRrR.js} +1 -1
  111. package/src/ui/dist/assets/{useFileDiffOverlay-CVXY6oeg.js → useFileDiffOverlay-DPRPv6rv.js} +1 -1
  112. package/src/ui/dist/assets/{wrap-text-Cf4flRW7.js → wrap-text-E5-UheyP.js} +1 -1
  113. package/src/ui/dist/assets/{zoom-out-Hb0Z1YpT.js → zoom-out-D4TR-ZZ_.js} +1 -1
  114. 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
- gateway_state = read_json(self.root / "gateway.json", {})
77
- gateway_last_conversation_id = (
78
- str((gateway_state or {}).get("last_conversation_id") or "").strip() or None
79
- if isinstance(gateway_state, dict)
80
- else None
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
- f"qq:direct:{main_chat_id}"
86
- if main_chat_id
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 main_chat_id else None,
222
+ default_conversation_id if profile_default_conversation_ids else None,
94
223
  source="saved_main_chat",
95
- is_default=bool(main_chat_id),
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
- build_discovered_target(
109
- gateway_last_conversation_id,
110
- source="recent_runtime_activity",
111
- is_default=gateway_last_conversation_id == default_conversation_id,
112
- updated_at=str((gateway_state or {}).get("updated_at") or "").strip() or None,
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(main_chat_id=main_chat_id, last_conversation_id=last_conversation_id)
128
- auth_state = self._auth_state()
129
- if bool(self.config.get("enabled", False)) and isinstance(gateway_state, dict):
130
- if gateway_state.get("connected") is True:
131
- connection_state = "connected"
132
- elif gateway_state.get("last_error"):
133
- connection_state = "error"
134
- elif gateway_state.get("enabled") and connection_state not in {"disabled", "needs_credentials"}:
135
- connection_state = "connecting"
136
- if gateway_state.get("last_error") and auth_state == "ready":
137
- auth_state = "ready"
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": gateway_state.get("last_error") if isinstance(gateway_state, dict) else None,
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
- conversation_id = str(payload.get("conversation_id") or data.get("conversation_id") or f"qq:{chat_type}:{chat_key or 'unknown'}")
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
- existing = binding_map.get(conversation_id)
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
- item = (bindings.get("bindings") or {}).get(conversation_id)
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
- items: list[dict[str, Any]] = []
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
- items.append({"conversation_id": conversation_id, **payload})
313
- return items
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
- bot_name = str(self.config.get("bot_name") or "DeepScientist").strip().lower()
321
- app_id = str(self.config.get("app_id") or "").strip()
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
- bot_name = str(self.config.get("bot_name") or "DeepScientist").strip()
330
- app_id = str(self.config.get("app_id") or "").strip()
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
- return bridge.deliver(record, self.config)
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
- conversation_id = str(raw.get("conversation_id") or "").strip()
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
- parsed = parse_conversation_id(conversation_id)
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(*, chat_type: str, chat_id: str, sender_name: str | None = None) -> str:
533
- if sender_name and str(chat_type or "").strip().lower() == "direct":
534
- return f"{sender_name} · {chat_id}"
535
- return f"{chat_type} · {chat_id}"
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 = str(conversation_id or "").strip()
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 _connection_state(self, *, main_chat_id: str | None, last_conversation_id: str | None) -> str:
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 str(self.config.get("app_id") or "").strip() or not self._secret("app_secret", "app_secret_env"):
1041
+ if not profiles:
609
1042
  return "needs_credentials"
610
- if main_chat_id or last_conversation_id:
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 _auth_state(self) -> str:
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 str(self.config.get("app_id") or "").strip() and self._secret("app_secret", "app_secret_env"):
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
- direct = str(self.config.get(key) or "").strip()
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(self.config.get(env_key) or "").strip()
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()