@xmanrui/dsh-im 0.16.0 → 0.17.1
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/client.js +33 -0
- package/lib/index.js +138 -137
- package/package.json +1 -1
- package/plugin-src/client/channels/weixin/api.js +10 -0
- package/plugin-src/client/channels/weixin/index.js +4 -0
- package/plugin-src/client/i18n.js +20 -0
- 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/shared/image-prompt.mjs +32 -1
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +23 -3
- package/src/channels/weixin/weixin-controller.mjs +15 -0
package/package.json
CHANGED
|
@@ -45,6 +45,15 @@ function normalizeTestMessage(value) {
|
|
|
45
45
|
return { sent: false, code };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function normalizeMessageError(value) {
|
|
49
|
+
if (!isRecord(value)) return null;
|
|
50
|
+
const code = string(value.code).slice(0, 64);
|
|
51
|
+
const reason = string(value.reason).slice(0, 128);
|
|
52
|
+
const message = string(value.message).slice(0, 500);
|
|
53
|
+
const at = timestamp(value.at);
|
|
54
|
+
return code && reason && message && at !== null ? { code, reason, message, at } : null;
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
export function unwrapRpcResult(result) {
|
|
49
58
|
if (!isRecord(result) || typeof result.ok !== 'boolean') {
|
|
50
59
|
throw new Error('微信服务返回了无法识别的响应');
|
|
@@ -128,6 +137,7 @@ function normalizeBot(value) {
|
|
|
128
137
|
messagesReceived: Math.max(0, Number(value.stats?.messagesReceived) || 0),
|
|
129
138
|
messagesReplied: Math.max(0, Number(value.stats?.messagesReplied) || 0),
|
|
130
139
|
},
|
|
140
|
+
lastMessageError: normalizeMessageError(value.lastMessageError),
|
|
131
141
|
error: isRecord(value.error)
|
|
132
142
|
? {
|
|
133
143
|
code: string(value.error.code, 'WEIXIN_ACCOUNT_ERROR'),
|
|
@@ -230,6 +230,10 @@ export function AccountCard({
|
|
|
230
230
|
}),
|
|
231
231
|
h('div', { className: 'dxw-accountFooter dim-cardFooter' },
|
|
232
232
|
summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
|
|
233
|
+
account.lastMessageError ? h('div', {
|
|
234
|
+
className: 'dxw-summary dim-cardSummary',
|
|
235
|
+
role: 'status',
|
|
236
|
+
}, `最近一条消息处理失败:${account.lastMessageError.message}`) : null,
|
|
233
237
|
feedback ? h('div', {
|
|
234
238
|
className: 'dxw-summary dim-cardSummary',
|
|
235
239
|
role: 'status',
|
|
@@ -449,6 +449,20 @@ const EN = Object.freeze({
|
|
|
449
449
|
'微信绑定没有完成': 'WeChat setup did not complete',
|
|
450
450
|
'微信连接正常': 'WeChat connection is healthy',
|
|
451
451
|
'微信连接未就绪': 'WeChat connection is not ready',
|
|
452
|
+
'当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。': 'The current model does not support images. Use /models to list models, then /model <number> to switch and resend.',
|
|
453
|
+
'图片超过宿主允许的大小,请压缩后重试。': 'The image exceeds the Host size limit. Compress it and try again.',
|
|
454
|
+
'图片分辨率过高,请压缩后重试。': 'The image resolution is too high. Compress it and try again.',
|
|
455
|
+
'图片内容无效或格式不受支持,请重新发送。': 'The image is invalid or unsupported. Send it again.',
|
|
456
|
+
'未能读取图片内容,请重新发送。': 'The image could not be read. Send it again.',
|
|
457
|
+
'图片格式与实际内容不一致,请重新发送。': 'The declared image format does not match its content. Send it again.',
|
|
458
|
+
'一次发送的图片数量超过宿主限制,请减少后重试。': 'The message exceeds the Host image-count limit. Remove some images and try again.',
|
|
459
|
+
'图片总大小超过宿主限制,请减少图片或压缩后重试。': 'The images exceed the Host total-size limit. Remove or compress some images and try again.',
|
|
460
|
+
'图片下载地址发生了重定向,暂时无法读取。': 'The image download redirected and cannot be read.',
|
|
461
|
+
'图片超过 5 MB,请压缩后重试。': 'The image exceeds 5 MB. Compress it and try again.',
|
|
462
|
+
'一次发送的图片总大小过大,请减少图片数量或压缩后重试。': 'The images are too large in total. Remove or compress some images and try again.',
|
|
463
|
+
'图片下载失败,请重新发送后再试。': 'The image download failed. Send it again.',
|
|
464
|
+
'暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。': 'This image format is unsupported. Send a JPEG, PNG, WebP, or GIF image.',
|
|
465
|
+
'消息处理失败,请稍后重试。': 'Message processing failed. Try again later.',
|
|
452
466
|
'微信服务没有返回有效的账号列表': 'WeChat did not return a valid account list',
|
|
453
467
|
'尚未绑定微信': 'No WeChat account connected yet',
|
|
454
468
|
'用于把微信机器人绑定到 DeepSeek Harness 的一次性二维码': 'One-time QR code for connecting a WeChat bot to DeepSeek Harness',
|
|
@@ -522,6 +536,12 @@ function translateDynamic(text) {
|
|
|
522
536
|
}
|
|
523
537
|
match = /^二维码剩余 (.+)$/.exec(text);
|
|
524
538
|
if (match) return `QR code expires in ${match[1]}`;
|
|
539
|
+
match = /^最近一条消息处理失败:(.+)$/.exec(text);
|
|
540
|
+
if (match) return `Latest message failed: ${localizeText(match[1])}`;
|
|
541
|
+
match = /^图片下载失败(HTTP (.+)),请重新发送后再试。$/.exec(text);
|
|
542
|
+
if (match) return `The image download failed (HTTP ${match[1]}). Send it again.`;
|
|
543
|
+
match = /^一次最多只能处理 (\d+) 张图片。$/.exec(text);
|
|
544
|
+
if (match) return `A message can contain at most ${match[1]} images.`;
|
|
525
545
|
match = /^状态刷新失败:(.+)$/.exec(text);
|
|
526
546
|
if (match) return `Status refresh failed: ${match[1]}`;
|
|
527
547
|
match = /^状态自动刷新失败:(.+)$/.exec(text);
|
|
@@ -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.1)',
|
|
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,
|