@henryxiaoyang/wechat-access-unqclawed 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/README.md +81 -0
- package/auth/device-guid.ts +43 -0
- package/auth/environments.ts +29 -0
- package/auth/index.ts +19 -0
- package/auth/qclaw-api.ts +129 -0
- package/auth/state-store.ts +48 -0
- package/auth/types.ts +57 -0
- package/auth/utils.ts +14 -0
- package/auth/wechat-login.ts +238 -0
- package/common/agent-events.ts +49 -0
- package/common/message-context.ts +174 -0
- package/common/runtime.ts +35 -0
- package/http/README.md +138 -0
- package/http/callback-service.ts +73 -0
- package/http/crypto-utils.ts +96 -0
- package/http/http-utils.ts +81 -0
- package/http/index.ts +59 -0
- package/http/message-context.ts +4 -0
- package/http/message-handler.ts +560 -0
- package/http/types.ts +148 -0
- package/http/webhook.ts +278 -0
- package/index.ts +236 -0
- package/openclaw.plugin.json +42 -0
- package/package.json +59 -0
- package/websocket/index.ts +40 -0
- package/websocket/message-adapter.ts +116 -0
- package/websocket/message-handler.ts +612 -0
- package/websocket/types.ts +290 -0
- package/websocket/websocket-client.ts +739 -0
- package/websocket.md +273 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# wechat-access-unqclawed
|
|
2
|
+
|
|
3
|
+
OpenClaw 微信通路插件 — 通过 WeChat OAuth 扫码登录获取 token,连接 AGP WebSocket 网关收发消息。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
openclaw plugins install @henryxiaoyang/wechat-access-unqclawed
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
重启 Gateway 后生效。
|
|
12
|
+
|
|
13
|
+
## 功能
|
|
14
|
+
|
|
15
|
+
- 微信扫码登录(终端二维码 + 浏览器链接)
|
|
16
|
+
- Token 自动持久化,重启免登录
|
|
17
|
+
- AGP 协议 WebSocket 双向通信(流式文本、工具调用)
|
|
18
|
+
- 邀请码验证(可配置跳过)
|
|
19
|
+
- 支持生产/测试环境切换
|
|
20
|
+
|
|
21
|
+
## 配置
|
|
22
|
+
|
|
23
|
+
在 OpenClaw 配置文件的 `channels.wechat-access` 下:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"channels": {
|
|
28
|
+
"wechat-access": {
|
|
29
|
+
"token": "",
|
|
30
|
+
"wsUrl": "",
|
|
31
|
+
"bypassInvite": false,
|
|
32
|
+
"environment": "production"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
| 字段 | 类型 | 说明 |
|
|
39
|
+
|------|------|------|
|
|
40
|
+
| `token` | string | 手动指定 channel token(留空则走扫码登录) |
|
|
41
|
+
| `wsUrl` | string | WebSocket 网关地址(留空使用环境默认值) |
|
|
42
|
+
| `bypassInvite` | boolean | 跳过邀请码验证 |
|
|
43
|
+
| `environment` | string | `production` 或 `test` |
|
|
44
|
+
| `authStatePath` | string | 自定义 token 持久化路径 |
|
|
45
|
+
|
|
46
|
+
## Token 获取策略
|
|
47
|
+
|
|
48
|
+
1. 读取配置中的 `token` — 如果有,直接使用
|
|
49
|
+
2. 读取本地保存的登录态(`~/.openclaw/wechat-access-auth.json`)
|
|
50
|
+
3. 以上都没有 — 启动交互式微信扫码登录
|
|
51
|
+
|
|
52
|
+
## 项目结构
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
index.ts # 插件入口,注册渠道、启停 WebSocket
|
|
56
|
+
auth/
|
|
57
|
+
types.ts # 认证相关类型
|
|
58
|
+
environments.ts # 生产/测试环境配置
|
|
59
|
+
device-guid.ts # 设备 GUID 生成(随机,持久化)
|
|
60
|
+
qclaw-api.ts # QClaw JPRX 网关 API 客户端
|
|
61
|
+
state-store.ts # Token 持久化
|
|
62
|
+
wechat-login.ts # 扫码登录流程编排
|
|
63
|
+
websocket/
|
|
64
|
+
types.ts # AGP 协议类型
|
|
65
|
+
websocket-client.ts # WebSocket 客户端(连接、心跳、重连)
|
|
66
|
+
message-handler.ts # 消息处理(调用 Agent)
|
|
67
|
+
message-adapter.ts # AGP <-> OpenClaw 消息适配
|
|
68
|
+
common/
|
|
69
|
+
runtime.ts # OpenClaw 运行时单例
|
|
70
|
+
agent-events.ts # Agent 事件订阅
|
|
71
|
+
message-context.ts # 消息上下文构建
|
|
72
|
+
http/ # HTTP webhook 通道(备用)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 协议
|
|
76
|
+
|
|
77
|
+
AGP (Agent Gateway Protocol) — 基于 WebSocket Text 帧的 JSON 消息协议,详见 `websocket.md`。
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file device-guid.ts
|
|
3
|
+
* @description 设备唯一标识生成
|
|
4
|
+
*
|
|
5
|
+
* 不使用真实机器码,而是首次运行时随机生成一个 GUID 并持久化到本地文件。
|
|
6
|
+
* 后续启动自动加载,保证同一台机器上 GUID 稳定不变。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { join, dirname } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
|
|
14
|
+
const GUID_FILE = join(homedir(), ".openclaw", "wechat-access-guid");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 获取设备唯一标识
|
|
18
|
+
*
|
|
19
|
+
* 首次运行时随机生成一个 MD5 格式的 GUID 并保存到 ~/.openclaw/wechat-access-guid,
|
|
20
|
+
* 后续启动直接从文件读取,确保稳定。
|
|
21
|
+
*/
|
|
22
|
+
export const getDeviceGuid = (): string => {
|
|
23
|
+
// 尝试从文件加载已有 GUID
|
|
24
|
+
try {
|
|
25
|
+
const existing = readFileSync(GUID_FILE, "utf-8").trim();
|
|
26
|
+
if (existing) return existing;
|
|
27
|
+
} catch {
|
|
28
|
+
// 文件不存在或读取失败,继续生成
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 首次运行:生成随机 GUID(MD5 hex 格式,32 字符)
|
|
32
|
+
const guid = createHash("md5").update(randomUUID()).digest("hex");
|
|
33
|
+
|
|
34
|
+
// 持久化
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(dirname(GUID_FILE), { recursive: true });
|
|
37
|
+
writeFileSync(GUID_FILE, guid, "utf-8");
|
|
38
|
+
} catch {
|
|
39
|
+
// 写入失败不致命,本次仍返回生成的 GUID
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return guid;
|
|
43
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file environments.ts
|
|
3
|
+
* @description QClaw 环境配置(生产/测试)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { QClawEnvironment } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const ENVIRONMENTS: Record<string, QClawEnvironment> = {
|
|
9
|
+
production: {
|
|
10
|
+
jprxGateway: "https://jprx.m.qq.com/",
|
|
11
|
+
qclawBaseUrl: "https://mmgrcalltoken.3g.qq.com/aizone/v1",
|
|
12
|
+
wxLoginRedirectUri: "https://security.guanjia.qq.com/login",
|
|
13
|
+
wechatWsUrl: "wss://mmgrcalltoken.3g.qq.com/agentwss",
|
|
14
|
+
wxAppId: "wx9d11056dd75b7240",
|
|
15
|
+
},
|
|
16
|
+
test: {
|
|
17
|
+
jprxGateway: "https://jprx.sparta.html5.qq.com/",
|
|
18
|
+
qclawBaseUrl: "https://jprx.sparta.html5.qq.com/aizone/v1",
|
|
19
|
+
wxLoginRedirectUri: "https://security-test.guanjia.qq.com/login",
|
|
20
|
+
wechatWsUrl: "wss://jprx.sparta.html5.qq.com/agentwss",
|
|
21
|
+
wxAppId: "wx3dd49afb7e2cf957",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const getEnvironment = (name: string): QClawEnvironment => {
|
|
26
|
+
const env = ENVIRONMENTS[name];
|
|
27
|
+
if (!env) throw new Error(`未知环境: ${name},可选: ${Object.keys(ENVIRONMENTS).join(", ")}`);
|
|
28
|
+
return env;
|
|
29
|
+
};
|
package/auth/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file auth/index.ts
|
|
3
|
+
* @description 认证模块导出
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type {
|
|
7
|
+
QClawEnvironment,
|
|
8
|
+
LoginCredentials,
|
|
9
|
+
PersistedAuthState,
|
|
10
|
+
QClawApiResponse,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
export { TokenExpiredError } from "./types.js";
|
|
13
|
+
|
|
14
|
+
export { getEnvironment } from "./environments.js";
|
|
15
|
+
export { getDeviceGuid } from "./device-guid.js";
|
|
16
|
+
export { QClawAPI } from "./qclaw-api.js";
|
|
17
|
+
export { loadState, saveState, clearState } from "./state-store.js";
|
|
18
|
+
export { performLogin } from "./wechat-login.js";
|
|
19
|
+
export type { PerformLoginOptions } from "./wechat-login.js";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file qclaw-api.ts
|
|
3
|
+
* @description QClaw JPRX 网关 API 客户端
|
|
4
|
+
*
|
|
5
|
+
* 对应 Python demo 的 QClawAPI 类,所有业务接口走 POST {jprxGateway}data/{cmdId}/forward。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { QClawEnvironment, QClawApiResponse } from "./types.js";
|
|
9
|
+
import { TokenExpiredError } from "./types.js";
|
|
10
|
+
import { nested } from "./utils.js";
|
|
11
|
+
|
|
12
|
+
export class QClawAPI {
|
|
13
|
+
private env: QClawEnvironment;
|
|
14
|
+
private guid: string;
|
|
15
|
+
|
|
16
|
+
/** 鉴权 key,登录后可由服务端返回新值 */
|
|
17
|
+
loginKey = "m83qdao0AmE5";
|
|
18
|
+
|
|
19
|
+
jwtToken: string;
|
|
20
|
+
userId = "";
|
|
21
|
+
|
|
22
|
+
constructor(env: QClawEnvironment, guid: string, jwtToken = "") {
|
|
23
|
+
this.env = env;
|
|
24
|
+
this.guid = guid;
|
|
25
|
+
this.jwtToken = jwtToken;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private headers(): Record<string, string> {
|
|
29
|
+
const h: Record<string, string> = {
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
"X-Version": "1",
|
|
32
|
+
"X-Token": this.loginKey,
|
|
33
|
+
"X-Guid": this.guid,
|
|
34
|
+
"X-Account": this.userId || "1",
|
|
35
|
+
"X-Session": "",
|
|
36
|
+
};
|
|
37
|
+
if (this.jwtToken) {
|
|
38
|
+
h["X-OpenClaw-Token"] = this.jwtToken;
|
|
39
|
+
}
|
|
40
|
+
return h;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private async post(path: string, body: Record<string, unknown> = {}): Promise<QClawApiResponse> {
|
|
44
|
+
const url = `${this.env.jprxGateway}${path}`;
|
|
45
|
+
const payload = { ...body, web_version: "1.4.0", web_env: "release" };
|
|
46
|
+
|
|
47
|
+
const res = await fetch(url, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: this.headers(),
|
|
50
|
+
body: JSON.stringify(payload),
|
|
51
|
+
signal: AbortSignal.timeout(30_000),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Token 续期
|
|
55
|
+
const newToken = res.headers.get("X-New-Token");
|
|
56
|
+
if (newToken) this.jwtToken = newToken;
|
|
57
|
+
|
|
58
|
+
const data = (await res.json()) as Record<string, unknown>;
|
|
59
|
+
|
|
60
|
+
const ret = data.ret;
|
|
61
|
+
const commonCode =
|
|
62
|
+
nested(data, "data", "resp", "common", "code") ??
|
|
63
|
+
nested(data, "data", "common", "code") ??
|
|
64
|
+
nested(data, "resp", "common", "code") ??
|
|
65
|
+
nested(data, "common", "code");
|
|
66
|
+
|
|
67
|
+
// Token 过期
|
|
68
|
+
if (commonCode === 21004) {
|
|
69
|
+
throw new TokenExpiredError();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (ret === 0 || commonCode === 0) {
|
|
73
|
+
const respData =
|
|
74
|
+
nested(data, "data", "resp", "data") ??
|
|
75
|
+
nested(data, "data", "data") ??
|
|
76
|
+
data.data ??
|
|
77
|
+
data;
|
|
78
|
+
return { success: true, data: respData as Record<string, unknown> };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const message =
|
|
82
|
+
(nested(data, "data", "common", "message") as string) ??
|
|
83
|
+
(nested(data, "resp", "common", "message") as string) ??
|
|
84
|
+
(nested(data, "common", "message") as string) ??
|
|
85
|
+
"请求失败";
|
|
86
|
+
return { success: false, message, data: data as Record<string, unknown> };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------- 业务 API ----------
|
|
90
|
+
|
|
91
|
+
/** 获取微信登录 state(OAuth2 安全校验) */
|
|
92
|
+
async getWxLoginState(): Promise<QClawApiResponse> {
|
|
93
|
+
return this.post("data/4050/forward", { guid: this.guid });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 用微信授权 code 换取 token */
|
|
97
|
+
async wxLogin(code: string, state: string): Promise<QClawApiResponse> {
|
|
98
|
+
return this.post("data/4026/forward", { guid: this.guid, code, state });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 创建模型 API Key */
|
|
102
|
+
async createApiKey(): Promise<QClawApiResponse> {
|
|
103
|
+
return this.post("data/4055/forward", {});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 获取用户信息 */
|
|
107
|
+
async getUserInfo(): Promise<QClawApiResponse> {
|
|
108
|
+
return this.post("data/4027/forward", {});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 检查邀请码验证状态 */
|
|
112
|
+
async checkInviteCode(userId: string): Promise<QClawApiResponse> {
|
|
113
|
+
return this.post("data/4056/forward", { user_id: userId });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 提交邀请码 */
|
|
117
|
+
async submitInviteCode(userId: string, code: string): Promise<QClawApiResponse> {
|
|
118
|
+
return this.post("data/4057/forward", { user_id: userId, code });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 刷新渠道 token */
|
|
122
|
+
async refreshChannelToken(): Promise<string | null> {
|
|
123
|
+
const result = await this.post("data/4058/forward", {});
|
|
124
|
+
if (result.success) {
|
|
125
|
+
return (result.data as Record<string, unknown>)?.openclaw_channel_token as string ?? null;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file state-store.ts
|
|
3
|
+
* @description 登录态持久化存储
|
|
4
|
+
*
|
|
5
|
+
* 将 token 保存到本地文件,下次启动时自动加载,避免重复扫码。
|
|
6
|
+
* 文件权限设为 0o600,仅当前用户可读写。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from "node:fs";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import type { PersistedAuthState } from "./types.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_STATE_PATH = join(homedir(), ".openclaw", "wechat-access-auth.json");
|
|
15
|
+
|
|
16
|
+
export const getStatePath = (customPath?: string): string =>
|
|
17
|
+
customPath || DEFAULT_STATE_PATH;
|
|
18
|
+
|
|
19
|
+
export const loadState = (customPath?: string): PersistedAuthState | null => {
|
|
20
|
+
const filePath = getStatePath(customPath);
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
23
|
+
return JSON.parse(raw) as PersistedAuthState;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const saveState = (state: PersistedAuthState, customPath?: string): void => {
|
|
30
|
+
const filePath = getStatePath(customPath);
|
|
31
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
32
|
+
writeFileSync(filePath, JSON.stringify(state, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
33
|
+
// 确保已有文件也收紧权限
|
|
34
|
+
try {
|
|
35
|
+
chmodSync(filePath, 0o600);
|
|
36
|
+
} catch {
|
|
37
|
+
// Windows 等平台可能不支持 chmod,忽略
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const clearState = (customPath?: string): void => {
|
|
42
|
+
const filePath = getStatePath(customPath);
|
|
43
|
+
try {
|
|
44
|
+
unlinkSync(filePath);
|
|
45
|
+
} catch {
|
|
46
|
+
// file not found — ignore
|
|
47
|
+
}
|
|
48
|
+
};
|
package/auth/types.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file types.ts
|
|
3
|
+
* @description 微信扫码登录相关类型定义
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** QClaw 环境配置 */
|
|
7
|
+
export interface QClawEnvironment {
|
|
8
|
+
/** JPRX 网关地址 */
|
|
9
|
+
jprxGateway: string;
|
|
10
|
+
/** QClaw 基础 URL (未直接使用,走 JPRX 网关) */
|
|
11
|
+
qclawBaseUrl: string;
|
|
12
|
+
/** 微信登录回调地址 */
|
|
13
|
+
wxLoginRedirectUri: string;
|
|
14
|
+
/** WebSocket 网关地址 */
|
|
15
|
+
wechatWsUrl: string;
|
|
16
|
+
/** 微信开放平台 AppID */
|
|
17
|
+
wxAppId: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 登录凭证 */
|
|
21
|
+
export interface LoginCredentials {
|
|
22
|
+
/** JWT Token (用于 API 鉴权) */
|
|
23
|
+
jwtToken: string;
|
|
24
|
+
/** Channel Token (用于 WebSocket 连接) */
|
|
25
|
+
channelToken: string;
|
|
26
|
+
/** 用户信息 */
|
|
27
|
+
userInfo: Record<string, unknown>;
|
|
28
|
+
/** API Key (用于调用模型) */
|
|
29
|
+
apiKey: string;
|
|
30
|
+
/** 设备 GUID */
|
|
31
|
+
guid: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 持久化的登录态 */
|
|
35
|
+
export interface PersistedAuthState {
|
|
36
|
+
jwtToken: string;
|
|
37
|
+
channelToken: string;
|
|
38
|
+
apiKey: string;
|
|
39
|
+
guid: string;
|
|
40
|
+
userInfo: Record<string, unknown>;
|
|
41
|
+
savedAt: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** QClaw API 通用响应 */
|
|
45
|
+
export interface QClawApiResponse<T = unknown> {
|
|
46
|
+
success: boolean;
|
|
47
|
+
data?: T;
|
|
48
|
+
message?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Token 过期错误 */
|
|
52
|
+
export class TokenExpiredError extends Error {
|
|
53
|
+
constructor(message = "登录已过期,请重新登录") {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "TokenExpiredError";
|
|
56
|
+
}
|
|
57
|
+
}
|
package/auth/utils.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file utils.ts
|
|
3
|
+
* @description 认证模块共享工具函数
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 安全嵌套取值 */
|
|
7
|
+
export const nested = (obj: unknown, ...keys: string[]): unknown => {
|
|
8
|
+
let current = obj;
|
|
9
|
+
for (const key of keys) {
|
|
10
|
+
if (current == null || typeof current !== "object") return undefined;
|
|
11
|
+
current = (current as Record<string, unknown>)[key];
|
|
12
|
+
}
|
|
13
|
+
return current;
|
|
14
|
+
};
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file wechat-login.ts
|
|
3
|
+
* @description 微信扫码登录流程编排
|
|
4
|
+
*
|
|
5
|
+
* 对应 Python demo 的 WeChatLogin 类和 do_login 函数。
|
|
6
|
+
* 流程:获取 state → 生成二维码 → 等待 code → 换 token → (邀请码) → 保存
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createInterface } from "node:readline";
|
|
10
|
+
import type { QClawEnvironment, LoginCredentials, PersistedAuthState } from "./types.js";
|
|
11
|
+
import { QClawAPI } from "./qclaw-api.js";
|
|
12
|
+
import { saveState } from "./state-store.js";
|
|
13
|
+
import { nested } from "./utils.js";
|
|
14
|
+
|
|
15
|
+
/** 构造微信 OAuth2 授权 URL */
|
|
16
|
+
const buildAuthUrl = (state: string, env: QClawEnvironment): string => {
|
|
17
|
+
const params = new URLSearchParams({
|
|
18
|
+
appid: env.wxAppId,
|
|
19
|
+
redirect_uri: env.wxLoginRedirectUri,
|
|
20
|
+
response_type: "code",
|
|
21
|
+
scope: "snsapi_login",
|
|
22
|
+
state,
|
|
23
|
+
});
|
|
24
|
+
return `https://open.weixin.qq.com/connect/qrconnect?${params.toString()}#wechat_redirect`;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** 在终端显示二维码 */
|
|
28
|
+
const displayQrCode = async (url: string): Promise<void> => {
|
|
29
|
+
console.log("\n" + "=".repeat(60));
|
|
30
|
+
console.log(" 请用微信扫描下方二维码登录");
|
|
31
|
+
console.log("=".repeat(60));
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// qrcode-terminal 是 CJS 模块,动态 import
|
|
35
|
+
const qrterm = await import("qrcode-terminal");
|
|
36
|
+
const generate = qrterm.default?.generate ?? qrterm.generate;
|
|
37
|
+
generate(url, { small: true }, (qrcode: string) => {
|
|
38
|
+
console.log(qrcode);
|
|
39
|
+
});
|
|
40
|
+
} catch {
|
|
41
|
+
console.log("\n(未安装 qrcode-terminal,无法在终端显示二维码)");
|
|
42
|
+
console.log("请安装: npm install qrcode-terminal");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
console.log("\n或者在浏览器中打开以下链接:");
|
|
46
|
+
console.log(` ${url}`);
|
|
47
|
+
console.log("=".repeat(60));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** 从 stdin 读取一行 */
|
|
51
|
+
const readLine = (prompt: string): Promise<string> => {
|
|
52
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
rl.question(prompt, (answer) => {
|
|
55
|
+
rl.close();
|
|
56
|
+
resolve(answer.trim());
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 等待用户输入微信授权后重定向 URL 中的 code
|
|
63
|
+
*
|
|
64
|
+
* 接受两种输入:
|
|
65
|
+
* 1. 完整 URL(自动从 query string 或 fragment 提取 code)
|
|
66
|
+
* 2. 裸 code 字符串
|
|
67
|
+
*/
|
|
68
|
+
const waitForAuthCode = async (): Promise<string> => {
|
|
69
|
+
console.log();
|
|
70
|
+
console.log("微信扫码授权后,浏览器会跳转到一个新页面。");
|
|
71
|
+
console.log("请从浏览器地址栏复制完整 URL,或只复制 code 参数值。");
|
|
72
|
+
console.log();
|
|
73
|
+
|
|
74
|
+
const raw = await readLine("请粘贴 URL 或 code: ");
|
|
75
|
+
if (!raw) return "";
|
|
76
|
+
|
|
77
|
+
// 尝试从 URL 中提取 code
|
|
78
|
+
if (raw.includes("code=")) {
|
|
79
|
+
try {
|
|
80
|
+
const url = new URL(raw);
|
|
81
|
+
// 先查 query string
|
|
82
|
+
const code = url.searchParams.get("code");
|
|
83
|
+
if (code) return code;
|
|
84
|
+
// 再查 fragment(微信可能将 code 放在 hash 后面)
|
|
85
|
+
if (url.hash) {
|
|
86
|
+
const fragmentParams = new URLSearchParams(url.hash.replace(/^#/, ""));
|
|
87
|
+
const fCode = fragmentParams.get("code");
|
|
88
|
+
if (fCode) return fCode;
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// URL 解析失败,尝试正则
|
|
92
|
+
}
|
|
93
|
+
const match = raw.match(/[?&#]code=([^&#]+)/);
|
|
94
|
+
if (match?.[1]) return match[1];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 直接就是 code
|
|
98
|
+
return raw;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export interface PerformLoginOptions {
|
|
102
|
+
guid: string;
|
|
103
|
+
env: QClawEnvironment;
|
|
104
|
+
bypassInvite?: boolean;
|
|
105
|
+
/** 自定义 state 文件路径 */
|
|
106
|
+
authStatePath?: string;
|
|
107
|
+
/** 日志函数 */
|
|
108
|
+
log?: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 执行完整的微信扫码登录流程
|
|
113
|
+
*
|
|
114
|
+
* 步骤:
|
|
115
|
+
* 1. 获取 OAuth state
|
|
116
|
+
* 2. 生成二维码并展示
|
|
117
|
+
* 3. 等待用户输入 code
|
|
118
|
+
* 4. 用 code 换 token
|
|
119
|
+
* 5. 创建 API Key(非致命)
|
|
120
|
+
* 6. 邀请码检查(可绕过)
|
|
121
|
+
* 7. 保存登录态
|
|
122
|
+
*/
|
|
123
|
+
export const performLogin = async (options: PerformLoginOptions): Promise<LoginCredentials> => {
|
|
124
|
+
const { guid, env, bypassInvite = false, authStatePath, log } = options;
|
|
125
|
+
const info = (...args: unknown[]) => log?.info?.(...args) ?? console.log(...args);
|
|
126
|
+
const warn = (...args: unknown[]) => log?.warn?.(...args) ?? console.warn(...args);
|
|
127
|
+
|
|
128
|
+
const api = new QClawAPI(env, guid);
|
|
129
|
+
|
|
130
|
+
// 1. 获取 OAuth state
|
|
131
|
+
info("[Login] 步骤 1/5: 获取登录 state...");
|
|
132
|
+
let state = String(Math.floor(Math.random() * 10000)); // 随机兜底
|
|
133
|
+
const stateResult = await api.getWxLoginState();
|
|
134
|
+
if (stateResult.success) {
|
|
135
|
+
const s = nested(stateResult.data, "state") as string | undefined;
|
|
136
|
+
if (s) state = s;
|
|
137
|
+
}
|
|
138
|
+
info(`[Login] state=${state}`);
|
|
139
|
+
|
|
140
|
+
// 2. 生成二维码
|
|
141
|
+
info("[Login] 步骤 2/5: 生成微信登录二维码...");
|
|
142
|
+
const authUrl = buildAuthUrl(state, env);
|
|
143
|
+
await displayQrCode(authUrl);
|
|
144
|
+
|
|
145
|
+
// 3. 等待 code
|
|
146
|
+
info("[Login] 步骤 3/5: 等待微信扫码授权...");
|
|
147
|
+
const code = await waitForAuthCode();
|
|
148
|
+
if (!code) {
|
|
149
|
+
throw new Error("未获取到授权 code");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 4. 用 code 换 token
|
|
153
|
+
info(`[Login] 步骤 4/5: 用授权码登录 (code=${code.substring(0, 10)}...)`);
|
|
154
|
+
const loginResult = await api.wxLogin(code, state);
|
|
155
|
+
if (!loginResult.success) {
|
|
156
|
+
throw new Error(`登录失败: ${loginResult.message ?? "未知错误"}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const loginData = loginResult.data as Record<string, unknown>;
|
|
160
|
+
const jwtToken = (loginData.token as string) || "";
|
|
161
|
+
const channelToken = (loginData.openclaw_channel_token as string) || "";
|
|
162
|
+
const userInfo = (loginData.user_info as Record<string, unknown>) || {};
|
|
163
|
+
|
|
164
|
+
api.jwtToken = jwtToken;
|
|
165
|
+
api.userId = String(userInfo.user_id ?? "");
|
|
166
|
+
// 更新 loginKey(服务端可能返回新值,后续 API 调用需要使用)
|
|
167
|
+
const loginKey = userInfo.loginKey as string | undefined;
|
|
168
|
+
if (loginKey) {
|
|
169
|
+
api.loginKey = loginKey;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
info(`[Login] 登录成功! 用户: ${(userInfo.nickname as string) ?? "unknown"}`);
|
|
173
|
+
|
|
174
|
+
// 5. 创建 API Key(非致命)
|
|
175
|
+
info("[Login] 步骤 5/5: 创建 API Key...");
|
|
176
|
+
let apiKey = "";
|
|
177
|
+
try {
|
|
178
|
+
const keyResult = await api.createApiKey();
|
|
179
|
+
if (keyResult.success) {
|
|
180
|
+
apiKey =
|
|
181
|
+
(nested(keyResult.data, "key") as string) ??
|
|
182
|
+
(nested(keyResult.data, "resp", "data", "key") as string) ??
|
|
183
|
+
"";
|
|
184
|
+
if (apiKey) info(`[Login] API Key: ${apiKey.substring(0, 8)}...`);
|
|
185
|
+
}
|
|
186
|
+
} catch (e) {
|
|
187
|
+
warn(`[Login] 创建 API Key 失败(非致命): ${e}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 邀请码检查
|
|
191
|
+
const userId = String(userInfo.user_id ?? "");
|
|
192
|
+
if (userId && !bypassInvite) {
|
|
193
|
+
try {
|
|
194
|
+
const check = await api.checkInviteCode(userId);
|
|
195
|
+
if (check.success) {
|
|
196
|
+
const verified = nested(check.data, "already_verified");
|
|
197
|
+
if (!verified) {
|
|
198
|
+
info("\n[Login] 需要邀请码验证。");
|
|
199
|
+
const inviteCode = await readLine("请输入邀请码: ");
|
|
200
|
+
if (inviteCode) {
|
|
201
|
+
const submitResult = await api.submitInviteCode(userId, inviteCode);
|
|
202
|
+
if (!submitResult.success) {
|
|
203
|
+
throw new Error(`邀请码验证失败: ${submitResult.message}`);
|
|
204
|
+
}
|
|
205
|
+
info("[Login] 邀请码验证通过!");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch (e) {
|
|
210
|
+
if (e instanceof Error && e.message.includes("邀请码验证失败")) throw e;
|
|
211
|
+
warn(`[Login] 邀请码检查失败(非致命): ${e}`);
|
|
212
|
+
}
|
|
213
|
+
} else if (bypassInvite) {
|
|
214
|
+
info("[Login] 已跳过邀请码验证 (bypassInvite=true)");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 保存登录态
|
|
218
|
+
const credentials: LoginCredentials = {
|
|
219
|
+
jwtToken,
|
|
220
|
+
channelToken,
|
|
221
|
+
userInfo,
|
|
222
|
+
apiKey,
|
|
223
|
+
guid,
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const persistedState: PersistedAuthState = {
|
|
227
|
+
jwtToken,
|
|
228
|
+
channelToken,
|
|
229
|
+
apiKey,
|
|
230
|
+
guid,
|
|
231
|
+
userInfo,
|
|
232
|
+
savedAt: Date.now(),
|
|
233
|
+
};
|
|
234
|
+
saveState(persistedState, authStatePath);
|
|
235
|
+
info("[Login] 登录态已保存");
|
|
236
|
+
|
|
237
|
+
return credentials;
|
|
238
|
+
};
|