@lumachat/lumachat 0.15.2 → 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,60 +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.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.0
16
+ ## 配对流程(OAuth 设备授权)
17
+
18
+ 1. Clawdbot 机器执行:
19
+
20
+ ```bash
21
+ openclaw channels login --channel lumachat
26
22
  ```
27
23
 
28
- ## 更新
29
- ### 0.1.2
30
- - 修复 WebSocket 消息处理时 `cfg` 未定义导致网关崩溃的问题。
24
+ 2. 终端会输出:
25
+ - 二维码(可直接扫码)
26
+ - 6 位授权码(`user_code`,可手动输入)
31
27
 
32
- ### 0.1.3
33
- - 默认后端地址切换为 `https://api-love2.huaizuo2029.cn`(仍可通过配置覆盖)。
28
+ 3. 在 LumaChat App 中进入:`设置 -> Clawdbot 配对`
29
+ - 可扫码授权
30
+ - 或手动输入 6 位授权码
34
31
 
35
- ### 0.1.4
36
- - 修复配对等待在部分时区立即超时的问题(将过期时间按 UTC 解析)。
32
+ 4. 授权成功后,插件会自动保存 `channelToken` 并建立连接。
37
33
 
38
- ### 0.15.0
39
- - 插件对外品牌升级为 LumaChat(npm 包名改为 `@lumachat/lumachat`)。
40
- - 支持返回并展示 6 位安全码(pairing confirm code),与 App 双码配对流程保持一致。
41
- - 补齐 clawdchat 消息目标解析(`chat:<uuid>` / `clawdchat:<uuid>`),避免 `Unknown target` 导致回复丢失。
34
+ ## 常见问题
42
35
 
43
- 升级:
44
- ```
45
- openclaw plugins install @lumachat/lumachat@0.15.0
46
- ```
36
+ - 提示 `授权码已过期`:重新执行 `openclaw channels login --channel lumachat`
37
+ - 提示 `OAuth client_id 无效`:升级插件和后端到最新版本
38
+ - 一直等待授权:确认 App 已登录并在配对页完成授权
47
39
 
48
- ## 配对流程(当前)
49
- 1. 在 Clawdbot 机器上执行:`openclaw channels login --channel lumachat`。
50
- 2. 终端会显示 6 位配对码和 6 位安全码。
51
- 3. 在 LumaChat App 中:设置 -> Clawdbot 配对,先登录账号,再输入配对码和安全码。
52
- 4. 插件会保存通道 token 并自动连接后端。
40
+ ## 可选环境变量
53
41
 
54
- ## 可选配置
55
- - `CLAWDCHAT_API_BASE_URL`:覆盖后端地址(默认:`https://api-love2.huaizuo2029.cn`;本地开发可设为 `http://127.0.0.1:8000`)。
56
- - `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`:同上(兼容旧变量)
57
46
 
58
47
  ## 备注
59
- - 配对 API 位于 `/channel/clawdchat/*`。
60
- - 插件使用返回的 token 连接 `/ws/channel/clawdchat`。
48
+
49
+ - 插件 OAuth 相关接口:
50
+ - `POST /oauth/device/code`
51
+ - `POST /oauth/token`
52
+ - WebSocket 连接地址:`/ws/channel/clawdchat`
@@ -1,9 +1,10 @@
1
1
  {
2
- "id": "clawdchat",
2
+ "id": "lumachat",
3
3
  "name": "LumaChat Channel",
4
4
  "description": "LumaChat mobile channel for OpenClaw",
5
- "version": "0.15.2",
5
+ "version": "0.15.4",
6
6
  "channels": [
7
+ "lumachat",
7
8
  "clawdchat"
8
9
  ],
9
10
  "configSchema": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumachat/lumachat",
3
- "version": "0.15.2",
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": {
@@ -21,14 +22,14 @@
21
22
  "./src/index.ts"
22
23
  ],
23
24
  "channel": {
24
- "id": "clawdchat",
25
+ "id": "lumachat",
25
26
  "label": "LumaChat",
26
- "selectionLabel": "LumaChat (配对码)",
27
- "docsPath": "/channels/clawdchat",
28
- "blurb": "通过配对码把 Clawdbot 接入 LumaChat。",
27
+ "selectionLabel": "LumaChat (授权码/扫码)",
28
+ "docsPath": "/channels/lumachat",
29
+ "blurb": "通过 OAuth 授权码或扫码把 Clawdbot 接入 LumaChat。",
29
30
  "aliases": [
30
- "clawdchat",
31
- "lumachat"
31
+ "lumachat",
32
+ "clawdchat"
32
33
  ]
33
34
  },
34
35
  "install": {
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,13 +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";
26
+ const CHANNEL_ID = "lumachat";
27
+ const LEGACY_CHANNEL_ID = "clawdchat";
20
28
 
21
29
  const HEADER_CHANNEL_KEY = "X-Clawdchat-Channel-Key";
22
- const HEADER_PAIRING_SECRET = "X-Pairing-Secret";
23
30
 
24
- const pairingSessions = new Map<string, PairingSession>();
31
+ const deviceAuthSessions = new Map<string, DeviceAuthSession>();
25
32
  const stopControllers = new Map<string, AbortController>();
26
33
 
27
34
  type ClawdchatConfigSection = {
@@ -47,25 +54,35 @@ type ResolvedClawdchatAccount = {
47
54
  pairedAt?: string;
48
55
  };
49
56
 
50
- type PairingSession = {
57
+ type DeviceAuthSession = {
51
58
  accountId: string;
52
59
  baseUrl: string;
53
60
  sharedKey?: string;
54
61
  gatewayId: string;
55
- pairingId: string;
56
- pairingSecret: string;
57
- pairingCode: string;
58
- pairingConfirmCode: string;
62
+ deviceCode: string;
63
+ userCode: string;
64
+ verificationUri: string;
65
+ verificationUriComplete: string;
66
+ pollIntervalSeconds: number;
59
67
  expiresAt?: string;
60
68
  };
61
69
 
62
- type PairingStatus = {
63
- status: "pending" | "confirmed" | "expired";
70
+ type DeviceTokenSuccess = {
71
+ status: "ok";
64
72
  channelToken?: string;
65
73
  bindingId?: string;
66
- expiresAt?: string;
67
74
  };
68
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
+
69
86
  type InboundPayload = {
70
87
  type?: string;
71
88
  request_id?: string;
@@ -83,15 +100,18 @@ const normalizeValue = (value?: string | null): string | undefined => {
83
100
  };
84
101
 
85
102
  const resolveClawdchatSection = (cfg: OpenClawConfig): ClawdchatConfigSection =>
86
- (cfg.channels?.clawdchat ?? {}) as ClawdchatConfigSection;
103
+ (cfg.channels?.lumachat ?? cfg.channels?.clawdchat ?? {}) as ClawdchatConfigSection;
87
104
 
88
105
  const resolveBaseUrl = (cfg: OpenClawConfig): string =>
89
106
  normalizeValue(resolveClawdchatSection(cfg).baseUrl) ??
107
+ normalizeValue(process.env.LUMACHAT_API_BASE_URL) ??
90
108
  normalizeValue(process.env.CLAWDCHAT_API_BASE_URL) ??
91
109
  DEFAULT_BASE_URL;
92
110
 
93
111
  const resolveSharedKey = (cfg: OpenClawConfig): string | undefined =>
94
112
  normalizeValue(resolveClawdchatSection(cfg).sharedKey) ??
113
+ normalizeValue(process.env.LUMACHAT_CHANNEL_KEY) ??
114
+ normalizeValue(process.env.LUMACHAT_SHARED_KEY) ??
95
115
  normalizeValue(process.env.CLAWDCHAT_CHANNEL_KEY) ??
96
116
  normalizeValue(process.env.CLAWDCHAT_SHARED_KEY);
97
117
 
@@ -101,7 +121,7 @@ const buildConfigPatch = (cfg: OpenClawConfig, patch: Partial<ClawdchatConfigSec
101
121
  ...cfg,
102
122
  channels: {
103
123
  ...cfg.channels,
104
- clawdchat: {
124
+ lumachat: {
105
125
  ...current,
106
126
  ...patch,
107
127
  },
@@ -111,14 +131,17 @@ const buildConfigPatch = (cfg: OpenClawConfig, patch: Partial<ClawdchatConfigSec
111
131
 
112
132
  const removeClawdchatFields = (cfg: OpenClawConfig, fields: Array<keyof ClawdchatConfigSection>) => {
113
133
  const current = { ...resolveClawdchatSection(cfg) } as Record<string, unknown>;
134
+ const legacy = { ...(cfg.channels?.clawdchat ?? {}) } as Record<string, unknown>;
114
135
  for (const field of fields) {
115
136
  delete current[field as string];
137
+ delete legacy[field as string];
116
138
  }
117
139
  return {
118
140
  ...cfg,
119
141
  channels: {
120
142
  ...cfg.channels,
121
- clawdchat: current,
143
+ lumachat: current,
144
+ clawdchat: legacy,
122
145
  },
123
146
  } as OpenClawConfig;
124
147
  };
@@ -191,20 +214,39 @@ const parseUtcTimestamp = (value: string | undefined) => {
191
214
  return Number.isFinite(ms) ? ms : null;
192
215
  };
193
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
+
194
232
  const fetchJson = async (url: string, options: RequestInit) => {
195
233
  const response = await fetch(url, options);
234
+ const text = await response.text();
235
+ const payload = parseJsonObject(text);
196
236
  if (!response.ok) {
197
- const text = await response.text();
198
- 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"}`);
199
241
  }
200
- return (await response.json()) as Record<string, unknown>;
242
+ return payload;
201
243
  };
202
244
 
203
245
  const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedClawdchatAccount> => {
204
246
  const resolveAccount = (cfg: OpenClawConfig, accountId?: string | null): ResolvedClawdchatAccount => {
205
247
  const section = resolveClawdchatSection(cfg);
206
248
  const resolvedId = accountId ?? DEFAULT_ACCOUNT_ID;
207
- const name = section.name?.trim() || "Clawdchat";
249
+ const name = section.name?.trim() || "LumaChat";
208
250
  return {
209
251
  accountId: resolvedId,
210
252
  name,
@@ -229,38 +271,196 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
229
271
  return { cfg: nextCfg, gatewayId };
230
272
  };
231
273
 
232
- 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) => {
233
302
  const headers: Record<string, string> = { "Content-Type": "application/json" };
234
303
  if (sharedKey) {
235
304
  headers[HEADER_CHANNEL_KEY] = sharedKey;
236
305
  }
237
- const payload = await fetchJson(`${baseUrl}/channel/clawdchat/pairings`, {
306
+ const payload = await fetchJson(`${baseUrl}/oauth/device/code`, {
238
307
  method: "POST",
239
308
  headers,
240
- 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
+ }),
241
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
+
242
332
  return {
243
- pairingId: String(payload.pairing_id ?? ""),
244
- pairingCode: String(payload.pairing_code ?? ""),
245
- pairingConfirmCode: String(payload.pairing_confirm_code ?? ""),
246
- pairingSecret: String(payload.pairing_secret ?? ""),
247
- expiresAt: payload.expires_at ? String(payload.expires_at) : undefined,
333
+ deviceCode,
334
+ userCode,
335
+ verificationUri,
336
+ verificationUriComplete,
337
+ pollIntervalSeconds: normalizePollIntervalSeconds(payload.interval),
338
+ expiresAt,
248
339
  };
249
340
  };
250
341
 
251
- const readPairingStatus = async (session: PairingSession): Promise<PairingStatus> => {
252
- const headers: Record<string, string> = {
253
- [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,
254
381
  };
255
- const payload = await fetchJson(
256
- `${session.baseUrl}/channel/clawdchat/pairings/${session.pairingId}`,
257
- { method: "GET", headers },
258
- );
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);
259
461
  return {
260
- status: (payload.status ?? "pending") as PairingStatus["status"],
261
- channelToken: payload.channel_token ? String(payload.channel_token) : undefined,
262
- bindingId: payload.binding_id ? String(payload.binding_id) : undefined,
263
- expiresAt: payload.expires_at ? String(payload.expires_at) : undefined,
462
+ channelToken,
463
+ bindingId: tokenResult.bindingId,
264
464
  };
265
465
  };
266
466
 
@@ -299,18 +499,18 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
299
499
  };
300
500
 
301
501
  if (!requestId || !conversationId || !content) {
302
- await respondError("invalid_message", "Invalid clawdchat payload.");
502
+ await respondError("invalid_message", "Invalid lumachat payload.");
303
503
  return;
304
504
  }
305
505
 
306
506
  const timestampMs = payload.timestamp ? Date.parse(payload.timestamp) : Date.now();
307
507
  const route = api.runtime.channel.routing.resolveAgentRoute({
308
508
  cfg,
309
- channel: "clawdchat",
509
+ channel: CHANNEL_ID,
310
510
  peer: { kind: "dm", id: conversationId },
311
511
  });
312
512
 
313
- const senderLabel = `clawdchat:${conversationId}`;
513
+ const senderLabel = `${CHANNEL_ID}:${conversationId}`;
314
514
  const storePath = api.runtime.channel.session.resolveStorePath(cfg.session?.store, {
315
515
  agentId: route.agentId,
316
516
  });
@@ -320,7 +520,7 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
320
520
  });
321
521
  const envelopeOptions = api.runtime.channel.reply.resolveEnvelopeFormatOptions(cfg);
322
522
  const body = api.runtime.channel.reply.formatAgentEnvelope({
323
- channel: "Clawdchat",
523
+ channel: "LumaChat",
324
524
  from: senderLabel,
325
525
  timestamp: timestampMs,
326
526
  previousTimestamp,
@@ -340,13 +540,13 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
340
540
  ConversationLabel: senderLabel,
341
541
  SenderName: senderLabel,
342
542
  SenderId: conversationId,
343
- Provider: "clawdchat" as const,
344
- Surface: "clawdchat" as const,
543
+ Provider: CHANNEL_ID as const,
544
+ Surface: CHANNEL_ID as const,
345
545
  MessageSid: requestId,
346
546
  Timestamp: timestampMs,
347
547
  CommandAuthorized: true,
348
548
  CommandSource: "text" as const,
349
- OriginatingChannel: "clawdchat" as const,
549
+ OriginatingChannel: CHANNEL_ID as const,
350
550
  OriginatingTo: `chat:${conversationId}`,
351
551
  });
352
552
 
@@ -394,7 +594,7 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
394
594
  }
395
595
  },
396
596
  onError: (err) => {
397
- log?.warn?.(`clawdchat reply error: ${String(err)}`);
597
+ log?.warn?.(`lumachat reply error: ${String(err)}`);
398
598
  },
399
599
  });
400
600
 
@@ -420,7 +620,7 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
420
620
  }
421
621
  }
422
622
  } catch (err) {
423
- log?.error?.(`clawdchat dispatch failed: ${String(err)}`);
623
+ log?.error?.(`lumachat dispatch failed: ${String(err)}`);
424
624
  await respondError("assistant_unavailable", "Assistant unavailable.");
425
625
  }
426
626
  };
@@ -466,7 +666,7 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
466
666
  heartbeat = setInterval(() => {
467
667
  sendJson(socket, { type: "heartbeat" }).catch(() => undefined);
468
668
  }, HEARTBEAT_INTERVAL_MS);
469
- log?.info?.("clawdchat channel connected");
669
+ log?.info?.("lumachat channel connected");
470
670
  });
471
671
 
472
672
  socket.on("message", (data) => {
@@ -475,7 +675,7 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
475
675
  try {
476
676
  payload = JSON.parse(text) as InboundPayload;
477
677
  } catch (err) {
478
- log?.warn?.(`clawdchat payload parse error: ${String(err)}`);
678
+ log?.warn?.(`lumachat payload parse error: ${String(err)}`);
479
679
  return;
480
680
  }
481
681
  if (payload.type === "heartbeat") {
@@ -495,7 +695,7 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
495
695
  });
496
696
 
497
697
  socket.on("error", (err) => {
498
- log?.warn?.(`clawdchat socket error: ${String(err)}`);
698
+ log?.warn?.(`lumachat socket error: ${String(err)}`);
499
699
  });
500
700
 
501
701
  socket.on("close", (code, reason) => {
@@ -503,7 +703,7 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
503
703
  signal.removeEventListener("abort", abortHandler);
504
704
  update({ running: false, lastStopAt: new Date().toISOString() });
505
705
  const reasonText = reason?.toString?.() ?? "";
506
- log?.warn?.(`clawdchat channel closed (${code}) ${reasonText}`);
706
+ log?.warn?.(`lumachat channel closed (${code}) ${reasonText}`);
507
707
  resolve();
508
708
  });
509
709
 
@@ -557,14 +757,14 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
557
757
  };
558
758
 
559
759
  return {
560
- id: "clawdchat",
760
+ id: CHANNEL_ID,
561
761
  meta: {
562
- id: "clawdchat",
563
- label: "Clawdchat",
564
- selectionLabel: "Clawdchat (pair code)",
565
- docsPath: "/channels/clawdchat",
566
- blurb: "Pair Clawdchat with a 6-digit code.",
567
- aliases: ["clawdchat"],
762
+ id: CHANNEL_ID,
763
+ label: "LumaChat",
764
+ selectionLabel: "LumaChat (pair code)",
765
+ docsPath: "/channels/lumachat",
766
+ blurb: "Pair LumaChat with a 6-digit code.",
767
+ aliases: [CHANNEL_ID, LEGACY_CHANNEL_ID],
568
768
  },
569
769
  capabilities: {
570
770
  chatTypes: ["direct"],
@@ -580,7 +780,10 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
580
780
  if (!trimmed) return undefined;
581
781
  const lower = trimmed.toLowerCase();
582
782
  if (lower.startsWith("chat:")) return trimmed;
583
- if (lower.startsWith("clawdchat:")) return `chat:${trimmed.slice("clawdchat:".length)}`;
783
+ if (lower.startsWith(`${CHANNEL_ID}:`)) return `chat:${trimmed.slice(`${CHANNEL_ID}:`.length)}`;
784
+ if (lower.startsWith(`${LEGACY_CHANNEL_ID}:`)) {
785
+ return `chat:${trimmed.slice(`${LEGACY_CHANNEL_ID}:`.length)}`;
786
+ }
584
787
  if (UUID_REGEX.test(trimmed)) return `chat:${trimmed}`;
585
788
  return undefined;
586
789
  },
@@ -588,13 +791,20 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
588
791
  looksLikeId: (raw, normalized) => {
589
792
  const lower = raw.trim().toLowerCase();
590
793
  if (!lower) return false;
591
- if (lower.startsWith("chat:") || lower.startsWith("clawdchat:")) return true;
794
+ if (
795
+ lower.startsWith("chat:") ||
796
+ lower.startsWith(`${CHANNEL_ID}:`) ||
797
+ lower.startsWith(`${LEGACY_CHANNEL_ID}:`)
798
+ ) {
799
+ return true;
800
+ }
592
801
  if (UUID_REGEX.test(lower)) return true;
593
802
  return Boolean(normalized && normalized.toLowerCase().startsWith("chat:"));
594
803
  },
595
- hint: "使用 clawdchat 会话 ID(形如 chat:<uuid>)。",
804
+ hint: "使用 lumachat 会话 ID(形如 chat:<uuid>)。",
596
805
  },
597
- formatTargetDisplay: ({ target }) => target.replace(/^chat:/i, "").replace(/^clawdchat:/i, ""),
806
+ formatTargetDisplay: ({ target }) =>
807
+ target.replace(/^chat:/i, "").replace(/^lumachat:/i, "").replace(/^clawdchat:/i, ""),
598
808
  },
599
809
  config: {
600
810
  listAccountIds: () => [DEFAULT_ACCOUNT_ID],
@@ -616,7 +826,7 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
616
826
  outbound: {
617
827
  deliveryMode: "direct",
618
828
  sendText: async () => {
619
- throw new Error("Clawdchat outbound is only supported for inbound replies.");
829
+ throw new Error("LumaChat outbound is only supported for inbound replies.");
620
830
  },
621
831
  },
622
832
  status: {
@@ -632,10 +842,10 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
632
842
  for (const account of accounts) {
633
843
  if (!account.configured) {
634
844
  issues.push({
635
- channel: "clawdchat",
845
+ channel: CHANNEL_ID,
636
846
  accountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
637
847
  kind: "config",
638
- message: "Clawdchat channel not paired",
848
+ message: "LumaChat channel not paired",
639
849
  });
640
850
  }
641
851
  }
@@ -673,60 +883,52 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
673
883
  const cfg = api.runtime.config.loadConfig();
674
884
  const account = resolveAccount(cfg, resolvedId);
675
885
  if (account.channelToken && !force) {
676
- return { message: "Clawdchat already paired." };
886
+ return { message: "LumaChat already paired." };
677
887
  }
678
888
  const baseUrl = account.baseUrl;
679
889
  const sharedKey = account.sharedKey;
680
890
  const { gatewayId } = await ensureGatewayId(cfg);
681
- const pairing = await requestPairing(baseUrl, sharedKey, gatewayId);
682
- if (!pairing.pairingId || !pairing.pairingCode || !pairing.pairingConfirmCode || !pairing.pairingSecret) {
683
- throw new Error("Failed to request pairing code.");
684
- }
685
- const session: PairingSession = {
891
+ const deviceCode = await requestDeviceCode(baseUrl, sharedKey, gatewayId);
892
+ const session: DeviceAuthSession = {
686
893
  accountId: resolvedId,
687
894
  baseUrl,
688
895
  sharedKey,
689
896
  gatewayId,
690
- pairingId: pairing.pairingId,
691
- pairingCode: pairing.pairingCode,
692
- pairingConfirmCode: pairing.pairingConfirmCode,
693
- pairingSecret: pairing.pairingSecret,
694
- 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,
695
903
  };
696
- pairingSessions.set(resolvedId, session);
697
- 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})` : "";
698
907
  return {
699
- message: `Pairing code: ${pairing.pairingCode}, confirm code: ${pairing.pairingConfirmCode}${expires}`,
908
+ qrDataUrl,
909
+ message: `OAuth user code: ${session.userCode}${expires}`,
700
910
  };
701
911
  },
702
912
  loginWithQrWait: async ({ accountId, timeoutMs }) => {
703
913
  const resolvedId = accountId ?? DEFAULT_ACCOUNT_ID;
704
- const session = pairingSessions.get(resolvedId);
914
+ const session = deviceAuthSessions.get(resolvedId);
705
915
  if (!session) {
706
- return { connected: false, message: "No pairing in progress." };
916
+ return { connected: false, message: "No OAuth device authorization in progress." };
707
917
  }
708
918
  const deadline = Date.now() + Math.max(1_000, timeoutMs ?? 60_000);
709
- while (Date.now() < deadline) {
710
- const status = await readPairingStatus(session);
711
- if (status.status === "confirmed" && status.channelToken) {
712
- const cfg = api.runtime.config.loadConfig();
713
- const nextCfg = buildConfigPatch(cfg, {
714
- enabled: true,
715
- channelToken: status.channelToken,
716
- bindingId: status.bindingId,
717
- pairedAt: new Date().toISOString(),
718
- });
719
- await api.runtime.config.writeConfigFile(nextCfg);
720
- pairingSessions.delete(resolvedId);
721
- return { connected: true, message: "Clawdchat paired." };
722
- }
723
- if (status.status === "expired") {
724
- pairingSessions.delete(resolvedId);
725
- 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." };
726
923
  }
727
- 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);
728
931
  }
729
- return { connected: false, message: "Waiting for pairing confirmation." };
730
932
  },
731
933
  logoutAccount: async ({ cfg, accountId }) => {
732
934
  const resolvedId = accountId ?? DEFAULT_ACCOUNT_ID;
@@ -744,62 +946,59 @@ const createClawdchatPlugin = (api: OpenClawPluginApi): ChannelPlugin<ResolvedCl
744
946
  const warn = runtime.error ?? console.warn;
745
947
 
746
948
  if (account.channelToken) {
747
- log("Clawdchat 已配对。若需重新配对,请先执行 `openclaw channels logout --channel clawdchat`。");
949
+ log("LumaChat 已配对。若需重新配对,请先执行 `openclaw channels logout --channel lumachat`。");
748
950
  return;
749
951
  }
750
952
 
751
953
  const baseUrl = account.baseUrl;
752
954
  const sharedKey = account.sharedKey;
753
955
  const { gatewayId } = await ensureGatewayId(cfg);
754
- const pairing = await requestPairing(baseUrl, sharedKey, gatewayId);
755
- if (!pairing.pairingId || !pairing.pairingCode || !pairing.pairingConfirmCode || !pairing.pairingSecret) {
756
- throw new Error("Failed to request pairing code.");
757
- }
758
- log(`Clawdchat 配对码:${pairing.pairingCode}`);
759
- log(`Clawdchat 安全码:${pairing.pairingConfirmCode}`);
760
- log("请在 clawdchat App -> 设置 -> Clawdbot 配对 中输入配对码与安全码。");
761
-
762
- const session: PairingSession = {
956
+ const deviceCode = await requestDeviceCode(baseUrl, sharedKey, gatewayId);
957
+ const session: DeviceAuthSession = {
763
958
  accountId: resolvedId,
764
959
  baseUrl,
765
960
  sharedKey,
766
961
  gatewayId,
767
- pairingId: pairing.pairingId,
768
- pairingCode: pairing.pairingCode,
769
- pairingConfirmCode: pairing.pairingConfirmCode,
770
- pairingSecret: pairing.pairingSecret,
771
- 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,
772
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}`);
773
981
 
774
- const expiresAtMs = parseUtcTimestamp(pairing.expiresAt);
982
+ const expiresAtMs = parseUtcTimestamp(session.expiresAt);
775
983
  const deadline = expiresAtMs && Number.isFinite(expiresAtMs)
776
984
  ? Math.max(Date.now(), expiresAtMs)
777
985
  : Date.now() + PAIRING_WAIT_TIMEOUT_MS;
778
986
 
779
- while (Date.now() < deadline) {
780
- const status = await readPairingStatus(session);
781
- if (status.status === "confirmed" && status.channelToken) {
782
- const latestCfg = api.runtime.config.loadConfig();
783
- const nextCfg = buildConfigPatch(latestCfg, {
784
- enabled: true,
785
- channelToken: status.channelToken,
786
- bindingId: status.bindingId,
787
- pairedAt: new Date().toISOString(),
788
- });
789
- await api.runtime.config.writeConfigFile(nextCfg);
790
- log("Clawdchat 配对成功。");
987
+ try {
988
+ const tokenResult = await waitForDeviceAuthorization(
989
+ session,
990
+ deadline,
991
+ verbose ? log : undefined,
992
+ );
993
+ if (tokenResult) {
994
+ await persistAuthorizedBinding(tokenResult);
995
+ log("LumaChat 配对成功。");
791
996
  return;
792
997
  }
793
- if (status.status === "expired") {
794
- throw new Error("配对码已过期,请重新执行登录。");
795
- }
796
- if (verbose) {
797
- log("等待配对确认中...");
798
- }
799
- await sleep(PAIRING_POLL_INTERVAL_MS);
998
+ warn("等待授权超时,请确认已在 App 中完成授权后重试。");
999
+ } finally {
1000
+ deviceAuthSessions.delete(resolvedId);
800
1001
  }
801
-
802
- warn("等待配对超时,请确认已在 App 中输入配对码后重试。");
803
1002
  },
804
1003
  },
805
1004
  };