@coclaw/openclaw-coclaw 0.6.2 → 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.
@@ -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.2",
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,
@@ -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
- this.__forwardToServer({
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
- this.__forwardToServer({
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
+ }