mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
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:
parent
7f968ac1d8
commit
64ba1463a8
6 changed files with 383 additions and 0 deletions
|
|
@ -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 / 新增
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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` 不需要参数,也不会创建会话;它会返回当前机器人支持的完整命令列表。
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
201
src/channels/feishu/slash-command-registry.mjs
Normal file
201
src/channels/feishu/slash-command-registry.mjs
Normal 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;
|
||||
118
test/channels/feishu/slash-command-registry.test.mjs
Normal file
118
test/channels/feishu/slash-command-registry.test.mjs
Normal 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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue