@henryxiaoyang/wechat-access-unqclawed 1.0.14 → 1.0.16
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/device-bind.ts +151 -0
- package/auth/index.ts +2 -0
- package/auth/qclaw-api.ts +7 -2
- package/auth/wechat-login.ts +14 -0
- package/index.ts +38 -52
- package/package.json +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file device-bind.ts
|
|
3
|
+
* @description 设备绑定流程:生成企微客服链接 → 用户在微信中打开 → 轮询绑定状态
|
|
4
|
+
*
|
|
5
|
+
* 登录拿到 token 后,微信里还看不到对话入口。
|
|
6
|
+
* 必须通过客服链接完成设备绑定,微信中才会出现聊天入口。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { QClawAPI } from "./qclaw-api.js";
|
|
10
|
+
import { nested } from "./utils.js";
|
|
11
|
+
|
|
12
|
+
/** 默认的企微客服 open_kfid */
|
|
13
|
+
const DEFAULT_OPEN_KFID = "wkzLlJLAAAfbxEV3ZcS-lHZxkaKmpejQ";
|
|
14
|
+
|
|
15
|
+
/** 轮询间隔(毫秒) */
|
|
16
|
+
const POLL_INTERVAL_MS = 2000;
|
|
17
|
+
|
|
18
|
+
/** 默认超时(毫秒) */
|
|
19
|
+
const DEFAULT_TIMEOUT_MS = 300_000; // 5 分钟
|
|
20
|
+
|
|
21
|
+
export interface DeviceBindOptions {
|
|
22
|
+
/** 已认证的 QClawAPI 实例 */
|
|
23
|
+
api: QClawAPI;
|
|
24
|
+
/** 企微客服 open_kfid(可选,有默认值) */
|
|
25
|
+
openKfId?: string;
|
|
26
|
+
/** 轮询超时(毫秒) */
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
/** 日志输出 */
|
|
29
|
+
log?: {
|
|
30
|
+
info: (...args: unknown[]) => void;
|
|
31
|
+
warn: (...args: unknown[]) => void;
|
|
32
|
+
error: (...args: unknown[]) => void;
|
|
33
|
+
};
|
|
34
|
+
/** 显示二维码(终端场景用,传入 URL 后在终端渲染 QR) */
|
|
35
|
+
showQr?: (url: string) => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DeviceBindResult {
|
|
39
|
+
/** 是否绑定成功 */
|
|
40
|
+
success: boolean;
|
|
41
|
+
/** 客服链接 URL(即使未成功也可能有值) */
|
|
42
|
+
contactUrl?: string;
|
|
43
|
+
/** 绑定成功时的微信昵称 */
|
|
44
|
+
nickname?: string;
|
|
45
|
+
/** 描述信息 */
|
|
46
|
+
message: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 在终端显示二维码的默认实现
|
|
51
|
+
*/
|
|
52
|
+
async function defaultShowQr(url: string): Promise<void> {
|
|
53
|
+
try {
|
|
54
|
+
const qrterm = await import("qrcode-terminal");
|
|
55
|
+
const generate = qrterm.default?.generate ?? qrterm.generate;
|
|
56
|
+
generate(url, { small: true }, (qrcode: string) => {
|
|
57
|
+
console.log(qrcode);
|
|
58
|
+
});
|
|
59
|
+
} catch {
|
|
60
|
+
// qrcode-terminal 不可用,静默跳过
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 执行设备绑定流程
|
|
66
|
+
*
|
|
67
|
+
* 步骤:
|
|
68
|
+
* 1. 调用 4018 接口生成企微客服链接
|
|
69
|
+
* 2. 展示链接(终端 QR / URL)供用户在微信中打开
|
|
70
|
+
* 3. 轮询 4019 接口等待绑定完成
|
|
71
|
+
*/
|
|
72
|
+
export async function performDeviceBinding(options: DeviceBindOptions): Promise<DeviceBindResult> {
|
|
73
|
+
const {
|
|
74
|
+
api,
|
|
75
|
+
openKfId = DEFAULT_OPEN_KFID,
|
|
76
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
77
|
+
log = { info: console.log, warn: console.warn, error: console.error },
|
|
78
|
+
showQr = defaultShowQr,
|
|
79
|
+
} = options;
|
|
80
|
+
|
|
81
|
+
// 1. 生成企微客服链接
|
|
82
|
+
log.info("[device-bind] 生成企微客服链接...");
|
|
83
|
+
let linkResult;
|
|
84
|
+
try {
|
|
85
|
+
linkResult = await api.generateContactLink(openKfId);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
const msg = `生成客服链接失败: ${e instanceof Error ? e.message : String(e)}`;
|
|
88
|
+
log.warn(`[device-bind] ${msg}`);
|
|
89
|
+
return { success: false, message: msg };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!linkResult.success) {
|
|
93
|
+
const msg = `生成客服链接失败: ${linkResult.message ?? "未知错误"}`;
|
|
94
|
+
log.warn(`[device-bind] ${msg}`);
|
|
95
|
+
return { success: false, message: msg };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const linkData = linkResult.data as Record<string, unknown>;
|
|
99
|
+
const contactUrl =
|
|
100
|
+
(nested(linkData, "url") as string) ||
|
|
101
|
+
(nested(linkData, "data", "url") as string) ||
|
|
102
|
+
(nested(linkData, "resp", "url") as string) ||
|
|
103
|
+
"";
|
|
104
|
+
|
|
105
|
+
if (!contactUrl) {
|
|
106
|
+
const msg = "服务端未返回客服链接 URL";
|
|
107
|
+
log.warn(`[device-bind] ${msg}`);
|
|
108
|
+
return { success: false, message: msg };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 2. 展示链接
|
|
112
|
+
console.log("\n" + "=".repeat(60));
|
|
113
|
+
console.log(" 请用「控制端微信」打开下方链接,完成设备绑定");
|
|
114
|
+
console.log(" 绑定后微信中会出现对话入口");
|
|
115
|
+
console.log("=".repeat(60));
|
|
116
|
+
await showQr(contactUrl);
|
|
117
|
+
console.log(`\n链接: ${contactUrl}\n`);
|
|
118
|
+
|
|
119
|
+
// 3. 轮询绑定状态
|
|
120
|
+
log.info("[device-bind] 等待设备绑定...");
|
|
121
|
+
const deadline = Date.now() + timeoutMs;
|
|
122
|
+
while (Date.now() < deadline) {
|
|
123
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
124
|
+
try {
|
|
125
|
+
const status = await api.queryDeviceByGuid();
|
|
126
|
+
if (status.success) {
|
|
127
|
+
const sd = status.data as Record<string, unknown>;
|
|
128
|
+
const nickname =
|
|
129
|
+
(nested(sd, "nickname") as string) ||
|
|
130
|
+
(nested(sd, "data", "nickname") as string);
|
|
131
|
+
const externalUserId =
|
|
132
|
+
(nested(sd, "external_user_id") as string) ||
|
|
133
|
+
(nested(sd, "data", "external_user_id") as string);
|
|
134
|
+
|
|
135
|
+
if (nickname || externalUserId) {
|
|
136
|
+
const msg = `设备绑定成功!${nickname ? ` 微信昵称: ${nickname}` : ""}`;
|
|
137
|
+
log.info(`[device-bind] ${msg}`);
|
|
138
|
+
return { success: true, contactUrl, nickname: nickname || undefined, message: msg };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
} catch {
|
|
142
|
+
// 轮询失败不中断,继续重试
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
success: false,
|
|
148
|
+
contactUrl,
|
|
149
|
+
message: "设备绑定超时。请确认已在微信中打开上方链接,然后重启 Gateway 重试。",
|
|
150
|
+
};
|
|
151
|
+
}
|
package/auth/index.ts
CHANGED
|
@@ -17,5 +17,7 @@ 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 { performDeviceBinding } from "./device-bind.js";
|
|
21
|
+
export type { DeviceBindOptions, DeviceBindResult } from "./device-bind.js";
|
|
20
22
|
export { buildAuthUrl, fetchQrUuid, fetchQrImageDataUrl, pollQrStatus } from "./wechat-qr-poll.js";
|
|
21
23
|
export type { QrPollResult } from "./wechat-qr-poll.js";
|
package/auth/qclaw-api.ts
CHANGED
|
@@ -70,9 +70,14 @@ export class QClawAPI {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
if (ret === 0 || commonCode === 0) {
|
|
73
|
+
// 匹配 Python 的 or 语义:空对象 {} 视为 falsy 跳过
|
|
74
|
+
const nonEmpty = (v: unknown): unknown =>
|
|
75
|
+
v != null && typeof v === "object" && !Array.isArray(v) && Object.keys(v as Record<string, unknown>).length === 0
|
|
76
|
+
? undefined
|
|
77
|
+
: v;
|
|
73
78
|
const respData =
|
|
74
|
-
nested(data, "data", "resp", "data") ??
|
|
75
|
-
nested(data, "data", "data") ??
|
|
79
|
+
nonEmpty(nested(data, "data", "resp", "data")) ??
|
|
80
|
+
nonEmpty(nested(data, "data", "data")) ??
|
|
76
81
|
data.data ??
|
|
77
82
|
data;
|
|
78
83
|
return { success: true, data: respData as Record<string, unknown> };
|
package/auth/wechat-login.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { QClawEnvironment, LoginCredentials, PersistedAuthState } from "./t
|
|
|
11
11
|
import { QClawAPI } from "./qclaw-api.js";
|
|
12
12
|
import { saveState } from "./state-store.js";
|
|
13
13
|
import { nested } from "./utils.js";
|
|
14
|
+
import { performDeviceBinding } from "./device-bind.js";
|
|
14
15
|
|
|
15
16
|
/** 构造微信 OAuth2 授权 URL */
|
|
16
17
|
const buildAuthUrl = (state: string, env: QClawEnvironment): string => {
|
|
@@ -234,5 +235,18 @@ export const performLogin = async (options: PerformLoginOptions): Promise<LoginC
|
|
|
234
235
|
saveState(persistedState, authStatePath);
|
|
235
236
|
info("[Login] 登录态已保存");
|
|
236
237
|
|
|
238
|
+
// 设备绑定:生成企微客服链接,用户在微信中打开后才有对话入口
|
|
239
|
+
info("[Login] 开始设备绑定...");
|
|
240
|
+
const bindResult = await performDeviceBinding({
|
|
241
|
+
api,
|
|
242
|
+
log: log ?? { info: console.log, warn: console.warn, error: console.error },
|
|
243
|
+
});
|
|
244
|
+
if (bindResult.success) {
|
|
245
|
+
info(`[Login] ${bindResult.message}`);
|
|
246
|
+
} else {
|
|
247
|
+
warn(`[Login] ${bindResult.message}`);
|
|
248
|
+
warn("[Login] 可稍后重新执行登录命令完成绑定。");
|
|
249
|
+
}
|
|
250
|
+
|
|
237
251
|
return credentials;
|
|
238
252
|
};
|
package/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ 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, getWecomRuntime } from "./common/runtime.js";
|
|
6
|
-
import { performLogin, loadState, clearState, saveState, getDeviceGuid, getEnvironment, QClawAPI, TokenExpiredError, buildAuthUrl, fetchQrUuid, fetchQrImageDataUrl, pollQrStatus } from "./auth/index.js";
|
|
6
|
+
import { performLogin, performDeviceBinding, loadState, clearState, saveState, getDeviceGuid, getEnvironment, QClawAPI, TokenExpiredError, buildAuthUrl, fetchQrUuid, fetchQrImageDataUrl, pollQrStatus } from "./auth/index.js";
|
|
7
7
|
import type { QClawEnvironment, PersistedAuthState } from "./auth/index.js";
|
|
8
8
|
import { nested } from "./auth/utils.js";
|
|
9
9
|
|
|
@@ -211,65 +211,29 @@ const tencentAccessPlugin = {
|
|
|
211
211
|
}
|
|
212
212
|
} catch { /* non-fatal */ }
|
|
213
213
|
|
|
214
|
-
// 7.
|
|
214
|
+
// 7. 设备绑定:生成企微客服链接,用户在微信中打开后才有对话入口
|
|
215
215
|
try {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
(nested(linkData, "url") as string) ||
|
|
226
|
-
(nested(linkData, "data", "url") as string) ||
|
|
227
|
-
"";
|
|
228
|
-
|
|
229
|
-
if (!contactUrl) {
|
|
230
|
-
runtime.log("[wechat-access] 返回数据中没有 URL(跳过设备绑定)");
|
|
231
|
-
} else {
|
|
232
|
-
runtime.log("=".repeat(60));
|
|
233
|
-
runtime.log(" 用微信扫描下方二维码,进入客服对话完成设备绑定");
|
|
234
|
-
runtime.log("=".repeat(60));
|
|
235
|
-
|
|
216
|
+
const runtimeLog = {
|
|
217
|
+
info: (...args: unknown[]) => runtime.log(...args),
|
|
218
|
+
warn: (...args: unknown[]) => runtime.log(...args),
|
|
219
|
+
error: (...args: unknown[]) => runtime.log(...args),
|
|
220
|
+
};
|
|
221
|
+
const bindResult = await performDeviceBinding({
|
|
222
|
+
api,
|
|
223
|
+
log: runtimeLog,
|
|
224
|
+
showQr: async (url: string) => {
|
|
236
225
|
try {
|
|
237
226
|
const qrterm = await import("qrcode-terminal");
|
|
238
227
|
const generate = qrterm.default?.generate ?? qrterm.generate;
|
|
239
|
-
generate(
|
|
228
|
+
generate(url, { small: true }, (qrcode: string) => {
|
|
240
229
|
runtime.log("\n" + qrcode);
|
|
241
230
|
});
|
|
242
231
|
} catch {
|
|
243
232
|
runtime.log("(qrcode-terminal 不可用)");
|
|
244
233
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
runtime.log("[wechat-access] 等待微信扫码绑定 (超时 5 分钟)...");
|
|
249
|
-
const bindDeadline = Date.now() + 300_000;
|
|
250
|
-
let deviceBound = false;
|
|
251
|
-
while (Date.now() < bindDeadline) {
|
|
252
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
253
|
-
try {
|
|
254
|
-
const status = await api.queryDeviceByGuid();
|
|
255
|
-
if (status.success) {
|
|
256
|
-
const sd = status.data as Record<string, unknown>;
|
|
257
|
-
const nick =
|
|
258
|
-
(nested(sd, "nickname") as string) ||
|
|
259
|
-
(nested(sd, "data", "nickname") as string);
|
|
260
|
-
if (nick) {
|
|
261
|
-
runtime.log(`[wechat-access] 设备绑定成功! 微信昵称: ${nick}`);
|
|
262
|
-
deviceBound = true;
|
|
263
|
-
break;
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
} catch { /* continue polling */ }
|
|
267
|
-
}
|
|
268
|
-
if (!deviceBound) {
|
|
269
|
-
runtime.log("[wechat-access] 设备绑定超时,可稍后重新执行 channels login");
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
runtime.log(`[wechat-access] ${bindResult.message}`);
|
|
273
237
|
} catch (e) {
|
|
274
238
|
runtime.log(`[wechat-access] 设备绑定失败(非致命): ${e instanceof Error ? e.message : String(e)}`);
|
|
275
239
|
}
|
|
@@ -282,6 +246,7 @@ const tencentAccessPlugin = {
|
|
|
282
246
|
channels["wechat-access-unqclawed"] = {
|
|
283
247
|
...(channels["wechat-access-unqclawed"] ?? {}),
|
|
284
248
|
token: channelToken,
|
|
249
|
+
wsUrl: env.wechatWsUrl,
|
|
285
250
|
};
|
|
286
251
|
const nextCfg: Record<string, unknown> = { ...fullCfg, channels };
|
|
287
252
|
if (apiKey) {
|
|
@@ -608,9 +573,30 @@ const tencentAccessPlugin = {
|
|
|
608
573
|
// 备份到独立文件(兜底)
|
|
609
574
|
saveState({ jwtToken, channelToken, apiKey, guid, userInfo, savedAt: Date.now() }, authStatePath);
|
|
610
575
|
|
|
576
|
+
// 生成设备绑定链接(非致命)
|
|
577
|
+
let bindUrl = "";
|
|
578
|
+
try {
|
|
579
|
+
const OPEN_KFID = "wkzLlJLAAAfbxEV3ZcS-lHZxkaKmpejQ";
|
|
580
|
+
const linkResult = await api.generateContactLink(OPEN_KFID);
|
|
581
|
+
if (linkResult.success) {
|
|
582
|
+
const linkData = linkResult.data as Record<string, unknown>;
|
|
583
|
+
bindUrl =
|
|
584
|
+
(nested(linkData, "url") as string) ||
|
|
585
|
+
(nested(linkData, "data", "url") as string) ||
|
|
586
|
+
"";
|
|
587
|
+
}
|
|
588
|
+
} catch { /* non-fatal */ }
|
|
589
|
+
|
|
611
590
|
pendingQrLogin = null;
|
|
612
591
|
const nickname = (userInfo.nickname as string) ?? "用户";
|
|
613
|
-
|
|
592
|
+
const bindHint = bindUrl
|
|
593
|
+
? `\n\n⚠️ 请在微信中打开以下链接完成设备绑定(绑定后才有对话入口):\n${bindUrl}`
|
|
594
|
+
: "";
|
|
595
|
+
return {
|
|
596
|
+
connected: true,
|
|
597
|
+
bindUrl: bindUrl || undefined,
|
|
598
|
+
message: `登录成功! 欢迎 ${nickname},请重启 Gateway 生效。${bindHint}`,
|
|
599
|
+
};
|
|
614
600
|
}
|
|
615
601
|
|
|
616
602
|
// error 或其他未知状态
|