feat(feishu): session-list rows as V2 column pairs (fixed star toggle + session button)

Each session row is a column_set with a fixed 90px watch-toggle column
(⭐关注/⭐取关 for already-watched sessions) and a weighted session button
carrying the number label; number replies bind the session. Replaces the
stacked button pair; also honors the per-bot archived-session policy.
This commit is contained in:
liuwenbo00 2026-08-21 11:29:35 +08:00 • committed by xmanrui
parent 1790ec21ac
commit 4e325c62e0
4 changed files with 195 additions and 145 deletions

View file

@ -1048,7 +1048,15 @@ export class FeishuHarnessBridge {
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();
if (!entry) {
// The card predates this process (the in-memory mapping resets on
// restart) or never came from us: nudge instead of staying silent.
const chatId = nonEmptyString(event?.context?.open_chat_id);
if (chatId) {
this.#send(chatId, '这个菜单已过期,请回复 /m 重新打开。').catch(() => undefined);
}
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) => {
@ -1140,6 +1148,7 @@ export class FeishuHarnessBridge {
await this.#send(chatId, `本页只有 ${menu.sessions.length} 个会话,回复 /sessionlist 重新查看。`);
return;
}
// The number label sits on the session (bind) button of the row.
await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key });
return;
}
@ -1186,16 +1195,24 @@ export class FeishuHarnessBridge {
}
const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE);
const safePage = Number.isSafeInteger(page) && page > 0 ? Math.min(page, pageCount - 1) : 0;
const watchedSet = new Set(
(this.#state.watchEntries?.(key) ?? []).map((entry) => entry.sessionId),
);
const pageSlice = sessions.slice(safePage * MENU_PAGE_SIZE, (safePage + 1) * MENU_PAGE_SIZE);
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,
// Keep the canonical selector result for later page callbacks. The
// list response's workspace is display data and is not authoritative.
sessionWorkspace: resolved.workspace,
sessions: pageSlice.map((session) => ({ ...session, watched: watchedSet.has(session.sessionId) })),
});
await this.#sendCard(
chatId,
sessionListCard(workspace, sessions, safePage, sessions.length, watchedSet),
{
key,
// Keep the canonical selector result for later page callbacks. The
// list response's workspace is display data and is not authoritative.
sessionWorkspace: resolved.workspace,
},
);
} catch (error) {
this.#logger.warn?.('[dsh-feishu] session list failed:', error.message);
await this.#send(chatId, '暂时无法获取会话列表,请稍后重试。');

View file

@ -4,10 +4,14 @@
* `im.message.create` API expects as `content` for `msg_type: interactive`
* (card schema 2.0; callback buttons live inside a column_set/column layout).
*
* Buttons carry a small `{ action }` callback behavior that
* `card.action.trigger` events echo back (when the app subscribes that
* callback); every button also carries a numeric label so the number-reply
* fallback stays usable without button callbacks.
* The session list lays each row out as a `column_set` (fixed-width ⭐
* watch-toggle column + weighted session-button column), which is how V2
* expresses a row of buttons.
*
* Buttons carry a small `{ action }` value object that `card.action.trigger`
* events echo back (when the app subscribes that event); every numbered
* button also has a numeric label so the number-reply fallback stays usable
* without button callbacks.
*/
export const MENU_PAGE_SIZE = 10;
@ -39,6 +43,17 @@ function button(content, actionValue) {
};
}
/** The raw button element (without the full-width column_set wrapper). */
function buttonElement(content, actionValue) {
return {
tag: 'button',
text: plainText(content),
type: 'default',
width: 'fill',
behaviors: [{ type: 'callback', 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 || '暂无标题';
@ -102,29 +117,43 @@ export function cardActionProbeCard(nonce) {
}
/**
* One page of the workspace's sessions. Each session renders as a bind
* button plus a watch button (schema 2.0: buttons are top-level body
* elements, so the pair is two adjacent buttons); the number label equals
* the reply-number for the bind action (fallback). Archived sessions are
* marked in the label.
* One page of the workspace's sessions. Each row is a `column_set` pair:
* the fixed-width ⭐ watch toggle (`⭐关注` / `⭐取关` for already-watched
* sessions) followed by the session button that carries the page-local
* number label (reply-number fallback = bind). Archived sessions are marked
* in the label. `watchedSessionIds` is a Set-like of ids this conversation
* already watches.
*/
export function sessionListCard(workspace, sessions, page, total) {
export function sessionListCard(workspace, sessions, page, total, watchedSessionIds = new Set()) {
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 watched = (id) => typeof watchedSessionIds?.has === 'function' && watchedSessionIds.has(id);
/** One row: fixed 90px watch toggle + the session button filling the rest. */
const row = (watchButton, sessionButton) => ({
tag: 'column_set',
flex_mode: 'none',
horizontal_spacing: 'default',
columns: [
{ tag: 'column', width: '90px', vertical_align: 'center', elements: [watchButton] },
{ tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center', elements: [sessionButton] },
],
});
const elements = [
{ tag: 'div', text: markdown(`**工作区**:\`${workspace}\`\n共 **${total}** 个会话${total > MENU_PAGE_SIZE ? `(第 ${page + 1}/${pageCount} 页)` : ''}`) },
...slice.flatMap((session, offset) => {
...slice.map((session, offset) => {
// Page-local numbering: number replies resolve against this page.
const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? '(已归档)' : ''}`;
return [
button(label, `use:${session.sessionId}`),
button('👁 关注', `watch:${session.sessionId}`),
];
const watching = watched(session.sessionId);
return row(
buttonElement(watching ? '⭐取关' : '⭐关注', watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
buttonElement(label, `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)同样可以绑定本页会话。') });
elements.push({ tag: 'div', text: markdown('回复数字(1~N)绑定本页会话。') });
return cardWith('📂 会话列表', elements);
}