@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 CHANGED
@@ -72,7 +72,8 @@ openclaw coclaw unbind [--server <url>]
72
72
  openclaw coclaw enroll [--server <url>]
73
73
  ```
74
74
 
75
- - bind/unbind 成功后会通过 gateway RPC 通知插件刷新/停止 bridge 连接(无需重启 gateway)。若 gateway 未运行,通知会失败但不影响绑定结果。
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
- await stopRealtimeBridge(); // 先断开,避免 bindBot 内 unbind 触发 bot.unbound 竞态
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 unbindBot({ serverUrl: options.server });
487
- await stopRealtimeBridge();
547
+ const result = await doUnbind({ serverUrl: options.server });
488
548
  return { text: unbindOk(result) };
489
549
  }
490
550
 
@@ -2,7 +2,6 @@
2
2
  "id": "openclaw-coclaw",
3
3
  "name": "CoClaw",
4
4
  "description": "OpenClaw CoClaw channel plugin for remote chat",
5
- "channels": ["coclaw"],
6
5
  "configSchema": {
7
6
  "type": "object",
8
7
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coclaw/openclaw-coclaw",
3
- "version": "0.6.1",
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"
@@ -22,7 +22,7 @@ export const coclawChannelPlugin = {
22
22
  capabilities: {
23
23
  chatTypes: ['direct'],
24
24
  nativeCommands: true,
25
- media: false,
25
+ media: true,
26
26
  reactions: false,
27
27
  threads: false,
28
28
  polls: false,
@@ -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
- function resolveServerUrl(opts, config) {
47
- return opts?.server
48
- ?? config?.plugins?.entries?.['openclaw-coclaw']?.config?.serverUrl
49
- ?? process.env.COCLAW_SERVER_URL;
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
- * 注册 `openclaw coclaw bind/unbind` CLI 子命令
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, config, logger }, deps = {}) {
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 OpenClaw instance to CoClaw')
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
- // 先断开 bridge,避免 unbindWithServer 触发的 bot.unbound 竞态
88
- await notifyGateway('coclaw.stopBridge');
89
- const serverUrl = resolveServerUrl(opts, config);
90
- const result = await bindBot({ code, serverUrl });
91
- /* c8 ignore next */
92
- console.log(bindOk(result));
93
- await notifyGateway('coclaw.refreshBridge');
94
- } catch (err) {
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 OpenClaw instance with CoClaw (generate a claim code for the user)')
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 = opts?.server ? { params: { serverUrl: opts.server } } : undefined;
107
- const callRpc = () => callGatewayMethod('coclaw.enroll', deps.spawn, rpcOpts);
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
- if (isGatewayUnavailable(result)) {
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 OpenClaw instance from CoClaw')
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 result = await unbindBot({ serverUrl: opts?.server });
162
- /* c8 ignore next */
163
- console.log(unbindOk(result));
164
- await notifyGateway('coclaw.stopBridge');
165
- } catch (err) {
166
- if (err.code === 'NOT_BOUND') {
167
- console.error(notBound());
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
- /* c8 ignore start -- 防御性兜底,unbindBot 当前仅抛 NOT_BOUND */
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 当前仅抛 NOT_BOUND */
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
- export async function bindBot({ code, serverUrl }) {
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 readConfig();
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 unbindWithServer({ baseUrl: oldBaseUrl, token: config.token });
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 clearConfig();
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 bindWithServer({
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 writeConfig({
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 config = await readConfig();
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
- data = await unbindWithServer({
147
- baseUrl,
148
- token: config.token,
149
- });
150
- }
151
- catch (err) {
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 clearConfig();
180
+ await clearCfg();
157
181
 
158
- return {
159
- botId: data?.botId ?? config.botId,
160
- serverError,
161
- };
182
+ return { botId: config.botId };
162
183
  }
@@ -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: 'Bot is blocked, please contact the admin',
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
  };
@@ -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 binding to bot ${previousBotId} was auto-removed)`
6
+ ? ` (previous Claw ${previousBotId} was auto-unbound)`
7
7
  : '';
8
- return `OK. Bot (${botId}) ${action} to CoClaw.${prev}`;
8
+ return `OK. Claw (${botId}) ${action} to CoClaw.${prev}`;
9
9
  }
10
10
 
11
- export function unbindOk({ botId, serverError }) {
11
+ export function unbindOk({ botId }) {
12
12
  const id = botId ?? 'unknown';
13
- const tag = serverError
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
- async function writeJson(filePath, value) {
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
- const bindingsPath = getBindingsPath();
61
- const bindings = toRecord(await readJson(bindingsPath));
62
- const current = toRecord(bindings[accountId]);
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
- const next = { ...current };
65
- if (nextConfig.serverUrl !== undefined) next.serverUrl = nextConfig.serverUrl;
66
- if (nextConfig.botId !== undefined) next.botId = nextConfig.botId;
67
- if (nextConfig.token !== undefined) next.token = nextConfig.token;
68
- if (nextConfig.boundAt !== undefined) next.boundAt = nextConfig.boundAt;
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
- bindings[accountId] = next;
71
- await writeJson(bindingsPath, bindings);
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
- const bindingsPath = getBindingsPath();
76
- const bindings = toRecord(await readJson(bindingsPath));
77
- delete bindings[accountId];
75
+ return bindingsMutex.withLock(async () => {
76
+ const bindingsPath = getBindingsPath();
77
+ const bindings = toRecord(await readJson(bindingsPath));
78
+ delete bindings[accountId];
78
79
 
79
- const remaining = Object.keys(bindings).length;
80
- if (remaining === 0) {
81
- try {
82
- await fs.unlink(bindingsPath);
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
- /* c8 ignore next 4 */
85
- catch (err) {
86
- if (err?.code !== 'ENOENT') throw err;
87
- }
88
- } else {
89
- await writeJson(bindingsPath, bindings);
90
- }
92
+ });
91
93
  }
@@ -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
- this.__forwardToServer({
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
- this.__forwardToServer({
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
- await this.__clearTokenLocal();
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
+ }