@rezti/dsh-rez-wechat 0.1.22 → 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
@@ -106,6 +106,19 @@ export declare function parseChoice(userText: string, questions: PendingQuestion
106
106
  label: string;
107
107
  } | undefined;
108
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
+ */
109
122
  export declare function lastAssistantText(events: unknown[]): string;
110
123
  export declare function turnIsIdle(events: unknown[]): boolean;
111
124
  export declare function pickWechatModel(models: unknown, env?: NodeJS.ProcessEnv): ModelRef | undefined;
@@ -122,7 +135,7 @@ export declare function weixinBridgeEnv(opts: {
122
135
  launcherPath: string;
123
136
  shimScript: string;
124
137
  }): NodeJS.ProcessEnv;
125
- 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>;
126
139
  export declare function extractArchivedSessionIds(body: unknown): string[];
127
140
  /** Stock dsh archives one-way and hides blank sessions. Restore + attach + title. */
128
141
  export declare function ensureSessionVisible(rpc: RpcFn, sessionId: string, env?: NodeJS.ProcessEnv, workspaceId?: string, loopback?: boolean): Promise<void>;
package/lib/web-shim.js CHANGED
@@ -733,24 +733,105 @@ export function historyEvents(body) {
733
733
  : []);
734
734
  return raw.map(unwrapHistoryItem);
735
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
+ */
736
791
  export function lastAssistantText(events) {
737
- let text = '';
792
+ let lastUserSeq = -1;
738
793
  for (const event of events) {
739
794
  if (typeof event !== 'object' || event === null)
740
795
  continue;
741
796
  const rec = event;
742
797
  const type = typeof rec.type === 'string' ? rec.type : '';
743
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 = [];
805
+ for (const event of events) {
806
+ if (typeof event !== 'object' || event === null)
807
+ continue;
808
+ const rec = event;
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
+ }
818
+ const role = typeof rec.role === 'string' ? rec.role : '';
744
819
  const isAssistant = type === 'assistant/message' || role === 'assistant';
745
- if (!isAssistant)
820
+ if (!isAssistant || !afterUser)
746
821
  continue;
747
822
  const data = typeof rec.data === 'object' && rec.data !== null ? rec.data : rec;
748
823
  const message = typeof data.message === 'object' && data.message !== null ? data.message : data;
749
- const chunk = flattenVisibleText(message.content ?? data.content ?? rec.content) || (typeof rec.text === 'string' ? stripThinkTags(rec.text) : '');
824
+ const chunk = flattenVisibleText(message.content ?? data.content ?? rec.content)
825
+ || (typeof rec.text === 'string' ? stripThinkTags(rec.text) : '');
750
826
  if (chunk.trim() !== '')
751
- text = chunk;
827
+ fromMessage = chunk;
752
828
  }
753
- return text.trim();
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;
754
835
  }
755
836
  export function turnIsIdle(events) {
756
837
  let open = 0;
@@ -875,7 +956,7 @@ export function weixinBridgeEnv(opts) {
875
956
  async function sleep(ms) {
876
957
  await new Promise(resolve => setTimeout(resolve, ms));
877
958
  }
878
- 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) {
879
960
  const origin = (() => {
880
961
  try {
881
962
  const withScheme = /:\/\//.test(baseUrl) ? baseUrl : `http://${baseUrl}`;
@@ -888,7 +969,9 @@ export async function postRpc(baseUrl, method, params, timeoutMs = RPC_TIMEOUT_M
888
969
  })();
889
970
  const endpoint = normalizeRpcEndpoint(method);
890
971
  const url = `${origin}/api/${endpoint}`;
891
- const envelope = encodeClientRequest(endpoint, params);
972
+ const envelope = endpoint === 'dsh-rez-suite/channel/history'
973
+ ? params
974
+ : encodeClientRequest(endpoint, params);
892
975
  const send = async (cookie) => {
893
976
  const headers = { 'content-type': 'application/json' };
894
977
  if (cookie !== undefined && cookie.length > 0)
@@ -897,7 +980,7 @@ export async function postRpc(baseUrl, method, params, timeoutMs = RPC_TIMEOUT_M
897
980
  method: 'POST',
898
981
  headers,
899
982
  body: JSON.stringify(envelope),
900
- signal: AbortSignal.timeout(timeoutMs),
983
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
901
984
  });
902
985
  };
903
986
  let cookie = await authCookieFor(origin, env);
@@ -976,38 +1059,21 @@ async function maybeSelectModel(rpc, sessionId, env) {
976
1059
  }
977
1060
  }
978
1061
  }
979
- /** 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
+ */
980
1065
  async function fetchSessionHistory(rpc, sessionId, maxMessages = 40) {
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));
1066
+ return historyEvents(await rpc('dsh-rez-suite/channel/history', { sessionId, maxMessages }));
1003
1067
  }
1004
1068
  async function sessionAlive(rpc, sessionId) {
1005
1069
  try {
1006
1070
  await fetchSessionHistory(rpc, sessionId, 1);
1007
1071
  return true;
1008
1072
  }
1009
- catch {
1010
- return false;
1073
+ catch (error) {
1074
+ if (/not.found|不存在/i.test(error instanceof Error ? error.message : String(error)))
1075
+ return false;
1076
+ throw error;
1011
1077
  }
1012
1078
  }
1013
1079
  function promptPayload(sessionId, text) {
@@ -1124,21 +1190,17 @@ function composeWechatReply(events, ignorePending) {
1124
1190
  }
1125
1191
  return { text: visible, pendingQuestions };
1126
1192
  }
1127
- async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePending, stuckText = STUCK_ACK, signal) {
1193
+ async function waitForAssistant(rpc, sessionId, beforeText, beforeSeq, timeoutMs, ignorePending, stuckText = STUCK_ACK, signal) {
1128
1194
  const deadline = Date.now() + timeoutMs;
1129
1195
  let last = { text: '', pendingQuestions: [] };
1130
- let idleOnce = false;
1196
+ const emptyDone = '(本轮无文字回复,请到网页查看)';
1131
1197
  while (Date.now() < deadline) {
1132
1198
  if (signal?.aborted === true) {
1133
1199
  throw new Error('本轮已被新消息打断(可发「重启对话」或再问一句)');
1134
1200
  }
1135
- let events = [];
1136
- try {
1137
- events = await fetchSessionHistory(rpc, sessionId, 40);
1138
- }
1139
- catch {
1140
- events = [];
1141
- }
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;
1142
1204
  const composed = composeWechatReply(events, ignorePending);
1143
1205
  // 旧题(本轮 prompt 前就在的 Selection)不能立刻发回微信;只发新 turn 里新出现的题。
1144
1206
  if (composed.pendingQuestions.length > 0 && composed.text !== '') {
@@ -1148,12 +1210,17 @@ async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs, ignorePen
1148
1210
  return { text: stuckText, pendingQuestions: [] };
1149
1211
  if (composed.text !== '')
1150
1212
  last = composed;
1213
+ if (cursorAware && turnSettledAfter(events, beforeSeq)) {
1214
+ if (composed.text !== '')
1215
+ return composed;
1216
+ // Turn finished but no new usable text (tool-only / filtered monologue).
1217
+ return {
1218
+ text: emptyDone,
1219
+ pendingQuestions: composed.pendingQuestions,
1220
+ };
1221
+ }
1151
1222
  const idle = turnIsIdle(events);
1152
- if (idle)
1153
- idleOnce = true;
1154
- if (idle && composed.text !== '' && composed.text !== beforeText)
1155
- return composed;
1156
- if (idleOnce && composed.text !== '' && composed.text !== beforeText)
1223
+ if (!cursorAware && idle && composed.text !== '' && composed.text !== beforeText)
1157
1224
  return composed;
1158
1225
  await sleep(400);
1159
1226
  }
@@ -1170,7 +1237,7 @@ export async function runHeadlessViaWeb(opts) {
1170
1237
  const storeFile = sessionStorePath(env);
1171
1238
  const key = basename(opts.cwd ?? process.cwd()) || 'default';
1172
1239
  const customRpc = opts.rpc !== undefined;
1173
- 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));
1174
1241
  const timeoutMs = opts.timeoutMs ?? Number(env.DSH_BRIDGE_TIMEOUT_MS ?? 10 * 60 * 1000);
1175
1242
  const signal = opts.signal;
1176
1243
  const sidebarUnarchive = opts.sidebarUnarchive !== false && !customRpc;
@@ -1207,15 +1274,9 @@ export async function runHeadlessViaWeb(opts) {
1207
1274
  if (signal?.aborted === true)
1208
1275
  throw new Error('本轮已被新消息打断');
1209
1276
  const promptText = restart ? RESTART_SEED : text;
1210
- let before = '';
1211
- let events = [];
1212
- try {
1213
- events = await fetchSessionHistory(rpc, sessionId, 40);
1214
- before = usableAssistantText(lastAssistantText(events));
1215
- }
1216
- catch {
1217
- before = '';
1218
- }
1277
+ const events = await fetchSessionHistory(rpc, sessionId, 40);
1278
+ const before = usableAssistantText(lastAssistantText(events));
1279
+ const beforeSeq = historyCursor(events);
1219
1280
  const pending = extractPendingQuestions(events);
1220
1281
  const questions = pending.length > 0
1221
1282
  ? pending
@@ -1240,7 +1301,8 @@ export async function runHeadlessViaWeb(opts) {
1240
1301
  }
1241
1302
  await rpc('session/prompt', promptPayload(sessionId, promptText));
1242
1303
  }
1243
- const reply = await waitForAssistant(rpc, sessionId, before, timeoutMs, questions, stuckAck(folder), signal);
1304
+ const reply = await waitForAssistant(rpc, sessionId, before, beforeSeq, timeoutMs, questions, stuckAck(folder), signal);
1305
+ signal?.throwIfAborted();
1244
1306
  // Stuck / approval-blocked sessions poison the sticky pointer — drop it so
1245
1307
  // the next WeCom/WeChat turn opens a fresh web session instead of hanging.
1246
1308
  if (reply.text === stuckAck(folder)) {
@@ -4,6 +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
+ /** Official replyStream content limit, measured in UTF-8 bytes. */
8
+ export declare const WECOM_REPLY_MAX_BYTES = 20480;
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>;
7
12
  export type WecomBindingPhase = 'idle' | 'connecting' | 'authenticated' | 'error';
8
13
  export interface WecomBindingStatus {
9
14
  room: string;
@@ -7,6 +7,42 @@ 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
+ /** Official replyStream content limit, measured in UTF-8 bytes. */
11
+ export const WECOM_REPLY_MAX_BYTES = 20_480;
12
+ export function clipWecomReply(text) {
13
+ if (Buffer.byteLength(text, 'utf8') <= WECOM_REPLY_MAX_BYTES)
14
+ return text;
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
+ }
45
+ }
10
46
  /** Cap how long one WeCom turn can block the per-room queue (stuck Kimi / approval). */
11
47
  export const WECOM_TURN_TIMEOUT_MS = 90_000;
12
48
  const MAX_SEEN_MSGIDS = 200;
@@ -163,6 +199,7 @@ export class WecomChannel {
163
199
  }
164
200
  dropBindings() {
165
201
  for (const row of this.bindings.values()) {
202
+ row.turnAbort?.abort();
166
203
  try {
167
204
  row.client.disconnect();
168
205
  }
@@ -229,6 +266,8 @@ export class WecomChannel {
229
266
  * queue reaches it). Otherwise「重启对话」waits behind a hung「正在处理…」.
230
267
  */
231
268
  enqueueText(row, frame) {
269
+ if (this.rememberMsgid(row, frame))
270
+ return;
232
271
  const text = extractWecomText(frame);
233
272
  if (text.length > 0) {
234
273
  row.turnAbort?.abort();
@@ -236,7 +275,7 @@ export class WecomChannel {
236
275
  clearSessionStoreKey(join(this.home, 'dsh-rez-wecom', row.bot.room, 'web-sessions.json'), row.bot.room);
237
276
  }
238
277
  }
239
- this.enqueue(row, () => this.handleText(row, frame));
278
+ this.enqueue(row, () => this.handleText(row, frame, true));
240
279
  }
241
280
  rememberMsgid(row, frame) {
242
281
  const id = wecomMsgid(frame);
@@ -249,8 +288,8 @@ export class WecomChannel {
249
288
  row.seen.splice(0, row.seen.length - MAX_SEEN_MSGIDS);
250
289
  return false;
251
290
  }
252
- async handleText(row, frame) {
253
- if (this.rememberMsgid(row, frame))
291
+ async handleText(row, frame, remembered = false) {
292
+ if (!remembered && this.rememberMsgid(row, frame))
254
293
  return;
255
294
  const text = extractWecomText(frame);
256
295
  const streamId = newStreamId();
@@ -262,21 +301,29 @@ export class WecomChannel {
262
301
  row.turnAbort = turnAbort;
263
302
  const timeout = AbortSignal.timeout(WECOM_TURN_TIMEOUT_MS);
264
303
  const signal = AbortSignal.any([turnAbort.signal, timeout]);
304
+ let finished = false;
305
+ const finish = async (content) => {
306
+ if (finished)
307
+ return;
308
+ await row.client.replyStream(frame, streamId, clipWecomReply(content), true);
309
+ finished = true;
310
+ };
265
311
  try {
266
312
  await row.client.replyStream(frame, streamId, '正在处理…', false);
267
- const reply = await this.runTurn(row.bot.room, text, signal);
313
+ const reply = await abortableTurn(this.runTurn(row.bot.room, text, signal), signal);
268
314
  if (turnAbort.signal.aborted) {
269
- await row.client.replyStream(frame, streamId, '已取消(收到新消息)', true);
315
+ await finish('已取消(收到新消息)');
270
316
  return;
271
317
  }
272
- await row.client.replyStream(frame, streamId, reply.length > 0 ? reply : '(空回复)', true);
318
+ await finish(reply.length > 0 ? reply : '(空回复)');
273
319
  }
274
320
  catch (error) {
275
321
  const why = error instanceof Error ? error.message : String(error);
322
+ row.hint = `「${row.bot.room}」回复未完成:${why}`;
276
323
  const superseded = turnAbort.signal.aborted && !timeout.aborted;
277
324
  if (superseded) {
278
325
  try {
279
- await row.client.replyStream(frame, streamId, '已取消(收到新消息)', true);
326
+ await finish('已取消(收到新消息)');
280
327
  }
281
328
  catch { /* WS may already be closed */ }
282
329
  return;
@@ -286,7 +333,7 @@ export class WecomChannel {
286
333
  ? '处理超时。请发「重启对话」再试,或到网页左侧该房间看是否在等批准。'
287
334
  : `处理失败:${why}`;
288
335
  try {
289
- await row.client.replyStream(frame, streamId, message, true);
336
+ await finish(message);
290
337
  }
291
338
  catch {
292
339
  /* WS may already be closed */
@@ -295,6 +342,12 @@ export class WecomChannel {
295
342
  clearSessionStoreKey(join(this.home, 'dsh-rez-wecom', row.bot.room, 'web-sessions.json'), row.bot.room);
296
343
  }
297
344
  finally {
345
+ if (!finished) {
346
+ try {
347
+ await finish('(回复中断,请再发一句或「重启对话」)');
348
+ }
349
+ catch { /* WS may already be closed */ }
350
+ }
298
351
  if (row.turnAbort === turnAbort)
299
352
  delete row.turnAbort;
300
353
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rezti/dsh-rez-wechat",
3
- "version": "0.1.22",
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": {