@xmanrui/dsh-im 4.34.0 → 4.34.1

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.1",
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); },
@@ -68,6 +68,11 @@ const EN = Object.freeze({
68
68
  '尚未添加用户': 'No users added',
69
69
  '当前渠道不支持群聊': 'Group chat is not supported by this channel',
70
70
  '原所有者或扫码接入者始终可以访问并执行命令;以下设置仅约束其他用户。': 'The original owner or QR-code operator can always access the bot and run commands; the settings below apply only to other users.',
71
+ '已由接入流程确认的所有者可以访问并执行命令,不受本页限制。未识别到所有者身份时,按本页访问设置执行。': '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.',
72
+ '白名单请填写使用者的 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.',
73
+ '扫码获得的是私聊身份,群聊只有匹配已确认的所有者身份时才豁免;其他成员按群聊白名单设置执行。': '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.',
74
+ '填写使用者的 Open ID(ou_xxx),不是机器人 ID': 'Enter the user’s Open ID (ou_xxx), not the bot ID',
75
+ '这里需要填写用户的 Open ID,不能填写机器人自己的 Open ID。': 'Enter a user Open ID here, not the bot’s own Open ID.',
71
76
  '此区域无需配置,保存私聊设置时会保留现有群聊策略。': 'No setup is needed here. Saving direct-message settings keeps the existing group policy.',
72
77
  '访问设置已保存。': 'Access settings saved.',
73
78
  '访问设置暂不可用。': 'Access settings are currently unavailable.',
@@ -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';
@@ -291,6 +292,16 @@ export async function createProductionController(ctx, config = {}, internals = {
291
292
  stateFor: stateForBotId,
292
293
  agentPresetCatalog,
293
294
  modelCatalog,
295
+ validateAccessPolicyForBot(botId, policy) {
296
+ const botOpenId = configStore.getBot(botId)?.botOpenId;
297
+ if (!botOpenId) return;
298
+ if ([policy.direct, policy.group].some((scope) => scope.mode === 'allowlist'
299
+ && scope.allowlist.users.some((user) => user.id === botOpenId))) {
300
+ const error = new TypeError(t('这里需要填写用户的 Open ID,不能填写机器人自己的 Open ID。'));
301
+ error.code = 'access-policy-invalid';
302
+ throw error;
303
+ }
304
+ },
294
305
  });
295
306
 
296
307
  const supervisor = createSupervisor({
@@ -109,6 +109,7 @@ export function privilegedSenderIdsFor(channel, config = {}) {
109
109
  }
110
110
  if (key === 'qq') return cleanIds(config.ownerUserOpenid).filter((id) => id !== '*');
111
111
  if (key === 'whatsapp') return cleanIds(config.accountJid);
112
+ if (key === 'discord') return cleanIds(config.ownerUserId);
112
113
  return [];
113
114
  }
114
115
 
@@ -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
  }
@@ -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
  }
@@ -1411,11 +1411,20 @@ export class FeishuHarnessBridge {
1411
1411
  return current;
1412
1412
  }
1413
1413
 
1414
+ #logAccessDenied(conversationType, reason) {
1415
+ this.#logger.info?.('[dsh-feishu] inbound access denied', {
1416
+ botId: this.#accessPolicy?.botId,
1417
+ conversationType,
1418
+ reason,
1419
+ });
1420
+ }
1421
+
1414
1422
  #finishAccessDecision(event, messageId, access) {
1415
1423
  let current;
1416
1424
  current = Promise.resolve().then(async () => {
1417
1425
  if (this.#state.hasSeen(messageId)) return;
1418
1426
  await this.#state.markSeen(messageId);
1427
+ this.#logAccessDenied(event.message.chat_type === 'p2p' ? 'direct' : 'group', access.reason);
1419
1428
  if (access.reason === 'command-not-allowed') {
1420
1429
  this.#status.lastMessageAt = new Date().toISOString();
1421
1430
  this.#status.messagesReceived += 1;
@@ -1659,6 +1668,7 @@ export class FeishuHarnessBridge {
1659
1668
  || (!hasImages && !hasFiles && NUMBER_REPLY.test(text) && this.#menus.has(key)),
1660
1669
  });
1661
1670
  if (!transcribedAccess.allowed) {
1671
+ this.#logAccessDenied(event.message.chat_type === 'p2p' ? 'direct' : 'group', transcribedAccess.reason);
1662
1672
  if (transcribedAccess.reason === 'command-not-allowed') {
1663
1673
  await this.#send(
1664
1674
  event.message.chat_id,
@@ -2310,11 +2320,11 @@ export class FeishuHarnessBridge {
2310
2320
  isCommand: !isInteractionResponse,
2311
2321
  });
2312
2322
  if (!access.allowed) {
2323
+ this.#logAccessDenied(conversationType, access.reason);
2313
2324
  if (access.reason === 'command-not-allowed') {
2314
2325
  return this.#send(route.chatId, t(COMMAND_PERMISSION_DENIED_MESSAGE))
2315
2326
  .catch(() => undefined);
2316
2327
  }
2317
- this.#logger.warn?.('[dsh-feishu] ignoring card action blocked by access policy');
2318
2328
  return Promise.resolve();
2319
2329
  }
2320
2330
  // A used card is recent even if it was first created long ago.
@@ -2136,6 +2136,7 @@ export function createWorkspaceAwareController(controller, {
2136
2136
  stateFor,
2137
2137
  agentPresetCatalog,
2138
2138
  modelCatalog,
2139
+ validateAccessPolicyForBot,
2139
2140
  } = {}) {
2140
2141
  if (!controller || !workspaces || typeof stateFor !== 'function') {
2141
2142
  throw new TypeError('controller, workspaces, and stateFor are required');
@@ -2289,6 +2290,9 @@ export function createWorkspaceAwareController(controller, {
2289
2290
  error.code = 'workspace-bot-not-found';
2290
2291
  throw error;
2291
2292
  }
2293
+ // Channel-specific identity checks must finish before projecting or
2294
+ // committing the new policy. Existing channels need no extra validation.
2295
+ await validateAccessPolicyForBot?.(botId, policy);
2292
2296
  const [catalog, models] = await Promise.all([
2293
2297
  resolveAgentPresetCatalog(agentPresetCatalog),
2294
2298
  resolveModelCatalog(modelCatalog),
@@ -1,5 +1,7 @@
1
1
  // English translations (feishu area). Keys are exact Chinese literals passed to t().
2
2
  export default {
3
+ '这里需要填写用户的 Open ID,不能填写机器人自己的 Open ID。':
4
+ 'Enter a user Open ID here, not the bot’s own Open ID.',
3
5
  '已结束本轮提问,将在当前会话中继续处理图片。':
4
6
  'The questions have been closed. Your image will be processed in this conversation.',
5
7
  '图片已排队,但暂时无法结束当前提问。请先回答问题或发送 /stop,结束后会继续处理图片。':