mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 05:20: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
|
|
@ -1399,7 +1399,7 @@ export class FeishuHarnessBridge {
|
|||
: t('链接约 {minutes} 分钟后过期', { minutes: Math.max(1, Math.ceil(remaining / 60)) });
|
||||
await this.#send(chatId, [
|
||||
restarted ? t('旧授权链接已作废,已生成新的修复链接。') : t('🔧 准备补全权限与回调。'),
|
||||
t('本次最多增量添加三项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);以及 im:resource(用于上传机器人发送的图片或文件)。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。'),
|
||||
t('本次会增量添加当前缺少项:卡片回调 card.action.trigger;飞书显示为“获取单聊、群组消息”的租户权限 im:message:readonly(用于读取用户消息中的图片或文件);im:resource(用于上传机器人发送的图片或文件);以及原生命令面板所需的 application:app_slash_command:read / write。确认页只会显示当前缺少的项;若出现上述范围之外的配置,请取消。'),
|
||||
'',
|
||||
t('当前设备直接打开:'),
|
||||
url,
|
||||
|
|
|
|||
|
|
@ -384,7 +384,7 @@ export class FeishuRuntime {
|
|||
// 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);
|
||||
void this.#registerSlashCommands(httpInstance, isCurrentStart, signal);
|
||||
}
|
||||
return this.status;
|
||||
} catch (error) {
|
||||
|
|
@ -616,7 +616,7 @@ export class FeishuRuntime {
|
|||
return { sent: true };
|
||||
}
|
||||
|
||||
async #registerSlashCommands(httpInstance, isCurrentStart) {
|
||||
async #registerSlashCommands(httpInstance, isCurrentStart, signal) {
|
||||
this.#status.slashCommandRegistration = 'registering';
|
||||
this.#status.slashCommandsError = null;
|
||||
try {
|
||||
|
|
@ -625,6 +625,7 @@ export class FeishuRuntime {
|
|||
appSecret: this.#appSecret,
|
||||
domain: this.#domain,
|
||||
httpInstance,
|
||||
signal,
|
||||
manifest: SLASH_COMMAND_MANIFEST,
|
||||
});
|
||||
if (!isCurrentStart()) return;
|
||||
|
|
@ -697,6 +698,7 @@ export class FeishuRuntime {
|
|||
if (bridge) await bridge.waitForIdle();
|
||||
this.#client = null;
|
||||
this.#status.feishuLongConnectionState = preserveError ? 'failed' : 'idle';
|
||||
this.#status.slashCommandRegistration = 'idle';
|
||||
this.#status.lastError = error;
|
||||
return this.status;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { RegistrationManager } from './registration-manager.mjs';
|
||||
import { SLASH_COMMAND_TENANT_SCOPES } from './slash-command-registry.mjs';
|
||||
|
||||
export const FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ export const REQUIRED_TENANT_SCOPES = Object.freeze([
|
|||
'im:message:recall',
|
||||
'im:resource',
|
||||
'cardkit:card:write',
|
||||
...SLASH_COMMAND_TENANT_SCOPES,
|
||||
]);
|
||||
|
||||
function safeConnectionStatus(runtime) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { RegistrationManager } from './registration-manager.mjs';
|
||||
import { SLASH_COMMAND_TENANT_SCOPES } from './slash-command-registry.mjs';
|
||||
|
||||
export const CARD_ACTION_CALLBACK = 'card.action.trigger';
|
||||
export const FEISHU_MESSAGE_READ_SCOPE = 'im:message:readonly';
|
||||
|
|
@ -74,9 +75,10 @@ export function assertCallbackRepairUrl(value, expectedAppId, domain = 'feishu')
|
|||
* One targeted update attempt for an existing Feishu app. It intentionally
|
||||
* shares RegistrationManager's polling/state implementation while fixing the
|
||||
* update manifest in one place so callers can add only the card callback, the
|
||||
* message-read scope needed to download user-sent media, and the resource
|
||||
* scope needed to upload bot-sent images/files, without adding unrelated
|
||||
* scopes, events, presets, or createOnly.
|
||||
* message-read scope needed to download user-sent media, the resource scope
|
||||
* needed to upload bot-sent images/files, and the Slash Command scopes needed
|
||||
* for the native command panel, without adding unrelated scopes, events,
|
||||
* presets, or createOnly.
|
||||
*/
|
||||
export class CallbackRepairManager {
|
||||
#manager;
|
||||
|
|
@ -110,7 +112,13 @@ export class CallbackRepairManager {
|
|||
appId: this.#appId,
|
||||
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_CALLBACK] },
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@
|
|||
*/
|
||||
|
||||
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';
|
||||
|
|
@ -37,14 +43,14 @@ export const SLASH_COMMAND_MANIFEST = Object.freeze([
|
|||
{ 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' },
|
||||
{ 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 <topic>" from the panel. A leading placeholder hint is not part of
|
||||
// "/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) {
|
||||
|
|
@ -65,48 +71,78 @@ function jsonResponse(body, operation) {
|
|||
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 }) {
|
||||
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 = jsonResponse(await httpInstance.request({
|
||||
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: AbortSignal.timeout(timeoutMs),
|
||||
signal: requestSignal(signal, timeoutMs),
|
||||
timeout: timeoutMs,
|
||||
}), 'Feishu authentication');
|
||||
}, '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({
|
||||
async function listSlashCommandsWithToken({
|
||||
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
|
||||
}) {
|
||||
const body = await requestJson(httpInstance, {
|
||||
method: 'GET',
|
||||
url: endpointFor(domain, SLASH_ENDPOINT).href,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
authorization: `Bearer ${tenantAccessToken}`,
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
},
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
signal: requestSignal(signal, timeoutMs),
|
||||
timeout: timeoutMs,
|
||||
}), 'Feishu slash command list');
|
||||
}, '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,
|
||||
/** 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 token = await fetchTenantAccessToken({ appId, appSecret, domain, httpInstance, timeoutMs });
|
||||
const data = { command };
|
||||
if (description && (description.default_value || description.i18n)) {
|
||||
data.description = description;
|
||||
|
|
@ -114,32 +150,48 @@ export async function createSlashCommand({
|
|||
data.description = { default_value: description.trim() };
|
||||
}
|
||||
if (icon) data.description = { ...(data.description ?? {}), icon: { icon_key: icon } };
|
||||
const body = jsonResponse(await httpInstance.request({
|
||||
const body = await requestJson(httpInstance, {
|
||||
method: 'POST',
|
||||
url: endpointFor(domain, SLASH_ENDPOINT).href,
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
authorization: `Bearer ${tenantAccessToken}`,
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
},
|
||||
data,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
signal: requestSignal(signal, timeoutMs),
|
||||
timeout: timeoutMs,
|
||||
}), `Feishu slash command create (/${command})`);
|
||||
}, `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, commandId,
|
||||
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000, signal, commandId,
|
||||
}) {
|
||||
const token = await fetchTenantAccessToken({ appId, appSecret, domain, httpInstance, timeoutMs });
|
||||
await jsonResponse(await httpInstance.request({
|
||||
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 ${token}` },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
headers: { authorization: `Bearer ${tenantAccessToken}` },
|
||||
signal: requestSignal(signal, timeoutMs),
|
||||
timeout: timeoutMs,
|
||||
}), 'Feishu slash command delete');
|
||||
}, 'Feishu slash command delete');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -152,9 +204,14 @@ export async function deleteSlashCommand({
|
|||
*/
|
||||
export async function registerSlashCommands({
|
||||
appId, appSecret, domain = 'feishu', httpInstance, timeoutMs = 15000,
|
||||
manifest = SLASH_COMMAND_MANIFEST,
|
||||
signal, manifest = SLASH_COMMAND_MANIFEST,
|
||||
}) {
|
||||
const existing = new Set((await listSlashCommands({ appId, appSecret, domain, httpInstance, timeoutMs }))
|
||||
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 = [];
|
||||
|
|
@ -170,10 +227,10 @@ export async function registerSlashCommands({
|
|||
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,
|
||||
const commandId = await createSlashCommandWithToken({
|
||||
tenantAccessToken, domain, httpInstance, timeoutMs, signal,
|
||||
command, description, icon: entry.icon ?? DEFAULT_ICON,
|
||||
});
|
||||
created.push({ command, command_id: commandId });
|
||||
} catch (error) {
|
||||
|
|
@ -182,7 +239,8 @@ export async function registerSlashCommands({
|
|||
existing.add(command);
|
||||
continue;
|
||||
}
|
||||
if (error?.code === '99991640' || /lacks permission/i.test(error?.msg ?? '')) {
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue