@xmanrui/dsh-im 4.1.1 → 4.2.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.en.md +5 -3
- package/README.md +5 -3
- package/lib/client.js +5 -5
- package/lib/index.js +205 -205
- package/package.json +1 -1
- package/plugin-src/client/channels/feishu/index.js +2 -2
- package/plugin-src/client/i18n.js +2 -2
- package/plugin-src/host/channels/feishu/production.mjs +1 -0
- package/src/channels/feishu/bridge.mjs +1 -1
- package/src/channels/feishu/feishu-runtime.mjs +57 -0
- package/src/channels/feishu/plugin-controller.mjs +2 -0
- package/src/channels/feishu/repair-manager.mjs +12 -4
- package/src/channels/feishu/slash-command-registry.mjs +259 -0
- package/src/channels/shared/i18n-en/feishu.mjs +2 -2
package/package.json
CHANGED
|
@@ -305,7 +305,7 @@ function QrPane({ provision, now, onRefresh, onCancel, busy }) {
|
|
|
305
305
|
? "使用飞书确认群消息权限"
|
|
306
306
|
: "使用飞书扫码创建机器人"),
|
|
307
307
|
h("p", null, repairing
|
|
308
|
-
? "
|
|
308
|
+
? "扫码会更新现有飞书应用,增量补充当前缺少的卡片按钮回调、读取用户消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”)、上传机器人图片或文件所需的 im:resource,以及原生命令面板所需的 application:app_slash_command:read / write;不会创建新应用。确认页只显示当前缺少项,完成后此机器人会短暂重连,其他机器人不受影响。"
|
|
309
309
|
: grantingGroupMessages
|
|
310
310
|
? "扫码会更新现有飞书应用,只增量开通“获取群组中所有消息”权限;不会创建新应用。确认后会自动启用“响应所有群消息”,其他机器人不受影响。"
|
|
311
311
|
: "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
|
|
@@ -666,7 +666,7 @@ export function BotCard({
|
|
|
666
666
|
role: "tooltip",
|
|
667
667
|
},
|
|
668
668
|
h("strong", null, "补全范围"),
|
|
669
|
-
h("span", null, "
|
|
669
|
+
h("span", null, "增量添加当前缺少的卡片回调 card.action.trigger、读取消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”)、上传机器人图片或文件所需的 im:resource,以及原生命令面板所需的 application:app_slash_command:read / write;确认页只显示当前缺少项,不会创建新应用。"))),
|
|
670
670
|
h(Button, {
|
|
671
671
|
className: "dim-cardAction", kind: "danger", onClick: onRequestRemove,
|
|
672
672
|
disabled: Boolean(busy), ref: removeButtonRef,
|
|
@@ -357,7 +357,7 @@ const EN = Object.freeze({
|
|
|
357
357
|
'权限配置已提交,正在启用全部消息模式并重连此机器人;此阶段无法取消,其他机器人不会中断。': 'The permission update was submitted. Enabling all-message mode and reconnecting this bot. This stage cannot be cancelled; other bots will not be interrupted.',
|
|
358
358
|
'正在为现有飞书应用申请群消息权限二维码,请稍候。': 'Requesting a group-message permission QR code for the existing Feishu app…',
|
|
359
359
|
'群消息权限没有开通完成': 'Group-message permission was not granted',
|
|
360
|
-
'
|
|
360
|
+
'扫码会更新现有飞书应用,增量补充当前缺少的卡片按钮回调、读取用户消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”)、上传机器人图片或文件所需的 im:resource,以及原生命令面板所需的 application:app_slash_command:read / write;不会创建新应用。确认页只显示当前缺少项,完成后此机器人会短暂重连,其他机器人不受影响。': 'Scanning updates the existing Feishu app with the missing card-button callback, im:message:readonly for reading images or files in user messages (shown by Feishu as “Read direct and group messages”), im:resource for uploading images or files sent by the bot, and application:app_slash_command:read / write for the native command panel. It does not create a new app. The confirmation page shows only missing items; this bot reconnects briefly afterward, while other bots are unaffected.',
|
|
361
361
|
'核对现有应用名称,并确认只新增当前缺少的上述配置': 'Review the existing app name and confirm that only the missing items described above are added',
|
|
362
362
|
'保持本页打开,等待权限与回调补全完成': 'Keep this page open until permissions and the callback are complete',
|
|
363
363
|
'取消补全': 'Cancel setup',
|
|
@@ -368,7 +368,7 @@ const EN = Object.freeze({
|
|
|
368
368
|
'权限与回调没有补全完成': 'Permissions and callback setup did not finish',
|
|
369
369
|
'补全权限': 'Complete permissions',
|
|
370
370
|
'补全范围': 'Completion scope',
|
|
371
|
-
'
|
|
371
|
+
'增量添加当前缺少的卡片回调 card.action.trigger、读取消息内图片或文件所需的 im:message:readonly(飞书显示为“获取单聊、群组消息”)、上传机器人图片或文件所需的 im:resource,以及原生命令面板所需的 application:app_slash_command:read / write;确认页只显示当前缺少项,不会创建新应用。': 'Adds the missing card callback card.action.trigger, im:message:readonly for reading images or files in messages (shown by Feishu as “Read direct and group messages”), im:resource for uploading images or files sent by the bot, and application:app_slash_command:read / write for the native command panel. The confirmation page shows only missing items; no new app is created.',
|
|
372
372
|
'等待扫码…': 'Waiting for scan…',
|
|
373
373
|
'飞书服务返回了不匹配的权限补全二维码': 'Feishu returned a permission-completion QR code for a different bot',
|
|
374
374
|
'飞书服务返回了不匹配的群消息权限二维码': 'Feishu returned a group-message permission QR code for a different bot',
|
|
@@ -172,6 +172,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
172
172
|
state: workspaceScope.state,
|
|
173
173
|
contextEnhancement: { botId: id, getSettings: () => workspaces.contextEnhancementFor(id) },
|
|
174
174
|
replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
|
|
175
|
+
slashCommands: config.slashCommands !== false,
|
|
175
176
|
...(wsAgent ? { wsAgent } : {}),
|
|
176
177
|
logger: {
|
|
177
178
|
error: (...args) => logger.error?.(`[${botId ?? botConfig.id}]`, ...args),
|
|
@@ -1399,7 +1399,7 @@ export class FeishuHarnessBridge {
|
|
|
1399
1399
|
: t('链接约 {minutes} 分钟后过期', { minutes: Math.max(1, Math.ceil(remaining / 60)) });
|
|
1400
1400
|
await this.#send(chatId, [
|
|
1401
1401
|
restarted ? t('旧授权链接已作废,已生成新的修复链接。') : t('🔧 准备补全权限与回调。'),
|
|
1402
|
-
t('
|
|
1402
|
+
t('本次会增量添加当前缺少项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);im:resource(用于上传机器人发送的图片或文件);以及原生命令面板所需的 application:app_slash_command:read / write。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。'),
|
|
1403
1403
|
'',
|
|
1404
1404
|
t('当前设备直接打开:'),
|
|
1405
1405
|
url,
|
|
@@ -3,6 +3,10 @@ import { FeishuHarnessBridge } from './bridge.mjs';
|
|
|
3
3
|
import { cardActionProbeCard } from './feishu-cards.mjs';
|
|
4
4
|
import { VerifiedFeishuChannel } from './feishu-channel.mjs';
|
|
5
5
|
import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
|
|
6
|
+
import {
|
|
7
|
+
registerSlashCommands,
|
|
8
|
+
SLASH_COMMAND_MANIFEST,
|
|
9
|
+
} from './slash-command-registry.mjs';
|
|
6
10
|
import {
|
|
7
11
|
connectionTestTargetUnavailable,
|
|
8
12
|
sendRememberedConnectionTest,
|
|
@@ -84,6 +88,11 @@ export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
|
|
|
84
88
|
agentPreset: 'standard',
|
|
85
89
|
authorizationMode: 'sender-open-id-allowlist',
|
|
86
90
|
allowedSenderCount,
|
|
91
|
+
slashCommandRegistration: 'idle',
|
|
92
|
+
slashCommandsRegistered: 0,
|
|
93
|
+
slashCommandsExisting: 0,
|
|
94
|
+
slashCommandsFailed: 0,
|
|
95
|
+
slashCommandsError: null,
|
|
87
96
|
};
|
|
88
97
|
}
|
|
89
98
|
|
|
@@ -119,6 +128,7 @@ export class FeishuRuntime {
|
|
|
119
128
|
#abortController = null;
|
|
120
129
|
#pendingCardActionProbes = new Map();
|
|
121
130
|
#status;
|
|
131
|
+
#slashCommands = true;
|
|
122
132
|
|
|
123
133
|
constructor({
|
|
124
134
|
lark,
|
|
@@ -137,6 +147,7 @@ export class FeishuRuntime {
|
|
|
137
147
|
replyTimeoutMs = 600000,
|
|
138
148
|
connectTimeoutMs = 15000,
|
|
139
149
|
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
150
|
+
slashCommands = true,
|
|
140
151
|
wsAgent,
|
|
141
152
|
logger = console,
|
|
142
153
|
}) {
|
|
@@ -169,6 +180,7 @@ export class FeishuRuntime {
|
|
|
169
180
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
170
181
|
this.#connectTimeoutMs = connectTimeoutMs;
|
|
171
182
|
this.#requestTimeoutMs = requestTimeoutMs;
|
|
183
|
+
this.#slashCommands = Boolean(slashCommands);
|
|
172
184
|
this.#wsAgent = wsAgent;
|
|
173
185
|
this.#logger = logger;
|
|
174
186
|
this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length });
|
|
@@ -368,6 +380,12 @@ export class FeishuRuntime {
|
|
|
368
380
|
});
|
|
369
381
|
await Promise.all([wsStarted, ready]);
|
|
370
382
|
assertCurrentStart();
|
|
383
|
+
// Register the native Slash Command panel best-effort and asynchronously
|
|
384
|
+
// so it never blocks the long-connection startup. The panel is only a
|
|
385
|
+
// client-side convenience; failure here must not take the bot down.
|
|
386
|
+
if (this.#slashCommands && httpInstance) {
|
|
387
|
+
void this.#registerSlashCommands(httpInstance, isCurrentStart, signal);
|
|
388
|
+
}
|
|
371
389
|
return this.status;
|
|
372
390
|
} catch (error) {
|
|
373
391
|
// stop() owns the terminal idle state for an explicitly aborted start.
|
|
@@ -598,6 +616,44 @@ export class FeishuRuntime {
|
|
|
598
616
|
return { sent: true };
|
|
599
617
|
}
|
|
600
618
|
|
|
619
|
+
async #registerSlashCommands(httpInstance, isCurrentStart, signal) {
|
|
620
|
+
this.#status.slashCommandRegistration = 'registering';
|
|
621
|
+
this.#status.slashCommandsError = null;
|
|
622
|
+
try {
|
|
623
|
+
const result = await registerSlashCommands({
|
|
624
|
+
appId: this.#appId,
|
|
625
|
+
appSecret: this.#appSecret,
|
|
626
|
+
domain: this.#domain,
|
|
627
|
+
httpInstance,
|
|
628
|
+
signal,
|
|
629
|
+
manifest: SLASH_COMMAND_MANIFEST,
|
|
630
|
+
});
|
|
631
|
+
if (!isCurrentStart()) return;
|
|
632
|
+
this.#status.slashCommandRegistration = 'done';
|
|
633
|
+
this.#status.slashCommandsRegistered = result.created.length;
|
|
634
|
+
this.#status.slashCommandsExisting = result.existing.length;
|
|
635
|
+
this.#status.slashCommandsFailed = result.failed.length;
|
|
636
|
+
this.#status.slashCommandsError = result.failed.length > 0
|
|
637
|
+
? result.failed.map((f) => `/${f.command}: ${f.error?.message ?? String(f.error)}`).join('; ')
|
|
638
|
+
: null;
|
|
639
|
+
if (result.created.length > 0) {
|
|
640
|
+
this.#logger.info?.(`[dsh-feishu] registered ${result.created.length} slash command(s)`);
|
|
641
|
+
}
|
|
642
|
+
if (result.failed.length > 0) {
|
|
643
|
+
this.#logger.warn?.(
|
|
644
|
+
`[dsh-feishu] ${result.failed.length} slash command(s) failed to register: ${this.#status.slashCommandsError}`,
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
} catch (error) {
|
|
648
|
+
if (!isCurrentStart()) return;
|
|
649
|
+
this.#status.slashCommandRegistration = 'failed';
|
|
650
|
+
this.#status.slashCommandsError = error?.message ?? String(error);
|
|
651
|
+
this.#logger.warn?.(
|
|
652
|
+
`[dsh-feishu] slash command registration skipped: ${this.#status.slashCommandsError}`,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
601
657
|
stop(options = {}) {
|
|
602
658
|
if (this.#stopping) return this.#stopping;
|
|
603
659
|
|
|
@@ -642,6 +698,7 @@ export class FeishuRuntime {
|
|
|
642
698
|
if (bridge) await bridge.waitForIdle();
|
|
643
699
|
this.#client = null;
|
|
644
700
|
this.#status.feishuLongConnectionState = preserveError ? 'failed' : 'idle';
|
|
701
|
+
this.#status.slashCommandRegistration = 'idle';
|
|
645
702
|
this.#status.lastError = error;
|
|
646
703
|
return this.status;
|
|
647
704
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RegistrationManager } from './registration-manager.mjs';
|
|
2
|
+
import { SLASH_COMMAND_TENANT_SCOPES } from './slash-command-registry.mjs';
|
|
2
3
|
|
|
3
4
|
export const FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
|
|
4
5
|
|
|
@@ -11,6 +12,7 @@ export const REQUIRED_TENANT_SCOPES = Object.freeze([
|
|
|
11
12
|
'im:message:recall',
|
|
12
13
|
'im:resource',
|
|
13
14
|
'cardkit:card:write',
|
|
15
|
+
...SLASH_COMMAND_TENANT_SCOPES,
|
|
14
16
|
]);
|
|
15
17
|
|
|
16
18
|
function safeConnectionStatus(runtime) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RegistrationManager } from './registration-manager.mjs';
|
|
2
|
+
import { SLASH_COMMAND_TENANT_SCOPES } from './slash-command-registry.mjs';
|
|
2
3
|
|
|
3
4
|
export const CARD_ACTION_CALLBACK = 'card.action.trigger';
|
|
4
5
|
export const FEISHU_MESSAGE_READ_SCOPE = 'im:message:readonly';
|
|
@@ -74,9 +75,10 @@ export function assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu')
|
|
|
74
75
|
* One targeted update attempt for an existing Feishu app. It intentionally
|
|
75
76
|
* shares RegistrationManager's polling/state implementation while fixing the
|
|
76
77
|
* update manifest in one place so callers can add only the card callback, the
|
|
77
|
-
* message-read scope needed to download user-sent media,
|
|
78
|
-
*
|
|
79
|
-
* scopes, events,
|
|
78
|
+
* message-read scope needed to download user-sent media, the resource scope
|
|
79
|
+
* needed to upload bot-sent images/files, and the Slash Command scopes needed
|
|
80
|
+
* for the native command panel, without adding unrelated scopes, events,
|
|
81
|
+
* presets, or createOnly.
|
|
80
82
|
*/
|
|
81
83
|
export class CallbackRepairManager {
|
|
82
84
|
#manager;
|
|
@@ -110,7 +112,13 @@ export class CallbackRepairManager {
|
|
|
110
112
|
appId: this.#appId,
|
|
111
113
|
addons: {
|
|
112
114
|
preset: false,
|
|
113
|
-
scopes: {
|
|
115
|
+
scopes: {
|
|
116
|
+
tenant: [
|
|
117
|
+
FEISHU_MESSAGE_READ_SCOPE,
|
|
118
|
+
FEISHU_RESOURCE_SCOPE,
|
|
119
|
+
...SLASH_COMMAND_TENANT_SCOPES,
|
|
120
|
+
],
|
|
121
|
+
},
|
|
114
122
|
callbacks: { items: [CARD_ACTION_CALLBACK] },
|
|
115
123
|
},
|
|
116
124
|
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feishu native Slash Command registration for the dsh-im Feishu channel.
|
|
3
|
+
*
|
|
4
|
+
* The Feishu client shows a "/" command panel in the chat input box. The
|
|
5
|
+
* command list is stored server-side per bot application and is NOT pushed
|
|
6
|
+
* by dsh/Harness. dsh-im holds its own static command manifest and calls the
|
|
7
|
+
* Feishu OpenAPI to register it, so users can discover commands by typing "/".
|
|
8
|
+
*
|
|
9
|
+
* Reference (official):
|
|
10
|
+
* https://open.feishu.cn/document/mcp_open_tools/agent-best-practices/agent-supports-slash-commands
|
|
11
|
+
*
|
|
12
|
+
* The registered command panel is only a client-side convenience: when a user
|
|
13
|
+
* taps a command, Feishu sends it to the bot as an ordinary text message via
|
|
14
|
+
* im.message.receive_v1. The bridge's #handle() command matcher therefore
|
|
15
|
+
* needs no changes as long as every registered command name matches the
|
|
16
|
+
* existing "/xxx" text commands.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const SLASH_ENDPOINT = '/open-apis/application/v7/app_slash_commands';
|
|
20
|
+
const MISSING_PERMISSION_CODES = new Set(['99991640', '99991672']);
|
|
21
|
+
|
|
22
|
+
export const SLASH_COMMAND_TENANT_SCOPES = Object.freeze([
|
|
23
|
+
'application:app_slash_command:read',
|
|
24
|
+
'application:app_slash_command:write',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// Icon keys are the documented values in the Feishu Slash Command doc.
|
|
28
|
+
const DEFAULT_ICON = 'ai-agent_outlined';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The dsh-im Feishu command manifest. Every entry's `command` is registered
|
|
32
|
+
* WITHOUT the leading slash; Feishu displays it as "/<command>" in the panel
|
|
33
|
+
* and sends "/<command>" back as text, which matches the bridge's regexes.
|
|
34
|
+
*
|
|
35
|
+
* Descriptions should stay short and match what the command actually does in
|
|
36
|
+
* bridge.mjs / the shared command modules.
|
|
37
|
+
*/
|
|
38
|
+
export const SLASH_COMMAND_MANIFEST = Object.freeze([
|
|
39
|
+
{ command: 'menu', icon: 'skill_outlined', default: '打开功能菜单', en_us: 'Open the feature menu' },
|
|
40
|
+
{ command: 'new', icon: 'ai-deepthink_outlined', default: '开启全新会话', en_us: 'Start a fresh session' },
|
|
41
|
+
{ command: 'help', icon: 'promptword_outlined', default: '查看帮助', en_us: 'Show help' },
|
|
42
|
+
{ command: 'status', icon: 'ai-functions_outlined', default: '查看机器人状态', en_us: 'Show bot status' },
|
|
43
|
+
{ command: 'compact', icon: 'ai-block_outlined', default: '压缩当前会话上下文', en_us: 'Compact the current session' },
|
|
44
|
+
{ command: 'sessionlist', icon: 'chat-ai_outlined', default: '列出会话', en_us: 'List sessions' },
|
|
45
|
+
{ command: 'workspacelist', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
|
|
46
|
+
{ command: 'watch', icon: 'flag_outlined', default: '关注一个会话', en_us: 'Watch a session' },
|
|
47
|
+
{ command: 'unwatch', icon: 'clear_outlined', default: '取消关注会话', en_us: 'Unwatch a session' },
|
|
48
|
+
{ command: 'watchlist', icon: 'flag_outlined', default: '查看关注列表', en_us: 'List watched sessions' },
|
|
49
|
+
{ command: 'archived', icon: 'folder_outlined', default: '设置归档会话显隐(on/off)', en_us: 'Show or hide archived sessions (on/off)' },
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
// Commands that require a parameter are registered too, so the user can type
|
|
53
|
+
// "/watch <session ID>" from the panel. A leading placeholder hint is not part of
|
|
54
|
+
// the registered name; Feishu only allows a plain command token.
|
|
55
|
+
|
|
56
|
+
function endpointFor(domain, path) {
|
|
57
|
+
const origin = domain === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
|
|
58
|
+
return new URL(path, origin);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function jsonResponse(body, operation) {
|
|
62
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
63
|
+
throw new Error(`${operation} returned a non-JSON response`);
|
|
64
|
+
}
|
|
65
|
+
if (body.code !== 0) {
|
|
66
|
+
const error = new Error(`${operation} failed: ${body.msg || `code ${body.code}`}`);
|
|
67
|
+
error.code = String(body.code);
|
|
68
|
+
error.msg = body.msg;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
return body;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function requestSignal(signal, timeoutMs) {
|
|
75
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
76
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function requestJson(httpInstance, options, operation) {
|
|
80
|
+
try {
|
|
81
|
+
return jsonResponse(await httpInstance.request(options), operation);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
const body = error?.response?.data;
|
|
84
|
+
if (body && typeof body === 'object' && !Array.isArray(body)
|
|
85
|
+
&& Object.hasOwn(body, 'code')) {
|
|
86
|
+
return jsonResponse(body, operation);
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Fetch a tenant_access_token for the app. */
|
|
93
|
+
async function fetchTenantAccessToken({
|
|
94
|
+
appId, appSecret, domain, httpInstance, timeoutMs, signal,
|
|
95
|
+
}) {
|
|
96
|
+
if (!appId || !appSecret) throw new Error('Feishu slash registration requires app credentials');
|
|
97
|
+
if (!httpInstance || typeof httpInstance.request !== 'function') {
|
|
98
|
+
throw new TypeError('Feishu slash registration requires an HTTP instance');
|
|
99
|
+
}
|
|
100
|
+
const body = await requestJson(httpInstance, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
url: endpointFor(domain, '/open-apis/auth/v3/tenant_access_token/internal').href,
|
|
103
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
104
|
+
data: { app_id: appId, app_secret: appSecret },
|
|
105
|
+
signal: requestSignal(signal, timeoutMs),
|
|
106
|
+
timeout: timeoutMs,
|
|
107
|
+
}, 'Feishu authentication');
|
|
108
|
+
if (!body.tenant_access_token) {
|
|
109
|
+
throw new Error('Feishu authentication returned no tenant access token');
|
|
110
|
+
}
|
|
111
|
+
return body.tenant_access_token;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function listSlashCommandsWithToken({
|
|
115
|
+
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
|
|
116
|
+
}) {
|
|
117
|
+
const body = await requestJson(httpInstance, {
|
|
118
|
+
method: 'GET',
|
|
119
|
+
url: endpointFor(domain, SLASH_ENDPOINT).href,
|
|
120
|
+
headers: {
|
|
121
|
+
authorization: `Bearer ${tenantAccessToken}`,
|
|
122
|
+
'content-type': 'application/json; charset=utf-8',
|
|
123
|
+
},
|
|
124
|
+
signal: requestSignal(signal, timeoutMs),
|
|
125
|
+
timeout: timeoutMs,
|
|
126
|
+
}, 'Feishu slash command list');
|
|
127
|
+
return Array.isArray(body.data?.items) ? body.data.items : [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** List every slash command currently registered for the app. */
|
|
131
|
+
export async function listSlashCommands({
|
|
132
|
+
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000, signal,
|
|
133
|
+
}) {
|
|
134
|
+
const tenantAccessToken = await fetchTenantAccessToken({
|
|
135
|
+
appId, appSecret, domain, httpInstance, timeoutMs, signal,
|
|
136
|
+
});
|
|
137
|
+
return listSlashCommandsWithToken({
|
|
138
|
+
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function createSlashCommandWithToken({
|
|
143
|
+
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
|
|
144
|
+
command, description, icon = DEFAULT_ICON,
|
|
145
|
+
}) {
|
|
146
|
+
const data = { command };
|
|
147
|
+
if (description && (description.default_value || description.i18n)) {
|
|
148
|
+
data.description = description;
|
|
149
|
+
} else if (typeof description === 'string' && description.trim()) {
|
|
150
|
+
data.description = { default_value: description.trim() };
|
|
151
|
+
}
|
|
152
|
+
if (icon) data.description = { ...(data.description ?? {}), icon: { icon_key: icon } };
|
|
153
|
+
const body = await requestJson(httpInstance, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
url: endpointFor(domain, SLASH_ENDPOINT).href,
|
|
156
|
+
headers: {
|
|
157
|
+
authorization: `Bearer ${tenantAccessToken}`,
|
|
158
|
+
'content-type': 'application/json; charset=utf-8',
|
|
159
|
+
},
|
|
160
|
+
data,
|
|
161
|
+
signal: requestSignal(signal, timeoutMs),
|
|
162
|
+
timeout: timeoutMs,
|
|
163
|
+
}, `Feishu slash command create (/${command})`);
|
|
164
|
+
return body.data?.command_id ?? null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Register a single slash command. Returns the server-assigned command_id. */
|
|
168
|
+
export async function createSlashCommand({
|
|
169
|
+
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000,
|
|
170
|
+
signal, command, description, icon = DEFAULT_ICON,
|
|
171
|
+
}) {
|
|
172
|
+
const tenantAccessToken = await fetchTenantAccessToken({
|
|
173
|
+
appId, appSecret, domain, httpInstance, timeoutMs, signal,
|
|
174
|
+
});
|
|
175
|
+
return createSlashCommandWithToken({
|
|
176
|
+
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
|
|
177
|
+
command, description, icon,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Delete a registered slash command by its server command_id. */
|
|
182
|
+
export async function deleteSlashCommand({
|
|
183
|
+
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000, signal, commandId,
|
|
184
|
+
}) {
|
|
185
|
+
const tenantAccessToken = await fetchTenantAccessToken({
|
|
186
|
+
appId, appSecret, domain, httpInstance, timeoutMs, signal,
|
|
187
|
+
});
|
|
188
|
+
await requestJson(httpInstance, {
|
|
189
|
+
method: 'DELETE',
|
|
190
|
+
url: endpointFor(domain, `${SLASH_ENDPOINT}/${commandId}`).href,
|
|
191
|
+
headers: { authorization: `Bearer ${tenantAccessToken}` },
|
|
192
|
+
signal: requestSignal(signal, timeoutMs),
|
|
193
|
+
timeout: timeoutMs,
|
|
194
|
+
}, 'Feishu slash command delete');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Best-effort sync of the manifest into the app's registered slash commands.
|
|
199
|
+
* Creates any command in the manifest that is not yet registered and returns
|
|
200
|
+
* a structured report. This is idempotent (the API rejects duplicates with
|
|
201
|
+
* "command already exists", so we skip existing names).
|
|
202
|
+
*
|
|
203
|
+
* @returns {{ created: Array<{command,command_id}>, existing: string[], failed: Array<{command,error}> }}
|
|
204
|
+
*/
|
|
205
|
+
export async function registerSlashCommands({
|
|
206
|
+
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000,
|
|
207
|
+
signal, manifest = SLASH_COMMAND_MANIFEST,
|
|
208
|
+
}) {
|
|
209
|
+
const tenantAccessToken = await fetchTenantAccessToken({
|
|
210
|
+
appId, appSecret, domain, httpInstance, timeoutMs, signal,
|
|
211
|
+
});
|
|
212
|
+
const existing = new Set((await listSlashCommandsWithToken({
|
|
213
|
+
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
|
|
214
|
+
}))
|
|
215
|
+
.map((item) => item.command));
|
|
216
|
+
|
|
217
|
+
const created = [];
|
|
218
|
+
const failed = [];
|
|
219
|
+
for (const entry of manifest) {
|
|
220
|
+
const command = String(entry.command ?? '').replace(/^\//, '');
|
|
221
|
+
if (!command) continue;
|
|
222
|
+
if (existing.has(command)) continue;
|
|
223
|
+
try {
|
|
224
|
+
const description = {
|
|
225
|
+
default_value: entry.default ?? entry.en_us ?? command,
|
|
226
|
+
i18n: {
|
|
227
|
+
zh_cn: entry.default ?? command,
|
|
228
|
+
en_us: entry.en_us ?? entry.default ?? command,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
const commandId = await createSlashCommandWithToken({
|
|
232
|
+
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
|
|
233
|
+
command, description, icon: entry.icon ?? DEFAULT_ICON,
|
|
234
|
+
});
|
|
235
|
+
created.push({ command, command_id: commandId });
|
|
236
|
+
} catch (error) {
|
|
237
|
+
// "command already exists" can race with concurrent runs; treat as existing.
|
|
238
|
+
if (error?.code === '40000000' && /already exists/i.test(error?.msg ?? '')) {
|
|
239
|
+
existing.add(command);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (MISSING_PERMISSION_CODES.has(error?.code)
|
|
243
|
+
|| /(?:lacks permission|access denied)/i.test(error?.msg ?? '')) {
|
|
244
|
+
// Missing app_slash_command:write permission; abort the batch.
|
|
245
|
+
failed.push({ command, error });
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
failed.push({ command, error: error?.message ?? String(error) });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
created,
|
|
254
|
+
existing: [...existing].filter((c) => c !== null && c !== undefined),
|
|
255
|
+
failed,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export default registerSlashCommands;
|
|
@@ -72,8 +72,8 @@ export default {
|
|
|
72
72
|
'旧授权链接已作废,已生成新的修复链接。':
|
|
73
73
|
'The previous authorization link was invalidated and a new repair link was generated.',
|
|
74
74
|
'🔧 准备补全权限与回调。': '🔧 Preparing to complete permissions and the callback.',
|
|
75
|
-
'
|
|
76
|
-
'This
|
|
75
|
+
'本次会增量添加当前缺少项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);im:resource(用于上传机器人发送的图片或文件);以及原生命令面板所需的 application:app_slash_command:read / write。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。':
|
|
76
|
+
'This incrementally adds the currently missing items: the card callback card.action.trigger; the tenant scope im:message:readonly, shown by Feishu as “Read direct and group messages” and used to read images or files in user messages; im:resource, used to upload images or files sent by the bot; and application:app_slash_command:read / write for the native command panel. The confirmation page shows only items the app is currently missing; cancel if anything outside this scope appears.',
|
|
77
77
|
'当前设备直接打开:': 'Open directly on this device:',
|
|
78
78
|
'若要用另一台设备扫码,发送 /repair qr。{expiry}。':
|
|
79
79
|
'To scan with another device, send /repair qr. {expiry}.',
|