feat(feishu): register native Slash Command panel on startup

The Feishu bot now calls the app_slash_commands OpenAPI at startup to register its common commands (menu, new, help, status, compact, sessionlist, workspacelist, watch, unwatch, watchlist, archived) as native Slash Commands, so typing / in a Feishu direct-message input box pops the command panel and tapping a command triggers it.

The command list is owned and pushed by dsh-im; it does not depend on the dsh/Harness backend. Registration is best-effort and runs asynchronously, so it never blocks the long-connection startup or message delivery when the app lacks the application:app_slash_command:read/write scopes.

Adds src/channels/feishu/slash-command-registry.mjs (manifest, list/create/delete, idempotent sync), wires it into feishu-runtime.mjs (slashCommands config flag + status fields), and covers it with tests.
This commit is contained in:
C3H3-AI 2026-08-30 23:40:59 +08:00
parent 7f968ac1d8
commit 64ba1463a8
6 changed files with 383 additions and 0 deletions

View file

@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
## [Unreleased] ## [Unreleased]
### Added / 新增
- 飞书机器人启动时调用 `app_slash_commands` OpenAPI,把常用命令注册为原生 Slash Command,使飞书单聊输入框输入 `/` 弹出命令面板;命令清单由 dsh-im 持有并推送注册,不依赖 dsh/Harness 后端。需要应用开通 `application:app_slash_command:read` / `write` 并发布版本,注册失败不影响消息收发。
On startup the Feishu bot registers its common commands as native Slash Commands via the `app_slash_commands` OpenAPI, so the `/` panel appears in Feishu direct-message input. The command list is owned and pushed by dsh-im and does not depend on the dsh/Harness backend. Requires the app to grant `application:app_slash_command:read` / `write` and publish a version; registration failure does not affect messaging.
## [4.1.0] - 2026-08-30 ## [4.1.0] - 2026-08-30
### Added / 新增 ### Added / 新增

View file

@ -210,6 +210,8 @@ Example: send `/models`, then `/model 2` to switch to the second model in the li
If the Slack desktop app has no native Slash Command registered with the same name, it intercepts messages that begin directly with `/`. Send the command with one leading space instead, for example ` /presetlist`, ` /preset 2`, ` /history`, or ` /history 10`; the plugin command layer trims surrounding whitespace, so it executes exactly like the unspaced form. If the Slack desktop app has no native Slash Command registered with the same name, it intercepts messages that begin directly with `/`. Send the command with one leading space instead, for example ` /presetlist`, ` /preset 2`, ` /history`, or ` /history 10`; the plugin command layer trims surrounding whitespace, so it executes exactly like the unspaced form.
**Feishu `/` command panel**: On startup the Feishu bot registers its common commands (`menu`, `new`, `help`, `status`, `compact`, `sessionlist`, `workspacelist`, `watch`, `unwatch`, `watchlist`, `archived`) as native Slash Commands through the `app_slash_commands` OpenAPI, so typing `/` in a Feishu direct-message input box pops the command panel and tapping a command triggers it. The command list is owned and pushed by dsh-im; it does not depend on the dsh/Harness backend. The app must grant `application:app_slash_command:read` and `application:app_slash_command:write` in the developer console and publish a version for it to take effect; the Feishu client also caches the list for a few minutes. This is best-effort and never blocks message delivery.
### Command details ### Command details
- `/help` takes no arguments and never creates a Session. It returns the complete command list supported by the current bot. - `/help` takes no arguments and never creates a Session. It returns the complete command list supported by the current bot.

View file

@ -213,6 +213,8 @@ dsh plugin --profile web add -w --save-exact @xmanrui/dsh-im@3.1.0 --registry=ht
Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/` 开头的消息。此时请加一个前导空格发送,例如 ` /presetlist`、` /preset 2`、` /history` 或 ` /history 10`;插件命令层会去除首尾空白,执行效果与无空格命令相同。 Slack 桌面端若未注册同名的原生 Slash Command,会拦截直接以 `/` 开头的消息。此时请加一个前导空格发送,例如 ` /presetlist`、` /preset 2`、` /history` 或 ` /history 10`;插件命令层会去除首尾空白,执行效果与无空格命令相同。
**飞书输入框的 `/` 命令面板**:机器人启动时,dsh-im 会调用飞书 `app_slash_commands` OpenAPI,把常用命令(`menu`、`new`、`help`、`status`、`compact`、`sessionlist`、`workspacelist`、`watch`、`unwatch`、`watchlist`、`archived`)注册成原生 Slash Command,这样在飞书单聊输入框输入 `/` 会弹出命令面板,点选即触发。命令列表由 dsh-im 自己持有并推送注册,不依赖 dsh/Harness 后端。需要开放平台在“权限管理”里为应用开通 `application:app_slash_command:read` 和 `application:app_slash_command:write` 并发布新版本才能生效;注册后飞书客户端约有几分钟缓存延迟。该能力是尽力而为的,注册失败不会影响机器人消息收发。
### 命令说明 ### 命令说明
- `/help` 不需要参数,也不会创建会话;它会返回当前机器人支持的完整命令列表。 - `/help` 不需要参数,也不会创建会话;它会返回当前机器人支持的完整命令列表。

View file

@ -3,6 +3,10 @@ import { FeishuHarnessBridge } from './bridge.mjs';
import { cardActionProbeCard } from './feishu-cards.mjs'; import { cardActionProbeCard } from './feishu-cards.mjs';
import { VerifiedFeishuChannel } from './feishu-channel.mjs'; import { VerifiedFeishuChannel } from './feishu-channel.mjs';
import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs'; import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
import {
registerSlashCommands,
SLASH_COMMAND_MANIFEST,
} from './slash-command-registry.mjs';
import { import {
connectionTestTargetUnavailable, connectionTestTargetUnavailable,
sendRememberedConnectionTest, sendRememberedConnectionTest,
@ -84,6 +88,11 @@ export function createBridgeStatus({ allowedSenderCount = 1 } = {}) {
agentPreset: 'standard', agentPreset: 'standard',
authorizationMode: 'sender-open-id-allowlist', authorizationMode: 'sender-open-id-allowlist',
allowedSenderCount, allowedSenderCount,
slashCommandRegistration: 'idle',
slashCommandsRegistered: 0,
slashCommandsExisting: 0,
slashCommandsFailed: 0,
slashCommandsError: null,
}; };
} }
@ -119,6 +128,7 @@ export class FeishuRuntime {
#abortController = null; #abortController = null;
#pendingCardActionProbes = new Map(); #pendingCardActionProbes = new Map();
#status; #status;
#slashCommands = true;
constructor({ constructor({
lark, lark,
@ -137,6 +147,7 @@ export class FeishuRuntime {
replyTimeoutMs = 600000, replyTimeoutMs = 600000,
connectTimeoutMs = 15000, connectTimeoutMs = 15000,
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
slashCommands = true,
wsAgent, wsAgent,
logger = console, logger = console,
}) { }) {
@ -169,6 +180,7 @@ export class FeishuRuntime {
this.#replyTimeoutMs = replyTimeoutMs; this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs; this.#connectTimeoutMs = connectTimeoutMs;
this.#requestTimeoutMs = requestTimeoutMs; this.#requestTimeoutMs = requestTimeoutMs;
this.#slashCommands = Boolean(slashCommands);
this.#wsAgent = wsAgent; this.#wsAgent = wsAgent;
this.#logger = logger; this.#logger = logger;
this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length }); this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length });
@ -368,6 +380,12 @@ export class FeishuRuntime {
}); });
await Promise.all([wsStarted, ready]); await Promise.all([wsStarted, ready]);
assertCurrentStart(); assertCurrentStart();
// Register the native Slash Command panel best-effort and asynchronously
// so it never blocks the long-connection startup. The panel is only a
// client-side convenience; failure here must not take the bot down.
if (this.#slashCommands && httpInstance) {
void this.#registerSlashCommands(httpInstance, isCurrentStart);
}
return this.status; return this.status;
} catch (error) { } catch (error) {
// stop() owns the terminal idle state for an explicitly aborted start. // stop() owns the terminal idle state for an explicitly aborted start.
@ -598,6 +616,43 @@ export class FeishuRuntime {
return { sent: true }; return { sent: true };
} }
async #registerSlashCommands(httpInstance, isCurrentStart) {
this.#status.slashCommandRegistration = 'registering';
this.#status.slashCommandsError = null;
try {
const result = await registerSlashCommands({
appId: this.#appId,
appSecret: this.#appSecret,
domain: this.#domain,
httpInstance,
manifest: SLASH_COMMAND_MANIFEST,
});
if (!isCurrentStart()) return;
this.#status.slashCommandRegistration = 'done';
this.#status.slashCommandsRegistered = result.created.length;
this.#status.slashCommandsExisting = result.existing.length;
this.#status.slashCommandsFailed = result.failed.length;
this.#status.slashCommandsError = result.failed.length > 0
? result.failed.map((f) => `/${f.command}: ${f.error?.message ?? String(f.error)}`).join('; ')
: null;
if (result.created.length > 0) {
this.#logger.info?.(`[dsh-feishu] registered ${result.created.length} slash command(s)`);
}
if (result.failed.length > 0) {
this.#logger.warn?.(
`[dsh-feishu] ${result.failed.length} slash command(s) failed to register: ${this.#status.slashCommandsError}`,
);
}
} catch (error) {
if (!isCurrentStart()) return;
this.#status.slashCommandRegistration = 'failed';
this.#status.slashCommandsError = error?.message ?? String(error);
this.#logger.warn?.(
`[dsh-feishu] slash command registration skipped: ${this.#status.slashCommandsError}`,
);
}
}
stop(options = {}) { stop(options = {}) {
if (this.#stopping) return this.#stopping; if (this.#stopping) return this.#stopping;

View file

@ -0,0 +1,201 @@
/**
* Feishu native Slash Command registration for the dsh-im Feishu channel.
*
* The Feishu client shows a "/" command panel in the chat input box. The
* command list is stored server-side per bot application and is NOT pushed
* by dsh/Harness. dsh-im holds its own static command manifest and calls the
* Feishu OpenAPI to register it, so users can discover commands by typing "/".
*
* Reference (official):
* https://open.feishu.cn/document/mcp_open_tools/agent-best-practices/agent-supports-slash-commands
*
* The registered command panel is only a client-side convenience: when a user
* taps a command, Feishu sends it to the bot as an ordinary text message via
* im.message.receive_v1. The bridge's #handle() command matcher therefore
* needs no changes as long as every registered command name matches the
* existing "/xxx" text commands.
*/
const SLASH_ENDPOINT = '/open-apis/application/v7/app_slash_commands';
// Icon keys are the documented values in the Feishu Slash Command doc.
const DEFAULT_ICON = 'ai-agent_outlined';
/**
* The dsh-im Feishu command manifest. Every entry's `command` is registered
* WITHOUT the leading slash; Feishu displays it as "/<command>" in the panel
* and sends "/<command>" back as text, which matches the bridge's regexes.
*
* Descriptions should stay short and match what the command actually does in
* bridge.mjs / the shared command modules.
*/
export const SLASH_COMMAND_MANIFEST = Object.freeze([
{ command: 'menu', icon: 'skill_outlined', default: '打开功能菜单', en_us: 'Open the feature menu' },
{ command: 'new', icon: 'ai-deepthink_outlined', default: '开启全新会话', en_us: 'Start a fresh session' },
{ command: 'help', icon: 'promptword_outlined', default: '查看帮助', en_us: 'Show help' },
{ command: 'status', icon: 'ai-functions_outlined', default: '查看机器人状态', en_us: 'Show bot status' },
{ command: 'compact', icon: 'ai-block_outlined', default: '压缩当前会话上下文', en_us: 'Compact the current session' },
{ command: 'sessionlist', icon: 'chat-ai_outlined', default: '列出会话', en_us: 'List sessions' },
{ command: 'workspacelist', icon: 'folder_outlined', default: '列出工作区', en_us: 'List workspaces' },
{ command: 'watch', icon: 'flag_outlined', default: '监听一个话题', en_us: 'Watch a topic' },
{ command: 'unwatch', icon: 'clear_outlined', default: '取消监听', en_us: 'Unwatch a topic' },
{ command: 'watchlist', icon: 'flag_outlined', default: '查看监听列表', en_us: 'List watched topics' },
{ command: 'archived', icon: 'folder_outlined', default: '显示或隐藏归档会话', en_us: 'Toggle archived sessions' },
]);
// Commands that require a parameter are registered too, so the user can type
// "/watch <topic>" from the panel. A leading placeholder hint is not part of
// the registered name; Feishu only allows a plain command token.
function endpointFor(domain, path) {
const origin = domain === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn';
return new URL(path, origin);
}
function jsonResponse(body, operation) {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw new Error(`${operation} returned a non-JSON response`);
}
if (body.code !== 0) {
const error = new Error(`${operation} failed: ${body.msg || `code ${body.code}`}`);
error.code = String(body.code);
error.msg = body.msg;
throw error;
}
return body;
}
/** Fetch a tenant_access_token for the app. */
async function fetchTenantAccessToken({ appId, appSecret, domain, httpInstance, timeoutMs }) {
if (!appId || !appSecret) throw new Error('Feishu slash registration requires app credentials');
if (!httpInstance || typeof httpInstance.request !== 'function') {
throw new TypeError('Feishu slash registration requires an HTTP instance');
}
const body = jsonResponse(await httpInstance.request({
method: 'POST',
url: endpointFor(domain, '/open-apis/auth/v3/tenant_access_token/internal').href,
headers: { 'content-type': 'application/json; charset=utf-8' },
data: { app_id: appId, app_secret: appSecret },
signal: AbortSignal.timeout(timeoutMs),
timeout: timeoutMs,
}), 'Feishu authentication');
if (!body.tenant_access_token) {
throw new Error('Feishu authentication returned no tenant access token');
}
return body.tenant_access_token;
}
/** List every slash command currently registered for the app. */
export async function listSlashCommands({ appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000 }) {
const token = await fetchTenantAccessToken({ appId, appSecret, domain, httpInstance, timeoutMs });
const body = jsonResponse(await httpInstance.request({
method: 'GET',
url: endpointFor(domain, SLASH_ENDPOINT).href,
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json; charset=utf-8',
},
signal: AbortSignal.timeout(timeoutMs),
timeout: timeoutMs,
}), 'Feishu slash command list');
return Array.isArray(body.data?.items) ? body.data.items : [];
}
/** Register a single slash command. Returns the server-assigned command_id. */
export async function createSlashCommand({
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000,
command, description, icon = DEFAULT_ICON,
}) {
const token = await fetchTenantAccessToken({ appId, appSecret, domain, httpInstance, timeoutMs });
const data = { command };
if (description && (description.default_value || description.i18n)) {
data.description = description;
} else if (typeof description === 'string' && description.trim()) {
data.description = { default_value: description.trim() };
}
if (icon) data.description = { ...(data.description ?? {}), icon: { icon_key: icon } };
const body = jsonResponse(await httpInstance.request({
method: 'POST',
url: endpointFor(domain, SLASH_ENDPOINT).href,
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json; charset=utf-8',
},
data,
signal: AbortSignal.timeout(timeoutMs),
timeout: timeoutMs,
}), `Feishu slash command create (/${command})`);
return body.data?.command_id ?? null;
}
/** Delete a registered slash command by its server command_id. */
export async function deleteSlashCommand({
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000, commandId,
}) {
const token = await fetchTenantAccessToken({ appId, appSecret, domain, httpInstance, timeoutMs });
await jsonResponse(await httpInstance.request({
method: 'DELETE',
url: endpointFor(domain, `${SLASH_ENDPOINT}/${commandId}`).href,
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(timeoutMs),
timeout: timeoutMs,
}), 'Feishu slash command delete');
}
/**
* Best-effort sync of the manifest into the app's registered slash commands.
* Creates any command in the manifest that is not yet registered and returns
* a structured report. This is idempotent (the API rejects duplicates with
* "command already exists", so we skip existing names).
*
* @returns {{ created: Array<{command,command_id}>, existing: string[], failed: Array<{command,error}> }}
*/
export async function registerSlashCommands({
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000,
manifest = SLASH_COMMAND_MANIFEST,
}) {
const existing = new Set((await listSlashCommands({ appId, appSecret, domain, httpInstance, timeoutMs }))
.map((item) => item.command));
const created = [];
const failed = [];
for (const entry of manifest) {
const command = String(entry.command ?? '').replace(/^\//, '');
if (!command) continue;
if (existing.has(command)) continue;
try {
const description = {
default_value: entry.default ?? entry.en_us ?? command,
i18n: {
zh_cn: entry.default ?? command,
en_us: entry.en_us ?? entry.default ?? command,
},
icon: { icon_key: entry.icon ?? DEFAULT_ICON },
};
const commandId = await createSlashCommand({
appId, appSecret, domain, httpInstance, timeoutMs, command, description,
});
created.push({ command, command_id: commandId });
} catch (error) {
// "command already exists" can race with concurrent runs; treat as existing.
if (error?.code === '40000000' && /already exists/i.test(error?.msg ?? '')) {
existing.add(command);
continue;
}
if (error?.code === '99991640' || /lacks permission/i.test(error?.msg ?? '')) {
// Missing app_slash_command:write permission; abort the batch.
failed.push({ command, error });
break;
}
failed.push({ command, error: error?.message ?? String(error) });
}
}
return {
created,
existing: [...existing].filter((c) => c !== null && c !== undefined),
failed,
};
}
export default registerSlashCommands;

View file

@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
listSlashCommands,
registerSlashCommands,
SLASH_COMMAND_MANIFEST,
} from '../../../src/channels/feishu/slash-command-registry.mjs';
function fakeHttpInstance(handlers) {
const requests = [];
return {
requests,
http: {
async request(options) {
requests.push(options);
const { method, url } = options;
const key = method + ' ' + url.split('/open-apis/')[1];
const handler = handlers[key];
if (typeof handler !== 'function') {
throw new Error(`no fake handler for ${key}`);
}
return handler(options);
},
},
};
}
const AUTH_KEY = 'POST auth/v3/tenant_access_token/internal';
const LIST_KEY = 'GET application/v7/app_slash_commands';
test('SLASH_COMMAND_MANIFEST is non-empty and has no leading slash', () => {
assert.ok(Array.isArray(SLASH_COMMAND_MANIFEST));
assert.ok(SLASH_COMMAND_MANIFEST.length > 0);
for (const entry of SLASH_COMMAND_MANIFEST) {
assert.equal(typeof entry.command, 'string');
assert.ok(entry.command.length > 0);
assert.equal(entry.command.startsWith('/'), false, `command ${entry.command} must not start with /`);
assert.equal(typeof entry.default, 'string');
}
});
test('listSlashCommands returns the registered command items', async () => {
const { http, requests } = fakeHttpInstance({
[AUTH_KEY]: () => ({ code: 0, tenant_access_token: 'tenant-token' }),
[LIST_KEY]: () => ({ code: 0, data: { items: [{ command: 'menu', command_id: '1' }] } }),
});
const items = await listSlashCommands({ appId: 'a', appSecret: 's', httpInstance: http });
assert.deepEqual(items, [{ command: 'menu', command_id: '1' }]);
assert.equal(requests[1].headers.authorization, 'Bearer tenant-token');
});
test('registerSlashCommands creates missing and skips existing commands', async () => {
const createdBodies = [];
const existing = new Set(['menu', 'help']);
const { http } = fakeHttpInstance({
[AUTH_KEY]: () => ({ code: 0, tenant_access_token: 'tenant-token' }),
[LIST_KEY]: () => ({ code: 0, data: { items: [{ command: 'menu' }, { command: 'help' }] } }),
'POST application/v7/app_slash_commands': (options) => {
createdBodies.push(options.data);
return { code: 0, data: { command_id: `id-${options.data.command}` } };
},
});
const manifest = [
{ command: 'menu', default: '打开菜单', en_us: 'Open menu' },
{ command: 'status', default: '状态', en_us: 'Status' },
{ command: 'watch', default: '监听', en_us: 'Watch' },
];
const result = await registerSlashCommands({
appId: 'a', appSecret: 's', httpInstance: http, manifest,
});
// menu exists -> skipped; status & watch created
assert.equal(result.created.length, 2);
assert.ok(result.created.some((c) => c.command === 'status'));
assert.ok(result.created.some((c) => c.command === 'watch'));
assert.ok(result.existing.includes('menu'));
assert.ok(result.existing.includes('help'));
assert.equal(result.failed.length, 0);
assert.equal(createdBodies.length, 2);
for (const body of createdBodies) {
assert.equal(body.command.startsWith('/'), false);
assert.ok(body.description.default_value);
assert.ok(body.description.i18n.zh_cn);
assert.ok(body.description.icon.icon_key);
}
});
test('registerSlashCommands aborts batch on missing permission', async () => {
const { http } = fakeHttpInstance({
[AUTH_KEY]: () => ({ code: 0, tenant_access_token: 'tenant-token' }),
[LIST_KEY]: () => ({ code: 0, data: { items: [] } }),
'POST application/v7/app_slash_commands': () => ({ code: 99991640, msg: 'lacks permission' }),
});
const manifest = [
{ command: 'menu', default: 'x', en_us: 'x' },
{ command: 'status', default: 'x', en_us: 'x' },
];
const result = await registerSlashCommands({
appId: 'a', appSecret: 's', httpInstance: http, manifest,
});
assert.equal(result.created.length, 0);
assert.equal(result.failed.length, 1);
});
test('registerSlashCommands treats duplicate-create as already-existing', async () => {
const { http } = fakeHttpInstance({
[AUTH_KEY]: () => ({ code: 0, tenant_access_token: 'tenant-token' }),
[LIST_KEY]: () => ({ code: 0, data: { items: [] } }),
'POST application/v7/app_slash_commands': () => ({ code: 40000000, msg: 'command already exists' }),
});
const result = await registerSlashCommands({
appId: 'a', appSecret: 's', httpInstance: http,
manifest: [{ command: 'menu', default: 'x', en_us: 'x' }],
});
assert.equal(result.created.length, 0);
assert.equal(result.failed.length, 0);
});