@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 +39 -0
- package/README.zh_CN.md +36 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/api/api.ts +146 -10
- package/src/api/types.ts +35 -0
- package/src/auth/accounts.ts +12 -0
- package/src/auth/login-qr.ts +162 -38
- package/src/channel.ts +39 -23
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 与后端网关通信。二次开发者若需对接自有后端,需实现以下接口。
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
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 {
|
|
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
|
|
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
|
|
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 =
|
|
184
|
-
|
|
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
|
+
}
|
package/src/auth/accounts.ts
CHANGED
|
@@ -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).
|
package/src/auth/login-qr.ts
CHANGED
|
@@ -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(`
|
|
63
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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⏳
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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:
|
|
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
|
|
339
|
-
|
|
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
|
|
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
|
-
|
|
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.
|