@tencent-weixin/openclaw-weixin 2.1.10 → 2.3.1

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
@@ -72,6 +72,45 @@ By default, DMs can share one session bucket. For **multiple logged-in WeChat ac
72
72
  openclaw config set session.dmScope per-account-channel-peer
73
73
  ```
74
74
 
75
+ ## Custom BotAgent (optional)
76
+
77
+ Every outbound request to the WeChat backend carries a self-declared `bot_agent`
78
+ identifier — analogous to an HTTP `User-Agent` — used for log attribution and
79
+ monitoring aggregation. The default is `OpenClaw`. Declaring your own app name
80
+ makes it much easier to trace your traffic in backend logs.
81
+
82
+ Add one line to `openclaw.json`:
83
+
84
+ ```json
85
+ {
86
+ "channels": {
87
+ "openclaw-weixin": {
88
+ "botAgent": "MyBot/1.2.0"
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ **Format** (UA-style):
95
+
96
+ - One or more `Name/Version` tokens, space-separated
97
+ - Each token may optionally be followed by ` (comment)`
98
+ - ASCII only; total length ≤ 256 bytes
99
+ - Invalid tokens are silently dropped during sanitization; falls back to
100
+ `OpenClaw` if nothing valid remains
101
+
102
+ Examples that pass through unchanged:
103
+
104
+ - `MyBot/1.2.0`
105
+ - `MyBot/1.2.0 (region=cn;env=prod)`
106
+ - `MyBot/1.2.0 LangChain/0.3.5`
107
+ - `MyBot/1.2.0-rc.1+build.5`
108
+
109
+ **Note**: `bot_agent` is for observability only — it is not used for
110
+ authentication or routing. All registered agents on this plugin instance
111
+ currently share the same `botAgent` declaration; per-agent overrides may be
112
+ added in a future version if needed.
113
+
75
114
  ## Backend API Protocol
76
115
 
77
116
  This plugin communicates with the backend gateway via HTTP JSON API. Developers integrating with their own backend need to implement the following interfaces.
package/README.zh_CN.md CHANGED
@@ -71,6 +71,42 @@ openclaw channels login --channel openclaw-weixin
71
71
  openclaw config set session.dmScope per-account-channel-peer
72
72
  ```
73
73
 
74
+ ## 自定义 BotAgent(可选)
75
+
76
+ 每条出站请求会带一个自我声明的 `bot_agent` 字段——类似 HTTP `User-Agent`——用于
77
+ 后台日志归因和监控聚合。**默认值为 `OpenClaw`**。声明自己的应用名能让你的流量
78
+ 在后台日志中更容易识别。
79
+
80
+ 在 `openclaw.json` 中加一行即可:
81
+
82
+ ```json
83
+ {
84
+ "channels": {
85
+ "openclaw-weixin": {
86
+ "botAgent": "MyBot/1.2.0"
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ **格式规范**(UA 风格):
93
+
94
+ - 一个或多个 `Name/Version` token,空格分隔
95
+ - 每个 token 可选地跟一个 ` (comment)`
96
+ - 仅允许 ASCII 字符;总长 ≤ 256 字节
97
+ - 不合规的 token 在清洗时静默丢弃;如果最终为空,回退到 `OpenClaw`
98
+
99
+ 可直接使用的示例:
100
+
101
+ - `MyBot/1.2.0`
102
+ - `MyBot/1.2.0 (region=cn;env=prod)`
103
+ - `MyBot/1.2.0 LangChain/0.3.5`
104
+ - `MyBot/1.2.0-rc.1+build.5`
105
+
106
+ **注意**:`bot_agent` 仅用于观测,**不参与鉴权或路由**。当前本插件实例下所有
107
+ 已注册的 agent 共享同一个 `botAgent` 声明;如有需要按 agent 单独标识的场景,
108
+ 可在后续版本扩展配置。
109
+
74
110
  ## 后端 API 协议
75
111
 
76
112
  本插件通过 HTTP JSON API 与后端网关通信。二次开发者若需对接自有后端,需实现以下接口。
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "openclaw-weixin",
3
- "version": "2.1.10",
3
+ "version": "2.3.1",
4
4
  "channels": [
5
5
  "openclaw-weixin"
6
6
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-weixin/openclaw-weixin",
3
- "version": "2.1.10",
3
+ "version": "2.3.1",
4
4
  "description": "OpenClaw Weixin channel",
5
5
  "license": "MIT",
6
6
  "author": "Tencent",
package/src/api/api.ts CHANGED
@@ -3,7 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
- import { loadConfigRouteTag } from "../auth/accounts.js";
6
+ import { loadConfigBotAgent, loadConfigRouteTag } from "../auth/accounts.js";
7
7
  import { logger } from "../util/logger.js";
8
8
  import { redactBody, redactUrl } from "../util/redact.js";
9
9
 
@@ -70,9 +70,105 @@ function buildClientVersion(version: string): number {
70
70
 
71
71
  const ILINK_APP_CLIENT_VERSION: number = buildClientVersion(pkg.version ?? "0.0.0");
72
72
 
73
+ /**
74
+ * Default `bot_agent` value used when the upstream app does not declare one.
75
+ * Mirrors the role of HTTP `User-Agent`'s implicit "no UA" fallback.
76
+ */
77
+ const DEFAULT_BOT_AGENT = "OpenClaw";
78
+
79
+ /** Maximum length (bytes) of the sanitized `bot_agent` string. */
80
+ const BOT_AGENT_MAX_LEN = 256;
81
+
82
+ /**
83
+ * Sanitize a user-supplied `botAgent` config value into a wire-safe string.
84
+ *
85
+ * Grammar (UA-style):
86
+ * bot_agent = product *( SP product )
87
+ * product = name "/" version [ SP "(" comment ")" ]
88
+ * name = 1*32( ALPHA / DIGIT / "_" / "." / "-" )
89
+ * version = 1*32( ALPHA / DIGIT / "_" / "." / "+" / "-" )
90
+ * comment = 1*64( printable ASCII minus "(" ")" )
91
+ *
92
+ * Tokens that fail to parse are dropped silently (no partial tokens kept).
93
+ * Returns `DEFAULT_BOT_AGENT` when the input is empty / all tokens dropped /
94
+ * the result exceeds the length cap after truncation.
95
+ */
96
+ export function sanitizeBotAgent(raw: string | undefined): string {
97
+ if (!raw || typeof raw !== "string") return DEFAULT_BOT_AGENT;
98
+ const trimmed = raw.trim();
99
+ if (!trimmed) return DEFAULT_BOT_AGENT;
100
+
101
+ const productRe = /^[A-Za-z0-9_.\-]{1,32}\/[A-Za-z0-9_.+\-]{1,32}$/;
102
+ const commentCharRe = /^[\x20-\x27\x2A-\x7E]{1,64}$/;
103
+
104
+ // Tokenize on whitespace, but keep `(comment)` glued to the preceding product.
105
+ // Strategy: split by spaces, then re-attach any token that starts with "(".
106
+ const rawTokens = trimmed.split(/\s+/);
107
+ const tokens: string[] = [];
108
+ for (let i = 0; i < rawTokens.length; i += 1) {
109
+ const tok = rawTokens[i];
110
+ if (tok.startsWith("(") && !tok.endsWith(")")) {
111
+ // Multi-word comment; greedily collect until we find the closing ")".
112
+ let acc = tok;
113
+ while (i + 1 < rawTokens.length && !acc.endsWith(")")) {
114
+ i += 1;
115
+ acc += " " + rawTokens[i];
116
+ }
117
+ tokens.push(acc);
118
+ } else {
119
+ tokens.push(tok);
120
+ }
121
+ }
122
+
123
+ const accepted: string[] = [];
124
+ let pendingProduct: string | null = null;
125
+ for (const tok of tokens) {
126
+ if (tok.startsWith("(") && tok.endsWith(")")) {
127
+ const inner = tok.slice(1, -1);
128
+ if (pendingProduct && commentCharRe.test(inner)) {
129
+ accepted.push(`${pendingProduct} (${inner})`);
130
+ pendingProduct = null;
131
+ } else {
132
+ if (pendingProduct) {
133
+ accepted.push(pendingProduct);
134
+ pendingProduct = null;
135
+ }
136
+ }
137
+ continue;
138
+ }
139
+ if (pendingProduct) {
140
+ accepted.push(pendingProduct);
141
+ pendingProduct = null;
142
+ }
143
+ if (productRe.test(tok)) {
144
+ pendingProduct = tok;
145
+ }
146
+ }
147
+ if (pendingProduct) accepted.push(pendingProduct);
148
+
149
+ if (accepted.length === 0) return DEFAULT_BOT_AGENT;
150
+
151
+ const joined = accepted.join(" ");
152
+ if (Buffer.byteLength(joined, "utf-8") <= BOT_AGENT_MAX_LEN) return joined;
153
+
154
+ // Truncate by dropping trailing tokens until under the cap.
155
+ const truncated: string[] = [];
156
+ let len = 0;
157
+ for (const t of accepted) {
158
+ const add = (truncated.length === 0 ? 0 : 1) + Buffer.byteLength(t, "utf-8");
159
+ if (len + add > BOT_AGENT_MAX_LEN) break;
160
+ truncated.push(t);
161
+ len += add;
162
+ }
163
+ return truncated.length > 0 ? truncated.join(" ") : DEFAULT_BOT_AGENT;
164
+ }
165
+
73
166
  /** Build the `base_info` payload included in every API request. */
74
167
  export function buildBaseInfo(): BaseInfo {
75
- return { channel_version: CHANNEL_VERSION };
168
+ return {
169
+ channel_version: CHANNEL_VERSION,
170
+ bot_agent: sanitizeBotAgent(loadConfigBotAgent()),
171
+ };
76
172
  }
77
173
 
78
174
  /** Default timeout for long-poll getUpdates requests. */
@@ -166,15 +262,17 @@ export async function apiGetFetch(params: {
166
262
  }
167
263
 
168
264
  /**
169
- * Common fetch wrapper: POST JSON to a Weixin API endpoint with timeout + abort.
265
+ * Common fetch wrapper: POST JSON to a Weixin API endpoint.
266
+ * When `timeoutMs` is provided, the request is aborted after that many milliseconds.
267
+ * When omitted, no client-side timeout is applied (relies on OS/TCP stack).
170
268
  * Returns the raw response text on success; throws on HTTP error or timeout.
171
269
  */
172
- async function apiPostFetch(params: {
270
+ export async function apiPostFetch(params: {
173
271
  baseUrl: string;
174
272
  endpoint: string;
175
273
  body: string;
176
274
  token?: string;
177
- timeoutMs: number;
275
+ timeoutMs?: number;
178
276
  label: string;
179
277
  }): Promise<string> {
180
278
  const base = ensureTrailingSlash(params.baseUrl);
@@ -182,16 +280,20 @@ async function apiPostFetch(params: {
182
280
  const hdrs = buildHeaders({ token: params.token, body: params.body });
183
281
  logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);
184
282
 
185
- const controller = new AbortController();
186
- const t = setTimeout(() => controller.abort(), params.timeoutMs);
283
+ const controller =
284
+ params.timeoutMs !== undefined ? new AbortController() : undefined;
285
+ const t =
286
+ controller != null && params.timeoutMs !== undefined
287
+ ? setTimeout(() => controller.abort(), params.timeoutMs)
288
+ : undefined;
187
289
  try {
188
290
  const res = await fetch(url.toString(), {
189
291
  method: "POST",
190
292
  headers: hdrs,
191
293
  body: params.body,
192
- signal: controller.signal,
294
+ ...(controller ? { signal: controller.signal } : {}),
193
295
  });
194
- clearTimeout(t);
296
+ if (t !== undefined) clearTimeout(t);
195
297
  const rawText = await res.text();
196
298
  logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
197
299
  if (!res.ok) {
@@ -199,7 +301,7 @@ async function apiPostFetch(params: {
199
301
  }
200
302
  return rawText;
201
303
  } catch (err) {
202
- clearTimeout(t);
304
+ if (t !== undefined) clearTimeout(t);
203
305
  throw err;
204
306
  }
205
307
  }
package/src/api/types.ts CHANGED
@@ -6,6 +6,19 @@
6
6
  /** Common request metadata attached to every CGI request. */
7
7
  export interface BaseInfo {
8
8
  channel_version?: string;
9
+ /**
10
+ * Self-declared identity of the upstream bot/app, analogous to HTTP
11
+ * `User-Agent`. Filled from `channels.openclaw-weixin.botAgent` in
12
+ * openclaw.json; defaults to `"OpenClaw"` when unset.
13
+ *
14
+ * Format: UA-style `Name/Version` tokens, optionally followed by
15
+ * `(comment)`, multiple tokens space-separated. ASCII only, total
16
+ * length <= 256 bytes after sanitization.
17
+ *
18
+ * For observability only (logging, monitoring aggregation); not used
19
+ * for authentication or routing.
20
+ */
21
+ bot_agent?: string;
9
22
  }
10
23
 
11
24
  /** proto: UploadMediaType */
@@ -290,6 +290,18 @@ export function loadConfigRouteTag(accountId?: string): string | undefined {
290
290
  : undefined;
291
291
  }
292
292
 
293
+ /**
294
+ * Read `botAgent` from `channels.openclaw-weixin.botAgent` in openclaw.json.
295
+ * Returns the raw configured string (caller is responsible for sanitization)
296
+ * or undefined when not set. Reuses the cached channel section.
297
+ */
298
+ export function loadConfigBotAgent(): string | undefined {
299
+ const section = loadRouteTagSection();
300
+ if (!section) return undefined;
301
+ const value = section.botAgent;
302
+ return typeof value === "string" && value.trim() ? value : undefined;
303
+ }
304
+
293
305
  /**
294
306
  * Bump `channels.openclaw-weixin.channelConfigUpdatedAt` in openclaw.json on each successful login
295
307
  * so the gateway reloads config from disk (no empty `accounts: {}` placeholder).
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
- import { apiGetFetch } from "../api/api.js";
3
+ import { apiGetFetch, apiPostFetch } from "../api/api.js";
4
+ import { listIndexedWeixinAccountIds, loadWeixinAccount } from "./accounts.js";
4
5
  import { logger } from "../util/logger.js";
5
6
  import { redactToken } from "../util/redact.js";
6
7
 
@@ -11,10 +12,12 @@ type ActiveLogin = {
11
12
  qrcodeUrl: string;
12
13
  startedAt: number;
13
14
  botToken?: string;
14
- status?: "wait" | "scaned" | "confirmed" | "expired" | "scaned_but_redirect";
15
+ status?: "wait" | "scaned" | "confirmed" | "expired" | "scaned_but_redirect" | "need_verifycode" | "verify_code_blocked" | "binded_redirect";
15
16
  error?: string;
16
17
  /** The current effective polling base URL; may be updated on IDC redirect. */
17
18
  currentApiBaseUrl?: string;
19
+ /** 待提交的配对码,用户输入后暂存,下次轮询时携带 */
20
+ pendingVerifyCode?: string;
18
21
  };
19
22
 
20
23
  const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;
@@ -35,7 +38,7 @@ interface QRCodeResponse {
35
38
  }
36
39
 
37
40
  interface StatusResponse {
38
- status: "wait" | "scaned" | "confirmed" | "expired" | "scaned_but_redirect";
41
+ status: "wait" | "scaned" | "confirmed" | "expired" | "scaned_but_redirect" | "need_verifycode" | "verify_code_blocked" | "binded_redirect";
39
42
  bot_token?: string;
40
43
  ilink_bot_id?: string;
41
44
  baseurl?: string;
@@ -58,22 +61,64 @@ function purgeExpiredLogins(): void {
58
61
  }
59
62
  }
60
63
 
64
+ /** 获取本地已登录账号的 bot token 列表,最多返回最新的 10 个。 */
65
+ function getLocalBotTokenList(): string[] {
66
+ const accountIds = listIndexedWeixinAccountIds();
67
+ const tokens: string[] = [];
68
+ // 从最新注册的账号开始取(列表末尾为最新)
69
+ for (let i = accountIds.length - 1; i >= 0 && tokens.length < 10; i--) {
70
+ const data = loadWeixinAccount(accountIds[i]);
71
+ const token = data?.token?.trim();
72
+ if (token) {
73
+ tokens.push(token);
74
+ }
75
+ }
76
+ return tokens;
77
+ }
78
+
61
79
  async function fetchQRCode(apiBaseUrl: string, botType: string): Promise<QRCodeResponse> {
62
- logger.info(`Fetching QR code from: ${apiBaseUrl} bot_type=${botType}`);
63
- const rawText = await apiGetFetch({
80
+ logger.info(`NewFetching QR code from: ${apiBaseUrl} bot_type=${botType}`);
81
+ const localTokenList = getLocalBotTokenList();
82
+ logger.info(`newfetchQRCode: local_token_list count=${localTokenList.length}`);
83
+ const rawText = await apiPostFetch({
64
84
  baseUrl: apiBaseUrl,
65
85
  endpoint: `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`,
86
+ body: JSON.stringify({ local_token_list: localTokenList }),
66
87
  label: "fetchQRCode",
67
88
  });
68
89
  return JSON.parse(rawText) as QRCodeResponse;
69
90
  }
70
91
 
71
- async function pollQRStatus(apiBaseUrl: string, qrcode: string): Promise<StatusResponse> {
92
+ /** stdin 读取一行用户输入,输出提示语后等待回车确认,返回 trim 后的字符串。 */
93
+ async function readVerifyCodeFromStdin(prompt: string): Promise<string> {
94
+ process.stdout.write(prompt);
95
+ return new Promise((resolve) => {
96
+ let input = "";
97
+ const onData = (chunk: Buffer | string) => {
98
+ const str = chunk.toString();
99
+ input += str;
100
+ if (input.includes("\n")) {
101
+ process.stdin.removeListener("data", onData);
102
+ process.stdin.pause();
103
+ resolve(input.trim());
104
+ }
105
+ };
106
+ process.stdin.resume();
107
+ process.stdin.setEncoding("utf-8");
108
+ process.stdin.on("data", onData);
109
+ });
110
+ }
111
+
112
+ async function pollQRStatus(apiBaseUrl: string, qrcode: string, verifyCode?: string): Promise<StatusResponse> {
72
113
  logger.debug(`Long-poll QR status from: ${apiBaseUrl} qrcode=***`);
73
114
  try {
115
+ let endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`;
116
+ if (verifyCode) {
117
+ endpoint += `&verify_code=${encodeURIComponent(verifyCode)}`;
118
+ }
74
119
  const rawText = await apiGetFetch({
75
120
  baseUrl: apiBaseUrl,
76
- endpoint: `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`,
121
+ endpoint,
77
122
  timeoutMs: QR_LONG_POLL_TIMEOUT_MS,
78
123
  label: "pollQRStatus",
79
124
  });
@@ -90,6 +135,22 @@ async function pollQRStatus(apiBaseUrl: string, qrcode: string): Promise<StatusR
90
135
  }
91
136
  }
92
137
 
138
+ /**
139
+ * 在终端展示二维码及备用链接。
140
+ * 供 CLI 登录流程和 MCP Tool 登录流程共同复用。
141
+ */
142
+ export async function displayQRCode(qrcodeUrl: string): Promise<void> {
143
+ try {
144
+ const qrterm = await import("qrcode-terminal");
145
+ qrterm.default.generate(qrcodeUrl, { small: true });
146
+ process.stdout.write(`若二维码未能显示或无法使用,你可以访问以下链接以继续:\n`);
147
+ process.stdout.write(`${qrcodeUrl}\n`);
148
+ } catch {
149
+ process.stdout.write(`若二维码未能显示或无法使用,你可以访问以下链接以继续:\n`);
150
+ process.stdout.write(`${qrcodeUrl}\n`);
151
+ }
152
+ }
153
+
93
154
  export type WeixinQrStartResult = {
94
155
  qrcodeUrl?: string;
95
156
  message: string;
@@ -108,7 +169,6 @@ export type WeixinQrWaitResult = {
108
169
 
109
170
  export async function startWeixinLoginWithQr(opts: {
110
171
  verbose?: boolean;
111
- timeoutMs?: number;
112
172
  force?: boolean;
113
173
  accountId?: string;
114
174
  apiBaseUrl: string;
@@ -122,7 +182,7 @@ export async function startWeixinLoginWithQr(opts: {
122
182
  if (!opts.force && existing && isLoginFresh(existing) && existing.qrcodeUrl) {
123
183
  return {
124
184
  qrcodeUrl: existing.qrcodeUrl,
125
- message: "二维码已就绪,请使用微信扫描。",
185
+ message: "二维码已显示,请用手机微信扫描。",
126
186
  sessionKey,
127
187
  };
128
188
  }
@@ -149,7 +209,7 @@ export async function startWeixinLoginWithQr(opts: {
149
209
 
150
210
  return {
151
211
  qrcodeUrl: qrResponse.qrcode_img_content,
152
- message: "使用微信扫描以下二维码,以完成连接。",
212
+ message: "用手机微信扫描以下二维码,以继续连接:",
153
213
  sessionKey,
154
214
  };
155
215
  } catch (err) {
@@ -163,6 +223,34 @@ export async function startWeixinLoginWithQr(opts: {
163
223
 
164
224
  const MAX_QR_REFRESH_COUNT = 3;
165
225
 
226
+ /**
227
+ * 刷新二维码并展示给用户,返回是否成功。
228
+ * 成功时更新 activeLogin 的 qrcode/qrcodeUrl/startedAt,并重置 scannedPrinted。
229
+ */
230
+ async function refreshQRCode(
231
+ activeLogin: ActiveLogin,
232
+ botType: string,
233
+ qrRefreshCount: number,
234
+ onScannedReset: () => void,
235
+ ): Promise<{ success: true } | { success: false; message: string }> {
236
+ process.stdout.write(`\n⏳ 正在刷新二维码...(${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})\n`);
237
+ logger.info(`waitForWeixinLogin: refreshing QR code (${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})`);
238
+ try {
239
+ const qrResponse = await fetchQRCode(FIXED_BASE_URL, botType);
240
+ activeLogin.qrcode = qrResponse.qrcode;
241
+ activeLogin.qrcodeUrl = qrResponse.qrcode_img_content;
242
+ activeLogin.startedAt = Date.now();
243
+ onScannedReset();
244
+ logger.info(`waitForWeixinLogin: new QR code obtained qrcode=${redactToken(qrResponse.qrcode)}`);
245
+ process.stdout.write(`🔄 二维码已更新,请重新扫描。\n\n`);
246
+ await displayQRCode(qrResponse.qrcode_img_content);
247
+ return { success: true };
248
+ } catch (refreshErr) {
249
+ logger.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`);
250
+ return { success: false, message: `刷新二维码失败: ${String(refreshErr)}` };
251
+ }
252
+ }
253
+
166
254
  export async function waitForWeixinLogin(opts: {
167
255
  timeoutMs?: number;
168
256
  verbose?: boolean;
@@ -202,7 +290,7 @@ export async function waitForWeixinLogin(opts: {
202
290
  while (Date.now() < deadline) {
203
291
  try {
204
292
  const currentBaseUrl = activeLogin.currentApiBaseUrl ?? FIXED_BASE_URL;
205
- const statusResponse = await pollQRStatus(currentBaseUrl, activeLogin.qrcode);
293
+ const statusResponse = await pollQRStatus(currentBaseUrl, activeLogin.qrcode, activeLogin.pendingVerifyCode);
206
294
  logger.debug(`pollQRStatus: status=${statusResponse.status} hasBotToken=${Boolean(statusResponse.bot_token)} hasBotId=${Boolean(statusResponse.ilink_bot_id)}`);
207
295
  activeLogin.status = statusResponse.status;
208
296
 
@@ -213,11 +301,26 @@ export async function waitForWeixinLogin(opts: {
213
301
  }
214
302
  break;
215
303
  case "scaned":
304
+ // 若携带了配对码且服务端返回 scaned,说明验证码正确,清除暂存
305
+ if (activeLogin.pendingVerifyCode) {
306
+ logger.info("verify code accepted, resuming polling");
307
+ activeLogin.pendingVerifyCode = undefined;
308
+ }
216
309
  if (!scannedPrinted) {
217
- process.stdout.write("\n👀 已扫码,在微信继续操作...\n");
310
+ process.stdout.write("\n正在验证\n");
218
311
  scannedPrinted = true;
219
312
  }
220
313
  break;
314
+ case "need_verifycode": {
315
+ // 首次进入提示输入,再次进入(已有 pendingVerifyCode)说明上次输入错误
316
+ const verifyPrompt = activeLogin.pendingVerifyCode
317
+ ? "❌ 你输入的数字不匹配,请重新输入:"
318
+ : "输入手机微信显示的数字,以继续连接:";
319
+ const code = await readVerifyCodeFromStdin(verifyPrompt);
320
+ activeLogin.pendingVerifyCode = code;
321
+ // 立即进入下一次轮询,不等待 1s
322
+ continue;
323
+ }
221
324
  case "expired": {
222
325
  qrRefreshCount++;
223
326
  if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
@@ -227,43 +330,64 @@ export async function waitForWeixinLogin(opts: {
227
330
  activeLogins.delete(opts.sessionKey);
228
331
  return {
229
332
  connected: false,
230
- message: "登录超时:二维码多次过期,请重新开始登录流程。",
333
+ message: "二维码多次失效,连接流程已停止。请稍后再试。",
231
334
  };
232
335
  }
233
336
 
234
- process.stdout.write(`\n⏳ 二维码已过期,正在刷新...(${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})\n`);
235
- logger.info(
236
- `waitForWeixinLogin: QR expired, refreshing (${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})`,
337
+ process.stdout.write(`\n⏳ 二维码已过期,正在刷新...\n`);
338
+ const expiredRefreshResult = await refreshQRCode(
339
+ activeLogin,
340
+ opts.botType || DEFAULT_ILINK_BOT_TYPE,
341
+ qrRefreshCount,
342
+ () => { scannedPrinted = false; },
343
+ );
344
+ if (!expiredRefreshResult.success) {
345
+ activeLogins.delete(opts.sessionKey);
346
+ return { connected: false, message: expiredRefreshResult.message };
347
+ }
348
+ break;
349
+ }
350
+ case "verify_code_blocked": {
351
+ logger.warn(
352
+ `waitForWeixinLogin: verify code blocked, qrRefreshCount=${qrRefreshCount} sessionKey=${opts.sessionKey}`,
237
353
  );
354
+ process.stdout.write("\n⛔ 多次输入错误,请稍后再试。\n");
355
+ // 清除配对码暂存
356
+ activeLogin.pendingVerifyCode = undefined;
238
357
 
239
- try {
240
- const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;
241
- const qrResponse = await fetchQRCode(FIXED_BASE_URL, botType);
242
- activeLogin.qrcode = qrResponse.qrcode;
243
- activeLogin.qrcodeUrl = qrResponse.qrcode_img_content;
244
- activeLogin.startedAt = Date.now();
245
- scannedPrinted = false;
246
- logger.info(`waitForWeixinLogin: new QR code obtained qrcode=${redactToken(qrResponse.qrcode)}`);
247
- process.stdout.write(`🔄 新二维码已生成,请重新扫描\n\n`);
248
- try {
249
- const qrterm = await import("qrcode-terminal");
250
- qrterm.default.generate(qrResponse.qrcode_img_content, { small: true });
251
- process.stdout.write(`如果二维码未能成功展示,请用浏览器打开以下链接扫码:\n`);
252
- process.stdout.write(`${qrResponse.qrcode_img_content}\n`);
253
- } catch {
254
- process.stdout.write(`二维码未加载成功,请用浏览器打开以下链接扫码:\n`);
255
- process.stdout.write(`${qrResponse.qrcode_img_content}\n`);
256
- }
257
- } catch (refreshErr) {
258
- logger.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`);
358
+ qrRefreshCount++;
359
+ if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
360
+ logger.warn(
361
+ `waitForWeixinLogin: verify_code_blocked and QR refresh limit reached, giving up sessionKey=${opts.sessionKey}`,
362
+ );
259
363
  activeLogins.delete(opts.sessionKey);
260
364
  return {
261
365
  connected: false,
262
- message: `刷新二维码失败: ${String(refreshErr)}`,
366
+ message: "多次输入错误,连接流程已停止。请稍后再试。",
263
367
  };
264
368
  }
369
+
370
+ const blockedRefreshResult = await refreshQRCode(
371
+ activeLogin,
372
+ opts.botType || DEFAULT_ILINK_BOT_TYPE,
373
+ qrRefreshCount,
374
+ () => { scannedPrinted = false; },
375
+ );
376
+ if (!blockedRefreshResult.success) {
377
+ activeLogins.delete(opts.sessionKey);
378
+ return { connected: false, message: blockedRefreshResult.message };
379
+ }
265
380
  break;
266
381
  }
382
+ case "binded_redirect": {
383
+ logger.info(`waitForWeixinLogin: binded_redirect received, bot already bound sessionKey=${opts.sessionKey}`);
384
+ process.stdout.write("\n✅ 已连接过此 OpenClaw,无需重复连接。\n");
385
+ activeLogins.delete(opts.sessionKey);
386
+ return {
387
+ connected: false,
388
+ message: "已连接过此 OpenClaw,无需重复连接。",
389
+ };
390
+ }
267
391
  case "scaned_but_redirect": {
268
392
  const redirectHost = statusResponse.redirect_host;
269
393
  if (redirectHost) {
@@ -298,7 +422,7 @@ export async function waitForWeixinLogin(opts: {
298
422
  accountId: statusResponse.ilink_bot_id,
299
423
  baseUrl: statusResponse.baseurl,
300
424
  userId: statusResponse.ilink_user_id,
301
- message: " 与微信连接成功!",
425
+ message: "已将此 OpenClaw 连接到微信。",
302
426
  };
303
427
  }
304
428
  }
package/src/channel.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  DEFAULT_ILINK_BOT_TYPE,
24
24
  startWeixinLoginWithQr,
25
25
  waitForWeixinLogin,
26
+ displayQRCode,
26
27
  } from "./auth/login-qr.js";
27
28
  import type { WeixinQrStartResult, WeixinQrWaitResult } from "./auth/login-qr.js";
28
29
  // Lazy-imported inside startAccount to avoid pulling in the monitor -> process-message ->
@@ -320,7 +321,7 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
320
321
  runtime?.log?.(msg);
321
322
  };
322
323
 
323
- log(`正在启动微信扫码登录...`);
324
+ log(`正在启动...`);
324
325
  const startResult: WeixinQrStartResult = await startWeixinLoginWithQr({
325
326
  accountId: account.accountId,
326
327
  apiBaseUrl: account.baseUrl,
@@ -336,27 +337,11 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
336
337
  throw new Error(startResult.message);
337
338
  }
338
339
 
339
- log(`\n使用微信扫描以下二维码,以完成连接:\n`);
340
- try {
341
- const qrcodeterminal = await import("qrcode-terminal");
342
- await new Promise<void>((resolve) => {
343
- qrcodeterminal.default.generate(startResult.qrcodeUrl!, { small: true }, (qr: string) => {
344
- console.log(qr);
345
- log(`如果二维码未能成功展示,请用浏览器打开以下链接扫码:`);
346
- log(startResult.qrcodeUrl!);
347
- resolve();
348
- });
349
- });
350
- } catch (err) {
351
- logger.warn(
352
- `auth.login: qrcode-terminal unavailable, falling back to URL err=${String(err)}`,
353
- );
354
- log(`二维码未加载成功,请用浏览器打开以下链接扫码:`);
355
- log(startResult.qrcodeUrl!);
356
- }
340
+ log(`\n用手机微信扫描以下二维码,以继续连接:\n`);
341
+ await displayQRCode(startResult.qrcodeUrl!);
357
342
 
358
343
  const loginTimeoutMs = 480_000;
359
- log(`\n等待连接结果...\n`);
344
+ log(`\n正在等待操作...\n`);
360
345
 
361
346
  const waitResult: WeixinQrWaitResult = await waitForWeixinLogin({
362
347
  sessionKey: startResult.sessionKey,
@@ -381,7 +366,7 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
381
366
  clearStaleAccountsForUserId(normalizedId, waitResult.userId, clearContextTokensForAccount);
382
367
  }
383
368
  void triggerWeixinChannelReload();
384
- log(`\n 与微信连接成功!`);
369
+ log(`\n已将此 OpenClaw 连接到微信。`);
385
370
  } catch (err) {
386
371
  logger.error(
387
372
  `auth.login: failed to save account data accountId=${waitResult.accountId} err=${String(err)}`,
@@ -474,7 +459,7 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
474
459
  aLog.warn(`notifyStop failed during shutdown (ignored): ${String(err)}`);
475
460
  }
476
461
  },
477
- loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => {
462
+ loginWithQrStart: async ({ accountId, force, verbose }) => {
478
463
  // For re-login: use saved baseUrl from account data; fall back to default for new accounts.
479
464
  const savedBaseUrl = accountId ? loadWeixinAccount(accountId)?.baseUrl?.trim() : "";
480
465
  const result: WeixinQrStartResult = await startWeixinLoginWithQr({
@@ -482,7 +467,6 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
482
467
  apiBaseUrl: savedBaseUrl || DEFAULT_BASE_URL,
483
468
  botType: DEFAULT_ILINK_BOT_TYPE,
484
469
  force,
485
- timeoutMs,
486
470
  verbose,
487
471
  });
488
472
  // Return sessionKey so the client can pass it back in loginWithQrWait.