diff --git a/CHANGELOG.md b/CHANGELOG.md index 75397ab..0108bb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows ## [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 ### Added / 新增 diff --git a/README.en.md b/README.en.md index 2e56c74..3935412 100644 --- a/README.en.md +++ b/README.en.md @@ -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. +**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 - `/help` takes no arguments and never creates a Session. It returns the complete command list supported by the current bot. diff --git a/README.md b/README.md index 640c366..078ab2a 100644 --- a/README.md +++ b/README.md @@ -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`;插件命令层会去除首尾空白,执行效果与无空格命令相同。 +**飞书输入框的 `/` 命令面板**:机器人启动时,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` 不需要参数,也不会创建会话;它会返回当前机器人支持的完整命令列表。 diff --git a/src/channels/feishu/feishu-runtime.mjs b/src/channels/feishu/feishu-runtime.mjs index 3234011..87bb109 100644 --- a/src/channels/feishu/feishu-runtime.mjs +++ b/src/channels/feishu/feishu-runtime.mjs @@ -3,6 +3,10 @@ import { FeishuHarnessBridge } from './bridge.mjs'; import { cardActionProbeCard } from './feishu-cards.mjs'; import { VerifiedFeishuChannel } from './feishu-channel.mjs'; import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs'; +import { + registerSlashCommands, + SLASH_COMMAND_MANIFEST, +} from './slash-command-registry.mjs'; import { connectionTestTargetUnavailable, sendRememberedConnectionTest, @@ -84,6 +88,11 @@ export function createBridgeStatus({ allowedSenderCount = 1 } = {}) { agentPreset: 'standard', authorizationMode: 'sender-open-id-allowlist', allowedSenderCount, + slashCommandRegistration: 'idle', + slashCommandsRegistered: 0, + slashCommandsExisting: 0, + slashCommandsFailed: 0, + slashCommandsError: null, }; } @@ -119,6 +128,7 @@ export class FeishuRuntime { #abortController = null; #pendingCardActionProbes = new Map(); #status; + #slashCommands = true; constructor({ lark, @@ -137,6 +147,7 @@ export class FeishuRuntime { replyTimeoutMs = 600000, connectTimeoutMs = 15000, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + slashCommands = true, wsAgent, logger = console, }) { @@ -169,6 +180,7 @@ export class FeishuRuntime { this.#replyTimeoutMs = replyTimeoutMs; this.#connectTimeoutMs = connectTimeoutMs; this.#requestTimeoutMs = requestTimeoutMs; + this.#slashCommands = Boolean(slashCommands); this.#wsAgent = wsAgent; this.#logger = logger; this.#status = createBridgeStatus({ allowedSenderCount: normalizedOwners.length }); @@ -368,6 +380,12 @@ export class FeishuRuntime { }); await Promise.all([wsStarted, ready]); 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; } catch (error) { // stop() owns the terminal idle state for an explicitly aborted start. @@ -598,6 +616,43 @@ export class FeishuRuntime { 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 = {}) { if (this.#stopping) return this.#stopping; diff --git a/src/channels/feishu/slash-command-registry.mjs b/src/channels/feishu/slash-command-registry.mjs new file mode 100644 index 0000000..523dcea --- /dev/null +++ b/src/channels/feishu/slash-command-registry.mjs @@ -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 "/" in the panel + * and sends "/" 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 " 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; diff --git a/test/channels/feishu/slash-command-registry.test.mjs b/test/channels/feishu/slash-command-registry.test.mjs new file mode 100644 index 0000000..7b2863c --- /dev/null +++ b/test/channels/feishu/slash-command-registry.test.mjs @@ -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); +});