1use 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
36pub const DEFAULT_MAX_PEERS: usize = 32;
38
39pub 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
88pub 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
121pub 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
162fn 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
208fn 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
259pub 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
274impl 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 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 .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 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 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 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 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
469impl 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 "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}