Skip to main content

netget/client/webrtc/
actions.rs

1//! WebRTC client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter, ParameterDefinition,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// WebRTC client channel opened event (supports multi-channel)
15pub static WEBRTC_CLIENT_CHANNEL_OPENED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "webrtc_channel_opened",
18        "WebRTC data channel opened (supports multiple channels)",
19        json!({
20            "type": "send_message",
21            "channel": "netget",
22            "message": "Hello, peer!"
23        }),
24    )
25    .with_parameters(vec![Parameter {
26        name: "channel_label".to_string(),
27        type_hint: "string".to_string(),
28        description: "Data channel label".to_string(),
29        required: true,
30    }])
31});
32
33/// WebRTC client message received event (enhanced with channel label and binary support)
34pub static WEBRTC_CLIENT_MESSAGE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
35    EventType::new(
36        "webrtc_message_received",
37        "Message received from WebRTC peer on data channel",
38        json!({
39            "type": "send_message",
40            "message": "Reply message"
41        }),
42    )
43    .with_parameters(vec![
44        Parameter {
45            name: "channel_label".to_string(),
46            type_hint: "string".to_string(),
47            description: "Data channel label where message was received".to_string(),
48            required: true,
49        },
50        Parameter {
51            name: "message".to_string(),
52            type_hint: "string".to_string(),
53            description: "Received message (text or hex-encoded binary)".to_string(),
54            required: true,
55        },
56        Parameter {
57            name: "is_binary".to_string(),
58            type_hint: "boolean".to_string(),
59            description: "Whether message is hex-encoded binary data".to_string(),
60            required: true,
61        },
62    ])
63});
64
65/// WebRTC client signaling connected event (WebSocket mode)
66pub static WEBRTC_CLIENT_SIGNALING_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
67    EventType::new(
68        "webrtc_signaling_connected",
69        "Connected to WebSocket signaling server",
70        json!({
71            "type": "wait_for_more"
72        }),
73    )
74    .with_parameters(vec![
75        Parameter {
76            name: "peer_id".to_string(),
77            type_hint: "string".to_string(),
78            description: "Registered peer ID on signaling server".to_string(),
79            required: true,
80        },
81        Parameter {
82            name: "server_url".to_string(),
83            type_hint: "string".to_string(),
84            description: "Signaling server WebSocket URL".to_string(),
85            required: true,
86        },
87    ])
88});
89
90/// WebRTC client protocol action handler
91pub struct WebRtcClientProtocol;
92
93impl WebRtcClientProtocol {
94    pub fn new() -> Self {
95        Self
96    }
97}
98
99// Implement Protocol trait (common functionality)
100impl Protocol for WebRtcClientProtocol {
101    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
102        vec![ParameterDefinition {
103            name: "ice_servers".to_string(),
104            description: "STUN/TURN servers for ICE. Default: NONE — host candidates \
105                              only, which is what a loopback or LAN peer uses. Set this to \
106                              reach anything behind NAT; the default used to be Google's \
107                              public STUN server, so merely starting a client talked to a \
108                              third party."
109                .to_string(),
110            type_hint: "array".to_string(),
111            required: false,
112            example: json!(["stun:stun.example.com:3478", "turn:turn.example.com:3478"]),
113            default: None,
114        }]
115    }
116
117    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
118        vec![
119            ActionDefinition {
120                name: "send_message".to_string(),
121                description:
122                    "Send a text or binary message over a data channel (use 'hex:' prefix for binary)"
123                        .to_string(),
124                parameters: vec![
125                    Parameter {
126                        name: "channel".to_string(),
127                        type_hint: "string".to_string(),
128                        description: "Channel label (default: 'netget')".to_string(),
129                        required: false,
130                    },
131                    Parameter {
132                        name: "message".to_string(),
133                        type_hint: "string".to_string(),
134                        description: "Message text or 'hex:HEXDATA' for binary".to_string(),
135                        required: true,
136                    },
137                ],
138                example: json!({
139                    "type": "send_message",
140                    "channel": "netget",
141                    "message": "Hello, WebRTC peer!"
142                }),
143            log_template: None,
144            },
145            ActionDefinition {
146                name: "send_binary".to_string(),
147                description: "Send binary data over a data channel (hex-encoded)".to_string(),
148                parameters: vec![
149                    Parameter {
150                        name: "channel".to_string(),
151                        type_hint: "string".to_string(),
152                        description: "Channel label (default: 'netget')".to_string(),
153                        required: false,
154                    },
155                    Parameter {
156                        name: "hex_data".to_string(),
157                        type_hint: "string".to_string(),
158                        description: "Hex-encoded binary data (e.g., '48656c6c6f')".to_string(),
159                        required: true,
160                    },
161                ],
162                example: json!({
163                    "type": "send_binary",
164                    "channel": "netget",
165                    "hex_data": "48656c6c6f"
166                }),
167            log_template: None,
168            },
169            ActionDefinition {
170                name: "apply_answer".to_string(),
171                description: "Apply the SDP answer from the remote peer to complete connection (manual mode)"
172                    .to_string(),
173                parameters: vec![Parameter {
174                    name: "answer_json".to_string(),
175                    type_hint: "string".to_string(),
176                    description: "SDP answer JSON from peer".to_string(),
177                    required: true,
178                }],
179                example: json!({
180                    "type": "apply_answer",
181                    "answer_json": "{\"type\":\"answer\",\"sdp\":\"...\"}"
182                }),
183            log_template: None,
184            },
185            ActionDefinition {
186                name: "create_channel".to_string(),
187                description: "Create a new data channel on the existing connection".to_string(),
188                parameters: vec![Parameter {
189                    name: "channel_label".to_string(),
190                    type_hint: "string".to_string(),
191                    description: "Label for the new data channel".to_string(),
192                    required: true,
193                }],
194                example: json!({
195                    "type": "create_channel",
196                    "channel_label": "file-transfer"
197                }),
198            log_template: None,
199            },
200            ActionDefinition {
201                name: "send_offer".to_string(),
202                description: "Send SDP offer to remote peer via signaling server (WebSocket mode)"
203                    .to_string(),
204                parameters: vec![Parameter {
205                    name: "target_peer".to_string(),
206                    type_hint: "string".to_string(),
207                    description: "Target peer ID on signaling server".to_string(),
208                    required: true,
209                }],
210                example: json!({
211                    "type": "send_offer",
212                    "target_peer": "peer-bob"
213                }),
214            log_template: None,
215            },
216            ActionDefinition {
217                name: "disconnect".to_string(),
218                description: "Close all data channels and the peer connection".to_string(),
219                parameters: vec![],
220                example: json!({
221                    "type": "disconnect"
222                }),
223            log_template: None,
224            },
225        ]
226    }
227
228    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
229        vec![
230            ActionDefinition {
231                name: "send_message".to_string(),
232                description:
233                    "Send a message in response to received data (text or 'hex:' prefix for binary)"
234                        .to_string(),
235                parameters: vec![
236                    Parameter {
237                        name: "channel".to_string(),
238                        type_hint: "string".to_string(),
239                        description: "Channel label (optional, uses receiving channel)".to_string(),
240                        required: false,
241                    },
242                    Parameter {
243                        name: "message".to_string(),
244                        type_hint: "string".to_string(),
245                        description: "Message text or 'hex:HEXDATA' for binary".to_string(),
246                        required: true,
247                    },
248                ],
249                example: json!({
250                    "type": "send_message",
251                    "message": "Reply message"
252                }),
253                log_template: None,
254            },
255            ActionDefinition {
256                name: "disconnect".to_string(),
257                description: "Close the connection".to_string(),
258                parameters: vec![],
259                example: json!({
260                    "type": "disconnect"
261                }),
262                log_template: None,
263            },
264            ActionDefinition {
265                name: "wait_for_more".to_string(),
266                description: "Wait for more messages before responding".to_string(),
267                parameters: vec![],
268                example: json!({
269                    "type": "wait_for_more"
270                }),
271                log_template: None,
272            },
273        ]
274    }
275
276    fn protocol_name(&self) -> &'static str {
277        "WebRTC"
278    }
279
280    /// The events this client raises — **the same `LazyLock` statics it actually
281    /// emits**, not fresh copies.
282    ///
283    /// This used to build four inline `EventType`s whose `example` was
284    /// `{"type": "placeholder", …}` and which carried no parameters, while the
285    /// statics above (which do carry parameters) were the ones emitted. Three
286    /// consequences, all invisible:
287    ///
288    /// * `placeholder` is not an action any executor accepts, so the one worked
289    ///   example per event was unusable.
290    /// * The parameter lists were not shown here at all.
291    /// * `webrtc_connected` was declared and emitted by **nothing**. Because it was
292    ///   inline rather than a `&CONST` emit site, `event_emit_sites_test` — which
293    ///   scans for constants — could not see it, so a never-emitted event was still
294    ///   offered to operators as a routing pattern. It is removed rather than
295    ///   relabelled: it was described as "(deprecated)", but nothing ever fired it,
296    ///   so there is no deprecation, only an absence.
297    fn get_event_types(&self) -> Vec<EventType> {
298        vec![
299            WEBRTC_CLIENT_CHANNEL_OPENED_EVENT.clone(),
300            WEBRTC_CLIENT_MESSAGE_RECEIVED_EVENT.clone(),
301            WEBRTC_CLIENT_SIGNALING_CONNECTED_EVENT.clone(),
302        ]
303    }
304
305    fn stack_name(&self) -> &'static str {
306        "ETH>IP>UDP>DTLS>SCTP>DataChannel"
307    }
308
309    fn keywords(&self) -> Vec<&'static str> {
310        vec![
311            "webrtc",
312            "webrtc client",
313            "data channel",
314            "peer to peer",
315            "p2p",
316            "signaling",
317        ]
318    }
319
320    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
321        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
322
323        ProtocolMetadataV2::builder()
324            .state(DevelopmentState::Experimental)
325            .implementation("webrtc-rs for data channels with WebSocket signaling support")
326            .llm_control("Full control over data channels, signaling, and binary/text messaging")
327            .e2e_testing("Manual SDP exchange or WebSocket signaling with local/remote peers")
328            .build()
329    }
330
331    fn description(&self) -> &'static str {
332        "WebRTC client for peer-to-peer data channels (text/binary, manual or WebSocket signaling)"
333    }
334
335    fn example_prompt(&self) -> &'static str {
336        "Open WebRTC client with automatic signaling or manual SDP exchange"
337    }
338
339    fn group_name(&self) -> &'static str {
340        "Real-time"
341    }
342    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
343        use crate::llm::actions::StartupExamples;
344        use serde_json::json;
345
346        StartupExamples::new(
347            // LLM mode: LLM controls WebRTC data channel messaging
348            json!({
349                "type": "open_client",
350                "remote_addr": "ws://localhost:8080/alice",
351                "base_stack": "webrtc",
352                "instruction": "Connect via signaling server and send hello message to peer bob"
353            }),
354            // Script mode: Code-based WebRTC message handling
355            json!({
356                "type": "open_client",
357                "remote_addr": "ws://localhost:8080/alice",
358                "base_stack": "webrtc",
359                "event_handlers": [{
360                    "event_pattern": "webrtc_message_received",
361                    "handler": {
362                        "type": "script",
363                        "language": "python",
364                        "code": "<webrtc_client_handler>"
365                    }
366                }]
367            }),
368            // Static mode: Fixed WebRTC message exchange
369            json!({
370                "type": "open_client",
371                "remote_addr": "ws://localhost:8080/alice",
372                "base_stack": "webrtc",
373                "event_handlers": [
374                    {
375                        "event_pattern": "webrtc_channel_opened",
376                        "handler": {
377                            "type": "static",
378                            "actions": [{
379                                "type": "send_message",
380                                "message": "Hello, peer!"
381                            }]
382                        }
383                    },
384                    {
385                        "event_pattern": "webrtc_message_received",
386                        "handler": {
387                            "type": "static",
388                            "actions": [{
389                                "type": "disconnect"
390                            }]
391                        }
392                    }
393                ]
394            }),
395        )
396    }
397}
398
399// Implement Client trait (client-specific functionality)
400impl Client for WebRtcClientProtocol {
401    fn connect(
402        &self,
403        ctx: crate::protocol::ConnectContext,
404    ) -> std::pin::Pin<
405        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
406    > {
407        Box::pin(async move {
408            use crate::client::webrtc::WebRtcClient;
409            WebRtcClient::connect_with_llm_actions(
410                ctx.remote_addr,
411                ctx.llm_client,
412                ctx.state,
413                ctx.status_tx,
414                ctx.client_id,
415                ctx.startup_params,
416            )
417            .await
418        })
419    }
420
421    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
422        let action_type = action
423            .get("type")
424            .and_then(|v| v.as_str())
425            .context("Missing 'type' field in action")?;
426
427        match action_type {
428            "send_message" => {
429                let message = action
430                    .get("message")
431                    .and_then(|v| v.as_str())
432                    .context("Missing 'message' field")?
433                    .to_string();
434
435                // Check if message has hex: prefix for binary data
436                let bytes = if let Some(hex_str) = message.strip_prefix("hex:") {
437                    // Decode hex to bytes
438                    hex::decode(hex_str).context("Invalid hex data in message")?
439                } else {
440                    // Send as text
441                    message.into_bytes()
442                };
443
444                Ok(ClientActionResult::SendData(bytes))
445            }
446            "send_binary" => {
447                let hex_data = action
448                    .get("hex_data")
449                    .and_then(|v| v.as_str())
450                    .context("Missing 'hex_data' field")?;
451
452                // Decode hex to bytes
453                let bytes = hex::decode(hex_data).context("Invalid hex data")?;
454
455                Ok(ClientActionResult::SendData(bytes))
456            }
457            "apply_answer" => {
458                let answer_json = action
459                    .get("answer_json")
460                    .and_then(|v| v.as_str())
461                    .context("Missing 'answer_json' field")?
462                    .to_string();
463
464                // Return custom result for async processing
465                Ok(ClientActionResult::Custom {
466                    name: "apply_answer".to_string(),
467                    data: json!({
468                        "answer_json": answer_json,
469                    }),
470                })
471            }
472            "create_channel" => {
473                let channel_label = action
474                    .get("channel_label")
475                    .and_then(|v| v.as_str())
476                    .context("Missing 'channel_label' field")?
477                    .to_string();
478
479                // Return custom result for async processing
480                Ok(ClientActionResult::Custom {
481                    name: "create_channel".to_string(),
482                    data: json!({
483                        "channel_label": channel_label,
484                    }),
485                })
486            }
487            "send_offer" => {
488                let target_peer = action
489                    .get("target_peer")
490                    .and_then(|v| v.as_str())
491                    .context("Missing 'target_peer' field")?
492                    .to_string();
493
494                // Return custom result for async processing
495                Ok(ClientActionResult::Custom {
496                    name: "send_offer".to_string(),
497                    data: json!({
498                        "target_peer": target_peer,
499                    }),
500                })
501            }
502            "disconnect" => Ok(ClientActionResult::Disconnect),
503            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
504            _ => Err(anyhow::anyhow!(
505                "Unknown WebRTC client action: {}",
506                action_type
507            )),
508        }
509    }
510}