@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/lib/client.js +18 -4
- package/lib/index.js +287 -287
- package/package.json +1 -1
- package/plugin-src/client/access-policy-settings.js +18 -3
- package/plugin-src/client/i18n.js +5 -0
- package/plugin-src/host/channels/feishu/production.mjs +11 -0
- package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -0
- package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
- package/src/channels/discord/config-store.mjs +21 -1
- package/src/channels/discord/discord-api.mjs +32 -0
- package/src/channels/discord/discord-controller.mjs +22 -1
- package/src/channels/feishu/bridge.mjs +11 -1
- package/src/channels/shared/bot-workspace-store.mjs +4 -0
- package/src/channels/shared/i18n-en/feishu.mjs +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.34.
|
|
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, {
|
|
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,结束后会继续处理图片。':
|