@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 +55 -53
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -3
- package/src/index.ts +281 -102
package/README.md
CHANGED
|
@@ -1,70 +1,72 @@
|
|
|
1
|
-
# LumaChat OpenClaw
|
|
1
|
+
# LumaChat OpenClaw 通道插件
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
+
|
|
25
|
+
### 2) 发起登录授权
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pnpm openclaw channels login --channel lumachat
|
|
26
29
|
```
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
-
|
|
31
|
+
预期输出:
|
|
32
|
+
- 终端二维码(ASCII)
|
|
33
|
+
- 仅显示扫码二维码
|
|
31
34
|
|
|
32
|
-
###
|
|
33
|
-
- 默认后端地址切换为 `https://api-love2.huaizuo2029.cn`(仍可通过配置覆盖)。
|
|
35
|
+
### 3) 在 App 内完成授权
|
|
34
36
|
|
|
35
|
-
|
|
36
|
-
-
|
|
37
|
+
打开 LumaChat App:`设置 -> Clawdbot 配对`
|
|
38
|
+
- 直接扫码授权
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
- 插件对外品牌升级为 LumaChat(npm 包名改为 `@lumachat/lumachat`)。
|
|
40
|
-
- 支持返回并展示 6 位安全码(pairing confirm code),与 App 双码配对流程保持一致。
|
|
41
|
-
- 补齐 clawdchat 消息目标解析(`chat:<uuid>` / `clawdchat:<uuid>`),避免 `Unknown target` 导致回复丢失。
|
|
40
|
+
授权完成后,插件会自动保存 `channelToken` 并连接网关。
|
|
42
41
|
|
|
43
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
- `
|
|
66
|
-
- `
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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`
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lumachat/lumachat",
|
|
3
|
-
"version": "0.15.
|
|
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": "
|
|
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
|
|
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
|
|
57
|
+
type DeviceAuthSession = {
|
|
53
58
|
accountId: string;
|
|
54
59
|
baseUrl: string;
|
|
55
60
|
sharedKey?: string;
|
|
56
61
|
gatewayId: string;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
65
|
-
status: "
|
|
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
|
|
206
|
-
|
|
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
|
|
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
|
|
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}/
|
|
306
|
+
const payload = await fetchJson(`${baseUrl}/oauth/device/code`, {
|
|
246
307
|
method: "POST",
|
|
247
308
|
headers,
|
|
248
|
-
body: JSON.stringify({
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
333
|
+
deviceCode,
|
|
334
|
+
userCode,
|
|
335
|
+
verificationUri,
|
|
336
|
+
verificationUriComplete,
|
|
337
|
+
pollIntervalSeconds: normalizePollIntervalSeconds(payload.interval),
|
|
338
|
+
expiresAt,
|
|
256
339
|
};
|
|
257
340
|
};
|
|
258
341
|
|
|
259
|
-
const
|
|
260
|
-
const
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
|
700
|
-
|
|
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
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
715
|
-
const
|
|
904
|
+
deviceAuthSessions.set(resolvedId, session);
|
|
905
|
+
const qrDataUrl = await renderQrDataUrl(session.verificationUriComplete);
|
|
906
|
+
const expires = session.expiresAt ? ` (expires ${session.expiresAt})` : "";
|
|
716
907
|
return {
|
|
717
|
-
|
|
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 =
|
|
914
|
+
const session = deviceAuthSessions.get(resolvedId);
|
|
723
915
|
if (!session) {
|
|
724
|
-
return { connected: false, message: "No
|
|
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
|
-
|
|
728
|
-
const
|
|
729
|
-
if (
|
|
730
|
-
|
|
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
|
|
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
|
|
773
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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(
|
|
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
|
-
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
812
|
-
|
|
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
|
};
|