dsh-connect 0.7.2 → 0.9.0
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.
- package/README.i18n.yaml +2 -2
- package/README.md +196 -43
- package/README.zh.md +171 -42
- package/client/client.js +817 -0
- package/client/client.js.map +7 -0
- package/client/locale.mjs +227 -0
- package/client/panel-state.mjs +54 -0
- package/client/settings-client.mjs +389 -0
- package/docs/images/settings-advanced-en.png +0 -0
- package/docs/images/settings-advanced-zh.png +0 -0
- package/docs/images/settings-defaults-en.png +0 -0
- package/docs/images/settings-defaults-zh.png +0 -0
- package/docs/images/settings-overview-en.png +0 -0
- package/docs/images/settings-overview-zh.png +0 -0
- package/examples/minimal.config.json +16 -0
- package/lib/binding.d.ts.map +1 -1
- package/lib/binding.js +2 -2
- package/lib/binding.js.map +1 -1
- package/lib/channels/dingtalk/adapter.d.ts +59 -0
- package/lib/channels/dingtalk/adapter.d.ts.map +1 -0
- package/lib/channels/dingtalk/adapter.js +123 -0
- package/lib/channels/dingtalk/adapter.js.map +1 -0
- package/lib/channels/dingtalk/i18n.d.ts +14 -0
- package/lib/channels/dingtalk/i18n.d.ts.map +1 -0
- package/lib/channels/dingtalk/i18n.js +16 -0
- package/lib/channels/dingtalk/i18n.js.map +1 -0
- package/lib/channels/dingtalk/index.d.ts +127 -0
- package/lib/channels/dingtalk/index.d.ts.map +1 -0
- package/lib/channels/dingtalk/index.js +144 -0
- package/lib/channels/dingtalk/index.js.map +1 -0
- package/lib/channels/dingtalk/message.d.ts +49 -0
- package/lib/channels/dingtalk/message.d.ts.map +1 -0
- package/lib/channels/dingtalk/message.js +50 -0
- package/lib/channels/dingtalk/message.js.map +1 -0
- package/lib/channels/dingtalk/stomp.d.ts +36 -0
- package/lib/channels/dingtalk/stomp.d.ts.map +1 -0
- package/lib/channels/dingtalk/stomp.js +107 -0
- package/lib/channels/dingtalk/stomp.js.map +1 -0
- package/lib/channels/dingtalk/stream.d.ts +53 -0
- package/lib/channels/dingtalk/stream.d.ts.map +1 -0
- package/lib/channels/dingtalk/stream.js +199 -0
- package/lib/channels/dingtalk/stream.js.map +1 -0
- package/lib/channels/dingtalk/webhook.d.ts +84 -0
- package/lib/channels/dingtalk/webhook.d.ts.map +1 -0
- package/lib/channels/dingtalk/webhook.js +143 -0
- package/lib/channels/dingtalk/webhook.js.map +1 -0
- package/lib/channels/feishu/adapter.d.ts +133 -0
- package/lib/channels/feishu/adapter.d.ts.map +1 -0
- package/lib/channels/feishu/adapter.js +662 -0
- package/lib/channels/feishu/adapter.js.map +1 -0
- package/lib/channels/feishu/i18n.d.ts +30 -0
- package/lib/channels/feishu/i18n.d.ts.map +1 -0
- package/lib/channels/feishu/i18n.js +42 -0
- package/lib/channels/feishu/i18n.js.map +1 -0
- package/lib/channels/feishu/index.d.ts +82 -0
- package/lib/channels/feishu/index.d.ts.map +1 -0
- package/lib/channels/feishu/index.js +103 -0
- package/lib/channels/feishu/index.js.map +1 -0
- package/lib/channels/feishu/onboard.d.ts +16 -0
- package/lib/channels/feishu/onboard.d.ts.map +1 -0
- package/lib/channels/feishu/onboard.js +86 -0
- package/lib/channels/feishu/onboard.js.map +1 -0
- package/lib/channels/telegram/adapter.d.ts +70 -0
- package/lib/channels/telegram/adapter.d.ts.map +1 -0
- package/lib/channels/telegram/adapter.js +460 -0
- package/lib/channels/telegram/adapter.js.map +1 -0
- package/lib/channels/telegram/client.d.ts +115 -0
- package/lib/channels/telegram/client.d.ts.map +1 -0
- package/lib/channels/telegram/client.js +198 -0
- package/lib/channels/telegram/client.js.map +1 -0
- package/lib/channels/telegram/i18n.d.ts +14 -0
- package/lib/channels/telegram/i18n.d.ts.map +1 -0
- package/lib/channels/telegram/i18n.js +18 -0
- package/lib/channels/telegram/i18n.js.map +1 -0
- package/lib/channels/telegram/index.d.ts +46 -0
- package/lib/channels/telegram/index.d.ts.map +1 -0
- package/lib/channels/telegram/index.js +54 -0
- package/lib/channels/telegram/index.js.map +1 -0
- package/lib/channels/web/adapter.d.ts +103 -0
- package/lib/channels/web/adapter.d.ts.map +1 -0
- package/lib/channels/web/adapter.js +161 -0
- package/lib/channels/web/adapter.js.map +1 -0
- package/lib/channels/web/index.d.ts +47 -0
- package/lib/channels/web/index.d.ts.map +1 -0
- package/lib/channels/web/index.js +59 -0
- package/lib/channels/web/index.js.map +1 -0
- package/lib/chat-key.d.ts +29 -0
- package/lib/chat-key.d.ts.map +1 -0
- package/lib/chat-key.js +38 -0
- package/lib/chat-key.js.map +1 -0
- package/lib/index.d.ts +78 -11
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +312 -9
- package/lib/index.js.map +1 -1
- package/lib/runner.d.ts +55 -2
- package/lib/runner.d.ts.map +1 -1
- package/lib/runner.js +158 -23
- package/lib/runner.js.map +1 -1
- package/lib/scheduler.d.ts.map +1 -1
- package/lib/scheduler.js +2 -2
- package/lib/scheduler.js.map +1 -1
- package/lib/service.d.ts +17 -1
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +47 -3
- package/lib/service.js.map +1 -1
- package/lib/settings/channel-runtime.d.ts +75 -0
- package/lib/settings/channel-runtime.d.ts.map +1 -0
- package/lib/settings/channel-runtime.js +165 -0
- package/lib/settings/channel-runtime.js.map +1 -0
- package/lib/settings/channels.d.ts +62 -0
- package/lib/settings/channels.d.ts.map +1 -0
- package/lib/settings/channels.js +132 -0
- package/lib/settings/channels.js.map +1 -0
- package/lib/settings/credential-store.d.ts +85 -0
- package/lib/settings/credential-store.d.ts.map +1 -0
- package/lib/settings/credential-store.js +142 -0
- package/lib/settings/credential-store.js.map +1 -0
- package/lib/settings/index.d.ts +14 -0
- package/lib/settings/index.d.ts.map +1 -0
- package/lib/settings/index.js +14 -0
- package/lib/settings/index.js.map +1 -0
- package/lib/settings/namespace.d.ts +175 -0
- package/lib/settings/namespace.d.ts.map +1 -0
- package/lib/settings/namespace.js +200 -0
- package/lib/settings/namespace.js.map +1 -0
- package/lib/settings/rpc-client.d.ts +36 -0
- package/lib/settings/rpc-client.d.ts.map +1 -0
- package/lib/settings/rpc-client.js +45 -0
- package/lib/settings/rpc-client.js.map +1 -0
- package/lib/settings/secret-disclosure.d.ts +50 -0
- package/lib/settings/secret-disclosure.d.ts.map +1 -0
- package/lib/settings/secret-disclosure.js +132 -0
- package/lib/settings/secret-disclosure.js.map +1 -0
- package/lib/settings/settings-model.d.ts +124 -0
- package/lib/settings/settings-model.d.ts.map +1 -0
- package/lib/settings/settings-model.js +118 -0
- package/lib/settings/settings-model.js.map +1 -0
- package/lib/settings/settings-rpc.d.ts +130 -0
- package/lib/settings/settings-rpc.d.ts.map +1 -0
- package/lib/settings/settings-rpc.js +233 -0
- package/lib/settings/settings-rpc.js.map +1 -0
- package/lib/settings/settings-service.d.ts +35 -0
- package/lib/settings/settings-service.d.ts.map +1 -0
- package/lib/settings/settings-service.js +171 -0
- package/lib/settings/settings-service.js.map +1 -0
- package/lib/state-dir.d.ts +28 -0
- package/lib/state-dir.d.ts.map +1 -0
- package/lib/state-dir.js +33 -0
- package/lib/state-dir.js.map +1 -0
- package/lib/stream.d.ts.map +1 -1
- package/lib/stream.js +3 -2
- package/lib/stream.js.map +1 -1
- package/package.json +56 -12
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["settings-client.mjs", "../src/settings/settings-rpc.ts", "../src/settings/rpc-client.ts", "../src/settings/channels.ts", "../src/settings/credential-store.ts", "../src/settings/settings-model.ts", "../src/settings/secret-disclosure.ts", "locale.mjs", "panel-state.mjs"],
|
|
4
|
+
"sourcesContent": ["/**\n * dsh-connect client settings plugin.\n *\n * Registers a \"settings.section\" in the DeepSeek Harness web UI, mirroring\n * xmanrui/dsh-im (plugin-src/client/index.js). The pane talks to the host over\n * the `/dsh-connect` channel via `ctx.connection.rpc.call`. All the data logic\n * (snapshot<->form mapping, save-payload building) lives in the tested\n * `settings-model.js` / `rpc-client.js` helpers; this component is a thin\n * renderer. Build the lib first (`pnpm --filter dsh-connect build`), then\n * bundle this in a `dsh web` shell (Vite).\n *\n * The pane is an accordion, not a flat list. Four channels with every field\n * visible is ~2000px of content in a ~690px scroller, so the channel cards\n * collapse, rarely-touched fields hide behind a second-level \"advanced\" fold,\n * and a tab strip at the top jumps to (and opens) a channel. The rules for\n * which cards start open live in `panel-state.mjs` \u2014 pure, and testable without\n * a DOM, which matters because the bundle test's React stub can only supply a\n * component's hook values, never observe what it initializes them to.\n */\nimport * as React from 'react';\n\nimport { SETTINGS_RPC_CHANNEL } from '../lib/settings/settings-rpc.js';\nimport { loadSettings, saveSettings, saveCredentials } from '../lib/settings/rpc-client.js';\nimport { snapshotToForm, buildConfigSave, buildCredentialSaves, CHANNEL_SECRET_FIELDS, CHANNEL_CONFIG_FIELDS, CHANNEL_DEFAULT_FIELDS, coerceConfigValue } from '../lib/settings/settings-model.js';\n// Shared with the host, so the pane and the masking it displays can never\n// disagree about which keys are confidential.\nimport { isMaskedSecret } from '../lib/settings/secret-disclosure.js';\nimport { LOCALES, tr, optionalText } from './locale.mjs';\nimport { initialOpenChannels, toggleInSet, isAdvanced } from './panel-state.mjs';\n\nexport const name = 'dsh-connect-settings';\nexport const inject = ['slots', 'connection', 'locale'];\nexport const NS = 'dsh-connect';\n\nexport const locale = LOCALES;\n\nconst h = React.createElement;\n\n/** The channels the pane knows about, in the order the cards are rendered. */\nconst ALL_CHANNELS = Object.keys(CHANNEL_SECRET_FIELDS);\n\n// Self-contained scoped stylesheet for the pane (injected once). The host's own\n// settings panes use per-feature CSS modules we can't import into this separate\n// bundle, so we ship our own consistent, theme-aware styles instead. Everything\n// is scoped under `.dsh-connect-settings` so nothing leaks into the shell.\n//\n// Two things here are load-bearing rather than cosmetic:\n//\n// 1. The `box-sizing` reset. Neither this bundle nor the host shell ships a\n// global one, so under the default `content-box` a `.ds-input` with\n// `width:100%` is ~20px (padding + border) *wider* than the grid column it\n// sits in, and paints over the neighbouring column's text. That overflow is\n// what \"\u6587\u5B57\u8DDF\u7A97\u53E3\u91CD\u53E0\" actually was.\n// 2. The host's theme tokens, with our old hex values as fallbacks. The host\n// puts `--dsw-alias-*` on `body` / `body[data-ds-dark-theme]`, so inheriting\n// them follows the user's in-app theme. A `prefers-color-scheme` block \u2014 what\n// used to be here \u2014 ignores that choice entirely and repaints the pane black\n// on a light shell for anyone whose OS is dark. Do not bring it back.\nconst STYLE = `\n.dsh-connect-settings,.dsh-connect-settings *,.dsh-connect-settings *::before,.dsh-connect-settings *::after{box-sizing:border-box}\n.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}\n.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}\n.dsh-connect-settings .ds-card-title{margin:0;font-size:13px;font-weight:600}\n.dsh-connect-settings .ds-note{margin:0;font-size:11px;line-height:1.5;color:var(--ds-muted)}\n.dsh-connect-settings .ds-tabs{display:flex;flex-wrap:wrap;gap:6px}\n.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}\n.dsh-connect-settings .ds-tab:hover{background:var(--ds-hover)}\n.dsh-connect-settings .ds-tab[aria-expanded=true]{border-color:var(--ds-accent);color:var(--ds-accent)}\n.dsh-connect-settings .ds-dot{width:6px;height:6px;border-radius:50%;background:var(--ds-border-2);flex:none}\n.dsh-connect-settings .ds-dot[data-on=\"1\"]{background:var(--ds-accent)}\n.dsh-connect-settings .ds-channel{border:1px solid var(--ds-border);border-radius:8px;background:var(--ds-bg-sub)}\n.dsh-connect-settings .ds-channel-head{display:flex;align-items:center;gap:8px;padding:5px 10px}\n.dsh-connect-settings .ds-enable{display:flex;align-items:center;flex:none}\n.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}\n.dsh-connect-settings .ds-channel-toggle:hover{background:var(--ds-hover);color:var(--ds-accent)}\n.dsh-connect-settings .ds-channel-name{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.dsh-connect-settings .ds-channel-body{display:flex;flex-direction:column;gap:8px;padding:0 10px 10px}\n.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}\n.dsh-connect-settings [aria-expanded=true]>.ds-chevron,.dsh-connect-settings [aria-expanded=true] .ds-chevron{transform:rotate(-135deg);margin-top:2px}\n.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)}\n.dsh-connect-settings .ds-fields{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px 14px}\n.dsh-connect-settings .ds-field{display:flex;flex-direction:column;gap:4px;min-width:0}\n.dsh-connect-settings .ds-control{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--ds-muted)}\n.dsh-connect-settings .ds-control.ds-check-field{flex-direction:row;align-items:center;gap:6px}\n.dsh-connect-settings .ds-hint{margin:0;font-size:11px;line-height:1.5;color:var(--ds-muted)}\n.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}\n.dsh-connect-settings .ds-preview b{font-weight:500;font-family:inherit;opacity:.75}\n.dsh-connect-settings .ds-check{flex:none;width:16px;height:16px;accent-color:var(--ds-accent)}\n.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}\n.dsh-connect-settings select.ds-input{cursor:pointer}\n.dsh-connect-settings .ds-input:focus{outline:none;border-color:var(--ds-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--ds-accent) 25%,transparent)}\n.dsh-connect-settings .ds-adv{display:flex;flex-direction:column;gap:8px}\n.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}\n.dsh-connect-settings .ds-advanced-toggle:hover{background:var(--ds-hover);color:var(--ds-text)}\n.dsh-connect-settings .ds-footer{position:sticky;bottom:0;z-index:1;display:flex;align-items:center;gap:12px;margin:0 -14px -12px;padding:10px 14px;border-top:1px solid var(--ds-border);border-radius:0 0 9px 9px;background:var(--ds-bg)}\n.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}\n.dsh-connect-settings .ds-btn:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,var(--ds-accent))}\n.dsh-connect-settings .ds-btn:disabled{opacity:.55;cursor:default}\n.dsh-connect-settings .ds-status{font-size:12px;color:var(--ds-muted)}\n`;\n\nfunction injectStyles() {\n if (typeof document === 'undefined') return;\n if (document.getElementById('dsh-connect-settings-style')) return;\n const el = document.createElement('style');\n el.id = 'dsh-connect-settings-style';\n el.textContent = STYLE;\n document.head.appendChild(el);\n}\n\n// Render one non-secret config field: its control, then its explanation.\n//\n// Label and hint are looked up by *config key* (`f.<key>`), so a field added\n// without its text shows a legible fallback (`field.label`) instead of a raw\n// identifier \u2014 and the locale-coverage test fails, which is the real guard.\nfunction renderConfigField(field, value, onChange, t) {\n const label = tr(t, `f.${field.key}`, field.label ?? field.key);\n const hint = optionalText(t, `f.${field.key}.hint`);\n let control;\n if (field.kind === 'boolean') {\n control = h('label', { className: 'ds-control ds-check-field' },\n h('input', { className: 'ds-check', type: 'checkbox', checked: !!value, onChange: (e) => onChange(e.target.checked) }),\n ' ' + label);\n } else if (field.kind === 'select') {\n control = h('label', { className: 'ds-control' }, label + ' ',\n h('select', { className: 'ds-input', value: value ?? '', onChange: (e) => onChange(e.target.value) },\n h('option', { value: '' }, tr(t, 'o.default', label)),\n ...(field.options ?? []).map((opt) => h('option', { key: opt, value: opt }, tr(t, `o.${opt}`, opt)))));\n } else if (field.kind === 'number') {\n control = h('label', { className: 'ds-control' }, label + ' ',\n h('input', { className: 'ds-input', type: 'number', value: value ?? '', onChange: (e) => onChange(e.target.value) }));\n } else {\n control = h('label', { className: 'ds-control' }, label + ' ',\n h('input', { className: 'ds-input', type: 'text', value: value ?? '', onChange: (e) => onChange(e.target.value) }));\n }\n return h('div', { className: 'ds-field', key: `cfg-${field.key}` },\n control,\n hint ? h('p', { className: 'ds-hint' }, hint) : null);\n}\n\n// Render one secret field: a write-only input, plus a read-only preview of the\n// stored value so the user can confirm what they configured.\n//\n// The preview is plain text, never the input's `value`. The host already masked\n// it, and seeding an input with a mask would let a save write the mask *back* as\n// the credential. Keeping the input blank also keeps \"empty\" meaning \"leave the\n// stored value alone\", which is what a password field is expected to do.\nfunction renderSecretField(ch, field, form, onChange, t) {\n const preview = form.secretPreviews?.[ch]?.[field] ?? '';\n const hint = optionalText(t, `s.${ch}.${field}.hint`);\n return h('div', { className: 'ds-field', key: `sec-${ch}-${field}` },\n h('label', { className: 'ds-control' },\n tr(t, `s.${ch}.${field}`, field),\n h('input', {\n className: 'ds-input',\n // Confidential keys stay masked while typing; identifiers (appId,\n // clientId) don't, so a typo is visible before it is ever saved.\n type: isMaskedSecret(field) ? 'password' : 'text',\n autoComplete: 'off',\n placeholder: preview ? t('configured') : t('notConfigured'),\n value: form.secrets?.[ch]?.[field] ?? '',\n onChange: (e) => onChange(e.target.value),\n })),\n h('p', { className: 'ds-preview' },\n h('b', null, t('current')),\n preview === '' ? t('notConfigured') : preview),\n hint ? h('p', { className: 'ds-hint' }, hint) : null);\n}\n\n// One channel card: an enable checkbox, a header that folds the card, and \u2014 when\n// open \u2014 its credentials and settings.\n//\n// A plain function rather than a child component, deliberately: the bundle test\n// supplies hook values *positionally*, so every `useState` in the tree has to\n// live in `ConnectSettingsTab` in a fixed order. Nothing in here may call a hook.\nfunction renderChannel(ch, ctx) {\n const { form, creds, t, open, advOverride, setChannels, setField, setChannelConfig, toggleOpen, toggleAdvanced } = ctx;\n const name = tr(t, `channel.${ch}`, ch);\n const channelHint = optionalText(t, `channel.${ch}.hint`);\n const isOpen = open.has(ch);\n const advOpen = advOverride?.has(ch) ?? false;\n const configFields = CHANNEL_CONFIG_FIELDS[ch] ?? [];\n // The split is what keeps a common case on one screen: two credentials and the\n // behavioural switches, with the set-once fields one disclosure deeper.\n const common = configFields.filter((f) => !isAdvanced(ch, f.key));\n const advanced = configFields.filter((f) => isAdvanced(ch, f.key));\n\n const configField = (field) => renderConfigField(\n field,\n form.channelConfigs?.[ch]?.[field.key],\n (raw) => setChannelConfig(ch, field.key, raw),\n t,\n );\n\n return h('div', { className: 'ds-channel', key: ch, id: `ds-ch-${ch}` },\n // The checkbox sits beside the header button, not inside it: a button may not\n // contain another interactive element, and browsers disagree about what to do\n // when one does.\n h('div', { className: 'ds-channel-head' },\n h('label', { className: 'ds-enable' },\n h('input', {\n className: 'ds-check',\n type: 'checkbox',\n 'aria-label': name,\n checked: form.channels.includes(ch),\n onChange: (e) => setChannels(ch, e.target.checked),\n })),\n h('button', {\n type: 'button',\n className: 'ds-channel-toggle',\n 'aria-expanded': isOpen,\n // Only while the body exists: pointing at an id that is not in the\n // document is worse than not pointing at all.\n 'aria-controls': isOpen ? `ds-ch-${ch}-body` : undefined,\n 'aria-label': `${name} \u2014 ${isOpen ? t('collapse') : t('expand')}`,\n onClick: () => toggleOpen(ch),\n },\n h('span', { className: 'ds-channel-name' }, name),\n h('span', { className: 'ds-badge' }, creds[ch] ? t('reachable') : t('unreachable')),\n // Drawn in CSS, never a text node: the bundle test reads rendered text as\n // user-visible copy, and a `\u25BE` here would be collected as a stray string.\n h('span', { className: 'ds-chevron' }))),\n // Collapsed means *not rendered*, not `display:none`. The inputs are\n // controlled from the parent's `form.secrets`, so folding a card cannot lose\n // an unsaved secret \u2014 keeping it mounted would buy nothing.\n isOpen ? h('div', { className: 'ds-channel-body', id: `ds-ch-${ch}-body` },\n channelHint ? h('p', { className: 'ds-note' }, channelHint) : null,\n h('div', { className: 'ds-fields' },\n ...CHANNEL_SECRET_FIELDS[ch].map((field) => renderSecretField(ch, field, form, (value) => setField(ch, field, value), t)),\n ...common.map(configField)),\n advanced.length === 0 ? null : h('div', { className: 'ds-adv' },\n h('button', {\n type: 'button',\n className: 'ds-advanced-toggle',\n 'aria-expanded': advOpen,\n 'aria-controls': advOpen ? `ds-ch-${ch}-adv` : undefined,\n onClick: () => toggleAdvanced(ch),\n },\n h('span', null, t('advanced')),\n h('span', { className: 'ds-chevron' })),\n advOpen ? h('div', { className: 'ds-fields', id: `ds-ch-${ch}-adv` }, ...advanced.map(configField)) : null),\n ) : null);\n}\n\n// Thin React renderer over the tested settings-model helpers.\nexport function ConnectSettingsTab({ rpcCall, t }) {\n const [form, setForm] = React.useState(null);\n const [status, setStatus] = React.useState('loading');\n const [creds, setCreds] = React.useState({});\n const [openOverride, setOpenOverride] = React.useState(null);\n const [advOverride, setAdvOverride] = React.useState(null);\n const rpc = (endpoint, payload) => rpcCall(endpoint, payload);\n\n React.useEffect(() => {\n let alive = true;\n loadSettings(rpc).then((snap) => {\n if (!alive) return;\n setForm(snapshotToForm(snap));\n setCreds(snap.credentials ?? {});\n setStatus('idle');\n }).catch(() => alive && setStatus('error'));\n return () => { alive = false; };\n }, [rpcCall]);\n\n const onSave = async () => {\n if (!form) return;\n setStatus('saving');\n try {\n // The last snapshot in the chain wins: it is the one that reflects every\n // write this save performed. Re-seeding the form from it also drops the\n // typed secret values (their inputs are write-only by design) and picks up\n // the new presence flags.\n let snap = await saveSettings(rpc, buildConfigSave(form));\n for (const c of buildCredentialSaves(form)) snap = await saveCredentials(rpc, c.channel, c.values);\n setForm(snapshotToForm(snap));\n setCreds(snap.credentials ?? {});\n setStatus('saved');\n } catch { setStatus('error'); }\n };\n\n const setChannels = (ch, on) => {\n setForm((f) => ({ ...f, channels: on ? [...f.channels, ch] : f.channels.filter((x) => x !== ch) }));\n // Enabling a channel opens it, and only ever opens: the user just said they\n // care about this one. Ticking it back off leaves the fold alone rather than\n // snapping shut under the cursor.\n if (on && !open.has(ch)) setOpenOverride(new Set(open).add(ch));\n };\n const setField = (ch, field, value) => setForm((f) => ({ ...f, secrets: { ...f.secrets, [ch]: { ...(f.secrets[ch] ?? {}), [field]: value } } }));\n const setChannelConfig = (ch, key, raw) => setForm((f) => {\n const descriptor = (CHANNEL_CONFIG_FIELDS[ch] ?? []).find((x) => x.key === key);\n const value = coerceConfigValue(descriptor?.kind ?? 'text', raw);\n const cfg = { ...(f.channelConfigs[ch] ?? {}) };\n if (value === undefined) delete cfg[key]; else cfg[key] = value;\n return { ...f, channelConfigs: { ...f.channelConfigs, [ch]: cfg } };\n });\n const setDefault = (key, raw) => setForm((f) => {\n const descriptor = CHANNEL_DEFAULT_FIELDS.find((x) => x.key === key);\n const value = coerceConfigValue(descriptor?.kind ?? 'text', raw);\n const defaults = { ...(f.channelDefaults ?? {}) };\n if (value === undefined) delete defaults[key]; else defaults[key] = value;\n return { ...f, channelDefaults: defaults };\n });\n\n if (!form) return h('div', { className: 'dsh-connect-settings' }, t('loading'));\n\n // Which cards are open is *derived*, not stored: the first render happens\n // before the host's snapshot arrives, and the test stub cannot run effects, so\n // an effect here would be the only place the default could ever be set \u2014 and\n // it would never run. The override records only what the user has since done,\n // on top of that default (`toggleInSet(open, \u2026)` rather than a bare toggle),\n // so opening DingTalk does not silently drop the enabled Feishu the user was\n // already looking at.\n const open = openOverride ?? initialOpenChannels(form.channels, ALL_CHANNELS);\n\n const toggleOpen = (ch) => setOpenOverride(toggleInSet(open, ch));\n // Advanced folds default to closed for every channel, so an empty set is the\n // derived default here and no counterpart to `initialOpenChannels` is needed.\n const toggleAdvanced = (ch) => setAdvOverride(toggleInSet(advOverride ?? new Set(), ch));\n const focusChannel = (ch) => {\n // A tab expands and scrolls, but never collapses: \"show me Telegram\" must not\n // close the channel the user is halfway through editing.\n if (!open.has(ch)) setOpenOverride(new Set(open).add(ch));\n if (typeof document === 'undefined' || !document.getElementById) return;\n // The header is rendered whether or not the card is open, so this resolves\n // before React has re-rendered, and lands in the same place either way.\n document.getElementById(`ds-ch-${ch}`)?.scrollIntoView?.({ block: 'nearest' });\n };\n\n return h('div', { className: 'dsh-connect-settings' },\n h('section', { className: 'ds-card' },\n h('h4', { className: 'ds-card-title' }, t('channels')),\n // Explains the masking before the user meets a truncated value and wonders\n // whether their stored secret is corrupt.\n h('p', { className: 'ds-note' }, t('previewNote')),\n // A tab strip, but not `role=tablist`: several channels can be open at\n // once, so there is no single \"selected\" tab to report. These are buttons\n // that open and jump to a channel, and `aria-expanded` says so honestly.\n h('nav', { className: 'ds-tabs', 'aria-label': t('tabsAria') },\n ...ALL_CHANNELS.map((ch) => h('button', {\n key: `tab-${ch}`,\n type: 'button',\n className: 'ds-tab',\n 'aria-expanded': open.has(ch),\n 'aria-controls': open.has(ch) ? `ds-ch-${ch}-body` : undefined,\n onClick: () => focusChannel(ch),\n },\n h('span', { className: 'ds-dot', 'data-on': form.channels.includes(ch) ? '1' : '0' }),\n tr(t, `channel.${ch}`, ch)))),\n // One card per built-in channel: enable toggle + cred badge + secret + config fields.\n ...ALL_CHANNELS.map((ch) => renderChannel(ch, {\n form, creds, t, open, advOverride,\n setChannels, setField, setChannelConfig, toggleOpen, toggleAdvanced,\n }))),\n h('section', { className: 'ds-card' },\n h('h4', { className: 'ds-card-title' }, t('defaults')),\n h('p', { className: 'ds-note' }, t('defaultsHint')),\n h('div', { className: 'ds-fields' },\n ...CHANNEL_DEFAULT_FIELDS.map((field) => renderConfigField(field, form.channelDefaults?.[field.key], (raw) => setDefault(field.key, raw), t)),\n // Only meaningful on the fallback plane: it names the file the pane\n // persists to. With the namespace live that path is not consulted (and\n // is not part of the section), so showing an editable field for it would\n // silently swallow edits.\n form.live ? null : h('div', { className: 'ds-field' },\n h('label', { className: 'ds-control' }, t('statePath'),\n h('input', { className: 'ds-input', value: form.settingsStatePath ?? '', onChange: (e) => setForm((f) => ({ ...f, settingsStatePath: e.target.value })) })),\n h('p', { className: 'ds-hint' }, t('statePathHint'))),\n ),\n h('div', { className: 'ds-status' }, form.live ? t('livePlane') : t('filePlane')),\n // Sticky to the bottom of the host's scrolling region, so Save stays in\n // reach however far the channel list has been unfolded.\n h('div', { className: 'ds-footer' },\n h('button', { className: 'ds-btn', type: 'button', onClick: onSave, disabled: status === 'saving' }, t('save')),\n // Rendering `status` directly leaks the raw state ids (`idle`, `saving`)\n // into the UI; every state has a locale entry instead.\n h('span', { className: 'ds-status' }, tr(t, `status.${status}`, status)),\n ),\n ),\n );\n}\n\nexport function apply(ctx) {\n injectStyles();\n ctx.effect(() => ctx.locale.register(NS, locale), 'dsh-connect: locale');\n const t = ctx.locale.bind(NS);\n const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(SETTINGS_RPC_CHANNEL, endpoint, payload, signal);\n ctx.effect(() => ctx.slots.inject('settings.section', () => ctx.slots.register({\n name: 'settings.section', id: 'dsh-connect', order: 20, label: () => t('title'), locale: NS, inject: () => ({ rpcCall, t }),\n }, ConnectSettingsTab)), 'dsh-connect: settings.section');\n}\n", "/**\n * Host-side RPC facade for the dsh-connect web settings pane.\n *\n * Mirrors dsh-im's host RPC pattern: a plugin registers a single channel on\n * `ctx.connection.rpc.handle(...)` and a handler dispatches dotted endpoints,\n * returning `{ ok: true, value }` on success or `{ ok: false, error }` on\n * failure (see dsh-im `plugin-src/host/update-rpc.mjs`).\n *\n * Endpoints:\n * - `settings.get` \u2014 current non-secret config + enabled channels + credential presence.\n * - `settings.save` \u2014 persist non-secret config (payload must be non-empty).\n * - `credentials.save` \u2014 persist secrets to the DSH credential store (payload\n * `{ channel, values }`, values are {configKey: secret}).\n * - `settings.status` \u2014 fresh status snapshot.\n *\n * @module dsh-connect/settings/settings-rpc\n */\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\n\n/** RPC channel the settings pane calls. */\nexport const SETTINGS_RPC_CHANNEL = \"/dsh-connect\";\n\n/** Endpoints the settings pane can invoke on the channel. */\nexport const SETTINGS_ENDPOINTS = Object.freeze([\"settings.get\", \"settings.save\", \"credentials.save\", \"settings.status\"]);\n\n/** Error codes considered safe to surface verbatim to the browser. */\nconst PUBLIC_ERRORS = new Set([\n \"settings-failed\", \"bad-request\", \"cancelled\", \"invalid-payload\", \"unknown-profile\",\n \"unsupported-runtime\", \"not-configured\", \"credential-missing\", \"credential-invalid\",\n \"channel-disabled\", \"save-failed\", \"state-unavailable\", \"disposed\", \"interrupted\",\n \"unsupported\", \"invalid-channel\", \"invalid-credentials\",\n]);\n\n/** A settings pane `settings.get`/status snapshot. */\nexport interface SettingsSnapshot {\n /** The current plugin config (non-secret fields). */\n config: Record<string, unknown>;\n /** The channel names currently enabled. */\n enabled: string[];\n /** Per-channel credential presence (true = stored & set). */\n credentials: Record<string, boolean>;\n /**\n * Per-channel, per-config-key secret **presence** (true = a value is stored\n * for that key) \u2014 never the values themselves. The pane uses it to tell an\n * untouched input (\u00AB\u5DF2\u914D\u7F6E\u00BB) from an empty one.\n *\n * This used to echo the values, which put an `appSecret` into browser state\n * and every screenshot of the settings page. What leaves the host now is\n * either this boolean or the masked {@link secretPreviews} below \u2014 a usable\n * secret still leaves exactly once, when the adapter consumes it\n * (`injectSecrets`).\n */\n secrets?: Record<string, Record<string, boolean>>;\n /**\n * Per-channel, per-config-key **display preview**, for the user to confirm\n * what they configured without being able to copy it back out.\n *\n * Already masked on the host by `maskSecret` (`./secret-disclosure.js`), so\n * it is lossy by construction: identifiers such as an `appId` come through\n * whole, an `appSecret` keeps only its first and last four characters, and a\n * `webhookUrl` keeps its origin and path with the `access_token` in the query\n * masked. A key is present here only when a value is stored for it, which\n * makes an empty entry the same statement as `secrets[key] === false`.\n *\n * **Display-only.** The pane renders it as text; it must never be written\n * back as a credential, and `buildCredentialSaves` ignores it \u2014 the secret\n * inputs are still write-only and still start blank.\n */\n secretPreviews?: Record<string, Record<string, string>>;\n /**\n * True when the `dsh-connect` settings namespace is live, i.e. this config is\n * stored in `$DSH_HOME/settings.yaml` and a save takes effect immediately.\n * False means the legacy JSON state file is behind this pane: the settings\n * path control still applies, and a save needs a restart.\n */\n live?: boolean;\n}\n\n/** The service backing the RPC; supplied by the web-settings integration. */\nexport interface SettingsService {\n get(): Promise<SettingsSnapshot>;\n save(config: Record<string, unknown>): Promise<SettingsSnapshot>;\n /** Persist channel secrets ({configKey: secret}) to the credential store. */\n saveCredentials?(channel: string, values: Record<string, string>): Promise<SettingsSnapshot>;\n status(): Promise<SettingsSnapshot>;\n}\n\nfunction validPayload(endpoint: string, payload: unknown): boolean {\n if (payload === null || typeof payload !== \"object\" || Array.isArray(payload)) return false;\n const keys = Object.keys(payload);\n if (endpoint === \"settings.save\") return keys.length > 0;\n if (endpoint === \"credentials.save\") {\n return keys.length > 0 && typeof (payload as any).channel === \"string\"\n && (payload as any).values !== null && typeof (payload as any).values === \"object\"\n && !Array.isArray((payload as any).values);\n }\n // The reads carry no payload.\n return keys.length === 0;\n}\n\n/** Build the channel handler for a settings service. */\nexport function createSettingsRpcHandler(service: SettingsService) {\n return async (endpoint: string, payload: unknown, signal?: { aborted?: boolean }) => {\n if (!SETTINGS_ENDPOINTS.includes(endpoint as never) || !validPayload(endpoint, payload)) {\n return { ok: false, error: { code: \"bad-request\", message: \"Invalid settings request.\" } };\n }\n if (signal?.aborted) return { ok: false, error: { code: \"cancelled\", message: \"Request cancelled.\" } };\n try {\n const record = (payload ?? {}) as Record<string, unknown>;\n const value = endpoint === \"settings.get\"\n ? await service.get()\n : endpoint === \"settings.save\"\n ? await service.save(record)\n : endpoint === \"credentials.save\"\n ? await saveCredentialsP(service, record)\n : await service.status();\n return { ok: true, value };\n } catch (error) {\n const code = PUBLIC_ERRORS.has((error as any)?.code) ? (error as any).code : \"settings-failed\";\n return { ok: false, error: { code, message: code } };\n }\n };\n}\n\nasync function saveCredentialsP(service: SettingsService, record: Record<string, unknown>): Promise<SettingsSnapshot> {\n if (typeof service.saveCredentials !== \"function\") {\n const err = new Error(\"unsupported\") as any;\n err.code = \"unsupported\";\n throw err;\n }\n const channel = record.channel as string;\n const values = (record.values ?? {}) as Record<string, string>;\n return service.saveCredentials(channel, values);\n}\n\n/** Endpoint segment accepted by a Connection RPC path (host-compatible). */\nconst ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;\n\n/** Upper bound on an accepted settings request body; settings payloads are tiny. */\nconst MAX_BODY_BYTES = 1024 * 1024;\n\n/** The Host/Origin + browser-auth fence exposed by the host `connection` service. */\ninterface ConnectionFence {\n /** HTTP status to reject with, or `undefined` to allow the request through. */\n requestRejection?(request: IncomingMessage): number | undefined;\n}\n\n/** A named `webServer` route (subset of the host's route shape we rely on). */\ninterface PrefixRoute {\n kind: \"prefix\";\n path: string;\n handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;\n}\n\n/** The `webServer` host service surface this module needs. */\ninterface WebServerLike {\n register?(route: PrefixRoute): () => void;\n}\n\n/** The inject-scope context `installSettingsRpc` expects (connection + webServer). */\ninterface SettingsRpcScope {\n connection?: ConnectionFence;\n webServer?: WebServerLike;\n effect?<T>(execute: () => T, label?: string): (() => void) | T;\n}\n\n/**\n * Derive the endpoint from a request path, exactly like the host's own\n * Connection router: the remainder of `channel/` must be non-empty segments\n * matching {@link ENDPOINT_SEGMENT_PATTERN}.\n */\nfunction endpointFromPath(channel: string, pathname: string): string | undefined {\n if (!pathname.startsWith(`${channel}/`)) return undefined;\n const endpoint = pathname.slice(channel.length + 1);\n const segments = endpoint.split(\"/\");\n if (segments.some((segment) => segment === \"\" || segment === \".\" || segment === \"..\" || !ENDPOINT_SEGMENT_PATTERN.test(segment))) return undefined;\n return endpoint;\n}\n\n/** Read a request body as UTF-8 text, rejecting past {@link MAX_BODY_BYTES}. */\nfunction readBody(req: IncomingMessage, limit = MAX_BODY_BYTES): Promise<string> {\n return new Promise((resolve, reject) => {\n const chunks: Buffer[] = [];\n let size = 0;\n let overLimit = false;\n req.on(\"data\", (chunk: Buffer) => {\n if (overLimit) return;\n size += chunk.length;\n if (size > limit) {\n overLimit = true;\n chunks.length = 0;\n reject(new Error(\"body exceeds limit\"));\n return;\n }\n chunks.push(chunk);\n });\n req.on(\"end\", () => resolve(Buffer.concat(chunks).toString(\"utf8\")));\n req.on(\"error\", reject);\n });\n}\n\n/** Write a JSON response with an accurate content length. */\nfunction sendJson(res: ServerResponse, status: number, body: unknown): void {\n const text = JSON.stringify(body);\n res.writeHead(status, { \"content-type\": \"application/json\", \"content-length\": String(Buffer.byteLength(text)) });\n res.end(text);\n}\n\n/**\n * Coerce a handler result into the Connection response envelope the browser\n * client validates. `error.details` must always be an object \u2014 the client's\n * `parseConnectionResponse` throws on a missing one.\n */\nfunction responseEnvelope(rpcId: string, result: { ok?: boolean; value?: unknown; error?: unknown }): unknown {\n if (result?.ok === true) return { type: \"server-response\", rpcId, result: { ok: true, value: result.value } };\n const error = (result?.error ?? {}) as { code?: unknown; message?: unknown; details?: unknown };\n const details = error.details !== null && typeof error.details === \"object\" ? error.details : {};\n return {\n type: \"server-response\",\n rpcId,\n result: { ok: false, error: { code: String(error.code ?? \"settings-failed\"), message: String(error.message ?? \"\"), details } },\n };\n}\n\n/**\n * Build the raw HTTP handler for {@link SETTINGS_RPC_CHANNEL}. Mirrors the\n * host's own Connection RPC adapter (method/content-type/envelope checks plus\n * the Host/Origin and browser-auth fence) so the pane sees the same protocol\n * and the same failure codes as a first-party channel.\n */\nexport function createSettingsHttpHandler(service: SettingsService, connection?: ConnectionFence) {\n const handle = createSettingsRpcHandler(service);\n\n return async function settingsHttpHandler(req: IncomingMessage, res: ServerResponse): Promise<void> {\n // Same fence the host applies to `/api`: trusted Host/Origin, then browser auth.\n const rejection = connection?.requestRejection?.(req);\n if (rejection !== undefined) {\n res.writeHead(rejection, { \"content-type\": \"text/plain\" });\n res.end(rejection === 401 ? \"unauthorized\" : \"forbidden\");\n return;\n }\n\n const endpoint = endpointFromPath(SETTINGS_RPC_CHANNEL, new URL(req.url ?? \"/\", \"http://dsh.internal\").pathname);\n if (req.method !== \"POST\" || endpoint === undefined) {\n res.writeHead(404);\n res.end();\n return;\n }\n\n const contentType = String(req.headers[\"content-type\"] ?? \"\").split(\";\", 1)[0]?.trim().toLowerCase();\n if (contentType !== \"application/json\") {\n res.writeHead(415);\n res.end(\"content type must be application/json\");\n return;\n }\n\n let raw: string;\n try {\n raw = await readBody(req);\n } catch {\n res.writeHead(400);\n res.end(\"body is not JSON\");\n return;\n }\n\n let body: unknown;\n try {\n body = JSON.parse(raw);\n } catch {\n res.writeHead(400);\n res.end(\"body is not JSON\");\n return;\n }\n\n const envelope = (body ?? {}) as { type?: unknown; rpcId?: unknown; method?: unknown; payload?: unknown };\n const rpcId = typeof envelope.rpcId === \"string\" ? envelope.rpcId : \"invalid-request\";\n if (envelope.type !== \"client-request\" || typeof envelope.rpcId !== \"string\" || typeof envelope.method !== \"string\") {\n sendJson(res, 200, responseEnvelope(rpcId, { ok: false, error: { code: \"bad-request\", message: \"invalid client-request message\" } }));\n return;\n }\n if (envelope.method !== endpoint) {\n sendJson(res, 200, responseEnvelope(rpcId, {\n ok: false,\n error: { code: \"bad-request\", message: `method ${JSON.stringify(envelope.method)} does not match endpoint ${JSON.stringify(endpoint)}` },\n }));\n return;\n }\n\n // Abort the caller's signal when the browser goes away before we answer.\n const aborter = new AbortController();\n res.on(\"close\", () => {\n if (!res.writableEnded) aborter.abort();\n });\n\n const result = await handle(endpoint, envelope.payload, aborter.signal) as { ok?: boolean; value?: unknown; error?: unknown };\n sendJson(res, 200, responseEnvelope(rpcId, result));\n };\n}\n\n/**\n * Mount the settings RPC channel on the host webserver.\n *\n * Mounts a `prefix` route for {@link SETTINGS_RPC_CHANNEL} directly on the\n * `webServer` host service \u2014 the same thing the host's own\n * `connection.rpc.handle()` does internally (`owner.webServer.register(route)`\n * with the `/api` fence), and the same thing `dsh-api-gateway` does for its\n * WebSocket route.\n *\n * Why not `connection.rpc.handle()`: the host's `HostConnectionService` is\n * context-tracked, so its `get rpc()` captures `this.ctx`. cordis resolves that\n * read to a *shadow* of the service's construction context, and service\n * resolution then walks **that** fiber chain \u2014 which never contains the\n * `webServer` provider (a sibling fiber). The call therefore dies with\n * `cannot get property \"webServer\" without inject`, the route is never mounted,\n * and every `settings.*` call falls through to the static fallback (HTTP 405),\n * leaving the pane stuck on \"\u52A0\u8F7D\u4E2D\". Injecting `webServer` into the calling\n * scope \u2014 or declaring it in the plugin's `inject` \u2014 does not help, because the\n * shadow overrides the caller's fiber.\n *\n * @param ctx - an injection scope holding `connection` and `webServer`.\n * @param options - the settings service backing the channel.\n * @returns a disposer removing the route (no-op if the host has no webserver).\n */\nexport function installSettingsRpc(ctx: unknown, options: { service: SettingsService }): () => void {\n const scope = (ctx ?? {}) as SettingsRpcScope;\n const register = scope.webServer?.register;\n if (typeof register !== \"function\") return () => {};\n\n const route: PrefixRoute = {\n kind: \"prefix\",\n path: SETTINGS_RPC_CHANNEL,\n handler: createSettingsHttpHandler(options.service, scope.connection),\n };\n\n // Register through the scope's effect so the route is torn down with the fiber.\n if (typeof scope.effect === \"function\") {\n const disposed = scope.effect(() => register.call(scope.webServer, route), `dsh-connect: ${SETTINGS_RPC_CHANNEL} settings rpc`);\n return typeof disposed === \"function\" ? (disposed as () => void) : () => {};\n }\n return register.call(scope.webServer, route);\n}", "/**\n * Client-side RPC helper for the dsh-connect web settings pane.\n *\n * The frontend talks to the host over the `/dsh-connect` channel; the channel\n * returns `{ ok, value }`/`{ ok:false, error }` envelopes (see `settings-rpc.js`).\n * This helper unwraps the envelope and throws a typed error on failure, so the\n * UI can catch it and render a message. Pure + dependency-free for easy testing.\n *\n * @module dsh-connect/settings/rpc-client\n */\n\nimport type { SettingsSnapshot } from \"./settings-rpc.js\";\n\n/** The `ctx.connection.rpc.call` signature the settings pane uses. */\nexport type RpcCall = (\n endpoint: string,\n payload?: unknown,\n signal?: AbortSignal,\n) => Promise<{ ok: boolean; value?: unknown; error?: { code: string; message: string } }>;\n\n/** A typed RPC failure, surfaced to the settings UI. */\nexport class RpcError extends Error {\n readonly code: string;\n constructor(code: string, message: string) {\n super(message);\n this.name = \"RpcError\";\n this.code = code;\n }\n}\n\n/** Invoke one endpoint and unwrap the envelope (throws `RpcError` on failure). */\nexport function callRpc<T = unknown>(\n rpcCall: RpcCall,\n endpoint: string,\n payload?: unknown,\n signal?: AbortSignal,\n): Promise<T> {\n return rpcCall(endpoint, payload, signal).then((res) => {\n if (res.ok) return res.value as T;\n const code = res.error?.code ?? \"settings-failed\";\n throw new RpcError(code, res.error?.message ?? code);\n });\n}\n\n/** Load the current settings snapshot via `settings.get`. */\nexport function loadSettings(rpcCall: RpcCall): Promise<SettingsSnapshot> {\n return callRpc<SettingsSnapshot>(rpcCall, \"settings.get\", {});\n}\n\n/** Persist the settings via `settings.save`. */\nexport function saveSettings(rpcCall: RpcCall, config: Record<string, unknown>): Promise<SettingsSnapshot> {\n return callRpc<SettingsSnapshot>(rpcCall, \"settings.save\", config);\n}\n\n/** Persist channel secrets to the credential store via `credentials.save`. */\nexport function saveCredentials(\n rpcCall: RpcCall,\n channel: string,\n values: Record<string, string>,\n): Promise<SettingsSnapshot> {\n return callRpc<SettingsSnapshot>(rpcCall, \"credentials.save\", { channel, values });\n}\n\n/** Ask the host for a fresh status snapshot via `settings.status`. */\nexport function loadStatus(rpcCall: RpcCall): Promise<SettingsSnapshot> {\n return callRpc<SettingsSnapshot>(rpcCall, \"settings.status\", {});\n}", "/**\n * Channel orchestration for dsh-connect: a small, side-effect-free selector\n * that activates a requested subset of channel adapters. Kept separate from the\n * cordis entry so it is unit-testable without pulling any channel SDK.\n * @module dsh-connect/settings/channels\n */\n\n/** Channel adapters the connect plugin can activate. */\nexport type ChannelName = \"feishu\" | \"telegram\" | \"dingtalk\" | \"web\";\n\n/** Config for the connect plugin: which channels + their per-channel config. */\nexport interface ChannelsConfig {\n /** Directory holding `bindings.json` / `reminders.json` / the settings document. */\n stateDir?: string;\n /** Optional path to persist web-settings edits (non-secret). */\n settingsStatePath?: string;\n /** Keys applied to every channel that doesn't set its own (e.g. a shared `language`). */\n channelDefaults?: Record<string, unknown>;\n /** Channel names to activate; default: all built-in channels. */\n channels?: ChannelName[];\n feishu?: Record<string, unknown>;\n telegram?: Record<string, unknown>;\n dingtalk?: Record<string, unknown>;\n web?: Record<string, unknown>;\n}\n\n/** The only slice of a context the selector touches: a `logger.warn`. */\nexport interface LoggerLike {\n logger?: { warn?: (...args: unknown[]) => void } | undefined;\n}\n\n/** A channel plugin's `apply(ctx, config)`, loosely typed for the selector. */\nexport type ChannelApply<Ctx extends LoggerLike = LoggerLike> = (ctx: Ctx, config: any) => void;\n\n/** Built-in channels, in a stable order (for docs and the default selector). */\nexport const CHANNELS: readonly ChannelName[] = [\"feishu\", \"telegram\", \"dingtalk\", \"web\"];\n\n/**\n * Activate the requested adapters into `ctx` (the `connect` service is\n * expected to be present via the caller's `inject`). `channels` maps a channel\n * name to its plugin `apply`. Unknown names are skipped with a warning; a\n * channel whose `apply` throws is caught so one broken adapter never takes the\n * others down.\n *\n * @returns the channel names that were started.\n */\nexport function activateChannels<Ctx extends LoggerLike>(\n ctx: Ctx,\n config: ChannelsConfig | null | undefined,\n channels: Record<string, ChannelApply<Ctx>>,\n): string[] {\n const cfg = config ?? {};\n const defaults = cfg.channelDefaults ?? {};\n const wanted = cfg.channels ?? CHANNELS;\n const started: string[] = [];\n for (const name of wanted) {\n const apply = channels[name];\n if (apply === undefined) {\n ctx.logger?.warn?.(`connect: unknown channel \"${name}\"; skipped`);\n continue;\n }\n try {\n const overrides = (cfg as Record<string, unknown>)[name] ?? {};\n apply(ctx, { ...defaults, ...overrides });\n started.push(name);\n } catch (error) {\n ctx.logger?.warn?.(`connect: channel \"${name}\" failed to start: ${String(error)}`);\n }\n }\n return started;\n}\n/**\n * Secret config-keys that live under a *dotted path* rather than at the channel\n * config root, because the adapter reads them from a sub-object (e.g. dingtalk's\n * bidirectional stream credentials read from `config.stream.clientId`). Keys\n * absent from this map are injected flat. Used by `injectSecrets`.\n */\nconst NESTED_SECRET_KEYS: Record<ChannelName, Record<string, string>> = {\n feishu: {},\n telegram: {},\n dingtalk: { clientId: \"stream.clientId\", clientSecret: \"stream.clientSecret\" },\n web: {},\n};\n\n/** Read a value at a dotted path (e.g. `\"stream.clientId\"`), undefined if absent. */\nfunction readDotPath(obj: Record<string, unknown>, path: string): unknown {\n let node: unknown = obj;\n for (const segment of path.split(\".\")) {\n if (node === null || typeof node !== \"object\") return undefined;\n node = (node as Record<string, unknown>)[segment];\n }\n return node;\n}\n\n/**\n * Extract the build-time secret values (configKey \u2192 value) that a channel config\n * carries for a given secret-key map, reading each key at its *actual* config\n * location (flat at the channel root, or nested per `NESTED_SECRET_KEYS` \u2014 e.g.\n * dingtalk's `stream.clientId`). Only non-empty string values are returned, so it\n * doubles as the source for migrating config-file secrets into the credential\n * store on boot. Read-only: never mutates `config`.\n */\nexport function extractConfigSecrets(\n config: Record<string, unknown> | undefined,\n channel: ChannelName,\n secretKeys: Record<string, string>,\n): Record<string, string> {\n if (!config || typeof config !== \"object\") return {};\n const nested = NESTED_SECRET_KEYS[channel] ?? {};\n const out: Record<string, string> = {};\n for (const configKey of Object.keys(secretKeys)) {\n const path = nested[configKey];\n const value = path ? readDotPath(config, path) : config[configKey];\n if (typeof value === \"string\" && value.length > 0) out[configKey] = value;\n }\n return out;\n}\n\n/** Copy `secrets` into `target`, writing nested keys at their dotted path. */\nfunction mergeSecrets(\n target: Record<string, unknown>,\n secrets: Record<string, string>,\n nested: Record<string, string>,\n): Record<string, unknown> {\n for (const [key, value] of Object.entries(secrets)) {\n const path = nested[key];\n if (path === undefined) {\n target[key] = value;\n continue;\n }\n const segments = path.split(\".\");\n let node: Record<string, unknown> = target;\n for (let i = 0; i < segments.length - 1; i += 1) {\n const segment = segments[i];\n const next = node[segment];\n if (next === null || typeof next !== \"object\" || Array.isArray(next)) {\n node[segment] = {};\n }\n node = node[segment] as Record<string, unknown>;\n }\n node[segments[segments.length - 1]] = value;\n }\n return target;\n}\n\n/**\n * Return a shallow-cloned config whose enabled channels are enriched with the\n * secrets returned by `getSecrets` (stored secrets win over channel config).\n * Used by `apply` to feed store-backed credentials into each adapter while\n * leaving the caller's config object untouched. Secrets are placed at the slot\n * each adapter expects \u2014 flat keys at the channel root, nested keys (dingtalk's\n * `stream.clientId`/`stream.clientSecret`) under their sub-object.\n */\nexport async function injectSecrets(\n config: ChannelsConfig,\n enabled: readonly ChannelName[],\n getSecrets: (channel: ChannelName) => Promise<Record<string, string>>,\n): Promise<ChannelsConfig> {\n const out: ChannelsConfig = { ...config };\n for (const name of enabled) {\n try {\n const secrets = await getSecrets(name);\n if (Object.keys(secrets).length > 0) {\n // Deep-clone the channel config so we never mutate the caller's nested\n // objects (e.g. `dingtalk.stream`) when writing nested secret keys.\n const base = (out as Record<string, unknown>)[name] ?? {};\n const cloned = structuredClone(base) as Record<string, unknown>;\n (out as Record<string, unknown>)[name] = mergeSecrets(cloned, secrets, NESTED_SECRET_KEYS[name]);\n }\n } catch {\n // A channel's secret resolution failing should never block activation.\n }\n }\n return out;\n}\n", "/**\n * Host-side credential store for dsh-connect channels. Adapts the DSH\n * `credentials` provider (`resolve/describe/set/unset`, env-style refs) into a\n * per-channel interface, so secrets live in the DSH credential store instead of\n * the plugin config (mirroring dsh-im's `credential-store.mjs`). The web\n * settings pane reports `configured` booleans and may echo store-backed secret\n * values (e.g. an appId) so an upgraded user can confirm them, but secrets\n * never persist to the settings state file \u2014 the credential store is the only\n * place secret values are held.\n *\n * @module dsh-connect/settings/credential-store\n */\nimport { CHANNELS, type ChannelName } from \"./channels.js\";\n\n/**\n * One resolved credential: the non-empty secret value plus the source layer\n * that supplied it (`env`, `file`, `project-env`, `user-env`). Mirrors the\n * host's `ResolvedCredential` from `@deepseek-ai/dsh-credentials`.\n *\n * `resolve` returns this *object*, not a bare string. Reading it as a string\n * silently yields nothing \u2014 see `resolveValue`.\n */\nexport interface ResolvedCredential {\n value: string;\n source: string;\n}\n\n/** The seam the DSH host provides; `describe` reports source metadata. */\nexport interface CredentialsProvider {\n /**\n * Resolve one ref to its value, or `undefined`/`null` while unconfigured.\n * The host returns a `{value, source}` record; a plain string is also\n * accepted so hand-written stubs stay valid.\n */\n resolve(ref: string): Promise<ResolvedCredential | string | null | undefined>;\n describe(ref: string): Promise<{ configured: boolean; [k: string]: unknown }>;\n set(ref: string, value: string): Promise<void>;\n unset(ref: string): Promise<void>;\n}\n\n/** Unwrap either resolution shape to a non-empty string, else undefined. */\nfunction resolveValue(resolved: ResolvedCredential | string | null | undefined): string | undefined {\n const value = typeof resolved === \"string\" ? resolved : resolved?.value;\n return typeof value === \"string\" && value.length > 0 ? value : undefined;\n}\n\n/** Maps each channel's secret *config key* to its credential ref, so the\n * settings service and activation can enrich a channel config from the store.\n *\n * This is the **authoritative list of refs a channel owns**: every ref that\n * `save` can write appears here. A ref reachable only through\n * {@link CREDENTIAL_GROUPS} would be writable-but-unclearable \u2014 `save` and\n * `clear` both derive from this table for exactly that reason. */\nexport const CHANNEL_SECRET_KEYS: Record<ChannelName, Record<string, string>> = Object.freeze({\n feishu: { appId: \"DSH_CONNECT_FEISHU_APP_ID\", appSecret: \"DSH_CONNECT_FEISHU_APP_SECRET\" },\n telegram: { botToken: \"DSH_CONNECT_TELEGRAM_BOT_TOKEN\" },\n dingtalk: {\n webhookUrl: \"DSH_CONNECT_DINGTALK_WEBHOOK_URL\",\n secret: \"DSH_CONNECT_DINGTALK_SECRET\",\n clientId: \"DSH_CONNECT_DINGTALK_CLIENT_ID\",\n clientSecret: \"DSH_CONNECT_DINGTALK_CLIENT_SECRET\",\n },\n web: {},\n});\n\n/**\n * Credential sets that satisfy a channel, as groups-of-refs.\n *\n * Within a group every ref must be configured; between groups any one group\n * being complete is enough. The grouping exists for DingTalk, which has two\n * mutually exclusive transports \u2014 webhook push (url + signing secret) and\n * stream mode (client id + secret). A flat all-of list would report a\n * webhook-only user as \"\u672A\u914D\u7F6E\u51ED\u636E\" because their stream refs are empty.\n *\n * An empty group list means the channel needs no credentials at all, so it is\n * always satisfied \u2014 `web` mirrors the DSH Web UI in-process and holds no\n * secret. Reporting that as unconfigured was pure noise in the pane's badge.\n */\nexport const CREDENTIAL_GROUPS: Record<ChannelName, readonly (readonly string[])[]> = Object.freeze({\n feishu: [[\"DSH_CONNECT_FEISHU_APP_ID\", \"DSH_CONNECT_FEISHU_APP_SECRET\"]],\n telegram: [[\"DSH_CONNECT_TELEGRAM_BOT_TOKEN\"]],\n dingtalk: [\n [\"DSH_CONNECT_DINGTALK_WEBHOOK_URL\", \"DSH_CONNECT_DINGTALK_SECRET\"],\n [\"DSH_CONNECT_DINGTALK_CLIENT_ID\", \"DSH_CONNECT_DINGTALK_CLIENT_SECRET\"],\n ],\n web: [],\n}) as Record<ChannelName, readonly (readonly string[])[]>;\n\n/**\n * Flat, de-duplicated view of every ref named by {@link CREDENTIAL_GROUPS},\n * in declaration order. Kept because existing callers and tests import it;\n * new code should prefer `CHANNEL_SECRET_KEYS` for the refs a channel owns.\n */\nexport const CREDENTIAL_REFS: Record<ChannelName, readonly string[]> = Object.freeze(\n Object.fromEntries(\n CHANNELS.map((channel) => [channel, groupRefs(channel)]),\n ),\n) as Record<ChannelName, readonly string[]>;\n\n/** De-duplicated union of every ref named by a channel's groups. */\nfunction groupRefs(channel: ChannelName): readonly string[] {\n const seen = new Set<string>();\n for (const group of CREDENTIAL_GROUPS[channel] ?? []) {\n for (const ref of group) seen.add(ref);\n }\n return Object.freeze([...seen]);\n}\n\n/**\n * Every ref a channel may have written, i.e. the union of the refs named by its\n * groups and its config-key table. `clear` uses this so it cannot leave behind\n * a ref that `save` was able to write.\n */\nfunction ownedRefs(channel: ChannelName): readonly string[] {\n const seen = new Set<string>(groupRefs(channel));\n for (const ref of Object.values(CHANNEL_SECRET_KEYS[channel] ?? {})) seen.add(ref);\n return [...seen];\n}\n\n/** A channel credential store bound to a DSH provider. */\nexport interface CredentialStore {\n /**\n * Whether the channel is usable, i.e. some group of {@link CREDENTIAL_GROUPS}\n * is fully configured. A channel with no groups is always usable.\n */\n configured(channel: ChannelName): Promise<boolean>;\n /** Persist a channel secret under its ref. */\n save(channel: ChannelName, values: Record<string, string>): Promise<void>;\n /** Read the channel's stored secrets as { configKey: value }. */\n get(channel: ChannelName): Promise<Record<string, string>>;\n /** Remove every ref for the channel. */\n clear(channel: ChannelName): Promise<void>;\n}\n\nfunction assertProvider(provider: CredentialsProvider): void {\n for (const method of [\"resolve\", \"describe\", \"set\", \"unset\"] as const) {\n if (typeof provider[method] !== \"function\") {\n throw new TypeError(`A DSH credential provider with ${method}() is required`);\n }\n }\n}\n\n/** Build a per-channel credential store over a DSH `credentials` provider. */\nexport function createCredentialStore(provider: CredentialsProvider): CredentialStore {\n assertProvider(provider);\n return Object.freeze({\n async configured(channel: ChannelName) {\n const groups = CREDENTIAL_GROUPS[channel] ?? [];\n // No credential requirement at all (e.g. the in-process Web mirror):\n // nothing to be missing, so the channel counts as configured.\n if (groups.length === 0) return true;\n for (const group of groups) {\n const described = await Promise.all(group.map((ref) => provider.describe(ref)));\n if (described.every((d) => d.configured === true)) return true;\n }\n return false;\n },\n async save(channel: ChannelName, values: Record<string, string>) {\n // Iterate the config-key table, not the group union: it is the complete\n // list of refs this channel owns. Deriving the write set from the groups\n // silently dropped DingTalk's stream credentials, because only the\n // webhook group was represented there \u2014 the pane reported `ok: true`\n // while the clientId/clientSecret the user typed were never stored.\n for (const ref of Object.values(CHANNEL_SECRET_KEYS[channel] ?? {})) {\n const value = values[ref];\n if (value !== undefined) await provider.set(ref, value);\n }\n },\n async get(channel: ChannelName) {\n const map = CHANNEL_SECRET_KEYS[channel] ?? {};\n const entries = await Promise.all(\n Object.entries(map).map(async ([configKey, ref]) => {\n const value = resolveValue(await provider.resolve(ref));\n return [configKey, value] as const;\n }),\n );\n const out: Record<string, string> = {};\n for (const [configKey, value] of entries) {\n if (value !== undefined) out[configKey] = value;\n }\n return out;\n },\n async clear(channel: ChannelName) {\n const outcomes = await Promise.allSettled(ownedRefs(channel).map((ref) => provider.unset(ref)));\n if (outcomes.some((o) => o.status === \"rejected\")) {\n throw new Error(`Unable to clear ${channel} credentials.`);\n }\n },\n });\n}", "/**\n * Client-side settings model: pure, dependency-free helpers that map between the\n * host snapshot and the edit form, and build the save payloads. Kept separate from\n * the React component so the client settings logic is unit-testable anywhere.\n * The host-only secret-ref mapping lives on the server; the client only carries\n * field names.\n *\n * @module dsh-connect/settings/settings-model\n */\nimport { CHANNEL_SECRET_KEYS } from \"./credential-store.js\";\nimport type { ChannelName } from \"./channels.js\";\nimport type { SettingsSnapshot } from \"./settings-rpc.js\";\n\n/** Secret config-keys per channel, for rendering secret inputs. */\nexport const CHANNEL_SECRET_FIELDS = Object.fromEntries(\n Object.entries(CHANNEL_SECRET_KEYS).map(([ch, map]) => [ch, Object.keys(map ?? {})]),\n) as Record<ChannelName, string[]>;\n\n/**\n * A non-secret, user-editable per-channel config field. `kind` drives how the\n * pane renders the control and how `coerceConfigValue` normalizes the raw input\n * (text \u2192 string, number \u2192 finite number, boolean \u2192 false on empty, select \u2192\n * one of `options`).\n */\nexport interface ConfigField {\n key: string;\n kind: 'text' | 'number' | 'boolean' | 'select';\n options?: string[];\n /** Human label; defaults to the key if absent. */\n label?: string;\n}\n\n/**\n * Non-secret per-channel fields the pane exposes, mirroring the keys used by\n * each channel adapter config (see `examples/profile-cordis.patch.yml`). Order\n * is the render order; keep secrets separate (`CHANNEL_SECRET_FIELDS`).\n */\nexport const CHANNEL_CONFIG_FIELDS = {\n feishu: [\n { key: 'transport', kind: 'select', options: ['websocket', 'webhook'], label: 'transport' },\n { key: 'requireMention', kind: 'boolean', label: 'requireMention' },\n { key: 'dmMode', kind: 'select', options: ['open', 'allowlist', 'pair', 'disabled'], label: 'dmMode' },\n { key: 'language', kind: 'select', options: ['zh', 'en'], label: 'language' },\n { key: 'webhookPort', kind: 'number', label: 'webhookPort' },\n { key: 'webhookPath', kind: 'text', label: 'webhookPath' },\n ],\n telegram: [\n { key: 'requireMention', kind: 'boolean', label: 'requireMention' },\n { key: 'language', kind: 'select', options: ['zh', 'en'], label: 'language' },\n { key: 'pollingTimeoutSeconds', kind: 'number', label: 'pollingTimeoutSeconds' },\n { key: 'baseUrl', kind: 'text', label: 'baseUrl' },\n ],\n dingtalk: [\n { key: 'language', kind: 'select', options: ['zh', 'en'], label: 'language' },\n { key: 'defaultAt', kind: 'text', label: 'defaultAt' },\n ],\n web: [\n { key: 'pollIntervalMs', kind: 'number', label: 'pollIntervalMs' },\n ],\n} as const satisfies Record<ChannelName, readonly ConfigField[]>;\n\n/** Channel-agnostic keys applied to every channel that doesn't set its own. */\nexport const CHANNEL_DEFAULT_FIELDS: readonly ConfigField[] = [\n { key: 'language', kind: 'select', options: ['zh', 'en'], label: 'language' },\n { key: 'notifyLevel', kind: 'select', options: ['full', 'important', 'result'], label: 'notifyLevel' },\n];\n\n/** Normalize a raw form input for a field `kind` (empty \u2192 undefined = not saved). */\nexport function coerceConfigValue(kind: ConfigField['kind'], raw: unknown): unknown {\n if (raw === undefined || raw === null || raw === '') return undefined;\n switch (kind) {\n case 'number': {\n const n = Number(raw);\n return Number.isFinite(n) ? n : undefined;\n }\n case 'boolean':\n return typeof raw === 'boolean' ? raw : raw === true || raw === 'true';\n case 'select':\n return String(raw);\n default:\n return String(raw);\n }\n}\n\n/** The edit form the pane works on. */\nexport interface SettingsForm {\n channels: ChannelName[];\n channelDefaults: Record<string, unknown>;\n channelConfigs: Record<string, Record<string, unknown>>;\n /** Secret inputs the user is *typing*: written on save, never seeded by a read. */\n secrets: Record<string, Record<string, string>>;\n /**\n * Whether a value is already stored for a secret key, for the placeholder.\n * Separate from `secrets` because the two have opposite lifetimes: presence\n * comes from the snapshot and the typed values start empty and are cleared\n * after a save \u2014 a read must never be able to populate an input.\n */\n secretPresence: Record<string, Record<string, boolean>>;\n /**\n * Host-masked previews of the stored secrets, so the user can confirm what\n * they configured. Rendered as read-only text and **never** fed into\n * `secrets`: a masked value is not a credential, and treating one as a value\n * to save would overwrite a working secret with its own mask.\n */\n secretPreviews: Record<string, Record<string, string>>;\n settingsStatePath?: string;\n /** True when the section lives in the settings namespace: a save is immediate and durable. */\n live: boolean;\n}\n\n/** Build the initial form from a snapshot. */\nexport function snapshotToForm(snapshot: SettingsSnapshot): SettingsForm {\n const config = snapshot.config ?? {};\n const channels = (snapshot.enabled ?? []) as ChannelName[];\n const channelConfigs: Record<string, Record<string, unknown>> = {};\n for (const ch of channels) channelConfigs[ch] = (config[ch] ?? {}) as Record<string, unknown>;\n return {\n channels,\n channelDefaults: (config.channelDefaults ?? {}) as Record<string, unknown>,\n channelConfigs,\n // Deliberately empty even though `secretPreviews` carries something: an\n // input is a place to *type a new* secret, and prefilling it with the mask\n // would either overwrite the stored secret with its own preview on save, or\n // train the user to think a masked value is the real one. The preview is\n // rendered beside the input instead.\n secrets: {},\n secretPresence: (snapshot.secrets ?? {}) as Record<string, Record<string, boolean>>,\n secretPreviews: (snapshot.secretPreviews ?? {}) as Record<string, Record<string, string>>,\n settingsStatePath: config.settingsStatePath as string | undefined,\n live: snapshot.live === true,\n };\n}\n\n/** Drop keys whose value is `undefined`/`null` so cleared inputs aren't persisted. */\nfunction stripEmpty(obj: Record<string, unknown>): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(obj)) if (v !== undefined && v !== null) out[k] = v;\n return out;\n}\n\n/** Build the non-secret config to persist via `settings.save`. */\nexport function buildConfigSave(form: SettingsForm): Record<string, unknown> {\n const config: Record<string, unknown> = { channels: form.channels };\n const defaults = stripEmpty(form.channelDefaults ?? {});\n if (Object.keys(defaults).length > 0) config.channelDefaults = defaults;\n for (const [ch, cfg] of Object.entries(form.channelConfigs ?? {})) {\n const clean = stripEmpty(cfg ?? {});\n if (Object.keys(clean).length > 0) config[ch] = clean;\n }\n if (form.settingsStatePath) config.settingsStatePath = form.settingsStatePath;\n return config;\n}\n\n/** Build the `credentials.save` payloads for every channel with secret values. */\nexport function buildCredentialSaves(form: SettingsForm): { channel: string; values: Record<string, string> }[] {\n const out: { channel: string; values: Record<string, string> }[] = [];\n for (const [ch, values] of Object.entries(form.secrets ?? {})) {\n if (values && Object.keys(values).length > 0) out.push({ channel: ch, values });\n }\n return out;\n}", "/**\n * How much of a stored secret the settings pane is allowed to show.\n *\n * The user has to be able to *confirm* what they configured \u2014 a pane that only\n * ever says \u300C\u5DF2\u914D\u7F6E\u300D cannot tell a correct appId from a typo \u2014 but a browser\n * tab (or a screenshot of one) must never hold a usable credential. The two are\n * reconciled here: the host masks the value **before it crosses the wire**, so\n * what the pane receives is already unusable, and `maskSecret` is the only\n * place that decides how much survives.\n *\n * Deliberately pure and dependency-free: the host (`settings-service`) masks\n * with it and the client bundle imports the same compiled module for the input\n * `type`, so the two can never drift into disagreeing about which keys are\n * confidential.\n *\n * @module dsh-connect/settings/secret-disclosure\n */\n\n/**\n * - `full` \u2014 shown verbatim. For identifiers, not authenticators.\n * - `mask` \u2014 head and tail survive, the middle does not.\n * - `url` \u2014 a URL whose *query* carries the token, so only that part is masked.\n */\nexport type Disclosure = \"full\" | \"mask\" | \"url\";\n\n/**\n * Per-config-key policy, keyed by the channel config key (`CREDENTIAL`-style\n * keys are unique across channels: no channel has two fields that share a name\n * but want different treatment).\n *\n * `appId` / `clientId` are `full` on purpose. They are **identifiers**: they\n * appear in the URL or body of every outbound API call, are readable in the\n * vendor's own console, and grant nothing without the paired secret. Masking\n * them would cost the user the one thing this pane is for \u2014 checking that the\n * id they pasted is the one they meant \u2014 and buy no secrecy at all.\n */\nexport const SECRET_DISCLOSURE: Record<string, Disclosure> = Object.freeze({\n appId: \"full\",\n clientId: \"full\",\n appSecret: \"mask\",\n clientSecret: \"mask\",\n botToken: \"mask\",\n secret: \"mask\",\n webhookUrl: \"url\",\n});\n\n/** Query parameters whose *value* is a credential and must not be echoed. */\nconst SECRET_QUERY_PARAM = /(secret|token|key|sign)/i;\n\n/** Characters kept visible on each end of a masked value. */\nconst EDGE = 4;\n\n/**\n * Below this length there is no middle to hide \u2014 showing 4+4 of a 10-character\n * value would print it in full \u2014 so short values collapse to a fixed-width\n * mask, which also avoids leaking the length.\n */\nconst MIN_MASKABLE = 12;\n\n/** The width of the all-bullets mask used for values too short to split. */\nconst BULLETS = \"\u2022\u2022\u2022\u2022\u2022\u2022\";\n\n/** Policy for a config key; unknown keys are treated as confidential. */\nexport function disclosureOf(configKey: string): Disclosure {\n return SECRET_DISCLOSURE[configKey] ?? \"mask\";\n}\n\n/** True when the pane should render this key as a password input. */\nexport function isMaskedSecret(configKey: string): boolean {\n return disclosureOf(configKey) !== \"full\";\n}\n\n/** `head\u2026tail`, or the bullets mask when the value is too short to split. */\nfunction maskMiddle(value: string): string {\n if (value.length < MIN_MASKABLE) return BULLETS;\n return value.slice(0, EDGE) + \"\u2026\" + value.slice(-EDGE);\n}\n\n/** Escape a query-parameter name for use inside a `RegExp`. */\nfunction escapeForRegExp(text: string): string {\n return text.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n\n/**\n * Mask the credential-bearing query parameters of a URL, keeping the origin,\n * path and the innocent parameters readable.\n *\n * Masking such a URL end-to-end would defeat the purpose: DingTalk's webhook is\n * `https://oapi.dingtalk.com/robot/send?access_token=\u2026`, so `https\u2026bcde` tells\n * the user nothing about whether they pasted the right robot. Falls back to the\n * plain mask when the value isn't a parseable URL (a bare token, a typo, an\n * empty-ish string).\n *\n * The masking is a surgical replace on the original text rather than a\n * re-serialize through `URLSearchParams`, which would percent-encode the `\u2026`\n * into `%E2%80%A6` and render the preview unreadable.\n */\nfunction maskUrl(value: string): string {\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return maskMiddle(value);\n }\n let masked = value;\n for (const key of new Set(url.searchParams.keys())) {\n if (!SECRET_QUERY_PARAM.test(key)) continue;\n const pattern = new RegExp(`([?&]${escapeForRegExp(key)}=)([^&#]*)`, \"g\");\n masked = masked.replace(pattern, (whole, prefix: string, held: string) => {\n // Tolerate a percent-encoded token: decode to measure it, mask the\n // decoded form, and splice the readable result back into the raw text.\n let decoded = held;\n try {\n decoded = decodeURIComponent(held);\n } catch {\n /* not encoded \u2014 use as-is */\n }\n return decoded.length === 0 ? whole : prefix + maskMiddle(decoded);\n });\n }\n return masked;\n}\n\n/**\n * The display preview for one stored secret. Never returns the input: for\n * `mask` and `url` the returned string is lossy by construction, so a caller\n * that echoes it to a browser cannot leak a usable credential.\n *\n * Surrounding whitespace is trimmed first \u2014 a pasted value commonly carries a\n * trailing newline, and the mask should describe the stored value, not the\n * paste.\n */\nexport function maskSecret(configKey: string, value: string): string {\n const trimmed = value.trim();\n if (trimmed.length === 0) return \"\";\n switch (disclosureOf(configKey)) {\n case \"full\":\n return trimmed;\n case \"url\":\n return maskUrl(trimmed);\n default:\n return maskMiddle(trimmed);\n }\n}\n", "/**\n * Every user-visible string in the dsh-connect settings pane, in both shipped\n * languages.\n *\n * Kept in its own dependency-free module \u2014 rather than inline in the component \u2014\n * so a test can import it directly and assert the two languages cover exactly\n * the same keys. That assertion is the whole point: the host's `t()` resolves\n * `lookup(ns, key, chain) ?? lookup(\"common\", key, chain) ?? key`, and a key\n * missing from `zh` **silently renders the `en` string**, so a Chinese UI with\n * one untranslated key reads as half-English with no error anywhere. Checking\n * coverage mechanically is the only way that stays fixed.\n *\n * Key scheme:\n * - plain keys \u2014 chrome (titles, buttons, status words)\n * - `channel.<name>` / `channel.<name>.hint` \u2014 one card per channel\n * - `f.<key>` / `f.<key>.hint` \u2014 a non-secret config field (`CHANNEL_CONFIG_FIELDS`)\n * - `s.<channel>.<key>` / `.hint` \u2014 a secret field (`CHANNEL_SECRET_FIELDS`)\n * - `o.<value>` \u2014 a `select` option value, shared across fields\n * - `status.<state>` \u2014 the save-button status line\n */\n\nexport const LOCALES = {\n zh: {\n title: 'dsh-connect',\n channels: '\u6E20\u9053',\n defaults: '\u516C\u5171\u9ED8\u8BA4',\n 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',\n save: '\u4FDD\u5B58',\n saved: '\u5DF2\u4FDD\u5B58',\n error: '\u4FDD\u5B58\u5931\u8D25',\n loading: '\u52A0\u8F7D\u4E2D\u2026',\n statePath: '\u8BBE\u7F6E\u6587\u4EF6',\n 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',\n livePlane: '\u914D\u7F6E\u5B58\u653E\u5728 settings.yaml\uFF0C\u4FDD\u5B58\u540E\u7ACB\u5373\u751F\u6548\u3002',\n filePlane: '\u914D\u7F6E\u5B58\u653E\u5728\u672C\u5730\u8BBE\u7F6E\u6587\u4EF6\uFF0C\u91CD\u542F dsh \u540E\u751F\u6548\u3002',\n reachable: '\u5DF2\u914D\u7F6E\u51ED\u636E',\n unreachable: '\u672A\u914D\u7F6E\u51ED\u636E',\n configured: '\u5DF2\u914D\u7F6E\uFF0C\u91CD\u65B0\u586B\u5199\u53EF\u8986\u76D6',\n current: '\u5F53\u524D\u503C\uFF1A',\n notConfigured: '\u672A\u914D\u7F6E',\n 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',\n secrets: '\u51ED\u636E\u4E0E\u5BC6\u94A5',\n expand: '\u5C55\u5F00',\n collapse: '\u6536\u8D77',\n advanced: '\u9AD8\u7EA7\u9009\u9879',\n tabsAria: '\u6E20\u9053\u5207\u6362',\n\n 'status.loading': '\u52A0\u8F7D\u4E2D\u2026',\n 'status.idle': '\u5C31\u7EEA',\n 'status.saving': '\u4FDD\u5B58\u4E2D\u2026',\n 'status.saved': '\u5DF2\u4FDD\u5B58',\n 'status.error': '\u4FDD\u5B58\u5931\u8D25',\n\n 'channel.feishu': '\u98DE\u4E66 / Lark',\n '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',\n 'channel.telegram': 'Telegram',\n 'channel.telegram.hint': '\u901A\u8FC7 Telegram Bot API \u957F\u8F6E\u8BE2\u63A5\u6536\u6D88\u606F\uFF0C\u673A\u5668\u4EBA\u5411 @BotFather \u7533\u8BF7\u3002',\n 'channel.dingtalk': '\u9489\u9489',\n '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',\n 'channel.web': '\u7F51\u9875',\n 'channel.web.hint': '\u76F4\u63A5\u5728 DSH \u7F51\u9875\u754C\u9762\u91CC\u5BF9\u8BDD\uFF0C\u4E0D\u9700\u8981\u4EFB\u4F55\u51ED\u636E\u3002',\n\n 'f.transport': '\u63A5\u5165\u65B9\u5F0F',\n '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',\n 'f.requireMention': '\u4EC5\u54CD\u5E94 @ \u63D0\u53CA',\n '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',\n 'f.dmMode': '\u5355\u804A\u7B56\u7565',\n 'f.dmMode.hint': '\u63A7\u5236\u54EA\u4E9B\u4EBA\u53EF\u4EE5\u76F4\u63A5\u79C1\u804A\u673A\u5668\u4EBA\u3002',\n 'f.language': '\u56DE\u590D\u8BED\u8A00',\n 'f.language.hint': '\u673A\u5668\u4EBA\u56DE\u590D\u7528\u6237\u65F6\u4F7F\u7528\u7684\u8BED\u8A00\u3002',\n 'f.webhookPort': '\u56DE\u8C03\u7AEF\u53E3',\n 'f.webhookPort.hint': '\u4EC5\u300C\u56DE\u8C03\u5730\u5740\u300D\u63A5\u5165\u65B9\u5F0F\u4F7F\u7528\uFF0C\u9ED8\u8BA4 3000\u3002',\n 'f.webhookPath': '\u56DE\u8C03\u8DEF\u5F84',\n 'f.webhookPath.hint': '\u4EC5\u300C\u56DE\u8C03\u5730\u5740\u300D\u63A5\u5165\u65B9\u5F0F\u4F7F\u7528\uFF0C\u4F8B\u5982 /feishu/events\u3002',\n 'f.pollingTimeoutSeconds': '\u8F6E\u8BE2\u8D85\u65F6\uFF08\u79D2\uFF09',\n 'f.pollingTimeoutSeconds.hint': '\u5355\u6B21\u957F\u8F6E\u8BE2\u7B49\u5F85\u79D2\u6570\uFF0C\u9ED8\u8BA4 30\u3002',\n 'f.baseUrl': '\u63A5\u53E3\u5730\u5740',\n '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',\n 'f.defaultAt': '\u9ED8\u8BA4 @ \u6210\u5458',\n 'f.defaultAt.hint': '\u7FA4\u6D88\u606F\u9ED8\u8BA4 @ \u7684\u6210\u5458\uFF0C\u591A\u4E2A\u7528\u9017\u53F7\u5206\u9694\u3002',\n 'f.pollIntervalMs': '\u8F6E\u8BE2\u95F4\u9694\uFF08\u6BEB\u79D2\uFF09',\n 'f.pollIntervalMs.hint': '\u7F51\u9875\u6E20\u9053\u68C0\u67E5\u65B0\u6D88\u606F\u7684\u95F4\u9694\uFF0C\u9ED8\u8BA4 1000\u3002',\n 'f.notifyLevel': '\u901A\u77E5\u7EA7\u522B',\n 'f.notifyLevel.hint': '\u63A7\u5236\u673A\u5668\u4EBA\u628A\u591A\u5C11\u8FC7\u7A0B\u4FE1\u606F\u53D1\u5230\u804A\u5929\u91CC\u3002',\n\n 's.feishu.appId': 'App ID',\n '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',\n 's.feishu.appSecret': 'App Secret',\n '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',\n 's.telegram.botToken': 'Bot Token',\n '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',\n 's.dingtalk.webhookUrl': 'Webhook \u5730\u5740',\n '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',\n 's.dingtalk.secret': '\u52A0\u7B7E\u5BC6\u94A5',\n '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',\n 's.dingtalk.clientId': 'Client ID',\n 's.dingtalk.clientId.hint': 'Stream \u6A21\u5F0F\u5E94\u7528\u7684 AppKey\uFF0C\u975E\u673A\u5BC6\uFF0C\u5B8C\u6574\u663E\u793A\u3002',\n 's.dingtalk.clientSecret': 'Client Secret',\n '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',\n\n 'o.default': '\uFF08\u6CBF\u7528\u9ED8\u8BA4\uFF09',\n 'o.websocket': '\u957F\u8FDE\u63A5\uFF08\u63A8\u8350\uFF09',\n 'o.webhook': '\u56DE\u8C03\u5730\u5740',\n 'o.open': '\u6240\u6709\u4EBA\u90FD\u53EF\u4EE5',\n 'o.allowlist': '\u4EC5\u540D\u5355\u5185\u7684\u4EBA',\n 'o.pair': '\u9700\u8981\u5148\u914D\u5BF9',\n 'o.disabled': '\u5173\u95ED\u5355\u804A',\n 'o.zh': '\u4E2D\u6587',\n 'o.en': '\u82F1\u6587',\n 'o.full': '\u5168\u90E8\u8FC7\u7A0B',\n 'o.important': '\u5173\u952E\u8282\u70B9',\n 'o.result': '\u4EC5\u6700\u7EC8\u7ED3\u679C',\n },\n en: {\n title: 'dsh-connect',\n channels: 'Channels',\n defaults: 'Defaults',\n defaultsHint: 'Channels without their own value fall back to these; leave a field empty to use each channel\u2019s built-in default.',\n save: 'Save',\n saved: 'Saved',\n error: 'Save failed',\n loading: 'Loading\u2026',\n statePath: 'Settings file',\n statePathHint: 'Where the config falls back to when no settings.yaml namespace is live.',\n livePlane: 'Stored in settings.yaml \u2014 a save takes effect immediately.',\n filePlane: 'Stored in a local settings file \u2014 a save applies after dsh restarts.',\n reachable: 'Credentials set',\n unreachable: 'Credentials missing',\n configured: 'Configured \u2014 type to replace',\n current: 'Current value: ',\n notConfigured: 'Not configured',\n previewNote: 'Shown masked, so you can confirm the stored value without exposing it. Leave the input empty to keep it.',\n secrets: 'Credentials & secrets',\n expand: 'Expand',\n collapse: 'Collapse',\n advanced: 'Advanced',\n tabsAria: 'Channel switcher',\n\n 'status.loading': 'Loading\u2026',\n 'status.idle': 'Ready',\n 'status.saving': 'Saving\u2026',\n 'status.saved': 'Saved',\n 'status.error': 'Save failed',\n\n 'channel.feishu': 'Feishu / Lark',\n 'channel.feishu.hint': 'Create a custom app on the Feishu open platform and receive messages over a long connection.',\n 'channel.telegram': 'Telegram',\n 'channel.telegram.hint': 'Receive messages by long-polling the Telegram Bot API; get a bot from @BotFather.',\n 'channel.dingtalk': 'DingTalk',\n 'channel.dingtalk.hint': 'Supports both webhook push and stream mode \u2014 fill in either set.',\n 'channel.web': 'Web',\n 'channel.web.hint': 'Chat straight from the DSH web UI; needs no credentials at all.',\n\n 'f.transport': 'Transport',\n '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.',\n 'f.requireMention': 'Only when @-mentioned',\n 'f.requireMention.hint': 'In group chats, handle a message only if the bot was mentioned. Direct messages are unaffected.',\n 'f.dmMode': 'Direct messages',\n 'f.dmMode.hint': 'Who is allowed to message the bot directly.',\n 'f.language': 'Reply language',\n 'f.language.hint': 'The language the bot replies to users in.',\n 'f.webhookPort': 'Callback port',\n 'f.webhookPort.hint': 'Used only by the callback-URL transport; defaults to 3000.',\n 'f.webhookPath': 'Callback path',\n 'f.webhookPath.hint': 'Used only by the callback-URL transport, e.g. /feishu/events.',\n 'f.pollingTimeoutSeconds': 'Poll timeout (s)',\n 'f.pollingTimeoutSeconds.hint': 'How long one long poll waits; defaults to 30.',\n 'f.baseUrl': 'API base URL',\n 'f.baseUrl.hint': 'The Telegram Bot API endpoint \u2014 only change it if you run your own proxy.',\n 'f.defaultAt': 'Default @-list',\n 'f.defaultAt.hint': 'Members to @ by default on group messages, comma-separated.',\n 'f.pollIntervalMs': 'Poll interval (ms)',\n 'f.pollIntervalMs.hint': 'How often the web channel checks for new messages; defaults to 1000.',\n 'f.notifyLevel': 'Notify level',\n 'f.notifyLevel.hint': 'How much of the working process the bot posts into the chat.',\n\n 's.feishu.appId': 'App ID',\n 's.feishu.appId.hint': 'The App ID from the open platform\u2019s credentials page. Not a secret \u2014 shown in full.',\n 's.feishu.appSecret': 'App Secret',\n 's.feishu.appSecret.hint': 'The app secret paired with the App ID. Confidential \u2014 only the first and last 4 characters are shown.',\n 's.telegram.botToken': 'Bot Token',\n 's.telegram.botToken.hint': 'The bot token from @BotFather, like 123456:ABC\u2026 \u2014 confidential.',\n 's.dingtalk.webhookUrl': 'Webhook URL',\n 's.dingtalk.webhookUrl.hint': 'The group robot\u2019s full webhook URL; the preview keeps the host and path and masks only the access_token.',\n 's.dingtalk.secret': 'Signing secret',\n 's.dingtalk.secret.hint': 'Only present once the group robot has the \u201Csign\u201D security setting enabled \u2014 confidential.',\n 's.dingtalk.clientId': 'Client ID',\n 's.dingtalk.clientId.hint': 'The AppKey of the stream-mode app. Not a secret \u2014 shown in full.',\n 's.dingtalk.clientSecret': 'Client Secret',\n 's.dingtalk.clientSecret.hint': 'The AppSecret of the stream-mode app. Confidential \u2014 only the first and last 4 characters are shown.',\n\n 'o.default': '(use default)',\n 'o.websocket': 'Long connection (recommended)',\n 'o.webhook': 'Callback URL',\n 'o.open': 'Anyone',\n 'o.allowlist': 'Allowlisted users only',\n 'o.pair': 'Pairing required',\n 'o.disabled': 'Direct messages off',\n 'o.zh': 'Chinese',\n 'o.en': 'English',\n 'o.full': 'Everything',\n 'o.important': 'Key milestones',\n 'o.result': 'Final result only',\n },\n};\n\n/** The union of keys the two languages cover. */\nexport const LOCALE_KEYS = Object.keys(LOCALES.zh);\n\n/** True when a key is shipped in both languages. */\nexport function hasLocale(key) {\n return Object.prototype.hasOwnProperty.call(LOCALES.zh, key) && Object.prototype.hasOwnProperty.call(LOCALES.en, key);\n}\n\n/**\n * Translate a key that is guaranteed to be present, falling back if it somehow\n * isn't. The host returns the *key itself* for a miss, which would render\n * `f.dmMode` into the UI \u2014 a fallback keeps that a legible label instead.\n */\nexport function tr(t, key, fallback) {\n return hasLocale(key) ? t(key) : fallback;\n}\n\n/** Translate an optional key: `undefined` when absent, so callers can skip the node. */\nexport function optionalText(t, key) {\n return hasLocale(key) ? t(key) : undefined;\n}\n", "/**\n * Which channel cards the settings pane shows open, and which fields hide behind\n * each card's \"advanced\" fold.\n *\n * Dependency-free and separate from the React component for the same reason\n * `locale.mjs` is: these rules are the interesting part, they are pure, and a\n * test can pin them without a DOM. The bundle test in particular cannot \u2014 it\n * renders the component with pre-supplied hook values, so it can never observe\n * what the component *initializes* to. Only this module can answer that.\n */\n\n/**\n * Fields that are set once and then left alone, grouped by channel.\n *\n * These are what makes the pane tall: with every channel expanded the pane\n * renders 20 field blocks, each a label + input + preview + hint. Folding the\n * rarely-touched ones behind a second-level disclosure keeps the common case \u2014\n * credentials plus the handful of behavioural switches \u2014 on one screen.\n * `test/panel-state.test.mjs` asserts every key here really exists in\n * `CHANNEL_CONFIG_FIELDS`, because a typo would silently drop a field from the\n * UI rather than fail anything.\n */\nexport const ADVANCED_KEYS = {\n feishu: ['webhookPort', 'webhookPath'],\n telegram: ['pollingTimeoutSeconds', 'baseUrl'],\n dingtalk: ['defaultAt'],\n web: ['pollIntervalMs'],\n};\n\n/** True when `key` is one of `channel`'s low-frequency fields. */\nexport function isAdvanced(channel, key) {\n return (ADVANCED_KEYS[channel] ?? []).includes(key);\n}\n\n/**\n * The channels whose card starts open: the enabled ones, or \u2014 when nothing is\n * enabled at all \u2014 the first channel, so the pane never opens as a wall of\n * collapsed headers with no contents in sight.\n *\n * Unknown names are filtered out rather than trusted: `enabled` comes from the\n * host and would otherwise open a card that does not exist.\n */\nexport function initialOpenChannels(enabled, all) {\n const open = (enabled ?? []).filter((ch) => all.includes(ch));\n return new Set(open.length > 0 ? open : all.slice(0, 1));\n}\n\n/** Toggle one key in a Set, returning a new Set (never mutating the argument). */\nexport function toggleInSet(set, key) {\n const next = new Set(set);\n if (next.has(key)) next.delete(key);\n else next.add(key);\n return next;\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["name"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every user-visible string in the dsh-connect settings pane, in both shipped
|
|
3
|
+
* languages.
|
|
4
|
+
*
|
|
5
|
+
* Kept in its own dependency-free module — rather than inline in the component —
|
|
6
|
+
* so a test can import it directly and assert the two languages cover exactly
|
|
7
|
+
* the same keys. That assertion is the whole point: the host's `t()` resolves
|
|
8
|
+
* `lookup(ns, key, chain) ?? lookup("common", key, chain) ?? key`, and a key
|
|
9
|
+
* missing from `zh` **silently renders the `en` string**, so a Chinese UI with
|
|
10
|
+
* one untranslated key reads as half-English with no error anywhere. Checking
|
|
11
|
+
* coverage mechanically is the only way that stays fixed.
|
|
12
|
+
*
|
|
13
|
+
* Key scheme:
|
|
14
|
+
* - plain keys — chrome (titles, buttons, status words)
|
|
15
|
+
* - `channel.<name>` / `channel.<name>.hint` — one card per channel
|
|
16
|
+
* - `f.<key>` / `f.<key>.hint` — a non-secret config field (`CHANNEL_CONFIG_FIELDS`)
|
|
17
|
+
* - `s.<channel>.<key>` / `.hint` — a secret field (`CHANNEL_SECRET_FIELDS`)
|
|
18
|
+
* - `o.<value>` — a `select` option value, shared across fields
|
|
19
|
+
* - `status.<state>` — the save-button status line
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const LOCALES = {
|
|
23
|
+
zh: {
|
|
24
|
+
title: 'dsh-connect',
|
|
25
|
+
channels: '渠道',
|
|
26
|
+
defaults: '公共默认',
|
|
27
|
+
defaultsHint: '未单独配置的渠道沿用这里的取值;留空表示沿用各渠道自身的默认值。',
|
|
28
|
+
save: '保存',
|
|
29
|
+
saved: '已保存',
|
|
30
|
+
error: '保存失败',
|
|
31
|
+
loading: '加载中…',
|
|
32
|
+
statePath: '设置文件',
|
|
33
|
+
statePathHint: '没有 settings.yaml 命名空间时,配置回退存放在这个 JSON 文件里。',
|
|
34
|
+
livePlane: '配置存放在 settings.yaml,保存后立即生效。',
|
|
35
|
+
filePlane: '配置存放在本地设置文件,重启 dsh 后生效。',
|
|
36
|
+
reachable: '已配置凭据',
|
|
37
|
+
unreachable: '未配置凭据',
|
|
38
|
+
configured: '已配置,重新填写可覆盖',
|
|
39
|
+
current: '当前值:',
|
|
40
|
+
notConfigured: '未配置',
|
|
41
|
+
previewNote: '为便于确认,此处只显示脱敏后的部分字符;输入框留空表示不修改。',
|
|
42
|
+
secrets: '凭据与密钥',
|
|
43
|
+
expand: '展开',
|
|
44
|
+
collapse: '收起',
|
|
45
|
+
advanced: '高级选项',
|
|
46
|
+
tabsAria: '渠道切换',
|
|
47
|
+
|
|
48
|
+
'status.loading': '加载中…',
|
|
49
|
+
'status.idle': '就绪',
|
|
50
|
+
'status.saving': '保存中…',
|
|
51
|
+
'status.saved': '已保存',
|
|
52
|
+
'status.error': '保存失败',
|
|
53
|
+
|
|
54
|
+
'channel.feishu': '飞书 / Lark',
|
|
55
|
+
'channel.feishu.hint': '在飞书开放平台创建自建应用,用长连接接收消息。',
|
|
56
|
+
'channel.telegram': 'Telegram',
|
|
57
|
+
'channel.telegram.hint': '通过 Telegram Bot API 长轮询接收消息,机器人向 @BotFather 申请。',
|
|
58
|
+
'channel.dingtalk': '钉钉',
|
|
59
|
+
'channel.dingtalk.hint': '支持 Webhook 推送与 Stream 双向两种模式,填其中一组即可。',
|
|
60
|
+
'channel.web': '网页',
|
|
61
|
+
'channel.web.hint': '直接在 DSH 网页界面里对话,不需要任何凭据。',
|
|
62
|
+
|
|
63
|
+
'f.transport': '接入方式',
|
|
64
|
+
'f.transport.hint': '长连接无需公网地址,推荐;回调地址需要 DSH 所在机器可从公网访问。',
|
|
65
|
+
'f.requireMention': '仅响应 @ 提及',
|
|
66
|
+
'f.requireMention.hint': '开启后群聊中只有 @ 机器人才处理;单聊不受影响。',
|
|
67
|
+
'f.dmMode': '单聊策略',
|
|
68
|
+
'f.dmMode.hint': '控制哪些人可以直接私聊机器人。',
|
|
69
|
+
'f.language': '回复语言',
|
|
70
|
+
'f.language.hint': '机器人回复用户时使用的语言。',
|
|
71
|
+
'f.webhookPort': '回调端口',
|
|
72
|
+
'f.webhookPort.hint': '仅「回调地址」接入方式使用,默认 3000。',
|
|
73
|
+
'f.webhookPath': '回调路径',
|
|
74
|
+
'f.webhookPath.hint': '仅「回调地址」接入方式使用,例如 /feishu/events。',
|
|
75
|
+
'f.pollingTimeoutSeconds': '轮询超时(秒)',
|
|
76
|
+
'f.pollingTimeoutSeconds.hint': '单次长轮询等待秒数,默认 30。',
|
|
77
|
+
'f.baseUrl': '接口地址',
|
|
78
|
+
'f.baseUrl.hint': 'Telegram Bot API 地址,只有在自建反向代理时才需要修改。',
|
|
79
|
+
'f.defaultAt': '默认 @ 成员',
|
|
80
|
+
'f.defaultAt.hint': '群消息默认 @ 的成员,多个用逗号分隔。',
|
|
81
|
+
'f.pollIntervalMs': '轮询间隔(毫秒)',
|
|
82
|
+
'f.pollIntervalMs.hint': '网页渠道检查新消息的间隔,默认 1000。',
|
|
83
|
+
'f.notifyLevel': '通知级别',
|
|
84
|
+
'f.notifyLevel.hint': '控制机器人把多少过程信息发到聊天里。',
|
|
85
|
+
|
|
86
|
+
's.feishu.appId': 'App ID',
|
|
87
|
+
's.feishu.appId.hint': '开放平台「凭证与基础信息」中的 App ID,非机密,完整显示。',
|
|
88
|
+
's.feishu.appSecret': 'App Secret',
|
|
89
|
+
's.feishu.appSecret.hint': '与 App ID 配对的应用密钥,属于机密,仅显示首尾各 4 位。',
|
|
90
|
+
's.telegram.botToken': 'Bot Token',
|
|
91
|
+
's.telegram.botToken.hint': '@BotFather 生成的机器人令牌,形如 123456:ABC…,属于机密。',
|
|
92
|
+
's.dingtalk.webhookUrl': 'Webhook 地址',
|
|
93
|
+
's.dingtalk.webhookUrl.hint': '群机器人的完整 Webhook 地址;预览保留域名与路径,只遮蔽 access_token。',
|
|
94
|
+
's.dingtalk.secret': '加签密钥',
|
|
95
|
+
's.dingtalk.secret.hint': '群机器人开启「加签」安全设置后才有,属于机密。',
|
|
96
|
+
's.dingtalk.clientId': 'Client ID',
|
|
97
|
+
's.dingtalk.clientId.hint': 'Stream 模式应用的 AppKey,非机密,完整显示。',
|
|
98
|
+
's.dingtalk.clientSecret': 'Client Secret',
|
|
99
|
+
's.dingtalk.clientSecret.hint': 'Stream 模式应用的 AppSecret,属于机密,仅显示首尾各 4 位。',
|
|
100
|
+
|
|
101
|
+
'o.default': '(沿用默认)',
|
|
102
|
+
'o.websocket': '长连接(推荐)',
|
|
103
|
+
'o.webhook': '回调地址',
|
|
104
|
+
'o.open': '所有人都可以',
|
|
105
|
+
'o.allowlist': '仅名单内的人',
|
|
106
|
+
'o.pair': '需要先配对',
|
|
107
|
+
'o.disabled': '关闭单聊',
|
|
108
|
+
'o.zh': '中文',
|
|
109
|
+
'o.en': '英文',
|
|
110
|
+
'o.full': '全部过程',
|
|
111
|
+
'o.important': '关键节点',
|
|
112
|
+
'o.result': '仅最终结果',
|
|
113
|
+
},
|
|
114
|
+
en: {
|
|
115
|
+
title: 'dsh-connect',
|
|
116
|
+
channels: 'Channels',
|
|
117
|
+
defaults: 'Defaults',
|
|
118
|
+
defaultsHint: 'Channels without their own value fall back to these; leave a field empty to use each channel’s built-in default.',
|
|
119
|
+
save: 'Save',
|
|
120
|
+
saved: 'Saved',
|
|
121
|
+
error: 'Save failed',
|
|
122
|
+
loading: 'Loading…',
|
|
123
|
+
statePath: 'Settings file',
|
|
124
|
+
statePathHint: 'Where the config falls back to when no settings.yaml namespace is live.',
|
|
125
|
+
livePlane: 'Stored in settings.yaml — a save takes effect immediately.',
|
|
126
|
+
filePlane: 'Stored in a local settings file — a save applies after dsh restarts.',
|
|
127
|
+
reachable: 'Credentials set',
|
|
128
|
+
unreachable: 'Credentials missing',
|
|
129
|
+
configured: 'Configured — type to replace',
|
|
130
|
+
current: 'Current value: ',
|
|
131
|
+
notConfigured: 'Not configured',
|
|
132
|
+
previewNote: 'Shown masked, so you can confirm the stored value without exposing it. Leave the input empty to keep it.',
|
|
133
|
+
secrets: 'Credentials & secrets',
|
|
134
|
+
expand: 'Expand',
|
|
135
|
+
collapse: 'Collapse',
|
|
136
|
+
advanced: 'Advanced',
|
|
137
|
+
tabsAria: 'Channel switcher',
|
|
138
|
+
|
|
139
|
+
'status.loading': 'Loading…',
|
|
140
|
+
'status.idle': 'Ready',
|
|
141
|
+
'status.saving': 'Saving…',
|
|
142
|
+
'status.saved': 'Saved',
|
|
143
|
+
'status.error': 'Save failed',
|
|
144
|
+
|
|
145
|
+
'channel.feishu': 'Feishu / Lark',
|
|
146
|
+
'channel.feishu.hint': 'Create a custom app on the Feishu open platform and receive messages over a long connection.',
|
|
147
|
+
'channel.telegram': 'Telegram',
|
|
148
|
+
'channel.telegram.hint': 'Receive messages by long-polling the Telegram Bot API; get a bot from @BotFather.',
|
|
149
|
+
'channel.dingtalk': 'DingTalk',
|
|
150
|
+
'channel.dingtalk.hint': 'Supports both webhook push and stream mode — fill in either set.',
|
|
151
|
+
'channel.web': 'Web',
|
|
152
|
+
'channel.web.hint': 'Chat straight from the DSH web UI; needs no credentials at all.',
|
|
153
|
+
|
|
154
|
+
'f.transport': 'Transport',
|
|
155
|
+
'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.',
|
|
156
|
+
'f.requireMention': 'Only when @-mentioned',
|
|
157
|
+
'f.requireMention.hint': 'In group chats, handle a message only if the bot was mentioned. Direct messages are unaffected.',
|
|
158
|
+
'f.dmMode': 'Direct messages',
|
|
159
|
+
'f.dmMode.hint': 'Who is allowed to message the bot directly.',
|
|
160
|
+
'f.language': 'Reply language',
|
|
161
|
+
'f.language.hint': 'The language the bot replies to users in.',
|
|
162
|
+
'f.webhookPort': 'Callback port',
|
|
163
|
+
'f.webhookPort.hint': 'Used only by the callback-URL transport; defaults to 3000.',
|
|
164
|
+
'f.webhookPath': 'Callback path',
|
|
165
|
+
'f.webhookPath.hint': 'Used only by the callback-URL transport, e.g. /feishu/events.',
|
|
166
|
+
'f.pollingTimeoutSeconds': 'Poll timeout (s)',
|
|
167
|
+
'f.pollingTimeoutSeconds.hint': 'How long one long poll waits; defaults to 30.',
|
|
168
|
+
'f.baseUrl': 'API base URL',
|
|
169
|
+
'f.baseUrl.hint': 'The Telegram Bot API endpoint — only change it if you run your own proxy.',
|
|
170
|
+
'f.defaultAt': 'Default @-list',
|
|
171
|
+
'f.defaultAt.hint': 'Members to @ by default on group messages, comma-separated.',
|
|
172
|
+
'f.pollIntervalMs': 'Poll interval (ms)',
|
|
173
|
+
'f.pollIntervalMs.hint': 'How often the web channel checks for new messages; defaults to 1000.',
|
|
174
|
+
'f.notifyLevel': 'Notify level',
|
|
175
|
+
'f.notifyLevel.hint': 'How much of the working process the bot posts into the chat.',
|
|
176
|
+
|
|
177
|
+
's.feishu.appId': 'App ID',
|
|
178
|
+
's.feishu.appId.hint': 'The App ID from the open platform’s credentials page. Not a secret — shown in full.',
|
|
179
|
+
's.feishu.appSecret': 'App Secret',
|
|
180
|
+
's.feishu.appSecret.hint': 'The app secret paired with the App ID. Confidential — only the first and last 4 characters are shown.',
|
|
181
|
+
's.telegram.botToken': 'Bot Token',
|
|
182
|
+
's.telegram.botToken.hint': 'The bot token from @BotFather, like 123456:ABC… — confidential.',
|
|
183
|
+
's.dingtalk.webhookUrl': 'Webhook URL',
|
|
184
|
+
's.dingtalk.webhookUrl.hint': 'The group robot’s full webhook URL; the preview keeps the host and path and masks only the access_token.',
|
|
185
|
+
's.dingtalk.secret': 'Signing secret',
|
|
186
|
+
's.dingtalk.secret.hint': 'Only present once the group robot has the “sign” security setting enabled — confidential.',
|
|
187
|
+
's.dingtalk.clientId': 'Client ID',
|
|
188
|
+
's.dingtalk.clientId.hint': 'The AppKey of the stream-mode app. Not a secret — shown in full.',
|
|
189
|
+
's.dingtalk.clientSecret': 'Client Secret',
|
|
190
|
+
's.dingtalk.clientSecret.hint': 'The AppSecret of the stream-mode app. Confidential — only the first and last 4 characters are shown.',
|
|
191
|
+
|
|
192
|
+
'o.default': '(use default)',
|
|
193
|
+
'o.websocket': 'Long connection (recommended)',
|
|
194
|
+
'o.webhook': 'Callback URL',
|
|
195
|
+
'o.open': 'Anyone',
|
|
196
|
+
'o.allowlist': 'Allowlisted users only',
|
|
197
|
+
'o.pair': 'Pairing required',
|
|
198
|
+
'o.disabled': 'Direct messages off',
|
|
199
|
+
'o.zh': 'Chinese',
|
|
200
|
+
'o.en': 'English',
|
|
201
|
+
'o.full': 'Everything',
|
|
202
|
+
'o.important': 'Key milestones',
|
|
203
|
+
'o.result': 'Final result only',
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** The union of keys the two languages cover. */
|
|
208
|
+
export const LOCALE_KEYS = Object.keys(LOCALES.zh);
|
|
209
|
+
|
|
210
|
+
/** True when a key is shipped in both languages. */
|
|
211
|
+
export function hasLocale(key) {
|
|
212
|
+
return Object.prototype.hasOwnProperty.call(LOCALES.zh, key) && Object.prototype.hasOwnProperty.call(LOCALES.en, key);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Translate a key that is guaranteed to be present, falling back if it somehow
|
|
217
|
+
* isn't. The host returns the *key itself* for a miss, which would render
|
|
218
|
+
* `f.dmMode` into the UI — a fallback keeps that a legible label instead.
|
|
219
|
+
*/
|
|
220
|
+
export function tr(t, key, fallback) {
|
|
221
|
+
return hasLocale(key) ? t(key) : fallback;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Translate an optional key: `undefined` when absent, so callers can skip the node. */
|
|
225
|
+
export function optionalText(t, key) {
|
|
226
|
+
return hasLocale(key) ? t(key) : undefined;
|
|
227
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which channel cards the settings pane shows open, and which fields hide behind
|
|
3
|
+
* each card's "advanced" fold.
|
|
4
|
+
*
|
|
5
|
+
* Dependency-free and separate from the React component for the same reason
|
|
6
|
+
* `locale.mjs` is: these rules are the interesting part, they are pure, and a
|
|
7
|
+
* test can pin them without a DOM. The bundle test in particular cannot — it
|
|
8
|
+
* renders the component with pre-supplied hook values, so it can never observe
|
|
9
|
+
* what the component *initializes* to. Only this module can answer that.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Fields that are set once and then left alone, grouped by channel.
|
|
14
|
+
*
|
|
15
|
+
* These are what makes the pane tall: with every channel expanded the pane
|
|
16
|
+
* renders 20 field blocks, each a label + input + preview + hint. Folding the
|
|
17
|
+
* rarely-touched ones behind a second-level disclosure keeps the common case —
|
|
18
|
+
* credentials plus the handful of behavioural switches — on one screen.
|
|
19
|
+
* `test/panel-state.test.mjs` asserts every key here really exists in
|
|
20
|
+
* `CHANNEL_CONFIG_FIELDS`, because a typo would silently drop a field from the
|
|
21
|
+
* UI rather than fail anything.
|
|
22
|
+
*/
|
|
23
|
+
export const ADVANCED_KEYS = {
|
|
24
|
+
feishu: ['webhookPort', 'webhookPath'],
|
|
25
|
+
telegram: ['pollingTimeoutSeconds', 'baseUrl'],
|
|
26
|
+
dingtalk: ['defaultAt'],
|
|
27
|
+
web: ['pollIntervalMs'],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** True when `key` is one of `channel`'s low-frequency fields. */
|
|
31
|
+
export function isAdvanced(channel, key) {
|
|
32
|
+
return (ADVANCED_KEYS[channel] ?? []).includes(key);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The channels whose card starts open: the enabled ones, or — when nothing is
|
|
37
|
+
* enabled at all — the first channel, so the pane never opens as a wall of
|
|
38
|
+
* collapsed headers with no contents in sight.
|
|
39
|
+
*
|
|
40
|
+
* Unknown names are filtered out rather than trusted: `enabled` comes from the
|
|
41
|
+
* host and would otherwise open a card that does not exist.
|
|
42
|
+
*/
|
|
43
|
+
export function initialOpenChannels(enabled, all) {
|
|
44
|
+
const open = (enabled ?? []).filter((ch) => all.includes(ch));
|
|
45
|
+
return new Set(open.length > 0 ? open : all.slice(0, 1));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Toggle one key in a Set, returning a new Set (never mutating the argument). */
|
|
49
|
+
export function toggleInSet(set, key) {
|
|
50
|
+
const next = new Set(set);
|
|
51
|
+
if (next.has(key)) next.delete(key);
|
|
52
|
+
else next.add(key);
|
|
53
|
+
return next;
|
|
54
|
+
}
|