@xmanrui/dsh-im 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +132 -131
- package/package.json +1 -1
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +367 -10
- package/src/channels/feishu/feishu-cards.mjs +87 -12
- package/src/channels/feishu/state-store.mjs +85 -1
- package/src/channels/shared/harness-client.mjs +119 -0
- package/src/channels/weixin/weixin-api.mjs +1 -1
package/package.json
CHANGED
|
@@ -108,7 +108,7 @@ export class DiscordApi {
|
|
|
108
108
|
headers: {
|
|
109
109
|
authorization: `Bot ${this.#token}`,
|
|
110
110
|
'content-type': 'application/json',
|
|
111
|
-
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.
|
|
111
|
+
'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.17.0)',
|
|
112
112
|
},
|
|
113
113
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
114
114
|
signal: requestSignal(signal, timeoutMs),
|
|
@@ -32,11 +32,14 @@ import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot
|
|
|
32
32
|
import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
|
|
33
33
|
import {
|
|
34
34
|
MENU_PAGE_SIZE,
|
|
35
|
+
completionCard,
|
|
35
36
|
menuCard,
|
|
36
37
|
menuHelpText,
|
|
37
38
|
sessionListCard,
|
|
39
|
+
watchListCard,
|
|
38
40
|
workspaceListCard,
|
|
39
41
|
} from './feishu-cards.mjs';
|
|
42
|
+
import { MAX_WATCHES_PER_KEY } from './state-store.mjs';
|
|
40
43
|
|
|
41
44
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
42
45
|
const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
|
@@ -44,6 +47,9 @@ const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
|
|
|
44
47
|
const MENU_COMMAND = /^\/m(?:enu)?$/i;
|
|
45
48
|
const REPAIR_COMMAND_PREFIX = /^\/repair(?:\s|$)/i;
|
|
46
49
|
const REPAIR_COMMAND = /^\/repair(?:\s+(qr|status|cancel|verify))?\s*$/i;
|
|
50
|
+
const WATCH_COMMAND = /^\/watch(?:\s+([^\s]+))?$/i;
|
|
51
|
+
const UNWATCH_COMMAND = /^\/unwatch(?:\s+([^\s]+))?$/i;
|
|
52
|
+
const WATCHLIST_COMMAND = /^\/watchlist$/i;
|
|
47
53
|
const SESSION_LIST_PREFIX = /^\/sessionlist(?:\s|$)/i;
|
|
48
54
|
const WORKSPACE_LIST_COMMAND = /^\/workspacelist$/i;
|
|
49
55
|
const NUMBER_REPLY = /^\d{1,2}$/;
|
|
@@ -83,9 +89,15 @@ const HELP_TEXT = [
|
|
|
83
89
|
'/status 检查连接状态',
|
|
84
90
|
'/repair 修复卡片按钮回调',
|
|
85
91
|
'/m(或 /menu) 打开交互卡片菜单',
|
|
92
|
+
'/watch [Session ID 或序号] 关注会话,任务完成自动推送',
|
|
93
|
+
'/unwatch [Session ID 或序号] 取消关注',
|
|
94
|
+
'/watchlist 查看关注列表',
|
|
95
|
+
'/archived on|off 会话列表是否包含归档会话',
|
|
86
96
|
'/help 显示本帮助',
|
|
87
97
|
].join('\n');
|
|
88
98
|
|
|
99
|
+
const ARCHIVED_COMMAND = /^\/archived(?:\s+(on|off))?$/i;
|
|
100
|
+
|
|
89
101
|
/** Safe user-facing text for bind/workspace failures (no raw messages). */
|
|
90
102
|
function safeErrorText(error) {
|
|
91
103
|
switch (error?.code) {
|
|
@@ -106,6 +118,13 @@ function nonEmptyString(value) {
|
|
|
106
118
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
107
119
|
}
|
|
108
120
|
|
|
121
|
+
function orderedHistoryEvents(history) {
|
|
122
|
+
return (Array.isArray(history?.events) ? history.events : [])
|
|
123
|
+
.map((entry) => entry?.event ?? entry)
|
|
124
|
+
.filter((entry) => entry && typeof entry === 'object' && Number.isFinite(entry.seq))
|
|
125
|
+
.sort((left, right) => left.seq - right.seq);
|
|
126
|
+
}
|
|
127
|
+
|
|
109
128
|
function senderOpenId(event) {
|
|
110
129
|
return nonEmptyString(event?.sender?.sender_id?.open_id)
|
|
111
130
|
?? nonEmptyString(event?.sender?.sender_id?.user_id);
|
|
@@ -240,6 +259,12 @@ export class FeishuHarnessBridge {
|
|
|
240
259
|
#menus = new Map();
|
|
241
260
|
/** Interactive-card message id → route context for button callbacks. */
|
|
242
261
|
#cardKeys = new Map();
|
|
262
|
+
/** The global event-mux watcher (one per bridge). */
|
|
263
|
+
#eventWatcher = null;
|
|
264
|
+
/** Serializes live completions and reconnect compensation. */
|
|
265
|
+
#eventTail = Promise.resolve();
|
|
266
|
+
/** Earliest completion that still needs delivery for each watch. */
|
|
267
|
+
#failedWatchSeqs = new Map();
|
|
243
268
|
|
|
244
269
|
constructor({
|
|
245
270
|
client,
|
|
@@ -295,6 +320,11 @@ export class FeishuHarnessBridge {
|
|
|
295
320
|
this.#approvals = new HarnessApprovalQueue({ label: 'Feishu', logger });
|
|
296
321
|
this.#signal = signal;
|
|
297
322
|
ensureStatus(this.#status);
|
|
323
|
+
// Persisted watches must resume at runtime start, not on the first
|
|
324
|
+
// message. Older hosts without the mux watcher simply skip this.
|
|
325
|
+
if (typeof this.#harness?.watchHarnessEvents === 'function') {
|
|
326
|
+
queueMicrotask(() => this.#ensureEventWatcher());
|
|
327
|
+
}
|
|
298
328
|
}
|
|
299
329
|
|
|
300
330
|
accept(event) {
|
|
@@ -519,6 +549,7 @@ export class FeishuHarnessBridge {
|
|
|
519
549
|
)),
|
|
520
550
|
...this.#interactionTasks,
|
|
521
551
|
...this.#commandTasks,
|
|
552
|
+
this.#eventTail,
|
|
522
553
|
]);
|
|
523
554
|
}
|
|
524
555
|
|
|
@@ -604,6 +635,36 @@ export class FeishuHarnessBridge {
|
|
|
604
635
|
await this.#showWorkspaces({ chatId: event.message.chat_id, key });
|
|
605
636
|
return;
|
|
606
637
|
}
|
|
638
|
+
if (WATCH_COMMAND.test(commandText)) {
|
|
639
|
+
const target = (WATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
|
|
640
|
+
await this.#runWatch(key, event.message.chat_id, target);
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (UNWATCH_COMMAND.test(commandText)) {
|
|
644
|
+
const target = (UNWATCH_COMMAND.exec(commandText)?.[1] ?? '').trim() || null;
|
|
645
|
+
await this.#runUnwatch(key, event.message.chat_id, target);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (WATCHLIST_COMMAND.test(commandText)) {
|
|
649
|
+
await this.#showWatchList(key, event.message.chat_id);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (ARCHIVED_COMMAND.test(commandText)) {
|
|
653
|
+
const match = ARCHIVED_COMMAND.exec(commandText);
|
|
654
|
+
const value = match[1]?.toLowerCase();
|
|
655
|
+
if (value !== 'on' && value !== 'off') {
|
|
656
|
+
await this.#send(event.message.chat_id, '用法:/archived on(包含归档会话)或 /archived off(隐藏归档会话)');
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
if (typeof this.#state?.setIncludeArchivedSessions === 'function') {
|
|
660
|
+
await this.#state.setIncludeArchivedSessions(value === 'on');
|
|
661
|
+
}
|
|
662
|
+
await this.#send(
|
|
663
|
+
event.message.chat_id,
|
|
664
|
+
value === 'on' ? '已开启:会话列表包含归档会话。' : '已关闭:会话列表隐藏归档会话。',
|
|
665
|
+
);
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
607
668
|
if (NUMBER_REPLY.test(commandText)) {
|
|
608
669
|
const menu = this.#takeMenu(key);
|
|
609
670
|
if (menu) {
|
|
@@ -991,7 +1052,15 @@ export class FeishuHarnessBridge {
|
|
|
991
1052
|
if (!action) return Promise.resolve();
|
|
992
1053
|
const messageId = nonEmptyString(event?.context?.open_message_id);
|
|
993
1054
|
const entry = messageId ? this.#cardKeys.get(messageId) : null;
|
|
994
|
-
if (!entry)
|
|
1055
|
+
if (!entry) {
|
|
1056
|
+
// The card predates this process (the in-memory mapping resets on
|
|
1057
|
+
// restart) or never came from us: nudge instead of staying silent.
|
|
1058
|
+
const chatId = nonEmptyString(event?.context?.open_chat_id);
|
|
1059
|
+
if (chatId) {
|
|
1060
|
+
this.#send(chatId, '这个菜单已过期,请回复 /m 重新打开。').catch(() => undefined);
|
|
1061
|
+
}
|
|
1062
|
+
return Promise.resolve();
|
|
1063
|
+
}
|
|
995
1064
|
// The promise is returned so tests (and future callers) can await the
|
|
996
1065
|
// action; the runtime dispatcher ignores it.
|
|
997
1066
|
return this.#handleCardAction(action, entry).catch((error) => {
|
|
@@ -1009,6 +1078,10 @@ export class FeishuHarnessBridge {
|
|
|
1009
1078
|
await this.#showWorkspaces({ chatId, key });
|
|
1010
1079
|
return;
|
|
1011
1080
|
}
|
|
1081
|
+
if (action === 'watchlist') {
|
|
1082
|
+
await this.#showWatchList(key, chatId);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1012
1085
|
if (action === 'new') {
|
|
1013
1086
|
await this.#state.clearSession(key);
|
|
1014
1087
|
await this.#send(chatId, '已开启全新 Harness 会话。');
|
|
@@ -1029,6 +1102,14 @@ export class FeishuHarnessBridge {
|
|
|
1029
1102
|
}
|
|
1030
1103
|
if (action.startsWith('workspace:')) {
|
|
1031
1104
|
await this.#switchWorkspace(key, chatId, action.slice('workspace:'.length));
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (action.startsWith('unwatch:')) {
|
|
1108
|
+
await this.#runUnwatch(key, chatId, action.slice('unwatch:'.length));
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (action.startsWith('watch:')) {
|
|
1112
|
+
await this.#runWatch(key, chatId, action.slice('watch:'.length));
|
|
1032
1113
|
}
|
|
1033
1114
|
}
|
|
1034
1115
|
|
|
@@ -1053,7 +1134,7 @@ export class FeishuHarnessBridge {
|
|
|
1053
1134
|
|
|
1054
1135
|
async #handleMenuPick(menu, number, { chatId, key, event }) {
|
|
1055
1136
|
if (menu.kind === 'menu') {
|
|
1056
|
-
const action = ['sessions', 'workspaces', 'new', 'status', 'help', 'repair'][number - 1];
|
|
1137
|
+
const action = ['sessions', 'workspaces', 'new', 'status', 'help', 'repair', 'watchlist'][number - 1];
|
|
1057
1138
|
if (!action) {
|
|
1058
1139
|
await this.#send(chatId, '菜单没有这个编号,回复 /m 重新打开。');
|
|
1059
1140
|
return;
|
|
@@ -1071,6 +1152,7 @@ export class FeishuHarnessBridge {
|
|
|
1071
1152
|
await this.#send(chatId, `本页只有 ${menu.sessions.length} 个会话,回复 /sessionlist 重新查看。`);
|
|
1072
1153
|
return;
|
|
1073
1154
|
}
|
|
1155
|
+
// The number label sits on the session (bind) button of the row.
|
|
1074
1156
|
await this.#handleCardAction(`use:${session.sessionId}`, { chatId, key });
|
|
1075
1157
|
return;
|
|
1076
1158
|
}
|
|
@@ -1081,9 +1163,26 @@ export class FeishuHarnessBridge {
|
|
|
1081
1163
|
return;
|
|
1082
1164
|
}
|
|
1083
1165
|
await this.#handleCardAction(`workspace:${workspace}`, { chatId, key });
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
if (menu.kind === 'watches') {
|
|
1169
|
+
const entry = menu.entries[number - 1];
|
|
1170
|
+
if (!entry?.sessionId) {
|
|
1171
|
+
await this.#send(chatId, `关注列表只有 ${menu.entries.length} 个会话。`);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
await this.#handleCardAction(`unwatch:${entry.sessionId}`, { chatId, key });
|
|
1084
1175
|
}
|
|
1085
1176
|
}
|
|
1086
1177
|
|
|
1178
|
+
/** The sessions visible under the bot's archived policy. */
|
|
1179
|
+
#visibleSessions(sessions) {
|
|
1180
|
+
if (this.#state?.includesArchivedSessions?.() === false) {
|
|
1181
|
+
return sessions.filter((session) => session.archived !== true);
|
|
1182
|
+
}
|
|
1183
|
+
return sessions;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1087
1186
|
async #showSessions({ chatId, key }, selector, page = 0) {
|
|
1088
1187
|
try {
|
|
1089
1188
|
const resolved = await resolveSessionListWorkspace(selector ?? '', this.#harness);
|
|
@@ -1092,7 +1191,7 @@ export class FeishuHarnessBridge {
|
|
|
1092
1191
|
return;
|
|
1093
1192
|
}
|
|
1094
1193
|
const listed = await this.#harness.listWorkspaceSessions(resolved.workspace);
|
|
1095
|
-
const sessions = Array.isArray(listed?.sessions) ? listed.sessions : [];
|
|
1194
|
+
const sessions = this.#visibleSessions(Array.isArray(listed?.sessions) ? listed.sessions : []);
|
|
1096
1195
|
const workspace = listed?.workspace ?? resolved.workspace;
|
|
1097
1196
|
if (sessions.length === 0) {
|
|
1098
1197
|
await this.#send(chatId, `工作区:${workspace}\n该工作区暂无会话。`);
|
|
@@ -1100,16 +1199,24 @@ export class FeishuHarnessBridge {
|
|
|
1100
1199
|
}
|
|
1101
1200
|
const pageCount = Math.ceil(sessions.length / MENU_PAGE_SIZE);
|
|
1102
1201
|
const safePage = Number.isSafeInteger(page) && page > 0 ? Math.min(page, pageCount - 1) : 0;
|
|
1202
|
+
const watchedSet = new Set(
|
|
1203
|
+
(this.#state.watchEntries?.(key) ?? []).map((entry) => entry.sessionId),
|
|
1204
|
+
);
|
|
1205
|
+
const pageSlice = sessions.slice(safePage * MENU_PAGE_SIZE, (safePage + 1) * MENU_PAGE_SIZE);
|
|
1103
1206
|
this.#rememberMenu(key, {
|
|
1104
1207
|
kind: 'sessions',
|
|
1105
|
-
sessions:
|
|
1106
|
-
});
|
|
1107
|
-
await this.#sendCard(chatId, sessionListCard(workspace, sessions, safePage, sessions.length), {
|
|
1108
|
-
key,
|
|
1109
|
-
// Keep the canonical selector result for later page callbacks. The
|
|
1110
|
-
// list response's workspace is display data and is not authoritative.
|
|
1111
|
-
sessionWorkspace: resolved.workspace,
|
|
1208
|
+
sessions: pageSlice.map((session) => ({ ...session, watched: watchedSet.has(session.sessionId) })),
|
|
1112
1209
|
});
|
|
1210
|
+
await this.#sendCard(
|
|
1211
|
+
chatId,
|
|
1212
|
+
sessionListCard(workspace, sessions, safePage, sessions.length, watchedSet),
|
|
1213
|
+
{
|
|
1214
|
+
key,
|
|
1215
|
+
// Keep the canonical selector result for later page callbacks. The
|
|
1216
|
+
// list response's workspace is display data and is not authoritative.
|
|
1217
|
+
sessionWorkspace: resolved.workspace,
|
|
1218
|
+
},
|
|
1219
|
+
);
|
|
1113
1220
|
} catch (error) {
|
|
1114
1221
|
this.#logger.warn?.('[dsh-feishu] session list failed:', error.message);
|
|
1115
1222
|
await this.#send(chatId, '暂时无法获取会话列表,请稍后重试。');
|
|
@@ -1171,6 +1278,256 @@ export class FeishuHarnessBridge {
|
|
|
1171
1278
|
return messageId;
|
|
1172
1279
|
}
|
|
1173
1280
|
|
|
1281
|
+
// ── Watches: read-only session tracking + completion pushes ─────────────
|
|
1282
|
+
|
|
1283
|
+
#ensureEventWatcher() {
|
|
1284
|
+
if (this.#eventWatcher) return;
|
|
1285
|
+
if (typeof this.#harness?.watchHarnessEvents !== 'function') return;
|
|
1286
|
+
if (this.#signal?.aborted) return;
|
|
1287
|
+
const signal = this.#signal ?? new AbortController().signal;
|
|
1288
|
+
try {
|
|
1289
|
+
this.#eventWatcher = this.#harness.watchHarnessEvents({
|
|
1290
|
+
signal,
|
|
1291
|
+
onSessionEvent: (payload) => this.#onHarnessEvent(payload),
|
|
1292
|
+
onReconnect: () => {
|
|
1293
|
+
void this.#queueEventTask(() => this.#compensateMissedEvents());
|
|
1294
|
+
},
|
|
1295
|
+
});
|
|
1296
|
+
Promise.resolve(this.#eventWatcher).catch((error) => {
|
|
1297
|
+
if (!signal.aborted) {
|
|
1298
|
+
this.#logger.warn?.('[dsh-feishu] event watcher stopped:', error.message);
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
this.#eventWatcher = null;
|
|
1303
|
+
this.#logger.warn?.('[dsh-feishu] event watcher failed to start:', error.message);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
#queueEventTask(task) {
|
|
1308
|
+
const next = this.#eventTail.then(task, task).catch((error) => {
|
|
1309
|
+
if (!this.#signal?.aborted) {
|
|
1310
|
+
this.#logger.warn?.('[dsh-feishu] completion event failed:', error.message);
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
this.#eventTail = next;
|
|
1314
|
+
return next;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
/**
|
|
1318
|
+
* Resolve a /watch target READ-ONLY: a session id is validated against
|
|
1319
|
+
* the registered workspaces' listings, an index against the current
|
|
1320
|
+
* workspace. Nothing is bound and no workspace is switched.
|
|
1321
|
+
*/
|
|
1322
|
+
async #resolveWatchTarget(target) {
|
|
1323
|
+
if (typeof target !== 'string' || target === '') {
|
|
1324
|
+
return { error: '用法:/watch <Session ID 或当前工作区序号>' };
|
|
1325
|
+
}
|
|
1326
|
+
const numeric = /^\d{1,4}$/.test(target) ? Number(target) : null;
|
|
1327
|
+
const currentPath = typeof this.#harness?.currentWorkspace === 'function'
|
|
1328
|
+
? this.#harness.currentWorkspace()
|
|
1329
|
+
: null;
|
|
1330
|
+
const listSessions = async (workspace) => {
|
|
1331
|
+
const listed = await this.#harness.listWorkspaceSessions(workspace);
|
|
1332
|
+
return Array.isArray(listed?.sessions) ? listed.sessions : [];
|
|
1333
|
+
};
|
|
1334
|
+
if (numeric !== null) {
|
|
1335
|
+
if (!currentPath) return { error: '当前机器人没有可用的工作区,无法按序号解析会话。' };
|
|
1336
|
+
const sessions = this.#visibleSessions(await listSessions(currentPath));
|
|
1337
|
+
const session = sessions[numeric - 1];
|
|
1338
|
+
if (!session?.sessionId) {
|
|
1339
|
+
return { error: `当前工作区只有 ${sessions.length} 个会话。` };
|
|
1340
|
+
}
|
|
1341
|
+
return { sessionId: session.sessionId, title: session.title ?? '暂无标题' };
|
|
1342
|
+
}
|
|
1343
|
+
const extraPaths = typeof this.#harness?.listWorkspaces === 'function'
|
|
1344
|
+
? (await this.#harness.listWorkspaces()).filter((path) => path !== currentPath)
|
|
1345
|
+
: [];
|
|
1346
|
+
const paths = [currentPath, ...extraPaths].filter(Boolean);
|
|
1347
|
+
for (const workspace of paths) {
|
|
1348
|
+
const sessions = await listSessions(workspace);
|
|
1349
|
+
const session = sessions.find((candidate) => candidate.sessionId === target);
|
|
1350
|
+
if (session) return { sessionId: target, title: session.title ?? '暂无标题' };
|
|
1351
|
+
}
|
|
1352
|
+
return { error: '没有找到这个会话,请用 /sessionlist 查看可用会话。' };
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
async #latestSessionSeq(sessionId) {
|
|
1356
|
+
if (typeof this.#harness?.rpc !== 'function') return null;
|
|
1357
|
+
const history = await this.#harness.rpc(
|
|
1358
|
+
'session.history',
|
|
1359
|
+
{ sessionId, maxMessages: 20 },
|
|
1360
|
+
30_000,
|
|
1361
|
+
{ signal: this.#signal },
|
|
1362
|
+
);
|
|
1363
|
+
return orderedHistoryEvents(history).at(-1)?.seq ?? -1;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
async #runWatch(key, chatId, target) {
|
|
1367
|
+
this.#ensureEventWatcher();
|
|
1368
|
+
if (typeof this.#state?.setWatch !== 'function') {
|
|
1369
|
+
await this.#send(chatId, '当前状态存储不支持关注。');
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
let resolved;
|
|
1373
|
+
try {
|
|
1374
|
+
resolved = await this.#resolveWatchTarget(target);
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
await this.#send(chatId, `无法解析会话:${safeErrorText(error)}`);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
if (resolved.error) {
|
|
1380
|
+
await this.#send(chatId, resolved.error);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
const existing = this.#state.watchEntries?.(key) ?? [];
|
|
1384
|
+
const existingEntry = existing.find((entry) => entry.sessionId === resolved.sessionId);
|
|
1385
|
+
if (!existingEntry && existing.length >= MAX_WATCHES_PER_KEY) {
|
|
1386
|
+
await this.#send(chatId, `每个聊天最多关注 ${MAX_WATCHES_PER_KEY} 个会话。`);
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
try {
|
|
1390
|
+
const lastSeq = typeof existingEntry?.lastSeq === 'number'
|
|
1391
|
+
? existingEntry.lastSeq
|
|
1392
|
+
: await this.#latestSessionSeq(resolved.sessionId);
|
|
1393
|
+
await this.#state.setWatch(key, {
|
|
1394
|
+
sessionId: resolved.sessionId,
|
|
1395
|
+
title: resolved.title,
|
|
1396
|
+
chatId,
|
|
1397
|
+
lastSeq,
|
|
1398
|
+
});
|
|
1399
|
+
await this.#send(chatId, `已关注会话「${String(resolved.title).replace(/\s+/gu, ' ')}」,任务完成会推送结果。`);
|
|
1400
|
+
await this.#queueEventTask(() => this.#compensateSession(resolved.sessionId));
|
|
1401
|
+
} catch (error) {
|
|
1402
|
+
await this.#send(chatId, `关注失败:${safeErrorText(error)}`);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
async #runUnwatch(key, chatId, target) {
|
|
1407
|
+
if (typeof this.#state?.removeWatch !== 'function') return;
|
|
1408
|
+
const entries = this.#state.watchEntries?.(key) ?? [];
|
|
1409
|
+
const entry = typeof target === 'string' && /^\d{1,4}$/.test(target)
|
|
1410
|
+
? entries[Number(target) - 1]
|
|
1411
|
+
: entries.find((candidate) => candidate.sessionId === target);
|
|
1412
|
+
if (!entry) {
|
|
1413
|
+
await this.#send(chatId, '关注列表里没有这个会话,回复 /watchlist 查看。');
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
try {
|
|
1417
|
+
await this.#state.removeWatch(key, entry.sessionId);
|
|
1418
|
+
this.#failedWatchSeqs.delete(`${key}\0${entry.sessionId}`);
|
|
1419
|
+
await this.#send(chatId, `已取消关注「${String(entry.title ?? '').replace(/\s+/gu, ' ')}」。`);
|
|
1420
|
+
} catch (error) {
|
|
1421
|
+
await this.#send(chatId, `取消失败:${safeErrorText(error)}`);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
async #showWatchList(key, chatId) {
|
|
1426
|
+
const entries = this.#state.watchEntries?.(key) ?? [];
|
|
1427
|
+
this.#rememberMenu(key, { kind: 'watches', entries });
|
|
1428
|
+
await this.#sendCard(chatId, watchListCard(entries), { key });
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/** Queue live turn completions behind any reconnect compensation. */
|
|
1432
|
+
#onHarnessEvent({ sessionId, event }) {
|
|
1433
|
+
if (this.#signal?.aborted
|
|
1434
|
+
|| !sessionId
|
|
1435
|
+
|| !event
|
|
1436
|
+
|| typeof event !== 'object'
|
|
1437
|
+
|| event.type !== 'turn/end'
|
|
1438
|
+
|| !Number.isFinite(event.seq)) return;
|
|
1439
|
+
void this.#queueEventTask(async () => {
|
|
1440
|
+
const hasFailedDelivery = (this.#state.keysWatching?.(sessionId) ?? [])
|
|
1441
|
+
.some((key) => this.#failedWatchSeqs.has(`${key}\0${sessionId}`));
|
|
1442
|
+
if (hasFailedDelivery) await this.#compensateSession(sessionId);
|
|
1443
|
+
await this.#deliverCompletion(sessionId, event);
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
async #deliverCompletion(sessionId, event) {
|
|
1448
|
+
if (this.#signal?.aborted || typeof this.#state?.keysWatching !== 'function') return;
|
|
1449
|
+
const reason = event?.data?.reason?.kind ?? event?.data?.reason ?? null;
|
|
1450
|
+
for (const key of this.#state.keysWatching(sessionId)) {
|
|
1451
|
+
if (this.#signal?.aborted) return;
|
|
1452
|
+
const entry = this.#state.watchEntry?.(key, sessionId);
|
|
1453
|
+
const deliveryKey = `${key}\0${sessionId}`;
|
|
1454
|
+
let failedSeq = this.#failedWatchSeqs.get(deliveryKey);
|
|
1455
|
+
if (typeof failedSeq === 'number'
|
|
1456
|
+
&& typeof entry?.lastSeq === 'number'
|
|
1457
|
+
&& entry.lastSeq >= failedSeq) {
|
|
1458
|
+
this.#failedWatchSeqs.delete(deliveryKey);
|
|
1459
|
+
failedSeq = undefined;
|
|
1460
|
+
}
|
|
1461
|
+
if (!entry?.chatId
|
|
1462
|
+
|| (typeof entry.lastSeq === 'number' && entry.lastSeq >= event.seq)
|
|
1463
|
+
|| (typeof failedSeq === 'number' && event.seq > failedSeq)) continue;
|
|
1464
|
+
try {
|
|
1465
|
+
await this.#sendCard(
|
|
1466
|
+
entry.chatId,
|
|
1467
|
+
completionCard(sessionId, entry.title, reason),
|
|
1468
|
+
{ key },
|
|
1469
|
+
);
|
|
1470
|
+
const current = this.#state.watchEntry?.(key, sessionId);
|
|
1471
|
+
if (!current
|
|
1472
|
+
|| current.chatId !== entry.chatId
|
|
1473
|
+
|| (typeof current.lastSeq === 'number' && current.lastSeq >= event.seq)) continue;
|
|
1474
|
+
await this.#state.setWatch(key, { ...current, lastSeq: event.seq });
|
|
1475
|
+
if (failedSeq === event.seq) this.#failedWatchSeqs.delete(deliveryKey);
|
|
1476
|
+
} catch (error) {
|
|
1477
|
+
this.#failedWatchSeqs.set(
|
|
1478
|
+
deliveryKey,
|
|
1479
|
+
typeof failedSeq === 'number' ? Math.min(failedSeq, event.seq) : event.seq,
|
|
1480
|
+
);
|
|
1481
|
+
this.#logger.warn?.('[dsh-feishu] completion push failed:', error.message);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
async #compensateSession(sessionId) {
|
|
1487
|
+
if (this.#signal?.aborted || typeof this.#harness?.rpc !== 'function') return;
|
|
1488
|
+
try {
|
|
1489
|
+
const history = await this.#harness.rpc(
|
|
1490
|
+
'session.history',
|
|
1491
|
+
{ sessionId, maxMessages: 20 },
|
|
1492
|
+
30_000,
|
|
1493
|
+
{ signal: this.#signal },
|
|
1494
|
+
);
|
|
1495
|
+
const events = orderedHistoryEvents(history);
|
|
1496
|
+
const latestSeq = events.at(-1)?.seq ?? -1;
|
|
1497
|
+
const keys = typeof this.#state?.keysWatching === 'function'
|
|
1498
|
+
? this.#state.keysWatching(sessionId)
|
|
1499
|
+
: [];
|
|
1500
|
+
|
|
1501
|
+
// Watches created by older versions have no baseline. Establish one
|
|
1502
|
+
// without replaying completions that predate the watch.
|
|
1503
|
+
for (const key of keys) {
|
|
1504
|
+
const entry = this.#state.watchEntry?.(key, sessionId);
|
|
1505
|
+
if (entry && typeof entry.lastSeq !== 'number') {
|
|
1506
|
+
await this.#state.setWatch(key, { ...entry, lastSeq: latestSeq });
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
for (const event of events) {
|
|
1511
|
+
if (event.type === 'turn/end') await this.#deliverCompletion(sessionId, event);
|
|
1512
|
+
}
|
|
1513
|
+
} catch (error) {
|
|
1514
|
+
if (!this.#signal?.aborted) {
|
|
1515
|
+
this.#logger.warn?.(`[dsh-feishu] watch compensation failed for ${sessionId}:`, error.message);
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** Replay recent turn completions missed while the mux was disconnected. */
|
|
1521
|
+
async #compensateMissedEvents() {
|
|
1522
|
+
const sessionIds = typeof this.#state?.watchedSessionIds === 'function'
|
|
1523
|
+
? this.#state.watchedSessionIds()
|
|
1524
|
+
: [];
|
|
1525
|
+
for (const sessionId of sessionIds) {
|
|
1526
|
+
if (this.#signal?.aborted) return;
|
|
1527
|
+
await this.#compensateSession(sessionId);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1174
1531
|
#interactionAskOptions(event, key) {
|
|
1175
1532
|
return {
|
|
1176
1533
|
timeoutMs: this.#replyTimeoutMs,
|
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
* `im.message.create` API expects as `content` for `msg_type: interactive`
|
|
5
5
|
* (card schema 2.0; callback buttons live inside a column_set/column layout).
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* The session list lays each row out as a `column_set` (fixed-width ⭐
|
|
8
|
+
* watch-toggle column + weighted session-button column), which is how V2
|
|
9
|
+
* expresses a row of buttons.
|
|
10
|
+
*
|
|
11
|
+
* Buttons carry a small `{ action }` value object that `card.action.trigger`
|
|
12
|
+
* events echo back (when the app subscribes that event); every numbered
|
|
13
|
+
* button also has a numeric label so the number-reply fallback stays usable
|
|
14
|
+
* without button callbacks.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
export const MENU_PAGE_SIZE = 10;
|
|
@@ -39,6 +43,17 @@ function button(content, actionValue) {
|
|
|
39
43
|
};
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
/** The raw button element (without the full-width column_set wrapper). */
|
|
47
|
+
function buttonElement(content, actionValue) {
|
|
48
|
+
return {
|
|
49
|
+
tag: 'button',
|
|
50
|
+
text: plainText(content),
|
|
51
|
+
type: 'default',
|
|
52
|
+
width: 'fill',
|
|
53
|
+
behaviors: [{ type: 'callback', value: { action: actionValue } }],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
function safeTitle(value) {
|
|
43
58
|
const title = String(value ?? '').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]+/gu, ' ').replace(/\s+/gu, ' ').trim();
|
|
44
59
|
return title || '暂无标题';
|
|
@@ -65,6 +80,7 @@ export function menuCard() {
|
|
|
65
80
|
// have card.action.trigger yet, so rendering it as a callback button would
|
|
66
81
|
// send the user straight back to Feishu's broken callback setup popup.
|
|
67
82
|
{ tag: 'div', text: markdown('**6 · 修复卡片按钮**(请直接回复数字 **6**)') },
|
|
83
|
+
button('7 · 关注列表', 'watchlist'),
|
|
68
84
|
]);
|
|
69
85
|
}
|
|
70
86
|
|
|
@@ -101,23 +117,43 @@ export function cardActionProbeCard(nonce) {
|
|
|
101
117
|
}
|
|
102
118
|
|
|
103
119
|
/**
|
|
104
|
-
* One page of the workspace's sessions. Each row is a
|
|
105
|
-
*
|
|
120
|
+
* One page of the workspace's sessions. Each row is a `column_set` pair:
|
|
121
|
+
* the fixed-width ⭐ watch toggle (`⭐关注` / `⭐取关` for already-watched
|
|
122
|
+
* sessions) followed by the session button that carries the page-local
|
|
123
|
+
* number label (reply-number fallback = bind). Archived sessions are marked
|
|
124
|
+
* in the label. `watchedSessionIds` is a Set-like of ids this conversation
|
|
125
|
+
* already watches.
|
|
106
126
|
*/
|
|
107
|
-
export function sessionListCard(workspace, sessions, page, total) {
|
|
127
|
+
export function sessionListCard(workspace, sessions, page, total, watchedSessionIds = new Set()) {
|
|
108
128
|
const start = page * MENU_PAGE_SIZE;
|
|
109
129
|
const slice = sessions.slice(start, start + MENU_PAGE_SIZE);
|
|
110
130
|
const pageCount = Math.max(1, Math.ceil(total / MENU_PAGE_SIZE));
|
|
131
|
+
const watched = (id) => typeof watchedSessionIds?.has === 'function' && watchedSessionIds.has(id);
|
|
132
|
+
/** One row: fixed 90px watch toggle + the session button filling the rest. */
|
|
133
|
+
const row = (watchButton, sessionButton) => ({
|
|
134
|
+
tag: 'column_set',
|
|
135
|
+
flex_mode: 'none',
|
|
136
|
+
horizontal_spacing: 'default',
|
|
137
|
+
columns: [
|
|
138
|
+
{ tag: 'column', width: '90px', vertical_align: 'center', elements: [watchButton] },
|
|
139
|
+
{ tag: 'column', width: 'weighted', weight: 1, vertical_align: 'center', elements: [sessionButton] },
|
|
140
|
+
],
|
|
141
|
+
});
|
|
111
142
|
const elements = [
|
|
112
143
|
{ tag: 'div', text: markdown(`**工作区**:\`${workspace}\`\n共 **${total}** 个会话${total > MENU_PAGE_SIZE ? `(第 ${page + 1}/${pageCount} 页)` : ''}`) },
|
|
113
|
-
...slice.map((session, offset) =>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
144
|
+
...slice.map((session, offset) => {
|
|
145
|
+
// Page-local numbering: number replies resolve against this page.
|
|
146
|
+
const label = `${offset + 1}. ${safeTitle(session.title)}${session.archived === true ? '(已归档)' : ''}`;
|
|
147
|
+
const watching = watched(session.sessionId);
|
|
148
|
+
return row(
|
|
149
|
+
buttonElement(watching ? '⭐取关' : '⭐关注', watching ? `unwatch:${session.sessionId}` : `watch:${session.sessionId}`),
|
|
150
|
+
buttonElement(label, `use:${session.sessionId}`),
|
|
151
|
+
);
|
|
152
|
+
}),
|
|
117
153
|
];
|
|
118
154
|
if (page > 0) elements.push(button('◀ 上一页', `sessions:${page - 1}`));
|
|
119
155
|
if (page + 1 < pageCount) elements.push(button('下一页 ▶', `sessions:${page + 1}`));
|
|
120
|
-
elements.push({ tag: 'div', text: markdown('回复数字(1~N
|
|
156
|
+
elements.push({ tag: 'div', text: markdown('回复数字(1~N)绑定本页会话。') });
|
|
121
157
|
return cardWith('📂 会话列表', elements);
|
|
122
158
|
}
|
|
123
159
|
|
|
@@ -146,10 +182,49 @@ export function menuHelpText() {
|
|
|
146
182
|
'4 · /status 连接状态',
|
|
147
183
|
'5 · /help 本帮助',
|
|
148
184
|
'6 · /repair 修复卡片按钮(请回复数字 6)',
|
|
185
|
+
'7 · /watchlist 关注列表',
|
|
149
186
|
'',
|
|
150
187
|
'直接发送文字/图片即继续当前会话。',
|
|
151
188
|
'/session ID 或序号 绑定已有会话',
|
|
189
|
+
'/watch ID 或序号 关注会话(完成后推送)',
|
|
152
190
|
'/compact 压缩上下文',
|
|
153
191
|
'/workspace 绝对路径 切换工作区',
|
|
154
192
|
].join('\n');
|
|
155
193
|
}
|
|
194
|
+
|
|
195
|
+
/** The watch list for one conversation (unwatch buttons + reply fallback). */
|
|
196
|
+
export function watchListCard(entries) {
|
|
197
|
+
const elements = entries.length === 0
|
|
198
|
+
? [{ tag: 'div', text: markdown('当前没有关注的会话。\n`/watch <ID|序号>` 关注后,任务完成会自动推送。') }]
|
|
199
|
+
: [
|
|
200
|
+
{ tag: 'div', text: markdown('任务完成会自动推送,回复数字或点按钮取消关注:') },
|
|
201
|
+
...entries.map((entry, index) => button(
|
|
202
|
+
`${index + 1}. ${safeTitle(entry.title)}`,
|
|
203
|
+
`unwatch:${entry.sessionId}`,
|
|
204
|
+
)),
|
|
205
|
+
];
|
|
206
|
+
return cardWith('👁 关注列表', elements);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The completion push card. `title` is the session title, `reason` the
|
|
211
|
+
* turn-end kind (completed / stopped / aborted).
|
|
212
|
+
*/
|
|
213
|
+
export function completionCard(sessionId, title, reason) {
|
|
214
|
+
const reasonText = reason === 'completed'
|
|
215
|
+
? '已完成'
|
|
216
|
+
: reason === 'stopped'
|
|
217
|
+
? '已停止'
|
|
218
|
+
: reason === 'aborted'
|
|
219
|
+
? '已中止'
|
|
220
|
+
: reason === 'cancelled'
|
|
221
|
+
? '已取消'
|
|
222
|
+
: '已结束';
|
|
223
|
+
return cardWith('✅ 任务完成', [
|
|
224
|
+
{ tag: 'div', text: markdown(`**${safeTitle(title)}**\n\`${sessionId}\``) },
|
|
225
|
+
{ tag: 'div', text: markdown(`**状态**:${reasonText}`) },
|
|
226
|
+
button('打开会话列表', 'sessions'),
|
|
227
|
+
button('工作区', 'workspaces'),
|
|
228
|
+
{ tag: 'div', text: markdown('绑定该会话后可继续追问,输入文字即可。') },
|
|
229
|
+
]);
|
|
230
|
+
}
|