@xmanrui/dsh-im 4.33.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/README.en.md +3 -1
- package/README.md +3 -1
- package/lib/client.js +20 -4
- package/lib/index.js +294 -291
- 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/plugin-src/host/modern-harness-api.mjs +22 -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 +23 -1
- package/src/channels/feishu/feishu-cards.mjs +5 -0
- package/src/channels/feishu/slash-command-panel.mjs +2 -0
- package/src/channels/qq/qq-bridge.mjs +6 -1
- package/src/channels/shared/bot-workspace-store.mjs +9 -0
- package/src/channels/shared/command-list-snapshots.mjs +33 -0
- package/src/channels/shared/harness-client.mjs +6 -0
- package/src/channels/shared/i18n-en/feishu.mjs +2 -0
- package/src/channels/shared/i18n-en/permission.mjs +30 -0
- package/src/channels/shared/i18n-en.mjs +2 -0
- package/src/channels/shared/permission-command.mjs +149 -0
- package/src/channels/shared/preset-command.mjs +6 -51
- package/src/channels/telegram/telegram-runtime.mjs +12 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.
|
|
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
|
}
|
|
@@ -240,6 +240,9 @@ class ModernHarnessApi {
|
|
|
240
240
|
models: (request, signal) => rpcResult(request, () => this.#sessionModels(
|
|
241
241
|
request.payload.sessionId, signal,
|
|
242
242
|
)),
|
|
243
|
+
permissions: (request, signal) => rpcResult(request, () => this.#sessionPermissions(
|
|
244
|
+
request.payload.sessionId, signal,
|
|
245
|
+
)),
|
|
243
246
|
selectModel: (request, signal) => rpcResult(request, () => this.#invoke(
|
|
244
247
|
'session', 'selectModel', { request: request.payload }, signal,
|
|
245
248
|
)),
|
|
@@ -414,6 +417,25 @@ class ModernHarnessApi {
|
|
|
414
417
|
return this.#invoke('session', 'modelCatalog', {}, signal);
|
|
415
418
|
}
|
|
416
419
|
|
|
420
|
+
async #sessionPermissions(sessionId, signal) {
|
|
421
|
+
const [catalog, projection] = await Promise.all([
|
|
422
|
+
this.#invoke('permissionPresets', 'catalog', {}, signal),
|
|
423
|
+
this.#invoke('session', 'projections', { request: { sessionId } }, signal),
|
|
424
|
+
]);
|
|
425
|
+
if (projection === null) {
|
|
426
|
+
const error = new Error('Session not found');
|
|
427
|
+
error.failure = { code: 'session-not-found', message: error.message, details: {} };
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
const currentValue = projection?.values?.permissions?.currentValue;
|
|
431
|
+
if (typeof currentValue !== 'string' || !Array.isArray(catalog?.options)) {
|
|
432
|
+
const error = new Error('Host permission presets are unavailable');
|
|
433
|
+
error.failure = { code: 'permissions-unavailable', message: error.message, details: {} };
|
|
434
|
+
throw error;
|
|
435
|
+
}
|
|
436
|
+
return { currentValue, options: catalog.options };
|
|
437
|
+
}
|
|
438
|
+
|
|
417
439
|
async #sessionModels(sessionId, signal) {
|
|
418
440
|
const [catalog, list] = await Promise.all([
|
|
419
441
|
this.#modelCatalog(signal),
|
|
@@ -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
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import QRCode from 'qrcode';
|
|
3
4
|
import {
|
|
@@ -298,6 +299,7 @@ function printableText(value) {
|
|
|
298
299
|
}
|
|
299
300
|
|
|
300
301
|
function isFeishuLocalCommand(text, { hasImages = false, hasFiles = false } = {}) {
|
|
302
|
+
if (isPermissionCommand(text)) return true;
|
|
301
303
|
if (hasImages || hasFiles || typeof text !== 'string') return false;
|
|
302
304
|
const command = text.trim();
|
|
303
305
|
return MENU_COMMAND.test(command)
|
|
@@ -1234,6 +1236,7 @@ export class FeishuHarnessBridge {
|
|
|
1234
1236
|
return processing;
|
|
1235
1237
|
}
|
|
1236
1238
|
const commandRunner = isHistoryCommand(commandText) ? runHistoryCommand
|
|
1239
|
+
: isPermissionCommand(commandText) ? runPermissionCommand
|
|
1237
1240
|
: hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
|
|
1238
1241
|
? runControlCommand
|
|
1239
1242
|
: (isModelCommand(commandText)
|
|
@@ -1408,11 +1411,20 @@ export class FeishuHarnessBridge {
|
|
|
1408
1411
|
return current;
|
|
1409
1412
|
}
|
|
1410
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
|
+
|
|
1411
1422
|
#finishAccessDecision(event, messageId, access) {
|
|
1412
1423
|
let current;
|
|
1413
1424
|
current = Promise.resolve().then(async () => {
|
|
1414
1425
|
if (this.#state.hasSeen(messageId)) return;
|
|
1415
1426
|
await this.#state.markSeen(messageId);
|
|
1427
|
+
this.#logAccessDenied(event.message.chat_type === 'p2p' ? 'direct' : 'group', access.reason);
|
|
1416
1428
|
if (access.reason === 'command-not-allowed') {
|
|
1417
1429
|
this.#status.lastMessageAt = new Date().toISOString();
|
|
1418
1430
|
this.#status.messagesReceived += 1;
|
|
@@ -1656,6 +1668,7 @@ export class FeishuHarnessBridge {
|
|
|
1656
1668
|
|| (!hasImages && !hasFiles && NUMBER_REPLY.test(text) && this.#menus.has(key)),
|
|
1657
1669
|
});
|
|
1658
1670
|
if (!transcribedAccess.allowed) {
|
|
1671
|
+
this.#logAccessDenied(event.message.chat_type === 'p2p' ? 'direct' : 'group', transcribedAccess.reason);
|
|
1659
1672
|
if (transcribedAccess.reason === 'command-not-allowed') {
|
|
1660
1673
|
await this.#send(
|
|
1661
1674
|
event.message.chat_id,
|
|
@@ -1690,6 +1703,15 @@ export class FeishuHarnessBridge {
|
|
|
1690
1703
|
await this.#handleRepairCommand(event, commandText);
|
|
1691
1704
|
return;
|
|
1692
1705
|
}
|
|
1706
|
+
if (isPermissionCommand(text)) {
|
|
1707
|
+
const result = await runPermissionCommand(text, this.#harness, this.#state, key, {
|
|
1708
|
+
signal: this.#signal, hasImages, hasFiles,
|
|
1709
|
+
});
|
|
1710
|
+
for (const reply of result.messages) {
|
|
1711
|
+
await this.#send(event.message.chat_id, reply, { replyTo: event.message.message_id });
|
|
1712
|
+
}
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1693
1715
|
if (commandText === '/help') {
|
|
1694
1716
|
await this.#send(event.message.chat_id, menuHelpText(), { replyTo: event.message.message_id });
|
|
1695
1717
|
return;
|
|
@@ -2298,11 +2320,11 @@ export class FeishuHarnessBridge {
|
|
|
2298
2320
|
isCommand: !isInteractionResponse,
|
|
2299
2321
|
});
|
|
2300
2322
|
if (!access.allowed) {
|
|
2323
|
+
this.#logAccessDenied(conversationType, access.reason);
|
|
2301
2324
|
if (access.reason === 'command-not-allowed') {
|
|
2302
2325
|
return this.#send(route.chatId, t(COMMAND_PERMISSION_DENIED_MESSAGE))
|
|
2303
2326
|
.catch(() => undefined);
|
|
2304
2327
|
}
|
|
2305
|
-
this.#logger.warn?.('[dsh-feishu] ignoring card action blocked by access policy');
|
|
2306
2328
|
return Promise.resolve();
|
|
2307
2329
|
}
|
|
2308
2330
|
// A used card is recent even if it was first created long ago.
|
|
@@ -535,6 +535,9 @@ export function menuHelpText() {
|
|
|
535
535
|
'/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset',
|
|
536
536
|
'纯数字 ID:/preset id:<ID>',
|
|
537
537
|
'/preset --default 跟随 Host 默认',
|
|
538
|
+
'/permissionlist 按序号列出权限档位',
|
|
539
|
+
'/permission [序号或完整ID] 查看或切换当前会话权限',
|
|
540
|
+
'纯数字 ID:/permission id:<ID>',
|
|
538
541
|
'/models 列出模型',
|
|
539
542
|
'/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
|
|
540
543
|
'/reasoning [序号、等级ID或 --default] 查看或切换当前推理等级',
|
|
@@ -619,6 +622,8 @@ export function helpCard(extraTextLines = []) {
|
|
|
619
622
|
{ tag: 'hr' },
|
|
620
623
|
{ tag: 'div', text: markdown([
|
|
621
624
|
t(HELP_TEXT_COMMANDS),
|
|
625
|
+
t('`/permissionlist` — 按序号列出权限档位'),
|
|
626
|
+
t('`/permission [序号/ID]` — 查看或切换当前会话权限'),
|
|
622
627
|
t('/ws、/wsl、/workspaces 工作区命令别名'),
|
|
623
628
|
t('`/version` — 查看插件版本'),
|
|
624
629
|
t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
|
|
@@ -49,6 +49,8 @@ export const SLASH_COMMAND_MANIFEST = Object.freeze([
|
|
|
49
49
|
{ command: 'reasoning', icon: 'ai-deepthink_outlined', default: '查看或切换推理等级', en_us: 'Show or switch the reasoning effort' },
|
|
50
50
|
{ command: 'presetlist', icon: 'skill_outlined', default: '列出可用 Agent 预设', en_us: 'List available Agent Presets' },
|
|
51
51
|
{ command: 'preset', icon: 'skill_outlined', default: '查看或切换 Agent 预设', en_us: 'Show or switch the Agent Preset' },
|
|
52
|
+
{ command: 'permissionlist', icon: 'skill_outlined', default: '按序号列出权限档位', en_us: 'List numbered permission presets' },
|
|
53
|
+
{ command: 'permission', icon: 'skill_outlined', default: '查看或切换当前会话权限', en_us: 'Show or switch current session permissions' },
|
|
52
54
|
{ command: 'stop', icon: 'clear_outlined', default: '停止当前任务', en_us: 'Stop the current task' },
|
|
53
55
|
{ command: 'steer', icon: 'promptword_outlined', default: '给当前任务补充指令', en_us: 'Send additional instructions to the current task' },
|
|
54
56
|
{ command: 'batch', icon: 'chat-ai_outlined', default: '开始批量输入(仅私聊)', en_us: 'Start batch input (private chats only)' },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isPermissionCommand, runPermissionCommand } from '../shared/permission-command.mjs';
|
|
1
2
|
import { createDeferredDeliveryCoordinator, deferredOutcomeText } from '../shared/deferred-delivery-coordinator.mjs';
|
|
2
3
|
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
|
3
4
|
import { runCompactCommand } from '../shared/compact-command.mjs';
|
|
@@ -115,6 +116,9 @@ function helpText() {
|
|
|
115
116
|
t('/preset [序号或完整ID] 查看或设置当前机器人 Agent Preset'),
|
|
116
117
|
t('纯数字 ID:/preset id:<ID>'),
|
|
117
118
|
t('/preset --default 跟随 Host 默认'),
|
|
119
|
+
t('/permissionlist 按序号列出权限档位'),
|
|
120
|
+
t('/permission [序号或完整ID] 查看或切换当前会话权限'),
|
|
121
|
+
t('纯数字 ID:/permission id:<ID>'),
|
|
118
122
|
t('/stop 停止当前任务'),
|
|
119
123
|
t('/steer 补充指令 纠偏当前任务'),
|
|
120
124
|
t('/batch 开始批量输入(仅私聊,最多 10 条文字)'),
|
|
@@ -523,7 +527,7 @@ export class QqHarnessBridge {
|
|
|
523
527
|
text: commandText,
|
|
524
528
|
hasImages: hasQqImageAttachments(message),
|
|
525
529
|
hasFiles: hasQqFileAttachments(message),
|
|
526
|
-
...(explicitMenu || numericMenu ? { isCommand: true } : {}),
|
|
530
|
+
...(explicitMenu || numericMenu || isPermissionCommand(commandText) ? { isCommand: true } : {}),
|
|
527
531
|
})
|
|
528
532
|
: senderAllowed(message, this.#ownerUserOpenid)
|
|
529
533
|
? { allowed: true, reason: 'legacy-owner' }
|
|
@@ -594,6 +598,7 @@ export class QqHarnessBridge {
|
|
|
594
598
|
const commandRunner = explicitMenu || numericMenu
|
|
595
599
|
? () => this.#runMenuCommand(message, menuInput, key)
|
|
596
600
|
: isHistoryCommand(commandText) ? runHistoryCommand
|
|
601
|
+
: isPermissionCommand(commandText) ? runPermissionCommand
|
|
597
602
|
: hasQqFileAttachments(message) ? null : isControlCommand(commandText)
|
|
598
603
|
? runControlCommand
|
|
599
604
|
: (isModelCommand(commandText)
|
|
@@ -2002,6 +2002,11 @@ export function createBotWorkspaceScope(
|
|
|
2002
2002
|
models(...args) {
|
|
2003
2003
|
return invokeCurrentSession('getSessionModels', args, 'model listing');
|
|
2004
2004
|
},
|
|
2005
|
+
...(typeof target.getSessionPermissions === 'function' ? {
|
|
2006
|
+
permissions(...args) {
|
|
2007
|
+
return invokeCurrentSession('getSessionPermissions', args, 'permission listing');
|
|
2008
|
+
},
|
|
2009
|
+
} : {}),
|
|
2005
2010
|
readHistory(...args) {
|
|
2006
2011
|
return invokeCurrentSession('readSessionHistory', args, 'history read');
|
|
2007
2012
|
},
|
|
@@ -2131,6 +2136,7 @@ export function createWorkspaceAwareController(controller, {
|
|
|
2131
2136
|
stateFor,
|
|
2132
2137
|
agentPresetCatalog,
|
|
2133
2138
|
modelCatalog,
|
|
2139
|
+
validateAccessPolicyForBot,
|
|
2134
2140
|
} = {}) {
|
|
2135
2141
|
if (!controller || !workspaces || typeof stateFor !== 'function') {
|
|
2136
2142
|
throw new TypeError('controller, workspaces, and stateFor are required');
|
|
@@ -2284,6 +2290,9 @@ export function createWorkspaceAwareController(controller, {
|
|
|
2284
2290
|
error.code = 'workspace-bot-not-found';
|
|
2285
2291
|
throw error;
|
|
2286
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);
|
|
2287
2296
|
const [catalog, models] = await Promise.all([
|
|
2288
2297
|
resolveAgentPresetCatalog(agentPresetCatalog),
|
|
2289
2298
|
resolveModelCatalog(modelCatalog),
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const COMMAND_LIST_TTL_MS = 15 * 60_000;
|
|
2
|
+
export const COMMAND_LIST_MAX_ENTRIES = 256;
|
|
3
|
+
|
|
4
|
+
/** Short-lived lists scoped to a bot's state and conversation, never persisted. */
|
|
5
|
+
export function createCommandListSnapshots() {
|
|
6
|
+
const states = new WeakMap();
|
|
7
|
+
return {
|
|
8
|
+
save(state, key, value) {
|
|
9
|
+
if ((!state || typeof state !== 'object') && typeof state !== 'function') return;
|
|
10
|
+
let entries = states.get(state);
|
|
11
|
+
if (!entries) states.set(state, entries = new Map());
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
for (const [entryKey, entry] of entries) {
|
|
14
|
+
if (entry.expiresAt <= now) entries.delete(entryKey);
|
|
15
|
+
}
|
|
16
|
+
entries.delete(key);
|
|
17
|
+
entries.set(key, { value, expiresAt: now + COMMAND_LIST_TTL_MS });
|
|
18
|
+
while (entries.size > COMMAND_LIST_MAX_ENTRIES) entries.delete(entries.keys().next().value);
|
|
19
|
+
},
|
|
20
|
+
load(state, key) {
|
|
21
|
+
const entries = states.get(state);
|
|
22
|
+
const entry = entries?.get(key);
|
|
23
|
+
if (!entry) return null;
|
|
24
|
+
if (entry.expiresAt <= Date.now()) {
|
|
25
|
+
entries.delete(key);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
entries.delete(key);
|
|
29
|
+
entries.set(key, entry);
|
|
30
|
+
return entry.value;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -1139,6 +1139,12 @@ export class HarnessClient {
|
|
|
1139
1139
|
return validateModelCatalog(value, 'session.models', { session: true });
|
|
1140
1140
|
}
|
|
1141
1141
|
|
|
1142
|
+
async getSessionPermissions(sessionId, options = {}) {
|
|
1143
|
+
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
1144
|
+
await this.ensureRunning(options);
|
|
1145
|
+
return this.rpc('session.permissions', { sessionId }, 30_000, options);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1142
1148
|
async selectSessionModel(sessionId, selection, options = {}) {
|
|
1143
1149
|
if (typeof sessionId !== 'string' || !sessionId) throw new TypeError('sessionId is required');
|
|
1144
1150
|
if (!validModelSelection(selection)) {
|
|
@@ -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,结束后会继续处理图片。':
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
'用法:/permission 查看当前权限;/permissionlist 列出档位;/permission <序号或完整ID> 切换;纯数字 ID 使用 /permission id:<ID>。': 'Usage: /permission to show current permissions; /permissionlist to list presets; /permission <number or full ID> to switch; use /permission id:<ID> for numeric IDs.',
|
|
3
|
+
'仅影响当前会话;沙箱与审批策略会同时按 Host 的档位定义更新。': 'Applies only to the current session; sandbox and approval policies change together according to the Host preset.',
|
|
4
|
+
'请先执行 /permissionlist,再按列表序号选择权限档位。': 'Run /permissionlist first, then select a permission preset by its number.',
|
|
5
|
+
'完全文件访问,不再请求审批。': 'Full file access without approval prompts.',
|
|
6
|
+
'可写工作区和允许的临时目录;扩大访问范围需要审批。': 'Write inside the workspace and permitted temporary directories; wider retries require approval.',
|
|
7
|
+
'当前聊天绑定的会话已不存在,请先绑定或建立会话。': 'The bound session no longer exists. Bind or start a session first.',
|
|
8
|
+
'当前 Host 暂不支持从机器人查看或切换权限档位。': 'This Host does not support viewing or switching permission presets from the bot.',
|
|
9
|
+
'未能确认权限切换结果,请执行 /permission 核对当前档位。': 'Could not confirm the permission change. Run /permission to check the current preset.',
|
|
10
|
+
'暂时无法获取权限档位,请稍后重试。': 'Could not read permission presets. Please try again later.',
|
|
11
|
+
'权限命令仅支持纯文字,请移除图片或文件后重试。': 'Permission commands require plain text. Remove images or files and try again.',
|
|
12
|
+
'当前聊天尚未绑定会话,请先发送普通消息建立会话,或使用 /session 绑定已有会话。': 'This chat has no bound session. Send a regular message to start one, or use /session to bind an existing session.',
|
|
13
|
+
'当前会话已变化,请重新执行 /permissionlist。': 'The current session changed. Run /permissionlist again.',
|
|
14
|
+
'当前会话权限:{id}': 'Current session permissions: {id}',
|
|
15
|
+
' ✓ 当前': ' ✓ Current',
|
|
16
|
+
'当前没有可用权限档位。': 'No permission presets are currently available.',
|
|
17
|
+
'切换:/permission <序号或完整ID>': 'Switch: /permission <number or full ID>',
|
|
18
|
+
'查看可用档位与序号:/permissionlist': 'Show available presets and numbers: /permissionlist',
|
|
19
|
+
'权限档位序号无效,请重新执行 /permissionlist。': 'Invalid permission preset number. Run /permissionlist again.',
|
|
20
|
+
'当前 Host 未注册 /permission 命令,请确认权限预设组件已启用。': 'This Host has not registered /permission. Check that permission presets are enabled.',
|
|
21
|
+
'该权限档位不存在或已不可用,请重新执行 /permissionlist。': 'That permission preset no longer exists or is unavailable. Run /permissionlist again.',
|
|
22
|
+
'权限切换失败。': 'Permission change failed.',
|
|
23
|
+
'当前会话权限已切换为:{id}': 'Current session permissions changed to: {id}',
|
|
24
|
+
'恢复工作区权限:/permission workspace-write(需 Host 支持该档位)': 'Restore workspace permissions: /permission workspace-write (if supported by the Host)',
|
|
25
|
+
'/permissionlist 按序号列出权限档位': '/permissionlist List numbered permission presets',
|
|
26
|
+
'/permission [序号或完整ID] 查看或切换当前会话权限': '/permission [number or full ID] Show or switch current session permissions',
|
|
27
|
+
'纯数字 ID:/permission id:<ID>': 'Numeric IDs: /permission id:<ID>',
|
|
28
|
+
'`/permissionlist` — 按序号列出权限档位': '`/permissionlist` — List numbered permission presets',
|
|
29
|
+
'`/permission [序号/ID]` — 查看或切换当前会话权限': '`/permission [number/ID]` — Show or switch current session permissions',
|
|
30
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import imageInput from './i18n-en/image-input.mjs';
|
|
2
|
+
import permission from './i18n-en/permission.mjs';
|
|
2
3
|
import diagnostics from './i18n-en/diagnostics.mjs';
|
|
3
4
|
// English translations for host-side user-facing text.
|
|
4
5
|
// Keys are the exact Chinese literals passed to t() in src/channels/**.
|
|
@@ -26,6 +27,7 @@ export const EN = Object.freeze(Object.assign(
|
|
|
26
27
|
{},
|
|
27
28
|
diagnostics,
|
|
28
29
|
imageInput,
|
|
30
|
+
permission,
|
|
29
31
|
sharedA,
|
|
30
32
|
sharedB,
|
|
31
33
|
sharedC,
|