@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
@@ -0,0 +1,346 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ from .connector_runtime import infer_connector_transport
7
+ from .shared import slugify
8
+
9
+
10
+ PROFILEABLE_CONNECTOR_NAMES = ("telegram", "discord", "slack", "feishu", "whatsapp")
11
+
12
+
13
+ CONNECTOR_PROFILE_SPECS: dict[str, dict[str, Any]] = {
14
+ "telegram": {
15
+ "profile_id_prefix": "telegram-profile",
16
+ "shared_fields": (
17
+ "enabled",
18
+ "profiles",
19
+ "transport",
20
+ "bot_name",
21
+ "bot_token",
22
+ "bot_token_env",
23
+ "command_prefix",
24
+ "dm_policy",
25
+ "allow_from",
26
+ "group_policy",
27
+ "group_allow_from",
28
+ "groups",
29
+ "require_mention_in_groups",
30
+ "auto_bind_dm_to_active_quest",
31
+ ),
32
+ "profile_defaults": {
33
+ "profile_id": None,
34
+ "enabled": True,
35
+ "transport": "polling",
36
+ "bot_name": "DeepScientist",
37
+ "bot_token": None,
38
+ "bot_token_env": "TELEGRAM_BOT_TOKEN",
39
+ },
40
+ "profile_fields": (
41
+ "enabled",
42
+ "transport",
43
+ "bot_name",
44
+ "bot_token",
45
+ "bot_token_env",
46
+ ),
47
+ "migration_keys": ("bot_token",),
48
+ "label_fields": ("bot_name",),
49
+ "id_fields": ("bot_name",),
50
+ },
51
+ "discord": {
52
+ "profile_id_prefix": "discord-profile",
53
+ "shared_fields": (
54
+ "enabled",
55
+ "profiles",
56
+ "transport",
57
+ "bot_name",
58
+ "bot_token",
59
+ "bot_token_env",
60
+ "command_prefix",
61
+ "application_id",
62
+ "dm_policy",
63
+ "allow_from",
64
+ "group_policy",
65
+ "group_allow_from",
66
+ "groups",
67
+ "require_mention_in_groups",
68
+ "auto_bind_dm_to_active_quest",
69
+ "guild_allowlist",
70
+ ),
71
+ "profile_defaults": {
72
+ "profile_id": None,
73
+ "enabled": True,
74
+ "transport": "gateway",
75
+ "bot_name": "DeepScientist",
76
+ "bot_token": None,
77
+ "bot_token_env": "DISCORD_BOT_TOKEN",
78
+ "application_id": None,
79
+ },
80
+ "profile_fields": (
81
+ "enabled",
82
+ "transport",
83
+ "bot_name",
84
+ "bot_token",
85
+ "bot_token_env",
86
+ "application_id",
87
+ ),
88
+ "migration_keys": ("bot_token", "application_id"),
89
+ "label_fields": ("bot_name", "application_id"),
90
+ "id_fields": ("application_id", "bot_name"),
91
+ },
92
+ "slack": {
93
+ "profile_id_prefix": "slack-profile",
94
+ "shared_fields": (
95
+ "enabled",
96
+ "profiles",
97
+ "transport",
98
+ "bot_name",
99
+ "bot_token",
100
+ "bot_token_env",
101
+ "bot_user_id",
102
+ "app_token",
103
+ "app_token_env",
104
+ "command_prefix",
105
+ "dm_policy",
106
+ "allow_from",
107
+ "group_policy",
108
+ "group_allow_from",
109
+ "groups",
110
+ "require_mention_in_groups",
111
+ "auto_bind_dm_to_active_quest",
112
+ ),
113
+ "profile_defaults": {
114
+ "profile_id": None,
115
+ "enabled": True,
116
+ "transport": "socket_mode",
117
+ "bot_name": "DeepScientist",
118
+ "bot_token": None,
119
+ "bot_token_env": "SLACK_BOT_TOKEN",
120
+ "bot_user_id": None,
121
+ "app_token": None,
122
+ "app_token_env": "SLACK_APP_TOKEN",
123
+ },
124
+ "profile_fields": (
125
+ "enabled",
126
+ "transport",
127
+ "bot_name",
128
+ "bot_token",
129
+ "bot_token_env",
130
+ "bot_user_id",
131
+ "app_token",
132
+ "app_token_env",
133
+ ),
134
+ "migration_keys": ("bot_token", "bot_user_id", "app_token"),
135
+ "label_fields": ("bot_name", "bot_user_id"),
136
+ "id_fields": ("bot_user_id", "bot_name"),
137
+ },
138
+ "feishu": {
139
+ "profile_id_prefix": "feishu-profile",
140
+ "shared_fields": (
141
+ "enabled",
142
+ "profiles",
143
+ "transport",
144
+ "bot_name",
145
+ "app_id",
146
+ "app_secret",
147
+ "app_secret_env",
148
+ "api_base_url",
149
+ "command_prefix",
150
+ "dm_policy",
151
+ "allow_from",
152
+ "group_policy",
153
+ "group_allow_from",
154
+ "groups",
155
+ "require_mention_in_groups",
156
+ "auto_bind_dm_to_active_quest",
157
+ ),
158
+ "profile_defaults": {
159
+ "profile_id": None,
160
+ "enabled": True,
161
+ "transport": "long_connection",
162
+ "bot_name": "DeepScientist",
163
+ "app_id": None,
164
+ "app_secret": None,
165
+ "app_secret_env": "FEISHU_APP_SECRET",
166
+ "api_base_url": "https://open.feishu.cn",
167
+ },
168
+ "profile_fields": (
169
+ "enabled",
170
+ "transport",
171
+ "bot_name",
172
+ "app_id",
173
+ "app_secret",
174
+ "app_secret_env",
175
+ "api_base_url",
176
+ ),
177
+ "migration_keys": ("app_id", "app_secret"),
178
+ "label_fields": ("bot_name", "app_id"),
179
+ "id_fields": ("app_id", "bot_name"),
180
+ },
181
+ "whatsapp": {
182
+ "profile_id_prefix": "whatsapp-profile",
183
+ "shared_fields": (
184
+ "enabled",
185
+ "profiles",
186
+ "transport",
187
+ "bot_name",
188
+ "auth_method",
189
+ "session_dir",
190
+ "command_prefix",
191
+ "dm_policy",
192
+ "allow_from",
193
+ "group_policy",
194
+ "group_allow_from",
195
+ "groups",
196
+ "auto_bind_dm_to_active_quest",
197
+ ),
198
+ "profile_defaults": {
199
+ "profile_id": None,
200
+ "enabled": True,
201
+ "transport": "local_session",
202
+ "bot_name": "DeepScientist",
203
+ "auth_method": "qr_browser",
204
+ "session_dir": "~/.deepscientist/connectors/whatsapp",
205
+ },
206
+ "profile_fields": (
207
+ "enabled",
208
+ "transport",
209
+ "bot_name",
210
+ "auth_method",
211
+ "session_dir",
212
+ ),
213
+ "migration_keys": ("session_dir",),
214
+ "label_fields": ("bot_name",),
215
+ "id_fields": ("bot_name",),
216
+ },
217
+ }
218
+
219
+
220
+ def _as_text(value: Any) -> str | None:
221
+ text = str(value or "").strip()
222
+ return text or None
223
+
224
+
225
+ def _profile_seed(connector_name: str, raw: dict[str, Any], *, index: int) -> str:
226
+ spec = CONNECTOR_PROFILE_SPECS[connector_name]
227
+ explicit = _as_text(raw.get("profile_id"))
228
+ if explicit:
229
+ return explicit
230
+ for key in spec["id_fields"]:
231
+ candidate = _as_text(raw.get(key))
232
+ if candidate:
233
+ return f"{connector_name}-{candidate}"
234
+ return f"{spec['profile_id_prefix']}-{index:03d}"
235
+
236
+
237
+ def _unique_profile_id(seed: str, *, prefix: str, used: set[str]) -> str:
238
+ base = slugify(seed, default=prefix)
239
+ candidate = base
240
+ suffix = 2
241
+ while candidate in used:
242
+ candidate = f"{base}-{suffix}"
243
+ suffix += 1
244
+ used.add(candidate)
245
+ return candidate
246
+
247
+
248
+ def default_connector_profile(connector_name: str) -> dict[str, Any]:
249
+ spec = CONNECTOR_PROFILE_SPECS[connector_name]
250
+ return deepcopy(spec["profile_defaults"])
251
+
252
+
253
+ def connector_profile_label(connector_name: str, profile: dict[str, Any] | None) -> str:
254
+ if not isinstance(profile, dict):
255
+ return connector_name.capitalize()
256
+ spec = CONNECTOR_PROFILE_SPECS[connector_name]
257
+ parts = [_as_text(profile.get(key)) for key in spec["label_fields"]]
258
+ filtered = [item for item in parts if item]
259
+ return " · ".join(filtered) if filtered else connector_name.capitalize()
260
+
261
+
262
+ def normalize_connector_config(connector_name: str, config: dict[str, Any] | None) -> dict[str, Any]:
263
+ if connector_name not in CONNECTOR_PROFILE_SPECS:
264
+ raise KeyError(f"Connector `{connector_name}` does not support generic profile normalization.")
265
+ spec = CONNECTOR_PROFILE_SPECS[connector_name]
266
+ payload = deepcopy(config or {})
267
+ shared = {
268
+ key: deepcopy(payload.get(key))
269
+ for key in spec["shared_fields"]
270
+ if key in payload
271
+ }
272
+ shared["profiles"] = []
273
+
274
+ raw_profiles = payload.get("profiles")
275
+ items = list(raw_profiles) if isinstance(raw_profiles, list) else []
276
+ if not items and any(_as_text(payload.get(key)) for key in spec["migration_keys"]):
277
+ items = [{key: payload.get(key) for key in spec["profile_fields"]}]
278
+
279
+ used_ids: set[str] = set()
280
+ profiles: list[dict[str, Any]] = []
281
+ for index, raw in enumerate(items, start=1):
282
+ if not isinstance(raw, dict):
283
+ continue
284
+ current = default_connector_profile(connector_name)
285
+ for key in ("profile_id", *spec["profile_fields"]):
286
+ if key in raw:
287
+ current[key] = deepcopy(raw.get(key))
288
+ current["enabled"] = bool(current.get("enabled", True))
289
+ for key in spec["profile_fields"]:
290
+ if key in {"enabled", "transport", "mode"}:
291
+ continue
292
+ if isinstance(current.get(key), list):
293
+ continue
294
+ if current.get(key) is None:
295
+ continue
296
+ current[key] = _as_text(current.get(key))
297
+ current["transport"] = infer_connector_transport(connector_name, current)
298
+ if "mode" in spec["profile_defaults"] or current.get("mode") is not None:
299
+ current["mode"] = _as_text(current.get("mode")) or str(spec["profile_defaults"].get("mode") or "")
300
+ current["profile_id"] = _unique_profile_id(
301
+ _profile_seed(connector_name, current, index=index),
302
+ prefix=str(spec["profile_id_prefix"]),
303
+ used=used_ids,
304
+ )
305
+ profiles.append(current)
306
+
307
+ shared["transport"] = infer_connector_transport(connector_name, shared)
308
+ shared["profiles"] = profiles
309
+ if len(profiles) == 1:
310
+ for key in spec["profile_fields"]:
311
+ shared[key] = profiles[0].get(key)
312
+ return shared
313
+
314
+
315
+ def list_connector_profiles(connector_name: str, config: dict[str, Any] | None) -> list[dict[str, Any]]:
316
+ normalized = normalize_connector_config(connector_name, config)
317
+ profiles = normalized.get("profiles")
318
+ return [dict(item) for item in profiles] if isinstance(profiles, list) else []
319
+
320
+
321
+ def find_connector_profile(
322
+ connector_name: str,
323
+ config: dict[str, Any] | None,
324
+ *,
325
+ profile_id: str | None = None,
326
+ ) -> dict[str, Any] | None:
327
+ normalized_profile_id = _as_text(profile_id)
328
+ for profile in list_connector_profiles(connector_name, config):
329
+ if normalized_profile_id and str(profile.get("profile_id") or "").strip() == normalized_profile_id:
330
+ return profile
331
+ return None
332
+
333
+
334
+ def merge_connector_profile_config(
335
+ connector_name: str,
336
+ shared_config: dict[str, Any] | None,
337
+ profile: dict[str, Any],
338
+ ) -> dict[str, Any]:
339
+ normalized = normalize_connector_config(connector_name, shared_config)
340
+ merged = deepcopy(normalized)
341
+ merged.pop("profiles", None)
342
+ for key in CONNECTOR_PROFILE_SPECS[connector_name]["profile_fields"]:
343
+ merged[key] = profile.get(key)
344
+ merged["profile_id"] = str(profile.get("profile_id") or "").strip() or None
345
+ merged["enabled"] = bool(normalized.get("enabled", False)) and bool(profile.get("enabled", True))
346
+ return merged
@@ -3,69 +3,68 @@ from __future__ import annotations
3
3
  from typing import Any
4
4
 
5
5
 
6
+ CONNECTOR_PROFILE_CHAT_ID_SEPARATOR = "::"
7
+ QQ_PROFILE_CHAT_ID_SEPARATOR = CONNECTOR_PROFILE_CHAT_ID_SEPARATOR
8
+
9
+
6
10
  def infer_connector_transport(name: str, config: dict[str, Any] | None) -> str:
7
11
  normalized = str(name or "").strip().lower()
8
12
  payload = config or {}
9
13
  explicit = str(payload.get("transport") or "").strip().lower()
10
- if explicit:
14
+ if explicit and explicit not in {
15
+ "relay",
16
+ "legacy_webhook",
17
+ "legacy_interactions",
18
+ "legacy_events_api",
19
+ "legacy_meta_cloud",
20
+ }:
11
21
  return explicit
12
22
 
13
- relay_url = str(payload.get("relay_url") or "").strip()
14
- mode = str(payload.get("mode") or "").strip().lower()
15
- public_callback_url = str(payload.get("public_callback_url") or "").strip()
16
-
17
23
  if normalized == "qq":
18
24
  return "gateway_direct"
19
25
  if normalized == "telegram":
20
- if relay_url and mode == "relay":
21
- return "relay"
22
- if public_callback_url or str(payload.get("webhook_secret") or "").strip():
23
- return "legacy_webhook"
24
26
  return "polling"
25
27
  if normalized == "discord":
26
- if relay_url and mode == "relay":
27
- return "relay"
28
- if str(payload.get("public_interactions_url") or "").strip() or str(payload.get("public_key") or "").strip():
29
- return "legacy_interactions"
30
28
  return "gateway"
31
29
  if normalized == "slack":
32
- if relay_url and mode == "relay":
33
- return "relay"
34
30
  if str(payload.get("app_token") or "").strip():
35
31
  return "socket_mode"
36
- if public_callback_url or str(payload.get("signing_secret") or "").strip():
37
- return "legacy_events_api"
38
32
  return "socket_mode"
39
33
  if normalized == "feishu":
40
- if relay_url and mode == "relay":
41
- return "relay"
42
- if (
43
- public_callback_url
44
- or str(payload.get("verification_token") or "").strip()
45
- or str(payload.get("encrypt_key") or "").strip()
46
- ):
47
- return "legacy_webhook"
48
34
  return "long_connection"
49
35
  if normalized == "whatsapp":
50
- provider = str(payload.get("provider") or "").strip().lower()
51
- if relay_url and mode == "relay" and provider == "relay":
52
- return "relay"
53
- if (
54
- provider == "meta"
55
- or str(payload.get("access_token") or "").strip()
56
- or str(payload.get("phone_number_id") or "").strip()
57
- or str(payload.get("verify_token") or "").strip()
58
- or public_callback_url
59
- ):
60
- return "legacy_meta_cloud"
61
36
  return "local_session"
62
37
  if normalized == "lingzhu":
63
38
  return "openclaw_sse"
64
- if relay_url and mode == "relay":
65
- return "relay"
66
39
  return "direct"
67
40
 
68
41
 
42
+ def _decode_chat_id(*, connector: str, chat_id: str) -> tuple[str | None, str]:
43
+ if CONNECTOR_PROFILE_CHAT_ID_SEPARATOR not in chat_id:
44
+ return None, chat_id
45
+ profile_id, resolved_chat_id = chat_id.split(CONNECTOR_PROFILE_CHAT_ID_SEPARATOR, 1)
46
+ normalized_profile_id = str(profile_id or "").strip() or None
47
+ normalized_chat_id = str(resolved_chat_id or "").strip() or chat_id
48
+ return normalized_profile_id, normalized_chat_id
49
+
50
+
51
+ def encode_chat_id(*, connector: str, chat_id: Any, profile_id: Any = None) -> str:
52
+ normalized_chat_id = str(chat_id or "").strip()
53
+ if not normalized_chat_id:
54
+ return ""
55
+ normalized_profile_id = str(profile_id or "").strip()
56
+ if not normalized_profile_id:
57
+ return normalized_chat_id
58
+ return f"{normalized_profile_id}{CONNECTOR_PROFILE_CHAT_ID_SEPARATOR}{normalized_chat_id}"
59
+
60
+
61
+ def format_conversation_id(connector: str, chat_type: str, chat_id: Any, *, profile_id: Any = None) -> str:
62
+ normalized_connector = str(connector or "").strip().lower()
63
+ normalized_chat_type = str(chat_type or "").strip().lower()
64
+ encoded_chat_id = encode_chat_id(connector=normalized_connector, chat_id=chat_id, profile_id=profile_id)
65
+ return f"{normalized_connector}:{normalized_chat_type}:{encoded_chat_id}"
66
+
67
+
69
68
  def parse_conversation_id(conversation_id: Any) -> dict[str, str] | None:
70
69
  raw = str(conversation_id or "").strip()
71
70
  parts = raw.split(":", 2)
@@ -74,11 +73,14 @@ def parse_conversation_id(conversation_id: Any) -> dict[str, str] | None:
74
73
  connector, chat_type, chat_id = parts
75
74
  if not connector or not chat_type or not chat_id:
76
75
  return None
76
+ profile_id, resolved_chat_id = _decode_chat_id(connector=connector, chat_id=chat_id)
77
77
  return {
78
78
  "conversation_id": raw,
79
79
  "connector": connector,
80
80
  "chat_type": chat_type,
81
- "chat_id": chat_id,
81
+ "chat_id": resolved_chat_id,
82
+ "chat_id_raw": chat_id,
83
+ "profile_id": profile_id or "",
82
84
  }
83
85
 
84
86
 
@@ -91,7 +93,12 @@ def normalize_conversation_id(conversation_id: Any) -> str:
91
93
  return "local:default"
92
94
  parsed = parse_conversation_id(raw)
93
95
  if parsed is not None:
94
- return f"{parsed['connector'].lower()}:{parsed['chat_type'].lower()}:{parsed['chat_id']}"
96
+ return format_conversation_id(
97
+ parsed["connector"].lower(),
98
+ parsed["chat_type"].lower(),
99
+ parsed["chat_id"],
100
+ profile_id=parsed.get("profile_id") or None,
101
+ )
95
102
  if ":" in raw:
96
103
  return raw
97
104
  return f"{lowered}:default"
@@ -102,7 +109,17 @@ def conversation_identity_key(conversation_id: Any) -> str:
102
109
  parsed = parse_conversation_id(normalized)
103
110
  if parsed is None:
104
111
  return normalized.lower()
105
- return f"{parsed['connector'].lower()}:{parsed['chat_type'].lower()}:{parsed['chat_id'].lower()}"
112
+ profile_key = str(parsed.get("profile_id") or "").strip().lower()
113
+ return ":".join(
114
+ item
115
+ for item in (
116
+ parsed["connector"].lower(),
117
+ profile_key,
118
+ parsed["chat_type"].lower(),
119
+ parsed["chat_id"].lower(),
120
+ )
121
+ if item
122
+ )
106
123
 
107
124
 
108
125
  def build_discovered_target(
@@ -113,6 +130,8 @@ def build_discovered_target(
113
130
  label: str | None = None,
114
131
  quest_id: str | None = None,
115
132
  updated_at: str | None = None,
133
+ profile_id: str | None = None,
134
+ profile_label: str | None = None,
116
135
  ) -> dict[str, Any] | None:
117
136
  parsed = parse_conversation_id(conversation_id)
118
137
  if parsed is None:
@@ -123,6 +142,10 @@ def build_discovered_target(
123
142
  "sources": [source],
124
143
  "label": label or f"{parsed['chat_type']} · {parsed['chat_id']}",
125
144
  }
145
+ if profile_id or parsed.get("profile_id"):
146
+ target["profile_id"] = str(profile_id or parsed.get("profile_id") or "").strip() or None
147
+ if profile_label:
148
+ target["profile_label"] = profile_label
126
149
  if is_default:
127
150
  target["is_default"] = True
128
151
  if quest_id:
@@ -140,9 +163,10 @@ def merge_discovered_targets(items: list[dict[str, Any] | None]) -> list[dict[st
140
163
  conversation_id = str(item.get("conversation_id") or "").strip()
141
164
  if not conversation_id:
142
165
  continue
143
- existing = merged.get(conversation_id)
166
+ identity = conversation_identity_key(conversation_id)
167
+ existing = merged.get(identity)
144
168
  if existing is None:
145
- merged[conversation_id] = dict(item)
169
+ merged[identity] = dict(item)
146
170
  continue
147
171
  sources = list(existing.get("sources") or [])
148
172
  for source in item.get("sources") or []:
@@ -161,6 +185,27 @@ def merge_discovered_targets(items: list[dict[str, Any] | None]) -> list[dict[st
161
185
  existing["label"] = item["label"]
162
186
  if not existing.get("source") and item.get("source"):
163
187
  existing["source"] = item["source"]
188
+ for key, value in item.items():
189
+ if key in {
190
+ "conversation_id",
191
+ "connector",
192
+ "chat_type",
193
+ "chat_id",
194
+ "sources",
195
+ "is_default",
196
+ "quest_id",
197
+ "updated_at",
198
+ "label",
199
+ "source",
200
+ }:
201
+ continue
202
+ if value is None:
203
+ continue
204
+ if key not in existing or existing.get(key) in {None, ""}:
205
+ existing[key] = value
206
+ continue
207
+ if key in {"bound_quest_id", "bound_quest_title", "warning", "first_seen_at"}:
208
+ existing[key] = value
164
209
 
165
210
  return sorted(
166
211
  merged.values(),