feat(feishu): /archived on|off toggle for archived sessions in lists

The session list (and the numeric /watch index) honors a per-bot persisted
policy: /archived off hides archived sessions from cards and index
resolution, /archived on restores them. Defaults to on.
This commit is contained in:
liuwenbo00 2026-08-21 10:27:43 +08:00 • committed by xmanrui
parent 5055ffe6c8
commit 961dd5503d
4 changed files with 203 additions and 112 deletions

File diff suppressed because one or more lines are too long

View file

@ -92,9 +92,12 @@ const HELP_TEXT = [
'/watch [Session ID 或序号] 关注会话,任务完成自动推送',
'/unwatch [Session ID 或序号] 取消关注',
'/watchlist 查看关注列表',
'/archived on|off 会话列表是否包含归档会话',
'/help 显示本帮助',
].join('\n');
const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
/** Safe user-facing text for bind/workspace failures (no raw messages). */
function safeErrorText(error) {
switch (error?.code) {
@ -642,6 +645,22 @@ export class FeishuHarnessBridge {
await this.#showWatchList(key, event.message.chat_id);
return;
}
if (ARCHIVED_COMMAND.test(commandText)) {
const match = ARCHIVED_COMMAND.exec(commandText);
const value = match[1]?.toLowerCase();
if (value !== 'on' && value !== 'off') {
await this.#send(event.message.chat_id, '用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)');
return;
}
if (typeof this.#state?.setIncludeArchivedSessions === 'function') {
await this.#state.setIncludeArchivedSessions(value === 'on');
}
await this.#send(
event.message.chat_id,
value === 'on' ? '已开启:会话列表包含归档会话。' : '已关闭:会话列表隐藏归档会话。',
);
return;
}
if (NUMBER_REPLY.test(commandText)) {
const menu = this.#takeMenu(key);
if (menu) {
@ -1139,6 +1158,14 @@ export class FeishuHarnessBridge {
}
}
/** The sessions visible under the bot's archived policy. */
#visibleSessions(sessions) {
if (this.#state?.includesArchivedSessions?.() === false) {
return sessions.filter((session) => session.archived !== true);
}
return sessions;
}
async #showSessions({ chatId, key }, selector, page = 0) {
try {
const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness);
@ -1147,7 +1174,7 @@ export class FeishuHarnessBridge {
return;
}
const listed = await this.#harness.listWorkspaceSessions(resolved.workspace);
const sessions = Array.isArray(listed?.sessions) ? listed.sessions : [];
const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
const workspace = listed?.workspace ?? resolved.workspace;
if (sessions.length === 0) {
await this.#send(chatId, `工作区:${workspace}\n该工作区暂无会话。`);
@ -1270,7 +1297,7 @@ export class FeishuHarnessBridge {
};
if (numeric !== null) {
if (!currentPath) return { error: '当前机器人没有可用的工作区,无法按序号解析会话。' };
const sessions = await listSessions(currentPath);
const sessions = this.#visibleSessions(await listSessions(currentPath));
const session = sessions[numeric - 1];
if (!session?.sessionId) {
return { error: `当前工作区只有 ${sessions.length} 个会话。` };

View file

@ -1,7 +1,14 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
const EMPTY_STATE = Object.freeze({ version: 1, sessions: {}, seenMessageIds: [], watches: {}, chatTargets: {} });
const EMPTY_STATE = Object.freeze({
version: 1,
sessions: {},
seenMessageIds: [],
watches: {},
chatTargets: {},
includeArchivedSessions: true,
});
/** One conversation key may watch at most this many sessions. */
export const MAX_WATCHES_PER_KEY = 20;
@ -32,6 +39,9 @@ export class StateStore {
seenMessageIds: Array.isArray(parsed.seenMessageIds) ? parsed.seenMessageIds.slice(-1000) : [],
watches: parsed.watches && typeof parsed.watches === 'object' ? parsed.watches : {},
chatTargets: parsed.chatTargets && typeof parsed.chatTargets === 'object' ? parsed.chatTargets : {},
includeArchivedSessions: typeof parsed.includeArchivedSessions === 'boolean'
? parsed.includeArchivedSessions
: true,
};
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
@ -148,6 +158,17 @@ export class StateStore {
await this.#persist();
}
// ── Session-list archived policy (per bot) ───────────────────────────────
includesArchivedSessions() {
return this.#state.includeArchivedSessions !== false;
}
async setIncludeArchivedSessions(include) {
this.#state.includeArchivedSessions = include !== false;
await this.#persist();
}
async #persist() {
const snapshot = JSON.stringify(this.#state, null, 2) + '\n';
this.#writeQueue = this.#writeQueue.then(async () => {

View file

@ -2543,3 +2543,46 @@ test('reconnect compensation replays missed turn/end and dedups duplicates', asy
assert.equal(cards.length, 2, 'exactly one new completion from compensation (t1 deduped)');
assert.match(JSON.stringify(cards[1]), /watched-session/);
});
test('/archived off hides archived sessions from the list and watch index', async () => {
const { state } = await watchStoreFixture();
const work = join(tmpdir(), 'dsh-im-archived-test-work');
mkdirSync(work, { recursive: true });
const harness = watchHarness({
current: work,
sessionsByWorkspace: {
[work]: [
{ sessionId: 'live-session', title: 'Live', archived: false },
{ sessionId: 'old-session', title: 'Old', archived: true },
],
},
});
const sent = [];
const cards = [];
const bridge = new FeishuHarnessBridge({
client: cardClient(async ({ msgType, content }) => {
if (msgType === 'interactive') cards.push(content);
}),
channel: {},
harness,
state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
});
bridge._sent = sent;
await bridge.accept(event('archived-off', '/archived off', { senderOpenId: 'ou_owner' }));
await bridge.waitForIdle();
assert.equal(state.includesArchivedSessions(), false);
await bridge.accept(event('sessions-arch', '/sessionlist', { senderOpenId: 'ou_owner' }));
await bridge.waitForIdle();
const use = useActionsFromCard(cards.at(-1));
assert.deepEqual(use, ['live-session'], 'archived session hidden');
// The numeric watch index must resolve against the filtered list too.
await bridge.accept(event('watch-arch', '/watch 1', { senderOpenId: 'ou_owner' }));
await bridge.waitForIdle();
assert.ok(state.watchEntry('p2p:ou_owner', 'live-session'));
assert.equal(state.watchEntry('p2p:ou_owner', 'old-session'), null);
});