@henryxiaoyang/wechat-access-unqclawed 1.0.3 → 1.0.4
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/auth/index.ts +2 -0
- package/auth/wechat-qr-poll.ts +88 -0
- package/index.ts +130 -1
- package/package.json +1 -1
package/auth/index.ts
CHANGED
|
@@ -17,3 +17,5 @@ export { QClawAPI } from "./qclaw-api.js";
|
|
|
17
17
|
export { loadState, saveState, clearState } from "./state-store.js";
|
|
18
18
|
export { performLogin } from "./wechat-login.js";
|
|
19
19
|
export type { PerformLoginOptions } from "./wechat-login.js";
|
|
20
|
+
export { buildAuthUrl, fetchQrUuid, fetchQrImageDataUrl, pollQrStatus } from "./wechat-qr-poll.js";
|
|
21
|
+
export type { QrPollResult } from "./wechat-qr-poll.js";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file wechat-qr-poll.ts
|
|
3
|
+
* @description 微信 QR 扫码登录轮询逻辑
|
|
4
|
+
*
|
|
5
|
+
* 流程:获取 state → 抓取 QR 页面拿 uuid → 长轮询扫码状态 → 拿到 code
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { QClawEnvironment } from "./types.js";
|
|
9
|
+
|
|
10
|
+
/** 构造微信 OAuth2 授权 URL */
|
|
11
|
+
export const buildAuthUrl = (state: string, env: QClawEnvironment): string => {
|
|
12
|
+
const params = new URLSearchParams({
|
|
13
|
+
appid: env.wxAppId,
|
|
14
|
+
redirect_uri: env.wxLoginRedirectUri,
|
|
15
|
+
response_type: "code",
|
|
16
|
+
scope: "snsapi_login",
|
|
17
|
+
state,
|
|
18
|
+
});
|
|
19
|
+
return `https://open.weixin.qq.com/connect/qrconnect?${params.toString()}#wechat_redirect`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** 从微信 QR 登录页面 HTML 中提取 uuid */
|
|
23
|
+
export const fetchQrUuid = async (authUrl: string): Promise<string> => {
|
|
24
|
+
const res = await fetch(authUrl, {
|
|
25
|
+
headers: {
|
|
26
|
+
"User-Agent":
|
|
27
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
28
|
+
},
|
|
29
|
+
signal: AbortSignal.timeout(15_000),
|
|
30
|
+
});
|
|
31
|
+
const html = await res.text();
|
|
32
|
+
const match = html.match(/\/connect\/qrcode\/([a-zA-Z0-9_=-]+)/);
|
|
33
|
+
if (!match?.[1]) {
|
|
34
|
+
throw new Error("无法从微信登录页面提取 QR UUID");
|
|
35
|
+
}
|
|
36
|
+
return match[1];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** 抓取微信 QR 码图片并返回 base64 data URL */
|
|
40
|
+
export const fetchQrImageDataUrl = async (uuid: string): Promise<string> => {
|
|
41
|
+
const url = `https://open.weixin.qq.com/connect/qrcode/${uuid}`;
|
|
42
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
|
|
43
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
44
|
+
const contentType = res.headers.get("content-type") || "image/png";
|
|
45
|
+
return `data:${contentType};base64,${buf.toString("base64")}`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export interface QrPollResult {
|
|
49
|
+
/** waiting=未扫码, scanned=已扫码待确认, confirmed=已确认(含code), expired=过期, error=异常 */
|
|
50
|
+
status: "waiting" | "scanned" | "confirmed" | "expired" | "error";
|
|
51
|
+
code?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 长轮询微信扫码状态
|
|
56
|
+
*
|
|
57
|
+
* 微信返回的 errcode:
|
|
58
|
+
* - 408: 等待扫码(长轮询超时,需重试)
|
|
59
|
+
* - 404: 已扫码,等待用户在手机上确认
|
|
60
|
+
* - 405: 已确认,wx_code 里带授权 code
|
|
61
|
+
* - 403/402: 二维码过期
|
|
62
|
+
*/
|
|
63
|
+
export const pollQrStatus = async (uuid: string): Promise<QrPollResult> => {
|
|
64
|
+
try {
|
|
65
|
+
const url = `https://lp.open.weixin.qq.com/connect/l/qrconnect?uuid=${uuid}&_=${Date.now()}`;
|
|
66
|
+
const res = await fetch(url, {
|
|
67
|
+
headers: {
|
|
68
|
+
"User-Agent":
|
|
69
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
70
|
+
},
|
|
71
|
+
signal: AbortSignal.timeout(30_000),
|
|
72
|
+
});
|
|
73
|
+
const text = await res.text();
|
|
74
|
+
|
|
75
|
+
const errMatch = text.match(/wx_errcode=(\d+)/);
|
|
76
|
+
const codeMatch = text.match(/wx_code='([^']*)'/);
|
|
77
|
+
const errCode = errMatch ? parseInt(errMatch[1], 10) : 0;
|
|
78
|
+
const wxCode = codeMatch?.[1] || "";
|
|
79
|
+
|
|
80
|
+
if (errCode === 408) return { status: "waiting" };
|
|
81
|
+
if (errCode === 404) return { status: "scanned" };
|
|
82
|
+
if (errCode === 405 && wxCode) return { status: "confirmed", code: wxCode };
|
|
83
|
+
if (errCode === 403 || errCode === 402) return { status: "expired" };
|
|
84
|
+
return { status: "error" };
|
|
85
|
+
} catch {
|
|
86
|
+
return { status: "error" };
|
|
87
|
+
}
|
|
88
|
+
};
|
package/index.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
|
3
3
|
import { WechatAccessWebSocketClient, handlePrompt, handleCancel } from "./websocket/index.js";
|
|
4
4
|
// import { handleSimpleWecomWebhook } from "./http/webhook.js";
|
|
5
5
|
import { setWecomRuntime } from "./common/runtime.js";
|
|
6
|
-
import { performLogin, loadState, clearState, getDeviceGuid, getEnvironment } from "./auth/index.js";
|
|
6
|
+
import { performLogin, loadState, clearState, saveState, getDeviceGuid, getEnvironment, QClawAPI, buildAuthUrl, fetchQrUuid, fetchQrImageDataUrl, pollQrStatus } from "./auth/index.js";
|
|
7
|
+
import type { QClawEnvironment, PersistedAuthState } from "./auth/index.js";
|
|
8
|
+
import { nested } from "./auth/utils.js";
|
|
7
9
|
|
|
8
10
|
// 类型定义
|
|
9
11
|
type NormalizedChatType = "direct" | "group" | "channel";
|
|
@@ -11,6 +13,16 @@ type NormalizedChatType = "direct" | "group" | "channel";
|
|
|
11
13
|
// WebSocket 客户端实例(按 accountId 存储)
|
|
12
14
|
const wsClients = new Map<string, WechatAccessWebSocketClient>();
|
|
13
15
|
|
|
16
|
+
// QR 扫码登录中间状态(loginWithQrStart 写入,loginWithQrWait 消费)
|
|
17
|
+
let pendingQrLogin: {
|
|
18
|
+
state: string;
|
|
19
|
+
uuid: string;
|
|
20
|
+
env: QClawEnvironment;
|
|
21
|
+
guid: string;
|
|
22
|
+
bypassInvite: boolean;
|
|
23
|
+
authStatePath?: string;
|
|
24
|
+
} | null = null;
|
|
25
|
+
|
|
14
26
|
// 渠道元数据
|
|
15
27
|
const meta = {
|
|
16
28
|
id: "wechat-access-unqclawed",
|
|
@@ -195,6 +207,123 @@ const tencentAccessPlugin = {
|
|
|
195
207
|
log?.warn(`[wechat-access] stopAccount: 未找到账号 ${accountId} 的客户端`);
|
|
196
208
|
}
|
|
197
209
|
},
|
|
210
|
+
|
|
211
|
+
// QR 扫码登录:生成二维码(openclaw channels login 调用)
|
|
212
|
+
loginWithQrStart: async (_params: { accountId?: string; force?: boolean; timeoutMs?: number; verbose?: boolean }) => {
|
|
213
|
+
try {
|
|
214
|
+
const runtime = getWecomRuntime();
|
|
215
|
+
const cfg = runtime.config.loadConfig();
|
|
216
|
+
const channelCfg = cfg?.channels?.["wechat-access-unqclawed"];
|
|
217
|
+
|
|
218
|
+
const envName = channelCfg?.environment ? String(channelCfg.environment) : "production";
|
|
219
|
+
const bypassInvite = channelCfg?.bypassInvite === true;
|
|
220
|
+
const authStatePath = channelCfg?.authStatePath ? String(channelCfg.authStatePath) : undefined;
|
|
221
|
+
|
|
222
|
+
const env = getEnvironment(envName);
|
|
223
|
+
const guid = getDeviceGuid();
|
|
224
|
+
|
|
225
|
+
// 1. 获取 OAuth state
|
|
226
|
+
const api = new QClawAPI(env, guid);
|
|
227
|
+
const stateResult = await api.getWxLoginState();
|
|
228
|
+
let state = String(Math.floor(Math.random() * 10000));
|
|
229
|
+
if (stateResult.success) {
|
|
230
|
+
const s = nested(stateResult.data, "state") as string | undefined;
|
|
231
|
+
if (s) state = s;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 2. 构造 auth URL → 抓取 QR 页面拿 uuid
|
|
235
|
+
const authUrl = buildAuthUrl(state, env);
|
|
236
|
+
const uuid = await fetchQrUuid(authUrl);
|
|
237
|
+
|
|
238
|
+
// 3. 拿 QR 图片转 base64 data URL
|
|
239
|
+
const qrDataUrl = await fetchQrImageDataUrl(uuid);
|
|
240
|
+
|
|
241
|
+
// 4. 存中间状态给 loginWithQrWait 用
|
|
242
|
+
pendingQrLogin = { state, uuid, env, guid, bypassInvite, authStatePath };
|
|
243
|
+
|
|
244
|
+
return { qrDataUrl, message: "请用微信扫描二维码登录" };
|
|
245
|
+
} catch (err) {
|
|
246
|
+
return { message: `登录初始化失败: ${err instanceof Error ? err.message : String(err)}` };
|
|
247
|
+
}
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
// QR 扫码登录:轮询扫码状态(openclaw channels login 循环调用)
|
|
251
|
+
loginWithQrWait: async (_params: { accountId?: string; timeoutMs?: number }) => {
|
|
252
|
+
if (!pendingQrLogin) {
|
|
253
|
+
return { connected: false, message: "请先执行 loginWithQrStart" };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const result = await pollQrStatus(pendingQrLogin.uuid);
|
|
258
|
+
|
|
259
|
+
if (result.status === "waiting") {
|
|
260
|
+
return { connected: false, message: "等待扫码..." };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (result.status === "scanned") {
|
|
264
|
+
return { connected: false, message: "已扫码,请在手机上确认..." };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (result.status === "expired") {
|
|
268
|
+
pendingQrLogin = null;
|
|
269
|
+
return { connected: false, message: "二维码已过期,请重新执行 openclaw channels login" };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (result.status === "confirmed" && result.code) {
|
|
273
|
+
const { state, env, guid, authStatePath } = pendingQrLogin;
|
|
274
|
+
const api = new QClawAPI(env, guid);
|
|
275
|
+
|
|
276
|
+
// 用 code 换 token
|
|
277
|
+
const loginResult = await api.wxLogin(result.code, state);
|
|
278
|
+
if (!loginResult.success) {
|
|
279
|
+
pendingQrLogin = null;
|
|
280
|
+
return { connected: false, message: `登录失败: ${loginResult.message ?? "未知错误"}` };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const loginData = loginResult.data as Record<string, unknown>;
|
|
284
|
+
const jwtToken = (loginData.token as string) || "";
|
|
285
|
+
const channelToken = (loginData.openclaw_channel_token as string) || "";
|
|
286
|
+
const userInfo = (loginData.user_info as Record<string, unknown>) || {};
|
|
287
|
+
|
|
288
|
+
// 保存登录态
|
|
289
|
+
const persistedState: PersistedAuthState = {
|
|
290
|
+
jwtToken,
|
|
291
|
+
channelToken,
|
|
292
|
+
apiKey: "",
|
|
293
|
+
guid,
|
|
294
|
+
userInfo,
|
|
295
|
+
savedAt: Date.now(),
|
|
296
|
+
};
|
|
297
|
+
saveState(persistedState, authStatePath);
|
|
298
|
+
|
|
299
|
+
// 创建 API Key(非致命)
|
|
300
|
+
api.jwtToken = jwtToken;
|
|
301
|
+
api.userId = String(userInfo.user_id ?? "");
|
|
302
|
+
try {
|
|
303
|
+
const keyResult = await api.createApiKey();
|
|
304
|
+
if (keyResult.success) {
|
|
305
|
+
const apiKey =
|
|
306
|
+
(nested(keyResult.data, "key") as string) ??
|
|
307
|
+
(nested(keyResult.data, "resp", "data", "key") as string) ??
|
|
308
|
+
"";
|
|
309
|
+
if (apiKey) {
|
|
310
|
+
persistedState.apiKey = apiKey;
|
|
311
|
+
saveState(persistedState, authStatePath);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} catch { /* non-fatal */ }
|
|
315
|
+
|
|
316
|
+
pendingQrLogin = null;
|
|
317
|
+
const nickname = (userInfo.nickname as string) ?? "用户";
|
|
318
|
+
return { connected: true, message: `登录成功! 欢迎 ${nickname},请重启 Gateway 生效。` };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// error 或其他未知状态
|
|
322
|
+
return { connected: false, message: "等待扫码..." };
|
|
323
|
+
} catch (err) {
|
|
324
|
+
return { connected: false, message: `轮询失败: ${err instanceof Error ? err.message : String(err)}` };
|
|
325
|
+
}
|
|
326
|
+
},
|
|
198
327
|
},
|
|
199
328
|
};
|
|
200
329
|
|