mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
Merge pull request #23 from NIU-001-LIU/feat/feishu-card-menus
This commit is contained in:
commit
60a364118e
9 changed files with 642 additions and 137 deletions
266
lib/index.js
266
lib/index.js
File diff suppressed because one or more lines are too long
|
|
@ -27,12 +27,27 @@ import {
|
||||||
isModelCommand,
|
isModelCommand,
|
||||||
runModelCommand,
|
runModelCommand,
|
||||||
} from '../shared/model-command.mjs';
|
} from '../shared/model-command.mjs';
|
||||||
import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
|
import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
|
||||||
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
||||||
|
import {
|
||||||
|
MENU_PAGE_SIZE,
|
||||||
|
menuCard,
|
||||||
|
menuHelpText,
|
||||||
|
sessionListCard,
|
||||||
|
workspaceListCard,
|
||||||
|
} from './feishu-cards.mjs';
|
||||||
|
|
||||||
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
||||||
const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
const MENU_COMMAND = /^\/m(?:enu)?$/i;
|
||||||
|
const SESSION_LIST_PREFIX = /^\/sessionlist(?:\s|$)/i;
|
||||||
|
const WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
|
||||||
|
const NUMBER_REPLY = /^\d{1,2}$/;
|
||||||
|
/** A displayed menu stays number-tappable for this long. */
|
||||||
|
const MENU_TTL_MS = 10 * 60_000;
|
||||||
|
const MAX_TRACKED_MENUS = 50;
|
||||||
|
|
||||||
const HELP_TEXT = [
|
const HELP_TEXT = [
|
||||||
'北汇星河 AIOS 已连接 DeepSeek Harness。',
|
'北汇星河 AIOS 已连接 DeepSeek Harness。',
|
||||||
'',
|
'',
|
||||||
|
|
@ -49,9 +64,26 @@ const HELP_TEXT = [
|
||||||
'/stop 停止当前任务',
|
'/stop 停止当前任务',
|
||||||
'/steer 补充指令 纠偏当前任务',
|
'/steer 补充指令 纠偏当前任务',
|
||||||
'/status 检查连接状态',
|
'/status 检查连接状态',
|
||||||
|
'/m(或 /menu) 打开交互卡片菜单',
|
||||||
'/help 显示本帮助',
|
'/help 显示本帮助',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
|
/** Safe user-facing text for bind/workspace failures (no raw messages). */
|
||||||
|
function safeErrorText(error) {
|
||||||
|
switch (error?.code) {
|
||||||
|
case 'workspace-not-absolute':
|
||||||
|
return '工作区必须是绝对路径。';
|
||||||
|
case 'workspace-not-found':
|
||||||
|
return '工作区路径不存在。';
|
||||||
|
case 'workspace-not-directory':
|
||||||
|
return '工作区路径必须指向一个目录。';
|
||||||
|
case 'workspace-bot-not-found':
|
||||||
|
return '机器人正在移除或已重新接入,无法操作原会话的工作区。';
|
||||||
|
default:
|
||||||
|
return '操作失败,请稍后重试。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function nonEmptyString(value) {
|
function nonEmptyString(value) {
|
||||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +129,10 @@ export class FeishuHarnessBridge {
|
||||||
#replyTimeoutMs;
|
#replyTimeoutMs;
|
||||||
#logger;
|
#logger;
|
||||||
#signal;
|
#signal;
|
||||||
|
/** Number-tappable menus: conversation key → menu state. */
|
||||||
|
#menus = new Map();
|
||||||
|
/** Interactive-card message id → { key, chatId } for button callbacks. */
|
||||||
|
#cardKeys = new Map();
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
client,
|
client,
|
||||||
|
|
@ -404,6 +440,11 @@ export class FeishuHarnessBridge {
|
||||||
await this.#send(event.message.chat_id, HELP_TEXT);
|
await this.#send(event.message.chat_id, HELP_TEXT);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (MENU_COMMAND.test(commandText)) {
|
||||||
|
this.#rememberMenu(key, { kind: 'menu', chatId: event.message.chat_id });
|
||||||
|
await this.#sendCard(event.message.chat_id, menuCard(), { key });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (commandText === '/new') {
|
if (commandText === '/new') {
|
||||||
await this.#state.clearSession(key);
|
await this.#state.clearSession(key);
|
||||||
await this.#send(event.message.chat_id, '已开启全新 Harness 会话。');
|
await this.#send(event.message.chat_id, '已开启全新 Harness 会话。');
|
||||||
|
|
@ -414,6 +455,22 @@ export class FeishuHarnessBridge {
|
||||||
await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
|
await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (SESSION_LIST_PREFIX.test(commandText)) {
|
||||||
|
const selector = commandText.slice('/sessionlist'.length).trim() || null;
|
||||||
|
await this.#showSessions({ chatId: event.message.chat_id, key }, selector, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (WORKSPACE_LIST_COMMAND.test(commandText)) {
|
||||||
|
await this.#showWorkspaces({ chatId: event.message.chat_id, key });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (NUMBER_REPLY.test(commandText)) {
|
||||||
|
const menu = this.#takeMenu(key);
|
||||||
|
if (menu) {
|
||||||
|
await this.#handleMenuPick(menu, Number(commandText), { chatId: event.message.chat_id, key });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const workspaceCommand = commandText === null
|
const workspaceCommand = commandText === null
|
||||||
? null
|
? null
|
||||||
: await runWorkspaceCommand(text, this.#harness, key);
|
: await runWorkspaceCommand(text, this.#harness, key);
|
||||||
|
|
@ -449,6 +506,191 @@ export class FeishuHarnessBridge {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Interactive cards: menus and session/workspace lists ────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card button callback (card.action.trigger). The operator must be an
|
||||||
|
* allowed sender: group members outside the allowlist must never drive
|
||||||
|
* session binding, workspace switches or other card actions.
|
||||||
|
*/
|
||||||
|
onCardAction(event) {
|
||||||
|
const operatorOpenId = nonEmptyString(event?.operator?.operator_id?.open_id)
|
||||||
|
?? nonEmptyString(event?.operator?.operator_id?.user_id);
|
||||||
|
if (operatorOpenId === null || !this.#allowedSenderOpenIds.has(operatorOpenId)) {
|
||||||
|
this.#logger.warn?.('[dsh-feishu] ignoring card action from an unallowed sender');
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
const action = typeof event?.action?.value?.action === 'string'
|
||||||
|
? event.action.value.action
|
||||||
|
: null;
|
||||||
|
if (!action) return Promise.resolve();
|
||||||
|
const messageId = nonEmptyString(event?.context?.open_message_id);
|
||||||
|
const entry = messageId ? this.#cardKeys.get(messageId) : null;
|
||||||
|
if (!entry) return Promise.resolve();
|
||||||
|
// The promise is returned so tests (and future callers) can await the
|
||||||
|
// action; the runtime dispatcher ignores it.
|
||||||
|
return this.#handleCardAction(action, entry).catch((error) => {
|
||||||
|
this.#logger.warn?.('[dsh-feishu] card action failed:', error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async #handleCardAction(action, { chatId, key }) {
|
||||||
|
if (action === 'sessions' || /^sessions:\d+$/.test(action)) {
|
||||||
|
const page = action === 'sessions' ? 0 : Number(action.slice('sessions:'.length));
|
||||||
|
await this.#showSessions({ chatId, key }, null, page);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'workspaces') {
|
||||||
|
await this.#showWorkspaces({ chatId, key });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'new') {
|
||||||
|
await this.#state.clearSession(key);
|
||||||
|
await this.#send(chatId, '已开启全新 Harness 会话。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'status') {
|
||||||
|
await this.#harness.ensureRunning({ signal: this.#signal });
|
||||||
|
await this.#send(chatId, '飞书机器人与 DeepSeek Harness 连接正常。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'help') {
|
||||||
|
await this.#send(chatId, menuHelpText());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.startsWith('use:')) {
|
||||||
|
await this.#bindSession(key, chatId, action.slice('use:'.length));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action.startsWith('workspace:')) {
|
||||||
|
await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#rememberMenu(key, menu) {
|
||||||
|
if (this.#menus.size >= MAX_TRACKED_MENUS) {
|
||||||
|
const oldest = this.#menus.keys().next().value;
|
||||||
|
if (oldest !== undefined) this.#menus.delete(oldest);
|
||||||
|
}
|
||||||
|
this.#menus.delete(key);
|
||||||
|
this.#menus.set(key, { ...menu, expiresAt: Date.now() + MENU_TTL_MS });
|
||||||
|
}
|
||||||
|
|
||||||
|
#takeMenu(key) {
|
||||||
|
const menu = this.#menus.get(key);
|
||||||
|
if (!menu) return null;
|
||||||
|
if (menu.expiresAt < Date.now()) {
|
||||||
|
this.#menus.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #handleMenuPick(menu, number, { chatId, key }) {
|
||||||
|
if (menu.kind === 'menu') {
|
||||||
|
const action = ['sessions', 'workspaces', 'new', 'status', 'help'][number - 1];
|
||||||
|
if (!action) {
|
||||||
|
await this.#send(chatId, '菜单没有这个编号,回复 /m 重新打开。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.#handleCardAction(action, { chatId, key });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (menu.kind === 'sessions') {
|
||||||
|
const session = menu.sessions[number - 1];
|
||||||
|
if (!session?.sessionId) {
|
||||||
|
await this.#send(chatId, `本页只有 ${menu.sessions.length} 个会话,回复 /sessionlist 重新查看。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (menu.kind === 'workspaces') {
|
||||||
|
const workspace = menu.paths[number - 1];
|
||||||
|
if (!workspace) {
|
||||||
|
await this.#send(chatId, `只有 ${menu.paths.length} 个工作区,回复 /workspacelist 重新查看。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.#handleCardAction(`workspace:${workspace}`, { chatId, key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #showSessions({ chatId, key }, selector, page = 0) {
|
||||||
|
try {
|
||||||
|
const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness);
|
||||||
|
if (resolved.error) {
|
||||||
|
await this.#send(chatId, resolved.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const listed = await this.#harness.listWorkspaceSessions(resolved.workspace);
|
||||||
|
const sessions = Array.isArray(listed?.sessions) ? listed.sessions : [];
|
||||||
|
const workspace = listed?.workspace ?? resolved.workspace;
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
await this.#send(chatId, `工作区:${workspace}\n该工作区暂无会话。`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE);
|
||||||
|
const safePage = Number.isSafeInteger(page) && page > 0 ? Math.min(page, pageCount - 1) : 0;
|
||||||
|
this.#rememberMenu(key, {
|
||||||
|
kind: 'sessions',
|
||||||
|
sessions: sessions.slice(safePage * MENU_PAGE_SIZE, (safePage + 1) * MENU_PAGE_SIZE),
|
||||||
|
});
|
||||||
|
await this.#sendCard(chatId, sessionListCard(workspace, sessions, safePage, sessions.length), { key });
|
||||||
|
} catch (error) {
|
||||||
|
this.#logger.warn?.('[dsh-feishu] session list failed:', error.message);
|
||||||
|
await this.#send(chatId, '暂时无法获取会话列表,请稍后重试。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #showWorkspaces({ chatId, key }) {
|
||||||
|
try {
|
||||||
|
const { current, paths } = await workspacePathSnapshot(this.#harness);
|
||||||
|
this.#rememberMenu(key, { kind: 'workspaces', paths });
|
||||||
|
await this.#sendCard(chatId, workspaceListCard(paths, current), { key });
|
||||||
|
} catch (error) {
|
||||||
|
this.#logger.warn?.('[dsh-feishu] workspace list failed:', error.message);
|
||||||
|
await this.#send(chatId, '暂时无法获取工作区列表,请稍后重试。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #bindSession(key, chatId, sessionId) {
|
||||||
|
try {
|
||||||
|
const bound = await this.#harness.bindWorkspaceSession(key, sessionId);
|
||||||
|
const title = String(bound?.title ?? '').replace(/\s+/gu, ' ').trim() || '暂无标题';
|
||||||
|
await this.#send(chatId, `已绑定会话「${title}」\nID:${bound?.sessionId ?? sessionId}`);
|
||||||
|
} catch (error) {
|
||||||
|
await this.#send(chatId, `绑定失败:${safeErrorText(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #switchWorkspace(key, chatId, workspace) {
|
||||||
|
try {
|
||||||
|
const current = await this.#harness.switchWorkspace(workspace);
|
||||||
|
await this.#send(chatId, `工作区已切换为:${current}`);
|
||||||
|
} catch (error) {
|
||||||
|
await this.#send(chatId, `切换失败:${safeErrorText(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #sendCard(chatId, cardJson, options = {}) {
|
||||||
|
const response = await this.#client.im.v1.message.create({
|
||||||
|
params: { receive_id_type: 'chat_id' },
|
||||||
|
data: { receive_id: chatId, msg_type: 'interactive', content: cardJson },
|
||||||
|
});
|
||||||
|
if (response?.code && response.code !== 0) {
|
||||||
|
throw new Error(`Feishu card send failed: ${response.msg || response.code}`);
|
||||||
|
}
|
||||||
|
const messageId = nonEmptyString(response?.data?.message_id);
|
||||||
|
if (options.key && messageId) {
|
||||||
|
this.#cardKeys.set(messageId, { key: options.key, chatId });
|
||||||
|
if (this.#cardKeys.size > 200) {
|
||||||
|
const oldest = this.#cardKeys.keys().next().value;
|
||||||
|
if (oldest !== undefined) this.#cardKeys.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messageId;
|
||||||
|
}
|
||||||
|
|
||||||
#interactionAskOptions(event, key) {
|
#interactionAskOptions(event, key) {
|
||||||
return {
|
return {
|
||||||
timeoutMs: this.#replyTimeoutMs,
|
timeoutMs: this.#replyTimeoutMs,
|
||||||
|
|
|
||||||
109
src/channels/feishu/feishu-cards.mjs
Normal file
109
src/channels/feishu/feishu-cards.mjs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
/**
|
||||||
|
* Feishu interactive-card builders for the dsh-im menu / session-list /
|
||||||
|
* workspace-list UX. All builders return the JSON string the
|
||||||
|
* `im.message.create` API expects as `content` for `msg_type: interactive`
|
||||||
|
* (card schema 2.0; buttons are plain body elements — the `action` container
|
||||||
|
* is gone in V2).
|
||||||
|
*
|
||||||
|
* Buttons carry a small `{ action }` value object that `card.action.trigger`
|
||||||
|
* events echo back (when the app subscribes that event); every button also
|
||||||
|
* carries a numeric label so the number-reply fallback stays usable without
|
||||||
|
* button callbacks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MENU_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
function plainText(content) {
|
||||||
|
return { tag: 'plain_text', content: String(content) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdown(content) {
|
||||||
|
return { tag: 'lark_md', content: String(content) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(content, actionValue) {
|
||||||
|
return {
|
||||||
|
tag: 'button',
|
||||||
|
text: plainText(content),
|
||||||
|
type: 'default',
|
||||||
|
value: { action: actionValue },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeTitle(value) {
|
||||||
|
const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
||||||
|
return title || '暂无标题';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardWith(headerText, elements) {
|
||||||
|
return JSON.stringify({
|
||||||
|
schema: '2.0',
|
||||||
|
header: { title: plainText(headerText), template: 'blue' },
|
||||||
|
body: { elements },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The main command menu (buttons + number-reply fallback). */
|
||||||
|
export function menuCard() {
|
||||||
|
return cardWith('🤖 助手菜单', [
|
||||||
|
{ tag: 'div', text: markdown('**点击按钮或直接回复数字**') },
|
||||||
|
button('1 · 会话列表', 'sessions'),
|
||||||
|
button('2 · 工作区', 'workspaces'),
|
||||||
|
button('3 · 新会话', 'new'),
|
||||||
|
button('4 · 状态', 'status'),
|
||||||
|
button('5 · 帮助', 'help'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of the workspace's sessions. Each row is a bind button; the
|
||||||
|
* number label equals the reply-number for the same action (fallback).
|
||||||
|
*/
|
||||||
|
export function sessionListCard(workspace, sessions, page, total) {
|
||||||
|
const start = page * MENU_PAGE_SIZE;
|
||||||
|
const slice = sessions.slice(start, start + MENU_PAGE_SIZE);
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / MENU_PAGE_SIZE));
|
||||||
|
const elements = [
|
||||||
|
{ tag: 'div', text: markdown(`**工作区**:\`${workspace}\`\n共 **${total}** 个会话${total > MENU_PAGE_SIZE ? `(第 ${page + 1}/${pageCount} 页)` : ''}`) },
|
||||||
|
...slice.map((session, offset) => button(
|
||||||
|
`${start + offset + 1}. ${safeTitle(session.title)}`,
|
||||||
|
`use:${session.sessionId}`,
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`));
|
||||||
|
if (page + 1 < pageCount) elements.push(button('下一页 ▶', `sessions:${page + 1}`));
|
||||||
|
elements.push({ tag: 'div', text: markdown('回复数字(1~N)同样可以绑定本页会话。') });
|
||||||
|
return cardWith('📂 会话列表', elements);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The workspace list card (switch-workspace buttons + reply fallback). */
|
||||||
|
export function workspaceListCard(paths, current) {
|
||||||
|
const elements = paths.length === 0
|
||||||
|
? [{ tag: 'div', text: markdown('当前 Host 上没有已登记的工作区。') }]
|
||||||
|
: [
|
||||||
|
{ tag: 'div', text: markdown(`回复数字切换工作区,或点击按钮:`) },
|
||||||
|
...paths.map((path, index) => button(
|
||||||
|
`${index + 1}. ${path}${path === current ? '(当前)' : ''}`,
|
||||||
|
`workspace:${path}`,
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
return cardWith('🗂 工作区', elements);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The card-menu help text (number-driven, no command memorization). */
|
||||||
|
export function menuHelpText() {
|
||||||
|
return [
|
||||||
|
'🤖 助手菜单(回复数字即可,无需记命令)',
|
||||||
|
'',
|
||||||
|
'1 · /sessionlist 列出会话(回复数字绑定)',
|
||||||
|
'2 · /workspacelist 列出工作区(回复数字切换)',
|
||||||
|
'3 · /new 开启新会话',
|
||||||
|
'4 · /status 连接状态',
|
||||||
|
'5 · /help 本帮助',
|
||||||
|
'',
|
||||||
|
'直接发送文字/图片即继续当前会话。',
|
||||||
|
'/session ID 或序号 绑定已有会话',
|
||||||
|
'/compact 压缩上下文',
|
||||||
|
'/workspace 绝对路径 切换工作区',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
@ -178,6 +178,13 @@ export class FeishuRuntime {
|
||||||
},
|
},
|
||||||
'im.message.reaction.created_v1': () => ({}),
|
'im.message.reaction.created_v1': () => ({}),
|
||||||
'im.message.reaction.deleted_v1': () => ({}),
|
'im.message.reaction.deleted_v1': () => ({}),
|
||||||
|
// Interactive-card button callbacks (only delivered when the app
|
||||||
|
// subscribes card.action.trigger; the number-reply fallback covers
|
||||||
|
// apps that do not).
|
||||||
|
'card.action.trigger': (event) => {
|
||||||
|
this.#bridge.onCardAction(event);
|
||||||
|
return {};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let settleReady;
|
let settleReady;
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ export class MultiBotDshFeishuController {
|
||||||
addons: {
|
addons: {
|
||||||
preset: false,
|
preset: false,
|
||||||
scopes: { tenant: [...REQUIRED_TENANT_SCOPES] },
|
scopes: { tenant: [...REQUIRED_TENANT_SCOPES] },
|
||||||
events: { items: { tenant: ['im.message.receive_v1'] } },
|
events: { items: { tenant: ['im.message.receive_v1', 'card.action.trigger'] } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.#touch();
|
this.#touch();
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ export class DshFeishuController {
|
||||||
addons: {
|
addons: {
|
||||||
preset: false,
|
preset: false,
|
||||||
scopes: { tenant: [...REQUIRED_TENANT_SCOPES] },
|
scopes: { tenant: [...REQUIRED_TENANT_SCOPES] },
|
||||||
events: { items: { tenant: ['im.message.receive_v1'] } },
|
events: { items: { tenant: ['im.message.receive_v1', 'card.action.trigger'] } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return this.status();
|
return this.status();
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ async function selectedWorkspacePath(value) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function workspacePathSnapshot(harness) {
|
export async function workspacePathSnapshot(harness) {
|
||||||
const listed = await harness.listWorkspaces();
|
const listed = await harness.listWorkspaces();
|
||||||
const currentValue = typeof harness?.currentWorkspace === 'function'
|
const currentValue = typeof harness?.currentWorkspace === 'function'
|
||||||
? harness.currentWorkspace()
|
? harness.currentWorkspace()
|
||||||
|
|
@ -148,7 +148,7 @@ async function runWorkspaceListCommand(match, harness) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveSessionListWorkspace(selector, harness) {
|
export async function resolveSessionListWorkspace(selector, harness) {
|
||||||
if (!selector) {
|
if (!selector) {
|
||||||
if (typeof harness?.currentWorkspace !== 'function') {
|
if (typeof harness?.currentWorkspace !== 'function') {
|
||||||
return { error: '当前机器人没有可用的工作区。' };
|
return { error: '当前机器人没有可用的工作区。' };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
|
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
|
||||||
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
|
||||||
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
|
||||||
|
|
@ -1871,3 +1874,143 @@ test('bridge does not expose internal error details in a Feishu failure reply',
|
||||||
assert.doesNotMatch(sent[0], /secret-shaped-internal-detail|private\/path/);
|
assert.doesNotMatch(sent[0], /secret-shaped-internal-detail|private\/path/);
|
||||||
assert.equal(status.lastError, 'secret-shaped-internal-detail /private/path');
|
assert.equal(status.lastError, 'secret-shaped-internal-detail /private/path');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Interactive cards: menus, session lists, workspace lists ───────────────
|
||||||
|
|
||||||
|
function cardClient(onSend) {
|
||||||
|
let sequence = 0;
|
||||||
|
return {
|
||||||
|
im: { v1: { message: { create: async (request) => {
|
||||||
|
const outgoing = {
|
||||||
|
chatId: request.data.receive_id,
|
||||||
|
msgType: request.data.msg_type,
|
||||||
|
content: request.data.msg_type === 'interactive'
|
||||||
|
? JSON.parse(request.data.content)
|
||||||
|
: request.data.content,
|
||||||
|
};
|
||||||
|
await onSend(outgoing);
|
||||||
|
sequence += 1;
|
||||||
|
return { code: 0, data: { message_id: `om_card_${sequence}` } };
|
||||||
|
} } } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardActionEvent(messageId, action, operatorOpenId) {
|
||||||
|
return {
|
||||||
|
operator: { operator_id: { open_id: operatorOpenId } },
|
||||||
|
action: { value: { action } },
|
||||||
|
context: { open_message_id: messageId },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useActionsFromCard(content) {
|
||||||
|
const values = [];
|
||||||
|
for (const element of content.body.elements) {
|
||||||
|
if (element.tag === 'button' && typeof element.value?.action === 'string' && element.value.action.startsWith('use:')) {
|
||||||
|
values.push(element.value.action.slice('use:'.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionsHarness(count) {
|
||||||
|
const workspace = join(tmpdir(), 'dsh-im-card-test-work');
|
||||||
|
mkdirSync(workspace, { recursive: true });
|
||||||
|
const sessions = Array.from({ length: count }, (_, index) => ({
|
||||||
|
sessionId: `session-${String(index + 1).padStart(2, '0')}`,
|
||||||
|
title: `Session ${index + 1}`,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
ensureRunning: async () => true,
|
||||||
|
currentWorkspace: () => workspace,
|
||||||
|
listWorkspaceSessions: async () => ({ workspace, sessions }),
|
||||||
|
listWorkspaces: async () => [workspace],
|
||||||
|
bindWorkspaceSession: async (_key, sessionId) => ({ sessionId, title: `Session ${sessionId}` }),
|
||||||
|
switchWorkspace: async (path) => path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('card buttons from an unallowed sender are ignored', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
const sent = [];
|
||||||
|
const bridge = new FeishuHarnessBridge({
|
||||||
|
client: cardClient(async ({ chatId, msgType, content }) => {
|
||||||
|
sent.push({ chatId, msgType, content });
|
||||||
|
}),
|
||||||
|
channel: {},
|
||||||
|
harness: sessionsHarness(3),
|
||||||
|
state: fixture.state,
|
||||||
|
status: bridgeStatus(),
|
||||||
|
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(event('menu-open', '/m', { senderOpenId: 'ou_owner' }));
|
||||||
|
await bridge.waitForIdle();
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
assert.equal(fixture.sessions.size, 0);
|
||||||
|
|
||||||
|
// A group member outside the allowlist clicks "new session" on the card.
|
||||||
|
await bridge.onCardAction(cardActionEvent('om_card_1', 'new', 'ou_evil'));
|
||||||
|
await bridge.waitForIdle();
|
||||||
|
assert.equal(fixture.sessions.size, 0, 'unallowed card operator must not act');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('card buttons from an allowed sender work', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
const sent = [];
|
||||||
|
const bridge = new FeishuHarnessBridge({
|
||||||
|
client: cardClient(async ({ chatId, msgType, content }) => {
|
||||||
|
sent.push({ chatId, msgType, content });
|
||||||
|
}),
|
||||||
|
channel: {},
|
||||||
|
harness: sessionsHarness(3),
|
||||||
|
state: fixture.state,
|
||||||
|
status: bridgeStatus(),
|
||||||
|
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(event('menu-open-2', '/m', { senderOpenId: 'ou_owner' }));
|
||||||
|
await bridge.waitForIdle();
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
|
||||||
|
await bridge.onCardAction(cardActionEvent('om_card_1', 'new', 'ou_owner'));
|
||||||
|
await bridge.waitForIdle();
|
||||||
|
assert.equal(sent.length, 2, 'allowed operator click should send a reply');
|
||||||
|
});
|
||||||
|
|
||||||
|
function cards(messages) { return messages.filter((m) => m.msgType === 'interactive'); }
|
||||||
|
|
||||||
|
test('session list paginates by page number across 25 sessions', async () => {
|
||||||
|
const fixture = stateFixture();
|
||||||
|
const sent = [];
|
||||||
|
const bridge = new FeishuHarnessBridge({
|
||||||
|
client: cardClient(async ({ chatId, msgType, content }) => {
|
||||||
|
sent.push({ chatId, msgType, content });
|
||||||
|
}),
|
||||||
|
channel: {},
|
||||||
|
harness: sessionsHarness(25),
|
||||||
|
state: fixture.state,
|
||||||
|
status: bridgeStatus(),
|
||||||
|
allowedSenderOpenIds: new Set(['ou_owner']),
|
||||||
|
});
|
||||||
|
|
||||||
|
await bridge.accept(event('sessions-open', '/sessionlist', { senderOpenId: 'ou_owner' }));
|
||||||
|
await bridge.waitForIdle();
|
||||||
|
assert.equal(cards(sent).length, 1);
|
||||||
|
const page0 = cards(sent).at(-1).content;
|
||||||
|
assert.equal(useActionsFromCard(page0).length, 10);
|
||||||
|
assert.equal(useActionsFromCard(page0)[0], 'session-01');
|
||||||
|
|
||||||
|
// Button on page 0 asks for page 2 (zero-based page number).
|
||||||
|
await bridge.onCardAction(cardActionEvent('om_card_1', 'sessions:2', 'ou_owner'));
|
||||||
|
await bridge.waitForIdle();
|
||||||
|
const page2 = cards(sent).at(-1).content;
|
||||||
|
assert.equal(useActionsFromCard(page2).length, 5);
|
||||||
|
assert.equal(useActionsFromCard(page2)[0], 'session-21', 'page 2 must start at the 21st session (no double page scaling)');
|
||||||
|
|
||||||
|
await bridge.onCardAction(cardActionEvent('om_card_2', 'sessions:1', 'ou_owner'));
|
||||||
|
await bridge.waitForIdle();
|
||||||
|
const page1 = cards(sent).at(-1).content;
|
||||||
|
assert.equal(useActionsFromCard(page1).length, 10);
|
||||||
|
assert.equal(useActionsFromCard(page1)[0], 'session-11');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ test('QR success stores the secret off-config and becomes immediately chat-ready
|
||||||
await flush();
|
await flush();
|
||||||
assert.equal(fx.getSdkOptions().createOnly, true);
|
assert.equal(fx.getSdkOptions().createOnly, true);
|
||||||
assert.equal(fx.getSdkOptions().addons.preset, false);
|
assert.equal(fx.getSdkOptions().addons.preset, false);
|
||||||
assert.deepEqual(fx.getSdkOptions().addons.events.items.tenant, ['im.message.receive_v1']);
|
assert.deepEqual(fx.getSdkOptions().addons.events.items.tenant, ['im.message.receive_v1', 'card.action.trigger']);
|
||||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message.p2p_msg:readonly'));
|
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message.p2p_msg:readonly'));
|
||||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:readonly'));
|
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:readonly'));
|
||||||
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:send_as_bot'));
|
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:send_as_bot'));
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue