dsh-im-ops/src/channels/feishu/slash-command-registry.mjs

259 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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';
const MISSING_PERMISSION_CODES = new Set(['99991640', '99991672']);
export const SLASH_COMMAND_TENANT_SCOPES = Object.freeze([
'application:app_slash_command:read',
'application:app_slash_command:write',
]);
// 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 session' },
{ command: 'unwatch', icon: 'clear_outlined', default: '取消关注会话', en_us: 'Unwatch a session' },
{ command: 'watchlist', icon: 'flag_outlined', default: '查看关注列表', en_us: 'List watched sessions' },
{ command: 'archived', icon: 'folder_outlined', default: '设置归档会话显隐(on/off)', en_us: 'Show or hide archived sessions (on/off)' },
]);
// Commands that require a parameter are registered too, so the user can type
// "/watch <session ID>" 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;
}
function requestSignal(signal, timeoutMs) {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
async function requestJson(httpInstance, options, operation) {
try {
return jsonResponse(await httpInstance.request(options), operation);
} catch (error) {
const body = error?.response?.data;
if (body && typeof body === 'object' && !Array.isArray(body)
&& Object.hasOwn(body, 'code')) {
return jsonResponse(body, operation);
}
throw error;
}
}
/** Fetch a tenant_access_token for the app. */
async function fetchTenantAccessToken({
appId, appSecret, domain, httpInstance, timeoutMs, signal,
}) {
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 = await requestJson(httpInstance, {
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: requestSignal(signal, 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;
}
async function listSlashCommandsWithToken({
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
}) {
const body = await requestJson(httpInstance, {
method: 'GET',
url: endpointFor(domain, SLASH_ENDPOINT).href,
headers: {
authorization: `Bearer ${tenantAccessToken}`,
'content-type': 'application/json; charset=utf-8',
},
signal: requestSignal(signal, timeoutMs),
timeout: timeoutMs,
}, 'Feishu slash command list');
return Array.isArray(body.data?.items) ? body.data.items : [];
}
/** List every slash command currently registered for the app. */
export async function listSlashCommands({
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000, signal,
}) {
const tenantAccessToken = await fetchTenantAccessToken({
appId, appSecret, domain, httpInstance, timeoutMs, signal,
});
return listSlashCommandsWithToken({
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
});
}
async function createSlashCommandWithToken({
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
command, description, icon = DEFAULT_ICON,
}) {
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 = await requestJson(httpInstance, {
method: 'POST',
url: endpointFor(domain, SLASH_ENDPOINT).href,
headers: {
authorization: `Bearer ${tenantAccessToken}`,
'content-type': 'application/json; charset=utf-8',
},
data,
signal: requestSignal(signal, timeoutMs),
timeout: timeoutMs,
}, `Feishu slash command create (/${command})`);
return body.data?.command_id ?? null;
}
/** Register a single slash command. Returns the server-assigned command_id. */
export async function createSlashCommand({
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000,
signal, command, description, icon = DEFAULT_ICON,
}) {
const tenantAccessToken = await fetchTenantAccessToken({
appId, appSecret, domain, httpInstance, timeoutMs, signal,
});
return createSlashCommandWithToken({
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
command, description, icon,
});
}
/** Delete a registered slash command by its server command_id. */
export async function deleteSlashCommand({
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000, signal, commandId,
}) {
const tenantAccessToken = await fetchTenantAccessToken({
appId, appSecret, domain, httpInstance, timeoutMs, signal,
});
await requestJson(httpInstance, {
method: 'DELETE',
url: endpointFor(domain, `${SLASH_ENDPOINT}/${commandId}`).href,
headers: { authorization: `Bearer ${tenantAccessToken}` },
signal: requestSignal(signal, 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,
signal, manifest = SLASH_COMMAND_MANIFEST,
}) {
const tenantAccessToken = await fetchTenantAccessToken({
appId, appSecret, domain, httpInstance, timeoutMs, signal,
});
const existing = new Set((await listSlashCommandsWithToken({
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
}))
.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,
},
};
const commandId = await createSlashCommandWithToken({
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
command, description, icon: entry.icon ?? DEFAULT_ICON,
});
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 (MISSING_PERMISSION_CODES.has(error?.code)
|| /(?:lacks permission|access denied)/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;