@tencent-weixin/openclaw-weixin 1.0.0
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/CHANGELOG.md +5 -0
- package/CHANGELOG.zh_CN.md +3 -0
- package/LICENSE +21 -0
- package/README.md +271 -0
- package/README.zh_CN.md +269 -0
- package/index.ts +27 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +55 -0
- package/src/api/api.ts +240 -0
- package/src/api/config-cache.ts +79 -0
- package/src/api/session-guard.ts +58 -0
- package/src/api/types.ts +222 -0
- package/src/auth/accounts.ts +321 -0
- package/src/auth/login-qr.ts +331 -0
- package/src/auth/pairing.ts +120 -0
- package/src/cdn/aes-ecb.ts +21 -0
- package/src/cdn/cdn-upload.ts +77 -0
- package/src/cdn/cdn-url.ts +17 -0
- package/src/cdn/pic-decrypt.ts +85 -0
- package/src/cdn/upload.ts +155 -0
- package/src/channel.ts +380 -0
- package/src/config/config-schema.ts +22 -0
- package/src/log-upload.ts +126 -0
- package/src/media/media-download.ts +141 -0
- package/src/media/mime.ts +76 -0
- package/src/media/silk-transcode.ts +74 -0
- package/src/messaging/debug-mode.ts +69 -0
- package/src/messaging/error-notice.ts +31 -0
- package/src/messaging/inbound.ts +171 -0
- package/src/messaging/process-message.ts +381 -0
- package/src/messaging/send-media.ts +72 -0
- package/src/messaging/send.ts +267 -0
- package/src/messaging/slash-commands.ts +110 -0
- package/src/monitor/monitor.ts +221 -0
- package/src/runtime.ts +70 -0
- package/src/storage/state-dir.ts +11 -0
- package/src/storage/sync-buf.ts +81 -0
- package/src/util/logger.ts +143 -0
- package/src/util/random.ts +17 -0
- package/src/util/redact.ts +46 -0
- package/src/vendor.d.ts +25 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { normalizeAccountId } from "openclaw/plugin-sdk";
|
|
5
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
6
|
+
|
|
7
|
+
import { resolveStateDir } from "../storage/state-dir.js";
|
|
8
|
+
import { logger } from "../util/logger.js";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
11
|
+
export const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Account ID compatibility (legacy raw ID → normalized ID)
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pattern-based reverse of normalizeWeixinAccountId for known weixin ID suffixes.
|
|
20
|
+
* Used only as a compatibility fallback when loading accounts / sync bufs stored
|
|
21
|
+
* under the old raw ID.
|
|
22
|
+
* e.g. "b0f5860fdecb-im-bot" → "b0f5860fdecb@im.bot"
|
|
23
|
+
*/
|
|
24
|
+
export function deriveRawAccountId(normalizedId: string): string | undefined {
|
|
25
|
+
if (normalizedId.endsWith("-im-bot")) {
|
|
26
|
+
return `${normalizedId.slice(0, -7)}@im.bot`;
|
|
27
|
+
}
|
|
28
|
+
if (normalizedId.endsWith("-im-wechat")) {
|
|
29
|
+
return `${normalizedId.slice(0, -10)}@im.wechat`;
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Account index (persistent list of registered account IDs)
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function resolveWeixinStateDir(): string {
|
|
39
|
+
return path.join(resolveStateDir(), "openclaw-weixin");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveAccountIndexPath(): string {
|
|
43
|
+
return path.join(resolveWeixinStateDir(), "accounts.json");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Returns all accountIds registered via QR login. */
|
|
47
|
+
export function listIndexedWeixinAccountIds(): string[] {
|
|
48
|
+
const filePath = resolveAccountIndexPath();
|
|
49
|
+
try {
|
|
50
|
+
if (!fs.existsSync(filePath)) return [];
|
|
51
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
52
|
+
const parsed = JSON.parse(raw);
|
|
53
|
+
if (!Array.isArray(parsed)) return [];
|
|
54
|
+
return parsed.filter((id): id is string => typeof id === "string" && id.trim() !== "");
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Add accountId to the persistent index (no-op if already present). */
|
|
61
|
+
export function registerWeixinAccountId(accountId: string): void {
|
|
62
|
+
const dir = resolveWeixinStateDir();
|
|
63
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
64
|
+
|
|
65
|
+
const existing = listIndexedWeixinAccountIds();
|
|
66
|
+
if (existing.includes(accountId)) return;
|
|
67
|
+
|
|
68
|
+
const updated = [...existing, accountId];
|
|
69
|
+
fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify(updated, null, 2), "utf-8");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Account store (per-account credential files)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/** Unified per-account data: token + baseUrl in one file. */
|
|
77
|
+
export type WeixinAccountData = {
|
|
78
|
+
token?: string;
|
|
79
|
+
savedAt?: string;
|
|
80
|
+
baseUrl?: string;
|
|
81
|
+
/** Last linked Weixin user id from QR login (optional). */
|
|
82
|
+
userId?: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function resolveAccountsDir(): string {
|
|
86
|
+
return path.join(resolveWeixinStateDir(), "accounts");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveAccountPath(accountId: string): string {
|
|
90
|
+
return path.join(resolveAccountsDir(), `${accountId}.json`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Legacy single-file token: `credentials/openclaw-weixin/credentials.json` (pre per-account files).
|
|
95
|
+
*/
|
|
96
|
+
function loadLegacyToken(): string | undefined {
|
|
97
|
+
const legacyPath = path.join(resolveStateDir(), "credentials", "openclaw-weixin", "credentials.json");
|
|
98
|
+
try {
|
|
99
|
+
if (!fs.existsSync(legacyPath)) return undefined;
|
|
100
|
+
const raw = fs.readFileSync(legacyPath, "utf-8");
|
|
101
|
+
const parsed = JSON.parse(raw) as { token?: string };
|
|
102
|
+
return typeof parsed.token === "string" ? parsed.token : undefined;
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readAccountFile(filePath: string): WeixinAccountData | null {
|
|
109
|
+
try {
|
|
110
|
+
if (fs.existsSync(filePath)) {
|
|
111
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8")) as WeixinAccountData;
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// ignore
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Load account data by ID, with compatibility fallbacks. */
|
|
120
|
+
export function loadWeixinAccount(accountId: string): WeixinAccountData | null {
|
|
121
|
+
// Primary: try given accountId (normalized IDs written after this change).
|
|
122
|
+
const primary = readAccountFile(resolveAccountPath(accountId));
|
|
123
|
+
if (primary) return primary;
|
|
124
|
+
|
|
125
|
+
// Compatibility: if the given ID is normalized, derive the old raw filename
|
|
126
|
+
// (e.g. "b0f5860fdecb-im-bot" → "b0f5860fdecb@im.bot") for existing installs.
|
|
127
|
+
const rawId = deriveRawAccountId(accountId);
|
|
128
|
+
if (rawId) {
|
|
129
|
+
const compat = readAccountFile(resolveAccountPath(rawId));
|
|
130
|
+
if (compat) return compat;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Legacy fallback: read token from old single-account credentials file.
|
|
134
|
+
const token = loadLegacyToken();
|
|
135
|
+
if (token) return { token };
|
|
136
|
+
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Persist account data after QR login (merges into existing file).
|
|
142
|
+
* - token: overwritten when provided.
|
|
143
|
+
* - baseUrl: stored when non-empty; resolveWeixinAccount falls back to DEFAULT_BASE_URL.
|
|
144
|
+
* - userId: set when `update.userId` is provided; omitted from file when cleared to empty.
|
|
145
|
+
*/
|
|
146
|
+
export function saveWeixinAccount(
|
|
147
|
+
accountId: string,
|
|
148
|
+
update: { token?: string; baseUrl?: string; userId?: string },
|
|
149
|
+
): void {
|
|
150
|
+
const dir = resolveAccountsDir();
|
|
151
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
152
|
+
|
|
153
|
+
const existing = loadWeixinAccount(accountId) ?? {};
|
|
154
|
+
|
|
155
|
+
const token = update.token?.trim() || existing.token;
|
|
156
|
+
const baseUrl = update.baseUrl?.trim() || existing.baseUrl;
|
|
157
|
+
const userId =
|
|
158
|
+
update.userId !== undefined
|
|
159
|
+
? update.userId.trim() || undefined
|
|
160
|
+
: existing.userId?.trim() || undefined;
|
|
161
|
+
|
|
162
|
+
const data: WeixinAccountData = {
|
|
163
|
+
...(token ? { token, savedAt: new Date().toISOString() } : {}),
|
|
164
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
165
|
+
...(userId ? { userId } : {}),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const filePath = resolveAccountPath(accountId);
|
|
169
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
170
|
+
try {
|
|
171
|
+
fs.chmodSync(filePath, 0o600);
|
|
172
|
+
} catch {
|
|
173
|
+
// best-effort
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Remove account data file. */
|
|
178
|
+
export function clearWeixinAccount(accountId: string): void {
|
|
179
|
+
try {
|
|
180
|
+
fs.unlinkSync(resolveAccountPath(accountId));
|
|
181
|
+
} catch {
|
|
182
|
+
// ignore if not found
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Resolve the openclaw.json config file path.
|
|
188
|
+
* Checks OPENCLAW_CONFIG env var, then state dir.
|
|
189
|
+
*/
|
|
190
|
+
function resolveConfigPath(): string {
|
|
191
|
+
const envPath = process.env.OPENCLAW_CONFIG?.trim();
|
|
192
|
+
if (envPath) return envPath;
|
|
193
|
+
return path.join(resolveStateDir(), "openclaw.json");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Read `routeTag` from openclaw.json (for callers without an `OpenClawConfig` object).
|
|
198
|
+
* Checks per-account `channels.<id>.accounts[accountId].routeTag` first, then section-level
|
|
199
|
+
* `channels.<id>.routeTag`. Matches `feat_weixin_extension` behavior; channel key is `"openclaw-weixin"`.
|
|
200
|
+
*/
|
|
201
|
+
export function loadConfigRouteTag(accountId?: string): string | undefined {
|
|
202
|
+
try {
|
|
203
|
+
const configPath = resolveConfigPath();
|
|
204
|
+
if (!fs.existsSync(configPath)) return undefined;
|
|
205
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
206
|
+
const cfg = JSON.parse(raw) as Record<string, unknown>;
|
|
207
|
+
const channels = cfg.channels as Record<string, unknown> | undefined;
|
|
208
|
+
const section = channels?.["openclaw-weixin"] as Record<string, unknown> | undefined;
|
|
209
|
+
if (!section) return undefined;
|
|
210
|
+
if (accountId) {
|
|
211
|
+
const accounts = section.accounts as Record<string, Record<string, unknown>> | undefined;
|
|
212
|
+
const tag = accounts?.[accountId]?.routeTag;
|
|
213
|
+
if (typeof tag === "number") return String(tag);
|
|
214
|
+
if (typeof tag === "string" && tag.trim()) return tag.trim();
|
|
215
|
+
}
|
|
216
|
+
if (typeof section.routeTag === "number") return String(section.routeTag);
|
|
217
|
+
return typeof section.routeTag === "string" && section.routeTag.trim()
|
|
218
|
+
? section.routeTag.trim()
|
|
219
|
+
: undefined;
|
|
220
|
+
} catch {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Touch openclaw.json to trigger channel reload.
|
|
227
|
+
* Updates `channels.<channelId>._accountsUpdatedAt` so config-reload detects a change
|
|
228
|
+
* and restarts the channel.
|
|
229
|
+
*/
|
|
230
|
+
export function triggerWeixinChannelReload(): void {
|
|
231
|
+
try {
|
|
232
|
+
const configPath = resolveConfigPath();
|
|
233
|
+
logger.info(`triggerWeixinChannelReload: configPath=${configPath}`);
|
|
234
|
+
|
|
235
|
+
if (!fs.existsSync(configPath)) {
|
|
236
|
+
logger.warn(`triggerWeixinChannelReload: config not found at ${configPath}`);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
241
|
+
const cfg = JSON.parse(raw) as Record<string, unknown>;
|
|
242
|
+
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
|
|
243
|
+
const section = (channels["openclaw-weixin"] ?? {}) as Record<string, unknown>;
|
|
244
|
+
|
|
245
|
+
const updated = {
|
|
246
|
+
...cfg,
|
|
247
|
+
channels: {
|
|
248
|
+
...channels,
|
|
249
|
+
"openclaw-weixin": {
|
|
250
|
+
...section,
|
|
251
|
+
_accountsUpdatedAt: Date.now(),
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
fs.writeFileSync(configPath, JSON.stringify(updated, null, 2), "utf-8");
|
|
257
|
+
logger.info(`triggerWeixinChannelReload: wrote to ${configPath}`);
|
|
258
|
+
} catch (err) {
|
|
259
|
+
logger.error(`triggerWeixinChannelReload: failed: ${String(err)}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Account resolution (merge config + stored credentials)
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
export type ResolvedWeixinAccount = {
|
|
268
|
+
accountId: string;
|
|
269
|
+
baseUrl: string;
|
|
270
|
+
cdnBaseUrl: string;
|
|
271
|
+
token?: string;
|
|
272
|
+
enabled: boolean;
|
|
273
|
+
/** true when a token has been obtained via QR login. */
|
|
274
|
+
configured: boolean;
|
|
275
|
+
name?: string;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
type WeixinAccountConfig = {
|
|
279
|
+
name?: string;
|
|
280
|
+
enabled?: boolean;
|
|
281
|
+
cdnBaseUrl?: string;
|
|
282
|
+
/** Optional SKRouteTag source; read from openclaw.json when `accountId` is passed to `loadConfigRouteTag`. */
|
|
283
|
+
routeTag?: number | string;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
type WeixinSectionConfig = WeixinAccountConfig & {
|
|
287
|
+
accounts?: Record<string, WeixinAccountConfig>;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/** List accountIds from the index file (written at QR login). */
|
|
291
|
+
export function listWeixinAccountIds(_cfg: OpenClawConfig): string[] {
|
|
292
|
+
return listIndexedWeixinAccountIds();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Resolve a weixin account by ID, merging config and stored credentials. */
|
|
296
|
+
export function resolveWeixinAccount(
|
|
297
|
+
cfg: OpenClawConfig,
|
|
298
|
+
accountId?: string | null,
|
|
299
|
+
): ResolvedWeixinAccount {
|
|
300
|
+
const raw = accountId?.trim();
|
|
301
|
+
if (!raw) {
|
|
302
|
+
throw new Error("weixin: accountId is required (no default account)");
|
|
303
|
+
}
|
|
304
|
+
const id = normalizeAccountId(raw);
|
|
305
|
+
const section = cfg.channels?.["openclaw-weixin"] as WeixinSectionConfig | undefined;
|
|
306
|
+
const accountCfg: WeixinAccountConfig = section?.accounts?.[id] ?? section ?? {};
|
|
307
|
+
|
|
308
|
+
const accountData = loadWeixinAccount(id);
|
|
309
|
+
const token = accountData?.token?.trim() || undefined;
|
|
310
|
+
const stateBaseUrl = accountData?.baseUrl?.trim() || "";
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
accountId: id,
|
|
314
|
+
baseUrl: stateBaseUrl || DEFAULT_BASE_URL,
|
|
315
|
+
cdnBaseUrl: accountCfg.cdnBaseUrl?.trim() || CDN_BASE_URL,
|
|
316
|
+
token,
|
|
317
|
+
enabled: accountCfg.enabled !== false,
|
|
318
|
+
configured: Boolean(token),
|
|
319
|
+
name: accountCfg.name?.trim() || undefined,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { loadConfigRouteTag } from "./accounts.js";
|
|
4
|
+
import { logger } from "../util/logger.js";
|
|
5
|
+
import { redactToken } from "../util/redact.js";
|
|
6
|
+
|
|
7
|
+
type ActiveLogin = {
|
|
8
|
+
sessionKey: string;
|
|
9
|
+
id: string;
|
|
10
|
+
qrcode: string;
|
|
11
|
+
qrcodeUrl: string;
|
|
12
|
+
startedAt: number;
|
|
13
|
+
botToken?: string;
|
|
14
|
+
status?: "wait" | "scaned" | "confirmed" | "expired";
|
|
15
|
+
error?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;
|
|
19
|
+
/** Client-side timeout for the long-poll get_qrcode_status request. */
|
|
20
|
+
const QR_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
21
|
+
|
|
22
|
+
/** Default `bot_type` for ilink get_bot_qrcode / get_qrcode_status (this channel build). */
|
|
23
|
+
export const DEFAULT_ILINK_BOT_TYPE = "3";
|
|
24
|
+
|
|
25
|
+
const activeLogins = new Map<string, ActiveLogin>();
|
|
26
|
+
|
|
27
|
+
interface QRCodeResponse {
|
|
28
|
+
qrcode: string;
|
|
29
|
+
qrcode_img_content: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface StatusResponse {
|
|
33
|
+
status: "wait" | "scaned" | "confirmed" | "expired";
|
|
34
|
+
bot_token?: string;
|
|
35
|
+
ilink_bot_id?: string;
|
|
36
|
+
baseurl?: string;
|
|
37
|
+
/** The user ID of the person who scanned the QR code. */
|
|
38
|
+
ilink_user_id?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isLoginFresh(login: ActiveLogin): boolean {
|
|
42
|
+
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Remove all expired entries from the activeLogins map to prevent memory leaks. */
|
|
46
|
+
function purgeExpiredLogins(): void {
|
|
47
|
+
for (const [id, login] of activeLogins) {
|
|
48
|
+
if (!isLoginFresh(login)) {
|
|
49
|
+
activeLogins.delete(id);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function fetchQRCode(apiBaseUrl: string, botType: string): Promise<QRCodeResponse> {
|
|
55
|
+
const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
|
|
56
|
+
const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
|
|
57
|
+
logger.info(`Fetching QR code from: ${url.toString()}`);
|
|
58
|
+
|
|
59
|
+
const headers: Record<string, string> = {};
|
|
60
|
+
const routeTag = loadConfigRouteTag();
|
|
61
|
+
if (routeTag) {
|
|
62
|
+
headers.SKRouteTag = routeTag;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const response = await fetch(url.toString(), { headers });
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const body = await response.text().catch(() => "(unreadable)");
|
|
68
|
+
logger.error(`QR code fetch failed: ${response.status} ${response.statusText} body=${body}`);
|
|
69
|
+
throw new Error(`Failed to fetch QR code: ${response.status} ${response.statusText}`);
|
|
70
|
+
}
|
|
71
|
+
return await response.json();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function pollQRStatus(apiBaseUrl: string, qrcode: string): Promise<StatusResponse> {
|
|
75
|
+
const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
|
|
76
|
+
const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
|
|
77
|
+
logger.debug(`Long-poll QR status from: ${url.toString()}`);
|
|
78
|
+
|
|
79
|
+
const headers: Record<string, string> = {
|
|
80
|
+
"iLink-App-ClientVersion": "1",
|
|
81
|
+
};
|
|
82
|
+
const routeTag = loadConfigRouteTag();
|
|
83
|
+
if (routeTag) {
|
|
84
|
+
headers.SKRouteTag = routeTag;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const timer = setTimeout(() => controller.abort(), QR_LONG_POLL_TIMEOUT_MS);
|
|
89
|
+
try {
|
|
90
|
+
const response = await fetch(url.toString(), { headers, signal: controller.signal });
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
logger.debug(`pollQRStatus: HTTP ${response.status}, reading body...`);
|
|
93
|
+
const rawText = await response.text();
|
|
94
|
+
logger.debug(`pollQRStatus: body=${rawText.substring(0, 200)}`);
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
logger.error(`QR status poll failed: ${response.status} ${response.statusText} body=${rawText}`);
|
|
97
|
+
throw new Error(`Failed to poll QR status: ${response.status} ${response.statusText}`);
|
|
98
|
+
}
|
|
99
|
+
return JSON.parse(rawText) as StatusResponse;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
103
|
+
logger.debug(`pollQRStatus: client-side timeout after ${QR_LONG_POLL_TIMEOUT_MS}ms, returning wait`);
|
|
104
|
+
return { status: "wait" };
|
|
105
|
+
}
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export type WeixinQrStartResult = {
|
|
111
|
+
qrcodeUrl?: string;
|
|
112
|
+
message: string;
|
|
113
|
+
sessionKey: string;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export type WeixinQrWaitResult = {
|
|
117
|
+
connected: boolean;
|
|
118
|
+
botToken?: string;
|
|
119
|
+
accountId?: string;
|
|
120
|
+
baseUrl?: string;
|
|
121
|
+
/** The user ID of the person who scanned the QR code; add to allowFrom. */
|
|
122
|
+
userId?: string;
|
|
123
|
+
message: string;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export async function startWeixinLoginWithQr(opts: {
|
|
127
|
+
verbose?: boolean;
|
|
128
|
+
timeoutMs?: number;
|
|
129
|
+
force?: boolean;
|
|
130
|
+
accountId?: string;
|
|
131
|
+
apiBaseUrl: string;
|
|
132
|
+
botType?: string;
|
|
133
|
+
}): Promise<WeixinQrStartResult> {
|
|
134
|
+
const sessionKey = opts.accountId || randomUUID();
|
|
135
|
+
|
|
136
|
+
purgeExpiredLogins();
|
|
137
|
+
|
|
138
|
+
const existing = activeLogins.get(sessionKey);
|
|
139
|
+
if (!opts.force && existing && isLoginFresh(existing) && existing.qrcodeUrl) {
|
|
140
|
+
return {
|
|
141
|
+
qrcodeUrl: existing.qrcodeUrl,
|
|
142
|
+
message: "二维码已就绪,请使用微信扫描。",
|
|
143
|
+
sessionKey,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;
|
|
149
|
+
logger.info(`Starting Weixin login with bot_type=${botType}`);
|
|
150
|
+
|
|
151
|
+
if (!opts.apiBaseUrl) {
|
|
152
|
+
return {
|
|
153
|
+
message:
|
|
154
|
+
"No baseUrl configured. Add channels.openclaw-weixin.baseUrl to your config before logging in.",
|
|
155
|
+
sessionKey,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const qrResponse = await fetchQRCode(opts.apiBaseUrl, botType);
|
|
160
|
+
logger.info(
|
|
161
|
+
`QR code received, qrcode=${redactToken(qrResponse.qrcode)} imgContentLen=${qrResponse.qrcode_img_content?.length ?? 0}`,
|
|
162
|
+
);
|
|
163
|
+
logger.info(`二维码链接: ${qrResponse.qrcode_img_content}`);
|
|
164
|
+
|
|
165
|
+
const login: ActiveLogin = {
|
|
166
|
+
sessionKey,
|
|
167
|
+
id: randomUUID(),
|
|
168
|
+
qrcode: qrResponse.qrcode,
|
|
169
|
+
qrcodeUrl: qrResponse.qrcode_img_content,
|
|
170
|
+
startedAt: Date.now(),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
activeLogins.set(sessionKey, login);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
qrcodeUrl: qrResponse.qrcode_img_content,
|
|
177
|
+
message: "使用微信扫描以下二维码,以完成连接。",
|
|
178
|
+
sessionKey,
|
|
179
|
+
};
|
|
180
|
+
} catch (err) {
|
|
181
|
+
logger.error(`Failed to start Weixin login: ${String(err)}`);
|
|
182
|
+
return {
|
|
183
|
+
message: `Failed to start login: ${String(err)}`,
|
|
184
|
+
sessionKey,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const MAX_QR_REFRESH_COUNT = 3;
|
|
190
|
+
|
|
191
|
+
export async function waitForWeixinLogin(opts: {
|
|
192
|
+
timeoutMs?: number;
|
|
193
|
+
verbose?: boolean;
|
|
194
|
+
sessionKey: string;
|
|
195
|
+
apiBaseUrl: string;
|
|
196
|
+
botType?: string;
|
|
197
|
+
}): Promise<WeixinQrWaitResult> {
|
|
198
|
+
let activeLogin = activeLogins.get(opts.sessionKey);
|
|
199
|
+
|
|
200
|
+
if (!activeLogin) {
|
|
201
|
+
logger.warn(`waitForWeixinLogin: no active login sessionKey=${opts.sessionKey}`);
|
|
202
|
+
return {
|
|
203
|
+
connected: false,
|
|
204
|
+
message: "当前没有进行中的登录,请先发起登录。",
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!isLoginFresh(activeLogin)) {
|
|
209
|
+
logger.warn(`waitForWeixinLogin: login QR expired sessionKey=${opts.sessionKey}`);
|
|
210
|
+
activeLogins.delete(opts.sessionKey);
|
|
211
|
+
return {
|
|
212
|
+
connected: false,
|
|
213
|
+
message: "二维码已过期,请重新生成。",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const timeoutMs = Math.max(opts.timeoutMs ?? 480_000, 1000);
|
|
218
|
+
const deadline = Date.now() + timeoutMs;
|
|
219
|
+
let scannedPrinted = false;
|
|
220
|
+
let qrRefreshCount = 1;
|
|
221
|
+
|
|
222
|
+
logger.info("Starting to poll QR code status...");
|
|
223
|
+
|
|
224
|
+
while (Date.now() < deadline) {
|
|
225
|
+
try {
|
|
226
|
+
const statusResponse = await pollQRStatus(opts.apiBaseUrl, activeLogin.qrcode);
|
|
227
|
+
logger.debug(`pollQRStatus: status=${statusResponse.status} hasBotToken=${Boolean(statusResponse.bot_token)} hasBotId=${Boolean(statusResponse.ilink_bot_id)}`);
|
|
228
|
+
activeLogin.status = statusResponse.status;
|
|
229
|
+
|
|
230
|
+
switch (statusResponse.status) {
|
|
231
|
+
case "wait":
|
|
232
|
+
if (opts.verbose) {
|
|
233
|
+
process.stdout.write(".");
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
case "scaned":
|
|
237
|
+
if (!scannedPrinted) {
|
|
238
|
+
process.stdout.write("\n👀 已扫码,在微信继续操作...\n");
|
|
239
|
+
scannedPrinted = true;
|
|
240
|
+
}
|
|
241
|
+
break;
|
|
242
|
+
case "expired": {
|
|
243
|
+
qrRefreshCount++;
|
|
244
|
+
if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
|
|
245
|
+
logger.warn(
|
|
246
|
+
`waitForWeixinLogin: QR expired ${MAX_QR_REFRESH_COUNT} times, giving up sessionKey=${opts.sessionKey}`,
|
|
247
|
+
);
|
|
248
|
+
activeLogins.delete(opts.sessionKey);
|
|
249
|
+
return {
|
|
250
|
+
connected: false,
|
|
251
|
+
message: "登录超时:二维码多次过期,请重新开始登录流程。",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
process.stdout.write(`\n⏳ 二维码已过期,正在刷新...(${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})\n`);
|
|
256
|
+
logger.info(
|
|
257
|
+
`waitForWeixinLogin: QR expired, refreshing (${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})`,
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const botType = opts.botType || DEFAULT_ILINK_BOT_TYPE;
|
|
262
|
+
const qrResponse = await fetchQRCode(opts.apiBaseUrl, botType);
|
|
263
|
+
activeLogin.qrcode = qrResponse.qrcode;
|
|
264
|
+
activeLogin.qrcodeUrl = qrResponse.qrcode_img_content;
|
|
265
|
+
activeLogin.startedAt = Date.now();
|
|
266
|
+
scannedPrinted = false;
|
|
267
|
+
logger.info(`waitForWeixinLogin: new QR code obtained qrcode=${redactToken(qrResponse.qrcode)}`);
|
|
268
|
+
process.stdout.write(`🔄 新二维码已生成,请重新扫描\n\n`);
|
|
269
|
+
try {
|
|
270
|
+
const qrterm = await import("qrcode-terminal");
|
|
271
|
+
qrterm.default.generate(qrResponse.qrcode_img_content, { small: true });
|
|
272
|
+
} catch {
|
|
273
|
+
process.stdout.write(`QR Code URL: ${qrResponse.qrcode_img_content}\n`);
|
|
274
|
+
}
|
|
275
|
+
} catch (refreshErr) {
|
|
276
|
+
logger.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`);
|
|
277
|
+
activeLogins.delete(opts.sessionKey);
|
|
278
|
+
return {
|
|
279
|
+
connected: false,
|
|
280
|
+
message: `刷新二维码失败: ${String(refreshErr)}`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
case "confirmed": {
|
|
286
|
+
if (!statusResponse.ilink_bot_id) {
|
|
287
|
+
activeLogins.delete(opts.sessionKey);
|
|
288
|
+
logger.error("Login confirmed but ilink_bot_id missing from response");
|
|
289
|
+
return {
|
|
290
|
+
connected: false,
|
|
291
|
+
message: "登录失败:服务器未返回 ilink_bot_id。",
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
activeLogin.botToken = statusResponse.bot_token;
|
|
296
|
+
activeLogins.delete(opts.sessionKey);
|
|
297
|
+
|
|
298
|
+
logger.info(
|
|
299
|
+
`✅ Login confirmed! ilink_bot_id=${statusResponse.ilink_bot_id} ilink_user_id=${redactToken(statusResponse.ilink_user_id)}`,
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
connected: true,
|
|
304
|
+
botToken: statusResponse.bot_token,
|
|
305
|
+
accountId: statusResponse.ilink_bot_id,
|
|
306
|
+
baseUrl: statusResponse.baseurl,
|
|
307
|
+
userId: statusResponse.ilink_user_id,
|
|
308
|
+
message: "✅ 与微信连接成功!",
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
} catch (err) {
|
|
314
|
+
logger.error(`Error polling QR status: ${String(err)}`);
|
|
315
|
+
activeLogins.delete(opts.sessionKey);
|
|
316
|
+
return {
|
|
317
|
+
connected: false,
|
|
318
|
+
message: `Login failed: ${String(err)}`,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
logger.warn(
|
|
324
|
+
`waitForWeixinLogin: timed out waiting for QR scan sessionKey=${opts.sessionKey} timeoutMs=${timeoutMs}`,
|
|
325
|
+
);
|
|
326
|
+
activeLogins.delete(opts.sessionKey);
|
|
327
|
+
return {
|
|
328
|
+
connected: false,
|
|
329
|
+
message: "登录超时,请重试。",
|
|
330
|
+
};
|
|
331
|
+
}
|