@rezti/dsh-rez-wechat 0.1.21 → 0.1.22
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 +5 -0
- package/lib/web-shim.js +35 -11
- package/lib/wecom-channel.d.ts +8 -1
- package/lib/wecom-channel.js +57 -7
- 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
|
/**
|
|
@@ -130,4 +132,7 @@ export declare function runHeadlessViaWeb(opts: {
|
|
|
130
132
|
env?: NodeJS.ProcessEnv;
|
|
131
133
|
rpc?: RpcFn;
|
|
132
134
|
timeoutMs?: number;
|
|
135
|
+
signal?: AbortSignal;
|
|
136
|
+
/** Personal WeChat restores via suite unarchive HTTP; WeCom must not. Default true. */
|
|
137
|
+
sidebarUnarchive?: boolean;
|
|
133
138
|
}): 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('/'))
|
|
@@ -975,7 +983,8 @@ async function fetchSessionHistory(rpc, sessionId, maxMessages = 40) {
|
|
|
975
983
|
method: 'session/page',
|
|
976
984
|
params: {
|
|
977
985
|
address: { kind: 'session', sessionId },
|
|
978
|
-
|
|
986
|
+
// Official tip-of-log sentinel. MAX_SAFE_INTEGER is "past cursor" and always 400s.
|
|
987
|
+
throughSeq: -1,
|
|
979
988
|
maxMessages,
|
|
980
989
|
},
|
|
981
990
|
},
|
|
@@ -1115,11 +1124,14 @@ function composeWechatReply(events, ignorePending) {
|
|
|
1115
1124
|
}
|
|
1116
1125
|
return { text: visible, pendingQuestions };
|
|
1117
1126
|
}
|
|
1118
|
-
async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePending, stuckText = STUCK_ACK) {
|
|
1127
|
+
async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePending, stuckText = STUCK_ACK, signal) {
|
|
1119
1128
|
const deadline = Date.now() + timeoutMs;
|
|
1120
1129
|
let last = { text: '', pendingQuestions: [] };
|
|
1121
1130
|
let idleOnce = false;
|
|
1122
1131
|
while (Date.now() < deadline) {
|
|
1132
|
+
if (signal?.aborted === true) {
|
|
1133
|
+
throw new Error('本轮已被新消息打断(可发「重启对话」或再问一句)');
|
|
1134
|
+
}
|
|
1123
1135
|
let events = [];
|
|
1124
1136
|
try {
|
|
1125
1137
|
events = await fetchSessionHistory(rpc, sessionId, 40);
|
|
@@ -1160,6 +1172,8 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1160
1172
|
const customRpc = opts.rpc !== undefined;
|
|
1161
1173
|
const rpc = opts.rpc ?? ((method, params) => postRpc(webBaseUrl(env), method, params));
|
|
1162
1174
|
const timeoutMs = opts.timeoutMs ?? Number(env.DSH_BRIDGE_TIMEOUT_MS ?? 10 * 60 * 1000);
|
|
1175
|
+
const signal = opts.signal;
|
|
1176
|
+
const sidebarUnarchive = opts.sidebarUnarchive !== false && !customRpc;
|
|
1163
1177
|
const workspaceId = await ensureWorkspace(rpc, workspace);
|
|
1164
1178
|
const folder = basename(workspace) || WECHAT_WORKSPACE_NAME;
|
|
1165
1179
|
const restart = isRestartCommand(text);
|
|
@@ -1171,6 +1185,7 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1171
1185
|
// the user asked to restart or the old session is already gone.
|
|
1172
1186
|
if (restart) {
|
|
1173
1187
|
sessionId = undefined;
|
|
1188
|
+
clearSessionStoreKey(storeFile, key);
|
|
1174
1189
|
}
|
|
1175
1190
|
else if (sessionId !== undefined) {
|
|
1176
1191
|
const alive = await sessionAlive(rpc, sessionId);
|
|
@@ -1180,15 +1195,17 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1180
1195
|
if (sessionId === undefined) {
|
|
1181
1196
|
sessionId = await createSession(rpc, workspace, workspaceId);
|
|
1182
1197
|
await maybeSelectModel(rpc, sessionId, env);
|
|
1183
|
-
await ensureSessionVisible(rpc, sessionId, env, workspaceId,
|
|
1198
|
+
await ensureSessionVisible(rpc, sessionId, env, workspaceId, sidebarUnarchive);
|
|
1184
1199
|
if (previousId !== undefined && previousId !== sessionId) {
|
|
1185
1200
|
await abortSessionTurn(rpc, previousId);
|
|
1186
1201
|
await archiveSession(rpc, previousId);
|
|
1187
1202
|
}
|
|
1188
1203
|
}
|
|
1189
1204
|
else {
|
|
1190
|
-
await ensureSessionVisible(rpc, sessionId, env, workspaceId,
|
|
1205
|
+
await ensureSessionVisible(rpc, sessionId, env, workspaceId, sidebarUnarchive);
|
|
1191
1206
|
}
|
|
1207
|
+
if (signal?.aborted === true)
|
|
1208
|
+
throw new Error('本轮已被新消息打断');
|
|
1192
1209
|
const promptText = restart ? RESTART_SEED : text;
|
|
1193
1210
|
let before = '';
|
|
1194
1211
|
let events = [];
|
|
@@ -1223,13 +1240,20 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1223
1240
|
}
|
|
1224
1241
|
await rpc('session/prompt', promptPayload(sessionId, promptText));
|
|
1225
1242
|
}
|
|
1226
|
-
const reply = await waitForAssistant(rpc, sessionId, before, timeoutMs, questions, stuckAck(folder));
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1243
|
+
const reply = await waitForAssistant(rpc, sessionId, before, timeoutMs, questions, stuckAck(folder), signal);
|
|
1244
|
+
// Stuck / approval-blocked sessions poison the sticky pointer — drop it so
|
|
1245
|
+
// the next WeCom/WeChat turn opens a fresh web session instead of hanging.
|
|
1246
|
+
if (reply.text === stuckAck(folder)) {
|
|
1247
|
+
clearSessionStoreKey(storeFile, key);
|
|
1248
|
+
}
|
|
1249
|
+
else {
|
|
1250
|
+
store.sessions[key] = {
|
|
1251
|
+
sessionId,
|
|
1252
|
+
updatedAt: Date.now(),
|
|
1253
|
+
...(reply.pendingQuestions.length > 0 ? { pendingQuestions: reply.pendingQuestions } : {}),
|
|
1254
|
+
};
|
|
1255
|
+
saveSessionStore(storeFile, store);
|
|
1256
|
+
}
|
|
1233
1257
|
if (restart && (reply.text === stuckAck(folder) || reply.text === ''))
|
|
1234
1258
|
return restartAck(folder);
|
|
1235
1259
|
return reply.text;
|
package/lib/wecom-channel.d.ts
CHANGED
|
@@ -32,7 +32,9 @@ export type WecomClientFactory = (opts: {
|
|
|
32
32
|
botId: string;
|
|
33
33
|
secret: string;
|
|
34
34
|
}) => WecomWsClient | Promise<WecomWsClient>;
|
|
35
|
-
export type WecomRunTurn = (room: string, text: string) => Promise<string>;
|
|
35
|
+
export type WecomRunTurn = (room: string, text: string, signal?: AbortSignal) => Promise<string>;
|
|
36
|
+
/** Cap how long one WeCom turn can block the per-room queue (stuck Kimi / approval). */
|
|
37
|
+
export declare const WECOM_TURN_TIMEOUT_MS = 90000;
|
|
36
38
|
export declare function extractWecomText(frame: unknown): string;
|
|
37
39
|
export declare function wecomMsgid(frame: unknown): string;
|
|
38
40
|
export declare function createOfficialWecomClient(opts: {
|
|
@@ -62,6 +64,11 @@ export declare class WecomChannel {
|
|
|
62
64
|
private dropBindings;
|
|
63
65
|
private bind;
|
|
64
66
|
private enqueue;
|
|
67
|
+
/**
|
|
68
|
+
* Abort the in-flight turn as soon as a new text arrives (before the serial
|
|
69
|
+
* queue reaches it). Otherwise「重启对话」waits behind a hung「正在处理…」.
|
|
70
|
+
*/
|
|
71
|
+
private enqueueText;
|
|
65
72
|
private rememberMsgid;
|
|
66
73
|
private handleText;
|
|
67
74
|
private handleNonText;
|
package/lib/wecom-channel.js
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
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
|
+
/** Cap how long one WeCom turn can block the per-room queue (stuck Kimi / approval). */
|
|
11
|
+
export const WECOM_TURN_TIMEOUT_MS = 90_000;
|
|
10
12
|
const MAX_SEEN_MSGIDS = 200;
|
|
11
13
|
export function extractWecomText(frame) {
|
|
12
14
|
if (typeof frame !== 'object' || frame === null)
|
|
@@ -85,13 +87,16 @@ export async function createOfficialWecomClient(opts) {
|
|
|
85
87
|
logger: quietLogger,
|
|
86
88
|
});
|
|
87
89
|
}
|
|
88
|
-
async function defaultRunTurn(room, text) {
|
|
90
|
+
async function defaultRunTurn(room, text, signal) {
|
|
89
91
|
const home = dshHome();
|
|
90
92
|
// Pass the user text as-is. Session stickiness is per-room in web-sessions.json;
|
|
91
93
|
// do not wrap WeCom turns in 桥接上下文 just to reuse a dialog.
|
|
92
94
|
return runHeadlessViaWeb({
|
|
93
95
|
task: text,
|
|
94
96
|
cwd: room,
|
|
97
|
+
...(signal !== undefined ? { signal } : {}),
|
|
98
|
+
timeoutMs: WECOM_TURN_TIMEOUT_MS,
|
|
99
|
+
sidebarUnarchive: false,
|
|
95
100
|
env: {
|
|
96
101
|
...process.env,
|
|
97
102
|
REZ_WECHAT_WORKSPACE: join(home, 'workspaces', room),
|
|
@@ -192,11 +197,11 @@ export class WecomChannel {
|
|
|
192
197
|
const why = error instanceof Error ? error.message : String(error);
|
|
193
198
|
row.hint = `「${bot.room}」连接失败:${why}`;
|
|
194
199
|
});
|
|
195
|
-
client.on('message.text', (frame) => { this.
|
|
196
|
-
client.on('message.mixed', (frame) => { this.
|
|
200
|
+
client.on('message.text', (frame) => { this.enqueueText(row, frame); });
|
|
201
|
+
client.on('message.mixed', (frame) => { this.enqueueText(row, frame); });
|
|
197
202
|
client.on('message.image', (frame) => { this.enqueue(row, () => this.handleNonText(row, frame)); });
|
|
198
203
|
client.on('message.file', (frame) => { this.enqueue(row, () => this.handleNonText(row, frame)); });
|
|
199
|
-
client.on('message.voice', (frame) => { this.
|
|
204
|
+
client.on('message.voice', (frame) => { this.enqueueText(row, frame); });
|
|
200
205
|
client.on('message.video', (frame) => { this.enqueue(row, () => this.handleNonText(row, frame)); });
|
|
201
206
|
client.on('event.enter_chat', (frame) => {
|
|
202
207
|
this.enqueue(row, async () => {
|
|
@@ -219,6 +224,20 @@ export class WecomChannel {
|
|
|
219
224
|
console.error(`[wecom] ${row.bot.room}: ${why}`);
|
|
220
225
|
});
|
|
221
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* Abort the in-flight turn as soon as a new text arrives (before the serial
|
|
229
|
+
* queue reaches it). Otherwise「重启对话」waits behind a hung「正在处理…」.
|
|
230
|
+
*/
|
|
231
|
+
enqueueText(row, frame) {
|
|
232
|
+
const text = extractWecomText(frame);
|
|
233
|
+
if (text.length > 0) {
|
|
234
|
+
row.turnAbort?.abort();
|
|
235
|
+
if (isRestartCommand(text)) {
|
|
236
|
+
clearSessionStoreKey(join(this.home, 'dsh-rez-wecom', row.bot.room, 'web-sessions.json'), row.bot.room);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
this.enqueue(row, () => this.handleText(row, frame));
|
|
240
|
+
}
|
|
222
241
|
rememberMsgid(row, frame) {
|
|
223
242
|
const id = wecomMsgid(frame);
|
|
224
243
|
if (id.length === 0)
|
|
@@ -239,14 +258,45 @@ export class WecomChannel {
|
|
|
239
258
|
await row.client.replyStream(frame, streamId, WECOM_NON_TEXT_HINT, true);
|
|
240
259
|
return;
|
|
241
260
|
}
|
|
261
|
+
const turnAbort = new AbortController();
|
|
262
|
+
row.turnAbort = turnAbort;
|
|
263
|
+
const timeout = AbortSignal.timeout(WECOM_TURN_TIMEOUT_MS);
|
|
264
|
+
const signal = AbortSignal.any([turnAbort.signal, timeout]);
|
|
242
265
|
try {
|
|
243
266
|
await row.client.replyStream(frame, streamId, '正在处理…', false);
|
|
244
|
-
const reply = await this.runTurn(row.bot.room, text);
|
|
267
|
+
const reply = await this.runTurn(row.bot.room, text, signal);
|
|
268
|
+
if (turnAbort.signal.aborted) {
|
|
269
|
+
await row.client.replyStream(frame, streamId, '已取消(收到新消息)', true);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
245
272
|
await row.client.replyStream(frame, streamId, reply.length > 0 ? reply : '(空回复)', true);
|
|
246
273
|
}
|
|
247
274
|
catch (error) {
|
|
248
275
|
const why = error instanceof Error ? error.message : String(error);
|
|
249
|
-
|
|
276
|
+
const superseded = turnAbort.signal.aborted && !timeout.aborted;
|
|
277
|
+
if (superseded) {
|
|
278
|
+
try {
|
|
279
|
+
await row.client.replyStream(frame, streamId, '已取消(收到新消息)', true);
|
|
280
|
+
}
|
|
281
|
+
catch { /* WS may already be closed */ }
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const timedOut = timeout.aborted || /aborted|timeout|TimeoutError|打断/i.test(why);
|
|
285
|
+
const message = timedOut
|
|
286
|
+
? '处理超时。请发「重启对话」再试,或到网页左侧该房间看是否在等批准。'
|
|
287
|
+
: `处理失败:${why}`;
|
|
288
|
+
try {
|
|
289
|
+
await row.client.replyStream(frame, streamId, message, true);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
/* WS may already be closed */
|
|
293
|
+
}
|
|
294
|
+
// Poisoned sticky session: force the next turn to mint a fresh web session.
|
|
295
|
+
clearSessionStoreKey(join(this.home, 'dsh-rez-wecom', row.bot.room, 'web-sessions.json'), row.bot.room);
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
if (row.turnAbort === turnAbort)
|
|
299
|
+
delete row.turnAbort;
|
|
250
300
|
}
|
|
251
301
|
}
|
|
252
302
|
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.22",
|
|
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": {
|