@researai/deepscientist 1.5.1 → 1.5.3

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 (116) hide show
  1. package/README.md +69 -1
  2. package/bin/ds.js +2239 -153
  3. package/docs/en/00_QUICK_START.md +60 -20
  4. package/docs/en/01_SETTINGS_REFERENCE.md +20 -20
  5. package/docs/en/02_START_RESEARCH_GUIDE.md +11 -11
  6. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +10 -10
  7. package/docs/en/05_TUI_GUIDE.md +1 -1
  8. package/docs/en/09_DOCTOR.md +48 -4
  9. package/docs/en/90_ARCHITECTURE.md +4 -2
  10. package/docs/zh/00_QUICK_START.md +60 -20
  11. package/docs/zh/01_SETTINGS_REFERENCE.md +21 -21
  12. package/docs/zh/02_START_RESEARCH_GUIDE.md +19 -19
  13. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +10 -10
  14. package/docs/zh/05_TUI_GUIDE.md +1 -1
  15. package/docs/zh/09_DOCTOR.md +46 -4
  16. package/install.sh +125 -8
  17. package/package.json +2 -1
  18. package/pyproject.toml +1 -1
  19. package/src/deepscientist/__init__.py +6 -1
  20. package/src/deepscientist/artifact/service.py +553 -26
  21. package/src/deepscientist/bash_exec/monitor.py +23 -4
  22. package/src/deepscientist/bash_exec/runtime.py +3 -0
  23. package/src/deepscientist/bash_exec/service.py +132 -4
  24. package/src/deepscientist/bridges/base.py +10 -19
  25. package/src/deepscientist/channels/discord_gateway.py +25 -2
  26. package/src/deepscientist/channels/feishu_long_connection.py +41 -3
  27. package/src/deepscientist/channels/qq.py +524 -64
  28. package/src/deepscientist/channels/qq_gateway.py +22 -3
  29. package/src/deepscientist/channels/relay.py +429 -90
  30. package/src/deepscientist/channels/slack_socket.py +29 -5
  31. package/src/deepscientist/channels/telegram_polling.py +25 -2
  32. package/src/deepscientist/channels/whatsapp_local_session.py +32 -4
  33. package/src/deepscientist/cli.py +27 -0
  34. package/src/deepscientist/config/models.py +6 -40
  35. package/src/deepscientist/config/service.py +165 -156
  36. package/src/deepscientist/connector_profiles.py +346 -0
  37. package/src/deepscientist/connector_runtime.py +88 -43
  38. package/src/deepscientist/daemon/api/handlers.py +65 -11
  39. package/src/deepscientist/daemon/api/router.py +4 -2
  40. package/src/deepscientist/daemon/app.py +772 -219
  41. package/src/deepscientist/doctor.py +69 -2
  42. package/src/deepscientist/gitops/diff.py +3 -0
  43. package/src/deepscientist/home.py +25 -2
  44. package/src/deepscientist/mcp/context.py +3 -1
  45. package/src/deepscientist/mcp/server.py +66 -7
  46. package/src/deepscientist/migration.py +114 -0
  47. package/src/deepscientist/prompts/builder.py +71 -3
  48. package/src/deepscientist/qq_profiles.py +186 -0
  49. package/src/deepscientist/quest/layout.py +1 -0
  50. package/src/deepscientist/quest/service.py +70 -12
  51. package/src/deepscientist/quest/stage_views.py +46 -0
  52. package/src/deepscientist/runners/codex.py +2 -0
  53. package/src/deepscientist/shared.py +44 -17
  54. package/src/prompts/connectors/lingzhu.md +3 -0
  55. package/src/prompts/connectors/qq.md +42 -2
  56. package/src/prompts/system.md +123 -10
  57. package/src/skills/analysis-campaign/SKILL.md +35 -6
  58. package/src/skills/baseline/SKILL.md +73 -32
  59. package/src/skills/decision/SKILL.md +4 -3
  60. package/src/skills/experiment/SKILL.md +28 -6
  61. package/src/skills/finalize/SKILL.md +5 -2
  62. package/src/skills/idea/SKILL.md +2 -2
  63. package/src/skills/intake-audit/SKILL.md +2 -2
  64. package/src/skills/rebuttal/SKILL.md +4 -2
  65. package/src/skills/review/SKILL.md +4 -2
  66. package/src/skills/scout/SKILL.md +2 -2
  67. package/src/skills/write/SKILL.md +2 -2
  68. package/src/tui/package.json +1 -1
  69. package/src/ui/dist/assets/{AiManusChatView-w5lF2Ttt.js → AiManusChatView-qzChi9uh.js} +67 -94
  70. package/src/ui/dist/assets/{AnalysisPlugin-DJOED79I.js → AnalysisPlugin-CcC_-UqN.js} +1 -1
  71. package/src/ui/dist/assets/{AutoFigurePlugin-DaG61Y0M.js → AutoFigurePlugin-DD8LkJLe.js} +5 -5
  72. package/src/ui/dist/assets/{CliPlugin-CV4LqUB_.js → CliPlugin-DJJFfVmW.js} +17 -110
  73. package/src/ui/dist/assets/{CodeEditorPlugin-DylfAea4.js → CodeEditorPlugin-CrjkHNLh.js} +8 -8
  74. package/src/ui/dist/assets/{CodeViewerPlugin-F7saY0LM.js → CodeViewerPlugin-obnD6G5R.js} +5 -5
  75. package/src/ui/dist/assets/{DocViewerPlugin-COP0c7jf.js → DocViewerPlugin-DB9SUQVd.js} +3 -3
  76. package/src/ui/dist/assets/{GitDiffViewerPlugin-CAS05pT9.js → GitDiffViewerPlugin-DZLlNlD2.js} +1 -1
  77. package/src/ui/dist/assets/{ImageViewerPlugin-Bco1CN_w.js → ImageViewerPlugin-BGwfDZ0Y.js} +5 -5
  78. package/src/ui/dist/assets/{LabCopilotPanel-CvMlCD99.js → LabCopilotPanel-dfLptQcR.js} +10 -10
  79. package/src/ui/dist/assets/{LabPlugin-BYankkE4.js → LabPlugin-CeGjAl3A.js} +1 -1
  80. package/src/ui/dist/assets/{LatexPlugin-LDSMR-t-.js → LatexPlugin-BBJ7kd1V.js} +7 -7
  81. package/src/ui/dist/assets/{MarkdownViewerPlugin-B7o80jgm.js → MarkdownViewerPlugin-DKZi7BcB.js} +4 -4
  82. package/src/ui/dist/assets/{MarketplacePlugin-CM6ZOcpC.js → MarketplacePlugin-C_k-9jD0.js} +3 -3
  83. package/src/ui/dist/assets/{NotebookEditor-Dc61cXmK.js → NotebookEditor-4R88_BMO.js} +1 -1
  84. package/src/ui/dist/assets/{PdfLoader-DWowuQwx.js → PdfLoader-DwEFQLrw.js} +1 -1
  85. package/src/ui/dist/assets/{PdfMarkdownPlugin-BsJM1q_a.js → PdfMarkdownPlugin-D-jdsqF8.js} +3 -3
  86. package/src/ui/dist/assets/{PdfViewerPlugin-DB2eEEFQ.js → PdfViewerPlugin-CmeBGDY0.js} +10 -10
  87. package/src/ui/dist/assets/{SearchPlugin-CraThSvt.js → SearchPlugin-Dlz2WKJ4.js} +1 -1
  88. package/src/ui/dist/assets/{Stepper-CgocRTPq.js → Stepper-ClOgzWM3.js} +1 -1
  89. package/src/ui/dist/assets/{TextViewerPlugin-B1JGhKtd.js → TextViewerPlugin-DDQWxibk.js} +4 -4
  90. package/src/ui/dist/assets/{VNCViewer-CclFC7FM.js → VNCViewer-CJXT0Nm8.js} +9 -9
  91. package/src/ui/dist/assets/{bibtex-D3IKsMl7.js → bibtex-DLr4Rtk4.js} +1 -1
  92. package/src/ui/dist/assets/{code-BP37Xx0p.js → code-DgKK408Y.js} +1 -1
  93. package/src/ui/dist/assets/{file-content-BAJSu-9r.js → file-content-6HBqQnvQ.js} +1 -1
  94. package/src/ui/dist/assets/{file-diff-panel-DUGeCTuy.js → file-diff-panel-Dhu0TbBM.js} +1 -1
  95. package/src/ui/dist/assets/{file-socket-CXc1Ojf7.js → file-socket-CP3iwVZG.js} +1 -1
  96. package/src/ui/dist/assets/{file-utils-2J21jt7M.js → file-utils-BsS-Aw68.js} +1 -1
  97. package/src/ui/dist/assets/{image-CMMmgvcn.js → image-ByeK-Zcv.js} +1 -1
  98. package/src/ui/dist/assets/{index-DmwmJmbW.js → index-BLjo5--a.js} +33610 -31016
  99. package/src/ui/dist/assets/{index-CWgMgpow.js → index-BdsE0uRz.js} +11 -11
  100. package/src/ui/dist/assets/{index-s7aHnNQ4.js → index-C-eX-N6A.js} +1 -1
  101. package/src/ui/dist/assets/{index-KGt-z-dD.css → index-CuQhlrR-.css} +2747 -2
  102. package/src/ui/dist/assets/{index-BaVumsQT.js → index-DyremSIv.js} +2 -2
  103. package/src/ui/dist/assets/{message-square-CQRfX0Am.js → message-square-DnagiLnc.js} +1 -1
  104. package/src/ui/dist/assets/{monaco-B4TbdsrF.js → monaco-4kBFeprs.js} +1 -1
  105. package/src/ui/dist/assets/{popover-B8Rokodk.js → popover-hRCXZzs2.js} +1 -1
  106. package/src/ui/dist/assets/{project-sync-D_i96KH4.js → project-sync-O_85YuP6.js} +1 -1
  107. package/src/ui/dist/assets/{sigma-D12PnzCN.js → sigma-DvKopSnL.js} +1 -1
  108. package/src/ui/dist/assets/{tooltip-B6YrI4aJ.js → tooltip-BmlPc6kc.js} +1 -1
  109. package/src/ui/dist/assets/{trash-Bc8jGp0V.js → trash-n-UvdZFR.js} +1 -1
  110. package/src/ui/dist/assets/{useCliAccess-mXVCYSZ-.js → useCliAccess-WDd3_wIh.js} +1 -1
  111. package/src/ui/dist/assets/{useFileDiffOverlay-Bg6b9H9K.js → useFileDiffOverlay-rXLIL2NF.js} +1 -1
  112. package/src/ui/dist/assets/{wrap-text-Drh5GEnL.js → wrap-text-qIYQ4a_W.js} +1 -1
  113. package/src/ui/dist/assets/{zoom-out-CJj9DZLn.js → zoom-out-fZXCEFsy.js} +1 -1
  114. package/src/ui/dist/index.html +2 -2
  115. package/uv.lock +1155 -0
  116. package/src/ui/dist/assets/LabPlugin-D9jVIo0A.css +0 -2698
@@ -8,6 +8,7 @@ from pathlib import Path
8
8
  from urllib.error import URLError
9
9
  from urllib.request import Request, urlopen
10
10
 
11
+ from ..connector_profiles import PROFILEABLE_CONNECTOR_NAMES, normalize_connector_config
11
12
  from ..connector_runtime import infer_connector_transport
12
13
  from ..home import repo_root
13
14
  from ..lingzhu_support import (
@@ -22,6 +23,12 @@ from ..lingzhu_support import (
22
23
  lingzhu_sse_url,
23
24
  lingzhu_supported_commands,
24
25
  )
26
+ from ..qq_profiles import (
27
+ find_qq_profile,
28
+ list_qq_profiles,
29
+ normalize_qq_connector_config,
30
+ qq_profile_label,
31
+ )
25
32
  from ..shared import read_json, read_text, read_yaml, resolve_runner_binary, run_command, sha256_text, utc_now, which, write_text, write_yaml
26
33
  from .models import (
27
34
  CONFIG_NAMES,
@@ -125,27 +132,42 @@ class ConfigManager:
125
132
  def save_named_payload(self, name: str, payload: dict) -> dict:
126
133
  return self.save_named_text(name, self.render_named_payload(name, payload))
127
134
 
128
- def bind_qq_main_chat(self, *, chat_id: str) -> dict:
135
+ def bind_qq_main_chat(self, *, profile_id: str | None = None, chat_id: str) -> dict:
129
136
  normalized_chat_id = str(chat_id or "").strip()
130
137
  if not normalized_chat_id:
131
138
  return {"ok": False, "saved": False, "message": "QQ main chat id is empty."}
132
139
  connectors = self.load_named_normalized("connectors")
133
140
  qq = connectors.get("qq") if isinstance(connectors.get("qq"), dict) else {}
134
- configured = str((qq or {}).get("main_chat_id") or "").strip()
141
+ profiles = list_qq_profiles(qq)
142
+ if not profiles:
143
+ return {"ok": False, "saved": False, "message": "QQ profile is not configured yet."}
144
+ resolved_profile = find_qq_profile(qq, profile_id=profile_id)
145
+ if resolved_profile is None and len(profiles) == 1:
146
+ resolved_profile = profiles[0]
147
+ if resolved_profile is None:
148
+ return {"ok": False, "saved": False, "message": "Unable to determine which QQ profile should save this OpenID."}
149
+ configured = str((resolved_profile or {}).get("main_chat_id") or "").strip()
135
150
  if configured:
136
151
  return {
137
152
  "ok": True,
138
153
  "saved": False,
139
154
  "chat_id": configured,
140
155
  "already_configured": True,
156
+ "profile_id": resolved_profile.get("profile_id"),
141
157
  }
142
- qq["main_chat_id"] = normalized_chat_id
158
+ for item in profiles:
159
+ if str(item.get("profile_id") or "").strip() == str(resolved_profile.get("profile_id") or "").strip():
160
+ item["main_chat_id"] = normalized_chat_id
161
+ qq["profiles"] = profiles
162
+ qq = normalize_qq_connector_config(qq)
143
163
  connectors["qq"] = qq
144
164
  result = self.save_named_payload("connectors", connectors)
145
165
  return {
146
166
  "ok": bool(result.get("ok")),
147
167
  "saved": bool(result.get("ok")),
148
168
  "chat_id": normalized_chat_id,
169
+ "profile_id": resolved_profile.get("profile_id"),
170
+ "profile_label": qq_profile_label(resolved_profile),
149
171
  "saved_at": result.get("saved_at"),
150
172
  "errors": result.get("errors") or [],
151
173
  "warnings": result.get("warnings") or [],
@@ -231,8 +253,7 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
231
253
  - connect Telegram, Discord, Slack, Feishu, WhatsApp, or QQ
232
254
  - choose one preferred connector for proactive artifact updates
233
255
  - decide whether artifact updates fan out or stay focused
234
- - prefer local or gateway-based transports that do not require public callbacks
235
- - keep legacy webhook or relay settings only as a fallback path
256
+ - use the built-in direct runtime for each connector
236
257
  - keep all secrets in one visible place
237
258
 
238
259
  ## Recommended order
@@ -252,22 +273,12 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
252
273
  - WhatsApp: `local_session`
253
274
  - QQ: `gateway_direct`
254
275
 
255
- ## Legacy fallback routes
256
-
257
- - `POST /api/bridges/telegram/webhook`
258
- - `POST /api/bridges/slack/webhook`
259
- - `POST /api/bridges/feishu/webhook`
260
- - `GET /api/bridges/whatsapp/webhook`
261
- - `POST /api/bridges/whatsapp/webhook`
262
- - `POST /api/bridges/discord/webhook`
263
-
264
276
  ## Practical notes
265
277
 
266
278
  ### Telegram
267
279
 
268
280
  - set `bot_token`
269
281
  - prefer `transport: polling`
270
- - use webhook fields only in the legacy section
271
282
  - readiness test uses `getMe`
272
283
 
273
284
  ### Slack
@@ -281,32 +292,30 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
281
292
 
282
293
  - set `bot_token`
283
294
  - prefer `transport: gateway`
284
- - keep interaction callback fields only for legacy compatibility
285
295
 
286
296
  ### Feishu
287
297
 
288
298
  - set `app_id`
289
299
  - set `app_secret`
290
300
  - prefer `transport: long_connection`
291
- - keep verification or encrypt keys only for legacy webhook fallback
292
301
  - test checks whether tenant token exchange succeeds
293
302
 
294
303
  ### WhatsApp
295
304
 
296
305
  - prefer `transport: local_session`
297
306
  - the local-session path is designed to avoid public callbacks
298
- - Meta Cloud API fields stay in the legacy section for fallback use
299
- - current live test can still probe Meta credentials when configured
307
+ - keep one writable `session_dir` for the local auth state
300
308
 
301
309
  ### QQ
302
310
 
303
311
  - QQ only uses the built-in gateway direct path with `app_id` + `app_secret`
304
- - save credentials first, then ask the user to send one private QQ message to the bot
305
- - the daemon auto-detects that user's `openid` and saves it into `main_chat_id`
312
+ - each QQ bot is stored as one item under `qq.profiles`
313
+ - save one QQ bot profile first, then ask the user to send one private QQ message to that specific bot
314
+ - the daemon auto-detects that user's `openid` and saves it into that profile's `main_chat_id`
306
315
  - private QQ chats can then auto-follow the latest quest by default, unless disabled in settings
307
316
  - readiness test exchanges `access_token` and probes `/gateway`
308
317
  - active send targets use QQ user `openid` or group `group_openid`
309
- - the settings page also surfaces recently discovered targets from runtime activity
318
+ - the settings page also surfaces recently discovered targets from runtime activity, grouped by QQ bot profile
310
319
  - milestone delivery toggles default to enabled; adjust them only if you want less outbound push
311
320
  - the recommended first-run path is: save credentials -> send one QQ private message -> confirm `Detected OpenID` -> run a probe
312
321
 
@@ -643,17 +652,39 @@ Use **Test** when the file exposes runtime dependencies.
643
652
  enabled_connectors.append(str(name))
644
653
 
645
654
  if name == "qq":
646
- if not str(config.get("app_id") or "").strip():
655
+ profiles = list_qq_profiles(config)
656
+ if not profiles:
657
+ errors.append("qq: requires at least one configured profile under `qq.profiles`.")
658
+ continue
659
+ legacy_missing_app_id = False
660
+ legacy_missing_secret = False
661
+ seen_profile_ids: set[str] = set()
662
+ seen_app_ids: set[str] = set()
663
+ for profile in profiles:
664
+ profile_id = str(profile.get("profile_id") or "").strip() or "unknown"
665
+ app_id = str(profile.get("app_id") or "").strip()
666
+ if not profile_id:
667
+ errors.append("qq: every profile requires a stable `profile_id`.")
668
+ elif profile_id in seen_profile_ids:
669
+ errors.append(f"qq: duplicate profile_id `{profile_id}`.")
670
+ else:
671
+ seen_profile_ids.add(profile_id)
672
+ if not app_id:
673
+ legacy_missing_app_id = True
674
+ errors.append(f"qq[{profile_id}]: requires `app_id`.")
675
+ elif app_id in seen_app_ids:
676
+ errors.append(f"qq: duplicate app_id `{app_id}` across profiles.")
677
+ else:
678
+ seen_app_ids.add(app_id)
679
+ if not self._has_secret(profile, "app_secret", "app_secret_env"):
680
+ legacy_missing_secret = True
681
+ errors.append(f"qq[{profile_id}]: requires `app_secret` or `app_secret_env`.")
682
+ if len(profiles) == 1 and legacy_missing_app_id:
647
683
  errors.append("qq: requires `app_id` for the built-in gateway direct connector.")
648
- if not self._has_secret(config, "app_secret", "app_secret_env"):
684
+ if len(profiles) == 1 and legacy_missing_secret:
649
685
  errors.append("qq: requires `app_secret` or `app_secret_env` for the built-in gateway direct connector.")
650
686
  continue
651
687
  transport = infer_connector_transport(name, config)
652
- relay_url = str(config.get("relay_url") or "").strip()
653
- public_callback_url = str(config.get("public_callback_url") or "").strip()
654
-
655
- if transport == "relay" and not relay_url:
656
- errors.append(f"{name}: `transport: relay` requires `relay_url`.")
657
688
 
658
689
  policy_validation = self._validate_access_policies(name, config)
659
690
  warnings.extend(policy_validation["warnings"])
@@ -661,75 +692,41 @@ Use **Test** when the file exposes runtime dependencies.
661
692
 
662
693
  if name == "telegram":
663
694
  has_token = self._has_secret(config, "bot_token", "bot_token_env")
664
- if transport in {"polling", "legacy_webhook"} and not has_token:
665
- errors.append("telegram: `transport: polling` or `legacy_webhook` requires `bot_token` or `bot_token_env`.")
666
- elif transport == "relay" and not relay_url:
667
- errors.append("telegram: `transport: relay` requires `relay_url`.")
668
- elif not has_token and not relay_url:
669
- warnings.append("telegram: set `bot_token` or `bot_token_env` so the connector can authenticate with the Telegram Bot API.")
695
+ if transport != "polling":
696
+ errors.append("telegram: `transport` must stay `polling`.")
697
+ if not has_token:
698
+ errors.append("telegram: `transport: polling` requires `bot_token` or `bot_token_env`.")
670
699
  elif name == "discord":
671
700
  has_token = self._has_secret(config, "bot_token", "bot_token_env")
672
- if transport == "gateway" and not has_token:
701
+ if transport != "gateway":
702
+ errors.append("discord: `transport` must stay `gateway`.")
703
+ if not has_token:
673
704
  errors.append("discord: `transport: gateway` requires `bot_token` or `bot_token_env`.")
674
- elif transport == "legacy_interactions":
675
- if not has_token:
676
- errors.append("discord: `transport: legacy_interactions` requires `bot_token` or `bot_token_env`.")
677
- if public_callback_url or str(config.get("public_interactions_url") or "").strip():
678
- if not self._has_secret(config, "public_key", "public_key_env"):
679
- errors.append("discord: interaction callback mode requires `public_key` or `public_key_env`.")
680
- elif transport == "relay" and not relay_url:
681
- errors.append("discord: `transport: relay` requires `relay_url`.")
682
705
  if not str(config.get("application_id") or "").strip():
683
706
  warnings.append("discord: `application_id` is recommended for richer routing and future slash command support.")
684
707
  elif name == "slack":
685
708
  has_bot_token = self._has_secret(config, "bot_token", "bot_token_env")
686
709
  has_app_token = self._has_secret(config, "app_token", "app_token_env")
687
- has_signing_secret = self._has_secret(config, "signing_secret", "signing_secret_env")
688
- if transport == "socket_mode":
689
- if not has_bot_token:
690
- errors.append("slack: `transport: socket_mode` requires `bot_token` or `bot_token_env`.")
691
- if not has_app_token:
692
- errors.append("slack: `transport: socket_mode` requires `app_token` or `app_token_env`.")
693
- elif transport == "legacy_events_api":
694
- if not has_bot_token:
695
- errors.append("slack: `transport: legacy_events_api` requires `bot_token` or `bot_token_env`.")
696
- if public_callback_url and not has_signing_secret:
697
- errors.append("slack: callback-based setup requires `signing_secret` or `signing_secret_env`.")
698
- elif transport == "relay" and not relay_url:
699
- errors.append("slack: `transport: relay` requires `relay_url`.")
710
+ if transport != "socket_mode":
711
+ errors.append("slack: `transport` must stay `socket_mode`.")
712
+ if not has_bot_token:
713
+ errors.append("slack: `transport: socket_mode` requires `bot_token` or `bot_token_env`.")
714
+ if not has_app_token:
715
+ errors.append("slack: `transport: socket_mode` requires `app_token` or `app_token_env`.")
700
716
  elif name == "feishu":
701
717
  has_app_id = bool(str(config.get("app_id") or "").strip())
702
718
  has_app_secret = self._has_secret(config, "app_secret", "app_secret_env")
703
- if transport in {"long_connection", "legacy_webhook"}:
704
- if not has_app_id:
705
- errors.append("feishu: `transport: long_connection` requires `app_id`.")
706
- if not has_app_secret:
707
- errors.append("feishu: `transport: long_connection` requires `app_secret` or `app_secret_env`.")
708
- elif transport == "relay" and not relay_url:
709
- errors.append("feishu: `transport: relay` requires `relay_url`.")
710
- if public_callback_url and not self._has_secret(
711
- config,
712
- "verification_token",
713
- "verification_token_env",
714
- ):
715
- errors.append("feishu: webhook-style bridge configuration requires `verification_token` or `verification_token_env`.")
719
+ if transport != "long_connection":
720
+ errors.append("feishu: `transport` must stay `long_connection`.")
721
+ if not has_app_id:
722
+ errors.append("feishu: `transport: long_connection` requires `app_id`.")
723
+ if not has_app_secret:
724
+ errors.append("feishu: `transport: long_connection` requires `app_secret` or `app_secret_env`.")
716
725
  elif name == "whatsapp":
717
- provider = str(config.get("provider") or "relay").strip().lower()
718
- if transport == "local_session":
719
- if not str(config.get("session_dir") or "").strip():
720
- warnings.append("whatsapp: `transport: local_session` should set `session_dir` for local auth state.")
721
- elif transport == "relay":
722
- if not relay_url:
723
- errors.append("whatsapp: `transport: relay` requires `relay_url`.")
724
- elif transport == "legacy_meta_cloud":
725
- if provider not in {"relay", "meta"}:
726
- errors.append(f"whatsapp: unsupported provider `{provider}`. Supported providers: meta, relay.")
727
- if not self._has_secret(config, "access_token", "access_token_env"):
728
- errors.append("whatsapp: `provider: meta` requires `access_token` or `access_token_env`.")
729
- if not str(config.get("phone_number_id") or "").strip():
730
- errors.append("whatsapp: `provider: meta` requires `phone_number_id`.")
731
- if not self._has_secret(config, "verify_token", "verify_token_env"):
732
- errors.append("whatsapp: `provider: meta` requires `verify_token` or `verify_token_env`.")
726
+ if transport != "local_session":
727
+ errors.append("whatsapp: `transport` must stay `local_session`.")
728
+ if not str(config.get("session_dir") or "").strip():
729
+ warnings.append("whatsapp: `transport: local_session` should set `session_dir` for local auth state.")
733
730
  elif name == "lingzhu":
734
731
  if transport != "openclaw_sse":
735
732
  errors.append("lingzhu: `transport` must stay `openclaw_sse`.")
@@ -794,57 +791,49 @@ Use **Test** when the file exposes runtime dependencies.
794
791
  return {
795
792
  "ok": all(item["ok"] for item in items) if items else True,
796
793
  "name": "connectors",
797
- "summary": "Connector bridge test completed." if items else "No enabled connectors to test.",
794
+ "summary": "Connector test completed." if items else "No enabled connectors to test.",
798
795
  "warnings": [],
799
796
  "errors": [],
800
797
  "items": items,
801
798
  }
802
799
 
803
800
  def _test_single_connector(self, name: str, config: dict, *, live: bool, delivery_target: dict[str, object] | None = None) -> dict:
804
- relay_url = str(config.get("relay_url") or "").strip()
805
801
  transport = infer_connector_transport(name, config)
806
802
  warnings: list[str] = []
807
803
  errors: list[str] = []
808
804
  details: dict[str, object] = {
809
- "mode": "gateway-direct" if name == "qq" else config.get("mode", "relay"),
805
+ "mode": "gateway-direct" if name == "qq" else str(config.get("mode") or transport),
810
806
  "transport": transport,
811
807
  }
812
- if name != "qq" and relay_url:
813
- details["relay_url"] = relay_url
814
- if relay_url and name != "qq" and transport == "relay":
815
- warnings.append("Configured with relay_url. The live test checks local prerequisites, not the external bridge health.")
816
808
 
817
809
  try:
818
810
  if name == "telegram":
819
811
  token = self._secret(config, "bot_token", "bot_token_env")
820
- if transport in {"polling", "legacy_webhook"} and live and token:
812
+ if transport == "polling" and live and token:
821
813
  payload = self._http_json(f"https://api.telegram.org/bot{token}/getMe")
822
814
  if not payload.get("ok", False):
823
815
  errors.append("Telegram getMe did not return ok=true.")
824
816
  else:
825
817
  details["identity"] = (payload.get("result") or {}).get("username")
826
- elif transport in {"polling", "legacy_webhook"} and not token:
827
- errors.append("Telegram requires `bot_token` for polling or webhook-style direct access.")
828
- elif transport == "relay" and not relay_url:
829
- errors.append("Telegram requires `relay_url` when `transport: relay` is selected.")
818
+ elif transport == "polling" and not token:
819
+ errors.append("Telegram requires `bot_token` for polling.")
820
+ else:
821
+ errors.append("Telegram transport must stay `polling`.")
830
822
  elif name == "slack":
831
823
  token = self._secret(config, "bot_token", "bot_token_env")
832
824
  app_token = self._secret(config, "app_token", "app_token_env")
833
- signing_secret = self._secret(config, "signing_secret", "signing_secret_env")
834
825
  if transport == "socket_mode" and not app_token:
835
826
  errors.append("Slack Socket Mode requires `app_token` or `app_token_env`.")
836
- if transport == "legacy_events_api" and not signing_secret:
837
- warnings.append("Slack signing_secret is empty; inbound verification will be skipped.")
838
827
  if live and token:
839
828
  payload = self._http_json("https://slack.com/api/auth.test", method="POST", headers={"Authorization": f"Bearer {token}"})
840
829
  if not payload.get("ok", False):
841
830
  errors.append(str(payload.get("error") or "Slack auth.test failed."))
842
831
  else:
843
832
  details["identity"] = payload.get("user")
844
- elif transport in {"socket_mode", "legacy_events_api"} and not token:
833
+ elif transport == "socket_mode" and not token:
845
834
  errors.append("Slack requires `bot_token` for native runtime access.")
846
- elif transport == "relay" and not relay_url:
847
- errors.append("Slack requires `relay_url` when `transport: relay` is selected.")
835
+ elif transport != "socket_mode":
836
+ errors.append("Slack transport must stay `socket_mode`.")
848
837
  elif name == "discord":
849
838
  token = self._secret(config, "bot_token", "bot_token_env")
850
839
  if live and token:
@@ -853,10 +842,10 @@ Use **Test** when the file exposes runtime dependencies.
853
842
  errors.append(str(payload.get("message") or "Discord identity check failed."))
854
843
  else:
855
844
  details["identity"] = payload.get("username")
856
- elif transport in {"gateway", "legacy_interactions"} and not token:
857
- errors.append("Discord requires `bot_token` for gateway or legacy interaction access.")
858
- elif transport == "relay" and not relay_url:
859
- errors.append("Discord requires `relay_url` when `transport: relay` is selected.")
845
+ elif transport == "gateway" and not token:
846
+ errors.append("Discord requires `bot_token` for gateway access.")
847
+ elif transport != "gateway":
848
+ errors.append("Discord transport must stay `gateway`.")
860
849
  elif name == "feishu":
861
850
  app_id = str(config.get("app_id") or "").strip()
862
851
  app_secret = self._secret(config, "app_secret", "app_secret_env")
@@ -869,15 +858,11 @@ Use **Test** when the file exposes runtime dependencies.
869
858
  )
870
859
  if not payload.get("tenant_access_token"):
871
860
  errors.append(str(payload.get("msg") or "Feishu tenant token exchange failed."))
872
- elif transport in {"long_connection", "legacy_webhook"} and not (app_id and app_secret):
873
- errors.append("Feishu requires `app_id` + `app_secret` for long-connection or legacy webhook access.")
874
- elif transport == "relay" and not relay_url:
875
- errors.append("Feishu requires `relay_url` when `transport: relay` is selected.")
861
+ elif transport == "long_connection" and not (app_id and app_secret):
862
+ errors.append("Feishu requires `app_id` + `app_secret` for long-connection access.")
863
+ elif transport != "long_connection":
864
+ errors.append("Feishu transport must stay `long_connection`.")
876
865
  elif name == "whatsapp":
877
- provider = str(config.get("provider") or "relay").strip().lower()
878
- details["provider"] = provider
879
- token = self._secret(config, "access_token", "access_token_env")
880
- phone_number_id = str(config.get("phone_number_id") or "").strip()
881
866
  if transport == "local_session":
882
867
  session_dir = str(config.get("session_dir") or "").strip()
883
868
  details["session_dir"] = session_dir or None
@@ -885,49 +870,66 @@ Use **Test** when the file exposes runtime dependencies.
885
870
  details["session_dir_exists"] = Path(session_dir).expanduser().exists()
886
871
  if not session_dir:
887
872
  warnings.append("WhatsApp local-session mode still needs a local `session_dir` for auth state.")
888
- elif live and provider == "meta" and token and phone_number_id:
889
- api_base_url = str(config.get("api_base_url") or "https://graph.facebook.com").rstrip("/")
890
- api_version = str(config.get("api_version") or "v21.0").strip()
891
- payload = self._http_json(
892
- f"{api_base_url}/{api_version}/{phone_number_id}",
893
- headers={"Authorization": f"Bearer {token}"},
894
- )
895
- if payload.get("error"):
896
- errors.append(str(payload["error"].get("message") or "WhatsApp phone number probe failed."))
897
- else:
898
- details["identity"] = payload.get("display_phone_number")
899
- elif transport == "legacy_meta_cloud" and not (token and phone_number_id):
900
- errors.append("WhatsApp Meta Cloud fallback requires `access_token` + `phone_number_id`.")
901
- elif transport == "relay" and not relay_url:
902
- errors.append("WhatsApp requires `relay_url` when `transport: relay` is selected.")
873
+ else:
874
+ errors.append("WhatsApp transport must stay `local_session`.")
903
875
  elif name == "qq":
904
- app_id = str(config.get("app_id") or "").strip()
905
- app_secret = self._secret(config, "app_secret", "app_secret_env")
906
876
  details["transport"] = "gateway_direct"
907
- if not app_id or not app_secret:
908
- errors.append("QQ requires `app_id` + `app_secret` for the built-in gateway direct connector.")
909
- elif live:
910
- token_payload = self._http_json(
911
- "https://bots.qq.com/app/getAppAccessToken",
912
- method="POST",
913
- headers={"Content-Type": "application/json; charset=utf-8"},
914
- body={"appId": app_id, "clientSecret": app_secret},
915
- )
916
- access_token = str(token_payload.get("access_token") or "").strip()
917
- if not access_token:
918
- errors.append(str(token_payload.get("message") or "QQ access token exchange failed."))
919
- else:
920
- details["identity"] = app_id
921
- details["token_expires_in"] = token_payload.get("expires_in")
877
+ profile_results: list[dict[str, object]] = []
878
+ profiles = list_qq_profiles(config)
879
+ if not profiles:
880
+ errors.append("QQ requires at least one configured profile.")
881
+ for profile in profiles:
882
+ profile_id = str(profile.get("profile_id") or "").strip() or "unknown"
883
+ app_id = str(profile.get("app_id") or "").strip()
884
+ app_secret = self._secret(profile, "app_secret", "app_secret_env")
885
+ profile_details: dict[str, object] = {
886
+ "profile_id": profile_id,
887
+ "label": qq_profile_label(profile),
888
+ "app_id": app_id or None,
889
+ "main_chat_id": str(profile.get("main_chat_id") or "").strip() or None,
890
+ }
891
+ if not app_id or not app_secret:
892
+ profile_details["ok"] = False
893
+ profile_details["error"] = "QQ requires `app_id` + `app_secret` for each configured profile."
894
+ errors.append(f"QQ profile `{profile_id}` is missing `app_id` or `app_secret`.")
895
+ profile_results.append(profile_details)
896
+ continue
897
+ if live:
898
+ token_payload = self._http_json(
899
+ "https://bots.qq.com/app/getAppAccessToken",
900
+ method="POST",
901
+ headers={"Content-Type": "application/json; charset=utf-8"},
902
+ body={"appId": app_id, "clientSecret": app_secret},
903
+ )
904
+ access_token = str(token_payload.get("access_token") or "").strip()
905
+ if not access_token:
906
+ message = str(token_payload.get("message") or "QQ access token exchange failed.")
907
+ errors.append(f"QQ profile `{profile_id}`: {message}")
908
+ profile_details["ok"] = False
909
+ profile_details["error"] = message
910
+ profile_results.append(profile_details)
911
+ continue
922
912
  gateway_payload = self._http_json(
923
913
  "https://api.sgroup.qq.com/gateway",
924
914
  headers={"Authorization": f"QQBot {access_token}"},
925
915
  )
926
916
  gateway_url = str(gateway_payload.get("url") or "").strip()
927
917
  if not gateway_url:
928
- errors.append(str(gateway_payload.get("message") or "QQ gateway probe failed."))
929
- else:
930
- details["gateway_url"] = gateway_url
918
+ message = str(gateway_payload.get("message") or "QQ gateway probe failed.")
919
+ errors.append(f"QQ profile `{profile_id}`: {message}")
920
+ profile_details["ok"] = False
921
+ profile_details["error"] = message
922
+ profile_results.append(profile_details)
923
+ continue
924
+ profile_details["gateway_url"] = gateway_url
925
+ profile_details["token_expires_in"] = token_payload.get("expires_in")
926
+ profile_details["ok"] = True
927
+ profile_results.append(profile_details)
928
+ details["profiles"] = profile_results
929
+ if len(profile_results) == 1 and profile_results[0].get("ok"):
930
+ details["identity"] = profile_results[0].get("app_id")
931
+ details["gateway_url"] = profile_results[0].get("gateway_url")
932
+ details["token_expires_in"] = profile_results[0].get("token_expires_in")
931
933
  elif name == "lingzhu":
932
934
  details.update(self._lingzhu_snapshot_details(config))
933
935
  auth_ak = self._secret(config, "auth_ak", "auth_ak_env")
@@ -952,7 +954,11 @@ Use **Test** when the file exposes runtime dependencies.
952
954
  delivery_message = str(delivery_target.get("text") or "").strip()
953
955
  chat_type = str(delivery_target.get("chat_type") or "direct").strip().lower()
954
956
  chat_id = str(delivery_target.get("chat_id") or "").strip()
955
- default_chat_id = str(config.get("main_chat_id") or "").strip() if name == "qq" else ""
957
+ default_chat_id = ""
958
+ if name == "qq":
959
+ profiles = list_qq_profiles(config)
960
+ if len(profiles) == 1:
961
+ default_chat_id = str(profiles[0].get("main_chat_id") or "").strip()
956
962
  if not default_chat_id:
957
963
  default_chat_id = self._connector_recent_chat_id(name, chat_type)
958
964
  if not chat_id and default_chat_id:
@@ -994,7 +1000,7 @@ Use **Test** when the file exposes runtime dependencies.
994
1000
  delivery = bridge.deliver(outbound, config)
995
1001
  if delivery is None:
996
1002
  warnings.append(
997
- "The current connector mode cannot actively send a test message. Configure direct credentials or `relay_url` first."
1003
+ "The current connector mode cannot actively send a test message yet. Finish the native direct setup first."
998
1004
  )
999
1005
  else:
1000
1006
  details["delivery"] = delivery
@@ -1052,7 +1058,7 @@ Use **Test** when the file exposes runtime dependencies.
1052
1058
  "warnings": [],
1053
1059
  "errors": [
1054
1060
  "Codex binary is not installed or could not be resolved.",
1055
- "Install `@openai/codex` and ensure the `codex` CLI is available to DeepScientist.",
1061
+ "DeepScientist could not resolve the bundled or configured `codex` CLI.",
1056
1062
  ],
1057
1063
  "details": details,
1058
1064
  "guidance": [
@@ -1197,7 +1203,6 @@ Use **Test** when the file exposes runtime dependencies.
1197
1203
  "mode": "openclaw_companion",
1198
1204
  "transport": "openclaw_sse",
1199
1205
  "enabled": bool(resolved.get("enabled", False)),
1200
- "relay_url": None,
1201
1206
  "main_chat_id": None,
1202
1207
  "last_conversation_id": None,
1203
1208
  "inbox_count": 0,
@@ -1337,7 +1342,11 @@ Use **Test** when the file exposes runtime dependencies.
1337
1342
  if connector_name == "qq":
1338
1343
  for legacy_key in ("mode", "relay_url", "relay_auth_token", "public_callback_url", "webhook_verify_signature"):
1339
1344
  sanitized_payload.pop(legacy_key, None)
1340
- sanitized_payload["transport"] = "gateway_direct"
1345
+ normalized["qq"] = normalize_qq_connector_config({**base, **sanitized_payload})
1346
+ continue
1347
+ if connector_name in PROFILEABLE_CONNECTOR_NAMES:
1348
+ normalized[connector_name] = normalize_connector_config(connector_name, {**base, **sanitized_payload})
1349
+ continue
1341
1350
  elif connector_name == "lingzhu":
1342
1351
  sanitized_payload["transport"] = "openclaw_sse"
1343
1352
  elif "transport" not in sanitized_payload: