@coclaw/openclaw-coclaw 0.6.0 → 0.6.2
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 +71 -8
- package/package.json +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 +18 -3
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
|
|
|
@@ -229,7 +293,10 @@ const plugin = {
|
|
|
229
293
|
try {
|
|
230
294
|
await waitForSessionsReady();
|
|
231
295
|
const version = await getPluginVersion();
|
|
232
|
-
|
|
296
|
+
const rawClawVersion = api.runtime?.version;
|
|
297
|
+
// OpenClaw 打包后 resolveVersion() 路径失配导致返回 'unknown',此时不传该字段
|
|
298
|
+
const clawVersion = (rawClawVersion && rawClawVersion !== 'unknown') ? rawClawVersion : undefined;
|
|
299
|
+
respond(true, { version, clawVersion, capabilities: ['topics', 'chatHistory'] });
|
|
233
300
|
}
|
|
234
301
|
catch (err) {
|
|
235
302
|
respondError(respond, err);
|
|
@@ -426,13 +493,10 @@ const plugin = {
|
|
|
426
493
|
|
|
427
494
|
try {
|
|
428
495
|
if (action === 'bind') {
|
|
429
|
-
|
|
430
|
-
const serverUrl = options.server ?? api.pluginConfig?.serverUrl;
|
|
431
|
-
const result = await bindBot({
|
|
496
|
+
const result = await doBind({
|
|
432
497
|
code: positionals[0],
|
|
433
|
-
serverUrl,
|
|
498
|
+
serverUrl: options.server,
|
|
434
499
|
});
|
|
435
|
-
await restartRealtimeBridge({ logger, pluginConfig: api.pluginConfig });
|
|
436
500
|
return { text: bindOk(result) };
|
|
437
501
|
}
|
|
438
502
|
|
|
@@ -480,8 +544,7 @@ const plugin = {
|
|
|
480
544
|
}
|
|
481
545
|
|
|
482
546
|
if (action === 'unbind') {
|
|
483
|
-
const result = await
|
|
484
|
-
await stopRealtimeBridge();
|
|
547
|
+
const result = await doUnbind({ serverUrl: options.server });
|
|
485
548
|
return { text: unbindOk(result) };
|
|
486
549
|
}
|
|
487
550
|
|
package/package.json
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
|
@@ -163,11 +163,15 @@ export class RealtimeBridge {
|
|
|
163
163
|
?? DEFAULT_GATEWAY_WS_URL;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
async __clearTokenLocal() {
|
|
166
|
+
async __clearTokenLocal(unboundBotId) {
|
|
167
167
|
const cfg = await this.__readConfig();
|
|
168
168
|
if (!cfg?.token) {
|
|
169
169
|
return;
|
|
170
170
|
}
|
|
171
|
+
// 只清除匹配的 bot,避免新绑定被误清
|
|
172
|
+
if (unboundBotId && cfg.botId && cfg.botId !== unboundBotId) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
171
175
|
await this.__clearConfig();
|
|
172
176
|
}
|
|
173
177
|
|
|
@@ -518,6 +522,11 @@ export class RealtimeBridge {
|
|
|
518
522
|
this.gatewayWs = null;
|
|
519
523
|
this.gatewayReady = false;
|
|
520
524
|
this.gatewayConnectReqId = null;
|
|
525
|
+
/* c8 ignore next 3 -- gateway 意外断开时结算未完成 RPC,避免等超时 */
|
|
526
|
+
for (const [, settle] of this.gatewayPendingRequests) {
|
|
527
|
+
settle({ ok: false, error: 'gateway_closed' });
|
|
528
|
+
}
|
|
529
|
+
this.gatewayPendingRequests.clear();
|
|
521
530
|
});
|
|
522
531
|
ws.addEventListener('error', () => {});
|
|
523
532
|
}
|
|
@@ -681,7 +690,7 @@ export class RealtimeBridge {
|
|
|
681
690
|
try {
|
|
682
691
|
const payload = JSON.parse(String(event.data ?? '{}'));
|
|
683
692
|
if (payload?.type === 'bot.unbound') {
|
|
684
|
-
await this.__clearTokenLocal();
|
|
693
|
+
await this.__clearTokenLocal(payload.botId);
|
|
685
694
|
try { sock.close(4001, 'bot_unbound'); }
|
|
686
695
|
/* c8 ignore next */
|
|
687
696
|
catch {}
|
|
@@ -713,7 +722,13 @@ export class RealtimeBridge {
|
|
|
713
722
|
this.__closeGatewayWs();
|
|
714
723
|
|
|
715
724
|
if (event?.code === 4001 || event?.code === 4003) {
|
|
716
|
-
|
|
725
|
+
try {
|
|
726
|
+
await this.__clearTokenLocal();
|
|
727
|
+
}
|
|
728
|
+
/* c8 ignore next 3 -- 防御性兜底,磁盘 I/O 异常时不可崩溃 gateway */
|
|
729
|
+
catch (e) {
|
|
730
|
+
this.logger.error?.('[coclaw] clearTokenLocal failed on auth-close', e);
|
|
731
|
+
}
|
|
717
732
|
return;
|
|
718
733
|
}
|
|
719
734
|
|