@rezti/dsh-rez-wechat 0.1.20 → 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/README.md +1 -1
- package/lib/channel.js +1 -1
- package/lib/index.js +1 -1
- package/lib/web-shim-cli.js +5 -0
- package/lib/web-shim.d.ts +6 -2
- package/lib/web-shim.js +61 -19
- package/lib/wecom-channel.d.ts +8 -1
- package/lib/wecom-channel.js +57 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ dsh plugin --profile web add @rezti/dsh-rez-suite
|
|
|
19
19
|
|
|
20
20
|
- 左侧工作区分组是 **微信**,不是 Ungrouped
|
|
21
21
|
- 连续消息延续同一会话
|
|
22
|
-
-
|
|
22
|
+
- 模型跟网页默认(官方默认 DeepSeek-V4-Flash;不要因为微信通道去切 Kimi)
|
|
23
23
|
- 网页归档后再从微信说话会自动回到侧栏(官方会藏空白会话,所以「重启对话」会先打一句招呼)
|
|
24
24
|
- 网页端选择题(Selection)会编成 `1. 2. 3.` 发到微信;回复数字或选项原文即可继续,不必在网页点选
|
|
25
25
|
- 思考过程不会发到微信;多段正文会保留换行
|
package/lib/channel.js
CHANGED
|
@@ -167,7 +167,7 @@ export class WeixinChannel {
|
|
|
167
167
|
if (this.runChild !== undefined && this.runChild.exitCode === null)
|
|
168
168
|
return;
|
|
169
169
|
this.phase = 'listening';
|
|
170
|
-
this.hint = '
|
|
170
|
+
this.hint = '微信通道已在后台听着。消息会进网页「微信」文件夹的同一会话,模型跟网页默认(DeepSeek-V4-Flash)。';
|
|
171
171
|
const child = spawn('npx', ['-y', 'dsh-wechat-bridge', 'run', '--data-dir', DATA_DIR], {
|
|
172
172
|
env: weixinRunEnv(),
|
|
173
173
|
stdio: ['ignore', 'pipe', 'pipe'],
|
package/lib/index.js
CHANGED
|
@@ -7,7 +7,7 @@ export const name = 'rez-wechat';
|
|
|
7
7
|
export const inject = ['systemPrompt', 'webServer', 'rezSso'];
|
|
8
8
|
const GUIDANCE = [
|
|
9
9
|
'企业微信有两层,不要混:① 房间消息通道 = 官方 @wecom/aibot-node-sdk(设置页 BotID + Secret 长连接),同事在企微跟哪个 bot 说话就进哪个 Harness 房间(人事/财务/法务/设计/发布/电商/实验室/老板三室),并固定同一条网页会话;只有同事说「重启对话」才新开并把旧的归档。② 办公能力 = 官方 wecom-cli >=1.1(通讯录/在线文档/在线表格/智能表格/智能文档/待办/日程/会议/邮件/微盘/消息),本企业已开通,看 rez_status.wecomCli.modules,按房间 wecomcli-* 技能执行。不要因为状态页没有企微 MCP 就说没有企微能力,也不要说「超过 10 人只能用文档」。CLI 报未授权才说该模块未开通。密钥写在 ~/.dsh/wecom-channels.json,不要写进 compose。没有绑定任何 bot 且仍配置了 REZ_WECHAT_WEBHOOK 时,才兼容旧的 wecom-mcp 群发,不要引导去填群 Webhook。不要自写 WebSocket/HTTP 客户端。',
|
|
10
|
-
'个人微信是 QClaw 同款 ClawBot 通道:在 Rez
|
|
10
|
+
'个人微信是 QClaw 同款 ClawBot 通道:在 Rez 面板「微信」页扫码一次,然后直接在微信里说话。消息进入网页左侧「微信」文件夹并延续同一会话,模型跟网页默认(官方默认 DeepSeek-V4-Flash,不要因为微信去切 Kimi)。网页端归档了也能从微信自动恢复到侧栏(空白会话网页会藏起来,所以新开的会话会先打一句招呼再出现)。网页端出现选择题时会编成纯文本发到微信,回复数字或选项原文即可;思考过程不会转发。发「重启对话」会新开一条可见的网页会话并把旧的归档。通道断了点「重新连接」(已登录不必再扫码)。不要再跑 npx weixin-mcp login,也不要调用 mcp__weixin__*。网页端必须开着。工商查询请切到法务助理,避免串房间。',
|
|
11
11
|
'这是 ClawBot 客服会话,不是用个人微信号管好友/群。对外发消息必须先征得用户批准。',
|
|
12
12
|
].join('');
|
|
13
13
|
function isLoopbackRequest(request) {
|
package/lib/web-shim-cli.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
* Executable stand-in for `dsh`. dsh-wechat-bridge spawns:
|
|
4
4
|
* $DSH_BRIDGE_DSH --profile headless <task>
|
|
5
5
|
* stdout is the WeChat reply; logs go to stderr.
|
|
6
|
+
*
|
|
7
|
+
* Must process.exit: native fetch keeps the event loop alive, and the bridge
|
|
8
|
+
* holds a per-chat lock until this child exits. Without an explicit exit the
|
|
9
|
+
* first WeChat turn can reply, then every follow-up waits forever.
|
|
6
10
|
*/
|
|
7
11
|
import { parseHeadlessArgv, runHeadlessViaWeb } from './web-shim.js';
|
|
8
12
|
const parsed = parseHeadlessArgv(process.argv.slice(1));
|
|
@@ -20,3 +24,4 @@ catch (error) {
|
|
|
20
24
|
process.stderr.write(`dsh-web-shim: ${message}\n`);
|
|
21
25
|
process.stdout.write(`${message}\n`);
|
|
22
26
|
}
|
|
27
|
+
process.exit(0);
|
package/lib/web-shim.d.ts
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Headless always mints a fresh Agent (DeepSeek default, Ungrouped cwd hash).
|
|
6
6
|
* Web RPC reuses one session under ~/.dsh/workspaces/微信 so the sidebar
|
|
7
|
-
* folder is named 微信, turns continue, and the model follows the web default
|
|
8
|
-
* (Kimi if that is what the user selected).
|
|
7
|
+
* folder is named 微信, turns continue, and the model follows the web default.
|
|
9
8
|
*/
|
|
10
9
|
export declare const WECHAT_WORKSPACE_NAME = "\u5FAE\u4FE1";
|
|
11
10
|
export declare const DEFAULT_WEB_URL = "http://127.0.0.1:3080";
|
|
@@ -75,6 +74,8 @@ export declare function ensureWechatInboxAgents(cwd: string): void;
|
|
|
75
74
|
export declare function sessionStorePath(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
76
75
|
export declare function loadSessionStore(path: string): SessionStore;
|
|
77
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;
|
|
78
79
|
/** dsh 0.1.5+ wire endpoint is `namespace/method` (slash), not `namespace.method`. */
|
|
79
80
|
export declare function normalizeRpcEndpoint(method: string): string;
|
|
80
81
|
/**
|
|
@@ -131,4 +132,7 @@ export declare function runHeadlessViaWeb(opts: {
|
|
|
131
132
|
env?: NodeJS.ProcessEnv;
|
|
132
133
|
rpc?: RpcFn;
|
|
133
134
|
timeoutMs?: number;
|
|
135
|
+
signal?: AbortSignal;
|
|
136
|
+
/** Personal WeChat restores via suite unarchive HTTP; WeCom must not. Default true. */
|
|
137
|
+
sidebarUnarchive?: boolean;
|
|
134
138
|
}): Promise<string>;
|
package/lib/web-shim.js
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Headless always mints a fresh Agent (DeepSeek default, Ungrouped cwd hash).
|
|
6
6
|
* Web RPC reuses one session under ~/.dsh/workspaces/微信 so the sidebar
|
|
7
|
-
* folder is named 微信, turns continue, and the model follows the web default
|
|
8
|
-
* (Kimi if that is what the user selected).
|
|
7
|
+
* folder is named 微信, turns continue, and the model follows the web default.
|
|
9
8
|
*/
|
|
10
9
|
import { randomUUID } from 'node:crypto';
|
|
11
10
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -270,6 +269,14 @@ export function saveSessionStore(path, store) {
|
|
|
270
269
|
mkdirSync(dirname(path), { recursive: true });
|
|
271
270
|
writeFileSync(path, JSON.stringify(store, null, 2), 'utf8');
|
|
272
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
|
+
}
|
|
273
280
|
/** dsh 0.1.5+ wire endpoint is `namespace/method` (slash), not `namespace.method`. */
|
|
274
281
|
export function normalizeRpcEndpoint(method) {
|
|
275
282
|
if (method.includes('/'))
|
|
@@ -813,7 +820,10 @@ export function pickWechatModel(models, env = process.env) {
|
|
|
813
820
|
const provider = env.REZ_WECHAT_PROVIDER?.trim();
|
|
814
821
|
return provider !== undefined && provider !== '' ? { provider, model: forced } : { model: forced };
|
|
815
822
|
}
|
|
816
|
-
|
|
823
|
+
// Follow the web default (official agent-default-model is DeepSeek-V4-Flash).
|
|
824
|
+
// Do not switch a DeepSeek session onto Kimi: session/selectModel also writes
|
|
825
|
+
// the global default, so a WeChat message would make the next restart land on Kimi.
|
|
826
|
+
if (env.REZ_WECHAT_PREFER_KIMI !== '1')
|
|
817
827
|
return undefined;
|
|
818
828
|
const current = currentModelRef(models);
|
|
819
829
|
if (current !== undefined && looksLikeKimi(`${current.provider ?? ''} ${current.model}`))
|
|
@@ -966,13 +976,30 @@ async function maybeSelectModel(rpc, sessionId, env) {
|
|
|
966
976
|
}
|
|
967
977
|
}
|
|
968
978
|
}
|
|
969
|
-
/** 0.1.5
|
|
979
|
+
/** Prefer session/page (0.1.5 surface); fall back to session/history (apiproxy). */
|
|
970
980
|
async function fetchSessionHistory(rpc, sessionId, maxMessages = 40) {
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
981
|
+
const attempts = [
|
|
982
|
+
{
|
|
983
|
+
method: 'session/page',
|
|
984
|
+
params: {
|
|
985
|
+
address: { kind: 'session', sessionId },
|
|
986
|
+
// Official tip-of-log sentinel. MAX_SAFE_INTEGER is "past cursor" and always 400s.
|
|
987
|
+
throughSeq: -1,
|
|
988
|
+
maxMessages,
|
|
989
|
+
},
|
|
990
|
+
},
|
|
991
|
+
{ method: 'session/history', params: { sessionId, maxMessages } },
|
|
992
|
+
];
|
|
993
|
+
let lastError;
|
|
994
|
+
for (const attempt of attempts) {
|
|
995
|
+
try {
|
|
996
|
+
return historyEvents(await rpc(attempt.method, attempt.params));
|
|
997
|
+
}
|
|
998
|
+
catch (error) {
|
|
999
|
+
lastError = error;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
976
1003
|
}
|
|
977
1004
|
async function sessionAlive(rpc, sessionId) {
|
|
978
1005
|
try {
|
|
@@ -1097,11 +1124,14 @@ function composeWechatReply(events, ignorePending) {
|
|
|
1097
1124
|
}
|
|
1098
1125
|
return { text: visible, pendingQuestions };
|
|
1099
1126
|
}
|
|
1100
|
-
async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePending, stuckText = STUCK_ACK) {
|
|
1127
|
+
async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePending, stuckText = STUCK_ACK, signal) {
|
|
1101
1128
|
const deadline = Date.now() + timeoutMs;
|
|
1102
1129
|
let last = { text: '', pendingQuestions: [] };
|
|
1103
1130
|
let idleOnce = false;
|
|
1104
1131
|
while (Date.now() < deadline) {
|
|
1132
|
+
if (signal?.aborted === true) {
|
|
1133
|
+
throw new Error('本轮已被新消息打断(可发「重启对话」或再问一句)');
|
|
1134
|
+
}
|
|
1105
1135
|
let events = [];
|
|
1106
1136
|
try {
|
|
1107
1137
|
events = await fetchSessionHistory(rpc, sessionId, 40);
|
|
@@ -1142,6 +1172,8 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1142
1172
|
const customRpc = opts.rpc !== undefined;
|
|
1143
1173
|
const rpc = opts.rpc ?? ((method, params) => postRpc(webBaseUrl(env), method, params));
|
|
1144
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;
|
|
1145
1177
|
const workspaceId = await ensureWorkspace(rpc, workspace);
|
|
1146
1178
|
const folder = basename(workspace) || WECHAT_WORKSPACE_NAME;
|
|
1147
1179
|
const restart = isRestartCommand(text);
|
|
@@ -1153,6 +1185,7 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1153
1185
|
// the user asked to restart or the old session is already gone.
|
|
1154
1186
|
if (restart) {
|
|
1155
1187
|
sessionId = undefined;
|
|
1188
|
+
clearSessionStoreKey(storeFile, key);
|
|
1156
1189
|
}
|
|
1157
1190
|
else if (sessionId !== undefined) {
|
|
1158
1191
|
const alive = await sessionAlive(rpc, sessionId);
|
|
@@ -1162,15 +1195,17 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1162
1195
|
if (sessionId === undefined) {
|
|
1163
1196
|
sessionId = await createSession(rpc, workspace, workspaceId);
|
|
1164
1197
|
await maybeSelectModel(rpc, sessionId, env);
|
|
1165
|
-
await ensureSessionVisible(rpc, sessionId, env, workspaceId,
|
|
1198
|
+
await ensureSessionVisible(rpc, sessionId, env, workspaceId, sidebarUnarchive);
|
|
1166
1199
|
if (previousId !== undefined && previousId !== sessionId) {
|
|
1167
1200
|
await abortSessionTurn(rpc, previousId);
|
|
1168
1201
|
await archiveSession(rpc, previousId);
|
|
1169
1202
|
}
|
|
1170
1203
|
}
|
|
1171
1204
|
else {
|
|
1172
|
-
await ensureSessionVisible(rpc, sessionId, env, workspaceId,
|
|
1205
|
+
await ensureSessionVisible(rpc, sessionId, env, workspaceId, sidebarUnarchive);
|
|
1173
1206
|
}
|
|
1207
|
+
if (signal?.aborted === true)
|
|
1208
|
+
throw new Error('本轮已被新消息打断');
|
|
1174
1209
|
const promptText = restart ? RESTART_SEED : text;
|
|
1175
1210
|
let before = '';
|
|
1176
1211
|
let events = [];
|
|
@@ -1205,13 +1240,20 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1205
1240
|
}
|
|
1206
1241
|
await rpc('session/prompt', promptPayload(sessionId, promptText));
|
|
1207
1242
|
}
|
|
1208
|
-
const reply = await waitForAssistant(rpc, sessionId, before, timeoutMs, questions, stuckAck(folder));
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
}
|
|
1214
|
-
|
|
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
|
+
}
|
|
1215
1257
|
if (restart && (reply.text === stuckAck(folder) || reply.text === ''))
|
|
1216
1258
|
return restartAck(folder);
|
|
1217
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": {
|