@rezti/dsh-rez-wechat 0.1.23 → 0.1.25
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.d.ts +2 -0
- package/lib/index.js +38 -1
- package/lib/session-history.d.ts +14 -0
- package/lib/session-history.js +21 -0
- package/lib/web-shim.d.ts +11 -1
- package/lib/web-shim.js +39 -59
- package/lib/wecom-channel.d.ts +12 -2
- package/lib/wecom-channel.js +92 -10
- package/package.json +1 -1
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
|
|
2
3
|
export declare const name = "rez-wechat";
|
|
3
4
|
export declare const inject: string[];
|
|
5
|
+
export declare function channelHistoryRoute(ctx: Context): WebRoute;
|
|
4
6
|
export declare function apply(ctx: Context): Promise<void>;
|
package/lib/index.js
CHANGED
|
@@ -3,6 +3,7 @@ import { WeixinChannel } from './channel.js';
|
|
|
3
3
|
import { WecomChannel, shouldStartWecomWebhook } from './wecom-channel.js';
|
|
4
4
|
import { revealSessionInRegistry } from './unarchive.js';
|
|
5
5
|
import { WECOM_BINDABLE_ROOMS } from './wecom-store.js';
|
|
6
|
+
import { channelHistorySnapshot } from './session-history.js';
|
|
6
7
|
export const name = 'rez-wechat';
|
|
7
8
|
export const inject = ['systemPrompt', 'webServer', 'rezSso'];
|
|
8
9
|
const GUIDANCE = [
|
|
@@ -165,6 +166,42 @@ function getWorkspaceRegistry(ctx) {
|
|
|
165
166
|
return undefined;
|
|
166
167
|
}
|
|
167
168
|
}
|
|
169
|
+
export function channelHistoryRoute(ctx) {
|
|
170
|
+
return {
|
|
171
|
+
kind: 'exact',
|
|
172
|
+
path: '/api/dsh-rez-suite/channel/history',
|
|
173
|
+
handler: async (req, res) => {
|
|
174
|
+
if (!isLoopbackRequest(req))
|
|
175
|
+
return writeJson(res, 403, { error: 'forbidden: loopback-only' });
|
|
176
|
+
if (req.method !== 'POST')
|
|
177
|
+
return writeJson(res, 405, { error: 'method not allowed' });
|
|
178
|
+
if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) {
|
|
179
|
+
return writeJson(res, 403, { error: 'forbidden: cross-origin' });
|
|
180
|
+
}
|
|
181
|
+
const body = await readJsonBody(req);
|
|
182
|
+
const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : '';
|
|
183
|
+
const maxMessages = body?.maxMessages ?? 40;
|
|
184
|
+
if (!sessionId || typeof maxMessages !== 'number' || !Number.isInteger(maxMessages) || maxMessages < 1 || maxMessages > 100) {
|
|
185
|
+
return writeJson(res, 400, { error: 'invalid sessionId or maxMessages' });
|
|
186
|
+
}
|
|
187
|
+
const abort = new AbortController();
|
|
188
|
+
const close = () => { abort.abort(); };
|
|
189
|
+
res.on('close', close);
|
|
190
|
+
try {
|
|
191
|
+
const controller = ctx.get('sessionController');
|
|
192
|
+
const snapshot = await channelHistorySnapshot(controller, sessionId, maxMessages, abort.signal);
|
|
193
|
+
writeJson(res, 200, snapshot);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (!res.destroyed)
|
|
197
|
+
writeJson(res, 502, { error: error instanceof Error ? error.message : String(error) });
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
res.off('close', close);
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
168
205
|
export async function apply(ctx) {
|
|
169
206
|
const channel = new WeixinChannel();
|
|
170
207
|
channel.startIfLoggedIn();
|
|
@@ -175,7 +212,7 @@ export async function apply(ctx) {
|
|
|
175
212
|
order: 162,
|
|
176
213
|
text: GUIDANCE,
|
|
177
214
|
}), 'dsh-rez-wechat: prompt');
|
|
178
|
-
const routes = [...weixinRoutes(channel, ctx), ...wecomRoutes(wecom)];
|
|
215
|
+
const routes = [channelHistoryRoute(ctx), ...weixinRoutes(channel, ctx), ...wecomRoutes(wecom)];
|
|
179
216
|
ctx.effect(() => {
|
|
180
217
|
const disposers = routes.map(route => ctx.webServer.register(route));
|
|
181
218
|
return () => { for (const dispose of disposers)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Reuse the host's official SessionController; never implement a second stream carrier. */
|
|
2
|
+
export interface ChannelSessionController {
|
|
3
|
+
follow(request: {
|
|
4
|
+
address: {
|
|
5
|
+
kind: 'session';
|
|
6
|
+
sessionId: string;
|
|
7
|
+
};
|
|
8
|
+
maxMessages: number;
|
|
9
|
+
}, signal: AbortSignal): AsyncIterable<unknown>;
|
|
10
|
+
}
|
|
11
|
+
export declare function channelHistorySnapshot(controller: ChannelSessionController | undefined, sessionId: string, maxMessages?: number, signal?: AbortSignal): Promise<{
|
|
12
|
+
records: unknown[];
|
|
13
|
+
cursor: number;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export async function channelHistorySnapshot(controller, sessionId, maxMessages = 40, signal) {
|
|
2
|
+
if (!controller)
|
|
3
|
+
throw new Error('未找到官方 sessionController,请使用 Harness 0.1.5-rc.2 启动本测试版。');
|
|
4
|
+
const abort = new AbortController();
|
|
5
|
+
const combined = AbortSignal.any([abort.signal, AbortSignal.timeout(10_000), ...(signal ? [signal] : [])]);
|
|
6
|
+
const stream = controller.follow({ address: { kind: 'session', sessionId }, maxMessages }, combined);
|
|
7
|
+
try {
|
|
8
|
+
for await (const value of stream) {
|
|
9
|
+
combined.throwIfAborted();
|
|
10
|
+
const frame = value;
|
|
11
|
+
if (frame?.type !== 'snapshot' || !Array.isArray(frame.records) || !Number.isSafeInteger(frame.cursor)) {
|
|
12
|
+
throw new Error('官方会话快照格式不兼容,无法读取回复。');
|
|
13
|
+
}
|
|
14
|
+
return { records: frame.records, cursor: frame.cursor };
|
|
15
|
+
}
|
|
16
|
+
throw new Error('官方会话订阅未返回快照。');
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
abort.abort();
|
|
20
|
+
}
|
|
21
|
+
}
|
package/lib/web-shim.d.ts
CHANGED
|
@@ -135,7 +135,17 @@ export declare function weixinBridgeEnv(opts: {
|
|
|
135
135
|
launcherPath: string;
|
|
136
136
|
shimScript: string;
|
|
137
137
|
}): NodeJS.ProcessEnv;
|
|
138
|
-
export declare function postRpc(baseUrl: string, method: string, params: Record<string, unknown>, timeoutMs?: number, env?: NodeJS.ProcessEnv): Promise<unknown>;
|
|
138
|
+
export declare function postRpc(baseUrl: string, method: string, params: Record<string, unknown>, timeoutMs?: number, env?: NodeJS.ProcessEnv, signal?: AbortSignal): Promise<unknown>;
|
|
139
|
+
/** Read later assistant output for channels with proactive delivery. */
|
|
140
|
+
export declare function sessionFollowupSnapshot(opts: {
|
|
141
|
+
room: string;
|
|
142
|
+
env?: NodeJS.ProcessEnv;
|
|
143
|
+
afterSeq?: number;
|
|
144
|
+
}): Promise<{
|
|
145
|
+
sessionId?: string;
|
|
146
|
+
cursor: number;
|
|
147
|
+
text: string;
|
|
148
|
+
}>;
|
|
139
149
|
export declare function extractArchivedSessionIds(body: unknown): string[];
|
|
140
150
|
/** Stock dsh archives one-way and hides blank sessions. Restore + attach + title. */
|
|
141
151
|
export declare function ensureSessionVisible(rpc: RpcFn, sessionId: string, env?: NodeJS.ProcessEnv, workspaceId?: string, loopback?: boolean): Promise<void>;
|
package/lib/web-shim.js
CHANGED
|
@@ -956,7 +956,7 @@ export function weixinBridgeEnv(opts) {
|
|
|
956
956
|
async function sleep(ms) {
|
|
957
957
|
await new Promise(resolve => setTimeout(resolve, ms));
|
|
958
958
|
}
|
|
959
|
-
export async function postRpc(baseUrl, method, params, timeoutMs = RPC_TIMEOUT_MS, env = process.env) {
|
|
959
|
+
export async function postRpc(baseUrl, method, params, timeoutMs = RPC_TIMEOUT_MS, env = process.env, signal) {
|
|
960
960
|
const origin = (() => {
|
|
961
961
|
try {
|
|
962
962
|
const withScheme = /:\/\//.test(baseUrl) ? baseUrl : `http://${baseUrl}`;
|
|
@@ -969,7 +969,9 @@ export async function postRpc(baseUrl, method, params, timeoutMs = RPC_TIMEOUT_M
|
|
|
969
969
|
})();
|
|
970
970
|
const endpoint = normalizeRpcEndpoint(method);
|
|
971
971
|
const url = `${origin}/api/${endpoint}`;
|
|
972
|
-
const envelope =
|
|
972
|
+
const envelope = endpoint === 'dsh-rez-suite/channel/history'
|
|
973
|
+
? params
|
|
974
|
+
: encodeClientRequest(endpoint, params);
|
|
973
975
|
const send = async (cookie) => {
|
|
974
976
|
const headers = { 'content-type': 'application/json' };
|
|
975
977
|
if (cookie !== undefined && cookie.length > 0)
|
|
@@ -978,7 +980,7 @@ export async function postRpc(baseUrl, method, params, timeoutMs = RPC_TIMEOUT_M
|
|
|
978
980
|
method: 'POST',
|
|
979
981
|
headers,
|
|
980
982
|
body: JSON.stringify(envelope),
|
|
981
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
983
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
|
|
982
984
|
});
|
|
983
985
|
};
|
|
984
986
|
let cookie = await authCookieFor(origin, env);
|
|
@@ -1057,38 +1059,36 @@ async function maybeSelectModel(rpc, sessionId, env) {
|
|
|
1057
1059
|
}
|
|
1058
1060
|
}
|
|
1059
1061
|
}
|
|
1060
|
-
/**
|
|
1062
|
+
/** Read the official follow opening snapshot via our authenticated loopback route.
|
|
1063
|
+
* session/page requires a real cursor; -1 means an empty log, not its tip.
|
|
1064
|
+
*/
|
|
1061
1065
|
async function fetchSessionHistory(rpc, sessionId, maxMessages = 40) {
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
}
|
|
1079
|
-
catch (error) {
|
|
1080
|
-
lastError = error;
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
1066
|
+
return historyEvents(await rpc('dsh-rez-suite/channel/history', { sessionId, maxMessages }));
|
|
1067
|
+
}
|
|
1068
|
+
/** Read later assistant output for channels with proactive delivery. */
|
|
1069
|
+
export async function sessionFollowupSnapshot(opts) {
|
|
1070
|
+
const env = opts.env ?? process.env;
|
|
1071
|
+
const store = loadSessionStore(sessionStorePath(env));
|
|
1072
|
+
const key = basename(opts.room) || 'default';
|
|
1073
|
+
const sessionId = store.sessions[key]?.sessionId;
|
|
1074
|
+
if (!sessionId)
|
|
1075
|
+
return { cursor: opts.afterSeq ?? -1, text: '' };
|
|
1076
|
+
const rpc = (method, params) => postRpc(webBaseUrl(env), method, params, RPC_TIMEOUT_MS, env);
|
|
1077
|
+
const events = await fetchSessionHistory(rpc, sessionId, 40);
|
|
1078
|
+
const cursor = historyCursor(events);
|
|
1079
|
+
const afterSeq = opts.afterSeq;
|
|
1080
|
+
const after = afterSeq === undefined ? events : events.filter(event => eventSeq(event) > afterSeq);
|
|
1081
|
+
return { sessionId, cursor, text: usableAssistantText(lastAssistantText(after)) };
|
|
1084
1082
|
}
|
|
1085
1083
|
async function sessionAlive(rpc, sessionId) {
|
|
1086
1084
|
try {
|
|
1087
1085
|
await fetchSessionHistory(rpc, sessionId, 1);
|
|
1088
1086
|
return true;
|
|
1089
1087
|
}
|
|
1090
|
-
catch {
|
|
1091
|
-
|
|
1088
|
+
catch (error) {
|
|
1089
|
+
if (/not.found|不存在/i.test(error instanceof Error ? error.message : String(error)))
|
|
1090
|
+
return false;
|
|
1091
|
+
throw error;
|
|
1092
1092
|
}
|
|
1093
1093
|
}
|
|
1094
1094
|
function promptPayload(sessionId, text) {
|
|
@@ -1208,19 +1208,14 @@ function composeWechatReply(events, ignorePending) {
|
|
|
1208
1208
|
async function waitForAssistant(rpc, sessionId, beforeText, beforeSeq, timeoutMs, ignorePending, stuckText = STUCK_ACK, signal) {
|
|
1209
1209
|
const deadline = Date.now() + timeoutMs;
|
|
1210
1210
|
let last = { text: '', pendingQuestions: [] };
|
|
1211
|
-
let idleOnce = false;
|
|
1212
1211
|
const emptyDone = '(本轮无文字回复,请到网页查看)';
|
|
1213
1212
|
while (Date.now() < deadline) {
|
|
1214
1213
|
if (signal?.aborted === true) {
|
|
1215
1214
|
throw new Error('本轮已被新消息打断(可发「重启对话」或再问一句)');
|
|
1216
1215
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
}
|
|
1221
|
-
catch {
|
|
1222
|
-
events = [];
|
|
1223
|
-
}
|
|
1216
|
+
const history = await fetchSessionHistory(rpc, sessionId, 40);
|
|
1217
|
+
const cursorAware = beforeSeq >= 0 || history.some(event => eventSeq(event) >= 0);
|
|
1218
|
+
const events = cursorAware ? history.filter(event => eventSeq(event) > beforeSeq) : history;
|
|
1224
1219
|
const composed = composeWechatReply(events, ignorePending);
|
|
1225
1220
|
// 旧题(本轮 prompt 前就在的 Selection)不能立刻发回微信;只发新 turn 里新出现的题。
|
|
1226
1221
|
if (composed.pendingQuestions.length > 0 && composed.text !== '') {
|
|
@@ -1230,24 +1225,17 @@ async function waitForAssistant(rpc, sessionId, beforeText, beforeSeq, timeoutMs
|
|
|
1230
1225
|
return { text: stuckText, pendingQuestions: [] };
|
|
1231
1226
|
if (composed.text !== '')
|
|
1232
1227
|
last = composed;
|
|
1233
|
-
const cursorAware = beforeSeq >= 0 || events.some(event => eventSeq(event) >= 0);
|
|
1234
1228
|
if (cursorAware && turnSettledAfter(events, beforeSeq)) {
|
|
1235
|
-
if (composed.text !== ''
|
|
1229
|
+
if (composed.text !== '')
|
|
1236
1230
|
return composed;
|
|
1237
1231
|
// Turn finished but no new usable text (tool-only / filtered monologue).
|
|
1238
1232
|
return {
|
|
1239
|
-
text:
|
|
1240
|
-
? '(本轮已完成,但没有新的文字回复;请到网页查看)'
|
|
1241
|
-
: emptyDone,
|
|
1233
|
+
text: emptyDone,
|
|
1242
1234
|
pendingQuestions: composed.pendingQuestions,
|
|
1243
1235
|
};
|
|
1244
1236
|
}
|
|
1245
1237
|
const idle = turnIsIdle(events);
|
|
1246
|
-
if (idle)
|
|
1247
|
-
idleOnce = true;
|
|
1248
|
-
if (idle && composed.text !== '' && composed.text !== beforeText)
|
|
1249
|
-
return composed;
|
|
1250
|
-
if (idleOnce && composed.text !== '' && composed.text !== beforeText)
|
|
1238
|
+
if (!cursorAware && idle && composed.text !== '' && composed.text !== beforeText)
|
|
1251
1239
|
return composed;
|
|
1252
1240
|
await sleep(400);
|
|
1253
1241
|
}
|
|
@@ -1264,7 +1252,7 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1264
1252
|
const storeFile = sessionStorePath(env);
|
|
1265
1253
|
const key = basename(opts.cwd ?? process.cwd()) || 'default';
|
|
1266
1254
|
const customRpc = opts.rpc !== undefined;
|
|
1267
|
-
const rpc = opts.rpc ?? ((method, params) => postRpc(webBaseUrl(env), method, params));
|
|
1255
|
+
const rpc = opts.rpc ?? ((method, params) => postRpc(webBaseUrl(env), method, params, RPC_TIMEOUT_MS, env, opts.signal));
|
|
1268
1256
|
const timeoutMs = opts.timeoutMs ?? Number(env.DSH_BRIDGE_TIMEOUT_MS ?? 10 * 60 * 1000);
|
|
1269
1257
|
const signal = opts.signal;
|
|
1270
1258
|
const sidebarUnarchive = opts.sidebarUnarchive !== false && !customRpc;
|
|
@@ -1301,18 +1289,9 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1301
1289
|
if (signal?.aborted === true)
|
|
1302
1290
|
throw new Error('本轮已被新消息打断');
|
|
1303
1291
|
const promptText = restart ? RESTART_SEED : text;
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
try {
|
|
1308
|
-
events = await fetchSessionHistory(rpc, sessionId, 40);
|
|
1309
|
-
before = usableAssistantText(lastAssistantText(events));
|
|
1310
|
-
beforeSeq = historyCursor(events);
|
|
1311
|
-
}
|
|
1312
|
-
catch {
|
|
1313
|
-
before = '';
|
|
1314
|
-
beforeSeq = -1;
|
|
1315
|
-
}
|
|
1292
|
+
const events = await fetchSessionHistory(rpc, sessionId, 40);
|
|
1293
|
+
const before = usableAssistantText(lastAssistantText(events));
|
|
1294
|
+
const beforeSeq = historyCursor(events);
|
|
1316
1295
|
const pending = extractPendingQuestions(events);
|
|
1317
1296
|
const questions = pending.length > 0
|
|
1318
1297
|
? pending
|
|
@@ -1338,6 +1317,7 @@ export async function runHeadlessViaWeb(opts) {
|
|
|
1338
1317
|
await rpc('session/prompt', promptPayload(sessionId, promptText));
|
|
1339
1318
|
}
|
|
1340
1319
|
const reply = await waitForAssistant(rpc, sessionId, before, beforeSeq, timeoutMs, questions, stuckAck(folder), signal);
|
|
1320
|
+
signal?.throwIfAborted();
|
|
1341
1321
|
// Stuck / approval-blocked sessions poison the sticky pointer — drop it so
|
|
1342
1322
|
// the next WeCom/WeChat turn opens a fresh web session instead of hanging.
|
|
1343
1323
|
if (reply.text === stuckAck(folder)) {
|
package/lib/wecom-channel.d.ts
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
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
|
-
/**
|
|
8
|
-
export declare const
|
|
7
|
+
/** Official replyStream content limit, measured in UTF-8 bytes. */
|
|
8
|
+
export declare const WECOM_REPLY_MAX_BYTES = 20480;
|
|
9
9
|
export declare function clipWecomReply(text: string): string;
|
|
10
|
+
/** Bound even a turn implementation that fails to observe cancellation. */
|
|
11
|
+
export declare function abortableTurn<T>(work: Promise<T>, signal: AbortSignal): Promise<T>;
|
|
10
12
|
export type WecomBindingPhase = 'idle' | 'connecting' | 'authenticated' | 'error';
|
|
11
13
|
export interface WecomBindingStatus {
|
|
12
14
|
room: string;
|
|
@@ -24,6 +26,12 @@ export interface WecomWsClient {
|
|
|
24
26
|
disconnect: () => void;
|
|
25
27
|
on: (event: string, handler: (...args: unknown[]) => void) => unknown;
|
|
26
28
|
replyStream: (frame: unknown, streamId: string, content: string, finish?: boolean) => Promise<unknown>;
|
|
29
|
+
sendMessage?: (chatid: string, body: {
|
|
30
|
+
msgtype: 'markdown';
|
|
31
|
+
markdown: {
|
|
32
|
+
content: string;
|
|
33
|
+
};
|
|
34
|
+
}) => Promise<unknown>;
|
|
27
35
|
replyWelcome: (frame: unknown, body: {
|
|
28
36
|
msgtype: string;
|
|
29
37
|
text: {
|
|
@@ -40,6 +48,7 @@ export type WecomRunTurn = (room: string, text: string, signal?: AbortSignal) =>
|
|
|
40
48
|
export declare const WECOM_TURN_TIMEOUT_MS = 90000;
|
|
41
49
|
export declare function extractWecomText(frame: unknown): string;
|
|
42
50
|
export declare function wecomMsgid(frame: unknown): string;
|
|
51
|
+
export declare function wecomChatId(frame: unknown): string;
|
|
43
52
|
export declare function createOfficialWecomClient(opts: {
|
|
44
53
|
botId: string;
|
|
45
54
|
secret: string;
|
|
@@ -74,5 +83,6 @@ export declare class WecomChannel {
|
|
|
74
83
|
private enqueueText;
|
|
75
84
|
private rememberMsgid;
|
|
76
85
|
private handleText;
|
|
86
|
+
private armFollowup;
|
|
77
87
|
private handleNonText;
|
|
78
88
|
}
|
package/lib/wecom-channel.js
CHANGED
|
@@ -4,15 +4,44 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { dshHome } from '@rezti/dsh-rez-sso';
|
|
7
|
-
import { clearSessionStoreKey, isRestartCommand, runHeadlessViaWeb, } from './web-shim.js';
|
|
7
|
+
import { clearSessionStoreKey, isRestartCommand, runHeadlessViaWeb, sessionFollowupSnapshot, } 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
|
-
/**
|
|
11
|
-
export const
|
|
10
|
+
/** Official replyStream content limit, measured in UTF-8 bytes. */
|
|
11
|
+
export const WECOM_REPLY_MAX_BYTES = 20_480;
|
|
12
12
|
export function clipWecomReply(text) {
|
|
13
|
-
if (text
|
|
13
|
+
if (Buffer.byteLength(text, 'utf8') <= WECOM_REPLY_MAX_BYTES)
|
|
14
14
|
return text;
|
|
15
|
-
|
|
15
|
+
const suffix = '\n…(已截断,请到网页查看完整回复)';
|
|
16
|
+
const budget = WECOM_REPLY_MAX_BYTES - Buffer.byteLength(suffix, 'utf8');
|
|
17
|
+
let bytes = 0;
|
|
18
|
+
let end = 0;
|
|
19
|
+
for (const character of text) {
|
|
20
|
+
bytes += Buffer.byteLength(character, 'utf8');
|
|
21
|
+
if (bytes > budget)
|
|
22
|
+
break;
|
|
23
|
+
end += character.length;
|
|
24
|
+
}
|
|
25
|
+
return text.slice(0, end) + suffix;
|
|
26
|
+
}
|
|
27
|
+
/** Bound even a turn implementation that fails to observe cancellation. */
|
|
28
|
+
export async function abortableTurn(work, signal) {
|
|
29
|
+
let abort;
|
|
30
|
+
try {
|
|
31
|
+
return await Promise.race([
|
|
32
|
+
work,
|
|
33
|
+
new Promise((_, reject) => {
|
|
34
|
+
abort = () => reject(signal.reason ?? new Error('本轮已被新消息打断'));
|
|
35
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
36
|
+
if (signal.aborted)
|
|
37
|
+
abort();
|
|
38
|
+
}),
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
if (abort)
|
|
43
|
+
signal.removeEventListener('abort', abort);
|
|
44
|
+
}
|
|
16
45
|
}
|
|
17
46
|
/** Cap how long one WeCom turn can block the per-room queue (stuck Kimi / approval). */
|
|
18
47
|
export const WECOM_TURN_TIMEOUT_MS = 90_000;
|
|
@@ -61,6 +90,21 @@ export function wecomMsgid(frame) {
|
|
|
61
90
|
const id = body.msgid;
|
|
62
91
|
return typeof id === 'string' ? id : '';
|
|
63
92
|
}
|
|
93
|
+
export function wecomChatId(frame) {
|
|
94
|
+
if (typeof frame !== 'object' || frame === null)
|
|
95
|
+
return '';
|
|
96
|
+
const body = frame.body;
|
|
97
|
+
if (typeof body !== 'object' || body === null)
|
|
98
|
+
return '';
|
|
99
|
+
const value = body;
|
|
100
|
+
if (typeof value.chatid === 'string' && value.chatid.trim())
|
|
101
|
+
return value.chatid.trim();
|
|
102
|
+
const from = value.from;
|
|
103
|
+
if (typeof from === 'object' && from !== null && typeof from.userid === 'string') {
|
|
104
|
+
return from.userid.trim();
|
|
105
|
+
}
|
|
106
|
+
return '';
|
|
107
|
+
}
|
|
64
108
|
function textContent(value) {
|
|
65
109
|
if (typeof value === 'string')
|
|
66
110
|
return value.trim();
|
|
@@ -170,6 +214,9 @@ export class WecomChannel {
|
|
|
170
214
|
}
|
|
171
215
|
dropBindings() {
|
|
172
216
|
for (const row of this.bindings.values()) {
|
|
217
|
+
row.turnAbort?.abort();
|
|
218
|
+
if (row.followupTimer !== undefined)
|
|
219
|
+
clearInterval(row.followupTimer);
|
|
173
220
|
try {
|
|
174
221
|
row.client.disconnect();
|
|
175
222
|
}
|
|
@@ -236,14 +283,19 @@ export class WecomChannel {
|
|
|
236
283
|
* queue reaches it). Otherwise「重启对话」waits behind a hung「正在处理…」.
|
|
237
284
|
*/
|
|
238
285
|
enqueueText(row, frame) {
|
|
286
|
+
if (this.rememberMsgid(row, frame))
|
|
287
|
+
return;
|
|
239
288
|
const text = extractWecomText(frame);
|
|
289
|
+
const chatId = wecomChatId(frame);
|
|
290
|
+
if (chatId.length > 0)
|
|
291
|
+
row.chatId = chatId;
|
|
240
292
|
if (text.length > 0) {
|
|
241
293
|
row.turnAbort?.abort();
|
|
242
294
|
if (isRestartCommand(text)) {
|
|
243
295
|
clearSessionStoreKey(join(this.home, 'dsh-rez-wecom', row.bot.room, 'web-sessions.json'), row.bot.room);
|
|
244
296
|
}
|
|
245
297
|
}
|
|
246
|
-
this.enqueue(row, () => this.handleText(row, frame));
|
|
298
|
+
this.enqueue(row, () => this.handleText(row, frame, true));
|
|
247
299
|
}
|
|
248
300
|
rememberMsgid(row, frame) {
|
|
249
301
|
const id = wecomMsgid(frame);
|
|
@@ -256,8 +308,8 @@ export class WecomChannel {
|
|
|
256
308
|
row.seen.splice(0, row.seen.length - MAX_SEEN_MSGIDS);
|
|
257
309
|
return false;
|
|
258
310
|
}
|
|
259
|
-
async handleText(row, frame) {
|
|
260
|
-
if (this.rememberMsgid(row, frame))
|
|
311
|
+
async handleText(row, frame, remembered = false) {
|
|
312
|
+
if (!remembered && this.rememberMsgid(row, frame))
|
|
261
313
|
return;
|
|
262
314
|
const text = extractWecomText(frame);
|
|
263
315
|
const streamId = newStreamId();
|
|
@@ -273,20 +325,22 @@ export class WecomChannel {
|
|
|
273
325
|
const finish = async (content) => {
|
|
274
326
|
if (finished)
|
|
275
327
|
return;
|
|
276
|
-
finished = true;
|
|
277
328
|
await row.client.replyStream(frame, streamId, clipWecomReply(content), true);
|
|
329
|
+
finished = true;
|
|
278
330
|
};
|
|
279
331
|
try {
|
|
280
332
|
await row.client.replyStream(frame, streamId, '正在处理…', false);
|
|
281
|
-
const reply = await this.runTurn(row.bot.room, text, signal);
|
|
333
|
+
const reply = await abortableTurn(this.runTurn(row.bot.room, text, signal), signal);
|
|
282
334
|
if (turnAbort.signal.aborted) {
|
|
283
335
|
await finish('已取消(收到新消息)');
|
|
284
336
|
return;
|
|
285
337
|
}
|
|
286
338
|
await finish(reply.length > 0 ? reply : '(空回复)');
|
|
339
|
+
await this.armFollowup(row);
|
|
287
340
|
}
|
|
288
341
|
catch (error) {
|
|
289
342
|
const why = error instanceof Error ? error.message : String(error);
|
|
343
|
+
row.hint = `「${row.bot.room}」回复未完成:${why}`;
|
|
290
344
|
const superseded = turnAbort.signal.aborted && !timeout.aborted;
|
|
291
345
|
if (superseded) {
|
|
292
346
|
try {
|
|
@@ -319,6 +373,34 @@ export class WecomChannel {
|
|
|
319
373
|
delete row.turnAbort;
|
|
320
374
|
}
|
|
321
375
|
}
|
|
376
|
+
async armFollowup(row) {
|
|
377
|
+
if (row.followupTimer !== undefined || row.client.sendMessage === undefined || row.chatId === undefined)
|
|
378
|
+
return;
|
|
379
|
+
const followupEnv = {
|
|
380
|
+
...process.env,
|
|
381
|
+
REZ_WECHAT_SESSION_STORE: join(this.home, 'dsh-rez-wecom', row.bot.room, 'web-sessions.json'),
|
|
382
|
+
};
|
|
383
|
+
try {
|
|
384
|
+
const baseline = await sessionFollowupSnapshot({ room: row.bot.room, env: followupEnv });
|
|
385
|
+
row.followupCursor = baseline.cursor;
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
row.followupTimer = setInterval(() => {
|
|
391
|
+
const options = row.followupCursor === undefined
|
|
392
|
+
? { room: row.bot.room }
|
|
393
|
+
: { room: row.bot.room, afterSeq: row.followupCursor };
|
|
394
|
+
void sessionFollowupSnapshot({ ...options, env: followupEnv }).then(async (snapshot) => {
|
|
395
|
+
if (snapshot.sessionId === undefined)
|
|
396
|
+
return;
|
|
397
|
+
row.followupCursor = snapshot.cursor;
|
|
398
|
+
if (snapshot.text.length === 0 || row.chatId === undefined || row.client.sendMessage === undefined)
|
|
399
|
+
return;
|
|
400
|
+
await row.client.sendMessage(row.chatId, { msgtype: 'markdown', markdown: { content: clipWecomReply(snapshot.text) } });
|
|
401
|
+
}).catch(error => console.warn(`[wecom] ${row.bot.room} 定时提醒投递失败:${error instanceof Error ? error.message : String(error)}`));
|
|
402
|
+
}, 2000);
|
|
403
|
+
}
|
|
322
404
|
async handleNonText(row, frame) {
|
|
323
405
|
const text = extractWecomText(frame);
|
|
324
406
|
if (text.length > 0) {
|
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.25",
|
|
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": {
|