@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 +35 -53
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -3
- package/src/index.ts +283 -102
package/README.md
CHANGED
|
@@ -1,70 +1,52 @@
|
|
|
1
|
-
# LumaChat OpenClaw
|
|
1
|
+
# LumaChat OpenClaw 通道插件
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
33
|
-
|
|
20
|
+
```bash
|
|
21
|
+
openclaw channels login --channel lumachat
|
|
22
|
+
```
|
|
34
23
|
|
|
35
|
-
|
|
36
|
-
-
|
|
24
|
+
2. 终端会输出:
|
|
25
|
+
- 二维码(可直接扫码)
|
|
26
|
+
- 6 位授权码(`user_code`,可手动输入)
|
|
37
27
|
|
|
38
|
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
- 补齐 clawdchat 消息目标解析(`chat:<uuid>` / `clawdchat:<uuid>`),避免 `Unknown target` 导致回复丢失。
|
|
28
|
+
3. 在 LumaChat App 中进入:`设置 -> Clawdbot 配对`
|
|
29
|
+
- 可扫码授权
|
|
30
|
+
- 或手动输入 6 位授权码
|
|
42
31
|
|
|
43
|
-
|
|
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
|
-
|
|
49
|
-
- 发布 `@lumachat/lumachat@0.15.2`,作为当前稳定安装版本。
|
|
34
|
+
## 常见问题
|
|
50
35
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
- `
|
|
64
|
-
- `
|
|
65
|
-
- `
|
|
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
|
-
|
|
70
|
-
-
|
|
48
|
+
|
|
49
|
+
- 插件 OAuth 相关接口:
|
|
50
|
+
- `POST /oauth/device/code`
|
|
51
|
+
- `POST /oauth/token`
|
|
52
|
+
- 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.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": "
|
|
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
|
|
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(`等待授权确认中(授权码 ${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
|
-
|
|
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: `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 =
|
|
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,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
|
|
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("二维码生成失败,请改用 6 位授权码手动输入。");
|
|
977
|
+
}
|
|
978
|
+
log(`LumaChat 授权码:${session.userCode}`);
|
|
979
|
+
log("请在 LumaChat App -> 设置 -> Clawdbot 配对 中输入授权码,或直接扫码授权。");
|
|
980
|
+
log(`授权链接:${session.verificationUriComplete}`);
|
|
791
981
|
|
|
792
|
-
const expiresAtMs = parseUtcTimestamp(
|
|
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
|
-
|
|
798
|
-
const
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
-
|
|
812
|
-
|
|
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
|
};
|