mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 16:40:46 +08:00
fix(feishu): integrate slash command permissions
This commit is contained in:
parent
29aa138b3b
commit
347acceffb
20 changed files with 464 additions and 273 deletions
|
|
@ -110,6 +110,14 @@ function deferred() {
|
|||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function waitFor(predicate, timeoutMs = 1_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) throw new Error('condition timed out');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connected', async () => {
|
||||
let harnessChecks = 0;
|
||||
let harnessSignal;
|
||||
|
|
@ -206,6 +214,74 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
|
|||
assert.deepEqual(runtime.status, stoppedStatus);
|
||||
});
|
||||
|
||||
test('FeishuRuntime keeps Slash registration non-blocking and aborts it on stop', async () => {
|
||||
const lark = fakeLark();
|
||||
const requests = [];
|
||||
let createSignal;
|
||||
lark.defaultHttpInstance.request = async (options) => {
|
||||
requests.push(options);
|
||||
if (options.url.includes('/tenant_access_token/')) {
|
||||
return { code: 0, tenant_access_token: 'tenant-token' };
|
||||
}
|
||||
if (options.method === 'GET') return { code: 0, data: { items: [] } };
|
||||
createSignal = options.signal;
|
||||
return new Promise((_resolve, reject) => {
|
||||
const abort = () => reject(createSignal.reason);
|
||||
createSignal.addEventListener('abort', abort, { once: true });
|
||||
if (createSignal.aborted) abort();
|
||||
});
|
||||
};
|
||||
const runtime = new FeishuRuntime({
|
||||
lark,
|
||||
appId: 'cli_slash',
|
||||
appSecret: 'secret',
|
||||
ownerOpenIds: ['ou_owner'],
|
||||
harness: { async ensureRunning() {} },
|
||||
state: { hasSeen: () => false },
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
});
|
||||
|
||||
const starting = runtime.start();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
FakeWSClient.instances[0].becomeReady();
|
||||
const ready = await starting;
|
||||
assert.equal(ready.ready, true);
|
||||
await waitFor(() => createSignal !== undefined);
|
||||
assert.equal(runtime.status.slashCommandRegistration, 'registering');
|
||||
assert.equal(requests.filter((request) => request.url.includes('/tenant_access_token/')).length, 1);
|
||||
|
||||
await runtime.stop();
|
||||
assert.equal(createSignal.aborted, true);
|
||||
assert.equal(runtime.status.slashCommandRegistration, 'idle');
|
||||
});
|
||||
|
||||
test('FeishuRuntime can disable Slash registration', async () => {
|
||||
const lark = fakeLark();
|
||||
let requests = 0;
|
||||
lark.defaultHttpInstance.request = async () => {
|
||||
requests += 1;
|
||||
return { code: 0 };
|
||||
};
|
||||
const runtime = new FeishuRuntime({
|
||||
lark,
|
||||
appId: 'cli_no_slash',
|
||||
appSecret: 'secret',
|
||||
ownerOpenIds: ['ou_owner'],
|
||||
harness: { async ensureRunning() {} },
|
||||
state: { hasSeen: () => false },
|
||||
slashCommands: false,
|
||||
});
|
||||
|
||||
const starting = runtime.start();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
FakeWSClient.instances[0].becomeReady();
|
||||
await starting;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(requests, 0);
|
||||
assert.equal(runtime.status.slashCommandRegistration, 'idle');
|
||||
await runtime.stop();
|
||||
});
|
||||
|
||||
test('FeishuRuntime uses a remembered private target for wildcard-only manual bots', async () => {
|
||||
const state = { hasSeen: () => false };
|
||||
const runtime = new FeishuRuntime({
|
||||
|
|
|
|||
|
|
@ -187,6 +187,8 @@ test('QR registration separates events from card callbacks', async () => {
|
|||
assert.deepEqual(run.options.addons.callbacks.items, ['card.action.trigger']);
|
||||
assert.ok(run.options.addons.scopes.tenant.includes('im:resource'));
|
||||
assert.equal(run.options.addons.scopes.tenant.includes('im:resource:upload'), false);
|
||||
assert.ok(run.options.addons.scopes.tenant.includes('application:app_slash_command:read'));
|
||||
assert.ok(run.options.addons.scopes.tenant.includes('application:app_slash_command:write'));
|
||||
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/callbacks', expireIn: 60 });
|
||||
run.resolve({
|
||||
client_id: 'cli_callbacks', client_secret: 'callbacks-secret',
|
||||
|
|
@ -346,7 +348,14 @@ test('callback repair is deduplicated per bot, updates only its secret, and prov
|
|||
assert.equal(Object.hasOwn(run.options, 'appPreset'), false);
|
||||
assert.deepEqual(run.options.addons, {
|
||||
preset: false,
|
||||
scopes: { tenant: ['im:message:readonly', 'im:resource'] },
|
||||
scopes: {
|
||||
tenant: [
|
||||
'im:message:readonly',
|
||||
'im:resource',
|
||||
'application:app_slash_command:read',
|
||||
'application:app_slash_command:write',
|
||||
],
|
||||
},
|
||||
callbacks: { items: ['card.action.trigger'] },
|
||||
});
|
||||
run.options.onQRCodeReady({
|
||||
|
|
|
|||
|
|
@ -86,6 +86,8 @@ test('QR success stores the secret off-config and becomes immediately chat-ready
|
|||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:send_as_bot'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:resource'));
|
||||
assert.equal(fx.getSdkOptions().addons.scopes.tenant.includes('im:resource:upload'), false);
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('application:app_slash_command:read'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('application:app_slash_command:write'));
|
||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('cardkit:card:write'));
|
||||
fx.getSdkOptions().onQRCodeReady({ url: 'https://accounts.feishu.cn/qr', expireIn: 600 });
|
||||
fixture.resolveRegistration({
|
||||
|
|
|
|||
|
|
@ -1165,6 +1165,7 @@ test('production assembly uses ctx credentials and the active Host apiProxy with
|
|||
}, {
|
||||
dshHome: '/tmp/dsh-feishu-host-test',
|
||||
workspace: '/tmp/dsh-feishu-workspace',
|
||||
slashCommands: false,
|
||||
}, {
|
||||
lark: { registerApp: async () => ({}), defaultHttpInstance: httpInstance },
|
||||
Controller: FakeController,
|
||||
|
|
@ -1209,6 +1210,7 @@ test('production assembly uses ctx credentials and the active Host apiProxy with
|
|||
assert.match(constructed.statePath, /integrations\/dsh-feishu\/state\.json$/);
|
||||
assert.equal(constructed.runtime.appSecret, 'host-only');
|
||||
assert.equal(constructed.runtime.wsAgent, wsAgent);
|
||||
assert.equal(constructed.runtime.slashCommands, false);
|
||||
const repair = { start() {}, status() {}, cancel() {} };
|
||||
await constructed.controller.createRuntime({
|
||||
botId: 'bot_alpha',
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
FEISHU_RESOURCE_SCOPE,
|
||||
assertCallbackRepairUrl,
|
||||
} from '../../../src/channels/feishu/repair-manager.mjs';
|
||||
import { SLASH_COMMAND_TENANT_SCOPES } from '../../../src/channels/feishu/slash-command-registry.mjs';
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
|
|
@ -17,7 +18,7 @@ async function waitFor(predicate, timeoutMs = 1000) {
|
|||
}
|
||||
}
|
||||
|
||||
test('CallbackRepairManager targets one real app with only the callback and media scopes', async () => {
|
||||
test('CallbackRepairManager targets one real app with only the callback and required scopes', async () => {
|
||||
let observed;
|
||||
let resolveRegistration;
|
||||
const accepted = [];
|
||||
|
|
@ -39,7 +40,13 @@ test('CallbackRepairManager targets one real app with only the callback and medi
|
|||
assert.equal(Object.hasOwn(observed, 'appPreset'), false);
|
||||
assert.deepEqual(observed.addons, {
|
||||
preset: false,
|
||||
scopes: { tenant: [FEISHU_MESSAGE_READ_SCOPE, FEISHU_RESOURCE_SCOPE] },
|
||||
scopes: {
|
||||
tenant: [
|
||||
FEISHU_MESSAGE_READ_SCOPE,
|
||||
FEISHU_RESOURCE_SCOPE,
|
||||
...SLASH_COMMAND_TENANT_SCOPES,
|
||||
],
|
||||
},
|
||||
callbacks: { items: ['card.action.trigger'] },
|
||||
});
|
||||
assert.equal(Object.hasOwn(observed.addons, 'events'), false);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import test from 'node:test';
|
|||
import {
|
||||
listSlashCommands,
|
||||
registerSlashCommands,
|
||||
SLASH_COMMAND_TENANT_SCOPES,
|
||||
SLASH_COMMAND_MANIFEST,
|
||||
} from '../../../src/channels/feishu/slash-command-registry.mjs';
|
||||
|
||||
|
|
@ -29,6 +30,10 @@ 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.deepEqual(SLASH_COMMAND_TENANT_SCOPES, [
|
||||
'application:app_slash_command:read',
|
||||
'application:app_slash_command:write',
|
||||
]);
|
||||
assert.ok(Array.isArray(SLASH_COMMAND_MANIFEST));
|
||||
assert.ok(SLASH_COMMAND_MANIFEST.length > 0);
|
||||
for (const entry of SLASH_COMMAND_MANIFEST) {
|
||||
|
|
@ -51,8 +56,7 @@ test('listSlashCommands returns the registered command items', async () => {
|
|||
|
||||
test('registerSlashCommands creates missing and skips existing commands', async () => {
|
||||
const createdBodies = [];
|
||||
const existing = new Set(['menu', 'help']);
|
||||
const { http } = fakeHttpInstance({
|
||||
const { http, requests } = 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) => {
|
||||
|
|
@ -63,8 +67,8 @@ test('registerSlashCommands creates missing and skips existing commands', async
|
|||
|
||||
const manifest = [
|
||||
{ command: 'menu', default: '打开菜单', en_us: 'Open menu' },
|
||||
{ command: 'status', default: '状态', en_us: 'Status' },
|
||||
{ command: 'watch', default: '监听', en_us: 'Watch' },
|
||||
{ command: 'status', icon: 'ai-functions_outlined', default: '状态', en_us: 'Status' },
|
||||
{ command: 'watch', icon: 'flag_outlined', default: '关注', en_us: 'Watch' },
|
||||
];
|
||||
const result = await registerSlashCommands({
|
||||
appId: 'a', appSecret: 's', httpInstance: http, manifest,
|
||||
|
|
@ -78,6 +82,11 @@ test('registerSlashCommands creates missing and skips existing commands', async
|
|||
assert.ok(result.existing.includes('help'));
|
||||
assert.equal(result.failed.length, 0);
|
||||
assert.equal(createdBodies.length, 2);
|
||||
assert.equal(requests.filter((request) => request.url.includes('/tenant_access_token/')).length, 1);
|
||||
assert.deepEqual(createdBodies.map((body) => body.description.icon.icon_key), [
|
||||
'ai-functions_outlined',
|
||||
'flag_outlined',
|
||||
]);
|
||||
for (const body of createdBodies) {
|
||||
assert.equal(body.command.startsWith('/'), false);
|
||||
assert.ok(body.description.default_value);
|
||||
|
|
@ -87,10 +96,19 @@ test('registerSlashCommands creates missing and skips existing commands', async
|
|||
});
|
||||
|
||||
test('registerSlashCommands aborts batch on missing permission', async () => {
|
||||
const { http } = fakeHttpInstance({
|
||||
const { http, requests } = 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' }),
|
||||
'POST application/v7/app_slash_commands': () => {
|
||||
const error = new Error('Request failed with status code 400');
|
||||
error.response = {
|
||||
data: {
|
||||
code: 99991672,
|
||||
msg: 'Access denied. One of the following scopes is required: [application:app_slash_command:write]',
|
||||
},
|
||||
};
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
const manifest = [
|
||||
{ command: 'menu', default: 'x', en_us: 'x' },
|
||||
|
|
@ -101,6 +119,12 @@ test('registerSlashCommands aborts batch on missing permission', async () => {
|
|||
});
|
||||
assert.equal(result.created.length, 0);
|
||||
assert.equal(result.failed.length, 1);
|
||||
assert.equal(result.failed[0].error.code, '99991672');
|
||||
assert.equal(
|
||||
requests.filter((request) => request.url.endsWith('/app_slash_commands')
|
||||
&& request.method === 'POST').length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('registerSlashCommands treats duplicate-create as already-existing', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue