@lumachat/lumachat 0.15.3 → 0.15.5

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 CHANGED
@@ -1,70 +1,72 @@
1
- # LumaChat OpenClaw 通道
1
+ # LumaChat OpenClaw 通道插件
2
2
 
3
- 该目录包含 LumaChat OpenClaw 通道插件实现。
3
+ 用于把 OpenClaw 接入 LumaChat App。
4
+ 从 `0.15.5` 开始,登录方式升级为 **OAuth 2.0 Device Flow(仅扫码)**,不再使用“配对码 + 安全码”双码流程。
4
5
 
5
- ## 已实现功能
6
- - 完整配对流程(请求配对码+安全码、轮询状态、保存 token 到 `channels.lumachat`,兼容读取 `channels.clawdchat`)。
7
- - 与 LumaChat 后端保持 WebSocket 长连接。
8
- - 入站消息接入(LumaChat -> OpenClaw),按 `request_id` 回传回复。
9
- - CLI 配对入口(`openclaw channels login --channel lumachat`)。
6
+ ## 快速开始(站在 OpenClaw 用户视角)
10
7
 
11
- ## 安装
12
- 先确认 Node.js 版本满足 `>=22.12.0`(`openclaw` npm 包要求):
13
- ```
14
- node -v
15
- ```
8
+ ### 1) 安装或升级插件
16
9
 
17
- 如需启用 pnpm(推荐):
18
- ```
19
- corepack enable
20
- corepack prepare pnpm@latest --activate
10
+ OpenClaw 项目目录执行:
11
+
12
+ ```bash
13
+ pnpm openclaw plugins install @lumachat/lumachat@0.15.5
14
+ # 已安装过则可直接升级
15
+ pnpm openclaw plugins update lumachat
16
+ openclaw gateway restart
21
17
  ```
22
18
 
23
- 安装插件:
19
+ 建议确认当前插件版本:
20
+
21
+ ```bash
22
+ pnpm openclaw plugins list
24
23
  ```
25
- openclaw plugins install @lumachat/lumachat@0.15.2
24
+
25
+ ### 2) 发起登录授权
26
+
27
+ ```bash
28
+ pnpm openclaw channels login --channel lumachat
26
29
  ```
27
30
 
28
- ## 更新
29
- ### 0.1.2
30
- - 修复 WebSocket 消息处理时 `cfg` 未定义导致网关崩溃的问题。
31
+ 预期输出:
32
+ - 终端二维码(ASCII)
33
+ - 仅显示扫码二维码
31
34
 
32
- ### 0.1.3
33
- - 默认后端地址切换为 `https://api-love2.huaizuo2029.cn`(仍可通过配置覆盖)。
35
+ ### 3) 在 App 内完成授权
34
36
 
35
- ### 0.1.4
36
- - 修复配对等待在部分时区立即超时的问题(将过期时间按 UTC 解析)。
37
+ 打开 LumaChat App:`设置 -> Clawdbot 配对`
38
+ - 直接扫码授权
37
39
 
38
- ### 0.15.0
39
- - 插件对外品牌升级为 LumaChat(npm 包名改为 `@lumachat/lumachat`)。
40
- - 支持返回并展示 6 位安全码(pairing confirm code),与 App 双码配对流程保持一致。
41
- - 补齐 clawdchat 消息目标解析(`chat:<uuid>` / `clawdchat:<uuid>`),避免 `Unknown target` 导致回复丢失。
40
+ 授权完成后,插件会自动保存 `channelToken` 并连接网关。
42
41
 
43
- ### 0.15.1
44
- - 修复 `openclaw plugins install @lumachat/lumachat` 后配置校验 `plugin not found: lumachat` 的问题。
45
- - 插件 ID 迁移为 `lumachat`,并保持对旧 ID `clawdchat` 的兼容读取。
46
- - 若本地历史配置里存在 `plugins.entries.clawdchat`,请删除该键并改为 `plugins.entries.lumachat`。
42
+ ## 常用命令
47
43
 
48
- ### 0.15.2
49
- - 发布 `@lumachat/lumachat@0.15.2`,作为当前稳定安装版本。
44
+ ```bash
45
+ # 重新登录(换绑/重绑)
46
+ pnpm openclaw channels logout --channel lumachat
47
+ pnpm openclaw channels login --channel lumachat
50
48
 
51
- 升级:
52
- ```
53
- openclaw plugins install @lumachat/lumachat@0.15.2
49
+ # 查看插件版本
50
+ pnpm openclaw plugins list
54
51
  ```
55
52
 
56
- ## 配对流程(当前)
57
- 1. 在 Clawdbot 机器上执行:`openclaw channels login --channel lumachat`。
58
- 2. 终端会显示 6 位配对码和 6 位安全码。
59
- 3. LumaChat App 中:设置 -> Clawdbot 配对,先登录账号,再输入配对码和安全码。
60
- 4. 插件会保存通道 token 并自动连接后端。
61
-
62
- ## 可选配置
63
- - `LUMACHAT_API_BASE_URL`:覆盖后端地址(默认:`https://api-love2.huaizuo2029.cn`;本地开发可设为 `http://127.0.0.1:8000`)。
64
- - `LUMACHAT_CHANNEL_KEY` / `LUMACHAT_SHARED_KEY`:当后端要求 `X-Clawdchat-Channel-Key` 时使用。
65
- - `CLAWDCHAT_API_BASE_URL`:覆盖后端地址(默认:`https://api-love2.huaizuo2029.cn`;本地开发可设为 `http://127.0.0.1:8000`)。
66
- - `CLAWDCHAT_CHANNEL_KEY` / `CLAWDCHAT_SHARED_KEY`:当后端要求 `X-Clawdchat-Channel-Key` 时使用。
67
-
68
- ## 备注
69
- - 配对 API 位于 `/channel/clawdchat/*`。
70
- - 插件使用返回的 token 连接 `/ws/channel/clawdchat`。
53
+ ## 常见问题
54
+
55
+ - 仍看到“配对码 + 安全码”:当前还在用旧版本,请升级到 `@lumachat/lumachat@0.15.5` 后重启网关。
56
+ - 提示 `授权已过期`:重新执行 `pnpm openclaw channels login --channel lumachat`。
57
+ - 提示 `OAuth client_id 无效`:插件和后端版本不匹配,请同时升级。
58
+ - 一直等待授权:确认 App 已登录账号,并停留在配对页面完成授权。
59
+
60
+ ## 可选环境变量
61
+
62
+ - `LUMACHAT_API_BASE_URL`:后端地址(默认 `https://api-love2.huaizuo2029.cn`)
63
+ - `LUMACHAT_CHANNEL_KEY` / `LUMACHAT_SHARED_KEY`:后端要求 `X-Clawdchat-Channel-Key` 时使用
64
+ - `CLAWDCHAT_API_BASE_URL`:同上(兼容旧变量)
65
+ - `CLAWDCHAT_CHANNEL_KEY` / `CLAWDCHAT_SHARED_KEY`:同上(兼容旧变量)
66
+
67
+ ## 技术备注
68
+
69
+ - OAuth 设备授权接口:
70
+ - `POST /oauth/device/code`
71
+ - `POST /oauth/token`
72
+ - WebSocket 地址:`/ws/channel/clawdchat`
@@ -2,7 +2,7 @@
2
2
  "id": "lumachat",
3
3
  "name": "LumaChat Channel",
4
4
  "description": "LumaChat mobile channel for OpenClaw",
5
- "version": "0.15.3",
5
+ "version": "0.15.5",
6
6
  "channels": [
7
7
  "lumachat",
8
8
  "clawdchat"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumachat/lumachat",
3
- "version": "0.15.3",
3
+ "version": "0.15.5",
4
4
  "description": "LumaChat channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -14,6 +14,7 @@
14
14
  "package.json"
15
15
  ],
16
16
  "dependencies": {
17
+ "qrcode": "^1.5.4",
17
18
  "ws": "^8.18.0"
18
19
  },
19
20
  "openclaw": {
@@ -23,9 +24,9 @@
23
24
  "channel": {
24
25
  "id": "lumachat",
25
26
  "label": "LumaChat",
26
- "selectionLabel": "LumaChat (配对码)",
27
+ "selectionLabel": "LumaChat (扫码授权)",
27
28
  "docsPath": "/channels/lumachat",
28
- "blurb": "通过配对码把 Clawdbot 接入 LumaChat。",
29
+ "blurb": "通过扫码授权把 Clawdbot 接入 LumaChat。",
29
30
  "aliases": [
30
31
  "lumachat",
31
32
  "clawdchat"
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import QRCode from "qrcode";
2
3
  import WebSocket from "ws";
3
4
  import {
4
5
  createReplyPrefixContext,
@@ -15,15 +16,19 @@ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-
15
16
  const HEARTBEAT_INTERVAL_MS = 25_000;
16
17
  const RECONNECT_BASE_MS = 2_000;
17
18
  const RECONNECT_MAX_MS = 20_000;
18
- const PAIRING_POLL_INTERVAL_MS = 2_000;
19
19
  const PAIRING_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
20
+ const DEVICE_AUTH_POLL_DEFAULT_INTERVAL_SECONDS = 5;
21
+ const DEVICE_AUTH_POLL_MIN_INTERVAL_MS = 1_000;
22
+ const DEVICE_AUTH_SLOW_DOWN_ADD_MS = 2_000;
23
+ const OAUTH_DEVICE_CLIENT_ID = "clawdchat-gateway";
24
+ const OAUTH_DEVICE_SCOPE = "channel:bind";
25
+ const OAUTH_DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
20
26
  const CHANNEL_ID = "lumachat";
21
27
  const LEGACY_CHANNEL_ID = "clawdchat";
22
28
 
23
29
  const HEADER_CHANNEL_KEY = "X-Clawdchat-Channel-Key";
24
- const HEADER_PAIRING_SECRET = "X-Pairing-Secret";
25
30
 
26
- const pairingSessions = new Map<string, PairingSession>();
31
+ const deviceAuthSessions = new Map<string, DeviceAuthSession>();
27
32
  const stopControllers = new Map<string, AbortController>();
28
33
 
29
34
  type ClawdchatConfigSection = {
@@ -49,25 +54,35 @@ type ResolvedClawdchatAccount = {
49
54
  pairedAt?: string;
50
55
  };
51
56
 
52
- type PairingSession = {
57
+ type DeviceAuthSession = {
53
58
  accountId: string;
54
59
  baseUrl: string;
55
60
  sharedKey?: string;
56
61
  gatewayId: string;
57
- pairingId: string;
58
- pairingSecret: string;
59
- pairingCode: string;
60
- pairingConfirmCode: string;
62
+ deviceCode: string;
63
+ userCode: string;
64
+ verificationUri: string;
65
+ verificationUriComplete: string;
66
+ pollIntervalSeconds: number;
61
67
  expiresAt?: string;
62
68
  };
63
69
 
64
- type PairingStatus = {
65
- status: "pending" | "confirmed" | "expired";
70
+ type DeviceTokenSuccess = {
71
+ status: "ok";
66
72
  channelToken?: string;
67
73
  bindingId?: string;
68
- expiresAt?: string;
69
74
  };
70
75
 
76
+ type DeviceTokenError = {
77
+ status: "error";
78
+ error: string;
79
+ errorDescription?: string;
80
+ statusCode: number;
81
+ rawBody?: string;
82
+ };
83
+
84
+ type DeviceTokenPollResult = DeviceTokenSuccess | DeviceTokenError;
85
+
71
86
  type InboundPayload = {
72
87
  type?: string;
73
88
  request_id?: string;
@@ -199,13 +214,32 @@ const parseUtcTimestamp = (value: string | undefined) => {
199
214
  return Number.isFinite(ms) ? ms : null;
200
215
  };
201
216
 
217
+ const parseJsonObject = (raw: string): Record<string, unknown> => {
218
+ if (!raw.trim()) {
219
+ return {};
220
+ }
221
+ try {
222
+ const parsed = JSON.parse(raw);
223
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
224
+ return parsed as Record<string, unknown>;
225
+ }
226
+ } catch {
227
+ // ignore
228
+ }
229
+ return {};
230
+ };
231
+
202
232
  const fetchJson = async (url: string, options: RequestInit) => {
203
233
  const response = await fetch(url, options);
234
+ const text = await response.text();
235
+ const payload = parseJsonObject(text);
204
236
  if (!response.ok) {
205
- const text = await response.text();
206
- throw new Error(`Request failed (${response.status}): ${text}`);
237
+ const detail = typeof payload.detail === "string" && payload.detail.trim()
238
+ ? payload.detail.trim()
239
+ : text.trim();
240
+ throw new Error(`Request failed (${response.status}): ${detail || "Unknown error"}`);
207
241
  }
208
- return (await response.json()) as Record<string, unknown>;
242
+ return payload;
209
243
  };
210
244
 
211
245
  const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedClawdchatAccount> => {
@@ -237,38 +271,196 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
237
271
  return { cfg: nextCfg, gatewayId };
238
272
  };
239
273
 
240
- const requestPairing = async (baseUrl: string, sharedKey: string | undefined, gatewayId: string) => {
274
+ const buildVerificationUriComplete = (verificationUri: string, userCode: string, existing?: string) => {
275
+ if (existing?.trim()) {
276
+ return existing.trim();
277
+ }
278
+ try {
279
+ const url = new URL(verificationUri);
280
+ url.searchParams.set("user_code", userCode);
281
+ return url.toString();
282
+ } catch {
283
+ const joiner = verificationUri.includes("?") ? "&" : "?";
284
+ return `${verificationUri}${joiner}user_code=${encodeURIComponent(userCode)}`;
285
+ }
286
+ };
287
+
288
+ const normalizePollIntervalSeconds = (value: unknown): number => {
289
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
290
+ return Math.max(1, Math.floor(value));
291
+ }
292
+ if (typeof value === "string" && value.trim()) {
293
+ const parsed = Number.parseInt(value.trim(), 10);
294
+ if (Number.isFinite(parsed) && parsed > 0) {
295
+ return Math.max(1, parsed);
296
+ }
297
+ }
298
+ return DEVICE_AUTH_POLL_DEFAULT_INTERVAL_SECONDS;
299
+ };
300
+
301
+ const requestDeviceCode = async (baseUrl: string, sharedKey: string | undefined, gatewayId: string) => {
241
302
  const headers: Record<string, string> = { "Content-Type": "application/json" };
242
303
  if (sharedKey) {
243
304
  headers[HEADER_CHANNEL_KEY] = sharedKey;
244
305
  }
245
- const payload = await fetchJson(`${baseUrl}/channel/clawdchat/pairings`, {
306
+ const payload = await fetchJson(`${baseUrl}/oauth/device/code`, {
246
307
  method: "POST",
247
308
  headers,
248
- body: JSON.stringify({ gateway_id: gatewayId }),
309
+ body: JSON.stringify({
310
+ gateway_id: gatewayId,
311
+ client_id: OAUTH_DEVICE_CLIENT_ID,
312
+ scope: OAUTH_DEVICE_SCOPE,
313
+ }),
249
314
  });
315
+
316
+ const deviceCode = normalizeValue(typeof payload.device_code === "string" ? payload.device_code : undefined);
317
+ const userCode = normalizeValue(typeof payload.user_code === "string" ? payload.user_code : undefined);
318
+ const verificationUri = normalizeValue(
319
+ typeof payload.verification_uri === "string" ? payload.verification_uri : undefined,
320
+ );
321
+ const expiresAt = normalizeValue(typeof payload.expires_at === "string" ? payload.expires_at : undefined);
322
+ if (!deviceCode || !userCode || !verificationUri) {
323
+ throw new Error("OAuth device code response is missing required fields.");
324
+ }
325
+
326
+ const verificationUriComplete = buildVerificationUriComplete(
327
+ verificationUri,
328
+ userCode,
329
+ typeof payload.verification_uri_complete === "string" ? payload.verification_uri_complete : undefined,
330
+ );
331
+
250
332
  return {
251
- pairingId: String(payload.pairing_id ?? ""),
252
- pairingCode: String(payload.pairing_code ?? ""),
253
- pairingConfirmCode: String(payload.pairing_confirm_code ?? ""),
254
- pairingSecret: String(payload.pairing_secret ?? ""),
255
- expiresAt: payload.expires_at ? String(payload.expires_at) : undefined,
333
+ deviceCode,
334
+ userCode,
335
+ verificationUri,
336
+ verificationUriComplete,
337
+ pollIntervalSeconds: normalizePollIntervalSeconds(payload.interval),
338
+ expiresAt,
256
339
  };
257
340
  };
258
341
 
259
- const readPairingStatus = async (session: PairingSession): Promise<PairingStatus> => {
260
- const headers: Record<string, string> = {
261
- [HEADER_PAIRING_SECRET]: session.pairingSecret,
342
+ const pollDeviceToken = async (session: DeviceAuthSession): Promise<DeviceTokenPollResult> => {
343
+ const body = new URLSearchParams({
344
+ grant_type: OAUTH_DEVICE_GRANT_TYPE,
345
+ device_code: session.deviceCode,
346
+ client_id: OAUTH_DEVICE_CLIENT_ID,
347
+ });
348
+ const response = await fetch(`${session.baseUrl}/oauth/token`, {
349
+ method: "POST",
350
+ headers: {
351
+ "Content-Type": "application/x-www-form-urlencoded",
352
+ },
353
+ body,
354
+ });
355
+ const rawBody = await response.text();
356
+ const payload = parseJsonObject(rawBody);
357
+
358
+ if (response.ok) {
359
+ const channelToken = normalizeValue(
360
+ typeof payload.channel_token === "string"
361
+ ? payload.channel_token
362
+ : typeof payload.access_token === "string"
363
+ ? payload.access_token
364
+ : undefined,
365
+ );
366
+ return {
367
+ status: "ok",
368
+ channelToken,
369
+ bindingId: normalizeValue(typeof payload.binding_id === "string" ? payload.binding_id : undefined),
370
+ };
371
+ }
372
+
373
+ return {
374
+ status: "error",
375
+ error: normalizeValue(typeof payload.error === "string" ? payload.error : undefined) ?? "unknown_error",
376
+ errorDescription: normalizeValue(
377
+ typeof payload.error_description === "string" ? payload.error_description : undefined,
378
+ ),
379
+ statusCode: response.status,
380
+ rawBody: rawBody.trim() || undefined,
262
381
  };
263
- const payload = await fetchJson(
264
- `${session.baseUrl}/channel/clawdchat/pairings/${session.pairingId}`,
265
- { method: "GET", headers },
266
- );
382
+ };
383
+
384
+ const formatTokenError = (result: DeviceTokenError) => {
385
+ switch (result.error) {
386
+ case "authorization_pending":
387
+ return "authorization_pending";
388
+ case "slow_down":
389
+ return "slow_down";
390
+ case "expired_token":
391
+ return "授权已过期,请重新执行登录。";
392
+ case "invalid_client":
393
+ return "OAuth client_id 无效,请升级插件或检查后端配置。";
394
+ case "invalid_grant":
395
+ return "device_code 无效或已失效,请重新执行登录。";
396
+ default:
397
+ return result.errorDescription
398
+ ? `OAuth 授权失败:${result.errorDescription} (${result.error})`
399
+ : `OAuth 授权失败:${result.error}${result.rawBody ? ` | ${result.rawBody}` : ""}`;
400
+ }
401
+ };
402
+
403
+ const renderQrDataUrl = async (content: string): Promise<string | undefined> => {
404
+ try {
405
+ return await QRCode.toDataURL(content, { margin: 1, width: 360 });
406
+ } catch {
407
+ return undefined;
408
+ }
409
+ };
410
+
411
+ const renderTerminalQr = async (content: string): Promise<string | undefined> => {
412
+ try {
413
+ return await QRCode.toString(content, { type: "terminal", small: true });
414
+ } catch {
415
+ return undefined;
416
+ }
417
+ };
418
+
419
+ const waitForDeviceAuthorization = async (
420
+ session: DeviceAuthSession,
421
+ deadline: number,
422
+ onProgress?: (message: string) => void,
423
+ ) => {
424
+ let pollIntervalMs = Math.max(DEVICE_AUTH_POLL_MIN_INTERVAL_MS, session.pollIntervalSeconds * 1_000);
425
+ while (Date.now() < deadline) {
426
+ const result = await pollDeviceToken(session);
427
+ if (result.status === "ok") {
428
+ return result;
429
+ }
430
+ const message = formatTokenError(result);
431
+ if (message === "authorization_pending") {
432
+ if (onProgress) {
433
+ onProgress("等待扫码授权确认中...");
434
+ }
435
+ } else if (message === "slow_down") {
436
+ pollIntervalMs += DEVICE_AUTH_SLOW_DOWN_ADD_MS;
437
+ if (onProgress) {
438
+ onProgress(`轮询过快,已调整为 ${Math.round(pollIntervalMs / 1000)} 秒间隔。`);
439
+ }
440
+ } else {
441
+ throw new Error(message);
442
+ }
443
+ await sleep(pollIntervalMs);
444
+ }
445
+ return null;
446
+ };
447
+
448
+ const persistAuthorizedBinding = async (tokenResult: DeviceTokenSuccess) => {
449
+ const channelToken = normalizeValue(tokenResult.channelToken);
450
+ if (!channelToken) {
451
+ throw new Error("OAuth token 响应缺少 channel_token/access_token。");
452
+ }
453
+ const latestCfg = api.runtime.config.loadConfig();
454
+ const nextCfg = buildConfigPatch(latestCfg, {
455
+ enabled: true,
456
+ channelToken,
457
+ bindingId: tokenResult.bindingId,
458
+ pairedAt: new Date().toISOString(),
459
+ });
460
+ await api.runtime.config.writeConfigFile(nextCfg);
267
461
  return {
268
- status: (payload.status ?? "pending") as PairingStatus["status"],
269
- channelToken: payload.channel_token ? String(payload.channel_token) : undefined,
270
- bindingId: payload.binding_id ? String(payload.binding_id) : undefined,
271
- expiresAt: payload.expires_at ? String(payload.expires_at) : undefined,
462
+ channelToken,
463
+ bindingId: tokenResult.bindingId,
272
464
  };
273
465
  };
274
466
 
@@ -696,55 +888,47 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
696
888
  const baseUrl = account.baseUrl;
697
889
  const sharedKey = account.sharedKey;
698
890
  const { gatewayId } = await ensureGatewayId(cfg);
699
- const pairing = await requestPairing(baseUrl, sharedKey, gatewayId);
700
- if (!pairing.pairingId || !pairing.pairingCode || !pairing.pairingConfirmCode || !pairing.pairingSecret) {
701
- throw new Error("Failed to request pairing code.");
702
- }
703
- const session: PairingSession = {
891
+ const deviceCode = await requestDeviceCode(baseUrl, sharedKey, gatewayId);
892
+ const session: DeviceAuthSession = {
704
893
  accountId: resolvedId,
705
894
  baseUrl,
706
895
  sharedKey,
707
896
  gatewayId,
708
- pairingId: pairing.pairingId,
709
- pairingCode: pairing.pairingCode,
710
- pairingConfirmCode: pairing.pairingConfirmCode,
711
- pairingSecret: pairing.pairingSecret,
712
- expiresAt: pairing.expiresAt,
897
+ deviceCode: deviceCode.deviceCode,
898
+ userCode: deviceCode.userCode,
899
+ verificationUri: deviceCode.verificationUri,
900
+ verificationUriComplete: deviceCode.verificationUriComplete,
901
+ pollIntervalSeconds: deviceCode.pollIntervalSeconds,
902
+ expiresAt: deviceCode.expiresAt,
713
903
  };
714
- pairingSessions.set(resolvedId, session);
715
- const expires = pairing.expiresAt ? ` (expires ${pairing.expiresAt})` : "";
904
+ deviceAuthSessions.set(resolvedId, session);
905
+ const qrDataUrl = await renderQrDataUrl(session.verificationUriComplete);
906
+ const expires = session.expiresAt ? ` (expires ${session.expiresAt})` : "";
716
907
  return {
717
- message: `Pairing code: ${pairing.pairingCode}, confirm code: ${pairing.pairingConfirmCode}${expires}`,
908
+ qrDataUrl,
909
+ message: `请在 LumaChat App 扫码授权${expires}`,
718
910
  };
719
911
  },
720
912
  loginWithQrWait: async ({ accountId, timeoutMs }) => {
721
913
  const resolvedId = accountId ?? DEFAULT_ACCOUNT_ID;
722
- const session = pairingSessions.get(resolvedId);
914
+ const session = deviceAuthSessions.get(resolvedId);
723
915
  if (!session) {
724
- return { connected: false, message: "No pairing in progress." };
916
+ return { connected: false, message: "No OAuth device authorization in progress." };
725
917
  }
726
918
  const deadline = Date.now() + Math.max(1_000, timeoutMs ?? 60_000);
727
- while (Date.now() < deadline) {
728
- const status = await readPairingStatus(session);
729
- if (status.status === "confirmed" && status.channelToken) {
730
- const cfg = api.runtime.config.loadConfig();
731
- const nextCfg = buildConfigPatch(cfg, {
732
- enabled: true,
733
- channelToken: status.channelToken,
734
- bindingId: status.bindingId,
735
- pairedAt: new Date().toISOString(),
736
- });
737
- await api.runtime.config.writeConfigFile(nextCfg);
738
- pairingSessions.delete(resolvedId);
739
- return { connected: true, message: "LumaChat paired." };
740
- }
741
- if (status.status === "expired") {
742
- pairingSessions.delete(resolvedId);
743
- return { connected: false, message: "Pairing code expired." };
919
+ try {
920
+ const tokenResult = await waitForDeviceAuthorization(session, deadline);
921
+ if (!tokenResult) {
922
+ return { connected: false, message: "Waiting for OAuth authorization confirmation." };
744
923
  }
745
- await sleep(PAIRING_POLL_INTERVAL_MS);
924
+ await persistAuthorizedBinding(tokenResult);
925
+ return { connected: true, message: "LumaChat paired." };
926
+ } catch (error) {
927
+ const message = error instanceof Error ? error.message : String(error);
928
+ return { connected: false, message };
929
+ } finally {
930
+ deviceAuthSessions.delete(resolvedId);
746
931
  }
747
- return { connected: false, message: "Waiting for pairing confirmation." };
748
932
  },
749
933
  logoutAccount: async ({ cfg, accountId }) => {
750
934
  const resolvedId = accountId ?? DEFAULT_ACCOUNT_ID;
@@ -769,55 +953,50 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
769
953
  const baseUrl = account.baseUrl;
770
954
  const sharedKey = account.sharedKey;
771
955
  const { gatewayId } = await ensureGatewayId(cfg);
772
- const pairing = await requestPairing(baseUrl, sharedKey, gatewayId);
773
- if (!pairing.pairingId || !pairing.pairingCode || !pairing.pairingConfirmCode || !pairing.pairingSecret) {
774
- throw new Error("Failed to request pairing code.");
775
- }
776
- log(`LumaChat 配对码:${pairing.pairingCode}`);
777
- log(`LumaChat 安全码:${pairing.pairingConfirmCode}`);
778
- log("请在 LumaChat App -> 设置 -> Clawdbot 配对 中输入配对码与安全码。");
779
-
780
- const session: PairingSession = {
956
+ const deviceCode = await requestDeviceCode(baseUrl, sharedKey, gatewayId);
957
+ const session: DeviceAuthSession = {
781
958
  accountId: resolvedId,
782
959
  baseUrl,
783
960
  sharedKey,
784
961
  gatewayId,
785
- pairingId: pairing.pairingId,
786
- pairingCode: pairing.pairingCode,
787
- pairingConfirmCode: pairing.pairingConfirmCode,
788
- pairingSecret: pairing.pairingSecret,
789
- expiresAt: pairing.expiresAt,
962
+ deviceCode: deviceCode.deviceCode,
963
+ userCode: deviceCode.userCode,
964
+ verificationUri: deviceCode.verificationUri,
965
+ verificationUriComplete: deviceCode.verificationUriComplete,
966
+ pollIntervalSeconds: deviceCode.pollIntervalSeconds,
967
+ expiresAt: deviceCode.expiresAt,
790
968
  };
969
+ deviceAuthSessions.set(resolvedId, session);
970
+
971
+ const terminalQr = await renderTerminalQr(session.verificationUriComplete);
972
+ if (terminalQr?.trim()) {
973
+ log("LumaChat 扫码授权二维码:");
974
+ log(terminalQr);
975
+ } else {
976
+ warn("二维码生成失败,请重新执行登录。");
977
+ }
978
+ log("请在 LumaChat App -> 设置 -> Clawdbot 配对 中扫码授权。");
791
979
 
792
- const expiresAtMs = parseUtcTimestamp(pairing.expiresAt);
980
+ const expiresAtMs = parseUtcTimestamp(session.expiresAt);
793
981
  const deadline = expiresAtMs && Number.isFinite(expiresAtMs)
794
982
  ? Math.max(Date.now(), expiresAtMs)
795
983
  : Date.now() + PAIRING_WAIT_TIMEOUT_MS;
796
984
 
797
- while (Date.now() < deadline) {
798
- const status = await readPairingStatus(session);
799
- if (status.status === "confirmed" && status.channelToken) {
800
- const latestCfg = api.runtime.config.loadConfig();
801
- const nextCfg = buildConfigPatch(latestCfg, {
802
- enabled: true,
803
- channelToken: status.channelToken,
804
- bindingId: status.bindingId,
805
- pairedAt: new Date().toISOString(),
806
- });
807
- await api.runtime.config.writeConfigFile(nextCfg);
985
+ try {
986
+ const tokenResult = await waitForDeviceAuthorization(
987
+ session,
988
+ deadline,
989
+ verbose ? log : undefined,
990
+ );
991
+ if (tokenResult) {
992
+ await persistAuthorizedBinding(tokenResult);
808
993
  log("LumaChat 配对成功。");
809
994
  return;
810
995
  }
811
- if (status.status === "expired") {
812
- throw new Error("配对码已过期,请重新执行登录。");
813
- }
814
- if (verbose) {
815
- log("等待配对确认中...");
816
- }
817
- await sleep(PAIRING_POLL_INTERVAL_MS);
996
+ warn("等待授权超时,请确认已在 App 中完成授权后重试。");
997
+ } finally {
998
+ deviceAuthSessions.delete(resolvedId);
818
999
  }
819
-
820
- warn("等待配对超时,请确认已在 App 中输入配对码后重试。");
821
1000
  },
822
1001
  },
823
1002
  };