@tencent-weixin/openclaw-weixin 2.0.1 → 2.1.2
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/README.md +1 -1
- package/README.zh_CN.md +1 -1
- package/index.ts +0 -9
- package/openclaw.plugin.json +1 -1
- package/package.json +3 -5
- package/src/api/api.ts +87 -15
- package/src/api/types.ts +4 -0
- package/src/auth/accounts.ts +16 -15
- package/src/auth/login-qr.ts +49 -56
- package/src/cdn/cdn-upload.ts +13 -3
- package/src/cdn/cdn-url.ts +3 -0
- package/src/cdn/pic-decrypt.ts +19 -3
- package/src/cdn/upload.ts +6 -4
- package/src/channel.ts +1 -0
- package/src/compat.ts +2 -2
- package/src/config/config-schema.ts +2 -2
- package/src/media/media-download.ts +18 -10
- package/src/messaging/process-message.ts +8 -6
- package/src/log-upload.ts +0 -149
package/README.md
CHANGED
package/README.zh_CN.md
CHANGED
package/index.ts
CHANGED
|
@@ -4,7 +4,6 @@ import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-sch
|
|
|
4
4
|
import { weixinPlugin } from "./src/channel.js";
|
|
5
5
|
import { assertHostCompatibility } from "./src/compat.js";
|
|
6
6
|
import { WeixinConfigSchema } from "./src/config/config-schema.js";
|
|
7
|
-
import { registerWeixinCli } from "./src/log-upload.js";
|
|
8
7
|
import { setWeixinRuntime } from "./src/runtime.js";
|
|
9
8
|
|
|
10
9
|
export default {
|
|
@@ -21,13 +20,5 @@ export default {
|
|
|
21
20
|
}
|
|
22
21
|
|
|
23
22
|
api.registerChannel({ plugin: weixinPlugin });
|
|
24
|
-
|
|
25
|
-
// registrationMode exists in 2026.3.22+; skip heavy registrations in setup-only mode.
|
|
26
|
-
const mode = (api as { registrationMode?: string }).registrationMode;
|
|
27
|
-
if (mode && mode !== "full") return;
|
|
28
|
-
|
|
29
|
-
api.registerCli(({ program, config }) => registerWeixinCli({ program, config }), {
|
|
30
|
-
commands: ["openclaw-weixin"],
|
|
31
|
-
});
|
|
32
23
|
},
|
|
33
24
|
};
|
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.
|
|
3
|
+
"version": "2.1.2",
|
|
4
4
|
"description": "OpenClaw Weixin channel",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Tencent",
|
|
@@ -29,9 +29,6 @@
|
|
|
29
29
|
"qrcode-terminal": "0.12.0",
|
|
30
30
|
"zod": "4.3.6"
|
|
31
31
|
},
|
|
32
|
-
"peerDependencies": {
|
|
33
|
-
"openclaw": ">=2026.3.22"
|
|
34
|
-
},
|
|
35
32
|
"devDependencies": {
|
|
36
33
|
"@vitest/coverage-v8": "^3.1.0",
|
|
37
34
|
"openclaw": "2026.3.23",
|
|
@@ -57,5 +54,6 @@
|
|
|
57
54
|
"defaultChoice": "npm",
|
|
58
55
|
"minHostVersion": ">=2026.3.22"
|
|
59
56
|
}
|
|
60
|
-
}
|
|
57
|
+
},
|
|
58
|
+
"ilink_appid": "bot"
|
|
61
59
|
}
|
package/src/api/api.ts
CHANGED
|
@@ -30,18 +30,43 @@ export type WeixinApiOptions = {
|
|
|
30
30
|
// BaseInfo — attached to every outgoing CGI request
|
|
31
31
|
// ---------------------------------------------------------------------------
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
interface PackageJson {
|
|
34
|
+
name?: string;
|
|
35
|
+
version?: string;
|
|
36
|
+
ilink_appid?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readPackageJson(): PackageJson {
|
|
34
40
|
try {
|
|
35
41
|
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
36
42
|
const pkgPath = path.resolve(dir, "..", "..", "package.json");
|
|
37
|
-
|
|
38
|
-
return pkg.version ?? "unknown";
|
|
43
|
+
return JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as PackageJson;
|
|
39
44
|
} catch {
|
|
40
|
-
return
|
|
45
|
+
return {};
|
|
41
46
|
}
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
const
|
|
49
|
+
const pkg = readPackageJson();
|
|
50
|
+
|
|
51
|
+
const CHANNEL_VERSION = pkg.version ?? "unknown";
|
|
52
|
+
|
|
53
|
+
/** iLink-App-Id: 直接读取 package.json 顶层 ilink_appid 字段。 */
|
|
54
|
+
const ILINK_APP_ID: string = pkg.ilink_appid ?? "";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* iLink-App-ClientVersion: uint32 encoded as 0x00MMNNPP
|
|
58
|
+
* High 8 bits fixed to 0; remaining bits: major<<16 | minor<<8 | patch.
|
|
59
|
+
* e.g. "1.0.11" -> 0x0001000B = 65547
|
|
60
|
+
*/
|
|
61
|
+
function buildClientVersion(version: string): number {
|
|
62
|
+
const parts = version.split(".").map((p) => parseInt(p, 10));
|
|
63
|
+
const major = parts[0] ?? 0;
|
|
64
|
+
const minor = parts[1] ?? 0;
|
|
65
|
+
const patch = parts[2] ?? 0;
|
|
66
|
+
return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ILINK_APP_CLIENT_VERSION: number = buildClientVersion(pkg.version ?? "0.0.0");
|
|
45
70
|
|
|
46
71
|
/** Build the `base_info` payload included in every API request. */
|
|
47
72
|
export function buildBaseInfo(): BaseInfo {
|
|
@@ -65,31 +90,78 @@ function randomWechatUin(): string {
|
|
|
65
90
|
return Buffer.from(String(uint32), "utf-8").toString("base64");
|
|
66
91
|
}
|
|
67
92
|
|
|
93
|
+
/** Build headers shared by both GET and POST requests. */
|
|
94
|
+
function buildCommonHeaders(): Record<string, string> {
|
|
95
|
+
const headers: Record<string, string> = {
|
|
96
|
+
"iLink-App-Id": ILINK_APP_ID,
|
|
97
|
+
"iLink-App-ClientVersion": String(ILINK_APP_CLIENT_VERSION),
|
|
98
|
+
};
|
|
99
|
+
const routeTag = loadConfigRouteTag();
|
|
100
|
+
if (routeTag) {
|
|
101
|
+
headers.SKRouteTag = routeTag;
|
|
102
|
+
}
|
|
103
|
+
return headers;
|
|
104
|
+
}
|
|
105
|
+
|
|
68
106
|
function buildHeaders(opts: { token?: string; body: string }): Record<string, string> {
|
|
69
107
|
const headers: Record<string, string> = {
|
|
70
108
|
"Content-Type": "application/json",
|
|
71
109
|
AuthorizationType: "ilink_bot_token",
|
|
72
110
|
"Content-Length": String(Buffer.byteLength(opts.body, "utf-8")),
|
|
73
111
|
"X-WECHAT-UIN": randomWechatUin(),
|
|
112
|
+
...buildCommonHeaders(),
|
|
74
113
|
};
|
|
75
114
|
if (opts.token?.trim()) {
|
|
76
115
|
headers.Authorization = `Bearer ${opts.token.trim()}`;
|
|
77
116
|
}
|
|
78
|
-
const routeTag = loadConfigRouteTag();
|
|
79
|
-
if (routeTag) {
|
|
80
|
-
headers.SKRouteTag = routeTag;
|
|
81
|
-
}
|
|
82
117
|
logger.debug(
|
|
83
118
|
`requestHeaders: ${JSON.stringify({ ...headers, Authorization: headers.Authorization ? "Bearer ***" : undefined })}`,
|
|
84
119
|
);
|
|
85
120
|
return headers;
|
|
86
121
|
}
|
|
87
122
|
|
|
123
|
+
/**
|
|
124
|
+
* GET fetch wrapper: send a GET request to a Weixin API endpoint with timeout + abort.
|
|
125
|
+
* Query parameters should already be encoded in `endpoint`.
|
|
126
|
+
* Returns the raw response text on success; throws on HTTP error or timeout.
|
|
127
|
+
*/
|
|
128
|
+
export async function apiGetFetch(params: {
|
|
129
|
+
baseUrl: string;
|
|
130
|
+
endpoint: string;
|
|
131
|
+
timeoutMs: number;
|
|
132
|
+
label: string;
|
|
133
|
+
}): Promise<string> {
|
|
134
|
+
const base = ensureTrailingSlash(params.baseUrl);
|
|
135
|
+
const url = new URL(params.endpoint, base);
|
|
136
|
+
const hdrs = buildCommonHeaders();
|
|
137
|
+
logger.debug(`GET ${redactUrl(url.toString())}`);
|
|
138
|
+
|
|
139
|
+
const controller = new AbortController();
|
|
140
|
+
const t = setTimeout(() => controller.abort(), params.timeoutMs);
|
|
141
|
+
try {
|
|
142
|
+
const res = await fetch(url.toString(), {
|
|
143
|
+
method: "GET",
|
|
144
|
+
headers: hdrs,
|
|
145
|
+
signal: controller.signal,
|
|
146
|
+
});
|
|
147
|
+
clearTimeout(t);
|
|
148
|
+
const rawText = await res.text();
|
|
149
|
+
logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
|
|
150
|
+
if (!res.ok) {
|
|
151
|
+
throw new Error(`${params.label} ${res.status}: ${rawText}`);
|
|
152
|
+
}
|
|
153
|
+
return rawText;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
clearTimeout(t);
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
88
160
|
/**
|
|
89
161
|
* Common fetch wrapper: POST JSON to a Weixin API endpoint with timeout + abort.
|
|
90
162
|
* Returns the raw response text on success; throws on HTTP error or timeout.
|
|
91
163
|
*/
|
|
92
|
-
async function
|
|
164
|
+
async function apiPostFetch(params: {
|
|
93
165
|
baseUrl: string;
|
|
94
166
|
endpoint: string;
|
|
95
167
|
body: string;
|
|
@@ -139,7 +211,7 @@ export async function getUpdates(
|
|
|
139
211
|
): Promise<GetUpdatesResp> {
|
|
140
212
|
const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
|
|
141
213
|
try {
|
|
142
|
-
const rawText = await
|
|
214
|
+
const rawText = await apiPostFetch({
|
|
143
215
|
baseUrl: params.baseUrl,
|
|
144
216
|
endpoint: "ilink/bot/getupdates",
|
|
145
217
|
body: JSON.stringify({
|
|
@@ -166,7 +238,7 @@ export async function getUpdates(
|
|
|
166
238
|
export async function getUploadUrl(
|
|
167
239
|
params: GetUploadUrlReq & WeixinApiOptions,
|
|
168
240
|
): Promise<GetUploadUrlResp> {
|
|
169
|
-
const rawText = await
|
|
241
|
+
const rawText = await apiPostFetch({
|
|
170
242
|
baseUrl: params.baseUrl,
|
|
171
243
|
endpoint: "ilink/bot/getuploadurl",
|
|
172
244
|
body: JSON.stringify({
|
|
@@ -195,7 +267,7 @@ export async function getUploadUrl(
|
|
|
195
267
|
export async function sendMessage(
|
|
196
268
|
params: WeixinApiOptions & { body: SendMessageReq },
|
|
197
269
|
): Promise<void> {
|
|
198
|
-
await
|
|
270
|
+
await apiPostFetch({
|
|
199
271
|
baseUrl: params.baseUrl,
|
|
200
272
|
endpoint: "ilink/bot/sendmessage",
|
|
201
273
|
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
|
@@ -209,7 +281,7 @@ export async function sendMessage(
|
|
|
209
281
|
export async function getConfig(
|
|
210
282
|
params: WeixinApiOptions & { ilinkUserId: string; contextToken?: string },
|
|
211
283
|
): Promise<GetConfigResp> {
|
|
212
|
-
const rawText = await
|
|
284
|
+
const rawText = await apiPostFetch({
|
|
213
285
|
baseUrl: params.baseUrl,
|
|
214
286
|
endpoint: "ilink/bot/getconfig",
|
|
215
287
|
body: JSON.stringify({
|
|
@@ -229,7 +301,7 @@ export async function getConfig(
|
|
|
229
301
|
export async function sendTyping(
|
|
230
302
|
params: WeixinApiOptions & { body: SendTypingReq },
|
|
231
303
|
): Promise<void> {
|
|
232
|
-
await
|
|
304
|
+
await apiPostFetch({
|
|
233
305
|
baseUrl: params.baseUrl,
|
|
234
306
|
endpoint: "ilink/bot/sendtyping",
|
|
235
307
|
body: JSON.stringify({ ...params.body, base_info: buildBaseInfo() }),
|
package/src/api/types.ts
CHANGED
|
@@ -44,6 +44,8 @@ export interface GetUploadUrlResp {
|
|
|
44
44
|
upload_param?: string;
|
|
45
45
|
/** 缩略图上传加密参数,无缩略图时为空 */
|
|
46
46
|
thumb_upload_param?: string;
|
|
47
|
+
/** 完整上传 URL(服务端直接返回,无需客户端拼接) */
|
|
48
|
+
upload_full_url?: string;
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
export const MessageType = {
|
|
@@ -77,6 +79,8 @@ export interface CDNMedia {
|
|
|
77
79
|
aes_key?: string;
|
|
78
80
|
/** 加密类型: 0=只加密fileid, 1=打包缩略图/中图等信息 */
|
|
79
81
|
encrypt_type?: number;
|
|
82
|
+
/** 完整下载 URL(服务端直接返回,无需客户端拼接) */
|
|
83
|
+
full_url?: string;
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
export interface ImageItem {
|
package/src/auth/accounts.ts
CHANGED
|
@@ -291,28 +291,27 @@ export function loadConfigRouteTag(accountId?: string): string | undefined {
|
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
/**
|
|
294
|
-
*
|
|
295
|
-
*
|
|
294
|
+
* Bump `channels.openclaw-weixin.channelConfigUpdatedAt` in openclaw.json on each successful login
|
|
295
|
+
* so the gateway reloads config from disk (no empty `accounts: {}` placeholder).
|
|
296
296
|
*/
|
|
297
297
|
export async function triggerWeixinChannelReload(): Promise<void> {
|
|
298
298
|
try {
|
|
299
299
|
const { loadConfig, writeConfigFile } = await import("openclaw/plugin-sdk/config-runtime");
|
|
300
300
|
const cfg = loadConfig();
|
|
301
301
|
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
},
|
|
302
|
+
const existing = (channels["openclaw-weixin"] as Record<string, unknown> | undefined) ?? {};
|
|
303
|
+
const updated: OpenClawConfig = {
|
|
304
|
+
...cfg,
|
|
305
|
+
channels: {
|
|
306
|
+
...channels,
|
|
307
|
+
"openclaw-weixin": {
|
|
308
|
+
...existing,
|
|
309
|
+
channelConfigUpdatedAt: new Date().toISOString(),
|
|
311
310
|
},
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
await writeConfigFile(updated);
|
|
314
|
+
logger.info("triggerWeixinChannelReload: wrote channel config to openclaw.json");
|
|
316
315
|
} catch (err) {
|
|
317
316
|
logger.warn(`triggerWeixinChannelReload: failed to update config: ${String(err)}`);
|
|
318
317
|
}
|
|
@@ -343,6 +342,8 @@ type WeixinAccountConfig = {
|
|
|
343
342
|
|
|
344
343
|
type WeixinSectionConfig = WeixinAccountConfig & {
|
|
345
344
|
accounts?: Record<string, WeixinAccountConfig>;
|
|
345
|
+
/** Written on each successful login; see triggerWeixinChannelReload. */
|
|
346
|
+
channelConfigUpdatedAt?: string;
|
|
346
347
|
};
|
|
347
348
|
|
|
348
349
|
/** List accountIds from the index file (written at QR login). */
|
package/src/auth/login-qr.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { apiGetFetch } from "../api/api.js";
|
|
4
4
|
import { logger } from "../util/logger.js";
|
|
5
5
|
import { redactToken } from "../util/redact.js";
|
|
6
6
|
|
|
@@ -11,17 +11,24 @@ type ActiveLogin = {
|
|
|
11
11
|
qrcodeUrl: string;
|
|
12
12
|
startedAt: number;
|
|
13
13
|
botToken?: string;
|
|
14
|
-
status?: "wait" | "scaned" | "confirmed" | "expired";
|
|
14
|
+
status?: "wait" | "scaned" | "confirmed" | "expired" | "scaned_but_redirect";
|
|
15
15
|
error?: string;
|
|
16
|
+
/** The current effective polling base URL; may be updated on IDC redirect. */
|
|
17
|
+
currentApiBaseUrl?: string;
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;
|
|
21
|
+
/** Client-side timeout for the get_bot_qrcode request. */
|
|
22
|
+
const GET_QRCODE_TIMEOUT_MS = 10_000;
|
|
19
23
|
/** Client-side timeout for the long-poll get_qrcode_status request. */
|
|
20
24
|
const QR_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
21
25
|
|
|
22
26
|
/** Default `bot_type` for ilink get_bot_qrcode / get_qrcode_status (this channel build). */
|
|
23
27
|
export const DEFAULT_ILINK_BOT_TYPE = "3";
|
|
24
28
|
|
|
29
|
+
/** Fixed API base URL for all QR code requests. */
|
|
30
|
+
const FIXED_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
31
|
+
|
|
25
32
|
const activeLogins = new Map<string, ActiveLogin>();
|
|
26
33
|
|
|
27
34
|
interface QRCodeResponse {
|
|
@@ -30,12 +37,14 @@ interface QRCodeResponse {
|
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
interface StatusResponse {
|
|
33
|
-
status: "wait" | "scaned" | "confirmed" | "expired";
|
|
40
|
+
status: "wait" | "scaned" | "confirmed" | "expired" | "scaned_but_redirect";
|
|
34
41
|
bot_token?: string;
|
|
35
42
|
ilink_bot_id?: string;
|
|
36
43
|
baseurl?: string;
|
|
37
44
|
/** The user ID of the person who scanned the QR code. */
|
|
38
45
|
ilink_user_id?: string;
|
|
46
|
+
/** New host to redirect polling to when status is scaned_but_redirect. */
|
|
47
|
+
redirect_host?: string;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
function isLoginFresh(login: ActiveLogin): boolean {
|
|
@@ -52,58 +61,35 @@ function purgeExpiredLogins(): void {
|
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
async function fetchQRCode(apiBaseUrl: string, botType: string): Promise<QRCodeResponse> {
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const response = await fetch(url.toString(), { headers });
|
|
66
|
-
if (!response.ok) {
|
|
67
|
-
const body = await response.text().catch(() => "(unreadable)");
|
|
68
|
-
logger.error(`QR code fetch failed: ${response.status} ${response.statusText} body=${body}`);
|
|
69
|
-
throw new Error(`Failed to fetch QR code: ${response.status} ${response.statusText}`);
|
|
70
|
-
}
|
|
71
|
-
return await response.json();
|
|
64
|
+
logger.info(`Fetching QR code from: ${apiBaseUrl} bot_type=${botType}`);
|
|
65
|
+
const rawText = await apiGetFetch({
|
|
66
|
+
baseUrl: apiBaseUrl,
|
|
67
|
+
endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
|
|
68
|
+
timeoutMs: GET_QRCODE_TIMEOUT_MS,
|
|
69
|
+
label: "fetchQRCode",
|
|
70
|
+
});
|
|
71
|
+
return JSON.parse(rawText) as QRCodeResponse;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
async function pollQRStatus(apiBaseUrl: string, qrcode: string): Promise<StatusResponse> {
|
|
75
|
-
|
|
76
|
-
const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
|
|
77
|
-
logger.debug(`Long-poll QR status from: ${url.toString()}`);
|
|
78
|
-
|
|
79
|
-
const headers: Record<string, string> = {
|
|
80
|
-
"iLink-App-ClientVersion": "1",
|
|
81
|
-
};
|
|
82
|
-
const routeTag = loadConfigRouteTag();
|
|
83
|
-
if (routeTag) {
|
|
84
|
-
headers.SKRouteTag = routeTag;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const controller = new AbortController();
|
|
88
|
-
const timer = setTimeout(() => controller.abort(), QR_LONG_POLL_TIMEOUT_MS);
|
|
75
|
+
logger.debug(`Long-poll QR status from: ${apiBaseUrl} qrcode=***`);
|
|
89
76
|
try {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
77
|
+
const rawText = await apiGetFetch({
|
|
78
|
+
baseUrl: apiBaseUrl,
|
|
79
|
+
endpoint: `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`,
|
|
80
|
+
timeoutMs: QR_LONG_POLL_TIMEOUT_MS,
|
|
81
|
+
label: "pollQRStatus",
|
|
82
|
+
});
|
|
94
83
|
logger.debug(`pollQRStatus: body=${rawText.substring(0, 200)}`);
|
|
95
|
-
if (!response.ok) {
|
|
96
|
-
logger.error(`QR status poll failed: ${response.status} ${response.statusText} body=${rawText}`);
|
|
97
|
-
throw new Error(`Failed to poll QR status: ${response.status} ${response.statusText}`);
|
|
98
|
-
}
|
|
99
84
|
return JSON.parse(rawText) as StatusResponse;
|
|
100
85
|
} catch (err) {
|
|
101
|
-
clearTimeout(timer);
|
|
102
86
|
if (err instanceof Error && err.name === "AbortError") {
|
|
103
87
|
logger.debug(`pollQRStatus: client-side timeout after ${QR_LONG_POLL_TIMEOUT_MS}ms, returning wait`);
|
|
104
88
|
return { status: "wait" };
|
|
105
89
|
}
|
|
106
|
-
|
|
90
|
+
// 网关超时(如 Cloudflare 524)或其他网络错误,视为等待状态继续轮询
|
|
91
|
+
logger.warn(`pollQRStatus: network/gateway error, will retry: ${String(err)}`);
|
|
92
|
+
return { status: "wait" };
|
|
107
93
|
}
|
|
108
94
|
}
|
|
109
95
|
|
|
@@ -148,15 +134,7 @@ export async function startWeixinLoginWithQr(opts: {
|
|
|
148
134
|
const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;
|
|
149
135
|
logger.info(`Starting Weixin login with bot_type=${botType}`);
|
|
150
136
|
|
|
151
|
-
|
|
152
|
-
return {
|
|
153
|
-
message:
|
|
154
|
-
"No baseUrl configured. Add channels.openclaw-weixin.baseUrl to your config before logging in.",
|
|
155
|
-
sessionKey,
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const qrResponse = await fetchQRCode(opts.apiBaseUrl, botType);
|
|
137
|
+
const qrResponse = await fetchQRCode(FIXED_BASE_URL, botType);
|
|
160
138
|
logger.info(
|
|
161
139
|
`QR code received, qrcode=${redactToken(qrResponse.qrcode)} imgContentLen=${qrResponse.qrcode_img_content?.length ?? 0}`,
|
|
162
140
|
);
|
|
@@ -219,11 +197,15 @@ export async function waitForWeixinLogin(opts: {
|
|
|
219
197
|
let scannedPrinted = false;
|
|
220
198
|
let qrRefreshCount = 1;
|
|
221
199
|
|
|
200
|
+
// Initialize the effective polling base URL; may be updated on IDC redirect.
|
|
201
|
+
activeLogin.currentApiBaseUrl = FIXED_BASE_URL;
|
|
202
|
+
|
|
222
203
|
logger.info("Starting to poll QR code status...");
|
|
223
204
|
|
|
224
205
|
while (Date.now() < deadline) {
|
|
225
206
|
try {
|
|
226
|
-
const
|
|
207
|
+
const currentBaseUrl = activeLogin.currentApiBaseUrl ?? FIXED_BASE_URL;
|
|
208
|
+
const statusResponse = await pollQRStatus(currentBaseUrl, activeLogin.qrcode);
|
|
227
209
|
logger.debug(`pollQRStatus: status=${statusResponse.status} hasBotToken=${Boolean(statusResponse.bot_token)} hasBotId=${Boolean(statusResponse.ilink_bot_id)}`);
|
|
228
210
|
activeLogin.status = statusResponse.status;
|
|
229
211
|
|
|
@@ -259,7 +241,7 @@ export async function waitForWeixinLogin(opts: {
|
|
|
259
241
|
|
|
260
242
|
try {
|
|
261
243
|
const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;
|
|
262
|
-
const qrResponse = await fetchQRCode(
|
|
244
|
+
const qrResponse = await fetchQRCode(FIXED_BASE_URL, botType);
|
|
263
245
|
activeLogin.qrcode = qrResponse.qrcode;
|
|
264
246
|
activeLogin.qrcodeUrl = qrResponse.qrcode_img_content;
|
|
265
247
|
activeLogin.startedAt = Date.now();
|
|
@@ -274,7 +256,7 @@ export async function waitForWeixinLogin(opts: {
|
|
|
274
256
|
} catch {
|
|
275
257
|
process.stdout.write(`二维码未加载成功,请用浏览器打开以下链接扫码:\n`);
|
|
276
258
|
process.stdout.write(`${qrResponse.qrcode_img_content}\n`);
|
|
277
|
-
}
|
|
259
|
+
}
|
|
278
260
|
} catch (refreshErr) {
|
|
279
261
|
logger.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`);
|
|
280
262
|
activeLogins.delete(opts.sessionKey);
|
|
@@ -285,6 +267,17 @@ export async function waitForWeixinLogin(opts: {
|
|
|
285
267
|
}
|
|
286
268
|
break;
|
|
287
269
|
}
|
|
270
|
+
case "scaned_but_redirect": {
|
|
271
|
+
const redirectHost = statusResponse.redirect_host;
|
|
272
|
+
if (redirectHost) {
|
|
273
|
+
const newBaseUrl = `https://${redirectHost}`;
|
|
274
|
+
activeLogin.currentApiBaseUrl = newBaseUrl;
|
|
275
|
+
logger.info(`waitForWeixinLogin: IDC redirect, switching polling host to ${redirectHost}`);
|
|
276
|
+
} else {
|
|
277
|
+
logger.warn(`waitForWeixinLogin: received scaned_but_redirect but redirect_host is missing, continuing with current host`);
|
|
278
|
+
}
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
288
281
|
case "confirmed": {
|
|
289
282
|
if (!statusResponse.ilink_bot_id) {
|
|
290
283
|
activeLogins.delete(opts.sessionKey);
|
package/src/cdn/cdn-upload.ts
CHANGED
|
@@ -13,15 +13,25 @@ const UPLOAD_MAX_RETRIES = 3;
|
|
|
13
13
|
*/
|
|
14
14
|
export async function uploadBufferToCdn(params: {
|
|
15
15
|
buf: Buffer;
|
|
16
|
-
uploadParam
|
|
16
|
+
/** From getUploadUrl.upload_full_url; POST target when set (takes precedence over uploadParam). */
|
|
17
|
+
uploadFullUrl?: string;
|
|
18
|
+
uploadParam?: string;
|
|
17
19
|
filekey: string;
|
|
18
20
|
cdnBaseUrl: string;
|
|
19
21
|
label: string;
|
|
20
22
|
aeskey: Buffer;
|
|
21
23
|
}): Promise<{ downloadParam: string }> {
|
|
22
|
-
const { buf, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params;
|
|
24
|
+
const { buf, uploadFullUrl, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params;
|
|
23
25
|
const ciphertext = encryptAesEcb(buf, aeskey);
|
|
24
|
-
const
|
|
26
|
+
const trimmedFull = uploadFullUrl?.trim();
|
|
27
|
+
let cdnUrl: string;
|
|
28
|
+
if (trimmedFull) {
|
|
29
|
+
cdnUrl = trimmedFull;
|
|
30
|
+
} else if (uploadParam) {
|
|
31
|
+
cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey });
|
|
32
|
+
} else {
|
|
33
|
+
throw new Error(`${label}: CDN upload URL missing (need upload_full_url or upload_param)`);
|
|
34
|
+
}
|
|
25
35
|
logger.debug(`${label}: CDN POST url=${redactUrl(cdnUrl)} ciphertextSize=${ciphertext.length}`);
|
|
26
36
|
|
|
27
37
|
let downloadParam: string | undefined;
|
package/src/cdn/cdn-url.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
* Unified CDN URL construction for Weixin CDN upload/download.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
/** 设为 true 时,当服务端未返回 full_url 字段,回退到客户端拼接 URL;false 则直接报错。 */
|
|
6
|
+
export const ENABLE_CDN_URL_FALLBACK = true;
|
|
7
|
+
|
|
5
8
|
/** Build a CDN download URL from encrypt_query_param. */
|
|
6
9
|
export function buildCdnDownloadUrl(encryptedQueryParam: string, cdnBaseUrl: string): string {
|
|
7
10
|
return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;
|
package/src/cdn/pic-decrypt.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { decryptAesEcb } from "./aes-ecb.js";
|
|
2
|
-
import { buildCdnDownloadUrl } from "./cdn-url.js";
|
|
2
|
+
import { buildCdnDownloadUrl, ENABLE_CDN_URL_FALLBACK } from "./cdn-url.js";
|
|
3
3
|
import { logger } from "../util/logger.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -60,9 +60,17 @@ export async function downloadAndDecryptBuffer(
|
|
|
60
60
|
aesKeyBase64: string,
|
|
61
61
|
cdnBaseUrl: string,
|
|
62
62
|
label: string,
|
|
63
|
+
fullUrl?: string,
|
|
63
64
|
): Promise<Buffer> {
|
|
64
65
|
const key = parseAesKey(aesKeyBase64, label);
|
|
65
|
-
|
|
66
|
+
let url: string;
|
|
67
|
+
if (fullUrl) {
|
|
68
|
+
url = fullUrl;
|
|
69
|
+
} else if (ENABLE_CDN_URL_FALLBACK) {
|
|
70
|
+
url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
|
|
71
|
+
} else {
|
|
72
|
+
throw new Error(`${label}: fullUrl is required (CDN URL fallback is disabled)`);
|
|
73
|
+
}
|
|
66
74
|
logger.debug(`${label}: fetching url=${url}`);
|
|
67
75
|
const encrypted = await fetchCdnBytes(url, label);
|
|
68
76
|
logger.debug(`${label}: downloaded ${encrypted.byteLength} bytes, decrypting`);
|
|
@@ -78,8 +86,16 @@ export async function downloadPlainCdnBuffer(
|
|
|
78
86
|
encryptedQueryParam: string,
|
|
79
87
|
cdnBaseUrl: string,
|
|
80
88
|
label: string,
|
|
89
|
+
fullUrl?: string,
|
|
81
90
|
): Promise<Buffer> {
|
|
82
|
-
|
|
91
|
+
let url: string;
|
|
92
|
+
if (fullUrl) {
|
|
93
|
+
url = fullUrl;
|
|
94
|
+
} else if (ENABLE_CDN_URL_FALLBACK) {
|
|
95
|
+
url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
|
|
96
|
+
} else {
|
|
97
|
+
throw new Error(`${label}: fullUrl is required (CDN URL fallback is disabled)`);
|
|
98
|
+
}
|
|
83
99
|
logger.debug(`${label}: fetching url=${url}`);
|
|
84
100
|
return fetchCdnBytes(url, label);
|
|
85
101
|
}
|
package/src/cdn/upload.ts
CHANGED
|
@@ -82,17 +82,19 @@ async function uploadMediaToCdn(params: {
|
|
|
82
82
|
aeskey: aeskey.toString("hex"),
|
|
83
83
|
});
|
|
84
84
|
|
|
85
|
+
const uploadFullUrl = uploadUrlResp.upload_full_url?.trim();
|
|
85
86
|
const uploadParam = uploadUrlResp.upload_param;
|
|
86
|
-
if (!uploadParam) {
|
|
87
|
+
if (!uploadFullUrl && !uploadParam) {
|
|
87
88
|
logger.error(
|
|
88
|
-
`${label}: getUploadUrl returned no upload_param, resp=${JSON.stringify(uploadUrlResp)}`,
|
|
89
|
+
`${label}: getUploadUrl returned no upload URL (need upload_full_url or upload_param), resp=${JSON.stringify(uploadUrlResp)}`,
|
|
89
90
|
);
|
|
90
|
-
throw new Error(`${label}: getUploadUrl returned no
|
|
91
|
+
throw new Error(`${label}: getUploadUrl returned no upload URL`);
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
const { downloadParam: downloadEncryptedQueryParam } = await uploadBufferToCdn({
|
|
94
95
|
buf: plaintext,
|
|
95
|
-
|
|
96
|
+
uploadFullUrl: uploadFullUrl || undefined,
|
|
97
|
+
uploadParam: uploadParam ?? undefined,
|
|
96
98
|
filekey,
|
|
97
99
|
cdnBaseUrl,
|
|
98
100
|
aeskey,
|
package/src/channel.ts
CHANGED
|
@@ -168,6 +168,7 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
|
|
|
168
168
|
"When the user asks you to find an image from the web, use a web search or browser tool to find a suitable image URL, then send it using the message tool with 'media' set to that HTTPS image URL — do NOT download the image first.",
|
|
169
169
|
"IMPORTANT: When generating or saving a file to send, always use an absolute path (e.g. /tmp/photo.png), never a relative path like ./photo.png. Relative paths cannot be resolved and the file will not be delivered.",
|
|
170
170
|
"IMPORTANT: When creating a cron job (scheduled task) for the current Weixin user, you MUST set delivery.to to the user's Weixin ID (the xxx@im.wechat address from the current conversation) AND set delivery.accountId to the current AccountId. Without an explicit 'to', the cron delivery will fail with 'requires target'. Without an explicit 'accountId', the message may be sent from the wrong bot account. Example: delivery: { mode: 'announce', channel: 'openclaw-weixin', to: '<current_user_id@im.wechat>', accountId: '<current_AccountId>' }.",
|
|
171
|
+
"IMPORTANT: When outputting a MEDIA: directive to send a file, the MEDIA: tag MUST be on its own line — never inline with other text. Correct:\nSome text here\nMEDIA:/path/to/file.mp4\nIncorrect: Some text here MEDIA:/path/to/file.mp4",
|
|
171
172
|
],
|
|
172
173
|
},
|
|
173
174
|
reload: { configPrefixes: ["channels.openclaw-weixin"] },
|
package/src/compat.ts
CHANGED
|
@@ -73,7 +73,7 @@ export function assertHostCompatibility(hostVersion: string | undefined): void {
|
|
|
73
73
|
throw new Error(
|
|
74
74
|
`openclaw-weixin@${PLUGIN_VERSION} requires OpenClaw >=${SUPPORTED_HOST_MIN}, ` +
|
|
75
75
|
`but found ${hostVersion}. ` +
|
|
76
|
-
`Please upgrade OpenClaw, or install
|
|
77
|
-
`
|
|
76
|
+
`Please upgrade OpenClaw, or install the compatible track for older hosts:\n` +
|
|
77
|
+
` npx @tencent-weixin/openclaw-weixin-cli install`,
|
|
78
78
|
);
|
|
79
79
|
}
|
|
@@ -17,6 +17,6 @@ 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
|
-
/**
|
|
21
|
-
|
|
20
|
+
/** ISO 8601; bumped on each successful login to refresh gateway config from disk. */
|
|
21
|
+
channelConfigUpdatedAt: z.string().optional(),
|
|
22
22
|
});
|
|
@@ -39,25 +39,27 @@ export async function downloadMediaFromItem(
|
|
|
39
39
|
|
|
40
40
|
if (item.type === MessageItemType.IMAGE) {
|
|
41
41
|
const img = item.image_item;
|
|
42
|
-
if (!img?.media?.encrypt_query_param) return result;
|
|
42
|
+
if (!img?.media?.encrypt_query_param && !img?.media?.full_url) return result;
|
|
43
43
|
const aesKeyBase64 = img.aeskey
|
|
44
44
|
? Buffer.from(img.aeskey, "hex").toString("base64")
|
|
45
45
|
: img.media.aes_key;
|
|
46
46
|
logger.debug(
|
|
47
|
-
`${label} image: encrypt_query_param=${img.media.encrypt_query_param.slice(0, 40)}... hasAesKey=${Boolean(aesKeyBase64)} aeskeySource=${img.aeskey ? "image_item.aeskey" : "media.aes_key"}`,
|
|
47
|
+
`${label} image: encrypt_query_param=${(img.media.encrypt_query_param ?? "").slice(0, 40)}... hasAesKey=${Boolean(aesKeyBase64)} aeskeySource=${img.aeskey ? "image_item.aeskey" : "media.aes_key"} full_url=${Boolean(img.media.full_url)}`,
|
|
48
48
|
);
|
|
49
49
|
try {
|
|
50
50
|
const buf = aesKeyBase64
|
|
51
51
|
? await downloadAndDecryptBuffer(
|
|
52
|
-
img.media.encrypt_query_param,
|
|
52
|
+
img.media.encrypt_query_param ?? "",
|
|
53
53
|
aesKeyBase64,
|
|
54
54
|
cdnBaseUrl,
|
|
55
55
|
`${label} image`,
|
|
56
|
+
img.media.full_url,
|
|
56
57
|
)
|
|
57
58
|
: await downloadPlainCdnBuffer(
|
|
58
|
-
img.media.encrypt_query_param,
|
|
59
|
+
img.media.encrypt_query_param ?? "",
|
|
59
60
|
cdnBaseUrl,
|
|
60
61
|
`${label} image-plain`,
|
|
62
|
+
img.media.full_url,
|
|
61
63
|
);
|
|
62
64
|
const saved = await saveMedia(buf, undefined, "inbound", WEIXIN_MEDIA_MAX_BYTES);
|
|
63
65
|
result.decryptedPicPath = saved.path;
|
|
@@ -68,13 +70,15 @@ export async function downloadMediaFromItem(
|
|
|
68
70
|
}
|
|
69
71
|
} else if (item.type === MessageItemType.VOICE) {
|
|
70
72
|
const voice = item.voice_item;
|
|
71
|
-
if (!voice?.media?.encrypt_query_param || !voice
|
|
73
|
+
if ((!voice?.media?.encrypt_query_param && !voice?.media?.full_url) || !voice?.media?.aes_key)
|
|
74
|
+
return result;
|
|
72
75
|
try {
|
|
73
76
|
const silkBuf = await downloadAndDecryptBuffer(
|
|
74
|
-
voice.media.encrypt_query_param,
|
|
77
|
+
voice.media.encrypt_query_param ?? "",
|
|
75
78
|
voice.media.aes_key,
|
|
76
79
|
cdnBaseUrl,
|
|
77
80
|
`${label} voice`,
|
|
81
|
+
voice.media.full_url,
|
|
78
82
|
);
|
|
79
83
|
logger.debug(`${label} voice: decrypted ${silkBuf.length} bytes, attempting silk transcode`);
|
|
80
84
|
const wavBuf = await silkToWav(silkBuf);
|
|
@@ -95,13 +99,15 @@ export async function downloadMediaFromItem(
|
|
|
95
99
|
}
|
|
96
100
|
} else if (item.type === MessageItemType.FILE) {
|
|
97
101
|
const fileItem = item.file_item;
|
|
98
|
-
if (!fileItem?.media?.encrypt_query_param || !fileItem
|
|
102
|
+
if ((!fileItem?.media?.encrypt_query_param && !fileItem?.media?.full_url) || !fileItem?.media?.aes_key)
|
|
103
|
+
return result;
|
|
99
104
|
try {
|
|
100
105
|
const buf = await downloadAndDecryptBuffer(
|
|
101
|
-
fileItem.media.encrypt_query_param,
|
|
106
|
+
fileItem.media.encrypt_query_param ?? "",
|
|
102
107
|
fileItem.media.aes_key,
|
|
103
108
|
cdnBaseUrl,
|
|
104
109
|
`${label} file`,
|
|
110
|
+
fileItem.media.full_url,
|
|
105
111
|
);
|
|
106
112
|
const mime = getMimeFromFilename(fileItem.file_name ?? "file.bin");
|
|
107
113
|
const saved = await saveMedia(
|
|
@@ -120,13 +126,15 @@ export async function downloadMediaFromItem(
|
|
|
120
126
|
}
|
|
121
127
|
} else if (item.type === MessageItemType.VIDEO) {
|
|
122
128
|
const videoItem = item.video_item;
|
|
123
|
-
if (!videoItem?.media?.encrypt_query_param || !videoItem
|
|
129
|
+
if ((!videoItem?.media?.encrypt_query_param && !videoItem?.media?.full_url) || !videoItem?.media?.aes_key)
|
|
130
|
+
return result;
|
|
124
131
|
try {
|
|
125
132
|
const buf = await downloadAndDecryptBuffer(
|
|
126
|
-
videoItem.media.encrypt_query_param,
|
|
133
|
+
videoItem.media.encrypt_query_param ?? "",
|
|
127
134
|
videoItem.media.aes_key,
|
|
128
135
|
cdnBaseUrl,
|
|
129
136
|
`${label} video`,
|
|
137
|
+
videoItem.media.full_url,
|
|
130
138
|
);
|
|
131
139
|
const saved = await saveMedia(buf, "video/mp4", "inbound", WEIXIN_MEDIA_MAX_BYTES);
|
|
132
140
|
result.decryptedVideoPath = saved.path;
|
|
@@ -109,21 +109,23 @@ export async function processOneMessage(
|
|
|
109
109
|
|
|
110
110
|
// Find the first downloadable media item (priority: IMAGE > VIDEO > FILE > VOICE).
|
|
111
111
|
// When none found in the main item_list, fall back to media referenced via a quoted message.
|
|
112
|
+
const hasDownloadableMedia = (m?: { encrypt_query_param?: string; full_url?: string }) =>
|
|
113
|
+
m?.encrypt_query_param || m?.full_url;
|
|
112
114
|
const mainMediaItem =
|
|
113
115
|
full.item_list?.find(
|
|
114
|
-
(i) => i.type === MessageItemType.IMAGE && i.image_item?.media
|
|
116
|
+
(i) => i.type === MessageItemType.IMAGE && hasDownloadableMedia(i.image_item?.media),
|
|
115
117
|
) ??
|
|
116
118
|
full.item_list?.find(
|
|
117
|
-
(i) => i.type === MessageItemType.VIDEO && i.video_item?.media
|
|
119
|
+
(i) => i.type === MessageItemType.VIDEO && hasDownloadableMedia(i.video_item?.media),
|
|
118
120
|
) ??
|
|
119
121
|
full.item_list?.find(
|
|
120
|
-
(i) => i.type === MessageItemType.FILE && i.file_item?.media
|
|
122
|
+
(i) => i.type === MessageItemType.FILE && hasDownloadableMedia(i.file_item?.media),
|
|
121
123
|
) ??
|
|
122
124
|
full.item_list?.find(
|
|
123
125
|
(i) =>
|
|
124
126
|
i.type === MessageItemType.VOICE &&
|
|
125
|
-
i.voice_item?.media
|
|
126
|
-
!i.voice_item
|
|
127
|
+
hasDownloadableMedia(i.voice_item?.media) &&
|
|
128
|
+
!i.voice_item?.text,
|
|
127
129
|
);
|
|
128
130
|
const refMediaItem = !mainMediaItem
|
|
129
131
|
? full.item_list?.find(
|
|
@@ -425,7 +427,7 @@ export async function processOneMessage(
|
|
|
425
427
|
markDispatchIdle();
|
|
426
428
|
|
|
427
429
|
logger.info(
|
|
428
|
-
`debug-check: accountId=${deps.accountId} debug=${String(debug)} hasContextToken=${Boolean(contextToken)}
|
|
430
|
+
`debug-check: accountId=${deps.accountId} debug=${String(debug)} hasContextToken=${Boolean(contextToken)}`,
|
|
429
431
|
);
|
|
430
432
|
|
|
431
433
|
if (debug && contextToken) {
|
package/src/log-upload.ts
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
|
|
4
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
5
|
-
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
/** Minimal subset of commander's Command used by registerWeixinCli. */
|
|
9
|
-
type CliCommand = {
|
|
10
|
-
command(name: string): CliCommand;
|
|
11
|
-
description(str: string): CliCommand;
|
|
12
|
-
option(flags: string, description: string): CliCommand;
|
|
13
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
-
action(fn: (...args: any[]) => void | Promise<void>): CliCommand;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
function currentDayLogFileName(): string {
|
|
18
|
-
const now = new Date();
|
|
19
|
-
const offsetMs = -now.getTimezoneOffset() * 60_000;
|
|
20
|
-
const dateKey = new Date(now.getTime() + offsetMs).toISOString().slice(0, 10);
|
|
21
|
-
return `openclaw-${dateKey}.log`;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Parse --file argument: accepts a short 8-digit date (YYYYMMDD)
|
|
26
|
-
* like "20260316", a full filename like "openclaw-2026-03-16.log",
|
|
27
|
-
* or a legacy 10-digit hour timestamp "2026031614".
|
|
28
|
-
*/
|
|
29
|
-
function resolveLogFileName(file: string): string {
|
|
30
|
-
if (/^\d{8}$/.test(file)) {
|
|
31
|
-
const yyyy = file.slice(0, 4);
|
|
32
|
-
const mm = file.slice(4, 6);
|
|
33
|
-
const dd = file.slice(6, 8);
|
|
34
|
-
return `openclaw-${yyyy}-${mm}-${dd}.log`;
|
|
35
|
-
}
|
|
36
|
-
if (/^\d{10}$/.test(file)) {
|
|
37
|
-
const yyyy = file.slice(0, 4);
|
|
38
|
-
const mm = file.slice(4, 6);
|
|
39
|
-
const dd = file.slice(6, 8);
|
|
40
|
-
return `openclaw-${yyyy}-${mm}-${dd}.log`;
|
|
41
|
-
}
|
|
42
|
-
return file;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function mainLogDir(): string {
|
|
46
|
-
return resolvePreferredOpenClawTmpDir();
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function getConfiguredUploadUrl(config: OpenClawConfig): string | undefined {
|
|
50
|
-
const section = config.channels?.["openclaw-weixin"] as { logUploadUrl?: string } | undefined;
|
|
51
|
-
return section?.logUploadUrl;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Register the `openclaw openclaw-weixin` CLI subcommands. */
|
|
55
|
-
export function registerWeixinCli(params: { program: CliCommand; config: OpenClawConfig }): void {
|
|
56
|
-
const { program, config } = params;
|
|
57
|
-
|
|
58
|
-
const root = program.command("openclaw-weixin").description("Weixin channel utilities");
|
|
59
|
-
|
|
60
|
-
root
|
|
61
|
-
.command("uninstall")
|
|
62
|
-
.description("Uninstall the Weixin plugin (cleans up channel config automatically)")
|
|
63
|
-
.action(async () => {
|
|
64
|
-
// 1. Remove channels.openclaw-weixin from config
|
|
65
|
-
const { loadConfig, writeConfigFile } = await import("openclaw/plugin-sdk/config-runtime");
|
|
66
|
-
const cfg = loadConfig();
|
|
67
|
-
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
|
68
|
-
if (channels["openclaw-weixin"]) {
|
|
69
|
-
delete channels["openclaw-weixin"];
|
|
70
|
-
await writeConfigFile({ ...cfg, channels });
|
|
71
|
-
console.log("[weixin] Cleaned up channel config.");
|
|
72
|
-
}
|
|
73
|
-
// 2. Run the actual uninstall
|
|
74
|
-
const { execSync } = await import("node:child_process");
|
|
75
|
-
try {
|
|
76
|
-
execSync("openclaw plugins uninstall openclaw-weixin", { stdio: "inherit" });
|
|
77
|
-
} catch {
|
|
78
|
-
// uninstall command handles its own error output
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
root
|
|
83
|
-
.command("logs-upload")
|
|
84
|
-
.description("Upload a Weixin log file to a remote URL via HTTP POST")
|
|
85
|
-
.option("--url <url>", "Remote URL to POST the log file to (overrides config)")
|
|
86
|
-
.option(
|
|
87
|
-
"--file <file>",
|
|
88
|
-
"Log file to upload: full filename or 8-digit date YYYYMMDD (default: today)",
|
|
89
|
-
)
|
|
90
|
-
.action(async (options: { url?: string; file?: string }) => {
|
|
91
|
-
const uploadUrl = options.url ?? getConfiguredUploadUrl(config);
|
|
92
|
-
if (!uploadUrl) {
|
|
93
|
-
console.error(
|
|
94
|
-
`[weixin] No upload URL specified. Pass --url or set it with:\n openclaw config set channels.openclaw-weixin.logUploadUrl <url>`,
|
|
95
|
-
);
|
|
96
|
-
process.exit(1);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const logDir = mainLogDir();
|
|
100
|
-
const rawFile = options.file ?? currentDayLogFileName();
|
|
101
|
-
const fileName = resolveLogFileName(rawFile);
|
|
102
|
-
const filePath = path.isAbsolute(fileName) ? fileName : path.join(logDir, fileName);
|
|
103
|
-
|
|
104
|
-
let content: Buffer;
|
|
105
|
-
try {
|
|
106
|
-
content = await fs.readFile(filePath);
|
|
107
|
-
} catch (err) {
|
|
108
|
-
console.error(`[weixin] Failed to read log file: ${filePath}\n ${String(err)}`);
|
|
109
|
-
process.exit(1);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
console.log(`[weixin] Uploading ${filePath} (${content.length} bytes) to ${uploadUrl} ...`);
|
|
113
|
-
|
|
114
|
-
const formData = new FormData();
|
|
115
|
-
formData.append("file", new Blob([new Uint8Array(content)], { type: "text/plain" }), fileName);
|
|
116
|
-
|
|
117
|
-
let res: Response;
|
|
118
|
-
try {
|
|
119
|
-
res = await fetch(uploadUrl, { method: "POST", body: formData });
|
|
120
|
-
} catch (err) {
|
|
121
|
-
console.error(`[weixin] Upload request failed: ${String(err)}`);
|
|
122
|
-
process.exit(1);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const responseBody = await res.text().catch(() => "");
|
|
126
|
-
if (!res.ok) {
|
|
127
|
-
console.error(
|
|
128
|
-
`[weixin] Upload failed: HTTP ${res.status} ${res.statusText}\n ${responseBody}`,
|
|
129
|
-
);
|
|
130
|
-
process.exit(1);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
console.log(`[weixin] Upload succeeded (HTTP ${res.status})`);
|
|
134
|
-
const fileid = res.headers.get("fileid");
|
|
135
|
-
if (fileid) {
|
|
136
|
-
console.log(`fileid: ${fileid}`);
|
|
137
|
-
} else {
|
|
138
|
-
// fileid not found; dump all headers for diagnosis
|
|
139
|
-
const headers: Record<string, string> = {};
|
|
140
|
-
res.headers.forEach((value, key) => {
|
|
141
|
-
headers[key] = value;
|
|
142
|
-
});
|
|
143
|
-
console.log("headers:", JSON.stringify(headers, null, 2));
|
|
144
|
-
}
|
|
145
|
-
if (responseBody) {
|
|
146
|
-
console.log("body:", responseBody);
|
|
147
|
-
}
|
|
148
|
-
});
|
|
149
|
-
}
|