@coclaw/openclaw-coclaw 0.6.1 → 0.7.0
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 +5 -2
- package/index.js +67 -7
- package/openclaw.plugin.json +0 -1
- package/package.json +4 -1
- package/src/channel-plugin.js +1 -1
- package/src/cli-registrar.js +81 -69
- package/src/cli.js +1 -1
- package/src/common/bot-binding.js +47 -26
- package/src/common/errors.js +1 -1
- package/src/common/messages.js +4 -7
- package/src/config.js +31 -29
- package/src/realtime-bridge.js +56 -7
- package/src/webrtc-peer.js +192 -0
package/README.md
CHANGED
|
@@ -72,7 +72,8 @@ openclaw coclaw unbind [--server <url>]
|
|
|
72
72
|
openclaw coclaw enroll [--server <url>]
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
- bind/unbind
|
|
75
|
+
- bind/unbind/enroll 均为瘦 CLI,通过 gateway RPC(`coclaw.bind`/`coclaw.unbind`/`coclaw.enroll`)在 gateway 内执行,由 gateway 内部管理 bridge 生命周期。若 gateway 未运行,CLI 会自动尝试重启一次再重试;若仍不可用,操作失败。
|
|
76
|
+
- unbind 是强制操作:server 不可达时操作失败(不清理本地 config,避免产生孤儿 bot)。server 返回 401/404/410 视为 bot 已不存在,允许继续。
|
|
76
77
|
- enroll 由 OpenClaw 侧主动发起,生成认领码和链接供用户点击完成绑定。已绑定时需先 unbind 再发起。
|
|
77
78
|
|
|
78
79
|
### 方式二:IM 渠道命令
|
|
@@ -87,13 +88,15 @@ openclaw coclaw enroll [--server <url>]
|
|
|
87
88
|
|
|
88
89
|
需要 gateway 运行中。
|
|
89
90
|
|
|
90
|
-
### 方式三:独立 CLI
|
|
91
|
+
### 方式三:独立 CLI(遗留)
|
|
91
92
|
|
|
92
93
|
```bash
|
|
93
94
|
node ~/.openclaw/extensions/coclaw/src/cli.js bind <binding-code> --server <url>
|
|
94
95
|
node ~/.openclaw/extensions/coclaw/src/cli.js unbind --server <url>
|
|
95
96
|
```
|
|
96
97
|
|
|
98
|
+
> 注意:独立 CLI 不走 gateway RPC,直接在 CLI 进程中执行 bind/unbind 并通过 `coclaw.refreshBridge`/`coclaw.stopBridge` 通知 gateway。此路径不具备瘦 CLI 的架构保证(所有 config 操作在同一进程内)。推荐使用方式一。
|
|
99
|
+
|
|
97
100
|
## 配置存储
|
|
98
101
|
|
|
99
102
|
绑定信息存储在 `~/.openclaw/coclaw/bindings.json`(通过 `resolveStateDir()` + channel ID 组合路径),**不存储在 `openclaw.json` 中**。
|
package/index.js
CHANGED
|
@@ -149,6 +149,70 @@ const plugin = {
|
|
|
149
149
|
}
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
+
// --- bind/unbind 共享逻辑(RPC handler + 斜杠命令共用) ---
|
|
153
|
+
|
|
154
|
+
async function doBind({ code, serverUrl }) {
|
|
155
|
+
await stopRealtimeBridge();
|
|
156
|
+
let result;
|
|
157
|
+
try {
|
|
158
|
+
result = await bindBot({
|
|
159
|
+
code,
|
|
160
|
+
serverUrl: serverUrl ?? api.pluginConfig?.serverUrl,
|
|
161
|
+
});
|
|
162
|
+
} catch (err) {
|
|
163
|
+
// bind 失败时恢复 bridge(best-effort,不覆盖原始错误)
|
|
164
|
+
await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig }).catch(() => {});
|
|
165
|
+
throw err;
|
|
166
|
+
}
|
|
167
|
+
// bind 已持久化,restart 失败不影响结果
|
|
168
|
+
await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig }).catch((err) => {
|
|
169
|
+
logger.warn?.(`[coclaw] bridge restart failed after bind: ${err?.message ?? err}`);
|
|
170
|
+
});
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function doUnbind({ serverUrl }) {
|
|
175
|
+
const result = await unbindBot({
|
|
176
|
+
serverUrl: serverUrl ?? api.pluginConfig?.serverUrl,
|
|
177
|
+
});
|
|
178
|
+
await stopRealtimeBridge();
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
api.registerGatewayMethod('coclaw.bind', async ({ params, respond }) => {
|
|
183
|
+
try {
|
|
184
|
+
const code = params?.code;
|
|
185
|
+
if (!code) {
|
|
186
|
+
respondInvalid(respond, 'code is required');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const result = await doBind({
|
|
190
|
+
code,
|
|
191
|
+
serverUrl: params?.serverUrl,
|
|
192
|
+
});
|
|
193
|
+
respond(true, {
|
|
194
|
+
status: {
|
|
195
|
+
botId: result.botId,
|
|
196
|
+
rebound: result.rebound,
|
|
197
|
+
previousBotId: result.previousBotId,
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
respondError(respond, err);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
api.registerGatewayMethod('coclaw.unbind', async ({ params, respond }) => {
|
|
207
|
+
try {
|
|
208
|
+
const result = await doUnbind({ serverUrl: params?.serverUrl });
|
|
209
|
+
respond(true, { status: { botId: result.botId } });
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
respondError(respond, err);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
152
216
|
// enroll 并发控制:同一时刻只允许一个活跃 enroll
|
|
153
217
|
let activeEnrollAbort = null;
|
|
154
218
|
|
|
@@ -429,13 +493,10 @@ const plugin = {
|
|
|
429
493
|
|
|
430
494
|
try {
|
|
431
495
|
if (action === 'bind') {
|
|
432
|
-
|
|
433
|
-
const serverUrl = options.server ?? api.pluginConfig?.serverUrl;
|
|
434
|
-
const result = await bindBot({
|
|
496
|
+
const result = await doBind({
|
|
435
497
|
code: positionals[0],
|
|
436
|
-
serverUrl,
|
|
498
|
+
serverUrl: options.server,
|
|
437
499
|
});
|
|
438
|
-
await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig });
|
|
439
500
|
return { text: bindOk(result) };
|
|
440
501
|
}
|
|
441
502
|
|
|
@@ -483,8 +544,7 @@ const plugin = {
|
|
|
483
544
|
}
|
|
484
545
|
|
|
485
546
|
if (action === 'unbind') {
|
|
486
|
-
const result = await
|
|
487
|
-
await stopRealtimeBridge();
|
|
547
|
+
const result = await doUnbind({ serverUrl: options.server });
|
|
488
548
|
return { text: unbindOk(result) };
|
|
489
549
|
}
|
|
490
550
|
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coclaw/openclaw-coclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"description": "OpenClaw CoClaw channel plugin for remote chat",
|
|
@@ -63,6 +63,9 @@
|
|
|
63
63
|
"release:check": "bash ./scripts/release-check.sh",
|
|
64
64
|
"release:versions": "npm view @coclaw/openclaw-coclaw versions --json --registry=https://registry.npmjs.org/ && npm view @coclaw/openclaw-coclaw versions --json"
|
|
65
65
|
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"werift": "^0.19.0"
|
|
68
|
+
},
|
|
66
69
|
"devDependencies": {
|
|
67
70
|
"c8": "^10.1.3",
|
|
68
71
|
"eslint": "^9.39.2"
|
package/src/channel-plugin.js
CHANGED
package/src/cli-registrar.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
import { bindBot, unbindBot } from './common/bot-binding.js';
|
|
2
1
|
import { resolveErrorMessage } from './common/errors.js';
|
|
3
2
|
import { callGatewayMethod } from './common/gateway-notify.js';
|
|
4
3
|
import {
|
|
5
4
|
notBound, bindOk, unbindOk,
|
|
6
|
-
gatewayNotified, gatewayNotifyFailed,
|
|
7
5
|
claimCodeCreated,
|
|
8
6
|
} from './common/messages.js';
|
|
9
7
|
|
|
@@ -43,55 +41,82 @@ async function restartGatewayProcess(spawnFn) {
|
|
|
43
41
|
}
|
|
44
42
|
/* c8 ignore stop */
|
|
45
43
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
// bind/unbind/enroll 的 RPC 超时(覆盖默认 10s)
|
|
45
|
+
// 卡点是 gateway ↔ server 的网络通信,bind 最多两次(先解绑再绑定)
|
|
46
|
+
const RPC_TIMEOUT_MS = 30_000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 通用 RPC 调用:gateway 不可用时重启重试
|
|
50
|
+
*/
|
|
51
|
+
async function callWithRetry(method, deps, rpcOpts) {
|
|
52
|
+
const callRpc = () => callGatewayMethod(method, deps.spawn, rpcOpts);
|
|
53
|
+
|
|
54
|
+
let result = await callRpc();
|
|
55
|
+
|
|
56
|
+
if (isGatewayUnavailable(result)) {
|
|
57
|
+
const restartFn = deps.restartGateway ?? restartGatewayProcess;
|
|
58
|
+
try {
|
|
59
|
+
await restartFn(deps.spawn);
|
|
60
|
+
} catch {
|
|
61
|
+
// 重启失败,仍然尝试再次 RPC
|
|
62
|
+
}
|
|
63
|
+
result = await callRpc();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return result;
|
|
50
67
|
}
|
|
51
68
|
|
|
52
69
|
/**
|
|
53
|
-
*
|
|
70
|
+
* 通用 RPC 错误输出
|
|
71
|
+
*/
|
|
72
|
+
function handleRpcError(result, fallbackMsg) {
|
|
73
|
+
if (isGatewayUnavailable(result)) {
|
|
74
|
+
console.error('Error: Could not reach gateway. Ensure OpenClaw gateway is running.');
|
|
75
|
+
console.error(' Try: openclaw gateway start');
|
|
76
|
+
} else {
|
|
77
|
+
console.error(`Error: ${extractRpcErrorMessage(result.message) || fallbackMsg}`);
|
|
78
|
+
}
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 注册 `openclaw coclaw bind/unbind/enroll` CLI 子命令
|
|
84
|
+
* bind/unbind/enroll 均为瘦 CLI,通过 gateway RPC 执行
|
|
54
85
|
* @param {object} ctx - OpenClaw CLI 注册上下文
|
|
55
86
|
* @param {import('commander').Command} ctx.program - Commander.js Command 实例
|
|
56
|
-
* @param {object} ctx.config - OpenClaw 配置
|
|
57
87
|
* @param {object} ctx.logger - 日志实例
|
|
58
88
|
* @param {object} [deps] - 可注入依赖(测试用)
|
|
59
89
|
*/
|
|
60
|
-
export function registerCoclawCli({ program,
|
|
61
|
-
const notifyGateway = async (method) => {
|
|
62
|
-
const action = method.endsWith('refreshBridge') ? 'refresh' : 'stop';
|
|
63
|
-
try {
|
|
64
|
-
const result = await callGatewayMethod(method, deps.spawn);
|
|
65
|
-
if (result.ok) {
|
|
66
|
-
logger.info?.(`[coclaw] ${gatewayNotified(action)}`);
|
|
67
|
-
} else {
|
|
68
|
-
logger.warn?.(`[coclaw] ${gatewayNotifyFailed()}`);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
/* c8 ignore next 3 -- callGatewayMethod 已内部兜底,此处纯防御 */
|
|
72
|
-
catch {
|
|
73
|
-
logger.warn?.(`[coclaw] ${gatewayNotifyFailed()}`);
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
|
|
90
|
+
export function registerCoclawCli({ program, logger }, deps = {}) {
|
|
77
91
|
const coclaw = program
|
|
78
92
|
.command('coclaw')
|
|
79
93
|
.description('CoClaw bind/unbind commands');
|
|
80
94
|
|
|
81
95
|
coclaw
|
|
82
96
|
.command('bind <code>')
|
|
83
|
-
.description('Bind this
|
|
97
|
+
.description('Bind this Claw to CoClaw')
|
|
84
98
|
.option('--server <url>', 'CoClaw server URL')
|
|
85
99
|
.action(async (code, opts) => {
|
|
86
100
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
101
|
+
const params = { code };
|
|
102
|
+
if (opts?.server) params.serverUrl = opts.server;
|
|
103
|
+
const result = await callWithRetry('coclaw.bind', deps, { params, timeoutMs: RPC_TIMEOUT_MS });
|
|
104
|
+
|
|
105
|
+
if (!result.ok) {
|
|
106
|
+
if (result.message && /NOT_BOUND|UNBIND_FAILED/.test(result.message)) {
|
|
107
|
+
console.error(`Error: ${extractRpcErrorMessage(result.message) || 'bind failed'}`);
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
handleRpcError(result, 'bind failed');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const data = result.status;
|
|
116
|
+
console.log(bindOk(data));
|
|
117
|
+
}
|
|
118
|
+
/* c8 ignore next 4 -- callGatewayMethod 不会抛异常,纯防御 */
|
|
119
|
+
catch (err) {
|
|
95
120
|
console.error(`Error: ${resolveErrorMessage(err)}`);
|
|
96
121
|
process.exitCode = 1;
|
|
97
122
|
}
|
|
@@ -99,36 +124,16 @@ export function registerCoclawCli({ program, config, logger }, deps = {}) {
|
|
|
99
124
|
|
|
100
125
|
coclaw
|
|
101
126
|
.command('enroll')
|
|
102
|
-
.description('Enroll this
|
|
127
|
+
.description('Enroll this Claw with CoClaw (generate a claim code for the user)')
|
|
103
128
|
.option('--server <url>', 'CoClaw server URL')
|
|
104
129
|
.action(async (opts) => {
|
|
105
130
|
try {
|
|
106
|
-
const rpcOpts =
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
let result = await callRpc();
|
|
110
|
-
|
|
111
|
-
// 仅在 gateway 不可用时重启重试,业务错误不重启
|
|
112
|
-
if (isGatewayUnavailable(result)) {
|
|
113
|
-
logger.info?.('[coclaw] enroll RPC failed, attempting gateway restart...');
|
|
114
|
-
const restartFn = deps.restartGateway ?? restartGatewayProcess;
|
|
115
|
-
try {
|
|
116
|
-
await restartFn(deps.spawn);
|
|
117
|
-
} catch {
|
|
118
|
-
// 重启失败,仍然尝试再次 RPC
|
|
119
|
-
}
|
|
120
|
-
result = await callRpc();
|
|
121
|
-
}
|
|
131
|
+
const rpcOpts = { timeoutMs: RPC_TIMEOUT_MS };
|
|
132
|
+
if (opts?.server) rpcOpts.params = { serverUrl: opts.server };
|
|
133
|
+
const result = await callWithRetry('coclaw.enroll', deps, rpcOpts);
|
|
122
134
|
|
|
123
135
|
if (!result.ok) {
|
|
124
|
-
|
|
125
|
-
console.error('Error: Could not reach gateway. Ensure OpenClaw gateway is running.');
|
|
126
|
-
console.error(' Try: openclaw gateway start');
|
|
127
|
-
} else {
|
|
128
|
-
// 业务错误(如已绑定):输出 gateway 返回的错误信息
|
|
129
|
-
console.error(`Error: ${extractRpcErrorMessage(result.message) || 'enroll failed'}`);
|
|
130
|
-
}
|
|
131
|
-
process.exitCode = 1;
|
|
136
|
+
handleRpcError(result, 'enroll failed');
|
|
132
137
|
return;
|
|
133
138
|
}
|
|
134
139
|
|
|
@@ -154,24 +159,31 @@ export function registerCoclawCli({ program, config, logger }, deps = {}) {
|
|
|
154
159
|
|
|
155
160
|
coclaw
|
|
156
161
|
.command('unbind')
|
|
157
|
-
.description('Unbind this
|
|
162
|
+
.description('Unbind this Claw from CoClaw')
|
|
158
163
|
.option('--server <url>', 'CoClaw server URL')
|
|
159
164
|
.action(async (opts) => {
|
|
160
165
|
try {
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
const rpcOpts = { timeoutMs: RPC_TIMEOUT_MS };
|
|
167
|
+
if (opts?.server) rpcOpts.params = { serverUrl: opts.server };
|
|
168
|
+
const result = await callWithRetry('coclaw.unbind', deps, rpcOpts);
|
|
169
|
+
|
|
170
|
+
if (!result.ok) {
|
|
171
|
+
if (result.message && /NOT_BOUND/.test(result.message)) {
|
|
172
|
+
console.error(notBound());
|
|
173
|
+
} else {
|
|
174
|
+
handleRpcError(result, 'unbind failed');
|
|
175
|
+
}
|
|
168
176
|
process.exitCode = 1;
|
|
169
177
|
return;
|
|
170
178
|
}
|
|
171
|
-
|
|
179
|
+
|
|
180
|
+
const data = result.status;
|
|
181
|
+
console.log(unbindOk(data));
|
|
182
|
+
}
|
|
183
|
+
/* c8 ignore next 4 -- callGatewayMethod 不会抛异常,纯防御 */
|
|
184
|
+
catch (err) {
|
|
172
185
|
console.error(`Error: ${resolveErrorMessage(err)}`);
|
|
173
186
|
process.exitCode = 1;
|
|
174
187
|
}
|
|
175
|
-
/* c8 ignore stop */
|
|
176
188
|
});
|
|
177
189
|
}
|
package/src/cli.js
CHANGED
|
@@ -92,7 +92,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
|
92
92
|
console.error(notBound());
|
|
93
93
|
return 1;
|
|
94
94
|
}
|
|
95
|
-
/* c8 ignore start -- 防御性兜底,unbindBot
|
|
95
|
+
/* c8 ignore start -- 防御性兜底,unbindBot 主要抛 NOT_BOUND,也可能抛网络/HTTP 错误 */
|
|
96
96
|
throw err;
|
|
97
97
|
}
|
|
98
98
|
/* c8 ignore stop */
|
|
@@ -8,31 +8,53 @@ function resolveServerUrl(serverUrl) {
|
|
|
8
8
|
return serverUrl ?? process.env.COCLAW_SERVER_URL ?? DEFAULT_SERVER_URL;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// 这些 HTTP 状态码表示 bot 在 server 端已不存在,视为解绑成功
|
|
12
|
+
const ALREADY_UNBOUND_STATUSES = new Set([401, 404, 410]);
|
|
13
|
+
|
|
14
|
+
function isAlreadyUnbound(err) {
|
|
15
|
+
return ALREADY_UNBOUND_STATUSES.has(err?.response?.status);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function bindBot({ code, serverUrl }, deps = {}) {
|
|
19
|
+
const {
|
|
20
|
+
readCfg = readConfig,
|
|
21
|
+
clearCfg = clearConfig,
|
|
22
|
+
writeCfg = writeConfig,
|
|
23
|
+
unbindServer = unbindWithServer,
|
|
24
|
+
bindServer = bindWithServer,
|
|
25
|
+
} = deps;
|
|
26
|
+
|
|
12
27
|
if (!code) {
|
|
13
28
|
throw new Error('binding code is required');
|
|
14
29
|
}
|
|
15
30
|
|
|
16
|
-
const config = await
|
|
31
|
+
const config = await readCfg();
|
|
17
32
|
|
|
18
|
-
//
|
|
33
|
+
// 已绑定时必须先解绑旧 bot,避免产生孤儿记录
|
|
19
34
|
let previousBotId;
|
|
20
35
|
if (config?.token) {
|
|
21
36
|
previousBotId = config.botId || 'unknown';
|
|
22
37
|
const oldBaseUrl = config.serverUrl;
|
|
23
38
|
if (oldBaseUrl) {
|
|
24
39
|
try {
|
|
25
|
-
await
|
|
26
|
-
} catch {
|
|
27
|
-
|
|
40
|
+
await unbindServer({ baseUrl: oldBaseUrl, token: config.token });
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (!isAlreadyUnbound(err)) {
|
|
43
|
+
const rebindErr = new Error(
|
|
44
|
+
`Failed to unbind previous bot (${previousBotId}): ${err.message}. ` +
|
|
45
|
+
'Unbind manually first, then retry.',
|
|
46
|
+
);
|
|
47
|
+
rebindErr.code = 'UNBIND_FAILED';
|
|
48
|
+
throw rebindErr;
|
|
49
|
+
}
|
|
28
50
|
}
|
|
29
51
|
}
|
|
30
|
-
await
|
|
52
|
+
await clearCfg();
|
|
31
53
|
}
|
|
32
54
|
|
|
33
55
|
/* c8 ignore next */
|
|
34
56
|
const baseUrl = serverUrl ?? process.env.COCLAW_SERVER_URL ?? DEFAULT_SERVER_URL;
|
|
35
|
-
const data = await
|
|
57
|
+
const data = await bindServer({
|
|
36
58
|
baseUrl,
|
|
37
59
|
code,
|
|
38
60
|
});
|
|
@@ -41,7 +63,7 @@ export async function bindBot({ code, serverUrl }) {
|
|
|
41
63
|
throw new Error('invalid bind response');
|
|
42
64
|
}
|
|
43
65
|
|
|
44
|
-
await
|
|
66
|
+
await writeCfg({
|
|
45
67
|
serverUrl: baseUrl,
|
|
46
68
|
botId: data.botId,
|
|
47
69
|
token: data.token,
|
|
@@ -128,8 +150,14 @@ export async function waitForClaimAndSave({ serverUrl, code, waitToken, signal }
|
|
|
128
150
|
}
|
|
129
151
|
}
|
|
130
152
|
|
|
131
|
-
export async function unbindBot({ serverUrl }) {
|
|
132
|
-
const
|
|
153
|
+
export async function unbindBot({ serverUrl }, deps = {}) {
|
|
154
|
+
const {
|
|
155
|
+
readCfg = readConfig,
|
|
156
|
+
clearCfg = clearConfig,
|
|
157
|
+
unbindServer = unbindWithServer,
|
|
158
|
+
} = deps;
|
|
159
|
+
|
|
160
|
+
const config = await readCfg();
|
|
133
161
|
if (!config?.token) {
|
|
134
162
|
const err = new Error('not bound, nothing to unbind');
|
|
135
163
|
err.code = 'NOT_BOUND';
|
|
@@ -138,25 +166,18 @@ export async function unbindBot({ serverUrl }) {
|
|
|
138
166
|
|
|
139
167
|
const baseUrl = serverUrl ?? config.serverUrl;
|
|
140
168
|
|
|
141
|
-
// 用户主动解绑:无论 server 通知成功与否,都清理本地绑定
|
|
142
|
-
let data = null;
|
|
143
|
-
let serverError = null;
|
|
144
169
|
if (baseUrl) {
|
|
145
170
|
try {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
serverError = err;
|
|
171
|
+
await unbindServer({ baseUrl, token: config.token });
|
|
172
|
+
} catch (err) {
|
|
173
|
+
// bot 在 server 已不存在 — 视为解绑成功,继续清理本地
|
|
174
|
+
if (!isAlreadyUnbound(err)) {
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
153
177
|
}
|
|
154
178
|
}
|
|
155
179
|
|
|
156
|
-
await
|
|
180
|
+
await clearCfg();
|
|
157
181
|
|
|
158
|
-
return {
|
|
159
|
-
botId: data?.botId ?? config.botId,
|
|
160
|
-
serverError,
|
|
161
|
-
};
|
|
182
|
+
return { botId: config.botId };
|
|
162
183
|
}
|
package/src/common/errors.js
CHANGED
|
@@ -3,7 +3,7 @@ const ERROR_TEXT_MAP = {
|
|
|
3
3
|
BINDING_CODE_INVALID: 'Binding code is invalid',
|
|
4
4
|
BINDING_CODE_EXPIRED: 'Binding code has expired, please get a new one',
|
|
5
5
|
BINDING_CODE_EXHAUSTED: 'Server cannot generate binding code right now, please try again later',
|
|
6
|
-
BOT_BLOCKED: '
|
|
6
|
+
BOT_BLOCKED: 'Claw is blocked, please contact the admin',
|
|
7
7
|
UNAUTHORIZED: 'Auth failed, please check token or re-bind',
|
|
8
8
|
INTERNAL_SERVER_ERROR: 'Server error, please try again later',
|
|
9
9
|
};
|
package/src/common/messages.js
CHANGED
|
@@ -3,17 +3,14 @@
|
|
|
3
3
|
export function bindOk({ botId, rebound, previousBotId }) {
|
|
4
4
|
const action = rebound ? 're-bound' : 'bound';
|
|
5
5
|
const prev = previousBotId
|
|
6
|
-
? ` (previous
|
|
6
|
+
? ` (previous Claw ${previousBotId} was auto-unbound)`
|
|
7
7
|
: '';
|
|
8
|
-
return `OK.
|
|
8
|
+
return `OK. Claw (${botId}) ${action} to CoClaw.${prev}`;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
export function unbindOk({ botId
|
|
11
|
+
export function unbindOk({ botId }) {
|
|
12
12
|
const id = botId ?? 'unknown';
|
|
13
|
-
|
|
14
|
-
? ' (server notification failed; you can unbind the orphan bot in the CoClaw app)'
|
|
15
|
-
: '';
|
|
16
|
-
return `OK. Bot (${id}) unbound from CoClaw.${tag}`;
|
|
13
|
+
return `OK. Claw (${id}) unbound from CoClaw.`;
|
|
17
14
|
}
|
|
18
15
|
|
|
19
16
|
export function notBound() {
|
package/src/config.js
CHANGED
|
@@ -3,6 +3,8 @@ import os from 'node:os';
|
|
|
3
3
|
import nodePath from 'node:path';
|
|
4
4
|
|
|
5
5
|
import { getRuntime } from './runtime.js';
|
|
6
|
+
import { atomicWriteJsonFile } from './utils/atomic-write.js';
|
|
7
|
+
import { createMutex } from './utils/mutex.js';
|
|
6
8
|
|
|
7
9
|
export const DEFAULT_ACCOUNT_ID = 'default';
|
|
8
10
|
const CHANNEL_ID = 'coclaw';
|
|
@@ -42,11 +44,7 @@ async function readJson(filePath) {
|
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
const dirPath = nodePath.dirname(filePath);
|
|
47
|
-
await fs.mkdir(dirPath, { recursive: true });
|
|
48
|
-
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
49
|
-
}
|
|
47
|
+
const bindingsMutex = createMutex();
|
|
50
48
|
|
|
51
49
|
// --- 公共 API ---
|
|
52
50
|
|
|
@@ -57,35 +55,39 @@ export async function readConfig(accountId = DEFAULT_ACCOUNT_ID) {
|
|
|
57
55
|
}
|
|
58
56
|
|
|
59
57
|
export async function writeConfig(nextConfig, accountId = DEFAULT_ACCOUNT_ID) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
return bindingsMutex.withLock(async () => {
|
|
59
|
+
const bindingsPath = getBindingsPath();
|
|
60
|
+
const bindings = toRecord(await readJson(bindingsPath));
|
|
61
|
+
const current = toRecord(bindings[accountId]);
|
|
63
62
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
const next = { ...current };
|
|
64
|
+
if (nextConfig.serverUrl !== undefined) next.serverUrl = nextConfig.serverUrl;
|
|
65
|
+
if (nextConfig.botId !== undefined) next.botId = nextConfig.botId;
|
|
66
|
+
if (nextConfig.token !== undefined) next.token = nextConfig.token;
|
|
67
|
+
if (nextConfig.boundAt !== undefined) next.boundAt = nextConfig.boundAt;
|
|
69
68
|
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
bindings[accountId] = next;
|
|
70
|
+
await atomicWriteJsonFile(bindingsPath, bindings);
|
|
71
|
+
});
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export async function clearConfig(accountId = DEFAULT_ACCOUNT_ID) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
return bindingsMutex.withLock(async () => {
|
|
76
|
+
const bindingsPath = getBindingsPath();
|
|
77
|
+
const bindings = toRecord(await readJson(bindingsPath));
|
|
78
|
+
delete bindings[accountId];
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
const remaining = Object.keys(bindings).length;
|
|
81
|
+
if (remaining === 0) {
|
|
82
|
+
try {
|
|
83
|
+
await fs.unlink(bindingsPath);
|
|
84
|
+
}
|
|
85
|
+
/* c8 ignore next 4 */
|
|
86
|
+
catch (err) {
|
|
87
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
await atomicWriteJsonFile(bindingsPath, bindings);
|
|
83
91
|
}
|
|
84
|
-
|
|
85
|
-
catch (err) {
|
|
86
|
-
if (err?.code !== 'ENOENT') throw err;
|
|
87
|
-
}
|
|
88
|
-
} else {
|
|
89
|
-
await writeJson(bindingsPath, bindings);
|
|
90
|
-
}
|
|
92
|
+
});
|
|
91
93
|
}
|
package/src/realtime-bridge.js
CHANGED
|
@@ -97,6 +97,7 @@ export class RealtimeBridge {
|
|
|
97
97
|
this.serverHbTimer = null;
|
|
98
98
|
this.__serverHbMissCount = 0;
|
|
99
99
|
this.__deviceIdentity = null;
|
|
100
|
+
this.webrtcPeer = null;
|
|
100
101
|
}
|
|
101
102
|
|
|
102
103
|
__resolveWebSocket() {
|
|
@@ -163,11 +164,15 @@ export class RealtimeBridge {
|
|
|
163
164
|
?? DEFAULT_GATEWAY_WS_URL;
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
async __clearTokenLocal() {
|
|
167
|
+
async __clearTokenLocal(unboundBotId) {
|
|
167
168
|
const cfg = await this.__readConfig();
|
|
168
169
|
if (!cfg?.token) {
|
|
169
170
|
return;
|
|
170
171
|
}
|
|
172
|
+
// 只清除匹配的 bot,避免新绑定被误清
|
|
173
|
+
if (unboundBotId && cfg.botId && cfg.botId !== unboundBotId) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
171
176
|
await this.__clearConfig();
|
|
172
177
|
}
|
|
173
178
|
|
|
@@ -508,6 +513,7 @@ export class RealtimeBridge {
|
|
|
508
513
|
}
|
|
509
514
|
if (payload.type === 'res' || payload.type === 'event') {
|
|
510
515
|
this.__forwardToServer(payload);
|
|
516
|
+
this.webrtcPeer?.broadcast(payload);
|
|
511
517
|
}
|
|
512
518
|
});
|
|
513
519
|
|
|
@@ -518,6 +524,11 @@ export class RealtimeBridge {
|
|
|
518
524
|
this.gatewayWs = null;
|
|
519
525
|
this.gatewayReady = false;
|
|
520
526
|
this.gatewayConnectReqId = null;
|
|
527
|
+
/* c8 ignore next 3 -- gateway 意外断开时结算未完成 RPC,避免等超时 */
|
|
528
|
+
for (const [, settle] of this.gatewayPendingRequests) {
|
|
529
|
+
settle({ ok: false, error: 'gateway_closed' });
|
|
530
|
+
}
|
|
531
|
+
this.gatewayPendingRequests.clear();
|
|
521
532
|
});
|
|
522
533
|
ws.addEventListener('error', () => {});
|
|
523
534
|
}
|
|
@@ -571,7 +582,7 @@ export class RealtimeBridge {
|
|
|
571
582
|
const ready = await this.__waitGatewayReady();
|
|
572
583
|
if (!ready || !this.gatewayWs || this.gatewayWs.readyState !== 1) {
|
|
573
584
|
this.__logDebug(`gateway req drop (offline): id=${payload.id} method=${payload.method}`);
|
|
574
|
-
|
|
585
|
+
const errorRes = {
|
|
575
586
|
type: 'res',
|
|
576
587
|
id: payload.id,
|
|
577
588
|
ok: false,
|
|
@@ -579,7 +590,9 @@ export class RealtimeBridge {
|
|
|
579
590
|
code: 'GATEWAY_OFFLINE',
|
|
580
591
|
message: 'Gateway is offline',
|
|
581
592
|
},
|
|
582
|
-
}
|
|
593
|
+
};
|
|
594
|
+
this.__forwardToServer(errorRes);
|
|
595
|
+
this.webrtcPeer?.broadcast(errorRes);
|
|
583
596
|
return;
|
|
584
597
|
}
|
|
585
598
|
try {
|
|
@@ -592,7 +605,7 @@ export class RealtimeBridge {
|
|
|
592
605
|
}));
|
|
593
606
|
}
|
|
594
607
|
catch {
|
|
595
|
-
|
|
608
|
+
const errorRes = {
|
|
596
609
|
type: 'res',
|
|
597
610
|
id: payload.id,
|
|
598
611
|
ok: false,
|
|
@@ -600,7 +613,9 @@ export class RealtimeBridge {
|
|
|
600
613
|
code: 'GATEWAY_SEND_FAILED',
|
|
601
614
|
message: 'Failed to send request to gateway',
|
|
602
615
|
},
|
|
603
|
-
}
|
|
616
|
+
};
|
|
617
|
+
this.__forwardToServer(errorRes);
|
|
618
|
+
this.webrtcPeer?.broadcast(errorRes);
|
|
604
619
|
}
|
|
605
620
|
}
|
|
606
621
|
|
|
@@ -681,12 +696,30 @@ export class RealtimeBridge {
|
|
|
681
696
|
try {
|
|
682
697
|
const payload = JSON.parse(String(event.data ?? '{}'));
|
|
683
698
|
if (payload?.type === 'bot.unbound') {
|
|
684
|
-
await this.__clearTokenLocal();
|
|
699
|
+
await this.__clearTokenLocal(payload.botId);
|
|
685
700
|
try { sock.close(4001, 'bot_unbound'); }
|
|
686
701
|
/* c8 ignore next */
|
|
687
702
|
catch {}
|
|
688
703
|
return;
|
|
689
704
|
}
|
|
705
|
+
if (payload?.type?.startsWith('rtc:')) {
|
|
706
|
+
try {
|
|
707
|
+
if (!this.webrtcPeer) {
|
|
708
|
+
const { WebRtcPeer } = await import('./webrtc-peer.js');
|
|
709
|
+
this.webrtcPeer = new WebRtcPeer({
|
|
710
|
+
onSend: (msg) => this.__forwardToServer(msg),
|
|
711
|
+
onRequest: (dcPayload) => {
|
|
712
|
+
void this.__handleGatewayRequestFromServer(dcPayload);
|
|
713
|
+
},
|
|
714
|
+
logger: this.logger,
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
await this.webrtcPeer.handleSignaling(payload);
|
|
718
|
+
} catch (err) {
|
|
719
|
+
this.logger.warn?.(`[coclaw/rtc] signaling error (or werift not found): ${err?.message}`);
|
|
720
|
+
}
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
690
723
|
if (payload?.type === 'req' || payload?.type === 'rpc.req') {
|
|
691
724
|
void this.__handleGatewayRequestFromServer({
|
|
692
725
|
id: payload.id,
|
|
@@ -711,9 +744,21 @@ export class RealtimeBridge {
|
|
|
711
744
|
this.serverWs = null;
|
|
712
745
|
this.intentionallyClosed = false;
|
|
713
746
|
this.__closeGatewayWs();
|
|
747
|
+
if (this.webrtcPeer) {
|
|
748
|
+
try { await this.webrtcPeer.closeAll(); }
|
|
749
|
+
/* c8 ignore next 3 -- 防御性兜底,werift close 异常时不可崩溃 gateway */
|
|
750
|
+
catch (e) { this.logger.warn?.(`[coclaw/rtc] closeAll failed: ${e?.message}`); }
|
|
751
|
+
this.webrtcPeer = null;
|
|
752
|
+
}
|
|
714
753
|
|
|
715
754
|
if (event?.code === 4001 || event?.code === 4003) {
|
|
716
|
-
|
|
755
|
+
try {
|
|
756
|
+
await this.__clearTokenLocal();
|
|
757
|
+
}
|
|
758
|
+
/* c8 ignore next 3 -- 防御性兜底,磁盘 I/O 异常时不可崩溃 gateway */
|
|
759
|
+
catch (e) {
|
|
760
|
+
this.logger.error?.('[coclaw] clearTokenLocal failed on auth-close', e);
|
|
761
|
+
}
|
|
717
762
|
return;
|
|
718
763
|
}
|
|
719
764
|
|
|
@@ -763,6 +808,10 @@ export class RealtimeBridge {
|
|
|
763
808
|
this.reconnectTimer = null;
|
|
764
809
|
}
|
|
765
810
|
this.__closeGatewayWs();
|
|
811
|
+
if (this.webrtcPeer) {
|
|
812
|
+
await this.webrtcPeer.closeAll().catch(() => {});
|
|
813
|
+
this.webrtcPeer = null;
|
|
814
|
+
}
|
|
766
815
|
const sock = this.serverWs;
|
|
767
816
|
if (sock) {
|
|
768
817
|
this.intentionallyClosed = true;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { RTCPeerConnection as WeriftRTCPeerConnection } from 'werift';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 管理多个 WebRTC PeerConnection(以 connId 为粒度)。
|
|
5
|
+
* Plugin 作为被叫方:收到 UI 的 offer → 回复 answer。
|
|
6
|
+
*/
|
|
7
|
+
export class WebRtcPeer {
|
|
8
|
+
/**
|
|
9
|
+
* @param {object} opts
|
|
10
|
+
* @param {function} opts.onSend - 将信令消息交给 RealtimeBridge 发送
|
|
11
|
+
* @param {function} [opts.onRequest] - DataChannel 收到 req 消息时的回调 (payload, connId) => void
|
|
12
|
+
* @param {object} [opts.logger] - pino 风格 logger
|
|
13
|
+
* @param {function} [opts.PeerConnection] - 可替换的构造函数(测试用)
|
|
14
|
+
*/
|
|
15
|
+
constructor({ onSend, onRequest, logger, PeerConnection }) {
|
|
16
|
+
this.__onSend = onSend;
|
|
17
|
+
this.__onRequest = onRequest;
|
|
18
|
+
this.logger = logger ?? console;
|
|
19
|
+
this.__PeerConnection = PeerConnection ?? WeriftRTCPeerConnection;
|
|
20
|
+
/** @type {Map<string, { pc: object, rpcChannel: object|null }>} */
|
|
21
|
+
this.__sessions = new Map();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 处理来自 Server 转发的信令消息 */
|
|
25
|
+
async handleSignaling(msg) {
|
|
26
|
+
const connId = msg.fromConnId ?? msg.toConnId;
|
|
27
|
+
if (msg.type === 'rtc:offer') {
|
|
28
|
+
await this.__handleOffer(msg);
|
|
29
|
+
} else if (msg.type === 'rtc:ice') {
|
|
30
|
+
await this.__handleIce(msg);
|
|
31
|
+
} else if (msg.type === 'rtc:ready' || msg.type === 'rtc:closed') {
|
|
32
|
+
this.__logDebug(`${msg.type} from ${connId}`);
|
|
33
|
+
if (msg.type === 'rtc:closed') {
|
|
34
|
+
await this.closeByConnId(connId);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 关闭指定 connId 的 PeerConnection */
|
|
40
|
+
async closeByConnId(connId) {
|
|
41
|
+
const session = this.__sessions.get(connId);
|
|
42
|
+
if (!session) return;
|
|
43
|
+
this.__sessions.delete(connId);
|
|
44
|
+
await session.pc.close();
|
|
45
|
+
this.logger.info?.(`[coclaw/rtc] [${connId}] closed`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 关闭所有 PeerConnection */
|
|
49
|
+
async closeAll() {
|
|
50
|
+
const closing = [...this.__sessions.keys()].map((id) => this.closeByConnId(id));
|
|
51
|
+
await Promise.all(closing);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 向所有已打开的 rpcChannel 广播 */
|
|
55
|
+
broadcast(payload) {
|
|
56
|
+
const data = JSON.stringify(payload);
|
|
57
|
+
for (const [connId, session] of this.__sessions) {
|
|
58
|
+
const dc = session.rpcChannel;
|
|
59
|
+
if (dc?.readyState === 'open') {
|
|
60
|
+
try { dc.send(data); }
|
|
61
|
+
catch (err) { this.__logDebug(`[${connId}] broadcast send failed: ${err.message}`); }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async __handleOffer(msg) {
|
|
67
|
+
const connId = msg.fromConnId;
|
|
68
|
+
this.logger.info?.(`[coclaw/rtc] offer received from ${connId}, creating answer`);
|
|
69
|
+
|
|
70
|
+
// 同一 connId 重复 offer → 先关闭旧连接
|
|
71
|
+
if (this.__sessions.has(connId)) {
|
|
72
|
+
await this.closeByConnId(connId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 从 Server 注入的 turnCreds 构建 iceServers
|
|
76
|
+
// werift 的 urls 必须是单个 string,每个 URL 独立一个对象
|
|
77
|
+
const iceServers = [];
|
|
78
|
+
if (msg.turnCreds) {
|
|
79
|
+
const { urls, username, credential } = msg.turnCreds;
|
|
80
|
+
for (const url of urls) {
|
|
81
|
+
const server = { urls: url };
|
|
82
|
+
if (url.startsWith('turn:')) {
|
|
83
|
+
server.username = username;
|
|
84
|
+
server.credential = credential;
|
|
85
|
+
}
|
|
86
|
+
iceServers.push(server);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const pc = new this.__PeerConnection({ iceServers });
|
|
91
|
+
const session = { pc, rpcChannel: null };
|
|
92
|
+
this.__sessions.set(connId, session);
|
|
93
|
+
|
|
94
|
+
// ICE candidate → 发给 UI
|
|
95
|
+
pc.onicecandidate = ({ candidate }) => {
|
|
96
|
+
if (!candidate) return;
|
|
97
|
+
this.__onSend({
|
|
98
|
+
type: 'rtc:ice',
|
|
99
|
+
toConnId: connId,
|
|
100
|
+
payload: {
|
|
101
|
+
candidate: candidate.candidate,
|
|
102
|
+
sdpMid: candidate.sdpMid,
|
|
103
|
+
sdpMLineIndex: candidate.sdpMLineIndex,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// 连接状态变更
|
|
109
|
+
pc.onconnectionstatechange = () => {
|
|
110
|
+
const state = pc.connectionState;
|
|
111
|
+
this.logger.info?.(`[coclaw/rtc] [${connId}] connectionState: ${state}`);
|
|
112
|
+
if (state === 'connected') {
|
|
113
|
+
const nominated = pc.iceTransports?.[0]?.connection?.nominated;
|
|
114
|
+
if (nominated) {
|
|
115
|
+
const type = nominated.localCandidate?.type ?? 'unknown';
|
|
116
|
+
this.logger.info?.(`[coclaw/rtc] [${connId}] ICE connected via ${type}`);
|
|
117
|
+
}
|
|
118
|
+
} else if (state === 'failed' || state === 'closed') {
|
|
119
|
+
this.__sessions.delete(connId);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// 监听 UI 创建的 DataChannel
|
|
124
|
+
pc.ondatachannel = ({ channel }) => {
|
|
125
|
+
this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${channel.label}" received`);
|
|
126
|
+
if (channel.label === 'rpc') {
|
|
127
|
+
session.rpcChannel = channel;
|
|
128
|
+
this.__setupDataChannel(connId, channel);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// offer → answer
|
|
133
|
+
try {
|
|
134
|
+
await pc.setRemoteDescription({ type: 'offer', sdp: msg.payload.sdp });
|
|
135
|
+
const answer = await pc.createAnswer();
|
|
136
|
+
await pc.setLocalDescription(answer);
|
|
137
|
+
|
|
138
|
+
this.__onSend({
|
|
139
|
+
type: 'rtc:answer',
|
|
140
|
+
toConnId: connId,
|
|
141
|
+
payload: { sdp: answer.sdp },
|
|
142
|
+
});
|
|
143
|
+
this.logger.info?.(`[coclaw/rtc] answer sent to ${connId}`);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// SDP 协商失败 → 清理已入 Map 的 session,避免泄漏
|
|
146
|
+
this.__sessions.delete(connId);
|
|
147
|
+
await pc.close().catch(() => {});
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async __handleIce(msg) {
|
|
153
|
+
const connId = msg.fromConnId;
|
|
154
|
+
const session = this.__sessions.get(connId);
|
|
155
|
+
if (!session) {
|
|
156
|
+
this.__logDebug(`ICE candidate from ${connId} but no session`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
await session.pc.addIceCandidate(msg.payload);
|
|
160
|
+
this.__logDebug(`[${connId}] ICE candidate added`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
__setupDataChannel(connId, dc) {
|
|
164
|
+
dc.onopen = () => {
|
|
165
|
+
this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" opened`);
|
|
166
|
+
};
|
|
167
|
+
dc.onclose = () => {
|
|
168
|
+
this.logger.info?.(`[coclaw/rtc] [${connId}] DataChannel "${dc.label}" closed`);
|
|
169
|
+
const session = this.__sessions.get(connId);
|
|
170
|
+
if (session && dc.label === 'rpc') session.rpcChannel = null;
|
|
171
|
+
};
|
|
172
|
+
dc.onmessage = (event) => {
|
|
173
|
+
try {
|
|
174
|
+
const raw = typeof event.data === 'string' ? event.data : event.data.toString();
|
|
175
|
+
const payload = JSON.parse(raw);
|
|
176
|
+
if (payload.type === 'req') {
|
|
177
|
+
this.__onRequest?.(payload, connId);
|
|
178
|
+
} else {
|
|
179
|
+
this.__logDebug(`[${connId}] unknown DC message type: ${payload.type}`);
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {
|
|
182
|
+
this.logger.warn?.(`[coclaw/rtc] [${connId}] DC message parse failed: ${err.message}`);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
__logDebug(message) {
|
|
188
|
+
if (typeof this.logger?.debug === 'function') {
|
|
189
|
+
this.logger.debug(`[coclaw/rtc] ${message}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|