@coclaw/openclaw-coclaw 0.6.2 → 0.7.1
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/openclaw.plugin.json +0 -1
- package/package.json +4 -1
- package/src/auto-upgrade/worker-verify.js +5 -5
- package/src/channel-plugin.js +1 -1
- package/src/realtime-bridge.js +38 -4
- package/src/webrtc-peer.js +192 -0
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.1",
|
|
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"
|
|
@@ -23,7 +23,7 @@ const CMD_TIMEOUT_MS = 30_000;
|
|
|
23
23
|
* @param {Function} [opts.execFileFn]
|
|
24
24
|
* @returns {Promise<string>}
|
|
25
25
|
*/
|
|
26
|
-
function
|
|
26
|
+
function runCmd(cmd, args, opts) {
|
|
27
27
|
const doExecFile = opts?.execFileFn ?? nodeExecFile;
|
|
28
28
|
return new Promise((resolve, reject) => {
|
|
29
29
|
doExecFile(cmd, args, { timeout: CMD_TIMEOUT_MS, shell: true }, (err, stdout) => {
|
|
@@ -44,7 +44,7 @@ function exec(cmd, args, opts) {
|
|
|
44
44
|
export async function waitForGateway(opts) {
|
|
45
45
|
// 主动触发重启,不依赖 OpenClaw 的文件变更自动重启策略
|
|
46
46
|
try {
|
|
47
|
-
await
|
|
47
|
+
await runCmd('openclaw', ['gateway', 'restart'], opts);
|
|
48
48
|
}
|
|
49
49
|
catch {
|
|
50
50
|
// restart 命令失败不阻断流程,仍尝试等待
|
|
@@ -56,7 +56,7 @@ export async function waitForGateway(opts) {
|
|
|
56
56
|
|
|
57
57
|
while (Date.now() - start < timeout) {
|
|
58
58
|
try {
|
|
59
|
-
const output = await
|
|
59
|
+
const output = await runCmd('openclaw', ['gateway', 'status'], opts);
|
|
60
60
|
if (output.includes('running')) return;
|
|
61
61
|
}
|
|
62
62
|
catch {
|
|
@@ -76,7 +76,7 @@ export async function waitForGateway(opts) {
|
|
|
76
76
|
* @returns {Promise<void>}
|
|
77
77
|
*/
|
|
78
78
|
export async function verifyPluginLoaded(pluginId, opts) {
|
|
79
|
-
const output = await
|
|
79
|
+
const output = await runCmd('openclaw', ['plugins', 'list'], opts);
|
|
80
80
|
if (!output.includes(pluginId)) {
|
|
81
81
|
throw new Error(`Plugin ${pluginId} not found in plugins list`);
|
|
82
82
|
}
|
|
@@ -89,7 +89,7 @@ export async function verifyPluginLoaded(pluginId, opts) {
|
|
|
89
89
|
* @returns {Promise<string>} 返回版本号
|
|
90
90
|
*/
|
|
91
91
|
export async function verifyUpgradeHealth(opts) {
|
|
92
|
-
const output = await
|
|
92
|
+
const output = await runCmd(
|
|
93
93
|
'openclaw',
|
|
94
94
|
['gateway', 'call', 'coclaw.upgradeHealth', '--json'],
|
|
95
95
|
opts,
|
package/src/channel-plugin.js
CHANGED
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() {
|
|
@@ -512,6 +513,7 @@ export class RealtimeBridge {
|
|
|
512
513
|
}
|
|
513
514
|
if (payload.type === 'res' || payload.type === 'event') {
|
|
514
515
|
this.__forwardToServer(payload);
|
|
516
|
+
this.webrtcPeer?.broadcast(payload);
|
|
515
517
|
}
|
|
516
518
|
});
|
|
517
519
|
|
|
@@ -580,7 +582,7 @@ export class RealtimeBridge {
|
|
|
580
582
|
const ready = await this.__waitGatewayReady();
|
|
581
583
|
if (!ready || !this.gatewayWs || this.gatewayWs.readyState !== 1) {
|
|
582
584
|
this.__logDebug(`gateway req drop (offline): id=${payload.id} method=${payload.method}`);
|
|
583
|
-
|
|
585
|
+
const errorRes = {
|
|
584
586
|
type: 'res',
|
|
585
587
|
id: payload.id,
|
|
586
588
|
ok: false,
|
|
@@ -588,7 +590,9 @@ export class RealtimeBridge {
|
|
|
588
590
|
code: 'GATEWAY_OFFLINE',
|
|
589
591
|
message: 'Gateway is offline',
|
|
590
592
|
},
|
|
591
|
-
}
|
|
593
|
+
};
|
|
594
|
+
this.__forwardToServer(errorRes);
|
|
595
|
+
this.webrtcPeer?.broadcast(errorRes);
|
|
592
596
|
return;
|
|
593
597
|
}
|
|
594
598
|
try {
|
|
@@ -601,7 +605,7 @@ export class RealtimeBridge {
|
|
|
601
605
|
}));
|
|
602
606
|
}
|
|
603
607
|
catch {
|
|
604
|
-
|
|
608
|
+
const errorRes = {
|
|
605
609
|
type: 'res',
|
|
606
610
|
id: payload.id,
|
|
607
611
|
ok: false,
|
|
@@ -609,7 +613,9 @@ export class RealtimeBridge {
|
|
|
609
613
|
code: 'GATEWAY_SEND_FAILED',
|
|
610
614
|
message: 'Failed to send request to gateway',
|
|
611
615
|
},
|
|
612
|
-
}
|
|
616
|
+
};
|
|
617
|
+
this.__forwardToServer(errorRes);
|
|
618
|
+
this.webrtcPeer?.broadcast(errorRes);
|
|
613
619
|
}
|
|
614
620
|
}
|
|
615
621
|
|
|
@@ -696,6 +702,24 @@ export class RealtimeBridge {
|
|
|
696
702
|
catch {}
|
|
697
703
|
return;
|
|
698
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
|
+
}
|
|
699
723
|
if (payload?.type === 'req' || payload?.type === 'rpc.req') {
|
|
700
724
|
void this.__handleGatewayRequestFromServer({
|
|
701
725
|
id: payload.id,
|
|
@@ -720,6 +744,12 @@ export class RealtimeBridge {
|
|
|
720
744
|
this.serverWs = null;
|
|
721
745
|
this.intentionallyClosed = false;
|
|
722
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
|
+
}
|
|
723
753
|
|
|
724
754
|
if (event?.code === 4001 || event?.code === 4003) {
|
|
725
755
|
try {
|
|
@@ -778,6 +808,10 @@ export class RealtimeBridge {
|
|
|
778
808
|
this.reconnectTimer = null;
|
|
779
809
|
}
|
|
780
810
|
this.__closeGatewayWs();
|
|
811
|
+
if (this.webrtcPeer) {
|
|
812
|
+
await this.webrtcPeer.closeAll().catch(() => {});
|
|
813
|
+
this.webrtcPeer = null;
|
|
814
|
+
}
|
|
781
815
|
const sock = this.serverWs;
|
|
782
816
|
if (sock) {
|
|
783
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
|
+
}
|