@netcat-ai/openclaw-weixin 2.4.7
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 +156 -0
- package/CHANGELOG.zh_CN.md +156 -0
- package/LICENSE +27 -0
- package/README.md +355 -0
- package/README.zh_CN.md +351 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/src/api/api.js +484 -0
- package/dist/src/api/api.js.map +1 -0
- package/dist/src/api/config-cache.js +64 -0
- package/dist/src/api/config-cache.js.map +1 -0
- package/dist/src/api/session-guard.js +49 -0
- package/dist/src/api/session-guard.js.map +1 -0
- package/dist/src/api/types.js +37 -0
- package/dist/src/api/types.js.map +1 -0
- package/dist/src/auth/accounts.js +328 -0
- package/dist/src/auth/accounts.js.map +1 -0
- package/dist/src/auth/login-qr.js +331 -0
- package/dist/src/auth/login-qr.js.map +1 -0
- package/dist/src/auth/pairing.js +104 -0
- package/dist/src/auth/pairing.js.map +1 -0
- package/dist/src/cdn/aes-ecb.js +19 -0
- package/dist/src/cdn/aes-ecb.js.map +1 -0
- package/dist/src/cdn/cdn-upload.js +74 -0
- package/dist/src/cdn/cdn-upload.js.map +1 -0
- package/dist/src/cdn/cdn-url.js +14 -0
- package/dist/src/cdn/cdn-url.js.map +1 -0
- package/dist/src/cdn/pic-decrypt.js +89 -0
- package/dist/src/cdn/pic-decrypt.js.map +1 -0
- package/dist/src/cdn/upload.js +115 -0
- package/dist/src/cdn/upload.js.map +1 -0
- package/dist/src/channel.js +478 -0
- package/dist/src/channel.js.map +1 -0
- package/dist/src/compat.js +67 -0
- package/dist/src/compat.js.map +1 -0
- package/dist/src/config/config-schema.js +20 -0
- package/dist/src/config/config-schema.js.map +1 -0
- package/dist/src/config/reply-progress.js +5 -0
- package/dist/src/config/reply-progress.js.map +1 -0
- package/dist/src/media/media-download.js +95 -0
- package/dist/src/media/media-download.js.map +1 -0
- package/dist/src/media/mime.js +73 -0
- package/dist/src/media/mime.js.map +1 -0
- package/dist/src/media/silk-transcode.js +64 -0
- package/dist/src/media/silk-transcode.js.map +1 -0
- package/dist/src/messaging/debug-mode.js +63 -0
- package/dist/src/messaging/debug-mode.js.map +1 -0
- package/dist/src/messaging/error-notice.js +25 -0
- package/dist/src/messaging/error-notice.js.map +1 -0
- package/dist/src/messaging/inbound.js +201 -0
- package/dist/src/messaging/inbound.js.map +1 -0
- package/dist/src/messaging/markdown-filter.js +368 -0
- package/dist/src/messaging/markdown-filter.js.map +1 -0
- package/dist/src/messaging/outbound-hooks.js +58 -0
- package/dist/src/messaging/outbound-hooks.js.map +1 -0
- package/dist/src/messaging/process-message.js +406 -0
- package/dist/src/messaging/process-message.js.map +1 -0
- 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-media.js +54 -0
- package/dist/src/messaging/send-media.js.map +1 -0
- package/dist/src/messaging/send.js +220 -0
- package/dist/src/messaging/send.js.map +1 -0
- package/dist/src/messaging/slash-commands.js +70 -0
- package/dist/src/messaging/slash-commands.js.map +1 -0
- package/dist/src/monitor/monitor.js +146 -0
- package/dist/src/monitor/monitor.js.map +1 -0
- package/dist/src/storage/state-dir.js +9 -0
- package/dist/src/storage/state-dir.js.map +1 -0
- package/dist/src/storage/sync-buf.js +64 -0
- package/dist/src/storage/sync-buf.js.map +1 -0
- package/dist/src/util/logger.js +120 -0
- package/dist/src/util/logger.js.map +1 -0
- package/dist/src/util/random.js +16 -0
- package/dist/src/util/random.js.map +1 -0
- package/dist/src/util/redact.js +54 -0
- package/dist/src/util/redact.js.map +1 -0
- package/index.ts +19 -0
- package/openclaw.plugin.json +22 -0
- package/package.json +75 -0
- package/src/api/api.ts +586 -0
- package/src/api/config-cache.ts +79 -0
- package/src/api/session-guard.ts +58 -0
- package/src/api/types.ts +278 -0
- package/src/auth/accounts.ts +394 -0
- package/src/auth/login-qr.ts +458 -0
- package/src/auth/pairing.ts +120 -0
- package/src/cdn/aes-ecb.ts +21 -0
- package/src/cdn/cdn-upload.ts +93 -0
- package/src/cdn/cdn-url.ts +20 -0
- package/src/cdn/pic-decrypt.ts +101 -0
- package/src/cdn/upload.ts +168 -0
- package/src/channel.ts +548 -0
- package/src/compat.ts +77 -0
- package/src/config/config-schema.ts +23 -0
- package/src/config/reply-progress.ts +10 -0
- package/src/media/media-download.ts +149 -0
- package/src/media/mime.ts +76 -0
- package/src/media/silk-transcode.ts +74 -0
- package/src/messaging/debug-mode.ts +69 -0
- package/src/messaging/error-notice.ts +32 -0
- package/src/messaging/inbound.ts +262 -0
- package/src/messaging/markdown-filter.ts +361 -0
- package/src/messaging/outbound-hooks.ts +88 -0
- package/src/messaging/process-message.ts +525 -0
- package/src/messaging/reply-progress-sender.ts +122 -0
- package/src/messaging/send-media.ts +72 -0
- package/src/messaging/send.ts +294 -0
- package/src/messaging/slash-commands.ts +110 -0
- package/src/monitor/monitor.ts +224 -0
- package/src/storage/state-dir.ts +11 -0
- package/src/storage/sync-buf.ts +81 -0
- package/src/util/logger.ts +145 -0
- package/src/util/random.ts +17 -0
- package/src/util/redact.ts +54 -0
- package/src/vendor.d.ts +25 -0
package/src/api/api.ts
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { loadConfigBotAgent, loadConfigRouteTag } from "../auth/accounts.js";
|
|
7
|
+
import { logger } from "../util/logger.js";
|
|
8
|
+
import { redactBody, redactUrl } from "../util/redact.js";
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
BaseInfo,
|
|
12
|
+
GetUploadUrlReq,
|
|
13
|
+
GetUploadUrlResp,
|
|
14
|
+
GetUpdatesReq,
|
|
15
|
+
GetUpdatesResp,
|
|
16
|
+
NotifyStopResp,
|
|
17
|
+
NotifyStartResp,
|
|
18
|
+
SendMessageReq,
|
|
19
|
+
SendMessageResp,
|
|
20
|
+
SendTypingReq,
|
|
21
|
+
GetConfigResp,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
|
|
24
|
+
export type WeixinApiOptions = {
|
|
25
|
+
baseUrl: string;
|
|
26
|
+
token?: string;
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
/** Long-poll timeout for getUpdates (server may hold the request up to this). */
|
|
29
|
+
longPollTimeoutMs?: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// BaseInfo — attached to every outgoing CGI request
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
interface PackageJson {
|
|
37
|
+
name?: string;
|
|
38
|
+
version?: string;
|
|
39
|
+
ilink_appid?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Identify whether a parsed package.json belongs to this plugin.
|
|
44
|
+
*
|
|
45
|
+
* The walk-up search may pass through unrelated `package.json` files
|
|
46
|
+
* (e.g. nested `node_modules/<dep>/package.json`); only ours is accepted.
|
|
47
|
+
*/
|
|
48
|
+
function isOwnPackageJson(parsed: PackageJson): boolean {
|
|
49
|
+
if (parsed.ilink_appid !== undefined) return true;
|
|
50
|
+
return typeof parsed.name === "string" && parsed.name.includes("openclaw-weixin");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Walk up from `startDir` searching for the plugin's own `package.json`.
|
|
55
|
+
*
|
|
56
|
+
* Resilient to differing layouts between dev (TS source under `src/`) and
|
|
57
|
+
* publish (compiled output under `dist/src/`) by not assuming a fixed depth.
|
|
58
|
+
*/
|
|
59
|
+
export function readPackageJsonFromDir(startDir: string): PackageJson {
|
|
60
|
+
try {
|
|
61
|
+
let dir = startDir;
|
|
62
|
+
const { root } = path.parse(dir);
|
|
63
|
+
while (dir && dir !== root) {
|
|
64
|
+
const candidate = path.join(dir, "package.json");
|
|
65
|
+
if (fs.existsSync(candidate)) {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(fs.readFileSync(candidate, "utf-8")) as PackageJson;
|
|
68
|
+
if (isOwnPackageJson(parsed)) {
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// Malformed package.json — keep walking up.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
dir = path.dirname(dir);
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Fall through to empty default.
|
|
79
|
+
}
|
|
80
|
+
return {};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readPackageJson(): PackageJson {
|
|
84
|
+
return readPackageJsonFromDir(path.dirname(fileURLToPath(import.meta.url)));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const pkg = readPackageJson();
|
|
88
|
+
|
|
89
|
+
const CHANNEL_VERSION = pkg.version ?? "unknown";
|
|
90
|
+
|
|
91
|
+
/** iLink-App-Id: 直接读取 package.json 顶层 ilink_appid 字段。 */
|
|
92
|
+
const ILINK_APP_ID: string = pkg.ilink_appid ?? "";
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* iLink-App-ClientVersion: uint32 encoded as 0x00MMNNPP
|
|
96
|
+
* High 8 bits fixed to 0; remaining bits: major<<16 | minor<<8 | patch.
|
|
97
|
+
* e.g. "1.0.11" -> 0x0001000B = 65547
|
|
98
|
+
*/
|
|
99
|
+
function buildClientVersion(version: string): number {
|
|
100
|
+
const parts = version.split(".").map((p) => parseInt(p, 10));
|
|
101
|
+
const major = parts[0] ?? 0;
|
|
102
|
+
const minor = parts[1] ?? 0;
|
|
103
|
+
const patch = parts[2] ?? 0;
|
|
104
|
+
return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ILINK_APP_CLIENT_VERSION: number = buildClientVersion(pkg.version ?? "0.0.0");
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Default `bot_agent` value used when the upstream app does not declare one.
|
|
111
|
+
* Mirrors the role of HTTP `User-Agent`'s implicit "no UA" fallback.
|
|
112
|
+
*/
|
|
113
|
+
const DEFAULT_BOT_AGENT = "OpenClaw";
|
|
114
|
+
|
|
115
|
+
/** Maximum length (bytes) of the sanitized `bot_agent` string. */
|
|
116
|
+
const BOT_AGENT_MAX_LEN = 256;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Sanitize a user-supplied `botAgent` config value into a wire-safe string.
|
|
120
|
+
*
|
|
121
|
+
* Grammar (UA-style):
|
|
122
|
+
* bot_agent = product *( SP product )
|
|
123
|
+
* product = name "/" version [ SP "(" comment ")" ]
|
|
124
|
+
* name = 1*32( ALPHA / DIGIT / "_" / "." / "-" )
|
|
125
|
+
* version = 1*32( ALPHA / DIGIT / "_" / "." / "+" / "-" )
|
|
126
|
+
* comment = 1*64( printable ASCII minus "(" ")" )
|
|
127
|
+
*
|
|
128
|
+
* Tokens that fail to parse are dropped silently (no partial tokens kept).
|
|
129
|
+
* Returns `DEFAULT_BOT_AGENT` when the input is empty / all tokens dropped /
|
|
130
|
+
* the result exceeds the length cap after truncation.
|
|
131
|
+
*/
|
|
132
|
+
export function sanitizeBotAgent(raw: string | undefined): string {
|
|
133
|
+
if (!raw || typeof raw !== "string") return DEFAULT_BOT_AGENT;
|
|
134
|
+
const trimmed = raw.trim();
|
|
135
|
+
if (!trimmed) return DEFAULT_BOT_AGENT;
|
|
136
|
+
|
|
137
|
+
const productRe = /^[A-Za-z0-9_.\-]{1,32}\/[A-Za-z0-9_.+\-]{1,32}$/;
|
|
138
|
+
const commentCharRe = /^[\x20-\x27\x2A-\x7E]{1,64}$/;
|
|
139
|
+
|
|
140
|
+
// Tokenize on whitespace, but keep `(comment)` glued to the preceding product.
|
|
141
|
+
// Strategy: split by spaces, then re-attach any token that starts with "(".
|
|
142
|
+
const rawTokens = trimmed.split(/\s+/);
|
|
143
|
+
const tokens: string[] = [];
|
|
144
|
+
for (let i = 0; i < rawTokens.length; i += 1) {
|
|
145
|
+
const tok = rawTokens[i];
|
|
146
|
+
if (tok.startsWith("(") && !tok.endsWith(")")) {
|
|
147
|
+
// Multi-word comment; greedily collect until we find the closing ")".
|
|
148
|
+
let acc = tok;
|
|
149
|
+
while (i + 1 < rawTokens.length && !acc.endsWith(")")) {
|
|
150
|
+
i += 1;
|
|
151
|
+
acc += " " + rawTokens[i];
|
|
152
|
+
}
|
|
153
|
+
tokens.push(acc);
|
|
154
|
+
} else {
|
|
155
|
+
tokens.push(tok);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const accepted: string[] = [];
|
|
160
|
+
let pendingProduct: string | null = null;
|
|
161
|
+
for (const tok of tokens) {
|
|
162
|
+
if (tok.startsWith("(") && tok.endsWith(")")) {
|
|
163
|
+
const inner = tok.slice(1, -1);
|
|
164
|
+
if (pendingProduct && commentCharRe.test(inner)) {
|
|
165
|
+
accepted.push(`${pendingProduct} (${inner})`);
|
|
166
|
+
pendingProduct = null;
|
|
167
|
+
} else {
|
|
168
|
+
if (pendingProduct) {
|
|
169
|
+
accepted.push(pendingProduct);
|
|
170
|
+
pendingProduct = null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (pendingProduct) {
|
|
176
|
+
accepted.push(pendingProduct);
|
|
177
|
+
pendingProduct = null;
|
|
178
|
+
}
|
|
179
|
+
if (productRe.test(tok)) {
|
|
180
|
+
pendingProduct = tok;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (pendingProduct) accepted.push(pendingProduct);
|
|
184
|
+
|
|
185
|
+
if (accepted.length === 0) return DEFAULT_BOT_AGENT;
|
|
186
|
+
|
|
187
|
+
const joined = accepted.join(" ");
|
|
188
|
+
if (Buffer.byteLength(joined, "utf-8") <= BOT_AGENT_MAX_LEN) return joined;
|
|
189
|
+
|
|
190
|
+
// Truncate by dropping trailing tokens until under the cap.
|
|
191
|
+
const truncated: string[] = [];
|
|
192
|
+
let len = 0;
|
|
193
|
+
for (const t of accepted) {
|
|
194
|
+
const add = (truncated.length === 0 ? 0 : 1) + Buffer.byteLength(t, "utf-8");
|
|
195
|
+
if (len + add > BOT_AGENT_MAX_LEN) break;
|
|
196
|
+
truncated.push(t);
|
|
197
|
+
len += add;
|
|
198
|
+
}
|
|
199
|
+
return truncated.length > 0 ? truncated.join(" ") : DEFAULT_BOT_AGENT;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Build the `base_info` payload included in every API request. */
|
|
203
|
+
export function buildBaseInfo(): BaseInfo {
|
|
204
|
+
return {
|
|
205
|
+
channel_version: CHANNEL_VERSION,
|
|
206
|
+
bot_agent: sanitizeBotAgent(loadConfigBotAgent()),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Default timeout for long-poll getUpdates requests. */
|
|
211
|
+
const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
212
|
+
/** Default timeout for regular API requests (sendMessage, getUploadUrl). */
|
|
213
|
+
const DEFAULT_API_TIMEOUT_MS = 15_000;
|
|
214
|
+
/** Default timeout for lightweight API requests (getConfig, sendTyping). */
|
|
215
|
+
const DEFAULT_CONFIG_TIMEOUT_MS = 10_000;
|
|
216
|
+
|
|
217
|
+
function ensureTrailingSlash(url: string): string {
|
|
218
|
+
return url.endsWith("/") ? url : `${url}/`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */
|
|
222
|
+
function randomWechatUin(): string {
|
|
223
|
+
const uint32 = crypto.randomBytes(4).readUInt32BE(0);
|
|
224
|
+
return Buffer.from(String(uint32), "utf-8").toString("base64");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Build headers shared by both GET and POST requests. */
|
|
228
|
+
function buildCommonHeaders(): Record<string, string> {
|
|
229
|
+
const headers: Record<string, string> = {
|
|
230
|
+
"iLink-App-Id": ILINK_APP_ID,
|
|
231
|
+
"iLink-App-ClientVersion": String(ILINK_APP_CLIENT_VERSION),
|
|
232
|
+
};
|
|
233
|
+
const routeTag = loadConfigRouteTag();
|
|
234
|
+
if (routeTag) {
|
|
235
|
+
headers.SKRouteTag = routeTag;
|
|
236
|
+
}
|
|
237
|
+
return headers;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function buildHeaders(opts: { token?: string }): Record<string, string> {
|
|
241
|
+
const headers: Record<string, string> = {
|
|
242
|
+
"Content-Type": "application/json",
|
|
243
|
+
AuthorizationType: "ilink_bot_token",
|
|
244
|
+
"X-WECHAT-UIN": randomWechatUin(),
|
|
245
|
+
...buildCommonHeaders(),
|
|
246
|
+
};
|
|
247
|
+
if (opts.token?.trim()) {
|
|
248
|
+
headers.Authorization = `Bearer ${opts.token.trim()}`;
|
|
249
|
+
}
|
|
250
|
+
logger.debug(
|
|
251
|
+
`requestHeaders: ${JSON.stringify({ ...headers, Authorization: headers.Authorization ? "Bearer ***" : undefined })}`,
|
|
252
|
+
);
|
|
253
|
+
return headers;
|
|
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
|
+
|
|
290
|
+
/**
|
|
291
|
+
* GET fetch wrapper: send a GET request to a Weixin API endpoint.
|
|
292
|
+
* When `timeoutMs` is set, the request is aborted after that many milliseconds.
|
|
293
|
+
* Query parameters should already be encoded in `endpoint`.
|
|
294
|
+
* Returns the raw response text on success; throws on HTTP error or (if used) timeout abort.
|
|
295
|
+
*/
|
|
296
|
+
export async function apiGetFetch(params: {
|
|
297
|
+
baseUrl: string;
|
|
298
|
+
endpoint: string;
|
|
299
|
+
timeoutMs?: number;
|
|
300
|
+
label: string;
|
|
301
|
+
}): Promise<string> {
|
|
302
|
+
const base = ensureTrailingSlash(params.baseUrl);
|
|
303
|
+
const url = new URL(params.endpoint, base);
|
|
304
|
+
const hdrs = buildCommonHeaders();
|
|
305
|
+
logger.debug(`GET ${redactUrl(url.toString())}`);
|
|
306
|
+
|
|
307
|
+
const timeoutMs = params.timeoutMs;
|
|
308
|
+
const controller =
|
|
309
|
+
timeoutMs != null && timeoutMs > 0 ? new AbortController() : undefined;
|
|
310
|
+
const t =
|
|
311
|
+
controller != null && timeoutMs != null
|
|
312
|
+
? setTimeout(() => controller.abort(), timeoutMs)
|
|
313
|
+
: undefined;
|
|
314
|
+
try {
|
|
315
|
+
const res = await fetch(url.toString(), {
|
|
316
|
+
method: "GET",
|
|
317
|
+
headers: hdrs,
|
|
318
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
319
|
+
});
|
|
320
|
+
if (t !== undefined) clearTimeout(t);
|
|
321
|
+
const rawText = await res.text();
|
|
322
|
+
logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
|
|
323
|
+
if (!res.ok) {
|
|
324
|
+
throw new Error(`${params.label} ${res.status}: ${rawText}`);
|
|
325
|
+
}
|
|
326
|
+
return rawText;
|
|
327
|
+
} catch (err) {
|
|
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
|
+
);
|
|
333
|
+
throw err;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
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
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Common fetch wrapper: POST JSON to a Weixin API endpoint.
|
|
367
|
+
* When `timeoutMs` is provided, the request is aborted after that many milliseconds.
|
|
368
|
+
* When omitted, no client-side timeout is applied (relies on OS/TCP stack).
|
|
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`.
|
|
373
|
+
*/
|
|
374
|
+
export async function apiPostFetch(params: {
|
|
375
|
+
baseUrl: string;
|
|
376
|
+
endpoint: string;
|
|
377
|
+
body: string;
|
|
378
|
+
token?: string;
|
|
379
|
+
timeoutMs?: number;
|
|
380
|
+
label: string;
|
|
381
|
+
abortSignal?: AbortSignal;
|
|
382
|
+
}): Promise<string> {
|
|
383
|
+
const base = ensureTrailingSlash(params.baseUrl);
|
|
384
|
+
const url = new URL(params.endpoint, base);
|
|
385
|
+
const hdrs = buildHeaders({ token: params.token });
|
|
386
|
+
logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);
|
|
387
|
+
|
|
388
|
+
const controller =
|
|
389
|
+
params.timeoutMs !== undefined ? new AbortController() : undefined;
|
|
390
|
+
const t =
|
|
391
|
+
controller != null && params.timeoutMs !== undefined
|
|
392
|
+
? setTimeout(() => controller.abort(), params.timeoutMs)
|
|
393
|
+
: undefined;
|
|
394
|
+
const { signal, cleanup } = combineAbortSignals({
|
|
395
|
+
internal: controller,
|
|
396
|
+
external: params.abortSignal,
|
|
397
|
+
});
|
|
398
|
+
try {
|
|
399
|
+
const res = await fetch(url.toString(), {
|
|
400
|
+
method: "POST",
|
|
401
|
+
headers: hdrs,
|
|
402
|
+
body: params.body,
|
|
403
|
+
...(signal ? { signal } : {}),
|
|
404
|
+
});
|
|
405
|
+
if (t !== undefined) clearTimeout(t);
|
|
406
|
+
const rawText = await res.text();
|
|
407
|
+
logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
|
|
408
|
+
if (!res.ok) {
|
|
409
|
+
throw new Error(`${params.label} ${res.status}: ${rawText}`);
|
|
410
|
+
}
|
|
411
|
+
return rawText;
|
|
412
|
+
} catch (err) {
|
|
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
|
+
);
|
|
418
|
+
throw err;
|
|
419
|
+
} finally {
|
|
420
|
+
cleanup();
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Long-poll getUpdates. Server should hold the request until new messages or timeout.
|
|
426
|
+
*
|
|
427
|
+
* On client-side timeout (no server response within timeoutMs), returns an empty response
|
|
428
|
+
* with ret=0 so the caller can simply retry. This is normal for long-poll.
|
|
429
|
+
*/
|
|
430
|
+
export async function getUpdates(
|
|
431
|
+
params: GetUpdatesReq & {
|
|
432
|
+
baseUrl: string;
|
|
433
|
+
token?: string;
|
|
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;
|
|
440
|
+
},
|
|
441
|
+
): Promise<GetUpdatesResp> {
|
|
442
|
+
const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
|
|
443
|
+
try {
|
|
444
|
+
const rawText = await apiPostFetch({
|
|
445
|
+
baseUrl: params.baseUrl,
|
|
446
|
+
endpoint: "ilink/bot/getupdates",
|
|
447
|
+
body: JSON.stringify({
|
|
448
|
+
get_updates_buf: params.get_updates_buf ?? "",
|
|
449
|
+
base_info: buildBaseInfo(),
|
|
450
|
+
}),
|
|
451
|
+
token: params.token,
|
|
452
|
+
timeoutMs: timeout,
|
|
453
|
+
label: "getUpdates",
|
|
454
|
+
abortSignal: params.abortSignal,
|
|
455
|
+
});
|
|
456
|
+
const resp: GetUpdatesResp = JSON.parse(rawText);
|
|
457
|
+
return resp;
|
|
458
|
+
} catch (err) {
|
|
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.
|
|
461
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
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
|
+
}
|
|
467
|
+
return { ret: 0, msgs: [], get_updates_buf: params.get_updates_buf };
|
|
468
|
+
}
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Get a pre-signed CDN upload URL for a file. */
|
|
474
|
+
export async function getUploadUrl(
|
|
475
|
+
params: GetUploadUrlReq & WeixinApiOptions,
|
|
476
|
+
): Promise<GetUploadUrlResp> {
|
|
477
|
+
const rawText = await apiPostFetch({
|
|
478
|
+
baseUrl: params.baseUrl,
|
|
479
|
+
endpoint: "ilink/bot/getuploadurl",
|
|
480
|
+
body: JSON.stringify({
|
|
481
|
+
filekey: params.filekey,
|
|
482
|
+
media_type: params.media_type,
|
|
483
|
+
to_user_id: params.to_user_id,
|
|
484
|
+
rawsize: params.rawsize,
|
|
485
|
+
rawfilemd5: params.rawfilemd5,
|
|
486
|
+
filesize: params.filesize,
|
|
487
|
+
thumb_rawsize: params.thumb_rawsize,
|
|
488
|
+
thumb_rawfilemd5: params.thumb_rawfilemd5,
|
|
489
|
+
thumb_filesize: params.thumb_filesize,
|
|
490
|
+
no_need_thumb: params.no_need_thumb,
|
|
491
|
+
aeskey: params.aeskey,
|
|
492
|
+
base_info: buildBaseInfo(),
|
|
493
|
+
}),
|
|
494
|
+
token: params.token,
|
|
495
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
496
|
+
label: "getUploadUrl",
|
|
497
|
+
});
|
|
498
|
+
const resp: GetUploadUrlResp = JSON.parse(rawText);
|
|
499
|
+
return resp;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Send a single message downstream. */
|
|
503
|
+
export async function sendMessage(
|
|
504
|
+
params: WeixinApiOptions & { body: SendMessageReq },
|
|
505
|
+
): Promise<void> {
|
|
506
|
+
const rawText = await apiPostFetch({
|
|
507
|
+
baseUrl: params.baseUrl,
|
|
508
|
+
endpoint: "ilink/bot/sendmessage",
|
|
509
|
+
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
|
510
|
+
token: params.token,
|
|
511
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
|
|
512
|
+
label: "sendMessage",
|
|
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
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Fetch bot config (includes typing_ticket) for a given user. */
|
|
523
|
+
export async function getConfig(
|
|
524
|
+
params: WeixinApiOptions & { ilinkUserId: string; contextToken?: string },
|
|
525
|
+
): Promise<GetConfigResp> {
|
|
526
|
+
const rawText = await apiPostFetch({
|
|
527
|
+
baseUrl: params.baseUrl,
|
|
528
|
+
endpoint: "ilink/bot/getconfig",
|
|
529
|
+
body: JSON.stringify({
|
|
530
|
+
ilink_user_id: params.ilinkUserId,
|
|
531
|
+
context_token: params.contextToken,
|
|
532
|
+
base_info: buildBaseInfo(),
|
|
533
|
+
}),
|
|
534
|
+
token: params.token,
|
|
535
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
536
|
+
label: "getConfig",
|
|
537
|
+
});
|
|
538
|
+
const resp: GetConfigResp = JSON.parse(rawText);
|
|
539
|
+
return resp;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Send a typing indicator to a user. */
|
|
543
|
+
export async function sendTyping(
|
|
544
|
+
params: WeixinApiOptions & { body: SendTypingReq },
|
|
545
|
+
): Promise<void> {
|
|
546
|
+
await apiPostFetch({
|
|
547
|
+
baseUrl: params.baseUrl,
|
|
548
|
+
endpoint: "ilink/bot/sendtyping",
|
|
549
|
+
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
|
550
|
+
token: params.token,
|
|
551
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
552
|
+
label: "sendTyping",
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Notify Weixin that this channel client is stopping (gateway shutdown / channel stop).
|
|
558
|
+
* Uses a standalone timeout (not the gateway abort signal) so the request can finish
|
|
559
|
+
* after OpenClaw has already aborted the long-poll.
|
|
560
|
+
*/
|
|
561
|
+
export async function notifyStop(params: WeixinApiOptions): Promise<NotifyStopResp> {
|
|
562
|
+
const rawText = await apiPostFetch({
|
|
563
|
+
baseUrl: params.baseUrl,
|
|
564
|
+
endpoint: "ilink/bot/msg/notifystop",
|
|
565
|
+
body: JSON.stringify({ base_info: buildBaseInfo() }),
|
|
566
|
+
token: params.token,
|
|
567
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
568
|
+
label: "notifyStop",
|
|
569
|
+
});
|
|
570
|
+
return JSON.parse(rawText) as NotifyStopResp;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Notify Weixin that this channel client is starting (gateway startup / channel start).
|
|
575
|
+
*/
|
|
576
|
+
export async function notifyStart(params: WeixinApiOptions): Promise<NotifyStartResp> {
|
|
577
|
+
const rawText = await apiPostFetch({
|
|
578
|
+
baseUrl: params.baseUrl,
|
|
579
|
+
endpoint: "ilink/bot/msg/notifystart",
|
|
580
|
+
body: JSON.stringify({ base_info: buildBaseInfo() }),
|
|
581
|
+
token: params.token,
|
|
582
|
+
timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
|
|
583
|
+
label: "notifyStart",
|
|
584
|
+
});
|
|
585
|
+
return JSON.parse(rawText) as NotifyStartResp;
|
|
586
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { getConfig } from "./api.js";
|
|
2
|
+
|
|
3
|
+
/** Subset of getConfig fields that we actually need; add new fields here as needed. */
|
|
4
|
+
export interface CachedConfig {
|
|
5
|
+
typingTicket: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
const CONFIG_CACHE_INITIAL_RETRY_MS = 2_000;
|
|
10
|
+
const CONFIG_CACHE_MAX_RETRY_MS = 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
interface ConfigCacheEntry {
|
|
13
|
+
config: CachedConfig;
|
|
14
|
+
everSucceeded: boolean;
|
|
15
|
+
nextFetchAt: number;
|
|
16
|
+
retryDelayMs: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Per-user getConfig cache with periodic random refresh (within 24h) and
|
|
21
|
+
* exponential-backoff retry (up to 1h) on failure.
|
|
22
|
+
*/
|
|
23
|
+
export class WeixinConfigManager {
|
|
24
|
+
private cache = new Map<string, ConfigCacheEntry>();
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
private apiOpts: { baseUrl: string; token?: string },
|
|
28
|
+
private log: (msg: string) => void,
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
async getForUser(userId: string, contextToken?: string): Promise<CachedConfig> {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const entry = this.cache.get(userId);
|
|
34
|
+
const shouldFetch = !entry || now >= entry.nextFetchAt;
|
|
35
|
+
|
|
36
|
+
if (shouldFetch) {
|
|
37
|
+
let fetchOk = false;
|
|
38
|
+
try {
|
|
39
|
+
const resp = await getConfig({
|
|
40
|
+
baseUrl: this.apiOpts.baseUrl,
|
|
41
|
+
token: this.apiOpts.token,
|
|
42
|
+
ilinkUserId: userId,
|
|
43
|
+
contextToken,
|
|
44
|
+
});
|
|
45
|
+
if (resp.ret === 0) {
|
|
46
|
+
this.cache.set(userId, {
|
|
47
|
+
config: { typingTicket: resp.typing_ticket ?? "" },
|
|
48
|
+
everSucceeded: true,
|
|
49
|
+
nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS,
|
|
50
|
+
retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,
|
|
51
|
+
});
|
|
52
|
+
this.log(
|
|
53
|
+
`[weixin] config ${entry?.everSucceeded ? "refreshed" : "cached"} for ${userId}`,
|
|
54
|
+
);
|
|
55
|
+
fetchOk = true;
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
this.log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`);
|
|
59
|
+
}
|
|
60
|
+
if (!fetchOk) {
|
|
61
|
+
const prevDelay = entry?.retryDelayMs ?? CONFIG_CACHE_INITIAL_RETRY_MS;
|
|
62
|
+
const nextDelay = Math.min(prevDelay * 2, CONFIG_CACHE_MAX_RETRY_MS);
|
|
63
|
+
if (entry) {
|
|
64
|
+
entry.nextFetchAt = now + nextDelay;
|
|
65
|
+
entry.retryDelayMs = nextDelay;
|
|
66
|
+
} else {
|
|
67
|
+
this.cache.set(userId, {
|
|
68
|
+
config: { typingTicket: "" },
|
|
69
|
+
everSucceeded: false,
|
|
70
|
+
nextFetchAt: now + CONFIG_CACHE_INITIAL_RETRY_MS,
|
|
71
|
+
retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return this.cache.get(userId)?.config ?? { typingTicket: "" };
|
|
78
|
+
}
|
|
79
|
+
}
|