Skip to main content

netget/server/dc/
actions.rs

1//! DC protocol actions implementation
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter,
6};
7use crate::protocol::{
8    log_template::LogTemplate,
9    metadata::{DevelopmentState, ProtocolMetadataV2},
10    EventType,
11};
12use crate::server::connection::ConnectionId;
13use crate::state::app_state::AppState;
14use anyhow::{Context, Result};
15use serde_json::json;
16use std::collections::HashMap;
17use std::sync::{Arc, LazyLock};
18use tokio::sync::Mutex;
19
20/// DC client state for tracking user information
21#[derive(Clone, Debug)]
22pub struct DcClientState {
23    pub nickname: Option<String>,
24    pub description: Option<String>,
25    pub email: Option<String>,
26    pub share_size: u64,
27    pub is_operator: bool,
28}
29
30impl DcClientState {
31    pub fn new() -> Self {
32        Self {
33            nickname: None,
34            description: None,
35            email: None,
36            share_size: 0,
37            is_operator: false,
38        }
39    }
40}
41
42/// DC protocol action handler
43pub struct DcProtocol {
44    /// Map of active connections to their DC state
45    clients: Arc<Mutex<HashMap<ConnectionId, DcClientState>>>,
46}
47
48impl DcProtocol {
49    pub fn new() -> Self {
50        Self {
51            clients: Arc::new(Mutex::new(HashMap::new())),
52        }
53    }
54
55    /// Add a connection to the protocol handler
56    pub async fn add_connection(&self, connection_id: ConnectionId) {
57        self.clients
58            .lock()
59            .await
60            .insert(connection_id, DcClientState::new());
61    }
62
63    /// Remove a connection from the protocol handler
64    pub async fn remove_connection(&self, connection_id: &ConnectionId) {
65        self.clients.lock().await.remove(connection_id);
66    }
67
68    /// Set client nickname
69    pub async fn set_nickname(&self, connection_id: ConnectionId, nickname: String) {
70        if let Some(client) = self.clients.lock().await.get_mut(&connection_id) {
71            client.nickname = Some(nickname);
72        }
73    }
74
75    /// Get client nickname
76    pub async fn get_nickname(&self, connection_id: &ConnectionId) -> Option<String> {
77        self.clients
78            .lock()
79            .await
80            .get(connection_id)
81            .and_then(|c| c.nickname.clone())
82    }
83
84    /// Set client info
85    pub async fn set_client_info(
86        &self,
87        connection_id: ConnectionId,
88        description: Option<String>,
89        email: Option<String>,
90        share_size: u64,
91    ) {
92        if let Some(client) = self.clients.lock().await.get_mut(&connection_id) {
93            if let Some(desc) = description {
94                client.description = Some(desc);
95            }
96            if let Some(em) = email {
97                client.email = Some(em);
98            }
99            client.share_size = share_size;
100        }
101    }
102
103    /// Set operator status
104    pub async fn set_operator(&self, connection_id: ConnectionId, is_op: bool) {
105        if let Some(client) = self.clients.lock().await.get_mut(&connection_id) {
106            client.is_operator = is_op;
107        }
108    }
109
110    /// Get client state
111    pub async fn get_client_state(&self, connection_id: &ConnectionId) -> Option<DcClientState> {
112        self.clients.lock().await.get(connection_id).cloned()
113    }
114
115    /// Get all connected nicknames
116    pub async fn get_all_nicknames(&self) -> Vec<String> {
117        self.clients
118            .lock()
119            .await
120            .values()
121            .filter_map(|c| c.nickname.clone())
122            .collect()
123    }
124}
125
126// Implement Protocol trait (common functionality)
127impl Protocol for DcProtocol {
128    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
129        vec![
130            crate::llm::actions::ParameterDefinition {
131                name: "hub_name".to_string(),
132                type_hint: "string".to_string(),
133                description: "Name of the DC hub".to_string(),
134                required: false,
135                example: serde_json::json!("NetGet DC Hub"),
136                default: None,
137            },
138            crate::llm::actions::ParameterDefinition {
139                name: "hub_topic".to_string(),
140                type_hint: "string".to_string(),
141                description: "Hub topic/description".to_string(),
142                required: false,
143                example: serde_json::json!("Welcome to NetGet DC Hub"),
144                default: None,
145            },
146        ]
147    }
148    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
149        // DC could have async actions like broadcast_message in the future
150        Vec::new()
151    }
152    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
153        vec![
154            send_dc_lock_action(),
155            send_dc_hello_action(),
156            send_dc_hubname_action(),
157            send_dc_message_action(),
158            send_dc_broadcast_action(),
159            send_dc_userlist_action(),
160            send_dc_search_result_action(),
161            send_dc_kick_action(),
162            send_dc_redirect_action(),
163            send_dc_raw_action(),
164            wait_for_more_action(),
165            close_connection_action(),
166        ]
167    }
168    fn protocol_name(&self) -> &'static str {
169        "DC"
170    }
171    fn get_event_types(&self) -> Vec<EventType> {
172        vec![DC_COMMAND_RECEIVED_EVENT.clone()]
173    }
174    fn stack_name(&self) -> &'static str {
175        "ETH>IP>TCP>DC"
176    }
177    fn keywords(&self) -> Vec<&'static str> {
178        vec!["dc", "direct connect", "dc++", "nmdc", "via dc"]
179    }
180    fn metadata(&self) -> ProtocolMetadataV2 {
181        ProtocolMetadataV2::builder()
182                .state(DevelopmentState::Experimental)
183                .well_known_port(411)
184                .implementation("Manual NMDC protocol implementation - text-based with pipe delimiters")
185                .llm_control("Authentication (Lock/Key/Hello), chat messages, search results, user management (kick/redirect)")
186                .e2e_testing("tokio::net::TcpStream speaking NMDC by hand (tests/server/dc/): the $Lock handshake, chat, search, kick/redirect, the injected-peer path, and the fail-closed reply when the model is unreachable. No third-party DC++ client, so this stays Experimental")
187                .notes("NMDC protocol only (ADC not supported). No key validation or P2P connection handling. Commands are bounded at 64 KiB; interpolated action values have | \\r \\n stripped so one field cannot inject a second command.")
188                .max_inbound_bytes(crate::server::dc::MAX_COMMAND_LEN)
189            .build()
190    }
191    fn description(&self) -> &'static str {
192        "DC (Direct Connect) hub server - peer-to-peer file sharing protocol with chat and search capabilities"
193    }
194    fn example_prompt(&self) -> &'static str {
195        "Start a DC hub server"
196    }
197    fn group_name(&self) -> &'static str {
198        "Application"
199    }
200
201    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
202        use crate::llm::actions::StartupExamples;
203
204        // Deterministic: greet every command with a private message to the
205        // client, no LLM call.
206        let script = r#"import json, sys
207data = json.load(sys.stdin)
208event = data["event"]
209if data["event_type_id"] == "dc_command_received":
210    actions = [{"type": "send_dc_message",
211                "target": event.get("client_nickname", "user"),
212                "message": "Hello from NetGet"}]
213else:
214    actions = []
215print(json.dumps({"actions": actions}))"#;
216
217        StartupExamples::new(
218            // LLM mode
219            json!({
220                "type": "open_server",
221                "port": 411,
222                "base_stack": "dc",
223                "instruction": "Direct Connect hub server. Accept users with $Hello, send hub name, and broadcast welcome messages. Support chat and search."
224            }),
225            // Script mode
226            json!({
227                "type": "open_server",
228                "port": 411,
229                "base_stack": "dc",
230                "event_handlers": [{
231                    "event_pattern": "dc_command_received",
232                    "handler": {
233                        "type": "script",
234                        "language": "python",
235                        "code": script
236                    }
237                }]
238            }),
239            // Static mode
240            json!({
241                "type": "open_server",
242                "port": 411,
243                "base_stack": "dc",
244                "event_handlers": [{
245                    "event_pattern": "dc_command_received",
246                    "handler": {
247                        "type": "static",
248                        "actions": [{
249                            "type": "send_dc_lock",
250                            "lock": "EXTENDEDPROTOCOLABCABCABCABCABCABC",
251                            "pk": "NetGetHub"
252                        }]
253                    }
254                }]
255            }),
256        )
257    }
258}
259
260// Implement Server trait (server-specific functionality)
261impl Server for DcProtocol {
262    fn spawn(
263        &self,
264        ctx: crate::protocol::SpawnContext,
265    ) -> std::pin::Pin<
266        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
267    > {
268        Box::pin(async move {
269            use crate::server::dc::DcServer;
270
271            let listen_addr = ctx.legacy_listen_addr();
272            DcServer::spawn_with_llm_actions(
273                listen_addr,
274                ctx.llm_client,
275                ctx.state,
276                ctx.status_tx,
277                ctx.server_id,
278                ctx.startup_params,
279            )
280            .await
281        })
282    }
283    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
284        use crate::server::dc::nmdc_field as f;
285
286        let action_type = action
287            .get("type")
288            .and_then(|v| v.as_str())
289            .context("Missing action type")?;
290
291        // Every arm below builds `$Something {}|`, so a `|` inside any interpolated value ends
292        // the command early and the remainder is parsed by the client as a second command.
293        // `f()` strips the three framing characters; `send_dc_raw` is deliberately exempt,
294        // because sending an arbitrary frame is what it is for.
295        match action_type {
296            "send_dc_lock" => {
297                let lock = action
298                    .get("lock")
299                    .and_then(|v| v.as_str())
300                    .unwrap_or("EXTENDEDPROTOCOLABCABCABCABCABCABC");
301                let pk = action
302                    .get("pk")
303                    .and_then(|v| v.as_str())
304                    .unwrap_or("NetGetHub");
305
306                let msg = format!("$Lock {} Pk={}|", f(lock), f(pk));
307                Ok(ActionResult::Output(msg.into_bytes()))
308            }
309            "send_dc_hello" => {
310                let nickname = action
311                    .get("nickname")
312                    .and_then(|v| v.as_str())
313                    .context("Missing nickname")?;
314
315                let msg = format!("$Hello {}|", f(nickname));
316                Ok(ActionResult::Output(msg.into_bytes()))
317            }
318            "send_dc_hubname" => {
319                let name = action
320                    .get("name")
321                    .and_then(|v| v.as_str())
322                    .context("Missing hub name")?;
323
324                let msg = format!("$HubName {}|", f(name));
325                Ok(ActionResult::Output(msg.into_bytes()))
326            }
327            "send_dc_message" => {
328                let target = action
329                    .get("target")
330                    .and_then(|v| v.as_str())
331                    .context("Missing target")?;
332                let source = action
333                    .get("source")
334                    .and_then(|v| v.as_str())
335                    .unwrap_or("Hub");
336                let message = action
337                    .get("message")
338                    .and_then(|v| v.as_str())
339                    .context("Missing message")?;
340
341                let (target, source, message) = (f(target), f(source), f(message));
342                let msg = format!(
343                    "$To: {} From: {} $<{}> {}|",
344                    target, source, source, message
345                );
346                Ok(ActionResult::Output(msg.into_bytes()))
347            }
348            "send_dc_broadcast" => {
349                let source = action
350                    .get("source")
351                    .and_then(|v| v.as_str())
352                    .context("Missing source")?;
353                let message = action
354                    .get("message")
355                    .and_then(|v| v.as_str())
356                    .context("Missing message")?;
357
358                let msg = format!("<{}> {}|", f(source), f(message));
359                Ok(ActionResult::Output(msg.into_bytes()))
360            }
361            "send_dc_userlist" => {
362                let users = action
363                    .get("users")
364                    .and_then(|v| v.as_array())
365                    .context("Missing users array")?;
366
367                let user_list: Vec<String> =
368                    users.iter().filter_map(|u| u.as_str().map(f)).collect();
369
370                let msg = format!("$NickList {}$$|", user_list.join("$$"));
371                Ok(ActionResult::Output(msg.into_bytes()))
372            }
373            "send_dc_search_result" => {
374                let source = action
375                    .get("source")
376                    .and_then(|v| v.as_str())
377                    .context("Missing source")?;
378                let filename = action
379                    .get("filename")
380                    .and_then(|v| v.as_str())
381                    .context("Missing filename")?;
382                let size = action
383                    .get("size")
384                    .and_then(|v| v.as_u64())
385                    .context("Missing size")?;
386                let slots = action.get("slots").and_then(|v| v.as_u64()).unwrap_or(1);
387                let hub_name = action
388                    .get("hub_name")
389                    .and_then(|v| v.as_str())
390                    .unwrap_or("NetGetHub");
391
392                // $SR source filename\x05size slots/totalslots\x05hubname|
393                let msg = format!(
394                    "$SR {} {}\x05{} {}/{}\x05{}|",
395                    f(source),
396                    f(filename),
397                    size,
398                    slots,
399                    slots,
400                    f(hub_name)
401                );
402                Ok(ActionResult::Output(msg.into_bytes()))
403            }
404            "send_dc_kick" => {
405                let nickname = action
406                    .get("nickname")
407                    .and_then(|v| v.as_str())
408                    .context("Missing nickname")?;
409
410                let msg = format!("$Kick {}|", f(nickname));
411                Ok(ActionResult::Output(msg.into_bytes()))
412            }
413            "send_dc_redirect" => {
414                let address = action
415                    .get("address")
416                    .and_then(|v| v.as_str())
417                    .context("Missing address")?;
418
419                let msg = format!("$ForceMove {}|", f(address));
420                Ok(ActionResult::Output(msg.into_bytes()))
421            }
422            "send_dc_raw" => {
423                let mut command = action
424                    .get("command")
425                    .and_then(|v| v.as_str())
426                    .context("Missing command")?
427                    .to_string();
428
429                // Ensure pipe terminator
430                if !command.ends_with('|') {
431                    command.push('|');
432                }
433
434                Ok(ActionResult::Output(command.into_bytes()))
435            }
436            "wait_for_more" => Ok(ActionResult::WaitForMore),
437            "close_connection" => Ok(ActionResult::CloseConnection),
438            _ => Err(anyhow::anyhow!("Unknown DC action: {}", action_type)),
439        }
440    }
441}
442
443/// Event type ID for DC command received
444pub static DC_COMMAND_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
445    EventType::new(
446        "dc_command_received",
447        "A Direct Connect client sent a hub command. Answer with the action matching the stage \
448         of the handshake: reply to $Key/$ValidateNick with send_dc_hello and send_dc_hubname, \
449         to $GetNickList with send_dc_userlist, to $Search with send_dc_search_result, to chat \
450         and $To: with send_dc_message or send_dc_broadcast. send_dc_raw sends any other NMDC \
451         command verbatim.",
452        json!({
453            "type": "send_dc_hello",
454            "nickname": "alice"
455        }),
456    )
457    // All ten sync actions can answer this event - it is the protocol's only event and covers
458    // every stage of the NMDC handshake. `call_llm` builds the model's tool list from the event
459    // type, not from get_sync_actions(), so without this the model was offered none of them.
460    .with_actions(vec![
461        send_dc_lock_action(),
462        send_dc_hello_action(),
463        send_dc_hubname_action(),
464        send_dc_message_action(),
465        send_dc_broadcast_action(),
466        send_dc_userlist_action(),
467        send_dc_search_result_action(),
468        send_dc_kick_action(),
469        send_dc_redirect_action(),
470        send_dc_raw_action(),
471        wait_for_more_action(),
472        close_connection_action(),
473    ])
474    .with_parameters(vec![
475        Parameter {
476            name: "command".to_string(),
477            type_hint: "string".to_string(),
478            description: "The DC command received (without pipe terminator)".to_string(),
479            required: true,
480        },
481        Parameter {
482            name: "command_type".to_string(),
483            type_hint: "string".to_string(),
484            description: "Parsed command type (e.g., ValidateNick, MyINFO, Search)".to_string(),
485            required: true,
486        },
487        Parameter {
488            name: "client_nickname".to_string(),
489            type_hint: "string".to_string(),
490            description: "Client's nickname if set".to_string(),
491            required: false,
492        },
493    ])
494});
495
496// Action definitions
497fn send_dc_lock_action() -> ActionDefinition {
498    ActionDefinition {
499        name: "send_dc_lock".to_string(),
500        description: "Send $Lock challenge for authentication".to_string(),
501        parameters: vec![
502            Parameter {
503                name: "lock".to_string(),
504                type_hint: "string".to_string(),
505                description: "Lock string (default: random string)".to_string(),
506                required: false,
507            },
508            Parameter {
509                name: "pk".to_string(),
510                type_hint: "string".to_string(),
511                description: "PK string (default: NetGetHub)".to_string(),
512                required: false,
513            },
514        ],
515        example: json!({
516            "type": "send_dc_lock",
517            "lock": "EXTENDEDPROTOCOLABCABCABCABCABCABC",
518            "pk": "NetGetHub"
519        }),
520        log_template: Some(
521            LogTemplate::new()
522                .with_info("-> DC $Lock challenge")
523                .with_debug("DC send_dc_lock: pk={pk}"),
524        ),
525    }
526}
527
528fn send_dc_hello_action() -> ActionDefinition {
529    ActionDefinition {
530        name: "send_dc_hello".to_string(),
531        description: "Accept user with $Hello".to_string(),
532        parameters: vec![Parameter {
533            name: "nickname".to_string(),
534            type_hint: "string".to_string(),
535            description: "Client nickname to accept".to_string(),
536            required: true,
537        }],
538        example: json!({
539            "type": "send_dc_hello",
540            "nickname": "alice"
541        }),
542        log_template: Some(
543            LogTemplate::new()
544                .with_info("-> DC $Hello {nickname}")
545                .with_debug("DC send_dc_hello: nickname={nickname}"),
546        ),
547    }
548}
549
550fn send_dc_hubname_action() -> ActionDefinition {
551    ActionDefinition {
552        name: "send_dc_hubname".to_string(),
553        description: "Send hub name to client".to_string(),
554        parameters: vec![Parameter {
555            name: "name".to_string(),
556            type_hint: "string".to_string(),
557            description: "Hub name".to_string(),
558            required: true,
559        }],
560        example: json!({
561            "type": "send_dc_hubname",
562            "name": "NetGet DC Hub"
563        }),
564        log_template: Some(
565            LogTemplate::new()
566                .with_info("-> DC $HubName '{name}'")
567                .with_debug("DC send_dc_hubname: name={name}"),
568        ),
569    }
570}
571
572fn send_dc_message_action() -> ActionDefinition {
573    ActionDefinition {
574        name: "send_dc_message".to_string(),
575        description: "Send chat message from hub or user to specific client".to_string(),
576        parameters: vec![
577            Parameter {
578                name: "target".to_string(),
579                type_hint: "string".to_string(),
580                description: "Target nickname".to_string(),
581                required: true,
582            },
583            Parameter {
584                name: "source".to_string(),
585                type_hint: "string".to_string(),
586                description: "Source nickname (default: Hub)".to_string(),
587                required: false,
588            },
589            Parameter {
590                name: "message".to_string(),
591                type_hint: "string".to_string(),
592                description: "Message text".to_string(),
593                required: true,
594            },
595        ],
596        example: json!({
597            "type": "send_dc_message",
598            "target": "alice",
599            "source": "HubBot",
600            "message": "Welcome to the hub!"
601        }),
602        log_template: Some(
603            LogTemplate::new()
604                .with_info("-> DC PM {source}->{target}")
605                .with_debug("DC send_dc_message: from={source} to={target}"),
606        ),
607    }
608}
609
610fn send_dc_broadcast_action() -> ActionDefinition {
611    ActionDefinition {
612        name: "send_dc_broadcast".to_string(),
613        description: "Broadcast message to all connected clients".to_string(),
614        parameters: vec![
615            Parameter {
616                name: "source".to_string(),
617                type_hint: "string".to_string(),
618                description: "Source nickname or hub name".to_string(),
619                required: true,
620            },
621            Parameter {
622                name: "message".to_string(),
623                type_hint: "string".to_string(),
624                description: "Message text".to_string(),
625                required: true,
626            },
627        ],
628        example: json!({
629            "type": "send_dc_broadcast",
630            "source": "Hub",
631            "message": "Server maintenance in 5 minutes"
632        }),
633        log_template: Some(
634            LogTemplate::new()
635                .with_info("-> DC broadcast from {source}")
636                .with_debug("DC send_dc_broadcast: source={source}"),
637        ),
638    }
639}
640
641fn send_dc_userlist_action() -> ActionDefinition {
642    ActionDefinition {
643        name: "send_dc_userlist".to_string(),
644        description: "Send list of connected users".to_string(),
645        parameters: vec![Parameter {
646            name: "users".to_string(),
647            type_hint: "array".to_string(),
648            description: "Array of nicknames".to_string(),
649            required: true,
650        }],
651        example: json!({
652            "type": "send_dc_userlist",
653            "users": ["alice", "bob", "charlie"]
654        }),
655        log_template: Some(
656            LogTemplate::new()
657                .with_info("-> DC $NickList ({users_len} users)")
658                .with_debug("DC send_dc_userlist: count={users_len}"),
659        ),
660    }
661}
662
663fn send_dc_search_result_action() -> ActionDefinition {
664    ActionDefinition {
665        name: "send_dc_search_result".to_string(),
666        description: "Send search result to client".to_string(),
667        parameters: vec![
668            Parameter {
669                name: "source".to_string(),
670                type_hint: "string".to_string(),
671                description: "Source nickname (who has the file)".to_string(),
672                required: true,
673            },
674            Parameter {
675                name: "filename".to_string(),
676                type_hint: "string".to_string(),
677                description: "File name".to_string(),
678                required: true,
679            },
680            Parameter {
681                name: "size".to_string(),
682                type_hint: "number".to_string(),
683                description: "File size in bytes".to_string(),
684                required: true,
685            },
686            Parameter {
687                name: "slots".to_string(),
688                type_hint: "number".to_string(),
689                description: "Available slots (default: 1)".to_string(),
690                required: false,
691            },
692            Parameter {
693                name: "hub_name".to_string(),
694                type_hint: "string".to_string(),
695                description: "Hub name (default: NetGetHub)".to_string(),
696                required: false,
697            },
698        ],
699        example: json!({
700            "type": "send_dc_search_result",
701            "source": "bob",
702            "filename": "ubuntu-22.04.iso",
703            "size": 3654957056u64,
704            "slots": 2,
705            "hub_name": "NetGetHub"
706        }),
707        log_template: Some(
708            LogTemplate::new()
709                .with_info("-> DC $SR {filename} ({size}B)")
710                .with_debug(
711                    "DC send_dc_search_result: source={source} file={filename} size={size}",
712                ),
713        ),
714    }
715}
716
717fn send_dc_kick_action() -> ActionDefinition {
718    ActionDefinition {
719        name: "send_dc_kick".to_string(),
720        description: "Kick user from hub".to_string(),
721        parameters: vec![Parameter {
722            name: "nickname".to_string(),
723            type_hint: "string".to_string(),
724            description: "User to kick".to_string(),
725            required: true,
726        }],
727        example: json!({
728            "type": "send_dc_kick",
729            "nickname": "alice"
730        }),
731        log_template: Some(
732            LogTemplate::new()
733                .with_info("-> DC $Kick {nickname}")
734                .with_debug("DC send_dc_kick: nickname={nickname}"),
735        ),
736    }
737}
738
739fn send_dc_redirect_action() -> ActionDefinition {
740    ActionDefinition {
741        name: "send_dc_redirect".to_string(),
742        description: "Redirect user to another hub".to_string(),
743        parameters: vec![Parameter {
744            name: "address".to_string(),
745            type_hint: "string".to_string(),
746            description: "Target hub address (host:port)".to_string(),
747            required: true,
748        }],
749        example: json!({
750            "type": "send_dc_redirect",
751            "address": "hub.example.com:411"
752        }),
753        log_template: Some(
754            LogTemplate::new()
755                .with_info("-> DC $ForceMove {address}")
756                .with_debug("DC send_dc_redirect: address={address}"),
757        ),
758    }
759}
760
761/// Declared, not merely executable.
762///
763/// `execute_action` has always handled `wait_for_more` and `close_connection`, and the
764/// protocol's CLAUDE.md lists both as available actions - but neither was in
765/// `get_sync_actions()` or on any event, so `call_llm` never offered them and the model could
766/// only reach them by guessing a name it had not been shown.
767fn wait_for_more_action() -> ActionDefinition {
768    ActionDefinition {
769        name: "wait_for_more".to_string(),
770        // The old text was "Correct when the command received so far is incomplete", which
771        // cannot happen: the server raises an event only once it has read the `|` terminator,
772        // so every command reaching the model is already whole. There is no accumulating state
773        // here and none is needed. What the action is genuinely for is the very common NMDC
774        // case of a command that wants no reply.
775        description: "Answer this command with nothing and keep the connection open. Correct \
776            for the many NMDC commands a hub does not reply to ($Version, $MyINFO, $GetINFO). \
777            You will never be shown a partial command - the hub raises an event only after the \
778            closing '|' - so this is not for reassembling anything."
779            .to_string(),
780        parameters: vec![],
781        example: json!({"type": "wait_for_more"}),
782        log_template: None,
783    }
784}
785
786fn close_connection_action() -> ActionDefinition {
787    ActionDefinition {
788        name: "close_connection".to_string(),
789        description: "Disconnect this client without sending anything further.".to_string(),
790        parameters: vec![],
791        example: json!({"type": "close_connection"}),
792        log_template: None,
793    }
794}
795
796fn send_dc_raw_action() -> ActionDefinition {
797    ActionDefinition {
798        name: "send_dc_raw".to_string(),
799        description: "Send raw NMDC command (for advanced use)".to_string(),
800        parameters: vec![Parameter {
801            name: "command".to_string(),
802            type_hint: "string".to_string(),
803            description: "Raw command (auto-adds | if missing)".to_string(),
804            required: true,
805        }],
806        example: json!({
807            "type": "send_dc_raw",
808            "command": "$HubTopic Welcome to NetGet!"
809        }),
810        log_template: Some(
811            LogTemplate::new()
812                .with_info("-> DC raw command")
813                .with_debug("DC send_dc_raw: command={command}"),
814        ),
815    }
816}