@lumachat/lumachat 0.15.3 → 0.15.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/README.md CHANGED
@@ -1,70 +1,52 @@
1
- # LumaChat OpenClaw 通道
1
+ # LumaChat OpenClaw 通道插件
2
2
 
3
- 该目录包含 LumaChat OpenClaw 通道插件实现。
3
+ 该插件用于将 OpenClaw 接入 LumaChat App,当前配对方式为 **OAuth 2.0 Device Flow**(授权码 + 扫码)。
4
4
 
5
- ## 已实现功能
6
- - 完整配对流程(请求配对码+安全码、轮询状态、保存 token 到 `channels.lumachat`,兼容读取 `channels.clawdchat`)。
7
- - 与 LumaChat 后端保持 WebSocket 长连接。
8
- - 入站消息接入(LumaChat -> OpenClaw),按 `request_id` 回传回复。
9
- - CLI 配对入口(`openclaw channels login --channel lumachat`)。
5
+ ## 版本与安装
10
6
 
11
- ## 安装
12
- 先确认 Node.js 版本满足 `>=22.12.0`(`openclaw` npm 包要求):
13
- ```
14
- node -v
15
- ```
7
+ 当前版本:`0.15.4`
16
8
 
17
- 如需启用 pnpm(推荐):
18
- ```
19
- corepack enable
20
- corepack prepare pnpm@latest --activate
9
+ ```bash
10
+ pnpm openclaw plugins install @lumachat/lumachat@0.15.4
11
+ # 或升级
12
+ pnpm openclaw plugins update lumachat
13
+ openclaw gateway restart
21
14
  ```
22
15
 
23
- 安装插件:
24
- ```
25
- openclaw plugins install @lumachat/lumachat@0.15.2
26
- ```
16
+ ## 配对流程(OAuth 设备授权)
27
17
 
28
- ## 更新
29
- ### 0.1.2
30
- - 修复 WebSocket 消息处理时 `cfg` 未定义导致网关崩溃的问题。
18
+ 1. 在 Clawdbot 机器执行:
31
19
 
32
- ### 0.1.3
33
- - 默认后端地址切换为 `https://api-love2.huaizuo2029.cn`(仍可通过配置覆盖)。
20
+ ```bash
21
+ openclaw channels login --channel lumachat
22
+ ```
34
23
 
35
- ### 0.1.4
36
- - 修复配对等待在部分时区立即超时的问题(将过期时间按 UTC 解析)。
24
+ 2. 终端会输出:
25
+ - 二维码(可直接扫码)
26
+ - 6 位授权码(`user_code`,可手动输入)
37
27
 
38
- ### 0.15.0
39
- - 插件对外品牌升级为 LumaChat(npm 包名改为 `@lumachat/lumachat`)。
40
- - 支持返回并展示 6 位安全码(pairing confirm code),与 App 双码配对流程保持一致。
41
- - 补齐 clawdchat 消息目标解析(`chat:<uuid>` / `clawdchat:<uuid>`),避免 `Unknown target` 导致回复丢失。
28
+ 3. 在 LumaChat App 中进入:`设置 -> Clawdbot 配对`
29
+ - 可扫码授权
30
+ - 或手动输入 6 位授权码
42
31
 
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`。
32
+ 4. 授权成功后,插件会自动保存 `channelToken` 并建立连接。
47
33
 
48
- ### 0.15.2
49
- - 发布 `@lumachat/lumachat@0.15.2`,作为当前稳定安装版本。
34
+ ## 常见问题
50
35
 
51
- 升级:
52
- ```
53
- openclaw plugins install @lumachat/lumachat@0.15.2
54
- ```
36
+ - 提示 `授权码已过期`:重新执行 `openclaw channels login --channel lumachat`
37
+ - 提示 `OAuth client_id 无效`:升级插件和后端到最新版本
38
+ - 一直等待授权:确认 App 已登录并在配对页完成授权
55
39
 
56
- ## 配对流程(当前)
57
- 1. 在 Clawdbot 机器上执行:`openclaw channels login --channel lumachat`。
58
- 2. 终端会显示 6 位配对码和 6 位安全码。
59
- 3. 在 LumaChat App 中:设置 -> Clawdbot 配对,先登录账号,再输入配对码和安全码。
60
- 4. 插件会保存通道 token 并自动连接后端。
40
+ ## 可选环境变量
61
41
 
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` 时使用。
42
+ - `LUMACHAT_API_BASE_URL`:后端地址(默认 `https://api-love2.huaizuo2029.cn`)
43
+ - `LUMACHAT_CHANNEL_KEY` / `LUMACHAT_SHARED_KEY`:后端要求 `X-Clawdchat-Channel-Key` 时使用
44
+ - `CLAWDCHAT_API_BASE_URL`:同上(兼容旧变量)
45
+ - `CLAWDCHAT_CHANNEL_KEY` / `CLAWDCHAT_SHARED_KEY`:同上(兼容旧变量)
67
46
 
68
47
  ## 备注
69
- - 配对 API 位于 `/channel/clawdchat/*`。
70
- - 插件使用返回的 token 连接 `/ws/channel/clawdchat`。
48
+
49
+ - 插件 OAuth 相关接口:
50
+ - `POST /oauth/device/code`
51
+ - `POST /oauth/token`
52
+ - 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.4",
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.4",
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": "通过 OAuth 授权码或扫码把 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(`等待授权确认中(授权码 ${session.userCode})...`);
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: `OAuth user code: ${session.userCode}${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,52 @@ 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("二维码生成失败,请改用 6 位授权码手动输入。");
977
+ }
978
+ log(`LumaChat 授权码:${session.userCode}`);
979
+ log("请在 LumaChat App -> 设置 -> Clawdbot 配对 中输入授权码,或直接扫码授权。");
980
+ log(`授权链接:${session.verificationUriComplete}`);
791
981
 
792
- const expiresAtMs = parseUtcTimestamp(pairing.expiresAt);
982
+ const expiresAtMs = parseUtcTimestamp(session.expiresAt);
793
983
  const deadline = expiresAtMs && Number.isFinite(expiresAtMs)
794
984
  ? Math.max(Date.now(), expiresAtMs)
795
985
  : Date.now() + PAIRING_WAIT_TIMEOUT_MS;
796
986
 
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);
987
+ try {
988
+ const tokenResult = await waitForDeviceAuthorization(
989
+ session,
990
+ deadline,
991
+ verbose ? log : undefined,
992
+ );
993
+ if (tokenResult) {
994
+ await persistAuthorizedBinding(tokenResult);
808
995
  log("LumaChat 配对成功。");
809
996
  return;
810
997
  }
811
- if (status.status === "expired") {
812
- throw new Error("配对码已过期,请重新执行登录。");
813
- }
814
- if (verbose) {
815
- log("等待配对确认中...");
816
- }
817
- await sleep(PAIRING_POLL_INTERVAL_MS);
998
+ warn("等待授权超时,请确认已在 App 中完成授权后重试。");
999
+ } finally {
1000
+ deviceAuthSessions.delete(resolvedId);
818
1001
  }
819
-
820
- warn("等待配对超时,请确认已在 App 中输入配对码后重试。");
821
1002
  },
822
1003
  },
823
1004
  };