dsh-connect 0.8.0 → 0.9.2

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 (117) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +236 -18
  3. package/README.zh.md +185 -18
  4. package/client/client.js +517 -84
  5. package/client/client.js.map +4 -4
  6. package/client/locale.mjs +227 -0
  7. package/client/panel-state.mjs +54 -0
  8. package/client/settings-client.mjs +282 -61
  9. package/docs/images/settings-advanced-en.png +0 -0
  10. package/docs/images/settings-advanced-zh.png +0 -0
  11. package/docs/images/settings-defaults-en.png +0 -0
  12. package/docs/images/settings-defaults-zh.png +0 -0
  13. package/docs/images/settings-overview-en.png +0 -0
  14. package/docs/images/settings-overview-zh.png +0 -0
  15. package/lib/binding.d.ts.map +1 -1
  16. package/lib/binding.js +2 -2
  17. package/lib/binding.js.map +1 -1
  18. package/lib/channels/dingtalk/index.d.ts +49 -49
  19. package/lib/channels/dingtalk/index.d.ts.map +1 -1
  20. package/lib/channels/dingtalk/message.d.ts.map +1 -1
  21. package/lib/channels/dingtalk/message.js +7 -0
  22. package/lib/channels/dingtalk/message.js.map +1 -1
  23. package/lib/channels/feishu/adapter.d.ts +14 -13
  24. package/lib/channels/feishu/adapter.d.ts.map +1 -1
  25. package/lib/channels/feishu/adapter.js +122 -56
  26. package/lib/channels/feishu/adapter.js.map +1 -1
  27. package/lib/channels/feishu/i18n.d.ts +2 -0
  28. package/lib/channels/feishu/i18n.d.ts.map +1 -1
  29. package/lib/channels/feishu/i18n.js +2 -0
  30. package/lib/channels/feishu/i18n.js.map +1 -1
  31. package/lib/channels/feishu/index.d.ts +48 -27
  32. package/lib/channels/feishu/index.d.ts.map +1 -1
  33. package/lib/channels/feishu/index.js +32 -6
  34. package/lib/channels/feishu/index.js.map +1 -1
  35. package/lib/channels/telegram/adapter.d.ts.map +1 -1
  36. package/lib/channels/telegram/adapter.js +7 -1
  37. package/lib/channels/telegram/adapter.js.map +1 -1
  38. package/lib/channels/telegram/index.d.ts +13 -13
  39. package/lib/channels/telegram/index.d.ts.map +1 -1
  40. package/lib/channels/web/adapter.d.ts +3 -1
  41. package/lib/channels/web/adapter.d.ts.map +1 -1
  42. package/lib/channels/web/adapter.js +3 -1
  43. package/lib/channels/web/adapter.js.map +1 -1
  44. package/lib/channels/web/index.d.ts +5 -5
  45. package/lib/channels/web/index.d.ts.map +1 -1
  46. package/lib/chat-key.d.ts +29 -0
  47. package/lib/chat-key.d.ts.map +1 -0
  48. package/lib/chat-key.js +38 -0
  49. package/lib/chat-key.js.map +1 -0
  50. package/lib/index.d.ts +57 -63
  51. package/lib/index.d.ts.map +1 -1
  52. package/lib/index.js +330 -55
  53. package/lib/index.js.map +1 -1
  54. package/lib/interaction.d.ts +90 -50
  55. package/lib/interaction.d.ts.map +1 -1
  56. package/lib/interaction.js +233 -272
  57. package/lib/interaction.js.map +1 -1
  58. package/lib/retry.d.ts.map +1 -1
  59. package/lib/retry.js +7 -1
  60. package/lib/retry.js.map +1 -1
  61. package/lib/runner.d.ts +54 -0
  62. package/lib/runner.d.ts.map +1 -1
  63. package/lib/runner.js +158 -23
  64. package/lib/runner.js.map +1 -1
  65. package/lib/scheduler.d.ts.map +1 -1
  66. package/lib/scheduler.js +2 -2
  67. package/lib/scheduler.js.map +1 -1
  68. package/lib/service.d.ts +16 -0
  69. package/lib/service.d.ts.map +1 -1
  70. package/lib/service.js +46 -4
  71. package/lib/service.js.map +1 -1
  72. package/lib/settings/channel-runtime.d.ts +75 -0
  73. package/lib/settings/channel-runtime.d.ts.map +1 -0
  74. package/lib/settings/channel-runtime.js +165 -0
  75. package/lib/settings/channel-runtime.js.map +1 -0
  76. package/lib/settings/channels.d.ts +11 -0
  77. package/lib/settings/channels.d.ts.map +1 -1
  78. package/lib/settings/channels.js +31 -0
  79. package/lib/settings/channels.js.map +1 -1
  80. package/lib/settings/credential-store.d.ts +52 -6
  81. package/lib/settings/credential-store.d.ts.map +1 -1
  82. package/lib/settings/credential-store.js +92 -18
  83. package/lib/settings/credential-store.js.map +1 -1
  84. package/lib/settings/legacy-import.d.ts +190 -0
  85. package/lib/settings/legacy-import.d.ts.map +1 -0
  86. package/lib/settings/legacy-import.js +373 -0
  87. package/lib/settings/legacy-import.js.map +1 -0
  88. package/lib/settings/namespace.d.ts +263 -0
  89. package/lib/settings/namespace.d.ts.map +1 -0
  90. package/lib/settings/namespace.js +287 -0
  91. package/lib/settings/namespace.js.map +1 -0
  92. package/lib/settings/secret-disclosure.d.ts +50 -0
  93. package/lib/settings/secret-disclosure.d.ts.map +1 -0
  94. package/lib/settings/secret-disclosure.js +132 -0
  95. package/lib/settings/secret-disclosure.js.map +1 -0
  96. package/lib/settings/settings-model.d.ts +17 -0
  97. package/lib/settings/settings-model.d.ts.map +1 -1
  98. package/lib/settings/settings-model.js +18 -1
  99. package/lib/settings/settings-model.js.map +1 -1
  100. package/lib/settings/settings-rpc.d.ts +71 -3
  101. package/lib/settings/settings-rpc.d.ts.map +1 -1
  102. package/lib/settings/settings-rpc.js +167 -24
  103. package/lib/settings/settings-rpc.js.map +1 -1
  104. package/lib/settings/settings-service.d.ts +22 -1
  105. package/lib/settings/settings-service.d.ts.map +1 -1
  106. package/lib/settings/settings-service.js +145 -8
  107. package/lib/settings/settings-service.js.map +1 -1
  108. package/lib/state-dir.d.ts +28 -0
  109. package/lib/state-dir.d.ts.map +1 -0
  110. package/lib/state-dir.js +33 -0
  111. package/lib/state-dir.js.map +1 -0
  112. package/lib/stream.d.ts.map +1 -1
  113. package/lib/stream.js +3 -2
  114. package/lib/stream.js.map +1 -1
  115. package/lib/types.d.ts +14 -1
  116. package/lib/types.d.ts.map +1 -1
  117. package/package.json +13 -10
package/client/client.js CHANGED
@@ -50,6 +50,7 @@ var React = __toESM(require("react"), 1);
50
50
  // lib/settings/settings-rpc.js
51
51
  var SETTINGS_RPC_CHANNEL = "/dsh-connect";
52
52
  var SETTINGS_ENDPOINTS = Object.freeze(["settings.get", "settings.save", "credentials.save", "settings.status"]);
53
+ var MAX_BODY_BYTES = 1024 * 1024;
53
54
 
54
55
  // lib/settings/rpc-client.js
55
56
  var RpcError = class extends Error {
@@ -78,13 +79,10 @@ function saveCredentials(rpcCall, channel, values) {
78
79
  return callRpc(rpcCall, "credentials.save", { channel, values });
79
80
  }
80
81
 
82
+ // lib/settings/channels.js
83
+ var CHANNELS = ["feishu", "telegram", "dingtalk", "web"];
84
+
81
85
  // lib/settings/credential-store.js
82
- var CREDENTIAL_REFS = Object.freeze({
83
- feishu: ["DSH_CONNECT_FEISHU_APP_ID", "DSH_CONNECT_FEISHU_APP_SECRET"],
84
- telegram: ["DSH_CONNECT_TELEGRAM_BOT_TOKEN"],
85
- dingtalk: ["DSH_CONNECT_DINGTALK_WEBHOOK_URL", "DSH_CONNECT_DINGTALK_SECRET"],
86
- web: []
87
- });
88
86
  var CHANNEL_SECRET_KEYS = Object.freeze({
89
87
  feishu: { appId: "DSH_CONNECT_FEISHU_APP_ID", appSecret: "DSH_CONNECT_FEISHU_APP_SECRET" },
90
88
  telegram: { botToken: "DSH_CONNECT_TELEGRAM_BOT_TOKEN" },
@@ -96,6 +94,24 @@ var CHANNEL_SECRET_KEYS = Object.freeze({
96
94
  },
97
95
  web: {}
98
96
  });
97
+ var CREDENTIAL_GROUPS = Object.freeze({
98
+ feishu: [["DSH_CONNECT_FEISHU_APP_ID", "DSH_CONNECT_FEISHU_APP_SECRET"]],
99
+ telegram: [["DSH_CONNECT_TELEGRAM_BOT_TOKEN"]],
100
+ dingtalk: [
101
+ ["DSH_CONNECT_DINGTALK_WEBHOOK_URL", "DSH_CONNECT_DINGTALK_SECRET"],
102
+ ["DSH_CONNECT_DINGTALK_CLIENT_ID", "DSH_CONNECT_DINGTALK_CLIENT_SECRET"]
103
+ ],
104
+ web: []
105
+ });
106
+ var CREDENTIAL_REFS = Object.freeze(Object.fromEntries(CHANNELS.map((channel) => [channel, groupRefs(channel)])));
107
+ function groupRefs(channel) {
108
+ const seen = /* @__PURE__ */ new Set();
109
+ for (const group of CREDENTIAL_GROUPS[channel] ?? []) {
110
+ for (const ref of group)
111
+ seen.add(ref);
112
+ }
113
+ return Object.freeze([...seen]);
114
+ }
99
115
 
100
116
  // lib/settings/settings-model.js
101
117
  var CHANNEL_SECRET_FIELDS = Object.fromEntries(Object.entries(CHANNEL_SECRET_KEYS).map(([ch, map]) => [ch, Object.keys(map ?? {})]));
@@ -146,14 +162,23 @@ function snapshotToForm(snapshot) {
146
162
  const config = snapshot.config ?? {};
147
163
  const channels = snapshot.enabled ?? [];
148
164
  const channelConfigs = {};
149
- for (const ch of channels)
165
+ for (const ch of Object.keys(CHANNEL_CONFIG_FIELDS)) {
150
166
  channelConfigs[ch] = config[ch] ?? {};
167
+ }
151
168
  return {
152
169
  channels,
153
170
  channelDefaults: config.channelDefaults ?? {},
154
171
  channelConfigs,
172
+ // Deliberately empty even though `secretPreviews` carries something: an
173
+ // input is a place to *type a new* secret, and prefilling it with the mask
174
+ // would either overwrite the stored secret with its own preview on save, or
175
+ // train the user to think a masked value is the real one. The preview is
176
+ // rendered beside the input instead.
155
177
  secrets: {},
156
- settingsStatePath: config.settingsStatePath
178
+ secretPresence: snapshot.secrets ?? {},
179
+ secretPreviews: snapshot.secretPreviews ?? {},
180
+ settingsStatePath: config.settingsStatePath,
181
+ live: snapshot.live === true
157
182
  };
158
183
  }
159
184
  function stripEmpty(obj) {
@@ -186,33 +211,276 @@ function buildCredentialSaves(form) {
186
211
  return out;
187
212
  }
188
213
 
214
+ // lib/settings/secret-disclosure.js
215
+ var SECRET_DISCLOSURE = Object.freeze({
216
+ appId: "full",
217
+ clientId: "full",
218
+ appSecret: "mask",
219
+ clientSecret: "mask",
220
+ botToken: "mask",
221
+ secret: "mask",
222
+ webhookUrl: "url"
223
+ });
224
+ function disclosureOf(configKey) {
225
+ return SECRET_DISCLOSURE[configKey] ?? "mask";
226
+ }
227
+ function isMaskedSecret(configKey) {
228
+ return disclosureOf(configKey) !== "full";
229
+ }
230
+
231
+ // client/locale.mjs
232
+ var LOCALES = {
233
+ zh: {
234
+ title: "dsh-connect",
235
+ channels: "\u6E20\u9053",
236
+ defaults: "\u516C\u5171\u9ED8\u8BA4",
237
+ defaultsHint: "\u672A\u5355\u72EC\u914D\u7F6E\u7684\u6E20\u9053\u6CBF\u7528\u8FD9\u91CC\u7684\u53D6\u503C\uFF1B\u7559\u7A7A\u8868\u793A\u6CBF\u7528\u5404\u6E20\u9053\u81EA\u8EAB\u7684\u9ED8\u8BA4\u503C\u3002",
238
+ save: "\u4FDD\u5B58",
239
+ saved: "\u5DF2\u4FDD\u5B58",
240
+ error: "\u4FDD\u5B58\u5931\u8D25",
241
+ loading: "\u52A0\u8F7D\u4E2D\u2026",
242
+ statePath: "\u8BBE\u7F6E\u6587\u4EF6",
243
+ statePathHint: "\u6CA1\u6709 settings.yaml \u547D\u540D\u7A7A\u95F4\u65F6\uFF0C\u914D\u7F6E\u56DE\u9000\u5B58\u653E\u5728\u8FD9\u4E2A JSON \u6587\u4EF6\u91CC\u3002",
244
+ livePlane: "\u914D\u7F6E\u5B58\u653E\u5728 settings.yaml\uFF0C\u4FDD\u5B58\u540E\u7ACB\u5373\u751F\u6548\u3002",
245
+ filePlane: "\u914D\u7F6E\u5B58\u653E\u5728\u672C\u5730\u8BBE\u7F6E\u6587\u4EF6\uFF0C\u91CD\u542F dsh \u540E\u751F\u6548\u3002",
246
+ reachable: "\u5DF2\u914D\u7F6E\u51ED\u636E",
247
+ unreachable: "\u672A\u914D\u7F6E\u51ED\u636E",
248
+ configured: "\u5DF2\u914D\u7F6E\uFF0C\u91CD\u65B0\u586B\u5199\u53EF\u8986\u76D6",
249
+ current: "\u5F53\u524D\u503C\uFF1A",
250
+ notConfigured: "\u672A\u914D\u7F6E",
251
+ previewNote: "\u4E3A\u4FBF\u4E8E\u786E\u8BA4\uFF0C\u6B64\u5904\u53EA\u663E\u793A\u8131\u654F\u540E\u7684\u90E8\u5206\u5B57\u7B26\uFF1B\u8F93\u5165\u6846\u7559\u7A7A\u8868\u793A\u4E0D\u4FEE\u6539\u3002",
252
+ secrets: "\u51ED\u636E\u4E0E\u5BC6\u94A5",
253
+ expand: "\u5C55\u5F00",
254
+ collapse: "\u6536\u8D77",
255
+ advanced: "\u9AD8\u7EA7\u9009\u9879",
256
+ tabsAria: "\u6E20\u9053\u5207\u6362",
257
+ "status.loading": "\u52A0\u8F7D\u4E2D\u2026",
258
+ "status.idle": "\u5C31\u7EEA",
259
+ "status.saving": "\u4FDD\u5B58\u4E2D\u2026",
260
+ "status.saved": "\u5DF2\u4FDD\u5B58",
261
+ "status.error": "\u4FDD\u5B58\u5931\u8D25",
262
+ "channel.feishu": "\u98DE\u4E66 / Lark",
263
+ "channel.feishu.hint": "\u5728\u98DE\u4E66\u5F00\u653E\u5E73\u53F0\u521B\u5EFA\u81EA\u5EFA\u5E94\u7528\uFF0C\u7528\u957F\u8FDE\u63A5\u63A5\u6536\u6D88\u606F\u3002",
264
+ "channel.telegram": "Telegram",
265
+ "channel.telegram.hint": "\u901A\u8FC7 Telegram Bot API \u957F\u8F6E\u8BE2\u63A5\u6536\u6D88\u606F\uFF0C\u673A\u5668\u4EBA\u5411 @BotFather \u7533\u8BF7\u3002",
266
+ "channel.dingtalk": "\u9489\u9489",
267
+ "channel.dingtalk.hint": "\u652F\u6301 Webhook \u63A8\u9001\u4E0E Stream \u53CC\u5411\u4E24\u79CD\u6A21\u5F0F\uFF0C\u586B\u5176\u4E2D\u4E00\u7EC4\u5373\u53EF\u3002",
268
+ "channel.web": "\u7F51\u9875",
269
+ "channel.web.hint": "\u76F4\u63A5\u5728 DSH \u7F51\u9875\u754C\u9762\u91CC\u5BF9\u8BDD\uFF0C\u4E0D\u9700\u8981\u4EFB\u4F55\u51ED\u636E\u3002",
270
+ "f.transport": "\u63A5\u5165\u65B9\u5F0F",
271
+ "f.transport.hint": "\u957F\u8FDE\u63A5\u65E0\u9700\u516C\u7F51\u5730\u5740\uFF0C\u63A8\u8350\uFF1B\u56DE\u8C03\u5730\u5740\u9700\u8981 DSH \u6240\u5728\u673A\u5668\u53EF\u4ECE\u516C\u7F51\u8BBF\u95EE\u3002",
272
+ "f.requireMention": "\u4EC5\u54CD\u5E94 @ \u63D0\u53CA",
273
+ "f.requireMention.hint": "\u5F00\u542F\u540E\u7FA4\u804A\u4E2D\u53EA\u6709 @ \u673A\u5668\u4EBA\u624D\u5904\u7406\uFF1B\u5355\u804A\u4E0D\u53D7\u5F71\u54CD\u3002",
274
+ "f.dmMode": "\u5355\u804A\u7B56\u7565",
275
+ "f.dmMode.hint": "\u63A7\u5236\u54EA\u4E9B\u4EBA\u53EF\u4EE5\u76F4\u63A5\u79C1\u804A\u673A\u5668\u4EBA\u3002",
276
+ "f.language": "\u56DE\u590D\u8BED\u8A00",
277
+ "f.language.hint": "\u673A\u5668\u4EBA\u56DE\u590D\u7528\u6237\u65F6\u4F7F\u7528\u7684\u8BED\u8A00\u3002",
278
+ "f.webhookPort": "\u56DE\u8C03\u7AEF\u53E3",
279
+ "f.webhookPort.hint": "\u4EC5\u300C\u56DE\u8C03\u5730\u5740\u300D\u63A5\u5165\u65B9\u5F0F\u4F7F\u7528\uFF0C\u9ED8\u8BA4 3000\u3002",
280
+ "f.webhookPath": "\u56DE\u8C03\u8DEF\u5F84",
281
+ "f.webhookPath.hint": "\u4EC5\u300C\u56DE\u8C03\u5730\u5740\u300D\u63A5\u5165\u65B9\u5F0F\u4F7F\u7528\uFF0C\u4F8B\u5982 /feishu/events\u3002",
282
+ "f.pollingTimeoutSeconds": "\u8F6E\u8BE2\u8D85\u65F6\uFF08\u79D2\uFF09",
283
+ "f.pollingTimeoutSeconds.hint": "\u5355\u6B21\u957F\u8F6E\u8BE2\u7B49\u5F85\u79D2\u6570\uFF0C\u9ED8\u8BA4 30\u3002",
284
+ "f.baseUrl": "\u63A5\u53E3\u5730\u5740",
285
+ "f.baseUrl.hint": "Telegram Bot API \u5730\u5740\uFF0C\u53EA\u6709\u5728\u81EA\u5EFA\u53CD\u5411\u4EE3\u7406\u65F6\u624D\u9700\u8981\u4FEE\u6539\u3002",
286
+ "f.defaultAt": "\u9ED8\u8BA4 @ \u6210\u5458",
287
+ "f.defaultAt.hint": "\u7FA4\u6D88\u606F\u9ED8\u8BA4 @ \u7684\u6210\u5458\uFF0C\u591A\u4E2A\u7528\u9017\u53F7\u5206\u9694\u3002",
288
+ "f.pollIntervalMs": "\u8F6E\u8BE2\u95F4\u9694\uFF08\u6BEB\u79D2\uFF09",
289
+ "f.pollIntervalMs.hint": "\u7F51\u9875\u6E20\u9053\u68C0\u67E5\u65B0\u6D88\u606F\u7684\u95F4\u9694\uFF0C\u9ED8\u8BA4 1000\u3002",
290
+ "f.notifyLevel": "\u901A\u77E5\u7EA7\u522B",
291
+ "f.notifyLevel.hint": "\u63A7\u5236\u673A\u5668\u4EBA\u628A\u591A\u5C11\u8FC7\u7A0B\u4FE1\u606F\u53D1\u5230\u804A\u5929\u91CC\u3002",
292
+ "s.feishu.appId": "App ID",
293
+ "s.feishu.appId.hint": "\u5F00\u653E\u5E73\u53F0\u300C\u51ED\u8BC1\u4E0E\u57FA\u7840\u4FE1\u606F\u300D\u4E2D\u7684 App ID\uFF0C\u975E\u673A\u5BC6\uFF0C\u5B8C\u6574\u663E\u793A\u3002",
294
+ "s.feishu.appSecret": "App Secret",
295
+ "s.feishu.appSecret.hint": "\u4E0E App ID \u914D\u5BF9\u7684\u5E94\u7528\u5BC6\u94A5\uFF0C\u5C5E\u4E8E\u673A\u5BC6\uFF0C\u4EC5\u663E\u793A\u9996\u5C3E\u5404 4 \u4F4D\u3002",
296
+ "s.telegram.botToken": "Bot Token",
297
+ "s.telegram.botToken.hint": "@BotFather \u751F\u6210\u7684\u673A\u5668\u4EBA\u4EE4\u724C\uFF0C\u5F62\u5982 123456:ABC\u2026\uFF0C\u5C5E\u4E8E\u673A\u5BC6\u3002",
298
+ "s.dingtalk.webhookUrl": "Webhook \u5730\u5740",
299
+ "s.dingtalk.webhookUrl.hint": "\u7FA4\u673A\u5668\u4EBA\u7684\u5B8C\u6574 Webhook \u5730\u5740\uFF1B\u9884\u89C8\u4FDD\u7559\u57DF\u540D\u4E0E\u8DEF\u5F84\uFF0C\u53EA\u906E\u853D access_token\u3002",
300
+ "s.dingtalk.secret": "\u52A0\u7B7E\u5BC6\u94A5",
301
+ "s.dingtalk.secret.hint": "\u7FA4\u673A\u5668\u4EBA\u5F00\u542F\u300C\u52A0\u7B7E\u300D\u5B89\u5168\u8BBE\u7F6E\u540E\u624D\u6709\uFF0C\u5C5E\u4E8E\u673A\u5BC6\u3002",
302
+ "s.dingtalk.clientId": "Client ID",
303
+ "s.dingtalk.clientId.hint": "Stream \u6A21\u5F0F\u5E94\u7528\u7684 AppKey\uFF0C\u975E\u673A\u5BC6\uFF0C\u5B8C\u6574\u663E\u793A\u3002",
304
+ "s.dingtalk.clientSecret": "Client Secret",
305
+ "s.dingtalk.clientSecret.hint": "Stream \u6A21\u5F0F\u5E94\u7528\u7684 AppSecret\uFF0C\u5C5E\u4E8E\u673A\u5BC6\uFF0C\u4EC5\u663E\u793A\u9996\u5C3E\u5404 4 \u4F4D\u3002",
306
+ "o.default": "\uFF08\u6CBF\u7528\u9ED8\u8BA4\uFF09",
307
+ "o.websocket": "\u957F\u8FDE\u63A5\uFF08\u63A8\u8350\uFF09",
308
+ "o.webhook": "\u56DE\u8C03\u5730\u5740",
309
+ "o.open": "\u6240\u6709\u4EBA\u90FD\u53EF\u4EE5",
310
+ "o.allowlist": "\u4EC5\u540D\u5355\u5185\u7684\u4EBA",
311
+ "o.pair": "\u9700\u8981\u5148\u914D\u5BF9",
312
+ "o.disabled": "\u5173\u95ED\u5355\u804A",
313
+ "o.zh": "\u4E2D\u6587",
314
+ "o.en": "\u82F1\u6587",
315
+ "o.full": "\u5168\u90E8\u8FC7\u7A0B",
316
+ "o.important": "\u5173\u952E\u8282\u70B9",
317
+ "o.result": "\u4EC5\u6700\u7EC8\u7ED3\u679C"
318
+ },
319
+ en: {
320
+ title: "dsh-connect",
321
+ channels: "Channels",
322
+ defaults: "Defaults",
323
+ defaultsHint: "Channels without their own value fall back to these; leave a field empty to use each channel\u2019s built-in default.",
324
+ save: "Save",
325
+ saved: "Saved",
326
+ error: "Save failed",
327
+ loading: "Loading\u2026",
328
+ statePath: "Settings file",
329
+ statePathHint: "Where the config falls back to when no settings.yaml namespace is live.",
330
+ livePlane: "Stored in settings.yaml \u2014 a save takes effect immediately.",
331
+ filePlane: "Stored in a local settings file \u2014 a save applies after dsh restarts.",
332
+ reachable: "Credentials set",
333
+ unreachable: "Credentials missing",
334
+ configured: "Configured \u2014 type to replace",
335
+ current: "Current value: ",
336
+ notConfigured: "Not configured",
337
+ previewNote: "Shown masked, so you can confirm the stored value without exposing it. Leave the input empty to keep it.",
338
+ secrets: "Credentials & secrets",
339
+ expand: "Expand",
340
+ collapse: "Collapse",
341
+ advanced: "Advanced",
342
+ tabsAria: "Channel switcher",
343
+ "status.loading": "Loading\u2026",
344
+ "status.idle": "Ready",
345
+ "status.saving": "Saving\u2026",
346
+ "status.saved": "Saved",
347
+ "status.error": "Save failed",
348
+ "channel.feishu": "Feishu / Lark",
349
+ "channel.feishu.hint": "Create a custom app on the Feishu open platform and receive messages over a long connection.",
350
+ "channel.telegram": "Telegram",
351
+ "channel.telegram.hint": "Receive messages by long-polling the Telegram Bot API; get a bot from @BotFather.",
352
+ "channel.dingtalk": "DingTalk",
353
+ "channel.dingtalk.hint": "Supports both webhook push and stream mode \u2014 fill in either set.",
354
+ "channel.web": "Web",
355
+ "channel.web.hint": "Chat straight from the DSH web UI; needs no credentials at all.",
356
+ "f.transport": "Transport",
357
+ "f.transport.hint": "A long connection needs no public address and is recommended; a callback URL requires this machine to be reachable from the internet.",
358
+ "f.requireMention": "Only when @-mentioned",
359
+ "f.requireMention.hint": "In group chats, handle a message only if the bot was mentioned. Direct messages are unaffected.",
360
+ "f.dmMode": "Direct messages",
361
+ "f.dmMode.hint": "Who is allowed to message the bot directly.",
362
+ "f.language": "Reply language",
363
+ "f.language.hint": "The language the bot replies to users in.",
364
+ "f.webhookPort": "Callback port",
365
+ "f.webhookPort.hint": "Used only by the callback-URL transport; defaults to 3000.",
366
+ "f.webhookPath": "Callback path",
367
+ "f.webhookPath.hint": "Used only by the callback-URL transport, e.g. /feishu/events.",
368
+ "f.pollingTimeoutSeconds": "Poll timeout (s)",
369
+ "f.pollingTimeoutSeconds.hint": "How long one long poll waits; defaults to 30.",
370
+ "f.baseUrl": "API base URL",
371
+ "f.baseUrl.hint": "The Telegram Bot API endpoint \u2014 only change it if you run your own proxy.",
372
+ "f.defaultAt": "Default @-list",
373
+ "f.defaultAt.hint": "Members to @ by default on group messages, comma-separated.",
374
+ "f.pollIntervalMs": "Poll interval (ms)",
375
+ "f.pollIntervalMs.hint": "How often the web channel checks for new messages; defaults to 1000.",
376
+ "f.notifyLevel": "Notify level",
377
+ "f.notifyLevel.hint": "How much of the working process the bot posts into the chat.",
378
+ "s.feishu.appId": "App ID",
379
+ "s.feishu.appId.hint": "The App ID from the open platform\u2019s credentials page. Not a secret \u2014 shown in full.",
380
+ "s.feishu.appSecret": "App Secret",
381
+ "s.feishu.appSecret.hint": "The app secret paired with the App ID. Confidential \u2014 only the first and last 4 characters are shown.",
382
+ "s.telegram.botToken": "Bot Token",
383
+ "s.telegram.botToken.hint": "The bot token from @BotFather, like 123456:ABC\u2026 \u2014 confidential.",
384
+ "s.dingtalk.webhookUrl": "Webhook URL",
385
+ "s.dingtalk.webhookUrl.hint": "The group robot\u2019s full webhook URL; the preview keeps the host and path and masks only the access_token.",
386
+ "s.dingtalk.secret": "Signing secret",
387
+ "s.dingtalk.secret.hint": "Only present once the group robot has the \u201Csign\u201D security setting enabled \u2014 confidential.",
388
+ "s.dingtalk.clientId": "Client ID",
389
+ "s.dingtalk.clientId.hint": "The AppKey of the stream-mode app. Not a secret \u2014 shown in full.",
390
+ "s.dingtalk.clientSecret": "Client Secret",
391
+ "s.dingtalk.clientSecret.hint": "The AppSecret of the stream-mode app. Confidential \u2014 only the first and last 4 characters are shown.",
392
+ "o.default": "(use default)",
393
+ "o.websocket": "Long connection (recommended)",
394
+ "o.webhook": "Callback URL",
395
+ "o.open": "Anyone",
396
+ "o.allowlist": "Allowlisted users only",
397
+ "o.pair": "Pairing required",
398
+ "o.disabled": "Direct messages off",
399
+ "o.zh": "Chinese",
400
+ "o.en": "English",
401
+ "o.full": "Everything",
402
+ "o.important": "Key milestones",
403
+ "o.result": "Final result only"
404
+ }
405
+ };
406
+ var LOCALE_KEYS = Object.keys(LOCALES.zh);
407
+ function hasLocale(key) {
408
+ return Object.prototype.hasOwnProperty.call(LOCALES.zh, key) && Object.prototype.hasOwnProperty.call(LOCALES.en, key);
409
+ }
410
+ function tr(t, key, fallback) {
411
+ return hasLocale(key) ? t(key) : fallback;
412
+ }
413
+ function optionalText(t, key) {
414
+ return hasLocale(key) ? t(key) : void 0;
415
+ }
416
+
417
+ // client/panel-state.mjs
418
+ var ADVANCED_KEYS = {
419
+ feishu: ["webhookPort", "webhookPath"],
420
+ telegram: ["pollingTimeoutSeconds", "baseUrl"],
421
+ dingtalk: ["defaultAt"],
422
+ web: ["pollIntervalMs"]
423
+ };
424
+ function isAdvanced(channel, key) {
425
+ return (ADVANCED_KEYS[channel] ?? []).includes(key);
426
+ }
427
+ function initialOpenChannels(enabled, all) {
428
+ const open = (enabled ?? []).filter((ch) => all.includes(ch));
429
+ return new Set(open.length > 0 ? open : all.slice(0, 1));
430
+ }
431
+ function toggleInSet(set, key) {
432
+ const next = new Set(set);
433
+ if (next.has(key)) next.delete(key);
434
+ else next.add(key);
435
+ return next;
436
+ }
437
+
189
438
  // client/settings-client.mjs
190
439
  var name = "dsh-connect-settings";
191
440
  var inject = ["slots", "connection", "locale"];
192
441
  var NS = "dsh-connect";
193
- var locale = {
194
- zh: { title: "\u8FDE\u63A5\u8BBE\u7F6E", channels: "\u6E20\u9053", save: "\u4FDD\u5B58", saved: "\u5DF2\u4FDD\u5B58", error: "\u4FDD\u5B58\u5931\u8D25", loading: "\u52A0\u8F7D\u4E2D", defaults: "\u516C\u5171\u9ED8\u8BA4(channelDefaults)", statePath: "\u8BBE\u7F6E\u6587\u4EF6", secret: "\u5BC6\u94A5", config: "\u914D\u7F6E", reachable: "\u5DF2\u8FDE\u63A5\u51ED\u636E", unreachable: "\u672A\u914D\u7F6E\u51ED\u636E" },
195
- en: { title: "Connection Settings", channels: "Channels", save: "Save", saved: "Saved", error: "Save failed", loading: "Loading", defaults: "Defaults (channelDefaults)", statePath: "Settings file", secret: "Secret", config: "Config", reachable: "Credential set", unreachable: "Credential missing" }
196
- };
442
+ var locale = LOCALES;
197
443
  var h = React.createElement;
444
+ var ALL_CHANNELS = Object.keys(CHANNEL_SECRET_FIELDS);
198
445
  var STYLE = `
199
- .dsh-connect-settings{--ds-bg:#ffffff;--ds-bg-sub:#f6f7f9;--ds-text:#1f2329;--ds-muted:#646a73;--ds-border:#e2e4e8;--ds-border-2:#c8cbd0;--ds-accent:#3b82f6;--ds-accent-fg:#ffffff;--ds-focus:rgba(59,130,246,.22);display:flex;flex-direction:column;gap:14px;min-width:280px;max-width:760px;color:var(--ds-text);font:13px/1.6 -apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;padding:4px 2px}
200
- @media(prefers-color-scheme:dark){.dsh-connect-settings{--ds-bg:#1a1b1e;--ds-bg-sub:#232428;--ds-text:#e8eaed;--ds-muted:#9aa0a6;--ds-border:#3b3d42;--ds-border-2:#5c5f66;--ds-accent:#5b9dff;--ds-accent-fg:#0f1115;--ds-focus:rgba(91,157,255,.25)}}
201
- .dsh-connect-settings .ds-card{border:1px solid var(--ds-border);border-radius:10px;background:var(--ds-bg);padding:12px 14px;display:flex;flex-direction:column;gap:10px}
446
+ .dsh-connect-settings,.dsh-connect-settings *,.dsh-connect-settings *::before,.dsh-connect-settings *::after{box-sizing:border-box}
447
+ .dsh-connect-settings{--ds-bg:var(--dsw-alias-bg-layer-3,#ffffff);--ds-bg-sub:var(--dsw-alias-bg-layer-1,#f6f7f9);--ds-text:var(--dsw-alias-label-primary,#1f2329);--ds-muted:var(--dsw-alias-label-tertiary,#646a73);--ds-border:var(--dsw-alias-border-l2,#e2e4e8);--ds-border-2:var(--dsw-alias-border-l3,#c8cbd0);--ds-accent:var(--dsw-alias-state-business-primary,#3b82f6);--ds-hover:var(--dsw-alias-interactive-bg-hover,#2631480f);display:flex;flex-direction:column;gap:12px;max-width:760px;color:var(--ds-text);font:13px/1.6 -apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
448
+ .dsh-connect-settings .ds-card{display:flex;flex-direction:column;gap:10px;border:1px solid var(--ds-border);border-radius:10px;background:var(--ds-bg);padding:12px 14px}
202
449
  .dsh-connect-settings .ds-card-title{margin:0;font-size:13px;font-weight:600}
203
- .dsh-connect-settings .ds-channel{border:1px dashed var(--ds-border-2);border-radius:8px;padding:8px 12px;margin:0;background:var(--ds-bg-sub)}
204
- .dsh-connect-settings .ds-legend{padding:0 6px}
205
- .dsh-connect-settings .ds-legend label{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600}
206
- .dsh-connect-settings .ds-badge{margin-left:4px;font-size:11px;font-weight:500;padding:1px 8px;border-radius:99px;background:var(--ds-bg);color:var(--ds-muted);border:1px solid var(--ds-border-2)}
207
- .dsh-connect-settings .ds-fields{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px 14px;margin-top:8px}
208
- .dsh-connect-settings .ds-field{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--ds-muted)}
209
- .dsh-connect-settings .ds-field.ds-check-field{flex-direction:row;align-items:center;gap:6px}
210
- .dsh-connect-settings .ds-check{width:16px;height:16px;accent-color:var(--ds-accent)}
211
- .dsh-connect-settings .ds-input{height:30px;width:100%;padding:0 9px;border:1px solid var(--ds-border-2);border-radius:6px;background:var(--ds-bg);color:var(--ds-text);font:inherit}
450
+ .dsh-connect-settings .ds-note{margin:0;font-size:11px;line-height:1.5;color:var(--ds-muted)}
451
+ .dsh-connect-settings .ds-tabs{display:flex;flex-wrap:wrap;gap:6px}
452
+ .dsh-connect-settings .ds-tab{display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 10px;border:1px solid var(--ds-border);border-radius:14px;background:transparent;color:var(--ds-text);font:inherit;font-size:12px;cursor:pointer}
453
+ .dsh-connect-settings .ds-tab:hover{background:var(--ds-hover)}
454
+ .dsh-connect-settings .ds-tab[aria-expanded=true]{border-color:var(--ds-accent);color:var(--ds-accent)}
455
+ .dsh-connect-settings .ds-dot{width:6px;height:6px;border-radius:50%;background:var(--ds-border-2);flex:none}
456
+ .dsh-connect-settings .ds-dot[data-on="1"]{background:var(--ds-accent)}
457
+ .dsh-connect-settings .ds-channel{border:1px solid var(--ds-border);border-radius:8px;background:var(--ds-bg-sub)}
458
+ .dsh-connect-settings .ds-channel-head{display:flex;align-items:center;gap:8px;padding:5px 10px}
459
+ .dsh-connect-settings .ds-enable{display:flex;align-items:center;flex:none}
460
+ .dsh-connect-settings .ds-channel-toggle{flex:1;min-width:0;display:flex;align-items:center;gap:8px;padding:5px 6px;border:0;border-radius:6px;background:transparent;color:inherit;font:inherit;font-size:12px;font-weight:600;text-align:left;cursor:pointer}
461
+ .dsh-connect-settings .ds-channel-toggle:hover{background:var(--ds-hover);color:var(--ds-accent)}
462
+ .dsh-connect-settings .ds-channel-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
463
+ .dsh-connect-settings .ds-channel-body{display:flex;flex-direction:column;gap:8px;padding:0 10px 10px}
464
+ .dsh-connect-settings .ds-chevron{width:7px;height:7px;margin:-3px 4px 0 auto;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(45deg);transition:transform .15s ease;flex:none}
465
+ .dsh-connect-settings [aria-expanded=true]>.ds-chevron,.dsh-connect-settings [aria-expanded=true] .ds-chevron{transform:rotate(-135deg);margin-top:2px}
466
+ .dsh-connect-settings .ds-badge{flex:none;font-size:11px;font-weight:500;padding:1px 8px;border-radius:99px;background:var(--ds-bg);color:var(--ds-muted);border:1px solid var(--ds-border-2)}
467
+ .dsh-connect-settings .ds-fields{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px 14px}
468
+ .dsh-connect-settings .ds-field{display:flex;flex-direction:column;gap:4px;min-width:0}
469
+ .dsh-connect-settings .ds-control{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--ds-muted)}
470
+ .dsh-connect-settings .ds-control.ds-check-field{flex-direction:row;align-items:center;gap:6px}
471
+ .dsh-connect-settings .ds-hint{margin:0;font-size:11px;line-height:1.5;color:var(--ds-muted)}
472
+ .dsh-connect-settings .ds-preview{margin:0;font-size:11px;line-height:1.5;color:var(--ds-muted);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;word-break:break-all}
473
+ .dsh-connect-settings .ds-preview b{font-weight:500;font-family:inherit;opacity:.75}
474
+ .dsh-connect-settings .ds-check{flex:none;width:16px;height:16px;accent-color:var(--ds-accent)}
475
+ .dsh-connect-settings .ds-input{height:30px;width:100%;min-width:0;padding:0 9px;border:1px solid var(--ds-border-2);border-radius:6px;background:var(--ds-bg);color:var(--ds-text);font:inherit}
212
476
  .dsh-connect-settings select.ds-input{cursor:pointer}
213
- .dsh-connect-settings .ds-input:focus{outline:none;border-color:var(--ds-accent);box-shadow:0 0 0 2px var(--ds-focus)}
214
- .dsh-connect-settings .ds-actions{display:flex;align-items:center;gap:12px;margin-top:2px}
215
- .dsh-connect-settings .ds-btn{height:32px;padding:0 18px;border:none;border-radius:6px;background:var(--ds-accent);color:var(--ds-accent-fg);font:inherit;font-weight:500;cursor:pointer}
477
+ .dsh-connect-settings .ds-input:focus{outline:none;border-color:var(--ds-accent);box-shadow:0 0 0 2px rgba(59,130,246,.25);box-shadow:0 0 0 2px color-mix(in srgb,var(--ds-accent) 25%,transparent)}
478
+ .dsh-connect-settings .ds-adv{display:flex;flex-direction:column;gap:8px}
479
+ .dsh-connect-settings .ds-advanced-toggle{align-self:flex-start;display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border:1px dashed var(--ds-border-2);border-radius:99px;background:transparent;color:var(--ds-muted);font:inherit;font-size:11px;cursor:pointer}
480
+ .dsh-connect-settings .ds-advanced-toggle:hover{background:var(--ds-hover);color:var(--ds-text)}
481
+ .dsh-connect-settings .ds-footer{position:sticky;bottom:0;z-index:1;display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--ds-border);border-radius:10px;background:var(--ds-bg)}
482
+ .dsh-connect-settings .ds-btn{height:32px;padding:0 18px;border:0;border-radius:6px;background:var(--dsw-alias-button-primary-fill,var(--ds-accent));color:var(--dsw-alias-label-primary-foreground,#ffffff);font:inherit;font-weight:500;cursor:pointer}
483
+ .dsh-connect-settings .ds-btn:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,var(--ds-accent))}
216
484
  .dsh-connect-settings .ds-btn:disabled{opacity:.55;cursor:default}
217
485
  .dsh-connect-settings .ds-status{font-size:12px;color:var(--ds-muted)}
218
486
  `;
@@ -224,48 +492,174 @@ function injectStyles() {
224
492
  el.textContent = STYLE;
225
493
  document.head.appendChild(el);
226
494
  }
227
- function renderConfigField(field, value, onChange) {
228
- const label = field.label ?? field.key;
495
+ function renderConfigField(field, value, onChange, t) {
496
+ const label = tr(t, `f.${field.key}`, field.label ?? field.key);
497
+ const hint = optionalText(t, `f.${field.key}.hint`);
498
+ let control;
229
499
  if (field.kind === "boolean") {
230
- return h(
500
+ control = h(
231
501
  "label",
232
- { className: "ds-field ds-check-field", key: `cfg-${field.key}` },
502
+ { className: "ds-control ds-check-field" },
233
503
  h("input", { className: "ds-check", type: "checkbox", checked: !!value, onChange: (e) => onChange(e.target.checked) }),
234
504
  " " + label
235
505
  );
236
- }
237
- if (field.kind === "select") {
238
- return h(
506
+ } else if (field.kind === "select") {
507
+ control = h(
239
508
  "label",
240
- { className: "ds-field", key: `cfg-${field.key}` },
509
+ { className: "ds-control" },
241
510
  label + " ",
242
511
  h(
243
512
  "select",
244
513
  { className: "ds-input", value: value ?? "", onChange: (e) => onChange(e.target.value) },
245
- h("option", { value: "" }, "(default)"),
246
- ...(field.options ?? []).map((opt) => h("option", { key: opt, value: opt }, opt))
514
+ h("option", { value: "" }, tr(t, "o.default", label)),
515
+ ...(field.options ?? []).map((opt) => h("option", { key: opt, value: opt }, tr(t, `o.${opt}`, opt)))
247
516
  )
248
517
  );
249
- }
250
- if (field.kind === "number") {
251
- return h(
518
+ } else if (field.kind === "number") {
519
+ control = h(
252
520
  "label",
253
- { className: "ds-field", key: `cfg-${field.key}` },
521
+ { className: "ds-control" },
254
522
  label + " ",
255
523
  h("input", { className: "ds-input", type: "number", value: value ?? "", onChange: (e) => onChange(e.target.value) })
256
524
  );
525
+ } else {
526
+ control = h(
527
+ "label",
528
+ { className: "ds-control" },
529
+ label + " ",
530
+ h("input", { className: "ds-input", type: "text", value: value ?? "", onChange: (e) => onChange(e.target.value) })
531
+ );
257
532
  }
258
533
  return h(
259
- "label",
534
+ "div",
260
535
  { className: "ds-field", key: `cfg-${field.key}` },
261
- label + " ",
262
- h("input", { className: "ds-input", type: "text", value: value ?? "", onChange: (e) => onChange(e.target.value) })
536
+ control,
537
+ hint ? h("p", { className: "ds-hint" }, hint) : null
538
+ );
539
+ }
540
+ function renderSecretField(ch, field, form, onChange, t) {
541
+ const preview = form.secretPreviews?.[ch]?.[field] ?? "";
542
+ const hint = optionalText(t, `s.${ch}.${field}.hint`);
543
+ return h(
544
+ "div",
545
+ { className: "ds-field", key: `sec-${ch}-${field}` },
546
+ h(
547
+ "label",
548
+ { className: "ds-control" },
549
+ tr(t, `s.${ch}.${field}`, field),
550
+ h("input", {
551
+ className: "ds-input",
552
+ // Confidential keys stay masked while typing; identifiers (appId,
553
+ // clientId) don't, so a typo is visible before it is ever saved.
554
+ type: isMaskedSecret(field) ? "password" : "text",
555
+ autoComplete: "off",
556
+ placeholder: preview ? t("configured") : t("notConfigured"),
557
+ value: form.secrets?.[ch]?.[field] ?? "",
558
+ onChange: (e) => onChange(e.target.value)
559
+ })
560
+ ),
561
+ h(
562
+ "p",
563
+ { className: "ds-preview" },
564
+ h("b", null, t("current")),
565
+ preview === "" ? t("notConfigured") : preview
566
+ ),
567
+ hint ? h("p", { className: "ds-hint" }, hint) : null
568
+ );
569
+ }
570
+ function renderChannel(ch, ctx) {
571
+ const { form, creds, t, open, advOverride, setChannels, setField, setChannelConfig, toggleOpen, toggleAdvanced } = ctx;
572
+ const name2 = tr(t, `channel.${ch}`, ch);
573
+ const channelHint = optionalText(t, `channel.${ch}.hint`);
574
+ const isOpen = open.has(ch);
575
+ const advOpen = advOverride?.has(ch) ?? false;
576
+ const configFields = CHANNEL_CONFIG_FIELDS[ch] ?? [];
577
+ const common = configFields.filter((f) => !isAdvanced(ch, f.key));
578
+ const advanced = configFields.filter((f) => isAdvanced(ch, f.key));
579
+ const configField = (field) => renderConfigField(
580
+ field,
581
+ form.channelConfigs?.[ch]?.[field.key],
582
+ (raw) => setChannelConfig(ch, field.key, raw),
583
+ t
584
+ );
585
+ return h(
586
+ "div",
587
+ { className: "ds-channel", key: ch, id: `ds-ch-${ch}` },
588
+ // The checkbox sits beside the header button, not inside it: a button may not
589
+ // contain another interactive element, and browsers disagree about what to do
590
+ // when one does.
591
+ h(
592
+ "div",
593
+ { className: "ds-channel-head" },
594
+ h(
595
+ "label",
596
+ { className: "ds-enable" },
597
+ h("input", {
598
+ className: "ds-check",
599
+ type: "checkbox",
600
+ "aria-label": name2,
601
+ checked: form.channels.includes(ch),
602
+ onChange: (e) => setChannels(ch, e.target.checked)
603
+ })
604
+ ),
605
+ h(
606
+ "button",
607
+ {
608
+ type: "button",
609
+ className: "ds-channel-toggle",
610
+ "aria-expanded": isOpen,
611
+ // Only while the body exists: pointing at an id that is not in the
612
+ // document is worse than not pointing at all.
613
+ "aria-controls": isOpen ? `ds-ch-${ch}-body` : void 0,
614
+ "aria-label": `${name2} \u2014 ${isOpen ? t("collapse") : t("expand")}`,
615
+ onClick: () => toggleOpen(ch)
616
+ },
617
+ h("span", { className: "ds-channel-name" }, name2),
618
+ h("span", { className: "ds-badge" }, creds[ch] ? t("reachable") : t("unreachable")),
619
+ // Drawn in CSS, never a text node: the bundle test reads rendered text as
620
+ // user-visible copy, and a `▾` here would be collected as a stray string.
621
+ h("span", { className: "ds-chevron" })
622
+ )
623
+ ),
624
+ // Collapsed means *not rendered*, not `display:none`. The inputs are
625
+ // controlled from the parent's `form.secrets`, so folding a card cannot lose
626
+ // an unsaved secret — keeping it mounted would buy nothing.
627
+ isOpen ? h(
628
+ "div",
629
+ { className: "ds-channel-body", id: `ds-ch-${ch}-body` },
630
+ channelHint ? h("p", { className: "ds-note" }, channelHint) : null,
631
+ h(
632
+ "div",
633
+ { className: "ds-fields" },
634
+ ...CHANNEL_SECRET_FIELDS[ch].map((field) => renderSecretField(ch, field, form, (value) => setField(ch, field, value), t)),
635
+ ...common.map(configField)
636
+ ),
637
+ advanced.length === 0 ? null : h(
638
+ "div",
639
+ { className: "ds-adv" },
640
+ h(
641
+ "button",
642
+ {
643
+ type: "button",
644
+ className: "ds-advanced-toggle",
645
+ "aria-expanded": advOpen,
646
+ "aria-controls": advOpen ? `ds-ch-${ch}-adv` : void 0,
647
+ onClick: () => toggleAdvanced(ch)
648
+ },
649
+ h("span", null, t("advanced")),
650
+ h("span", { className: "ds-chevron" })
651
+ ),
652
+ advOpen ? h("div", { className: "ds-fields", id: `ds-ch-${ch}-adv` }, ...advanced.map(configField)) : null
653
+ )
654
+ ) : null
263
655
  );
264
656
  }
265
657
  function ConnectSettingsTab({ rpcCall, t }) {
266
658
  const [form, setForm] = React.useState(null);
267
659
  const [status, setStatus] = React.useState("loading");
268
660
  const [creds, setCreds] = React.useState({});
661
+ const [openOverride, setOpenOverride] = React.useState(null);
662
+ const [advOverride, setAdvOverride] = React.useState(null);
269
663
  const rpc = (endpoint, payload) => rpcCall(endpoint, payload);
270
664
  React.useEffect(() => {
271
665
  let alive = true;
@@ -283,14 +677,19 @@ function ConnectSettingsTab({ rpcCall, t }) {
283
677
  if (!form) return;
284
678
  setStatus("saving");
285
679
  try {
286
- await saveSettings(rpc, buildConfigSave(form));
287
- for (const c of buildCredentialSaves(form)) await saveCredentials(rpc, c.channel, c.values);
680
+ let snap = await saveSettings(rpc, buildConfigSave(form));
681
+ for (const c of buildCredentialSaves(form)) snap = await saveCredentials(rpc, c.channel, c.values);
682
+ setForm(snapshotToForm(snap));
683
+ setCreds(snap.credentials ?? {});
288
684
  setStatus("saved");
289
685
  } catch {
290
686
  setStatus("error");
291
687
  }
292
688
  };
293
- const setChannels = (ch, on) => setForm((f) => ({ ...f, channels: on ? [...f.channels, ch] : f.channels.filter((x) => x !== ch) }));
689
+ const setChannels = (ch, on) => {
690
+ setForm((f) => ({ ...f, channels: on ? [...f.channels, ch] : f.channels.filter((x) => x !== ch) }));
691
+ setOpenOverride(on && !open.has(ch) ? new Set(open).add(ch) : new Set(open));
692
+ };
294
693
  const setField = (ch, field, value) => setForm((f) => ({ ...f, secrets: { ...f.secrets, [ch]: { ...f.secrets[ch] ?? {}, [field]: value } } }));
295
694
  const setChannelConfig = (ch, key, raw) => setForm((f) => {
296
695
  const descriptor = (CHANNEL_CONFIG_FIELDS[ch] ?? []).find((x) => x.key === key);
@@ -309,6 +708,14 @@ function ConnectSettingsTab({ rpcCall, t }) {
309
708
  return { ...f, channelDefaults: defaults };
310
709
  });
311
710
  if (!form) return h("div", { className: "dsh-connect-settings" }, t("loading"));
711
+ const open = openOverride ?? initialOpenChannels(form.channels, ALL_CHANNELS);
712
+ const toggleOpen = (ch) => setOpenOverride(toggleInSet(open, ch));
713
+ const toggleAdvanced = (ch) => setAdvOverride(toggleInSet(advOverride ?? /* @__PURE__ */ new Set(), ch));
714
+ const focusChannel = (ch) => {
715
+ if (!open.has(ch)) setOpenOverride(new Set(open).add(ch));
716
+ if (typeof document === "undefined" || !document.getElementById) return;
717
+ document.getElementById(`ds-ch-${ch}`)?.scrollIntoView?.({ block: "nearest" });
718
+ };
312
719
  return h(
313
720
  "div",
314
721
  { className: "dsh-connect-settings" },
@@ -316,54 +723,80 @@ function ConnectSettingsTab({ rpcCall, t }) {
316
723
  "section",
317
724
  { className: "ds-card" },
318
725
  h("h4", { className: "ds-card-title" }, t("channels")),
319
- ...Object.keys(CHANNEL_SECRET_FIELDS).map((ch) => h(
320
- "fieldset",
321
- { className: "ds-channel", key: ch },
322
- h(
323
- "legend",
324
- { className: "ds-legend" },
325
- h(
326
- "label",
327
- null,
328
- h("input", { className: "ds-check", type: "checkbox", checked: form.channels.includes(ch), onChange: (e) => setChannels(ch, e.target.checked) }),
329
- h("span", null, ch),
330
- h("span", { className: "ds-badge" }, creds[ch] ? t("reachable") : t("unreachable"))
331
- )
332
- ),
333
- h(
334
- "div",
335
- { className: "ds-fields" },
336
- ...CHANNEL_SECRET_FIELDS[ch].map((field) => h(
337
- "label",
338
- { className: "ds-field", key: `sec-${field}` },
339
- field,
340
- h("input", { className: "ds-input", type: "password", placeholder: field, value: form.secrets?.[ch]?.[field] ?? "", onChange: (e) => setField(ch, field, e.target.value) })
341
- )),
342
- ...(CHANNEL_CONFIG_FIELDS[ch] ?? []).map((field) => renderConfigField(field, form.channelConfigs?.[ch]?.[field.key], (raw) => setChannelConfig(ch, field.key, raw)))
343
- )
344
- ))
726
+ // Explains the masking before the user meets a truncated value and wonders
727
+ // whether their stored secret is corrupt.
728
+ h("p", { className: "ds-note" }, t("previewNote")),
729
+ // A tab strip, but not `role=tablist`: several channels can be open at
730
+ // once, so there is no single "selected" tab to report. These are buttons
731
+ // that open and jump to a channel, and `aria-expanded` says so honestly.
732
+ h(
733
+ "nav",
734
+ { className: "ds-tabs", "aria-label": t("tabsAria") },
735
+ ...ALL_CHANNELS.map((ch) => h(
736
+ "button",
737
+ {
738
+ key: `tab-${ch}`,
739
+ type: "button",
740
+ className: "ds-tab",
741
+ "aria-expanded": open.has(ch),
742
+ "aria-controls": open.has(ch) ? `ds-ch-${ch}-body` : void 0,
743
+ onClick: () => focusChannel(ch)
744
+ },
745
+ h("span", { className: "ds-dot", "data-on": form.channels.includes(ch) ? "1" : "0" }),
746
+ tr(t, `channel.${ch}`, ch)
747
+ ))
748
+ ),
749
+ ...ALL_CHANNELS.map((ch) => renderChannel(ch, {
750
+ form,
751
+ creds,
752
+ t,
753
+ open,
754
+ advOverride,
755
+ setChannels,
756
+ setField,
757
+ setChannelConfig,
758
+ toggleOpen,
759
+ toggleAdvanced
760
+ }))
345
761
  ),
346
762
  h(
347
763
  "section",
348
764
  { className: "ds-card" },
349
765
  h("h4", { className: "ds-card-title" }, t("defaults")),
766
+ h("p", { className: "ds-note" }, t("defaultsHint")),
350
767
  h(
351
768
  "div",
352
769
  { className: "ds-fields" },
353
- ...CHANNEL_DEFAULT_FIELDS.map((field) => renderConfigField(field, form.channelDefaults?.[field.key], (raw) => setDefault(field.key, raw))),
354
- h(
355
- "label",
770
+ ...CHANNEL_DEFAULT_FIELDS.map((field) => renderConfigField(field, form.channelDefaults?.[field.key], (raw) => setDefault(field.key, raw), t)),
771
+ // Only meaningful on the fallback plane: it names the file the pane
772
+ // persists to. With the namespace live that path is not consulted (and
773
+ // is not part of the section), so showing an editable field for it would
774
+ // silently swallow edits.
775
+ form.live ? null : h(
776
+ "div",
356
777
  { className: "ds-field" },
357
- t("statePath"),
358
- h("input", { className: "ds-input", value: form.settingsStatePath ?? "", onChange: (e) => setForm((f) => ({ ...f, settingsStatePath: e.target.value })) })
778
+ h(
779
+ "label",
780
+ { className: "ds-control" },
781
+ t("statePath"),
782
+ h("input", { className: "ds-input", value: form.settingsStatePath ?? "", onChange: (e) => setForm((f) => ({ ...f, settingsStatePath: e.target.value })) })
783
+ ),
784
+ h("p", { className: "ds-hint" }, t("statePathHint"))
359
785
  )
360
786
  ),
361
- h(
362
- "div",
363
- { className: "ds-actions" },
364
- h("button", { className: "ds-btn", onClick: onSave, disabled: status === "saving" }, t("save")),
365
- h("span", { className: "ds-status" }, status === "saved" ? t("saved") : status === "error" ? t("error") : status)
366
- )
787
+ h("div", { className: "ds-status" }, form.live ? t("livePlane") : t("filePlane"))
788
+ ),
789
+ // Last child of the root, not of a card: `position:sticky` pins to the
790
+ // nearest scrollport only while its containing block is the scrolled box.
791
+ // Nested in the defaults card it could never move outside that card, so it
792
+ // pinned to nothing and Save scrolled away with the channel list.
793
+ h(
794
+ "div",
795
+ { className: "ds-footer" },
796
+ h("button", { className: "ds-btn", type: "button", onClick: onSave, disabled: status === "saving" }, t("save")),
797
+ // Rendering `status` directly leaks the raw state ids (`idle`, `saving`)
798
+ // into the UI; every state has a locale entry instead.
799
+ h("span", { className: "ds-status" }, tr(t, `status.${status}`, status))
367
800
  )
368
801
  );
369
802
  }