@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
@@ -6,8 +6,9 @@ import subprocess
6
6
  from copy import deepcopy
7
7
  from pathlib import Path
8
8
  from urllib.error import URLError
9
- from urllib.request import Request, urlopen
9
+ from urllib.request import Request
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,13 @@ 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
+ )
32
+ from ..network import urlopen_with_proxy as urlopen
25
33
  from ..shared import read_json, read_text, read_yaml, resolve_runner_binary, run_command, sha256_text, utc_now, which, write_text, write_yaml
26
34
  from .models import (
27
35
  CONFIG_NAMES,
@@ -125,27 +133,42 @@ class ConfigManager:
125
133
  def save_named_payload(self, name: str, payload: dict) -> dict:
126
134
  return self.save_named_text(name, self.render_named_payload(name, payload))
127
135
 
128
- def bind_qq_main_chat(self, *, chat_id: str) -> dict:
136
+ def bind_qq_main_chat(self, *, profile_id: str | None = None, chat_id: str) -> dict:
129
137
  normalized_chat_id = str(chat_id or "").strip()
130
138
  if not normalized_chat_id:
131
139
  return {"ok": False, "saved": False, "message": "QQ main chat id is empty."}
132
140
  connectors = self.load_named_normalized("connectors")
133
141
  qq = connectors.get("qq") if isinstance(connectors.get("qq"), dict) else {}
134
- configured = str((qq or {}).get("main_chat_id") or "").strip()
142
+ profiles = list_qq_profiles(qq)
143
+ if not profiles:
144
+ return {"ok": False, "saved": False, "message": "QQ profile is not configured yet."}
145
+ resolved_profile = find_qq_profile(qq, profile_id=profile_id)
146
+ if resolved_profile is None and len(profiles) == 1:
147
+ resolved_profile = profiles[0]
148
+ if resolved_profile is None:
149
+ return {"ok": False, "saved": False, "message": "Unable to determine which QQ profile should save this OpenID."}
150
+ configured = str((resolved_profile or {}).get("main_chat_id") or "").strip()
135
151
  if configured:
136
152
  return {
137
153
  "ok": True,
138
154
  "saved": False,
139
155
  "chat_id": configured,
140
156
  "already_configured": True,
157
+ "profile_id": resolved_profile.get("profile_id"),
141
158
  }
142
- qq["main_chat_id"] = normalized_chat_id
159
+ for item in profiles:
160
+ if str(item.get("profile_id") or "").strip() == str(resolved_profile.get("profile_id") or "").strip():
161
+ item["main_chat_id"] = normalized_chat_id
162
+ qq["profiles"] = profiles
163
+ qq = normalize_qq_connector_config(qq)
143
164
  connectors["qq"] = qq
144
165
  result = self.save_named_payload("connectors", connectors)
145
166
  return {
146
167
  "ok": bool(result.get("ok")),
147
168
  "saved": bool(result.get("ok")),
148
169
  "chat_id": normalized_chat_id,
170
+ "profile_id": resolved_profile.get("profile_id"),
171
+ "profile_label": qq_profile_label(resolved_profile),
149
172
  "saved_at": result.get("saved_at"),
150
173
  "errors": result.get("errors") or [],
151
174
  "warnings": result.get("warnings") or [],
@@ -231,8 +254,7 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
231
254
  - connect Telegram, Discord, Slack, Feishu, WhatsApp, or QQ
232
255
  - choose one preferred connector for proactive artifact updates
233
256
  - 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
257
+ - use the built-in direct runtime for each connector
236
258
  - keep all secrets in one visible place
237
259
 
238
260
  ## Recommended order
@@ -252,22 +274,12 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
252
274
  - WhatsApp: `local_session`
253
275
  - QQ: `gateway_direct`
254
276
 
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
277
  ## Practical notes
265
278
 
266
279
  ### Telegram
267
280
 
268
281
  - set `bot_token`
269
282
  - prefer `transport: polling`
270
- - use webhook fields only in the legacy section
271
283
  - readiness test uses `getMe`
272
284
 
273
285
  ### Slack
@@ -281,32 +293,30 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
281
293
 
282
294
  - set `bot_token`
283
295
  - prefer `transport: gateway`
284
- - keep interaction callback fields only for legacy compatibility
285
296
 
286
297
  ### Feishu
287
298
 
288
299
  - set `app_id`
289
300
  - set `app_secret`
290
301
  - prefer `transport: long_connection`
291
- - keep verification or encrypt keys only for legacy webhook fallback
292
302
  - test checks whether tenant token exchange succeeds
293
303
 
294
304
  ### WhatsApp
295
305
 
296
306
  - prefer `transport: local_session`
297
307
  - 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
308
+ - keep one writable `session_dir` for the local auth state
300
309
 
301
310
  ### QQ
302
311
 
303
312
  - 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`
313
+ - each QQ bot is stored as one item under `qq.profiles`
314
+ - save one QQ bot profile first, then ask the user to send one private QQ message to that specific bot
315
+ - the daemon auto-detects that user's `openid` and saves it into that profile's `main_chat_id`
306
316
  - private QQ chats can then auto-follow the latest quest by default, unless disabled in settings
307
317
  - readiness test exchanges `access_token` and probes `/gateway`
308
318
  - active send targets use QQ user `openid` or group `group_openid`
309
- - the settings page also surfaces recently discovered targets from runtime activity
319
+ - the settings page also surfaces recently discovered targets from runtime activity, grouped by QQ bot profile
310
320
  - milestone delivery toggles default to enabled; adjust them only if you want less outbound push
311
321
  - the recommended first-run path is: save credentials -> send one QQ private message -> confirm `Detected OpenID` -> run a probe
312
322
 
@@ -377,7 +387,7 @@ This page edits `{home_text}/config/runners.yaml`.
377
387
  - `claude` remains TODO / reserved in the current open-source release and is not runnable yet
378
388
  - keep `codex.model_reasoning_effort: xhigh` unless you explicitly want a lighter default
379
389
  - keep `codex.retry_on_failure: true` so transient Codex failures can resume automatically
380
- - keep retry timing near `1s / 2x / 8s max` unless you have a strong reason to slow recovery down
390
+ - keep retry timing near `10s / 6x / 1800s max` so Codex backs off exponentially and the last retry waits about 30 minutes
381
391
  - DeepScientist hard-limits one turn to at most `5` total attempts, even if the config says more
382
392
 
383
393
  ## Test behavior
@@ -643,17 +653,39 @@ Use **Test** when the file exposes runtime dependencies.
643
653
  enabled_connectors.append(str(name))
644
654
 
645
655
  if name == "qq":
646
- if not str(config.get("app_id") or "").strip():
656
+ profiles = list_qq_profiles(config)
657
+ if not profiles:
658
+ errors.append("qq: requires at least one configured profile under `qq.profiles`.")
659
+ continue
660
+ legacy_missing_app_id = False
661
+ legacy_missing_secret = False
662
+ seen_profile_ids: set[str] = set()
663
+ seen_app_ids: set[str] = set()
664
+ for profile in profiles:
665
+ profile_id = str(profile.get("profile_id") or "").strip() or "unknown"
666
+ app_id = str(profile.get("app_id") or "").strip()
667
+ if not profile_id:
668
+ errors.append("qq: every profile requires a stable `profile_id`.")
669
+ elif profile_id in seen_profile_ids:
670
+ errors.append(f"qq: duplicate profile_id `{profile_id}`.")
671
+ else:
672
+ seen_profile_ids.add(profile_id)
673
+ if not app_id:
674
+ legacy_missing_app_id = True
675
+ errors.append(f"qq[{profile_id}]: requires `app_id`.")
676
+ elif app_id in seen_app_ids:
677
+ errors.append(f"qq: duplicate app_id `{app_id}` across profiles.")
678
+ else:
679
+ seen_app_ids.add(app_id)
680
+ if not self._has_secret(profile, "app_secret", "app_secret_env"):
681
+ legacy_missing_secret = True
682
+ errors.append(f"qq[{profile_id}]: requires `app_secret` or `app_secret_env`.")
683
+ if len(profiles) == 1 and legacy_missing_app_id:
647
684
  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"):
685
+ if len(profiles) == 1 and legacy_missing_secret:
649
686
  errors.append("qq: requires `app_secret` or `app_secret_env` for the built-in gateway direct connector.")
650
687
  continue
651
688
  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
689
 
658
690
  policy_validation = self._validate_access_policies(name, config)
659
691
  warnings.extend(policy_validation["warnings"])
@@ -661,75 +693,41 @@ Use **Test** when the file exposes runtime dependencies.
661
693
 
662
694
  if name == "telegram":
663
695
  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.")
696
+ if transport != "polling":
697
+ errors.append("telegram: `transport` must stay `polling`.")
698
+ if not has_token:
699
+ errors.append("telegram: `transport: polling` requires `bot_token` or `bot_token_env`.")
670
700
  elif name == "discord":
671
701
  has_token = self._has_secret(config, "bot_token", "bot_token_env")
672
- if transport == "gateway" and not has_token:
702
+ if transport != "gateway":
703
+ errors.append("discord: `transport` must stay `gateway`.")
704
+ if not has_token:
673
705
  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
706
  if not str(config.get("application_id") or "").strip():
683
707
  warnings.append("discord: `application_id` is recommended for richer routing and future slash command support.")
684
708
  elif name == "slack":
685
709
  has_bot_token = self._has_secret(config, "bot_token", "bot_token_env")
686
710
  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`.")
711
+ if transport != "socket_mode":
712
+ errors.append("slack: `transport` must stay `socket_mode`.")
713
+ if not has_bot_token:
714
+ errors.append("slack: `transport: socket_mode` requires `bot_token` or `bot_token_env`.")
715
+ if not has_app_token:
716
+ errors.append("slack: `transport: socket_mode` requires `app_token` or `app_token_env`.")
700
717
  elif name == "feishu":
701
718
  has_app_id = bool(str(config.get("app_id") or "").strip())
702
719
  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`.")
720
+ if transport != "long_connection":
721
+ errors.append("feishu: `transport` must stay `long_connection`.")
722
+ if not has_app_id:
723
+ errors.append("feishu: `transport: long_connection` requires `app_id`.")
724
+ if not has_app_secret:
725
+ errors.append("feishu: `transport: long_connection` requires `app_secret` or `app_secret_env`.")
716
726
  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`.")
727
+ if transport != "local_session":
728
+ errors.append("whatsapp: `transport` must stay `local_session`.")
729
+ if not str(config.get("session_dir") or "").strip():
730
+ warnings.append("whatsapp: `transport: local_session` should set `session_dir` for local auth state.")
733
731
  elif name == "lingzhu":
734
732
  if transport != "openclaw_sse":
735
733
  errors.append("lingzhu: `transport` must stay `openclaw_sse`.")
@@ -794,57 +792,49 @@ Use **Test** when the file exposes runtime dependencies.
794
792
  return {
795
793
  "ok": all(item["ok"] for item in items) if items else True,
796
794
  "name": "connectors",
797
- "summary": "Connector bridge test completed." if items else "No enabled connectors to test.",
795
+ "summary": "Connector test completed." if items else "No enabled connectors to test.",
798
796
  "warnings": [],
799
797
  "errors": [],
800
798
  "items": items,
801
799
  }
802
800
 
803
801
  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
802
  transport = infer_connector_transport(name, config)
806
803
  warnings: list[str] = []
807
804
  errors: list[str] = []
808
805
  details: dict[str, object] = {
809
- "mode": "gateway-direct" if name == "qq" else config.get("mode", "relay"),
806
+ "mode": "gateway-direct" if name == "qq" else str(config.get("mode") or transport),
810
807
  "transport": transport,
811
808
  }
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
809
 
817
810
  try:
818
811
  if name == "telegram":
819
812
  token = self._secret(config, "bot_token", "bot_token_env")
820
- if transport in {"polling", "legacy_webhook"} and live and token:
813
+ if transport == "polling" and live and token:
821
814
  payload = self._http_json(f"https://api.telegram.org/bot{token}/getMe")
822
815
  if not payload.get("ok", False):
823
816
  errors.append("Telegram getMe did not return ok=true.")
824
817
  else:
825
818
  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.")
819
+ elif transport == "polling" and not token:
820
+ errors.append("Telegram requires `bot_token` for polling.")
821
+ else:
822
+ errors.append("Telegram transport must stay `polling`.")
830
823
  elif name == "slack":
831
824
  token = self._secret(config, "bot_token", "bot_token_env")
832
825
  app_token = self._secret(config, "app_token", "app_token_env")
833
- signing_secret = self._secret(config, "signing_secret", "signing_secret_env")
834
826
  if transport == "socket_mode" and not app_token:
835
827
  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
828
  if live and token:
839
829
  payload = self._http_json("https://slack.com/api/auth.test", method="POST", headers={"Authorization": f"Bearer {token}"})
840
830
  if not payload.get("ok", False):
841
831
  errors.append(str(payload.get("error") or "Slack auth.test failed."))
842
832
  else:
843
833
  details["identity"] = payload.get("user")
844
- elif transport in {"socket_mode", "legacy_events_api"} and not token:
834
+ elif transport == "socket_mode" and not token:
845
835
  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.")
836
+ elif transport != "socket_mode":
837
+ errors.append("Slack transport must stay `socket_mode`.")
848
838
  elif name == "discord":
849
839
  token = self._secret(config, "bot_token", "bot_token_env")
850
840
  if live and token:
@@ -853,10 +843,10 @@ Use **Test** when the file exposes runtime dependencies.
853
843
  errors.append(str(payload.get("message") or "Discord identity check failed."))
854
844
  else:
855
845
  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.")
846
+ elif transport == "gateway" and not token:
847
+ errors.append("Discord requires `bot_token` for gateway access.")
848
+ elif transport != "gateway":
849
+ errors.append("Discord transport must stay `gateway`.")
860
850
  elif name == "feishu":
861
851
  app_id = str(config.get("app_id") or "").strip()
862
852
  app_secret = self._secret(config, "app_secret", "app_secret_env")
@@ -869,15 +859,11 @@ Use **Test** when the file exposes runtime dependencies.
869
859
  )
870
860
  if not payload.get("tenant_access_token"):
871
861
  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.")
862
+ elif transport == "long_connection" and not (app_id and app_secret):
863
+ errors.append("Feishu requires `app_id` + `app_secret` for long-connection access.")
864
+ elif transport != "long_connection":
865
+ errors.append("Feishu transport must stay `long_connection`.")
876
866
  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
867
  if transport == "local_session":
882
868
  session_dir = str(config.get("session_dir") or "").strip()
883
869
  details["session_dir"] = session_dir or None
@@ -885,49 +871,66 @@ Use **Test** when the file exposes runtime dependencies.
885
871
  details["session_dir_exists"] = Path(session_dir).expanduser().exists()
886
872
  if not session_dir:
887
873
  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.")
874
+ else:
875
+ errors.append("WhatsApp transport must stay `local_session`.")
903
876
  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
877
  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")
878
+ profile_results: list[dict[str, object]] = []
879
+ profiles = list_qq_profiles(config)
880
+ if not profiles:
881
+ errors.append("QQ requires at least one configured profile.")
882
+ for profile in profiles:
883
+ profile_id = str(profile.get("profile_id") or "").strip() or "unknown"
884
+ app_id = str(profile.get("app_id") or "").strip()
885
+ app_secret = self._secret(profile, "app_secret", "app_secret_env")
886
+ profile_details: dict[str, object] = {
887
+ "profile_id": profile_id,
888
+ "label": qq_profile_label(profile),
889
+ "app_id": app_id or None,
890
+ "main_chat_id": str(profile.get("main_chat_id") or "").strip() or None,
891
+ }
892
+ if not app_id or not app_secret:
893
+ profile_details["ok"] = False
894
+ profile_details["error"] = "QQ requires `app_id` + `app_secret` for each configured profile."
895
+ errors.append(f"QQ profile `{profile_id}` is missing `app_id` or `app_secret`.")
896
+ profile_results.append(profile_details)
897
+ continue
898
+ if live:
899
+ token_payload = self._http_json(
900
+ "https://bots.qq.com/app/getAppAccessToken",
901
+ method="POST",
902
+ headers={"Content-Type": "application/json; charset=utf-8"},
903
+ body={"appId": app_id, "clientSecret": app_secret},
904
+ )
905
+ access_token = str(token_payload.get("access_token") or "").strip()
906
+ if not access_token:
907
+ message = str(token_payload.get("message") or "QQ access token exchange failed.")
908
+ errors.append(f"QQ profile `{profile_id}`: {message}")
909
+ profile_details["ok"] = False
910
+ profile_details["error"] = message
911
+ profile_results.append(profile_details)
912
+ continue
922
913
  gateway_payload = self._http_json(
923
914
  "https://api.sgroup.qq.com/gateway",
924
915
  headers={"Authorization": f"QQBot {access_token}"},
925
916
  )
926
917
  gateway_url = str(gateway_payload.get("url") or "").strip()
927
918
  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
919
+ message = str(gateway_payload.get("message") or "QQ gateway probe failed.")
920
+ errors.append(f"QQ profile `{profile_id}`: {message}")
921
+ profile_details["ok"] = False
922
+ profile_details["error"] = message
923
+ profile_results.append(profile_details)
924
+ continue
925
+ profile_details["gateway_url"] = gateway_url
926
+ profile_details["token_expires_in"] = token_payload.get("expires_in")
927
+ profile_details["ok"] = True
928
+ profile_results.append(profile_details)
929
+ details["profiles"] = profile_results
930
+ if len(profile_results) == 1 and profile_results[0].get("ok"):
931
+ details["identity"] = profile_results[0].get("app_id")
932
+ details["gateway_url"] = profile_results[0].get("gateway_url")
933
+ details["token_expires_in"] = profile_results[0].get("token_expires_in")
931
934
  elif name == "lingzhu":
932
935
  details.update(self._lingzhu_snapshot_details(config))
933
936
  auth_ak = self._secret(config, "auth_ak", "auth_ak_env")
@@ -952,7 +955,11 @@ Use **Test** when the file exposes runtime dependencies.
952
955
  delivery_message = str(delivery_target.get("text") or "").strip()
953
956
  chat_type = str(delivery_target.get("chat_type") or "direct").strip().lower()
954
957
  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 ""
958
+ default_chat_id = ""
959
+ if name == "qq":
960
+ profiles = list_qq_profiles(config)
961
+ if len(profiles) == 1:
962
+ default_chat_id = str(profiles[0].get("main_chat_id") or "").strip()
956
963
  if not default_chat_id:
957
964
  default_chat_id = self._connector_recent_chat_id(name, chat_type)
958
965
  if not chat_id and default_chat_id:
@@ -994,7 +1001,7 @@ Use **Test** when the file exposes runtime dependencies.
994
1001
  delivery = bridge.deliver(outbound, config)
995
1002
  if delivery is None:
996
1003
  warnings.append(
997
- "The current connector mode cannot actively send a test message. Configure direct credentials or `relay_url` first."
1004
+ "The current connector mode cannot actively send a test message yet. Finish the native direct setup first."
998
1005
  )
999
1006
  else:
1000
1007
  details["delivery"] = delivery
@@ -1197,7 +1204,6 @@ Use **Test** when the file exposes runtime dependencies.
1197
1204
  "mode": "openclaw_companion",
1198
1205
  "transport": "openclaw_sse",
1199
1206
  "enabled": bool(resolved.get("enabled", False)),
1200
- "relay_url": None,
1201
1207
  "main_chat_id": None,
1202
1208
  "last_conversation_id": None,
1203
1209
  "inbox_count": 0,
@@ -1315,11 +1321,20 @@ Use **Test** when the file exposes runtime dependencies.
1315
1321
  prepared = deepcopy(payload)
1316
1322
  if name == "config":
1317
1323
  prepared.pop("reports", None)
1324
+ return self._normalize_config_payload(prepared)
1318
1325
  if name == "plugins":
1319
1326
  prepared = self._normalize_plugins_payload(prepared)
1320
1327
  elif name == "mcp_servers":
1321
1328
  prepared = self._normalize_mcp_payload(prepared)
1322
1329
  defaults = default_payload(name, self.home)
1330
+ if name == "runners":
1331
+ normalized = self._deep_merge(defaults, prepared)
1332
+ codex = normalized.get("codex")
1333
+ if isinstance(codex, dict) and self._looks_like_legacy_codex_retry_profile(codex):
1334
+ codex["retry_initial_backoff_sec"] = 10.0
1335
+ codex["retry_backoff_multiplier"] = 6.0
1336
+ codex["retry_max_backoff_sec"] = 1800.0
1337
+ return normalized
1323
1338
  if name == "connectors":
1324
1339
  normalized = deepcopy(defaults)
1325
1340
  for connector_name, connector_payload in prepared.items():
@@ -1337,7 +1352,11 @@ Use **Test** when the file exposes runtime dependencies.
1337
1352
  if connector_name == "qq":
1338
1353
  for legacy_key in ("mode", "relay_url", "relay_auth_token", "public_callback_url", "webhook_verify_signature"):
1339
1354
  sanitized_payload.pop(legacy_key, None)
1340
- sanitized_payload["transport"] = "gateway_direct"
1355
+ normalized["qq"] = normalize_qq_connector_config({**base, **sanitized_payload})
1356
+ continue
1357
+ if connector_name in PROFILEABLE_CONNECTOR_NAMES:
1358
+ normalized[connector_name] = normalize_connector_config(connector_name, {**base, **sanitized_payload})
1359
+ continue
1341
1360
  elif connector_name == "lingzhu":
1342
1361
  sanitized_payload["transport"] = "openclaw_sse"
1343
1362
  elif "transport" not in sanitized_payload:
@@ -1349,6 +1368,46 @@ Use **Test** when the file exposes runtime dependencies.
1349
1368
  return normalized
1350
1369
  return self._deep_merge(defaults, prepared)
1351
1370
 
1371
+ def _normalize_config_payload(self, payload: dict) -> dict:
1372
+ defaults = default_payload("config", self.home)
1373
+ normalized = self._deep_merge(defaults, payload)
1374
+ bootstrap = normalized.get("bootstrap") if isinstance(normalized.get("bootstrap"), dict) else {}
1375
+ raw_bootstrap = payload.get("bootstrap") if isinstance(payload.get("bootstrap"), dict) else {}
1376
+ default_locale = str(defaults.get("default_locale") or "").strip()
1377
+ current_locale = str(normalized.get("default_locale") or "").strip()
1378
+ locale_source = str(raw_bootstrap.get("locale_source") or "").strip().lower()
1379
+ locale_initialized_from_browser = bool(
1380
+ raw_bootstrap.get("locale_initialized_from_browser", bootstrap.get("locale_initialized_from_browser", False))
1381
+ )
1382
+
1383
+ if locale_source not in {"default", "browser", "user"}:
1384
+ if current_locale and current_locale != default_locale:
1385
+ locale_source = "user"
1386
+ elif locale_initialized_from_browser:
1387
+ locale_source = "browser"
1388
+ else:
1389
+ locale_source = "default"
1390
+
1391
+ if locale_source == "browser":
1392
+ locale_initialized_from_browser = True
1393
+
1394
+ bootstrap["locale_source"] = locale_source
1395
+ bootstrap["locale_initialized_from_browser"] = locale_initialized_from_browser
1396
+ bootstrap["locale_initialized_at"] = bootstrap.get("locale_initialized_at")
1397
+ bootstrap["locale_initialized_browser_locale"] = bootstrap.get("locale_initialized_browser_locale")
1398
+ normalized["bootstrap"] = bootstrap
1399
+ return normalized
1400
+
1401
+ @staticmethod
1402
+ def _looks_like_legacy_codex_retry_profile(payload: dict) -> bool:
1403
+ try:
1404
+ initial = float(payload.get("retry_initial_backoff_sec"))
1405
+ multiplier = float(payload.get("retry_backoff_multiplier"))
1406
+ max_backoff = float(payload.get("retry_max_backoff_sec"))
1407
+ except (TypeError, ValueError):
1408
+ return False
1409
+ return abs(initial - 1.0) < 1e-9 and abs(multiplier - 2.0) < 1e-9 and abs(max_backoff - 8.0) < 1e-9
1410
+
1352
1411
  def _normalize_plugins_payload(self, payload: dict) -> dict:
1353
1412
  normalized = deepcopy(payload)
1354
1413
  if "load_paths" not in normalized and isinstance(normalized.get("search_paths"), list):