@tencent-weixin/openclaw-weixin 2.4.3 → 2.4.5
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/CHANGELOG.md +34 -0
- package/CHANGELOG.zh_CN.md +34 -0
- package/dist/src/api/api.js +79 -4
- package/dist/src/api/api.js.map +1 -1
- package/dist/src/api/session-guard.js +3 -3
- package/dist/src/api/session-guard.js.map +1 -1
- package/dist/src/api/types.js +2 -0
- package/dist/src/api/types.js.map +1 -1
- package/dist/src/cdn/cdn-upload.js +3 -2
- package/dist/src/cdn/cdn-upload.js.map +1 -1
- package/dist/src/cdn/upload.js +12 -3
- package/dist/src/cdn/upload.js.map +1 -1
- package/dist/src/channel.js +7 -1
- package/dist/src/channel.js.map +1 -1
- package/dist/src/config/config-schema.js +1 -0
- package/dist/src/config/config-schema.js.map +1 -1
- package/dist/src/config/reply-progress.js +5 -0
- package/dist/src/config/reply-progress.js.map +1 -0
- package/dist/src/messaging/error-notice.js +1 -0
- package/dist/src/messaging/error-notice.js.map +1 -1
- package/dist/src/messaging/outbound-hooks.js +2 -0
- package/dist/src/messaging/outbound-hooks.js.map +1 -1
- package/dist/src/messaging/process-message.js +32 -7
- package/dist/src/messaging/process-message.js.map +1 -1
- package/dist/src/messaging/reply-progress-sender.js +93 -0
- package/dist/src/messaging/reply-progress-sender.js.map +1 -0
- package/dist/src/messaging/send.js +40 -2
- package/dist/src/messaging/send.js.map +1 -1
- package/dist/src/monitor/monitor.js +11 -8
- package/dist/src/monitor/monitor.js.map +1 -1
- package/npm-shrinkwrap.json +21848 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +7 -5
- package/src/api/api.ts +102 -4
- package/src/api/session-guard.ts +3 -3
- package/src/api/types.ts +18 -1
- package/src/cdn/cdn-upload.ts +8 -2
- package/src/cdn/upload.ts +14 -3
- package/src/channel.ts +7 -1
- package/src/config/config-schema.ts +1 -0
- package/src/config/reply-progress.ts +10 -0
- package/src/messaging/error-notice.ts +2 -0
- package/src/messaging/outbound-hooks.ts +4 -0
- package/src/messaging/process-message.ts +32 -7
- package/src/messaging/reply-progress-sender.ts +122 -0
- package/src/messaging/send-media.ts +1 -1
- package/src/messaging/send.ts +60 -7
- package/src/monitor/monitor.ts +12 -11
- package/dist/src/media/voice-outbound.js +0 -177
- package/dist/src/media/voice-outbound.js.map +0 -1
- package/dist/src/messaging/abort-fence.js +0 -70
- package/dist/src/messaging/abort-fence.js.map +0 -1
- package/dist/src/messaging/buttons.js +0 -117
- package/dist/src/messaging/buttons.js.map +0 -1
- package/dist/src/messaging/lane-key.js +0 -66
- package/dist/src/messaging/lane-key.js.map +0 -1
- package/dist/src/messaging/merged-record.js +0 -149
- package/dist/src/messaging/merged-record.js.map +0 -1
- package/dist/src/messaging/model-buttons.js +0 -182
- package/dist/src/messaging/model-buttons.js.map +0 -1
- package/dist/src/messaging/model-callback-handler.js +0 -133
- package/dist/src/messaging/model-callback-handler.js.map +0 -1
- package/dist/src/monitor/lane-scheduler.js +0 -46
- package/dist/src/monitor/lane-scheduler.js.map +0 -1
- package/dist/src/runtime.js +0 -54
- package/dist/src/runtime.js.map +0 -1
- package/dist/src/streaming/stream-pipeline.js +0 -431
- package/dist/src/streaming/stream-pipeline.js.map +0 -1
- package/dist/src/streaming/stream-session.js +0 -260
- package/dist/src/streaming/stream-session.js.map +0 -1
- package/dist/src/streaming/stream.js +0 -239
- package/dist/src/streaming/stream.js.map +0 -1
- package/dist/src/util/markdown-fences.js +0 -54
- package/dist/src/util/markdown-fences.js.map +0 -1
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tencent-weixin/openclaw-weixin",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.5",
|
|
4
4
|
"description": "OpenClaw Weixin channel",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Tencent",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"!src/**/*.test.ts",
|
|
11
11
|
"!src/**/node_modules/",
|
|
12
12
|
"index.ts",
|
|
13
|
+
"npm-shrinkwrap.json",
|
|
13
14
|
"openclaw.plugin.json",
|
|
14
15
|
"dist/",
|
|
15
16
|
"README.md",
|
|
@@ -20,7 +21,8 @@
|
|
|
20
21
|
"scripts": {
|
|
21
22
|
"test": "vitest run --coverage",
|
|
22
23
|
"typecheck": "tsc --noEmit",
|
|
23
|
-
"
|
|
24
|
+
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
25
|
+
"build": "npm run clean && tsc",
|
|
24
26
|
"prepublishOnly": "npm run typecheck && npm run build"
|
|
25
27
|
},
|
|
26
28
|
"engines": {
|
|
@@ -31,11 +33,11 @@
|
|
|
31
33
|
"zod": "^4.3.6"
|
|
32
34
|
},
|
|
33
35
|
"peerDependencies": {
|
|
34
|
-
"openclaw": ">=2026.
|
|
36
|
+
"openclaw": ">=2026.5.12"
|
|
35
37
|
},
|
|
36
38
|
"devDependencies": {
|
|
37
39
|
"@vitest/coverage-v8": "^3.1.0",
|
|
38
|
-
"openclaw": "2026.
|
|
40
|
+
"openclaw": "2026.5.12",
|
|
39
41
|
"silk-wasm": "^3.7.1",
|
|
40
42
|
"typescript": "^5.8.0",
|
|
41
43
|
"vitest": "^3.1.0"
|
|
@@ -59,7 +61,7 @@
|
|
|
59
61
|
"install": {
|
|
60
62
|
"npmSpec": "@tencent-weixin/openclaw-weixin",
|
|
61
63
|
"defaultChoice": "npm",
|
|
62
|
-
"minHostVersion": ">=2026.
|
|
64
|
+
"minHostVersion": ">=2026.5.12"
|
|
63
65
|
}
|
|
64
66
|
},
|
|
65
67
|
"ilink_appid": "bot"
|
package/src/api/api.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
NotifyStopResp,
|
|
17
17
|
NotifyStartResp,
|
|
18
18
|
SendMessageReq,
|
|
19
|
+
SendMessageResp,
|
|
19
20
|
SendTypingReq,
|
|
20
21
|
GetConfigResp,
|
|
21
22
|
} from "./types.js";
|
|
@@ -252,6 +253,40 @@ function buildHeaders(opts: { token?: string }): Record<string, string> {
|
|
|
252
253
|
return headers;
|
|
253
254
|
}
|
|
254
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Classify a fetch-level error into a category for logging / diagnostics.
|
|
258
|
+
* This does NOT cover HTTP-level errors (4xx/5xx) — those are thrown separately.
|
|
259
|
+
*/
|
|
260
|
+
export function classifyFetchError(err: unknown): {
|
|
261
|
+
type: "dns" | "tcp" | "tls" | "timeout" | "unknown";
|
|
262
|
+
description: string;
|
|
263
|
+
code?: string;
|
|
264
|
+
} {
|
|
265
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
266
|
+
return { type: "timeout", description: "request timeout" };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const cause = (err as NodeJS.ErrnoException)?.cause;
|
|
270
|
+
const causeCode = (cause as any)?.code ?? "";
|
|
271
|
+
const causeStr = String(cause ?? err ?? "") + " " + String(causeCode);
|
|
272
|
+
const matchedCode = causeCode || (typeof cause === "string" ? cause : "");
|
|
273
|
+
|
|
274
|
+
if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(causeStr)) {
|
|
275
|
+
return { type: "dns", description: "DNS resolution failed, check DNS configuration", ...(matchedCode ? { code: matchedCode } : {}) };
|
|
276
|
+
}
|
|
277
|
+
if (/ECONNREFUSED/i.test(causeStr)) {
|
|
278
|
+
return { type: "tcp", description: "TCP connection refused", ...(matchedCode ? { code: matchedCode } : {}) };
|
|
279
|
+
}
|
|
280
|
+
if (/UND_ERR_CONNECT_TIMEOUT|ETIMEDOUT|ENETUNREACH|EHOSTUNREACH/i.test(causeStr)) {
|
|
281
|
+
return { type: "tcp", description: "TCP connection timeout or unreachable", ...(matchedCode ? { code: matchedCode } : {}) };
|
|
282
|
+
}
|
|
283
|
+
if (/UND_ERR_SOCKET|SSL|TLS|CERT|UNABLE_TO_VERIFY|DEPTH_ZERO/i.test(causeStr)) {
|
|
284
|
+
return { type: "tls", description: "TLS handshake error", ...(matchedCode ? { code: matchedCode } : {}) };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { type: "unknown", description: "network request failed" };
|
|
288
|
+
}
|
|
289
|
+
|
|
255
290
|
/**
|
|
256
291
|
* GET fetch wrapper: send a GET request to a Weixin API endpoint.
|
|
257
292
|
* When `timeoutMs` is set, the request is aborted after that many milliseconds.
|
|
@@ -291,15 +326,50 @@ export async function apiGetFetch(params: {
|
|
|
291
326
|
return rawText;
|
|
292
327
|
} catch (err) {
|
|
293
328
|
if (t !== undefined) clearTimeout(t);
|
|
329
|
+
const classified = classifyFetchError(err);
|
|
330
|
+
logger.error(
|
|
331
|
+
`${params.label}: GET fetch failed url=${redactUrl(url.toString())} timeoutMs=${timeoutMs ?? "none"} type=${classified.type} description=${classified.description}${classified.code ? ` code=${classified.code}` : ""} error=${String(err)}`,
|
|
332
|
+
);
|
|
294
333
|
throw err;
|
|
295
334
|
}
|
|
296
335
|
}
|
|
297
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Combine an internal timeout controller with an optional external abort signal.
|
|
339
|
+
* This lets gateway channel-stop aborts cancel in-flight long-poll requests
|
|
340
|
+
* immediately while preserving the existing timeout-driven AbortError path.
|
|
341
|
+
*/
|
|
342
|
+
function combineAbortSignals(params: {
|
|
343
|
+
internal?: AbortController;
|
|
344
|
+
external?: AbortSignal;
|
|
345
|
+
}): { signal?: AbortSignal; cleanup: () => void } {
|
|
346
|
+
const { internal, external } = params;
|
|
347
|
+
if (!external) {
|
|
348
|
+
return { signal: internal?.signal, cleanup: () => {} };
|
|
349
|
+
}
|
|
350
|
+
if (!internal) {
|
|
351
|
+
return { signal: external, cleanup: () => {} };
|
|
352
|
+
}
|
|
353
|
+
if (external.aborted) {
|
|
354
|
+
internal.abort();
|
|
355
|
+
return { signal: internal.signal, cleanup: () => {} };
|
|
356
|
+
}
|
|
357
|
+
const onExternalAbort = () => internal.abort();
|
|
358
|
+
external.addEventListener("abort", onExternalAbort, { once: true });
|
|
359
|
+
return {
|
|
360
|
+
signal: internal.signal,
|
|
361
|
+
cleanup: () => external.removeEventListener("abort", onExternalAbort),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
298
365
|
/**
|
|
299
366
|
* Common fetch wrapper: POST JSON to a Weixin API endpoint.
|
|
300
367
|
* When `timeoutMs` is provided, the request is aborted after that many milliseconds.
|
|
301
368
|
* When omitted, no client-side timeout is applied (relies on OS/TCP stack).
|
|
302
369
|
* Returns the raw response text on success; throws on HTTP error or timeout.
|
|
370
|
+
*
|
|
371
|
+
* When `abortSignal` is provided, an external abort (e.g. gateway channel stop)
|
|
372
|
+
* cancels the in-flight request immediately instead of waiting for `timeoutMs`.
|
|
303
373
|
*/
|
|
304
374
|
export async function apiPostFetch(params: {
|
|
305
375
|
baseUrl: string;
|
|
@@ -308,6 +378,7 @@ export async function apiPostFetch(params: {
|
|
|
308
378
|
token?: string;
|
|
309
379
|
timeoutMs?: number;
|
|
310
380
|
label: string;
|
|
381
|
+
abortSignal?: AbortSignal;
|
|
311
382
|
}): Promise<string> {
|
|
312
383
|
const base = ensureTrailingSlash(params.baseUrl);
|
|
313
384
|
const url = new URL(params.endpoint, base);
|
|
@@ -320,12 +391,16 @@ export async function apiPostFetch(params: {
|
|
|
320
391
|
controller != null && params.timeoutMs !== undefined
|
|
321
392
|
? setTimeout(() => controller.abort(), params.timeoutMs)
|
|
322
393
|
: undefined;
|
|
394
|
+
const { signal, cleanup } = combineAbortSignals({
|
|
395
|
+
internal: controller,
|
|
396
|
+
external: params.abortSignal,
|
|
397
|
+
});
|
|
323
398
|
try {
|
|
324
399
|
const res = await fetch(url.toString(), {
|
|
325
400
|
method: "POST",
|
|
326
401
|
headers: hdrs,
|
|
327
402
|
body: params.body,
|
|
328
|
-
...(
|
|
403
|
+
...(signal ? { signal } : {}),
|
|
329
404
|
});
|
|
330
405
|
if (t !== undefined) clearTimeout(t);
|
|
331
406
|
const rawText = await res.text();
|
|
@@ -336,7 +411,13 @@ export async function apiPostFetch(params: {
|
|
|
336
411
|
return rawText;
|
|
337
412
|
} catch (err) {
|
|
338
413
|
if (t !== undefined) clearTimeout(t);
|
|
414
|
+
const classified = classifyFetchError(err);
|
|
415
|
+
logger.error(
|
|
416
|
+
`${params.label}: POST fetch failed url=${redactUrl(url.toString())} timeoutMs=${params.timeoutMs ?? "none"} type=${classified.type} description=${classified.description}${classified.code ? ` code=${classified.code}` : ""} error=${String(err)}`,
|
|
417
|
+
);
|
|
339
418
|
throw err;
|
|
419
|
+
} finally {
|
|
420
|
+
cleanup();
|
|
340
421
|
}
|
|
341
422
|
}
|
|
342
423
|
|
|
@@ -351,6 +432,11 @@ export async function getUpdates(
|
|
|
351
432
|
baseUrl: string;
|
|
352
433
|
token?: string;
|
|
353
434
|
timeoutMs?: number;
|
|
435
|
+
/**
|
|
436
|
+
* Optional external abort signal from the gateway. When stopping the channel,
|
|
437
|
+
* this aborts the in-flight long-poll immediately so hot reload can restart.
|
|
438
|
+
*/
|
|
439
|
+
abortSignal?: AbortSignal;
|
|
354
440
|
},
|
|
355
441
|
): Promise<GetUpdatesResp> {
|
|
356
442
|
const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
|
|
@@ -365,13 +451,19 @@ export async function getUpdates(
|
|
|
365
451
|
token: params.token,
|
|
366
452
|
timeoutMs: timeout,
|
|
367
453
|
label: "getUpdates",
|
|
454
|
+
abortSignal: params.abortSignal,
|
|
368
455
|
});
|
|
369
456
|
const resp: GetUpdatesResp = JSON.parse(rawText);
|
|
370
457
|
return resp;
|
|
371
458
|
} catch (err) {
|
|
372
|
-
// Long-poll timeout
|
|
459
|
+
// Long-poll timeout or external abort are both normal control-flow exits.
|
|
460
|
+
// The monitor loop checks abortSignal after return and exits when needed.
|
|
373
461
|
if (err instanceof Error && err.name === "AbortError") {
|
|
374
|
-
|
|
462
|
+
if (params.abortSignal?.aborted) {
|
|
463
|
+
logger.debug(`getUpdates: aborted by external signal`);
|
|
464
|
+
} else {
|
|
465
|
+
logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);
|
|
466
|
+
}
|
|
375
467
|
return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf };
|
|
376
468
|
}
|
|
377
469
|
throw err;
|
|
@@ -411,7 +503,7 @@ export async function getUploadUrl(
|
|
|
411
503
|
export async function sendMessage(
|
|
412
504
|
params: WeixinApiOptions & { body: SendMessageReq },
|
|
413
505
|
): Promise<void> {
|
|
414
|
-
await apiPostFetch({
|
|
506
|
+
const rawText = await apiPostFetch({
|
|
415
507
|
baseUrl: params.baseUrl,
|
|
416
508
|
endpoint: "ilink/bot/sendmessage",
|
|
417
509
|
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
|
@@ -419,6 +511,12 @@ export async function sendMessage(
|
|
|
419
511
|
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
420
512
|
label: "sendMessage",
|
|
421
513
|
});
|
|
514
|
+
const resp: SendMessageResp = JSON.parse(rawText);
|
|
515
|
+
if (resp.ret && resp.ret !== 0) {
|
|
516
|
+
throw new Error(
|
|
517
|
+
`sendMessage ret=${resp.ret} errmsg=${resp.errmsg ?? "(none)"}`,
|
|
518
|
+
);
|
|
519
|
+
}
|
|
422
520
|
}
|
|
423
521
|
|
|
424
522
|
/** Fetch bot config (includes typing_ticket) for a given user. */
|
package/src/api/session-guard.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { logger } from "../util/logger.js";
|
|
|
2
2
|
|
|
3
3
|
const SESSION_PAUSE_DURATION_MS = 60 * 60 * 1000;
|
|
4
4
|
|
|
5
|
-
/** Error code returned by the server when the bot
|
|
6
|
-
export const
|
|
5
|
+
/** Error code returned by the server when the bot token is stale / expired. */
|
|
6
|
+
export const STALE_TOKEN_ERRCODE = -14;
|
|
7
7
|
|
|
8
8
|
const pauseUntilMap = new Map<string, number>();
|
|
9
9
|
|
|
@@ -44,7 +44,7 @@ export function assertSessionActive(accountId: string): void {
|
|
|
44
44
|
if (isSessionPaused(accountId)) {
|
|
45
45
|
const remainingMin = Math.ceil(getRemainingPauseMs(accountId) / 60_000);
|
|
46
46
|
throw new Error(
|
|
47
|
-
`session paused for accountId=${accountId}, ${remainingMin} min remaining (errcode ${
|
|
47
|
+
`session paused for accountId=${accountId}, ${remainingMin} min remaining (errcode ${STALE_TOKEN_ERRCODE})`,
|
|
48
48
|
);
|
|
49
49
|
}
|
|
50
50
|
}
|
package/src/api/types.ts
CHANGED
|
@@ -74,6 +74,8 @@ export const MessageItemType = {
|
|
|
74
74
|
VOICE: 3,
|
|
75
75
|
FILE: 4,
|
|
76
76
|
VIDEO: 5,
|
|
77
|
+
TOOL_CALL_START: 11,
|
|
78
|
+
TOOL_CALL_RESULT: 12,
|
|
77
79
|
} as const;
|
|
78
80
|
|
|
79
81
|
export const MessageState = {
|
|
@@ -147,6 +149,17 @@ export interface RefMessage {
|
|
|
147
149
|
title?: string; // 摘要
|
|
148
150
|
}
|
|
149
151
|
|
|
152
|
+
export interface ToolCallStartItem {
|
|
153
|
+
tool_name?: string;
|
|
154
|
+
tool_call_id?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface ToolCallResultItem {
|
|
158
|
+
tool_name?: string;
|
|
159
|
+
tool_call_id?: string;
|
|
160
|
+
status?: string;
|
|
161
|
+
}
|
|
162
|
+
|
|
150
163
|
export interface MessageItem {
|
|
151
164
|
type?: number;
|
|
152
165
|
create_time_ms?: number;
|
|
@@ -159,6 +172,8 @@ export interface MessageItem {
|
|
|
159
172
|
voice_item?: VoiceItem;
|
|
160
173
|
file_item?: FileItem;
|
|
161
174
|
video_item?: VideoItem;
|
|
175
|
+
tool_call_start_item?: ToolCallStartItem;
|
|
176
|
+
tool_call_result_item?: ToolCallResultItem;
|
|
162
177
|
}
|
|
163
178
|
|
|
164
179
|
/** Unified message (proto: WeixinMessage). Replaces the old split Message + MessageContent + FullMessage. */
|
|
@@ -177,6 +192,7 @@ export interface WeixinMessage {
|
|
|
177
192
|
message_state?: number;
|
|
178
193
|
item_list?: MessageItem[];
|
|
179
194
|
context_token?: string;
|
|
195
|
+
run_id?: string;
|
|
180
196
|
}
|
|
181
197
|
|
|
182
198
|
/** GetUpdates request: bytes fields are base64 strings in JSON. */
|
|
@@ -208,7 +224,8 @@ export interface SendMessageReq {
|
|
|
208
224
|
}
|
|
209
225
|
|
|
210
226
|
export interface SendMessageResp {
|
|
211
|
-
|
|
227
|
+
ret?: number;
|
|
228
|
+
errmsg?: string;
|
|
212
229
|
}
|
|
213
230
|
|
|
214
231
|
/** Typing status: 1 = typing (default), 2 = cancel typing. */
|
package/src/cdn/cdn-upload.ts
CHANGED
|
@@ -70,10 +70,16 @@ export async function uploadBufferToCdn(params: {
|
|
|
70
70
|
} catch (err) {
|
|
71
71
|
lastError = err;
|
|
72
72
|
if (err instanceof Error && err.message.includes("client error")) throw err;
|
|
73
|
+
const cause =
|
|
74
|
+
(err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? "";
|
|
73
75
|
if (attempt < UPLOAD_MAX_RETRIES) {
|
|
74
|
-
logger.error(
|
|
76
|
+
logger.error(
|
|
77
|
+
`${label}: attempt ${attempt} failed, retrying... url=${redactUrl(cdnUrl)} error=${String(err)}${cause ? ` cause=${cause}` : ""}`,
|
|
78
|
+
);
|
|
75
79
|
} else {
|
|
76
|
-
logger.error(
|
|
80
|
+
logger.error(
|
|
81
|
+
`${label}: all ${UPLOAD_MAX_RETRIES} attempts failed url=${redactUrl(cdnUrl)} error=${String(err)}${cause ? ` cause=${cause}` : ""}`,
|
|
82
|
+
);
|
|
77
83
|
}
|
|
78
84
|
}
|
|
79
85
|
}
|
package/src/cdn/upload.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { WeixinApiOptions } from "../api/api.js";
|
|
|
7
7
|
import { aesEcbPaddedSize } from "./aes-ecb.js";
|
|
8
8
|
import { uploadBufferToCdn } from "./cdn-upload.js";
|
|
9
9
|
import { logger } from "../util/logger.js";
|
|
10
|
+
import { redactUrl } from "../util/redact.js";
|
|
10
11
|
import { getExtensionFromContentTypeOrUrl } from "../media/mime.js";
|
|
11
12
|
import { tempFileName } from "../util/random.js";
|
|
12
13
|
import { UploadMediaType } from "../api/types.js";
|
|
@@ -28,10 +29,20 @@ export type UploadedFileInfo = {
|
|
|
28
29
|
* Returns the local file path; extension is inferred from Content-Type / URL.
|
|
29
30
|
*/
|
|
30
31
|
export async function downloadRemoteImageToTemp(url: string, destDir: string): Promise<string> {
|
|
31
|
-
logger.debug(`downloadRemoteImageToTemp: fetching url=${url}`);
|
|
32
|
-
|
|
32
|
+
logger.debug(`downloadRemoteImageToTemp: fetching url=${redactUrl(url)}`);
|
|
33
|
+
let res: Response;
|
|
34
|
+
try {
|
|
35
|
+
res = await fetch(url);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
const cause =
|
|
38
|
+
(err as NodeJS.ErrnoException).cause ?? (err as NodeJS.ErrnoException).code ?? "";
|
|
39
|
+
logger.error(
|
|
40
|
+
`downloadRemoteImageToTemp: fetch network error url=${redactUrl(url)} error=${String(err)}${cause ? ` cause=${cause}` : ""}`,
|
|
41
|
+
);
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
33
44
|
if (!res.ok) {
|
|
34
|
-
const msg = `remote media download failed: ${res.status} ${res.statusText} url=${url}`;
|
|
45
|
+
const msg = `remote media download failed: ${res.status} ${res.statusText} url=${redactUrl(url)}`;
|
|
35
46
|
logger.error(`downloadRemoteImageToTemp: ${msg}`);
|
|
36
47
|
throw new Error(msg);
|
|
37
48
|
}
|
package/src/channel.ts
CHANGED
|
@@ -167,7 +167,13 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
|
|
|
167
167
|
schema: {
|
|
168
168
|
type: "object",
|
|
169
169
|
additionalProperties: false,
|
|
170
|
-
properties: {
|
|
170
|
+
properties: {
|
|
171
|
+
replyProgressMessages: {
|
|
172
|
+
type: "boolean",
|
|
173
|
+
default: true,
|
|
174
|
+
description: "Send structured tool-call progress messages.",
|
|
175
|
+
},
|
|
176
|
+
},
|
|
171
177
|
},
|
|
172
178
|
},
|
|
173
179
|
capabilities: {
|
|
@@ -17,6 +17,7 @@ const weixinAccountSchema = z.object({
|
|
|
17
17
|
/** Top-level weixin config schema (token is stored in credentials file, not config). */
|
|
18
18
|
export const WeixinConfigSchema = weixinAccountSchema.extend({
|
|
19
19
|
accounts: z.record(z.string(), weixinAccountSchema).optional(),
|
|
20
|
+
replyProgressMessages: z.boolean().default(true),
|
|
20
21
|
/** ISO 8601; bumped on each successful login to refresh gateway config from disk. */
|
|
21
22
|
channelConfigUpdatedAt: z.string().optional(),
|
|
22
23
|
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
|
+
|
|
3
|
+
type WeixinChannelConfig = {
|
|
4
|
+
replyProgressMessages?: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function resolveReplyProgressMessagesEnabled(cfg: OpenClawConfig): boolean {
|
|
8
|
+
const section = cfg.channels?.["openclaw-weixin"] as WeixinChannelConfig | undefined;
|
|
9
|
+
return section?.replyProgressMessages !== false;
|
|
10
|
+
}
|
|
@@ -12,6 +12,7 @@ export async function sendWeixinErrorNotice(params: {
|
|
|
12
12
|
message: string;
|
|
13
13
|
baseUrl: string;
|
|
14
14
|
token?: string;
|
|
15
|
+
runId?: string;
|
|
15
16
|
errLog: (m: string) => void;
|
|
16
17
|
}): Promise<void> {
|
|
17
18
|
if (!params.contextToken) {
|
|
@@ -22,6 +23,7 @@ export async function sendWeixinErrorNotice(params: {
|
|
|
22
23
|
baseUrl: params.baseUrl,
|
|
23
24
|
token: params.token,
|
|
24
25
|
contextToken: params.contextToken,
|
|
26
|
+
...(params.runId ? { runId: params.runId } : {}),
|
|
25
27
|
}});
|
|
26
28
|
logger.debug(`sendWeixinErrorNotice: sent to=${params.to}`);
|
|
27
29
|
} catch (err) {
|
|
@@ -20,6 +20,7 @@ export async function applyWeixinMessageSendingHook(params: {
|
|
|
20
20
|
text: string;
|
|
21
21
|
accountId?: string;
|
|
22
22
|
mediaUrl?: string;
|
|
23
|
+
runId?: string;
|
|
23
24
|
}): Promise<{ cancelled: boolean; text: string }> {
|
|
24
25
|
const hookRunner = getGlobalHookRunner();
|
|
25
26
|
if (!hookRunner?.hasHooks("message_sending")) {
|
|
@@ -33,6 +34,7 @@ export async function applyWeixinMessageSendingHook(params: {
|
|
|
33
34
|
metadata: {
|
|
34
35
|
channel: CHANNEL_ID,
|
|
35
36
|
accountId: params.accountId,
|
|
37
|
+
runId: params.runId,
|
|
36
38
|
...(params.mediaUrl ? { mediaUrls: [params.mediaUrl] } : {}),
|
|
37
39
|
},
|
|
38
40
|
},
|
|
@@ -60,6 +62,7 @@ export function emitWeixinMessageSent(params: {
|
|
|
60
62
|
success: boolean;
|
|
61
63
|
error?: string;
|
|
62
64
|
accountId?: string;
|
|
65
|
+
runId?: string;
|
|
63
66
|
}): void {
|
|
64
67
|
const hookRunner = getGlobalHookRunner();
|
|
65
68
|
if (!hookRunner?.hasHooks("message_sent")) return;
|
|
@@ -71,6 +74,7 @@ export function emitWeixinMessageSent(params: {
|
|
|
71
74
|
channelId: CHANNEL_ID,
|
|
72
75
|
accountId: params.accountId,
|
|
73
76
|
conversationId: params.to,
|
|
77
|
+
runId: params.runId,
|
|
74
78
|
});
|
|
75
79
|
fireAndForgetHook(
|
|
76
80
|
Promise.resolve(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
|
|
3
4
|
import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime";
|
|
4
5
|
import {
|
|
@@ -14,6 +15,7 @@ import { MessageItemType, TypingStatus } from "../api/types.js";
|
|
|
14
15
|
import { loadWeixinAccount } from "../auth/accounts.js";
|
|
15
16
|
import { readFrameworkAllowFromList } from "../auth/pairing.js";
|
|
16
17
|
import { downloadRemoteImageToTemp } from "../cdn/upload.js";
|
|
18
|
+
import { resolveReplyProgressMessagesEnabled } from "../config/reply-progress.js";
|
|
17
19
|
import { downloadMediaFromItem } from "../media/media-download.js";
|
|
18
20
|
import { logger } from "../util/logger.js";
|
|
19
21
|
import { redactBody, redactToken } from "../util/redact.js";
|
|
@@ -31,6 +33,7 @@ import type { WeixinInboundMediaOpts } from "./inbound.js";
|
|
|
31
33
|
import { sendWeixinMediaFile } from "./send-media.js";
|
|
32
34
|
import { StreamingMarkdownFilter } from "./markdown-filter.js";
|
|
33
35
|
import { sendMessageWeixin } from "./send.js";
|
|
36
|
+
import { WeixinReplyProgressSender } from "./reply-progress-sender.js";
|
|
34
37
|
import { handleSlashCommand } from "./slash-commands.js";
|
|
35
38
|
|
|
36
39
|
const MEDIA_OUTBOUND_TEMP_DIR = path.join(resolvePreferredOpenClawTmpDir(), "weixin/media/outbound-temp");
|
|
@@ -270,6 +273,19 @@ export async function processOneMessage(
|
|
|
270
273
|
if (contextToken) {
|
|
271
274
|
setContextToken(deps.accountId, full.from_user_id ?? "", contextToken);
|
|
272
275
|
}
|
|
276
|
+
const runId = randomUUID();
|
|
277
|
+
const replyProgressSender = resolveReplyProgressMessagesEnabled(deps.config)
|
|
278
|
+
? new WeixinReplyProgressSender({
|
|
279
|
+
runId,
|
|
280
|
+
to: ctx.To,
|
|
281
|
+
accountId: deps.accountId,
|
|
282
|
+
opts: {
|
|
283
|
+
baseUrl: deps.baseUrl,
|
|
284
|
+
token: deps.token,
|
|
285
|
+
contextToken,
|
|
286
|
+
},
|
|
287
|
+
})
|
|
288
|
+
: undefined;
|
|
273
289
|
const humanDelay = deps.channelRuntime.reply.resolveHumanDelayConfig(deps.config, route.agentId);
|
|
274
290
|
|
|
275
291
|
const hasTypingTicket = Boolean(deps.typingTicket);
|
|
@@ -336,6 +352,7 @@ export async function processOneMessage(
|
|
|
336
352
|
text,
|
|
337
353
|
accountId: deps.accountId,
|
|
338
354
|
mediaUrl,
|
|
355
|
+
runId,
|
|
339
356
|
});
|
|
340
357
|
if (sendingResult.cancelled) {
|
|
341
358
|
logger.info(`outbound: cancelled by message_sending hook to=${ctx.To}`);
|
|
@@ -368,8 +385,9 @@ export async function processOneMessage(
|
|
|
368
385
|
baseUrl: deps.baseUrl,
|
|
369
386
|
token: deps.token,
|
|
370
387
|
contextToken,
|
|
388
|
+
runId,
|
|
371
389
|
}});
|
|
372
|
-
emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId });
|
|
390
|
+
emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId, runId });
|
|
373
391
|
logger.info(`outbound: text sent to=${ctx.To}`);
|
|
374
392
|
return;
|
|
375
393
|
}
|
|
@@ -377,10 +395,10 @@ export async function processOneMessage(
|
|
|
377
395
|
filePath,
|
|
378
396
|
to: ctx.To,
|
|
379
397
|
text,
|
|
380
|
-
opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
|
|
398
|
+
opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken, runId },
|
|
381
399
|
cdnBaseUrl: deps.cdnBaseUrl,
|
|
382
400
|
});
|
|
383
|
-
emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId });
|
|
401
|
+
emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId, runId });
|
|
384
402
|
logger.info(`outbound: media sent OK to=${ctx.To}`);
|
|
385
403
|
} else {
|
|
386
404
|
logger.debug(`outbound: sending text message to=${ctx.To}`);
|
|
@@ -388,12 +406,13 @@ export async function processOneMessage(
|
|
|
388
406
|
baseUrl: deps.baseUrl,
|
|
389
407
|
token: deps.token,
|
|
390
408
|
contextToken,
|
|
409
|
+
runId,
|
|
391
410
|
}});
|
|
392
|
-
emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId });
|
|
411
|
+
emitWeixinMessageSent({ to: ctx.To, content: text, success: true, accountId: deps.accountId, runId });
|
|
393
412
|
logger.info(`outbound: text sent OK to=${ctx.To}`);
|
|
394
413
|
}
|
|
395
414
|
} catch (err) {
|
|
396
|
-
emitWeixinMessageSent({ to: ctx.To, content: text, success: false, error: String(err), accountId: deps.accountId });
|
|
415
|
+
emitWeixinMessageSent({ to: ctx.To, content: text, success: false, error: String(err), accountId: deps.accountId, runId });
|
|
397
416
|
logger.error(
|
|
398
417
|
`outbound: FAILED to=${ctx.To} mediaUrl=${mediaUrl ?? "none"} err=${String(err)} stack=${(err as Error).stack ?? ""}`,
|
|
399
418
|
);
|
|
@@ -421,6 +440,7 @@ export async function processOneMessage(
|
|
|
421
440
|
message: notice,
|
|
422
441
|
baseUrl: deps.baseUrl,
|
|
423
442
|
token: deps.token,
|
|
443
|
+
runId,
|
|
424
444
|
errLog: deps.errLog,
|
|
425
445
|
});
|
|
426
446
|
},
|
|
@@ -435,7 +455,11 @@ export async function processOneMessage(
|
|
|
435
455
|
ctx: finalized,
|
|
436
456
|
cfg: deps.config,
|
|
437
457
|
dispatcher,
|
|
438
|
-
replyOptions: {
|
|
458
|
+
replyOptions: {
|
|
459
|
+
...replyOptions,
|
|
460
|
+
...(replyProgressSender?.replyOptions ?? {}),
|
|
461
|
+
disableBlockStreaming: true,
|
|
462
|
+
},
|
|
439
463
|
}),
|
|
440
464
|
});
|
|
441
465
|
logger.debug(`dispatchReplyFromConfig: done agentId=${route.agentId ?? "(none)"}`);
|
|
@@ -446,6 +470,7 @@ export async function processOneMessage(
|
|
|
446
470
|
throw err;
|
|
447
471
|
} finally {
|
|
448
472
|
markDispatchIdle();
|
|
473
|
+
await replyProgressSender?.finalize();
|
|
449
474
|
|
|
450
475
|
logger.info(
|
|
451
476
|
`debug-check: accountId=${deps.accountId} debug=${String(debug)} hasContextToken=${Boolean(contextToken)}`,
|
|
@@ -489,7 +514,7 @@ export async function processOneMessage(
|
|
|
489
514
|
await sendMessageWeixin({
|
|
490
515
|
to: ctx.To,
|
|
491
516
|
text: timingText,
|
|
492
|
-
opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken },
|
|
517
|
+
opts: { baseUrl: deps.baseUrl, token: deps.token, contextToken, runId },
|
|
493
518
|
});
|
|
494
519
|
logger.info(`debug-timing: sent OK`);
|
|
495
520
|
} catch (debugErr) {
|