Skip to main content

netget/server/webrtc/
actions.rs

1//! WebRTC server protocol actions.
2//!
3//! Three events, all reachable (see `mod.rs` for the signalling transport that makes them
4//! so):
5//!
6//! - `webrtc_offer_received` — a peer sent an SDP offer over the signalling WebSocket. The
7//!   model answers `accept_offer` or `reject_offer`. This path is fail-closed: `mod.rs`
8//!   treats anything else, including an LLM error, as a refusal.
9//! - `webrtc_peer_connected` — the data channel opened.
10//! - `webrtc_message_received` — the peer sent a data-channel message.
11//!
12//! The last two accept `send_message` / `disconnect` / `wait_for_more`.
13//!
14//! No action carries SDP, ICE candidates or raw bytes. The model decides *whether* to admit
15//! a peer and *what to say* on the channel; the SDP never leaves Rust.
16//!
17//! `get_async_actions` is deliberately empty: server-side async actions are advertised
18//! nowhere in the LLM prompt path (only `call_llm_for_client` reads them, and that is the
19//! client trait), so declaring `list_peers`/`send_to_peer` here would advertise a capability
20//! the model has no way to invoke. `WebRtcServerData::list_peers` / `send_to_peer` exist for
21//! callers that hold the live server data.
22
23use crate::llm::actions::{
24    protocol_trait::{ActionResult, Protocol, Server},
25    ActionDefinition, Parameter, ParameterDefinition,
26};
27use crate::protocol::log_template::LogTemplate;
28use crate::protocol::EventType;
29use crate::state::app_state::AppState;
30use anyhow::{Context, Result};
31use serde_json::json;
32use std::sync::LazyLock;
33
34use super::OFFER_DECISION_RESULT;
35
36/// Default cap on simultaneous peers, overridable with the `max_peers` startup parameter.
37pub const DEFAULT_MAX_PEERS: usize = 32;
38
39/// A peer offered a WebRTC connection over the signalling WebSocket.
40pub static WEBRTC_OFFER_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
41    EventType::new(
42        "webrtc_offer_received",
43        "A peer sent a WebRTC SDP offer. Decide whether to admit it. Answering with anything \
44         other than accept_offer refuses the peer.",
45        json!({
46            "type": "accept_offer"
47        }),
48    )
49    .with_actions(offer_actions())
50    .with_parameters(vec![
51        Parameter {
52            name: "peer_id".to_string(),
53            type_hint: "string".to_string(),
54            description: "Identifier the peer chose for itself".to_string(),
55            required: true,
56        },
57        Parameter {
58            name: "remote_addr".to_string(),
59            type_hint: "string".to_string(),
60            description: "Address the signalling connection came from".to_string(),
61            required: true,
62        },
63        Parameter {
64            name: "requests_data_channel".to_string(),
65            type_hint: "boolean".to_string(),
66            description: "Whether the offer contains a data channel (this server supports \
67                          nothing else)"
68                .to_string(),
69            required: true,
70        },
71        Parameter {
72            name: "media_kinds".to_string(),
73            type_hint: "array".to_string(),
74            description: "Media the peer asked for, e.g. [\"audio\"]. This server carries no \
75                          media, so a non-empty list means part of the offer cannot be served."
76                .to_string(),
77            required: true,
78        },
79    ])
80    .with_log_template(
81        LogTemplate::new()
82            .with_info("WebRTC offer from {peer_id} ({remote_addr})")
83            .with_debug("WebRTC offer peer_id={peer_id} data_channel={requests_data_channel}")
84            .with_trace("WebRTC offer: {json_pretty(.)}"),
85    )
86});
87
88/// WebRTC peer connected event (data channel opened)
89pub static WEBRTC_PEER_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
90    EventType::new(
91        "webrtc_peer_connected",
92        "WebRTC data channel opened and ready to send messages",
93        json!({
94            "type": "send_message",
95            "message": "Welcome to NetGet WebRTC server!"
96        }),
97    )
98    .with_actions(sync_actions())
99    .with_parameters(vec![
100        Parameter {
101            name: "peer_id".to_string(),
102            type_hint: "string".to_string(),
103            description: "Unique peer identifier".to_string(),
104            required: true,
105        },
106        Parameter {
107            name: "channel_label".to_string(),
108            type_hint: "string".to_string(),
109            description: "Data channel label chosen by the peer".to_string(),
110            required: true,
111        },
112    ])
113    .with_log_template(
114        LogTemplate::new()
115            .with_info("WebRTC peer {peer_id} connected (channel: {channel_label})")
116            .with_debug("WebRTC peer_id={peer_id} channel={channel_label}")
117            .with_trace("WebRTC connected: {json_pretty(.)}"),
118    )
119});
120
121/// WebRTC message received event
122pub static WEBRTC_MESSAGE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
123    EventType::new(
124        "webrtc_message_received",
125        "Message received from WebRTC peer over the data channel",
126        json!({
127            "type": "send_message",
128            "message": "Message received"
129        }),
130    )
131    .with_actions(sync_actions())
132    .with_parameters(vec![
133        Parameter {
134            name: "peer_id".to_string(),
135            type_hint: "string".to_string(),
136            description: "Unique peer identifier".to_string(),
137            required: true,
138        },
139        Parameter {
140            name: "message".to_string(),
141            type_hint: "string".to_string(),
142            description: "Received message text".to_string(),
143            required: true,
144        },
145        Parameter {
146            name: "is_binary".to_string(),
147            type_hint: "boolean".to_string(),
148            description: "True if the peer sent a binary frame; its bytes are rendered as \
149                          lossy UTF-8 in 'message'"
150                .to_string(),
151            required: true,
152        },
153    ])
154    .with_log_template(
155        LogTemplate::new()
156            .with_info("WebRTC {peer_id} <- {preview(message,50)}")
157            .with_debug("WebRTC message from peer_id={peer_id} binary={is_binary}")
158            .with_trace("WebRTC message: {json_pretty(.)}"),
159    )
160});
161
162/// Actions available while deciding on an incoming offer.
163fn offer_actions() -> Vec<ActionDefinition> {
164    vec![
165        ActionDefinition {
166            name: "accept_offer".to_string(),
167            description: "Admit this peer: answer its SDP offer and open the data channel"
168                .to_string(),
169            parameters: vec![],
170            example: json!({
171                "type": "accept_offer"
172            }),
173            log_template: Some(
174                LogTemplate::new()
175                    .with_info("-> WebRTC accept offer")
176                    .with_debug("WebRTC accept_offer"),
177            ),
178        },
179        ActionDefinition {
180            name: "reject_offer".to_string(),
181            description: "Refuse this peer. The reason is sent back in the signalling response."
182                .to_string(),
183            parameters: vec![Parameter {
184                name: "reason".to_string(),
185                type_hint: "string".to_string(),
186                description: "Why the peer is being refused. Optional: a refusal with no \
187                              reason is still a refusal, and the peer gets a generic one. \
188                              This was declared required while the executor defaulted it, \
189                              which is the wrong half of that pair to fix — refusing to \
190                              execute a rejection because it lacked a sentence would turn a \
191                              model's denial into an error, and an error is not a denial."
192                    .to_string(),
193                required: false,
194            }],
195            example: json!({
196                "type": "reject_offer",
197                "reason": "unknown peer"
198            }),
199            log_template: Some(
200                LogTemplate::new()
201                    .with_info("-> WebRTC reject offer: {reason}")
202                    .with_debug("WebRTC reject_offer reason={reason}"),
203            ),
204        },
205    ]
206}
207
208/// Actions available on an open data channel.
209fn sync_actions() -> Vec<ActionDefinition> {
210    vec![
211        ActionDefinition {
212            name: "send_message".to_string(),
213            description: "Send a text message on this peer's data channel".to_string(),
214            parameters: vec![Parameter {
215                name: "message".to_string(),
216                type_hint: "string".to_string(),
217                description: "Message text to send".to_string(),
218                required: true,
219            }],
220            example: json!({
221                "type": "send_message",
222                "message": "Reply message"
223            }),
224            log_template: Some(
225                LogTemplate::new()
226                    .with_info("-> WebRTC message")
227                    .with_debug("WebRTC send_message"),
228            ),
229        },
230        ActionDefinition {
231            name: "disconnect".to_string(),
232            description: "Close the peer connection".to_string(),
233            parameters: vec![],
234            example: json!({
235                "type": "disconnect"
236            }),
237            log_template: Some(
238                LogTemplate::new()
239                    .with_info("-> WebRTC disconnect")
240                    .with_debug("WebRTC disconnect"),
241            ),
242        },
243        ActionDefinition {
244            name: "wait_for_more".to_string(),
245            description: "Wait for more messages before responding".to_string(),
246            parameters: vec![],
247            example: json!({
248                "type": "wait_for_more"
249            }),
250            log_template: Some(
251                LogTemplate::new()
252                    .with_info("-> WebRTC wait")
253                    .with_debug("WebRTC wait_for_more"),
254            ),
255        },
256    ]
257}
258
259/// WebRTC server protocol action handler
260pub struct WebRtcProtocol;
261
262impl WebRtcProtocol {
263    pub fn new() -> Self {
264        Self
265    }
266}
267
268impl Default for WebRtcProtocol {
269    fn default() -> Self {
270        Self::new()
271    }
272}
273
274// Implement Protocol trait (common functionality)
275impl Protocol for WebRtcProtocol {
276    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
277        vec![
278            ParameterDefinition {
279                name: "ice_servers".to_string(),
280                description: "STUN/TURN server URLs for ICE. Default is none, which uses host \
281                              candidates only — correct for localhost and LAN peers, and it \
282                              contacts no third party. Add a STUN server for NAT traversal."
283                    .to_string(),
284                type_hint: "array".to_string(),
285                required: false,
286                example: json!(["stun:stun.l.google.com:19302"]),
287                default: None,
288            },
289            ParameterDefinition {
290                name: "max_peers".to_string(),
291                description: format!(
292                    "Refuse offers once this many peers are connected (default {})",
293                    DEFAULT_MAX_PEERS
294                ),
295                type_hint: "integer".to_string(),
296                required: false,
297                example: json!(32),
298                default: None,
299            },
300            ParameterDefinition {
301                name: "idle_timeout_secs".to_string(),
302                description: "Seconds the signalling WebSocket may carry no frame at all before \
303                              the server closes it, ending the peer (default 600). The server \
304                              pings at half of it and every WebSocket client answers \
305                              automatically, so a live peer is never closed."
306                    .to_string(),
307                type_hint: "integer".to_string(),
308                required: false,
309                example: json!(600),
310                default: Some(serde_json::json!(super::IDLE_TIMEOUT.as_secs())),
311            },
312        ]
313    }
314
315    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
316        // Deliberately empty; see the module header.
317        Vec::new()
318    }
319
320    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
321        let mut actions = offer_actions();
322        actions.extend(sync_actions());
323        actions
324    }
325
326    fn protocol_name(&self) -> &'static str {
327        "WebRTC"
328    }
329
330    fn get_event_types(&self) -> Vec<EventType> {
331        vec![
332            WEBRTC_OFFER_RECEIVED_EVENT.clone(),
333            WEBRTC_PEER_CONNECTED_EVENT.clone(),
334            WEBRTC_MESSAGE_RECEIVED_EVENT.clone(),
335        ]
336    }
337
338    fn stack_name(&self) -> &'static str {
339        "ETH>IP>UDP>DTLS>SCTP>DataChannel"
340    }
341
342    fn keywords(&self) -> Vec<&'static str> {
343        vec![
344            "webrtc",
345            "webrtc server",
346            "data channel",
347            "peer to peer",
348            "p2p",
349        ]
350    }
351
352    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
353        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
354
355        ProtocolMetadataV2::builder()
356            .state(DevelopmentState::Beta)
357            // The signalling WebSocket's message limit; data-channel messages are capped lower
358            // inside webrtc-rs. Tested in tests/server/webrtc/inbound_limit_test.rs.
359            .max_inbound_bytes(crate::server::webrtc::SIGNALLING_MAX_MESSAGE_BYTES)
360            .implementation(
361                "webrtc-rs 0.11 peer connections with built-in WebSocket signalling \
362                 (tokio-tungstenite): the server binds its port, answers SDP offers and runs \
363                 data channels over DTLS/SCTP",
364            )
365            .llm_control(
366                "Admission (accept_offer / reject_offer, fail-closed) and data-channel content \
367                 (send_message / disconnect / wait_for_more). SDP and ICE stay in Rust.",
368            )
369            .e2e_testing(
370                "webrtc-rs 0.11 is the peer, not a mock: an `RTCPeerConnection` creates a data \
371                 channel, gathers a real SDP offer, exchanges SDP over the server's signalling \
372                 WebSocket, completes ICE, DTLS and SCTP, and the test asserts the exact \
373                 messages that crossed the channel in both directions — the peer's send is what \
374                 provokes the server's reply, so receiving it proves both directions \
375                 (tests/server/webrtc/e2e_test.rs::test_webrtc_data_channel_message_round_trip, \
376                 not #[ignore]d)",
377            )
378            .notes(
379                "Browser interoperability is still unverified — webrtc-rs is a real independent \
380                 peer, but it is not Chrome or Firefox, and that is the check a human should \
381                 make before this goes past Beta. Data channels only — no audio or video, and a \
382                 media m-line in an offer is reported to the model but never served. ICE is not \
383                 trickled: the peer must \
384                 gather candidates before sending its offer. One peer per signalling \
385                 WebSocket, and closing that socket closes the peer connection. Binary \
386                 data-channel frames are surfaced to the model as lossy UTF-8; replies are \
387                 always text.",
388            )
389            .build()
390    }
391
392    fn description(&self) -> &'static str {
393        "WebRTC data-channel server with built-in WebSocket signalling (no media)"
394    }
395
396    fn example_prompt(&self) -> &'static str {
397        "Open a WebRTC server on port 9000 that accepts peers and echoes data channel messages"
398    }
399
400    fn group_name(&self) -> &'static str {
401        "Real-time"
402    }
403
404    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
405        use crate::llm::actions::StartupExamples;
406
407        // Deterministic: echo each data-channel message back to the peer, no LLM
408        // call.
409        let script = r#"import json, sys
410data = json.load(sys.stdin)
411event = data["event"]
412if data["event_type_id"] == "webrtc_message_received":
413    actions = [{"type": "send_message", "message": event.get("message", "")}]
414else:
415    actions = []
416print(json.dumps({"actions": actions}))"#;
417
418        StartupExamples::new(
419            // LLM mode
420            json!({
421                "type": "open_server",
422                "port": 9000,
423                "base_stack": "webrtc",
424                "instruction": "WebRTC data channel server. Accept offers from peers whose peer_id starts with 'guest', and echo every message back."
425            }),
426            // Script mode
427            json!({
428                "type": "open_server",
429                "port": 9000,
430                "base_stack": "webrtc",
431                "event_handlers": [{
432                    "event_pattern": "webrtc_message_received",
433                    "handler": {
434                        "type": "script",
435                        "language": "python",
436                        "code": script
437                    }
438                }]
439            }),
440            // Static mode
441            json!({
442                "type": "open_server",
443                "port": 9000,
444                "base_stack": "webrtc",
445                "event_handlers": [
446                    {
447                        "event_pattern": "webrtc_offer_received",
448                        "handler": {
449                            "type": "static",
450                            "actions": [{"type": "accept_offer"}]
451                        }
452                    },
453                    {
454                        "event_pattern": "webrtc_peer_connected",
455                        "handler": {
456                            "type": "static",
457                            "actions": [{
458                                "type": "send_message",
459                                "message": "Welcome to NetGet WebRTC server!"
460                            }]
461                        }
462                    }
463                ]
464            }),
465        )
466    }
467}
468
469// Implement Server trait (server-specific functionality)
470impl Server for WebRtcProtocol {
471    fn spawn(
472        &self,
473        ctx: crate::protocol::SpawnContext,
474    ) -> std::pin::Pin<
475        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
476    > {
477        Box::pin(async move {
478            use crate::server::webrtc::WebRtcServer;
479
480            let mut ice_servers: Vec<String> = Vec::new();
481            let mut max_peers = DEFAULT_MAX_PEERS;
482            let mut idle_timeout = crate::server::webrtc::IDLE_TIMEOUT;
483
484            if let Some(params) = &ctx.startup_params {
485                if let Some(urls) = params.get_optional_array("ice_servers")? {
486                    for url in urls {
487                        match url.as_str() {
488                            Some(url) => ice_servers.push(url.to_string()),
489                            None => anyhow::bail!(
490                                "Invalid startup parameter 'ice_servers': every entry must be a \
491                                 STUN/TURN URL string, got {}",
492                                url
493                            ),
494                        }
495                    }
496                }
497                if let Some(limit) = params.get_optional_u64("max_peers")? {
498                    if limit == 0 {
499                        anyhow::bail!("Invalid startup parameter 'max_peers': must be at least 1");
500                    }
501                    max_peers = limit.min(u16::MAX as u64) as usize;
502                }
503                if let Some(secs) = params.get_optional_u64("idle_timeout_secs")? {
504                    idle_timeout = std::time::Duration::from_secs(secs);
505                }
506            }
507
508            let listen_addr = ctx
509                .socket_addr()
510                .unwrap_or_else(|| ctx.legacy_listen_addr());
511
512            WebRtcServer::spawn_with_llm_actions(
513                listen_addr,
514                ctx.llm_client,
515                ctx.state,
516                ctx.status_tx,
517                ctx.server_id,
518                ice_servers,
519                max_peers,
520                idle_timeout,
521            )
522            .await
523        })
524    }
525
526    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
527        let action_type = action
528            .get("type")
529            .and_then(|v| v.as_str())
530            .context("Missing 'type' field in action")?;
531
532        match action_type {
533            // The admission decision is carried back to the signalling task as a Custom
534            // result; `webrtc::WebRtcServer::decide_offer` is the consumer.
535            "accept_offer" => Ok(ActionResult::Custom {
536                name: OFFER_DECISION_RESULT.to_string(),
537                data: json!({ "accept": true }),
538            }),
539            "reject_offer" => {
540                let reason = action
541                    .get("reason")
542                    .and_then(|v| v.as_str())
543                    .unwrap_or("the model rejected this offer")
544                    .to_string();
545                Ok(ActionResult::Custom {
546                    name: OFFER_DECISION_RESULT.to_string(),
547                    data: json!({ "accept": false, "reason": reason }),
548                })
549            }
550            "send_message" => {
551                let message = action
552                    .get("message")
553                    .and_then(|v| v.as_str())
554                    .context("Missing 'message' field")?
555                    .to_string();
556
557                Ok(ActionResult::Output(message.into_bytes()))
558            }
559            "disconnect" => Ok(ActionResult::CloseConnection),
560            "wait_for_more" => Ok(ActionResult::WaitForMore),
561            _ => Err(anyhow::anyhow!(
562                "Unknown WebRTC server action: {}",
563                action_type
564            )),
565        }
566    }
567}