@rezti/dsh-rez-wechat 0.1.23 → 0.1.24

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 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,7 @@ 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
139
  export declare function extractArchivedSessionIds(body: unknown): string[];
140
140
  /** Stock dsh archives one-way and hides blank sessions. Restore + attach + title. */
141
141
  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 = encodeClientRequest(endpoint, params);
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,21 @@ async function maybeSelectModel(rpc, sessionId, env) {
1057
1059
  }
1058
1060
  }
1059
1061
  }
1060
- /** Prefer session/page (0.1.5 surface); fall back to session/history (apiproxy). */
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
- const attempts = [
1063
- {
1064
- method: 'session/page',
1065
- params: {
1066
- address: { kind: 'session', sessionId },
1067
- // Official tip-of-log sentinel. MAX_SAFE_INTEGER is "past cursor" and always 400s.
1068
- throughSeq: -1,
1069
- maxMessages,
1070
- },
1071
- },
1072
- { method: 'session/history', params: { sessionId, maxMessages } },
1073
- ];
1074
- let lastError;
1075
- for (const attempt of attempts) {
1076
- try {
1077
- return historyEvents(await rpc(attempt.method, attempt.params));
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 }));
1084
1067
  }
1085
1068
  async function sessionAlive(rpc, sessionId) {
1086
1069
  try {
1087
1070
  await fetchSessionHistory(rpc, sessionId, 1);
1088
1071
  return true;
1089
1072
  }
1090
- catch {
1091
- return false;
1073
+ catch (error) {
1074
+ if (/not.found|不存在/i.test(error instanceof Error ? error.message : String(error)))
1075
+ return false;
1076
+ throw error;
1092
1077
  }
1093
1078
  }
1094
1079
  function promptPayload(sessionId, text) {
@@ -1208,19 +1193,14 @@ function composeWechatReply(events, ignorePending) {
1208
1193
  async function waitForAssistant(rpc, sessionId, beforeText, beforeSeq, timeoutMs, ignorePending, stuckText = STUCK_ACK, signal) {
1209
1194
  const deadline = Date.now() + timeoutMs;
1210
1195
  let last = { text: '', pendingQuestions: [] };
1211
- let idleOnce = false;
1212
1196
  const emptyDone = '(本轮无文字回复,请到网页查看)';
1213
1197
  while (Date.now() < deadline) {
1214
1198
  if (signal?.aborted === true) {
1215
1199
  throw new Error('本轮已被新消息打断(可发「重启对话」或再问一句)');
1216
1200
  }
1217
- let events = [];
1218
- try {
1219
- events = await fetchSessionHistory(rpc, sessionId, 40);
1220
- }
1221
- catch {
1222
- events = [];
1223
- }
1201
+ const history = await fetchSessionHistory(rpc, sessionId, 40);
1202
+ const cursorAware = beforeSeq >= 0 || history.some(event => eventSeq(event) >= 0);
1203
+ const events = cursorAware ? history.filter(event => eventSeq(event) > beforeSeq) : history;
1224
1204
  const composed = composeWechatReply(events, ignorePending);
1225
1205
  // 旧题(本轮 prompt 前就在的 Selection)不能立刻发回微信;只发新 turn 里新出现的题。
1226
1206
  if (composed.pendingQuestions.length > 0 && composed.text !== '') {
@@ -1230,24 +1210,17 @@ async function waitForAssistant(rpc, sessionId, beforeText, beforeSeq, timeoutMs
1230
1210
  return { text: stuckText, pendingQuestions: [] };
1231
1211
  if (composed.text !== '')
1232
1212
  last = composed;
1233
- const cursorAware = beforeSeq >= 0 || events.some(event => eventSeq(event) >= 0);
1234
1213
  if (cursorAware && turnSettledAfter(events, beforeSeq)) {
1235
- if (composed.text !== '' && composed.text !== beforeText)
1214
+ if (composed.text !== '')
1236
1215
  return composed;
1237
1216
  // Turn finished but no new usable text (tool-only / filtered monologue).
1238
1217
  return {
1239
- text: composed.text !== '' && composed.text === beforeText
1240
- ? '(本轮已完成,但没有新的文字回复;请到网页查看)'
1241
- : emptyDone,
1218
+ text: emptyDone,
1242
1219
  pendingQuestions: composed.pendingQuestions,
1243
1220
  };
1244
1221
  }
1245
1222
  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)
1223
+ if (!cursorAware && idle && composed.text !== '' && composed.text !== beforeText)
1251
1224
  return composed;
1252
1225
  await sleep(400);
1253
1226
  }
@@ -1264,7 +1237,7 @@ export async function runHeadlessViaWeb(opts) {
1264
1237
  const storeFile = sessionStorePath(env);
1265
1238
  const key = basename(opts.cwd ?? process.cwd()) || 'default';
1266
1239
  const customRpc = opts.rpc !== undefined;
1267
- const rpc = opts.rpc ?? ((method, params) => postRpc(webBaseUrl(env), method, params));
1240
+ const rpc = opts.rpc ?? ((method, params) => postRpc(webBaseUrl(env), method, params, RPC_TIMEOUT_MS, env, opts.signal));
1268
1241
  const timeoutMs = opts.timeoutMs ?? Number(env.DSH_BRIDGE_TIMEOUT_MS ?? 10 * 60 * 1000);
1269
1242
  const signal = opts.signal;
1270
1243
  const sidebarUnarchive = opts.sidebarUnarchive !== false && !customRpc;
@@ -1301,18 +1274,9 @@ export async function runHeadlessViaWeb(opts) {
1301
1274
  if (signal?.aborted === true)
1302
1275
  throw new Error('本轮已被新消息打断');
1303
1276
  const promptText = restart ? RESTART_SEED : text;
1304
- let before = '';
1305
- let beforeSeq = -1;
1306
- let events = [];
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
- }
1277
+ const events = await fetchSessionHistory(rpc, sessionId, 40);
1278
+ const before = usableAssistantText(lastAssistantText(events));
1279
+ const beforeSeq = historyCursor(events);
1316
1280
  const pending = extractPendingQuestions(events);
1317
1281
  const questions = pending.length > 0
1318
1282
  ? pending
@@ -1338,6 +1302,7 @@ export async function runHeadlessViaWeb(opts) {
1338
1302
  await rpc('session/prompt', promptPayload(sessionId, promptText));
1339
1303
  }
1340
1304
  const reply = await waitForAssistant(rpc, sessionId, before, beforeSeq, timeoutMs, questions, stuckAck(folder), signal);
1305
+ signal?.throwIfAborted();
1341
1306
  // Stuck / approval-blocked sessions poison the sticky pointer — drop it so
1342
1307
  // the next WeCom/WeChat turn opens a fresh web session instead of hanging.
1343
1308
  if (reply.text === stuckAck(folder)) {
@@ -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
- /** WeCom stream payload soft limit; oversize finish frames are dropped by the SDK. */
8
- export declare const WECOM_REPLY_MAX_CHARS = 20000;
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;
@@ -7,12 +7,41 @@ import { dshHome } from '@rezti/dsh-rez-sso';
7
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;
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.length <= WECOM_REPLY_MAX_CHARS)
13
+ if (Buffer.byteLength(text, 'utf8') <= WECOM_REPLY_MAX_BYTES)
14
14
  return text;
15
- return `${text.slice(0, WECOM_REPLY_MAX_CHARS - 16)}\n…(已截断)`;
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;
@@ -170,6 +199,7 @@ export class WecomChannel {
170
199
  }
171
200
  dropBindings() {
172
201
  for (const row of this.bindings.values()) {
202
+ row.turnAbort?.abort();
173
203
  try {
174
204
  row.client.disconnect();
175
205
  }
@@ -236,6 +266,8 @@ export class WecomChannel {
236
266
  * queue reaches it). Otherwise「重启对话」waits behind a hung「正在处理…」.
237
267
  */
238
268
  enqueueText(row, frame) {
269
+ if (this.rememberMsgid(row, frame))
270
+ return;
239
271
  const text = extractWecomText(frame);
240
272
  if (text.length > 0) {
241
273
  row.turnAbort?.abort();
@@ -243,7 +275,7 @@ export class WecomChannel {
243
275
  clearSessionStoreKey(join(this.home, 'dsh-rez-wecom', row.bot.room, 'web-sessions.json'), row.bot.room);
244
276
  }
245
277
  }
246
- this.enqueue(row, () => this.handleText(row, frame));
278
+ this.enqueue(row, () => this.handleText(row, frame, true));
247
279
  }
248
280
  rememberMsgid(row, frame) {
249
281
  const id = wecomMsgid(frame);
@@ -256,8 +288,8 @@ export class WecomChannel {
256
288
  row.seen.splice(0, row.seen.length - MAX_SEEN_MSGIDS);
257
289
  return false;
258
290
  }
259
- async handleText(row, frame) {
260
- if (this.rememberMsgid(row, frame))
291
+ async handleText(row, frame, remembered = false) {
292
+ if (!remembered && this.rememberMsgid(row, frame))
261
293
  return;
262
294
  const text = extractWecomText(frame);
263
295
  const streamId = newStreamId();
@@ -273,12 +305,12 @@ export class WecomChannel {
273
305
  const finish = async (content) => {
274
306
  if (finished)
275
307
  return;
276
- finished = true;
277
308
  await row.client.replyStream(frame, streamId, clipWecomReply(content), true);
309
+ finished = true;
278
310
  };
279
311
  try {
280
312
  await row.client.replyStream(frame, streamId, '正在处理…', false);
281
- const reply = await this.runTurn(row.bot.room, text, signal);
313
+ const reply = await abortableTurn(this.runTurn(row.bot.room, text, signal), signal);
282
314
  if (turnAbort.signal.aborted) {
283
315
  await finish('已取消(收到新消息)');
284
316
  return;
@@ -287,6 +319,7 @@ export class WecomChannel {
287
319
  }
288
320
  catch (error) {
289
321
  const why = error instanceof Error ? error.message : String(error);
322
+ row.hint = `「${row.bot.room}」回复未完成:${why}`;
290
323
  const superseded = turnAbort.signal.aborted && !timeout.aborted;
291
324
  if (superseded) {
292
325
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rezti/dsh-rez-wechat",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
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": {