@tencent-weixin/openclaw-weixin 2.1.9 → 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.9",
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.9",
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
 
@@ -13,6 +13,8 @@ import type {
13
13
  GetUploadUrlResp,
14
14
  GetUpdatesReq,
15
15
  GetUpdatesResp,
16
+ NotifyStopResp,
17
+ NotifyStartResp,
16
18
  SendMessageReq,
17
19
  SendTypingReq,
18
20
  GetConfigResp,
@@ -68,9 +70,105 @@ function buildClientVersion(version: string): number {
68
70
 
69
71
  const ILINK_APP_CLIENT_VERSION: number = buildClientVersion(pkg.version ?? "0.0.0");
70
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
+
71
166
  /** Build the `base_info` payload included in every API request. */
72
167
  export function buildBaseInfo(): BaseInfo {
73
- return { channel_version: CHANNEL_VERSION };
168
+ return {
169
+ channel_version: CHANNEL_VERSION,
170
+ bot_agent: sanitizeBotAgent(loadConfigBotAgent()),
171
+ };
74
172
  }
75
173
 
76
174
  /** Default timeout for long-poll getUpdates requests. */
@@ -164,15 +262,17 @@ export async function apiGetFetch(params: {
164
262
  }
165
263
 
166
264
  /**
167
- * 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).
168
268
  * Returns the raw response text on success; throws on HTTP error or timeout.
169
269
  */
170
- async function apiPostFetch(params: {
270
+ export async function apiPostFetch(params: {
171
271
  baseUrl: string;
172
272
  endpoint: string;
173
273
  body: string;
174
274
  token?: string;
175
- timeoutMs: number;
275
+ timeoutMs?: number;
176
276
  label: string;
177
277
  }): Promise<string> {
178
278
  const base = ensureTrailingSlash(params.baseUrl);
@@ -180,16 +280,20 @@ async function apiPostFetch(params: {
180
280
  const hdrs = buildHeaders({ token: params.token, body: params.body });
181
281
  logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);
182
282
 
183
- const controller = new AbortController();
184
- 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;
185
289
  try {
186
290
  const res = await fetch(url.toString(), {
187
291
  method: "POST",
188
292
  headers: hdrs,
189
293
  body: params.body,
190
- signal: controller.signal,
294
+ ...(controller ? { signal: controller.signal } : {}),
191
295
  });
192
- clearTimeout(t);
296
+ if (t !== undefined) clearTimeout(t);
193
297
  const rawText = await res.text();
194
298
  logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
195
299
  if (!res.ok) {
@@ -197,7 +301,7 @@ async function apiPostFetch(params: {
197
301
  }
198
302
  return rawText;
199
303
  } catch (err) {
200
- clearTimeout(t);
304
+ if (t !== undefined) clearTimeout(t);
201
305
  throw err;
202
306
  }
203
307
  }
@@ -316,3 +420,35 @@ export async function sendTyping(
316
420
  label: "sendTyping",
317
421
  });
318
422
  }
423
+
424
+ /**
425
+ * Notify Weixin that this channel client is stopping (gateway shutdown / channel stop).
426
+ * Uses a standalone timeout (not the gateway abort signal) so the request can finish
427
+ * after OpenClaw has already aborted the long-poll.
428
+ */
429
+ export async function notifyStop(params: WeixinApiOptions): Promise<NotifyStopResp> {
430
+ const rawText = await apiPostFetch({
431
+ baseUrl: params.baseUrl,
432
+ endpoint: "ilink/bot/msg/notifystop",
433
+ body: JSON.stringify({ base_info: buildBaseInfo() }),
434
+ token: params.token,
435
+ timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
436
+ label: "notifyStop",
437
+ });
438
+ return JSON.parse(rawText) as NotifyStopResp;
439
+ }
440
+
441
+ /**
442
+ * Notify Weixin that this channel client is starting (gateway startup / channel start).
443
+ */
444
+ export async function notifyStart(params: WeixinApiOptions): Promise<NotifyStartResp> {
445
+ const rawText = await apiPostFetch({
446
+ baseUrl: params.baseUrl,
447
+ endpoint: "ilink/bot/msg/notifystart",
448
+ body: JSON.stringify({ base_info: buildBaseInfo() }),
449
+ token: params.token,
450
+ timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
451
+ label: "notifyStart",
452
+ });
453
+ return JSON.parse(rawText) as NotifyStartResp;
454
+ }
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 */
@@ -224,3 +237,25 @@ export interface GetConfigResp {
224
237
  /** Base64-encoded typing ticket for sendTyping. */
225
238
  typing_ticket?: string;
226
239
  }
240
+
241
+ /** proto: NotifyStopReq — notify server when the channel client is stopping. */
242
+ export interface NotifyStopReq {
243
+ base_info?: BaseInfo;
244
+ }
245
+
246
+ /** proto: NotifyStopResp */
247
+ export interface NotifyStopResp {
248
+ ret?: number;
249
+ errmsg?: string;
250
+ }
251
+
252
+ /** proto: NotifyStartReq — notify server when the channel client is starting. */
253
+ export interface NotifyStartReq {
254
+ base_info?: BaseInfo;
255
+ }
256
+
257
+ /** proto: NotifyStartResp */
258
+ export interface NotifyStartResp {
259
+ ret?: number;
260
+ errmsg?: string;
261
+ }
@@ -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
@@ -15,6 +15,7 @@ import {
15
15
  DEFAULT_BASE_URL,
16
16
  } from "./auth/accounts.js";
17
17
  import type { ResolvedWeixinAccount } from "./auth/accounts.js";
18
+ import { notifyStop, notifyStart } from "./api/api.js";
18
19
  import { assertSessionActive } from "./api/session-guard.js";
19
20
  import { getContextToken, findAccountIdsByContextToken, restoreContextTokens, clearContextTokensForAccount } from "./messaging/inbound.js";
20
21
  import { logger } from "./util/logger.js";
@@ -22,6 +23,7 @@ import {
22
23
  DEFAULT_ILINK_BOT_TYPE,
23
24
  startWeixinLoginWithQr,
24
25
  waitForWeixinLogin,
26
+ displayQRCode,
25
27
  } from "./auth/login-qr.js";
26
28
  import type { WeixinQrStartResult, WeixinQrWaitResult } from "./auth/login-qr.js";
27
29
  // Lazy-imported inside startAccount to avoid pulling in the monitor -> process-message ->
@@ -319,7 +321,7 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
319
321
  runtime?.log?.(msg);
320
322
  };
321
323
 
322
- log(`正在启动微信扫码登录...`);
324
+ log(`正在启动...`);
323
325
  const startResult: WeixinQrStartResult = await startWeixinLoginWithQr({
324
326
  accountId: account.accountId,
325
327
  apiBaseUrl: account.baseUrl,
@@ -335,27 +337,11 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
335
337
  throw new Error(startResult.message);
336
338
  }
337
339
 
338
- log(`\n使用微信扫描以下二维码,以完成连接:\n`);
339
- try {
340
- const qrcodeterminal = await import("qrcode-terminal");
341
- await new Promise<void>((resolve) => {
342
- qrcodeterminal.default.generate(startResult.qrcodeUrl!, { small: true }, (qr: string) => {
343
- console.log(qr);
344
- log(`如果二维码未能成功展示,请用浏览器打开以下链接扫码:`);
345
- log(startResult.qrcodeUrl!);
346
- resolve();
347
- });
348
- });
349
- } catch (err) {
350
- logger.warn(
351
- `auth.login: qrcode-terminal unavailable, falling back to URL err=${String(err)}`,
352
- );
353
- log(`二维码未加载成功,请用浏览器打开以下链接扫码:`);
354
- log(startResult.qrcodeUrl!);
355
- }
340
+ log(`\n用手机微信扫描以下二维码,以继续连接:\n`);
341
+ await displayQRCode(startResult.qrcodeUrl!);
356
342
 
357
343
  const loginTimeoutMs = 480_000;
358
- log(`\n等待连接结果...\n`);
344
+ log(`\n正在等待操作...\n`);
359
345
 
360
346
  const waitResult: WeixinQrWaitResult = await waitForWeixinLogin({
361
347
  sessionKey: startResult.sessionKey,
@@ -380,7 +366,7 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
380
366
  clearStaleAccountsForUserId(normalizedId, waitResult.userId, clearContextTokensForAccount);
381
367
  }
382
368
  void triggerWeixinChannelReload();
383
- log(`\n 与微信连接成功!`);
369
+ log(`\n已将此 OpenClaw 连接到微信。`);
384
370
  } catch (err) {
385
371
  logger.error(
386
372
  `auth.login: failed to save account data accountId=${waitResult.accountId} err=${String(err)}`,
@@ -427,6 +413,18 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
427
413
 
428
414
  ctx.log?.info?.(`[${account.accountId}] starting weixin provider (${DEFAULT_BASE_URL})`);
429
415
 
416
+ try {
417
+ const resp = await notifyStart({
418
+ baseUrl: account.baseUrl,
419
+ token: account.token,
420
+ });
421
+ if (resp.ret !== undefined && resp.ret !== 0) {
422
+ aLog.warn(`notifyStart: ret=${resp.ret} errmsg=${resp.errmsg ?? ""}`);
423
+ }
424
+ } catch (err) {
425
+ aLog.warn(`notifyStart failed during startup (ignored): ${String(err)}`);
426
+ }
427
+
430
428
  const logPath = aLog.getLogFilePath();
431
429
  ctx.log?.info?.(`[${account.accountId}] weixin logs: ${logPath}`);
432
430
 
@@ -442,7 +440,26 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
442
440
  setStatus: ctx.setStatus,
443
441
  });
444
442
  },
445
- loginWithQrStart: async ({ accountId, force, timeoutMs, verbose }) => {
443
+ stopAccount: async (ctx) => {
444
+ const account = ctx.account;
445
+ const aLog = logger.withAccount(account.accountId);
446
+ if (!account.configured || !account.token?.trim()) {
447
+ aLog.debug(`gateway.stopAccount: skip notifyStop (not configured or no token)`);
448
+ return;
449
+ }
450
+ try {
451
+ const resp = await notifyStop({
452
+ baseUrl: account.baseUrl,
453
+ token: account.token,
454
+ });
455
+ if (resp.ret !== undefined && resp.ret !== 0) {
456
+ aLog.warn(`notifyStop: ret=${resp.ret} errmsg=${resp.errmsg ?? ""}`);
457
+ }
458
+ } catch (err) {
459
+ aLog.warn(`notifyStop failed during shutdown (ignored): ${String(err)}`);
460
+ }
461
+ },
462
+ loginWithQrStart: async ({ accountId, force, verbose }) => {
446
463
  // For re-login: use saved baseUrl from account data; fall back to default for new accounts.
447
464
  const savedBaseUrl = accountId ? loadWeixinAccount(accountId)?.baseUrl?.trim() : "";
448
465
  const result: WeixinQrStartResult = await startWeixinLoginWithQr({
@@ -450,7 +467,6 @@ export const weixinPlugin: ChannelPlugin<ResolvedWeixinAccount> = {
450
467
  apiBaseUrl: savedBaseUrl || DEFAULT_BASE_URL,
451
468
  botType: DEFAULT_ILINK_BOT_TYPE,
452
469
  force,
453
- timeoutMs,
454
470
  verbose,
455
471
  });
456
472
  // Return sessionKey so the client can pass it back in loginWithQrWait.