mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-18 13:50:52 +08:00
feat(feishu): interactive card menus and session/workspace list cards
- /m (or /menu) opens a card menu; /sessionlist and /workspacelist render cards with bind/switch buttons; number replies stay usable as a fallback when the app does not subscribe card.action.trigger. - card.action.trigger callbacks validate the operator's open_id against the allowed senders: group members outside the allowlist can never drive binding, workspace switches or other card actions. - Session-list pagination uses page numbers everywhere (buttons carry sessions:<page>), fixing the previous double page-size scaling that skipped pages past 20 sessions. - Bind/workspace failures map to safe user-facing messages instead of raw error details. - Apps registered after this change subscribe card.action.trigger during the scan flow.
This commit is contained in:
parent
832bd539a2
commit
6366404c42
9 changed files with 642 additions and 137 deletions
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');
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue