@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 CHANGED
@@ -285,7 +285,7 @@ All media types (image/voice/file/video) are transferred via CDN using AES-128-E
285
285
  ## Uninstall
286
286
 
287
287
  ```bash
288
- openclaw openclaw-weixin uninstall
288
+ openclaw plugins uninstall @tencent-weixin/openclaw-weixin
289
289
  ```
290
290
 
291
291
  ## Troubleshooting
package/README.zh_CN.md CHANGED
@@ -284,7 +284,7 @@ openclaw config set agents.mode per-channel-per-peer
284
284
  ## 卸载
285
285
 
286
286
  ```bash
287
- openclaw openclaw-weixin uninstall
287
+ openclaw plugins uninstall @tencent-weixin/openclaw-weixin
288
288
  ```
289
289
 
290
290
  ## 故障排查
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
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "openclaw-weixin",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "channels": ["openclaw-weixin"],
5
5
  "configSchema": {
6
6
  "type": "object",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-weixin/openclaw-weixin",
3
- "version": "2.0.1",
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
- function readChannelVersion(): string {
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
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { version?: string };
38
- return pkg.version ?? "unknown";
43
+ return JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as PackageJson;
39
44
  } catch {
40
- return "unknown";
45
+ return {};
41
46
  }
42
47
  }
43
48
 
44
- const CHANNEL_VERSION = readChannelVersion();
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 apiFetch(params: {
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 apiFetch({
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 apiFetch({
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 apiFetch({
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 apiFetch({
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 apiFetch({
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 {
@@ -291,28 +291,27 @@ export function loadConfigRouteTag(accountId?: string): string | undefined {
291
291
  }
292
292
 
293
293
  /**
294
- * Ensure the openclaw-weixin channel section exists in openclaw.json so the gateway
295
- * recognises it as a configured channel at startup, then trigger a config reload.
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
- if (!channels["openclaw-weixin"] || Object.keys(channels["openclaw-weixin"] as Record<string, unknown>).every((k) => k === "enabled")) {
303
- const updated: OpenClawConfig = {
304
- ...cfg,
305
- channels: {
306
- ...channels,
307
- "openclaw-weixin": {
308
- ...(channels["openclaw-weixin"] as Record<string, unknown> ?? {}),
309
- accounts: {},
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
- await writeConfigFile(updated);
314
- logger.info("triggerWeixinChannelReload: wrote channel config to openclaw.json");
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). */
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
- import { loadConfigRouteTag } from "./accounts.js";
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
- const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
56
- const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
57
- logger.info(`Fetching QR code from: ${url.toString()}`);
58
-
59
- const headers: Record<string, string> = {};
60
- const routeTag = loadConfigRouteTag();
61
- if (routeTag) {
62
- headers.SKRouteTag = routeTag;
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
- const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
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 response = await fetch(url.toString(), { headers, signal: controller.signal });
91
- clearTimeout(timer);
92
- logger.debug(`pollQRStatus: HTTP ${response.status}, reading body...`);
93
- const rawText = await response.text();
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
- throw err;
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
- if (!opts.apiBaseUrl) {
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 statusResponse = await pollQRStatus(opts.apiBaseUrl, activeLogin.qrcode);
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(opts.apiBaseUrl, botType);
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);
@@ -13,15 +13,25 @@ const UPLOAD_MAX_RETRIES = 3;
13
13
  */
14
14
  export async function uploadBufferToCdn(params: {
15
15
  buf: Buffer;
16
- uploadParam: string;
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 cdnUrl = buildCdnUploadUrl({ cdnBaseUrl, uploadParam, filekey });
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;
@@ -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)}`;
@@ -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
- const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
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
- const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
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 upload_param`);
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
- uploadParam,
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 openclaw-weixin@1.x (legacy) for older hosts:\n` +
77
- ` openclaw plugins install @tencent-weixin/openclaw-weixin@legacy`,
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
- /** Default URL for `openclaw openclaw-weixin logs-upload`. Set via `openclaw config set channels.openclaw-weixin.logUploadUrl <url>`. */
21
- logUploadUrl: z.string().optional(),
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.media.aes_key) return result;
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.media.aes_key) return result;
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.media.aes_key) return result;
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?.encrypt_query_param,
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?.encrypt_query_param,
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?.encrypt_query_param,
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?.encrypt_query_param &&
126
- !i.voice_item.text,
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)} stateDir=${process.env.OPENCLAW_STATE_DIR ?? "(unset)"}`,
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
- }