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
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