@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.
@@ -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> };
@@ -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. 生成企微客服链接,等待设备绑定(非致命,token 已拿到)
214
+ // 7. 设备绑定:生成企微客服链接,用户在微信中打开后才有对话入口
215
215
  try {
216
- const OPEN_KFID = "wkzLlJLAAAfbxEV3ZcS-lHZxkaKmpejQ";
217
- runtime.log("\n[wechat-access] 生成企微客服链接...");
218
- const linkResult = await api.generateContactLink(OPEN_KFID);
219
-
220
- if (!linkResult.success) {
221
- runtime.log(`[wechat-access] 生成链接失败: ${linkResult.message ?? "未知错误"}(跳过设备绑定)`);
222
- } else {
223
- const linkData = linkResult.data as Record<string, unknown>;
224
- const contactUrl =
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(contactUrl, { small: true }, (qrcode: string) => {
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
- runtime.log(`\n或手动打开: ${contactUrl}\n`);
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
- return { connected: true, message: `登录成功! 欢迎 ${nickname},请重启 Gateway 生效。` };
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 或其他未知状态
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryxiaoyang/wechat-access-unqclawed",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "type": "module",
5
5
  "description": "OpenClaw 微信通路插件 — 扫码登录 + AGP WebSocket 双向通信",
6
6
  "author": "HenryXiaoYang",