@rezti/dsh-rez-wechat 0.1.21 → 0.1.23
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/web-shim.d.ts +18 -0
- package/lib/web-shim.js +137 -16
- package/lib/wecom-channel.d.ts +11 -1
- package/lib/wecom-channel.js +78 -8
- package/package.json +1 -1
package/lib/web-shim.d.ts
CHANGED
|
@@ -74,6 +74,8 @@ export declare function ensureWechatInboxAgents(cwd: string): void;
|
|
|
74
74
|
export declare function sessionStorePath(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
75
75
|
export declare function loadSessionStore(path: string): SessionStore;
|
|
76
76
|
export declare function saveSessionStore(path: string, store: SessionStore): void;
|
|
77
|
+
/** Drop the sticky session pointer for one room/chat key (WeCom restart / stuck recovery). */
|
|
78
|
+
export declare function clearSessionStoreKey(path: string, key: string): void;
|
|
77
79
|
/** dsh 0.1.5+ wire endpoint is `namespace/method` (slash), not `namespace.method`. */
|
|
78
80
|
export declare function normalizeRpcEndpoint(method: string): string;
|
|
79
81
|
/**
|
|
@@ -104,6 +106,19 @@ export declare function parseChoice(userText: string, questions: PendingQuestion
|
|
|
104
106
|
label: string;
|
|
105
107
|
} | undefined;
|
|
106
108
|
export declare function historyEvents(body: unknown): unknown[];
|
|
109
|
+
/** Durable seq, or seq0 for batched frames like text-chunks. Missing → -1. */
|
|
110
|
+
export declare function eventSeq(event: unknown): number;
|
|
111
|
+
export declare function historyCursor(events: unknown[]): number;
|
|
112
|
+
/**
|
|
113
|
+
* True when a turn that started *after* beforeSeq has ended.
|
|
114
|
+
* Events without seq are always included (unit fixtures / legacy history).
|
|
115
|
+
*/
|
|
116
|
+
export declare function turnSettledAfter(events: unknown[], beforeSeq: number): boolean;
|
|
117
|
+
/**
|
|
118
|
+
* Visible assistant text for WeChat.
|
|
119
|
+
* Web UI streams via text-chunks; final assistant/message is often tool-only.
|
|
120
|
+
* Prefer longer streamed chunks when they supersede a short mid-turn message.
|
|
121
|
+
*/
|
|
107
122
|
export declare function lastAssistantText(events: unknown[]): string;
|
|
108
123
|
export declare function turnIsIdle(events: unknown[]): boolean;
|
|
109
124
|
export declare function pickWechatModel(models: unknown, env?: NodeJS.ProcessEnv): ModelRef | undefined;
|
|
@@ -130,4 +145,7 @@ export declare function runHeadlessViaWeb(opts: {
|
|
|
130
145
|
env?: NodeJS.ProcessEnv;
|
|
131
146
|
rpc?: RpcFn;
|
|
132
147
|
timeoutMs?: number;
|
|
148
|
+
signal?: AbortSignal;
|
|
149
|
+
/** Personal WeChat restores via suite unarchive HTTP; WeCom must not. Default true. */
|
|
150
|
+
sidebarUnarchive?: boolean;
|
|
133
151
|
}): Promise<string>;
|
package/lib/web-shim.js
CHANGED
|
@@ -269,6 +269,14 @@ export function saveSessionStore(path, store) {
|
|
|
269
269
|
mkdirSync(dirname(path), { recursive: true });
|
|
270
270
|
writeFileSync(path, JSON.stringify(store, null, 2), 'utf8');
|
|
271
271
|
}
|
|
272
|
+
/** Drop the sticky session pointer for one room/chat key (WeCom restart / stuck recovery). */
|
|
273
|
+
export function clearSessionStoreKey(path, key) {
|
|
274
|
+
const store = loadSessionStore(path);
|
|
275
|
+
if (store.sessions[key] === undefined)
|
|
276
|
+
return;
|
|
277
|
+
delete store.sessions[key];
|
|
278
|
+
saveSessionStore(path, store);
|
|
279
|
+
}
|
|
272
280
|
/** dsh 0.1.5+ wire endpoint is `namespace/method` (slash), not `namespace.method`. */
|
|
273
281
|
export function normalizeRpcEndpoint(method) {
|
|
274
282
|
if (method.includes('/'))
|
|
@@ -725,24 +733,105 @@ export function historyEvents(body) {
|
|
|
725
733
|
: []);
|
|
726
734
|
return raw.map(unwrapHistoryItem);
|
|
727
735
|
}
|
|
736
|
+
/** Durable seq, or seq0 for batched frames like text-chunks. Missing → -1. */
|
|
737
|
+
export function eventSeq(event) {
|
|
738
|
+
if (typeof event !== 'object' || event === null)
|
|
739
|
+
return -1;
|
|
740
|
+
const rec = event;
|
|
741
|
+
if (typeof rec.seq === 'number' && Number.isFinite(rec.seq))
|
|
742
|
+
return rec.seq;
|
|
743
|
+
if (typeof rec.seq0 === 'number' && Number.isFinite(rec.seq0))
|
|
744
|
+
return rec.seq0;
|
|
745
|
+
return -1;
|
|
746
|
+
}
|
|
747
|
+
export function historyCursor(events) {
|
|
748
|
+
let max = -1;
|
|
749
|
+
for (const event of events) {
|
|
750
|
+
const seq = eventSeq(event);
|
|
751
|
+
if (seq > max)
|
|
752
|
+
max = seq;
|
|
753
|
+
}
|
|
754
|
+
return max;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* True when a turn that started *after* beforeSeq has ended.
|
|
758
|
+
* Events without seq are always included (unit fixtures / legacy history).
|
|
759
|
+
*/
|
|
760
|
+
export function turnSettledAfter(events, beforeSeq) {
|
|
761
|
+
let open = 0;
|
|
762
|
+
let sawStart = false;
|
|
763
|
+
for (const event of events) {
|
|
764
|
+
if (typeof event !== 'object' || event === null)
|
|
765
|
+
continue;
|
|
766
|
+
const seq = eventSeq(event);
|
|
767
|
+
if (seq >= 0 && seq <= beforeSeq)
|
|
768
|
+
continue;
|
|
769
|
+
const type = event.type;
|
|
770
|
+
if (type === 'turn/start') {
|
|
771
|
+
open += 1;
|
|
772
|
+
sawStart = true;
|
|
773
|
+
}
|
|
774
|
+
if (type === 'turn/end')
|
|
775
|
+
open = Math.max(0, open - 1);
|
|
776
|
+
}
|
|
777
|
+
return sawStart && open === 0;
|
|
778
|
+
}
|
|
779
|
+
function textsFromChunkEvent(rec) {
|
|
780
|
+
const data = typeof rec.data === 'object' && rec.data !== null ? rec.data : rec;
|
|
781
|
+
const texts = data.texts;
|
|
782
|
+
if (!Array.isArray(texts))
|
|
783
|
+
return '';
|
|
784
|
+
return texts.map(part => typeof part === 'string' ? part : '').join('');
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Visible assistant text for WeChat.
|
|
788
|
+
* Web UI streams via text-chunks; final assistant/message is often tool-only.
|
|
789
|
+
* Prefer longer streamed chunks when they supersede a short mid-turn message.
|
|
790
|
+
*/
|
|
728
791
|
export function lastAssistantText(events) {
|
|
729
|
-
let
|
|
792
|
+
let lastUserSeq = -1;
|
|
793
|
+
for (const event of events) {
|
|
794
|
+
if (typeof event !== 'object' || event === null)
|
|
795
|
+
continue;
|
|
796
|
+
const rec = event;
|
|
797
|
+
const type = typeof rec.type === 'string' ? rec.type : '';
|
|
798
|
+
const role = typeof rec.role === 'string' ? rec.role : '';
|
|
799
|
+
if (type === 'user/message' || role === 'user') {
|
|
800
|
+
lastUserSeq = Math.max(lastUserSeq, eventSeq(rec));
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
let fromMessage = '';
|
|
804
|
+
const chunkParts = [];
|
|
730
805
|
for (const event of events) {
|
|
731
806
|
if (typeof event !== 'object' || event === null)
|
|
732
807
|
continue;
|
|
733
808
|
const rec = event;
|
|
734
809
|
const type = typeof rec.type === 'string' ? rec.type : '';
|
|
810
|
+
const seq = eventSeq(rec);
|
|
811
|
+
const afterUser = lastUserSeq < 0 || seq < 0 || seq > lastUserSeq;
|
|
812
|
+
if (afterUser && type === 'text-chunks') {
|
|
813
|
+
const piece = textsFromChunkEvent(rec);
|
|
814
|
+
if (piece !== '')
|
|
815
|
+
chunkParts.push(piece);
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
735
818
|
const role = typeof rec.role === 'string' ? rec.role : '';
|
|
736
819
|
const isAssistant = type === 'assistant/message' || role === 'assistant';
|
|
737
|
-
if (!isAssistant)
|
|
820
|
+
if (!isAssistant || !afterUser)
|
|
738
821
|
continue;
|
|
739
822
|
const data = typeof rec.data === 'object' && rec.data !== null ? rec.data : rec;
|
|
740
823
|
const message = typeof data.message === 'object' && data.message !== null ? data.message : data;
|
|
741
|
-
const chunk = flattenVisibleText(message.content ?? data.content ?? rec.content)
|
|
824
|
+
const chunk = flattenVisibleText(message.content ?? data.content ?? rec.content)
|
|
825
|
+
|| (typeof rec.text === 'string' ? stripThinkTags(rec.text) : '');
|
|
742
826
|
if (chunk.trim() !== '')
|
|
743
|
-
|
|
827
|
+
fromMessage = chunk;
|
|
744
828
|
}
|
|
745
|
-
|
|
829
|
+
const fromChunks = chunkParts.join('');
|
|
830
|
+
const msg = fromMessage.trim();
|
|
831
|
+
const chunks = fromChunks.trim();
|
|
832
|
+
if (chunks.length > msg.length)
|
|
833
|
+
return chunks;
|
|
834
|
+
return msg || chunks;
|
|
746
835
|
}
|
|
747
836
|
export function turnIsIdle(events) {
|
|
748
837
|
let open = 0;
|
|
@@ -975,7 +1064,8 @@ async function fetchSessionHistory(rpc, sessionId, maxMessages = 40) {
|
|
|
975
1064
|
method: 'session/page',
|
|
976
1065
|
params: {
|
|
977
1066
|
address: { kind: 'session', sessionId },
|
|
978
|
-
|
|
1067
|
+
// Official tip-of-log sentinel. MAX_SAFE_INTEGER is "past cursor" and always 400s.
|
|
1068
|
+
throughSeq: -1,
|
|
979
1069
|
maxMessages,
|
|
980
1070
|
},
|
|
981
1071
|
},
|
|
@@ -1115,11 +1205,15 @@ function composeWechatReply(events, ignorePending) {
|
|
|
1115
1205
|
}
|
|
1116
1206
|
return { text: visible, pendingQuestions };
|
|
1117
1207
|
}
|
|
1118
|
-
async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePending, stuckText = STUCK_ACK) {
|
|
1208
|
+
async function waitForAssistant(rpc, sessionId, beforeText, beforeSeq, timeoutMs, ignorePending, stuckText = STUCK_ACK, signal) {
|
|
1119
1209
|
const deadline = Date.now() + timeoutMs;
|
|
1120
1210
|
let last = { text: '', pendingQuestions: [] };
|
|
1121
1211
|
let idleOnce = false;
|
|
1212
|
+
const emptyDone = '(本轮无文字回复,请到网页查看)';
|
|
1122
1213
|
while (Date.now() < deadline) {
|
|
1214
|
+
if (signal?.aborted === true) {
|
|
1215
|
+
throw new Error('本轮已被新消息打断(可发「重启对话」或再问一句)');
|
|
1216
|
+
}
|
|
1123
1217
|
let events = [];
|
|
1124
1218
|
try {
|
|
1125
1219
|
events = await fetchSessionHistory(rpc, sessionId, 40);
|
|
@@ -1136,6 +1230,18 @@ async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePen
|
|
|
1136
1230
|
return { text: stuckText, pendingQuestions: [] };
|
|
1137
1231
|
if (composed.text !== '')
|
|
1138
1232
|
last = composed;
|
|
1233
|
+
const cursorAware = beforeSeq >= 0 || events.some(event => eventSeq(event) >= 0);
|
|
1234
|
+
if (cursorAware && turnSettledAfter(events, beforeSeq)) {
|
|
1235
|
+
if (composed.text !== '' && composed.text !== beforeText)
|
|
1236
|
+
return composed;
|
|
1237
|
+
// Turn finished but no new usable text (tool-only / filtered monologue).
|
|
1238
|
+
return {
|
|
1239
|
+
text: composed.text !== '' && composed.text === beforeText
|
|
1240
|
+
? '(本轮已完成,但没有新的文字回复;请到网页查看)'
|
|
1241
|
+
: emptyDone,
|
|
1242
|
+
pendingQuestions: composed.pendingQuestions,
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1139
1245
|
const idle = turnIsIdle(events);
|
|
1140
1246
|
if (idle)
|
|
1141
1247
|
idleOnce = true;
|
|
@@ -1160,6 +1266,8 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1160
1266
|
const customRpc = opts.rpc !== undefined;
|
|
1161
1267
|
const rpc = opts.rpc ?? ((method, params) => postRpc(webBaseUrl(env), method, params));
|
|
1162
1268
|
const timeoutMs = opts.timeoutMs ?? Number(env.DSH_BRIDGE_TIMEOUT_MS ?? 10 * 60 * 1000);
|
|
1269
|
+
const signal = opts.signal;
|
|
1270
|
+
const sidebarUnarchive = opts.sidebarUnarchive !== false && !customRpc;
|
|
1163
1271
|
const workspaceId = await ensureWorkspace(rpc, workspace);
|
|
1164
1272
|
const folder = basename(workspace) || WECHAT_WORKSPACE_NAME;
|
|
1165
1273
|
const restart = isRestartCommand(text);
|
|
@@ -1171,6 +1279,7 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1171
1279
|
// the user asked to restart or the old session is already gone.
|
|
1172
1280
|
if (restart) {
|
|
1173
1281
|
sessionId = undefined;
|
|
1282
|
+
clearSessionStoreKey(storeFile, key);
|
|
1174
1283
|
}
|
|
1175
1284
|
else if (sessionId !== undefined) {
|
|
1176
1285
|
const alive = await sessionAlive(rpc, sessionId);
|
|
@@ -1180,24 +1289,29 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1180
1289
|
if (sessionId === undefined) {
|
|
1181
1290
|
sessionId = await createSession(rpc, workspace, workspaceId);
|
|
1182
1291
|
await maybeSelectModel(rpc, sessionId, env);
|
|
1183
|
-
await ensureSessionVisible(rpc, sessionId, env, workspaceId,
|
|
1292
|
+
await ensureSessionVisible(rpc, sessionId, env, workspaceId, sidebarUnarchive);
|
|
1184
1293
|
if (previousId !== undefined && previousId !== sessionId) {
|
|
1185
1294
|
await abortSessionTurn(rpc, previousId);
|
|
1186
1295
|
await archiveSession(rpc, previousId);
|
|
1187
1296
|
}
|
|
1188
1297
|
}
|
|
1189
1298
|
else {
|
|
1190
|
-
await ensureSessionVisible(rpc, sessionId, env, workspaceId,
|
|
1299
|
+
await ensureSessionVisible(rpc, sessionId, env, workspaceId, sidebarUnarchive);
|
|
1191
1300
|
}
|
|
1301
|
+
if (signal?.aborted === true)
|
|
1302
|
+
throw new Error('本轮已被新消息打断');
|
|
1192
1303
|
const promptText = restart ? RESTART_SEED : text;
|
|
1193
1304
|
let before = '';
|
|
1305
|
+
let beforeSeq = -1;
|
|
1194
1306
|
let events = [];
|
|
1195
1307
|
try {
|
|
1196
1308
|
events = await fetchSessionHistory(rpc, sessionId, 40);
|
|
1197
1309
|
before = usableAssistantText(lastAssistantText(events));
|
|
1310
|
+
beforeSeq = historyCursor(events);
|
|
1198
1311
|
}
|
|
1199
1312
|
catch {
|
|
1200
1313
|
before = '';
|
|
1314
|
+
beforeSeq = -1;
|
|
1201
1315
|
}
|
|
1202
1316
|
const pending = extractPendingQuestions(events);
|
|
1203
1317
|
const questions = pending.length > 0
|
|
@@ -1223,13 +1337,20 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1223
1337
|
}
|
|
1224
1338
|
await rpc('session/prompt', promptPayload(sessionId, promptText));
|
|
1225
1339
|
}
|
|
1226
|
-
const reply = await waitForAssistant(rpc, sessionId, before, timeoutMs, questions, stuckAck(folder));
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1340
|
+
const reply = await waitForAssistant(rpc, sessionId, before, beforeSeq, timeoutMs, questions, stuckAck(folder), signal);
|
|
1341
|
+
// Stuck / approval-blocked sessions poison the sticky pointer — drop it so
|
|
1342
|
+
// the next WeCom/WeChat turn opens a fresh web session instead of hanging.
|
|
1343
|
+
if (reply.text === stuckAck(folder)) {
|
|
1344
|
+
clearSessionStoreKey(storeFile, key);
|
|
1345
|
+
}
|
|
1346
|
+
else {
|
|
1347
|
+
store.sessions[key] = {
|
|
1348
|
+
sessionId,
|
|
1349
|
+
updatedAt: Date.now(),
|
|
1350
|
+
...(reply.pendingQuestions.length > 0 ? { pendingQuestions: reply.pendingQuestions } : {}),
|
|
1351
|
+
};
|
|
1352
|
+
saveSessionStore(storeFile, store);
|
|
1353
|
+
}
|
|
1233
1354
|
if (restart && (reply.text === stuckAck(folder) || reply.text === ''))
|
|
1234
1355
|
return restartAck(folder);
|
|
1235
1356
|
return reply.text;
|
package/lib/wecom-channel.d.ts
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { type WecomBotPublic, type WecomBotRecord } from './wecom-store.js';
|
|
6
6
|
export declare const WECOM_NON_TEXT_HINT = "\u8BF7\u53D1\u6587\u5B57\u3002\u56FE\u7247\u548C\u6587\u4EF6\u8BF7\u8D70\u7F51\u9875\u7AEF\u6216 Nextcloud\u3002";
|
|
7
|
+
/** WeCom stream payload soft limit; oversize finish frames are dropped by the SDK. */
|
|
8
|
+
export declare const WECOM_REPLY_MAX_CHARS = 20000;
|
|
9
|
+
export declare function clipWecomReply(text: string): string;
|
|
7
10
|
export type WecomBindingPhase = 'idle' | 'connecting' | 'authenticated' | 'error';
|
|
8
11
|
export interface WecomBindingStatus {
|
|
9
12
|
room: string;
|
|
@@ -32,7 +35,9 @@ export type WecomClientFactory = (opts: {
|
|
|
32
35
|
botId: string;
|
|
33
36
|
secret: string;
|
|
34
37
|
}) => WecomWsClient | Promise<WecomWsClient>;
|
|
35
|
-
export type WecomRunTurn = (room: string, text: string) => Promise<string>;
|
|
38
|
+
export type WecomRunTurn = (room: string, text: string, signal?: AbortSignal) => Promise<string>;
|
|
39
|
+
/** Cap how long one WeCom turn can block the per-room queue (stuck Kimi / approval). */
|
|
40
|
+
export declare const WECOM_TURN_TIMEOUT_MS = 90000;
|
|
36
41
|
export declare function extractWecomText(frame: unknown): string;
|
|
37
42
|
export declare function wecomMsgid(frame: unknown): string;
|
|
38
43
|
export declare function createOfficialWecomClient(opts: {
|
|
@@ -62,6 +67,11 @@ export declare class WecomChannel {
|
|
|
62
67
|
private dropBindings;
|
|
63
68
|
private bind;
|
|
64
69
|
private enqueue;
|
|
70
|
+
/**
|
|
71
|
+
* Abort the in-flight turn as soon as a new text arrives (before the serial
|
|
72
|
+
* queue reaches it). Otherwise「重启对话」waits behind a hung「正在处理…」.
|
|
73
|
+
*/
|
|
74
|
+
private enqueueText;
|
|
65
75
|
private rememberMsgid;
|
|
66
76
|
private handleText;
|
|
67
77
|
private handleNonText;
|
package/lib/wecom-channel.js
CHANGED
|
@@ -4,9 +4,18 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { dshHome } from '@rezti/dsh-rez-sso';
|
|
7
|
-
import { runHeadlessViaWeb } from './web-shim.js';
|
|
7
|
+
import { clearSessionStoreKey, isRestartCommand, runHeadlessViaWeb, } from './web-shim.js';
|
|
8
8
|
import { loadWecomChannels, publicWecomBots, saveWecomChannels, applyWecomBotPatch, WECOM_BINDABLE_ROOMS, } from './wecom-store.js';
|
|
9
9
|
export const WECOM_NON_TEXT_HINT = '请发文字。图片和文件请走网页端或 Nextcloud。';
|
|
10
|
+
/** WeCom stream payload soft limit; oversize finish frames are dropped by the SDK. */
|
|
11
|
+
export const WECOM_REPLY_MAX_CHARS = 20_000;
|
|
12
|
+
export function clipWecomReply(text) {
|
|
13
|
+
if (text.length <= WECOM_REPLY_MAX_CHARS)
|
|
14
|
+
return text;
|
|
15
|
+
return `${text.slice(0, WECOM_REPLY_MAX_CHARS - 16)}\n…(已截断)`;
|
|
16
|
+
}
|
|
17
|
+
/** Cap how long one WeCom turn can block the per-room queue (stuck Kimi / approval). */
|
|
18
|
+
export const WECOM_TURN_TIMEOUT_MS = 90_000;
|
|
10
19
|
const MAX_SEEN_MSGIDS = 200;
|
|
11
20
|
export function extractWecomText(frame) {
|
|
12
21
|
if (typeof frame !== 'object' || frame === null)
|
|
@@ -85,13 +94,16 @@ export async function createOfficialWecomClient(opts) {
|
|
|
85
94
|
logger: quietLogger,
|
|
86
95
|
});
|
|
87
96
|
}
|
|
88
|
-
async function defaultRunTurn(room, text) {
|
|
97
|
+
async function defaultRunTurn(room, text, signal) {
|
|
89
98
|
const home = dshHome();
|
|
90
99
|
// Pass the user text as-is. Session stickiness is per-room in web-sessions.json;
|
|
91
100
|
// do not wrap WeCom turns in 桥接上下文 just to reuse a dialog.
|
|
92
101
|
return runHeadlessViaWeb({
|
|
93
102
|
task: text,
|
|
94
103
|
cwd: room,
|
|
104
|
+
...(signal !== undefined ? { signal } : {}),
|
|
105
|
+
timeoutMs: WECOM_TURN_TIMEOUT_MS,
|
|
106
|
+
sidebarUnarchive: false,
|
|
95
107
|
env: {
|
|
96
108
|
...process.env,
|
|
97
109
|
REZ_WECHAT_WORKSPACE: join(home, 'workspaces', room),
|
|
@@ -192,11 +204,11 @@ export class WecomChannel {
|
|
|
192
204
|
const why = error instanceof Error ? error.message : String(error);
|
|
193
205
|
row.hint = `「${bot.room}」连接失败:${why}`;
|
|
194
206
|
});
|
|
195
|
-
client.on('message.text', (frame) => { this.
|
|
196
|
-
client.on('message.mixed', (frame) => { this.
|
|
207
|
+
client.on('message.text', (frame) => { this.enqueueText(row, frame); });
|
|
208
|
+
client.on('message.mixed', (frame) => { this.enqueueText(row, frame); });
|
|
197
209
|
client.on('message.image', (frame) => { this.enqueue(row, () => this.handleNonText(row, frame)); });
|
|
198
210
|
client.on('message.file', (frame) => { this.enqueue(row, () => this.handleNonText(row, frame)); });
|
|
199
|
-
client.on('message.voice', (frame) => { this.
|
|
211
|
+
client.on('message.voice', (frame) => { this.enqueueText(row, frame); });
|
|
200
212
|
client.on('message.video', (frame) => { this.enqueue(row, () => this.handleNonText(row, frame)); });
|
|
201
213
|
client.on('event.enter_chat', (frame) => {
|
|
202
214
|
this.enqueue(row, async () => {
|
|
@@ -219,6 +231,20 @@ export class WecomChannel {
|
|
|
219
231
|
console.error(`[wecom] ${row.bot.room}: ${why}`);
|
|
220
232
|
});
|
|
221
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* Abort the in-flight turn as soon as a new text arrives (before the serial
|
|
236
|
+
* queue reaches it). Otherwise「重启对话」waits behind a hung「正在处理…」.
|
|
237
|
+
*/
|
|
238
|
+
enqueueText(row, frame) {
|
|
239
|
+
const text = extractWecomText(frame);
|
|
240
|
+
if (text.length > 0) {
|
|
241
|
+
row.turnAbort?.abort();
|
|
242
|
+
if (isRestartCommand(text)) {
|
|
243
|
+
clearSessionStoreKey(join(this.home, 'dsh-rez-wecom', row.bot.room, 'web-sessions.json'), row.bot.room);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
this.enqueue(row, () => this.handleText(row, frame));
|
|
247
|
+
}
|
|
222
248
|
rememberMsgid(row, frame) {
|
|
223
249
|
const id = wecomMsgid(frame);
|
|
224
250
|
if (id.length === 0)
|
|
@@ -239,14 +265,58 @@ export class WecomChannel {
|
|
|
239
265
|
await row.client.replyStream(frame, streamId, WECOM_NON_TEXT_HINT, true);
|
|
240
266
|
return;
|
|
241
267
|
}
|
|
268
|
+
const turnAbort = new AbortController();
|
|
269
|
+
row.turnAbort = turnAbort;
|
|
270
|
+
const timeout = AbortSignal.timeout(WECOM_TURN_TIMEOUT_MS);
|
|
271
|
+
const signal = AbortSignal.any([turnAbort.signal, timeout]);
|
|
272
|
+
let finished = false;
|
|
273
|
+
const finish = async (content) => {
|
|
274
|
+
if (finished)
|
|
275
|
+
return;
|
|
276
|
+
finished = true;
|
|
277
|
+
await row.client.replyStream(frame, streamId, clipWecomReply(content), true);
|
|
278
|
+
};
|
|
242
279
|
try {
|
|
243
280
|
await row.client.replyStream(frame, streamId, '正在处理…', false);
|
|
244
|
-
const reply = await this.runTurn(row.bot.room, text);
|
|
245
|
-
|
|
281
|
+
const reply = await this.runTurn(row.bot.room, text, signal);
|
|
282
|
+
if (turnAbort.signal.aborted) {
|
|
283
|
+
await finish('已取消(收到新消息)');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
await finish(reply.length > 0 ? reply : '(空回复)');
|
|
246
287
|
}
|
|
247
288
|
catch (error) {
|
|
248
289
|
const why = error instanceof Error ? error.message : String(error);
|
|
249
|
-
|
|
290
|
+
const superseded = turnAbort.signal.aborted && !timeout.aborted;
|
|
291
|
+
if (superseded) {
|
|
292
|
+
try {
|
|
293
|
+
await finish('已取消(收到新消息)');
|
|
294
|
+
}
|
|
295
|
+
catch { /* WS may already be closed */ }
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const timedOut = timeout.aborted || /aborted|timeout|TimeoutError|打断/i.test(why);
|
|
299
|
+
const message = timedOut
|
|
300
|
+
? '处理超时。请发「重启对话」再试,或到网页左侧该房间看是否在等批准。'
|
|
301
|
+
: `处理失败:${why}`;
|
|
302
|
+
try {
|
|
303
|
+
await finish(message);
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
/* WS may already be closed */
|
|
307
|
+
}
|
|
308
|
+
// Poisoned sticky session: force the next turn to mint a fresh web session.
|
|
309
|
+
clearSessionStoreKey(join(this.home, 'dsh-rez-wecom', row.bot.room, 'web-sessions.json'), row.bot.room);
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
if (!finished) {
|
|
313
|
+
try {
|
|
314
|
+
await finish('(回复中断,请再发一句或「重启对话」)');
|
|
315
|
+
}
|
|
316
|
+
catch { /* WS may already be closed */ }
|
|
317
|
+
}
|
|
318
|
+
if (row.turnAbort === turnAbort)
|
|
319
|
+
delete row.turnAbort;
|
|
250
320
|
}
|
|
251
321
|
}
|
|
252
322
|
async handleNonText(row, frame) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rezti/dsh-rez-wechat",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "ReZ-TI WeChat/WeCom bridges. Personal WeChat is QClaw/ClawBot scan-and-chat via dsh-wechat-bridge; WeCom AI bots use the official @wecom/aibot-node-sdk (BotID + Secret) bound per Harness room.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|