@xmanrui/dsh-im 4.34.0 → 4.34.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.34.0",
3
+ "version": "4.34.2",
4
4
  "icon": "./assets/logo-plugin-message-link-gradient.webp",
5
5
  "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
6
6
  "keywords": [
@@ -8,6 +8,7 @@ import {
8
8
  import { h, localizeText } from './i18n.js';
9
9
 
10
10
  export const ACCESS_POLICY_ENDPOINT = 'bot.access-policy.set';
11
+ const CONFIRMED_OWNER_CHANNELS = new Set(['weixin', 'feishu', 'qq', 'whatsapp', 'discord']);
11
12
 
12
13
  export const ACCESS_CHANNEL_DEFINITIONS = Object.freeze({
13
14
  weixin: Object.freeze({
@@ -17,9 +18,9 @@ export const ACCESS_CHANNEL_DEFINITIONS = Object.freeze({
17
18
  }),
18
19
  feishu: Object.freeze({
19
20
  directUserLabel: '飞书 Open ID',
20
- directPlaceholder: 'ou_xxx',
21
+ directPlaceholder: '填写使用者的 Open ID(ou_xxx),不是机器人 ID',
21
22
  groupUserLabel: '群成员 Open ID',
22
- groupPlaceholder: 'ou_xxx',
23
+ groupPlaceholder: '填写使用者的 Open ID(ou_xxx),不是机器人 ID',
23
24
  }),
24
25
  dingtalk: Object.freeze({
25
26
  directUserLabel: '钉钉用户 ID',
@@ -125,6 +126,8 @@ function ScenePolicyEditor({
125
126
  placeholder,
126
127
  disabled = false,
127
128
  unsupported = false,
129
+ ownerHelp,
130
+ identityHelp,
128
131
  onChange,
129
132
  }) {
130
133
  const ownerHelpId = React.useId();
@@ -165,7 +168,7 @@ function ScenePolicyEditor({
165
168
  id: ownerHelpId,
166
169
  className: 'dim-channelTooltip dim-accessHelpTooltip',
167
170
  role: 'tooltip',
168
- }, '原所有者或扫码接入者始终可以访问并执行命令;以下设置仅约束其他用户。')))),
171
+ }, ownerHelp, identityHelp ? h('br') : null, identityHelp)))),
169
172
  unsupported
170
173
  ? h('div', { className: 'dim-accessUnsupported', role: 'note' },
171
174
  h('strong', null, '当前渠道不支持群聊'),
@@ -271,6 +274,12 @@ function ScenePolicyEditor({
271
274
 
272
275
  export function AccessPolicySettingsPage({ channel, account, rpcCall, onSaved }) {
273
276
  const definition = ACCESS_CHANNEL_DEFINITIONS[channel];
277
+ const ownerHelp = CONFIRMED_OWNER_CHANNELS.has(channel)
278
+ ? '已由接入流程确认的所有者可以访问并执行命令,不受本页限制。未识别到所有者身份时,按本页访问设置执行。'
279
+ : '原所有者或扫码接入者始终可以访问并执行命令;以下设置仅约束其他用户。';
280
+ const identityHelp = channel === 'feishu'
281
+ ? '白名单请填写使用者的 Open ID,不是机器人的 Open ID。仅保存通配符的接入配置不包含所有者身份。'
282
+ : null;
274
283
  const initialPolicy = normalizeAccessPolicy(account?.accessPolicy);
275
284
  const initialKey = JSON.stringify(initialPolicy);
276
285
  const [draft, setDraft] = React.useState(() => clonePolicy(
@@ -333,6 +342,8 @@ export function AccessPolicySettingsPage({ channel, account, rpcCall, onSaved })
333
342
  policy: draft.direct,
334
343
  userLabel: definition.directUserLabel,
335
344
  placeholder: definition.directPlaceholder,
345
+ ownerHelp,
346
+ identityHelp,
336
347
  disabled: saving,
337
348
  onChange: (direct) => { setDraft((current) => ({ ...current, direct })); setFeedback(null); },
338
349
  }),
@@ -342,6 +353,10 @@ export function AccessPolicySettingsPage({ channel, account, rpcCall, onSaved })
342
353
  policy: draft.group,
343
354
  userLabel: definition.groupUserLabel ?? definition.directUserLabel,
344
355
  placeholder: definition.groupPlaceholder ?? definition.directPlaceholder,
356
+ ownerHelp,
357
+ identityHelp: channel === 'qq'
358
+ ? '扫码获得的是私聊身份,群聊只有匹配已确认的所有者身份时才豁免;其他成员按群聊白名单设置执行。'
359
+ : identityHelp,
345
360
  disabled: saving || definition.groupSupported === false,
346
361
  unsupported: definition.groupSupported === false,
347
362
  onChange: (group) => { setDraft((current) => ({ ...current, group })); setFeedback(null); },
@@ -0,0 +1,114 @@
1
+ import * as React from 'react';
2
+
3
+ import { h } from './i18n.js';
4
+
5
+ // Scroll only the tab strip: scrollIntoView can also move the host settings page.
6
+ function revealTab(strip, tab) {
7
+ if (!strip || !tab) return;
8
+ const viewport = strip.getBoundingClientRect();
9
+ const bounds = tab.getBoundingClientRect();
10
+ if (bounds.left < viewport.left) strip.scrollLeft += bounds.left - viewport.left;
11
+ else if (bounds.right > viewport.right) strip.scrollLeft += bounds.right - viewport.right;
12
+ }
13
+
14
+ export function BotSettingsTabs({ tabs, activeTabId, onChange }) {
15
+ const barRef = React.useRef(null);
16
+ const stripRef = React.useRef(null);
17
+ const listRef = React.useRef(null);
18
+ const [scroll, setScroll] = React.useState({ overflow: false, left: false, right: false });
19
+
20
+ const measure = React.useCallback(() => {
21
+ const bar = barRef.current;
22
+ const strip = stripRef.current;
23
+ const list = listRef.current;
24
+ if (!bar || !strip || !list) return;
25
+ const next = {
26
+ // Compare against the whole bar, so arrows disappear as soon as all tabs fit.
27
+ overflow: list.scrollWidth > bar.clientWidth + 1,
28
+ left: strip.scrollLeft > 1,
29
+ right: strip.scrollWidth - strip.clientWidth - strip.scrollLeft > 1,
30
+ };
31
+ setScroll((previous) => Object.keys(next).every((key) => next[key] === previous[key])
32
+ ? previous : next);
33
+ }, []);
34
+
35
+ React.useEffect(() => {
36
+ const strip = stripRef.current;
37
+ if (!strip) return undefined;
38
+ const refresh = () => {
39
+ revealTab(strip, strip.querySelector('[aria-selected="true"]'));
40
+ measure();
41
+ };
42
+ refresh();
43
+ const view = strip.ownerDocument.defaultView;
44
+ const observer = view.ResizeObserver ? new view.ResizeObserver(refresh) : null;
45
+ for (const node of [barRef.current, strip, listRef.current]) observer?.observe(node);
46
+ view.addEventListener('resize', refresh);
47
+ return () => {
48
+ observer?.disconnect();
49
+ view.removeEventListener('resize', refresh);
50
+ };
51
+ }, [tabs, activeTabId, scroll.overflow, measure]);
52
+
53
+ const scrollTabs = (direction) => {
54
+ const strip = stripRef.current;
55
+ if (!strip) return;
56
+ strip.scrollLeft += direction * Math.max(80, strip.clientWidth * 0.75);
57
+ measure();
58
+ };
59
+
60
+ const handleKeyDown = (event, index) => {
61
+ let next;
62
+ if (event.key === 'Home') next = 0;
63
+ else if (event.key === 'End') next = tabs.length - 1;
64
+ else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
65
+ next = (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
66
+ }
67
+ if (next === undefined) return;
68
+ event.preventDefault();
69
+ onChange(tabs[next].id);
70
+ const target = stripRef.current?.querySelectorAll('[role="tab"]')[next];
71
+ target?.focus({ preventScroll: true });
72
+ revealTab(stripRef.current, target);
73
+ };
74
+
75
+ const arrow = (direction, label, enabled) => scroll.overflow ? h('button', {
76
+ type: 'button',
77
+ className: 'dim-botSettingsScroll',
78
+ 'aria-label': label,
79
+ title: label,
80
+ disabled: !enabled,
81
+ onClick: () => scrollTabs(direction),
82
+ }, h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', 'aria-hidden': 'true' },
83
+ h('path', {
84
+ d: direction < 0 ? 'M10 3 5 8l5 5' : 'm6 3 5 5-5 5',
85
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.5,
86
+ strokeLinecap: 'round', strokeLinejoin: 'round',
87
+ }))) : null;
88
+
89
+ return h('div', { ref: barRef, className: 'dim-botSettingsTabsBar' },
90
+ arrow(-1, '向左滚动设置页签', scroll.left),
91
+ h('nav', {
92
+ ref: stripRef,
93
+ className: 'dim-botSettingsTabs',
94
+ role: 'tablist',
95
+ 'aria-label': '机器人设置页签',
96
+ onScroll: measure,
97
+ }, h('div', { ref: listRef, className: 'dim-botSettingsTabsList', role: 'presentation' },
98
+ tabs.map((tab, index) => h('button', {
99
+ key: tab.id,
100
+ id: `dim-bot-settings-${tab.id}-tab`,
101
+ type: 'button',
102
+ role: 'tab',
103
+ className: 'dim-botSettingsTab',
104
+ 'aria-selected': tab.id === activeTabId,
105
+ 'aria-controls': `dim-bot-settings-${tab.id}-panel`,
106
+ tabIndex: tab.id === activeTabId ? 0 : -1,
107
+ onClick: (event) => {
108
+ onChange(tab.id);
109
+ revealTab(stripRef.current, event?.currentTarget);
110
+ },
111
+ onKeyDown: (event) => handleKeyDown(event, index),
112
+ }, tab.label)))),
113
+ arrow(1, '向右滚动设置页签', scroll.right));
114
+ }
@@ -1,6 +1,7 @@
1
1
  import * as React from 'react';
2
2
 
3
3
  import { AccessPolicySettingsPage } from './access-policy-settings.js';
4
+ import { BotSettingsTabs } from './bot-settings-tabs.js';
4
5
  import { FeishuGroupSettingsPage } from './channels/feishu/group-settings.js';
5
6
  import { FeishuVoiceSettingsPage } from './channels/feishu/voice-settings.js';
6
7
  import { FeishuSlashPanelSettingsPage } from './channels/feishu/slash-panel-settings.js';
@@ -770,22 +771,7 @@ export function DeliveryTargetSettingsPage({
770
771
  },
771
772
  h('header', { className: 'dim-deliveryHeader' },
772
773
  h(DeliveryButton, { className: 'dim-deliveryBack', onClick: onBack }, '← 返回机器人列表')),
773
- h('div', { className: 'dim-botSettingsTabsBar' },
774
- h('nav', {
775
- className: 'dim-botSettingsTabs',
776
- role: 'tablist',
777
- 'aria-label': '机器人设置页签',
778
- }, settingsTabs.map((tab) => h('button', {
779
- key: tab.id,
780
- id: `dim-bot-settings-${tab.id}-tab`,
781
- type: 'button',
782
- role: 'tab',
783
- className: 'dim-botSettingsTab',
784
- 'aria-selected': tab.id === activeTab.id,
785
- 'aria-controls': `dim-bot-settings-${tab.id}-panel`,
786
- tabIndex: tab.id === activeTab.id ? 0 : -1,
787
- onClick: () => setActiveTabId(tab.id),
788
- }, tab.label)))),
774
+ h(BotSettingsTabs, { tabs: settingsTabs, activeTabId: activeTab.id, onChange: setActiveTabId }),
789
775
  h('div', {
790
776
  id: activePanelId,
791
777
  className: 'dim-botSettingsTabPanel',
@@ -48,6 +48,8 @@ const EN = Object.freeze({
48
48
  'IM 面板加载失败': 'Could not load the IM panel',
49
49
  '更多机器人设置': 'More bot settings',
50
50
  '更多设置': 'More settings',
51
+ '向左滚动设置页签': 'Scroll settings tabs left',
52
+ '向右滚动设置页签': 'Scroll settings tabs right',
51
53
  '常用配置': 'Common settings',
52
54
  '机器人设置': 'Bot settings',
53
55
  '机器人设置页签': 'Bot settings tabs',
@@ -68,6 +70,11 @@ const EN = Object.freeze({
68
70
  '尚未添加用户': 'No users added',
69
71
  '当前渠道不支持群聊': 'Group chat is not supported by this channel',
70
72
  '原所有者或扫码接入者始终可以访问并执行命令;以下设置仅约束其他用户。': 'The original owner or QR-code operator can always access the bot and run commands; the settings below apply only to other users.',
73
+ '已由接入流程确认的所有者可以访问并执行命令,不受本页限制。未识别到所有者身份时,按本页访问设置执行。': 'Owners confirmed during connection setup can access the bot and run commands regardless of these settings. When no owner has been identified, these access settings apply.',
74
+ '白名单请填写使用者的 Open ID,不是机器人的 Open ID。仅保存通配符的接入配置不包含所有者身份。': 'Enter the user’s Open ID in the allowlist, not the bot’s Open ID. A connection configured with only a wildcard has no identified owner.',
75
+ '扫码获得的是私聊身份,群聊只有匹配已确认的所有者身份时才豁免;其他成员按群聊白名单设置执行。': 'QR setup identifies the owner for direct messages. In groups, only a matching confirmed owner identity is exempt; other members follow the group allowlist settings.',
76
+ '填写使用者的 Open ID(ou_xxx),不是机器人 ID': 'Enter the user’s Open ID (ou_xxx), not the bot ID',
77
+ '这里需要填写用户的 Open ID,不能填写机器人自己的 Open ID。': 'Enter a user Open ID here, not the bot’s own Open ID.',
71
78
  '此区域无需配置,保存私聊设置时会保留现有群聊策略。': 'No setup is needed here. Saving direct-message settings keeps the existing group policy.',
72
79
  '访问设置已保存。': 'Access settings saved.',
73
80
  '访问设置暂不可用。': 'Access settings are currently unavailable.',
@@ -467,9 +467,14 @@ const CSS = String.raw`
467
467
  .dim-moreSettingsChevron { width: 6px; height: 6px; margin-right: 2px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; transform: rotate(-45deg); }
468
468
  .dim-deliveryPage { min-width: 0; display: grid; }
469
469
  .dim-deliveryHeader { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
470
- .dim-botSettingsTabsBar { min-width: 0; margin-top: 10px; border-bottom: 1px solid var(--dsw-alias-border-l2, #dfe1e5); }
471
- .dim-botSettingsTabs { min-width: 0; display: flex; align-items: flex-end; gap: 24px; overflow-x: auto; scrollbar-width: none; }
470
+ .dim-botSettingsTabsBar { min-width: 0; display: flex; align-items: center; gap: 4px; margin-top: 10px; border-bottom: 1px solid var(--dsw-alias-border-l2, #dfe1e5); }
471
+ .dim-botSettingsTabs { min-width: 0; flex: 1; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; }
472
472
  .dim-botSettingsTabs::-webkit-scrollbar { display: none; }
473
+ .dim-botSettingsTabsList { width: max-content; min-width: 100%; display: flex; align-items: flex-end; gap: 24px; }
474
+ .dim-botSettingsScroll { flex: 0 0 30px; width: 30px; height: 32px; display: inline-flex; align-items: center; justify-content: center; padding: 0; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, #646a73); cursor: pointer; }
475
+ .dim-botSettingsScroll:hover:not(:disabled) { color: var(--dsw-alias-state-business-primary, #3370ff); background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
476
+ .dim-botSettingsScroll:disabled { opacity: .3; cursor: default; }
477
+ .dim-botSettingsScroll:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: -2px; }
473
478
  .dim-botSettingsTab { position: relative; min-height: 38px; flex: none; display: inline-flex; align-items: center; justify-content: center; padding: 6px 2px 9px; border: 0; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 13px; line-height: 20px; font-weight: 560; white-space: nowrap; cursor: pointer; transition: color .15s ease; }
474
479
  .dim-botSettingsTab::after { content: ''; position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; border-radius: 2px 2px 0 0; background: transparent; transform: scaleX(.45); transition: background .15s ease, transform .15s ease; }
475
480
  .dim-botSettingsTab:hover:not([aria-selected="true"]) { color: var(--dsw-alias-label-primary, #1f2329); }
@@ -735,7 +740,7 @@ const CSS = String.raw`
735
740
  .dim-loopbackRecovery { align-items: stretch; flex-direction: column; gap: 12px; }
736
741
  .dim-loopbackRecoveryAction { width: 100%; }
737
742
  .dim-deliverySectionHeading { align-items: stretch; flex-direction: column; }
738
- .dim-botSettingsTabs { gap: 18px; }
743
+ .dim-botSettingsTabsList { gap: 18px; }
739
744
  .dim-deliveryBotId { grid-template-columns: minmax(0, 1fr) max-content; }
740
745
  .dim-deliveryBotId > span { grid-column: 1 / -1; }
741
746
  .dim-targetActions .dim-deliveryButton { flex: 1 1 auto; }
@@ -1,4 +1,5 @@
1
1
  import { getImageInputSettingsStore } from '../../../../src/channels/shared/image-input-settings-store.mjs';
2
+ import { t } from '../../../../src/channels/shared/i18n.mjs';
2
3
  import { homedir } from 'node:os';
3
4
  import { join, resolve } from 'node:path';
4
5
  import { unlink } from 'node:fs/promises';
@@ -34,6 +35,7 @@ import { createDeliveryAdapter } from '../../delivery-adapter.mjs';
34
35
  import {
35
36
  accessPolicyProvider,
36
37
  initialAccessPolicyFor,
38
+ newBotAccessPolicyFor,
37
39
  } from '../shared/access-policy-production.mjs';
38
40
 
39
41
  // The WebSocket agent built here is only used for the Feishu long connection,
@@ -229,7 +231,7 @@ export async function createProductionController(ctx, config = {}, internals = {
229
231
  const id = botId ?? botConfig.id ?? botConfig.appId;
230
232
  await workspaces.ensure(id, {
231
233
  defaultAgentPreset: config.agentPreset,
232
- initialAccessPolicy: initialAccessPolicyFor('feishu', botConfig),
234
+ initialAccessPolicy: newBotAccessPolicyFor('feishu', botConfig),
233
235
  });
234
236
  const workspaceScope = createBotWorkspaceScope(harness, {
235
237
  botId: id, workspaces, state, agentPresetCatalog,
@@ -291,6 +293,16 @@ export async function createProductionController(ctx, config = {}, internals = {
291
293
  stateFor: stateForBotId,
292
294
  agentPresetCatalog,
293
295
  modelCatalog,
296
+ validateAccessPolicyForBot(botId, policy) {
297
+ const botOpenId = configStore.getBot(botId)?.botOpenId;
298
+ if (!botOpenId) return;
299
+ if ([policy.direct, policy.group].some((scope) => scope.mode === 'allowlist'
300
+ && scope.allowlist.users.some((user) => user.id === botOpenId))) {
301
+ const error = new TypeError(t('这里需要填写用户的 Open ID,不能填写机器人自己的 Open ID。'));
302
+ error.code = 'access-policy-invalid';
303
+ throw error;
304
+ }
305
+ },
294
306
  });
295
307
 
296
308
  const supervisor = createSupervisor({
@@ -30,6 +30,7 @@ import {
30
30
  import {
31
31
  accessPolicyProvider,
32
32
  initialAccessPolicyFor,
33
+ newBotAccessPolicyFor,
33
34
  } from '../shared/access-policy-production.mjs';
34
35
 
35
36
  function pluginPaths(config) {
@@ -117,7 +118,7 @@ export async function createProductionController(ctx, config = {}, internals = {
117
118
  const state = await stateFor(botId);
118
119
  await workspaces.ensure(botId, {
119
120
  defaultAgentPreset: config.agentPreset,
120
- initialAccessPolicy: initialAccessPolicyFor('qq', botConfig),
121
+ initialAccessPolicy: newBotAccessPolicyFor('qq', botConfig),
121
122
  });
122
123
  const workspaceScope = createBotWorkspaceScope(harness, {
123
124
  botId, workspaces, state, agentPresetCatalog,
@@ -97,6 +97,19 @@ export function initialAccessPolicyFor(channel, config = {}) {
97
97
  throw new TypeError(`Unsupported access-policy channel: ${channel}`);
98
98
  }
99
99
 
100
+ /**
101
+ * Seed newly connected bots after startup has migrated existing bots with
102
+ * initialAccessPolicyFor(). Open Feishu/QQ messages and commands in both chat
103
+ * types; workspace ensure() preserves any policy already saved.
104
+ */
105
+ export function newBotAccessPolicyFor(channel, config = {}) {
106
+ const key = String(channel ?? '').trim().toLowerCase();
107
+ if (key === 'feishu' || key === 'qq') {
108
+ return createAccessPolicy({ direct: openScope(), group: openScope() });
109
+ }
110
+ return initialAccessPolicyFor(channel, config);
111
+ }
112
+
100
113
  export function privilegedSenderIdsFor(channel, config = {}) {
101
114
  const key = String(channel ?? '').trim().toLowerCase();
102
115
  if (key === 'weixin') return cleanIds(config.ownerUserId);
@@ -109,6 +122,7 @@ export function privilegedSenderIdsFor(channel, config = {}) {
109
122
  }
110
123
  if (key === 'qq') return cleanIds(config.ownerUserOpenid).filter((id) => id !== '*');
111
124
  if (key === 'whatsapp') return cleanIds(config.accountJid);
125
+ if (key === 'discord') return cleanIds(config.ownerUserId);
112
126
  return [];
113
127
  }
114
128
 
@@ -26,6 +26,7 @@ export function publicWorkspaceError(error) {
26
26
  'model-selection-unavailable',
27
27
  'model-reasoning-unavailable',
28
28
  'context-enhancement-invalid',
29
+ 'access-policy-invalid',
29
30
  ].includes(error?.code)) return null;
30
31
  return { code: error.code, message: error.message };
31
32
  }
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
 
3
3
  import { hasActiveHarnessInteractionOwner } from '../../src/channels/shared/harness-client.mjs';
4
+ import { extractConnectionEvidence } from '../../src/channels/shared/connection-error.mjs';
4
5
 
5
6
  const modernApis = new WeakMap();
6
7
 
@@ -25,7 +26,7 @@ function failureOf(error) {
25
26
  return {
26
27
  code: 'internal',
27
28
  message: error instanceof Error ? error.message : String(error),
28
- details: {},
29
+ details: extractConnectionEvidence(error).details,
29
30
  };
30
31
  }
31
32
 
@@ -20,6 +20,26 @@ export function maskDiscordBotId(platformId) {
20
20
 
21
21
  export class DiscordConfigStore extends TokenBotConfigStore {
22
22
  constructor(path) {
23
- super(path, { channel: 'Discord', ...IDENTITY_OPTIONS });
23
+ super(path, {
24
+ channel: 'Discord',
25
+ ...IDENTITY_OPTIONS,
26
+ normalizeBotExtension(value) {
27
+ if (value.ownerUserId == null) return { ownerUserId: null };
28
+ const ownerUserId = typeof value.ownerUserId === 'string' ? value.ownerUserId.trim() : '';
29
+ if (!/^\d{5,30}$/.test(ownerUserId) || ownerUserId === value.platformId?.trim()) return null;
30
+ return { ownerUserId };
31
+ },
32
+ });
33
+ }
34
+
35
+ save(value) {
36
+ // Shared credential binding rebuilds the base config. Preserve only this
37
+ // bot's confirmed identity when omitted; explicit null still clears it.
38
+ return super.save({
39
+ ...value,
40
+ ownerUserId: value?.ownerUserId === undefined
41
+ ? this.get(value?.botId)?.ownerUserId ?? null
42
+ : value.ownerUserId,
43
+ });
24
44
  }
25
45
  }
@@ -123,6 +123,10 @@ export class DiscordApi {
123
123
  return this.#request('users/@me', { ...options, method: 'GET' });
124
124
  }
125
125
 
126
+ getCurrentApplication(options = {}) {
127
+ return this.#request('applications/@me', { ...options, method: 'GET' });
128
+ }
129
+
126
130
  getGatewayBot(options = {}) {
127
131
  return this.#request('gateway/bot', { ...options, method: 'GET' });
128
132
  }
@@ -345,3 +349,31 @@ export async function inspectDiscordToken(token, options = {}) {
345
349
  username: cleanString(bot.username),
346
350
  };
347
351
  }
352
+
353
+ /** Owner lookup is optional metadata, bounded separately from Gateway startup. */
354
+ export async function inspectDiscordOwner(token, {
355
+ platformId,
356
+ timeoutMs = 5_000,
357
+ signal,
358
+ ...options
359
+ } = {}) {
360
+ const api = new DiscordApi({ token, ...options });
361
+ const lookupSignal = requestSignal(signal, positiveTimeout(timeoutMs, 'owner lookup timeout'));
362
+ const request = { signal: lookupSignal, retry: false };
363
+ const application = await api.getCurrentApplication(request);
364
+ const bot = application?.bot ?? await api.getCurrentUser(request);
365
+ if ((!application?.bot && bot?.bot !== true) || bot?.bot === false
366
+ || snowflake(bot?.id, 'bot id') !== platformId) {
367
+ throw new Error('Discord application does not belong to the configured bot');
368
+ }
369
+ // A team application belongs to its team owner, not every developer or the
370
+ // synthetic application owner that Discord can return for team applications.
371
+ const ownerUserId = snowflake(application?.team != null
372
+ ? application.team.owner_user_id
373
+ : application?.owner?.id, 'owner id');
374
+ if (ownerUserId === platformId || (application?.owner?.bot === true && application?.team == null)) {
375
+ throw new Error('Discord application owner is not a human identity');
376
+ }
377
+ lookupSignal.throwIfAborted();
378
+ return ownerUserId;
379
+ }
@@ -1,16 +1,37 @@
1
1
  import { TokenBotController } from '../shared/token-bot-controller.mjs';
2
2
  import { deriveDiscordBotIdentity, maskDiscordBotId } from './config-store.mjs';
3
- import { inspectDiscordToken } from './discord-api.mjs';
3
+ import { inspectDiscordOwner, inspectDiscordToken } from './discord-api.mjs';
4
4
  import { DISCORD_DESCRIPTOR } from './discord-bridge.mjs';
5
5
 
6
6
  export class DiscordController extends TokenBotController {
7
7
  constructor(options) {
8
+ if (typeof options.createRuntime !== 'function') throw new TypeError('Discord runtime factory must be a function');
9
+ const inspectOwner = options.inspectOwner ?? inspectDiscordOwner;
10
+ if (typeof inspectOwner !== 'function') throw new TypeError('Discord owner inspector must be a function');
8
11
  super({
9
12
  ...options,
10
13
  descriptor: DISCORD_DESCRIPTOR,
11
14
  inspectToken: options.inspectToken ?? inspectDiscordToken,
12
15
  deriveIdentity: deriveDiscordBotIdentity,
13
16
  maskPlatformId: maskDiscordBotId,
17
+ createRuntime: async (request) => {
18
+ // Run inside the parent's existing per-bot transition. The production
19
+ // callback must receive the committed owner before building its policy.
20
+ let config = options.configStore.get(request.botId) ?? request.config;
21
+ try {
22
+ const ownerUserId = await inspectOwner(request.token, { platformId: config.platformId });
23
+ if (ownerUserId != null && ownerUserId !== config.ownerUserId) {
24
+ config = await options.configStore.save({ ...config, ownerUserId });
25
+ }
26
+ } catch {
27
+ // Neither provider errors nor optional metadata writes should break
28
+ // an otherwise usable bot. Do not log provider responses or user IDs.
29
+ (options.logger ?? console).warn?.('[dsh-im:discord] owner lookup unavailable; using saved identity', {
30
+ botId: request.botId,
31
+ });
32
+ }
33
+ return options.createRuntime({ ...request, config });
34
+ },
14
35
  });
15
36
  }
16
37
  }