Skip to main content

netget/client/dc/
actions.rs

1//! DC (Direct Connect) client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter,
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/// DC client connected event - received Lock challenge
15pub static DC_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
16    EventType::new(
17        "dc_client_connected",
18        "DC client connected to hub and received Lock challenge",
19        json!({"type": "wait_for_more"}),
20    )
21    .with_parameters(vec![
22        Parameter {
23            name: "lock".to_string(),
24            type_hint: "string".to_string(),
25            description: "Lock challenge from hub".to_string(),
26            required: true,
27        },
28        Parameter {
29            name: "pk".to_string(),
30            type_hint: "string".to_string(),
31            description: "Hub PK name from Lock".to_string(),
32            required: false,
33        },
34    ])
35});
36
37/// DC client authenticated event
38pub static DC_CLIENT_AUTHENTICATED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
39    EventType::new(
40        "dc_client_authenticated",
41        "DC client successfully authenticated with hub",
42        json!({"type": "send_dc_chat", "message": "Hello everyone!"}),
43    )
44    .with_parameters(vec![Parameter {
45        name: "nickname".to_string(),
46        type_hint: "string".to_string(),
47        description: "Accepted nickname".to_string(),
48        required: true,
49    }])
50});
51
52/// DC client message received event
53pub static DC_CLIENT_MESSAGE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
54    EventType::new(
55        "dc_client_message_received",
56        "Received chat message from hub or user",
57        json!({"type": "send_dc_chat", "message": "Hello everyone!"}),
58    )
59    .with_parameters(vec![
60        Parameter {
61            name: "source".to_string(),
62            type_hint: "string".to_string(),
63            description: "Message source (nickname or hub)".to_string(),
64            required: true,
65        },
66        Parameter {
67            name: "message".to_string(),
68            type_hint: "string".to_string(),
69            description: "Message text".to_string(),
70            required: true,
71        },
72        Parameter {
73            name: "is_private".to_string(),
74            type_hint: "boolean".to_string(),
75            description: "True if private message".to_string(),
76            required: true,
77        },
78        Parameter {
79            name: "target".to_string(),
80            type_hint: "string".to_string(),
81            description: "Target nickname (for private messages)".to_string(),
82            required: false,
83        },
84    ])
85});
86
87/// DC client search result received event
88pub static DC_CLIENT_SEARCH_RESULT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
89    EventType::new(
90        "dc_client_search_result",
91        "Received search result from hub",
92        json!({"type": "placeholder", "event_id": "dc_client_search_result"}),
93    )
94    .with_parameters(vec![
95        Parameter {
96            name: "source".to_string(),
97            type_hint: "string".to_string(),
98            description: "User who has the file".to_string(),
99            required: true,
100        },
101        Parameter {
102            name: "filename".to_string(),
103            type_hint: "string".to_string(),
104            description: "File name".to_string(),
105            required: true,
106        },
107        Parameter {
108            name: "size".to_string(),
109            type_hint: "number".to_string(),
110            description: "File size in bytes".to_string(),
111            required: true,
112        },
113        Parameter {
114            name: "free_slots".to_string(),
115            type_hint: "number".to_string(),
116            description: "Available download slots".to_string(),
117            required: true,
118        },
119        Parameter {
120            name: "total_slots".to_string(),
121            type_hint: "number".to_string(),
122            description: "Total download slots".to_string(),
123            required: true,
124        },
125        Parameter {
126            name: "hub_name".to_string(),
127            type_hint: "string".to_string(),
128            description: "Hub name where file is located".to_string(),
129            required: false,
130        },
131    ])
132});
133
134/// DC client user list received event
135pub static DC_CLIENT_USERLIST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
136    EventType::new(
137        "dc_client_userlist_received",
138        "Received list of connected users",
139        json!({"type": "send_dc_chat", "message": "Hello everyone!"}),
140    )
141    .with_parameters(vec![Parameter {
142        name: "users".to_string(),
143        type_hint: "array".to_string(),
144        description: "Array of nicknames".to_string(),
145        required: true,
146    }])
147});
148
149/// DC client hub info received event
150pub static DC_CLIENT_HUBINFO_EVENT: LazyLock<EventType> = LazyLock::new(|| {
151    EventType::new(
152        "dc_client_hubinfo_received",
153        "Received hub information",
154        json!({"type": "placeholder", "event_id": "dc_client_hubinfo_received"}),
155    )
156    .with_parameters(vec![
157        Parameter {
158            name: "hub_name".to_string(),
159            type_hint: "string".to_string(),
160            description: "Hub name".to_string(),
161            required: false,
162        },
163        Parameter {
164            name: "hub_topic".to_string(),
165            type_hint: "string".to_string(),
166            description: "Hub topic/description".to_string(),
167            required: false,
168        },
169    ])
170});
171
172/// DC client kicked event
173pub static DC_CLIENT_KICKED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
174    EventType::new(
175        "dc_client_kicked",
176        "Client was kicked from hub",
177        json!({"type": "placeholder", "event_id": "dc_client_kicked"}),
178    )
179    .with_parameters(vec![Parameter {
180        name: "nickname".to_string(),
181        type_hint: "string".to_string(),
182        description: "Nickname that was kicked".to_string(),
183        required: true,
184    }])
185});
186
187/// DC client redirect event
188pub static DC_CLIENT_REDIRECT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
189    EventType::new(
190        "dc_client_redirect",
191        "Client redirected to another hub",
192        json!({"type": "wait_for_more"}),
193    )
194    .with_parameters(vec![Parameter {
195        name: "address".to_string(),
196        type_hint: "string".to_string(),
197        description: "New hub address to connect to".to_string(),
198        required: true,
199    }])
200});
201
202/// DC client disconnected event
203pub static DC_CLIENT_DISCONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
204    EventType::new(
205        "dc_client_disconnected",
206        "DC client disconnected from hub",
207        json!({"type": "wait_for_more"}),
208    )
209    .with_parameters(vec![
210        Parameter {
211            name: "reason".to_string(),
212            type_hint: "string".to_string(),
213            description: "Disconnection reason".to_string(),
214            required: true,
215        },
216        Parameter {
217            name: "will_reconnect".to_string(),
218            type_hint: "boolean".to_string(),
219            description: "Whether auto-reconnect will attempt to reconnect".to_string(),
220            required: true,
221        },
222        Parameter {
223            name: "reconnect_attempt".to_string(),
224            type_hint: "number".to_string(),
225            description: "Current reconnection attempt number (0 if first disconnect)".to_string(),
226            required: false,
227        },
228    ])
229});
230
231/// DC client protocol action handler
232pub struct DcClientProtocol;
233
234impl DcClientProtocol {
235    pub fn new() -> Self {
236        Self
237    }
238}
239
240impl Default for DcClientProtocol {
241    fn default() -> Self {
242        Self::new()
243    }
244}
245
246// Implement Protocol trait (common functionality)
247impl Protocol for DcClientProtocol {
248    fn get_startup_parameters(&self) -> Vec<crate::llm::actions::ParameterDefinition> {
249        vec![
250            crate::llm::actions::ParameterDefinition {
251                name: "nickname".to_string(),
252                type_hint: "string".to_string(),
253                description: "Nickname to use on the hub".to_string(),
254                required: true,
255                example: json!("alice"),
256                default: None,
257            },
258            crate::llm::actions::ParameterDefinition {
259                name: "description".to_string(),
260                type_hint: "string".to_string(),
261                description: "Client description".to_string(),
262                required: false,
263                example: json!("NetGet DC Client"),
264                default: None,
265            },
266            crate::llm::actions::ParameterDefinition {
267                name: "email".to_string(),
268                type_hint: "string".to_string(),
269                description: "Email address advertised in the client's MyINFO line, which every \
270                              other user on the hub can read. Optional; hubs rarely \
271                              require it and many users leave it empty."
272                    .to_string(),
273                required: false,
274                example: json!("user@example.com"),
275                default: None,
276            },
277            crate::llm::actions::ParameterDefinition {
278                name: "share_size".to_string(),
279                type_hint: "number".to_string(),
280                description: "Total bytes shared (fake for testing)".to_string(),
281                required: false,
282                example: json!(1073741824u64),
283                default: None,
284            },
285            crate::llm::actions::ParameterDefinition {
286                name: "use_tls".to_string(),
287                type_hint: "boolean".to_string(),
288                description: "Use TLS encryption (DCCS protocol, port 412)".to_string(),
289                required: false,
290                example: json!(false),
291                default: None,
292            },
293            crate::llm::actions::ParameterDefinition {
294                name: "auto_reconnect".to_string(),
295                type_hint: "boolean".to_string(),
296                description: "Automatically reconnect if disconnected".to_string(),
297                required: false,
298                example: json!(false),
299                default: None,
300            },
301            crate::llm::actions::ParameterDefinition {
302                name: "max_reconnect_attempts".to_string(),
303                type_hint: "number".to_string(),
304                description: "Maximum reconnection attempts (0 = unlimited)".to_string(),
305                required: false,
306                example: json!(5),
307                default: None,
308            },
309            crate::llm::actions::ParameterDefinition {
310                name: "initial_reconnect_delay_secs".to_string(),
311                type_hint: "number".to_string(),
312                description: "Initial delay before reconnecting (exponential backoff)".to_string(),
313                required: false,
314                example: json!(2),
315                default: None,
316            },
317        ]
318    }
319
320    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
321        vec![
322            send_dc_chat_action(),
323            send_dc_private_message_action(),
324            send_dc_search_action(),
325            send_dc_myinfo_action(),
326            send_dc_get_nicklist_action(),
327            send_dc_filelist_action(),
328            send_dc_raw_command_action(),
329            disconnect_action(),
330        ]
331    }
332
333    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
334        vec![
335            send_dc_chat_action(),
336            send_dc_private_message_action(),
337            send_dc_filelist_action(),
338            send_dc_raw_command_action(),
339            wait_for_more_action(),
340        ]
341    }
342
343    fn protocol_name(&self) -> &'static str {
344        "DC"
345    }
346
347    fn get_event_types(&self) -> Vec<EventType> {
348        vec![
349            DC_CLIENT_CONNECTED_EVENT.clone(),
350            DC_CLIENT_AUTHENTICATED_EVENT.clone(),
351            DC_CLIENT_MESSAGE_RECEIVED_EVENT.clone(),
352            DC_CLIENT_SEARCH_RESULT_EVENT.clone(),
353            DC_CLIENT_USERLIST_EVENT.clone(),
354            DC_CLIENT_HUBINFO_EVENT.clone(),
355            DC_CLIENT_KICKED_EVENT.clone(),
356            DC_CLIENT_REDIRECT_EVENT.clone(),
357            DC_CLIENT_DISCONNECTED_EVENT.clone(),
358        ]
359    }
360
361    fn stack_name(&self) -> &'static str {
362        "ETH>IP>TCP>DC"
363    }
364
365    fn keywords(&self) -> Vec<&'static str> {
366        vec!["dc", "direct connect", "dc++", "nmdc", "dc client"]
367    }
368
369    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
370        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
371
372        ProtocolMetadataV2::builder()
373            .state(DevelopmentState::Experimental)
374            .implementation("Manual NMDC protocol implementation with Lock/Key authentication")
375            .llm_control("Connect, authenticate, chat, search, user list management")
376            .e2e_testing("Uses local DC server from src/server/dc/")
377            .notes("NMDC protocol only (no ADC). No P2P file transfers. Hub interaction only. 64 KiB inbound pipe-delimited frames; 600 s whole-frame deadline; truncated frames are refused. Binary Lock/Key escaping follows NMDC.")
378            .build()
379    }
380
381    fn description(&self) -> &'static str {
382        "DC (Direct Connect) client - peer-to-peer file sharing protocol client"
383    }
384
385    fn example_prompt(&self) -> &'static str {
386        "Connect to DC hub at localhost:411 as 'alice' and say hello in chat"
387    }
388
389    fn group_name(&self) -> &'static str {
390        "Application"
391    }
392    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
393        use crate::llm::actions::StartupExamples;
394        use serde_json::json;
395
396        StartupExamples::new(
397            // LLM mode: LLM controls DC hub interaction
398            json!({
399                "type": "open_client",
400                "remote_addr": "localhost:411",
401                "base_stack": "dc",
402                "instruction": "Connect as alice and say hello in chat",
403                "startup_params": {
404                    "nickname": "alice",
405                    "description": "NetGet DC Client"
406                }
407            }),
408            // Script mode: Code-based DC message handling
409            json!({
410                "type": "open_client",
411                "remote_addr": "localhost:411",
412                "base_stack": "dc",
413                "startup_params": {
414                    "nickname": "alice"
415                },
416                "event_handlers": [{
417                    "event_pattern": "dc_client_message_received",
418                    "handler": {
419                        "type": "script",
420                        "language": "python",
421                        "code": "<dc_client_handler>"
422                    }
423                }]
424            }),
425            // Static mode: Fixed DC chat message
426            json!({
427                "type": "open_client",
428                "remote_addr": "localhost:411",
429                "base_stack": "dc",
430                "startup_params": {
431                    "nickname": "alice"
432                },
433                "event_handlers": [
434                    {
435                        "event_pattern": "dc_client_authenticated",
436                        "handler": {
437                            "type": "static",
438                            "actions": [{
439                                "type": "send_dc_chat",
440                                "message": "Hello everyone!"
441                            }]
442                        }
443                    },
444                    {
445                        "event_pattern": "dc_client_message_received",
446                        "handler": {
447                            "type": "static",
448                            "actions": [{
449                                "type": "disconnect"
450                            }]
451                        }
452                    }
453                ]
454            }),
455        )
456    }
457}
458
459// Implement Client trait (client-specific functionality)
460impl Client for DcClientProtocol {
461    fn connect(
462        &self,
463        ctx: crate::protocol::ConnectContext,
464    ) -> std::pin::Pin<
465        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
466    > {
467        Box::pin(async move {
468            use crate::client::dc::DcClient;
469            DcClient::connect_with_llm_actions(
470                ctx.remote_addr,
471                ctx.llm_client,
472                ctx.state,
473                ctx.status_tx,
474                ctx.client_id,
475                ctx.startup_params,
476            )
477            .await
478        })
479    }
480
481    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
482        let action_type = action
483            .get("type")
484            .and_then(|v| v.as_str())
485            .context("Missing 'type' field in action")?;
486
487        match action_type {
488            "send_dc_chat" => {
489                let message = action
490                    .get("message")
491                    .and_then(|v| v.as_str())
492                    .context("Missing 'message' field")?;
493
494                Ok(ClientActionResult::Custom {
495                    name: "dc_chat".to_string(),
496                    data: json!({
497                        "message": message,
498                    }),
499                })
500            }
501            "send_dc_private_message" => {
502                let target = action
503                    .get("target")
504                    .and_then(|v| v.as_str())
505                    .context("Missing 'target' field")?;
506                let message = action
507                    .get("message")
508                    .and_then(|v| v.as_str())
509                    .context("Missing 'message' field")?;
510
511                Ok(ClientActionResult::Custom {
512                    name: "dc_private_message".to_string(),
513                    data: json!({
514                        "target": target,
515                        "message": message,
516                    }),
517                })
518            }
519            "send_dc_search" => {
520                let query = action
521                    .get("query")
522                    .and_then(|v| v.as_str())
523                    .context("Missing 'query' field")?;
524                let size_restricted = action
525                    .get("size_restricted")
526                    .and_then(|v| v.as_bool())
527                    .unwrap_or(false);
528                let min_size = action.get("min_size").and_then(|v| v.as_u64()).unwrap_or(0);
529
530                Ok(ClientActionResult::Custom {
531                    name: "dc_search".to_string(),
532                    data: json!({
533                        "query": query,
534                        "size_restricted": size_restricted,
535                        "min_size": min_size,
536                    }),
537                })
538            }
539            "send_dc_myinfo" => {
540                let description = action
541                    .get("description")
542                    .and_then(|v| v.as_str())
543                    .unwrap_or("NetGet DC Client");
544                let email = action.get("email").and_then(|v| v.as_str()).unwrap_or("");
545                let share_size = action
546                    .get("share_size")
547                    .and_then(|v| v.as_u64())
548                    .unwrap_or(0);
549
550                Ok(ClientActionResult::Custom {
551                    name: "dc_myinfo".to_string(),
552                    data: json!({
553                        "description": description,
554                        "email": email,
555                        "share_size": share_size,
556                    }),
557                })
558            }
559            "send_dc_get_nicklist" => Ok(ClientActionResult::Custom {
560                name: "dc_get_nicklist".to_string(),
561                data: json!({}),
562            }),
563            "send_dc_filelist" => {
564                let files = action
565                    .get("files")
566                    .and_then(|v| v.as_array())
567                    .cloned()
568                    .unwrap_or_default();
569
570                Ok(ClientActionResult::Custom {
571                    name: "dc_filelist".to_string(),
572                    data: json!({
573                        "files": files,
574                    }),
575                })
576            }
577            "send_dc_raw_command" => {
578                let command = action
579                    .get("command")
580                    .and_then(|v| v.as_str())
581                    .context("Missing 'command' field")?;
582
583                Ok(ClientActionResult::Custom {
584                    name: "dc_raw_command".to_string(),
585                    data: json!({
586                        "command": command,
587                    }),
588                })
589            }
590            "disconnect" => Ok(ClientActionResult::Disconnect),
591            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
592            _ => Err(anyhow::anyhow!("Unknown DC client action: {}", action_type)),
593        }
594    }
595}
596
597// Action definitions
598
599fn send_dc_chat_action() -> ActionDefinition {
600    ActionDefinition {
601        name: "send_dc_chat".to_string(),
602        description: "Send public chat message to hub".to_string(),
603        parameters: vec![Parameter {
604            name: "message".to_string(),
605            type_hint: "string".to_string(),
606            description: "Message text".to_string(),
607            required: true,
608        }],
609        example: json!({
610            "type": "send_dc_chat",
611            "message": "Hello everyone!"
612        }),
613        log_template: None,
614    }
615}
616
617fn send_dc_private_message_action() -> ActionDefinition {
618    ActionDefinition {
619        name: "send_dc_private_message".to_string(),
620        description: "Send private message to specific user".to_string(),
621        parameters: vec![
622            Parameter {
623                name: "target".to_string(),
624                type_hint: "string".to_string(),
625                description: "Target nickname".to_string(),
626                required: true,
627            },
628            Parameter {
629                name: "message".to_string(),
630                type_hint: "string".to_string(),
631                description: "Message text".to_string(),
632                required: true,
633            },
634        ],
635        example: json!({
636            "type": "send_dc_private_message",
637            "target": "bob",
638            "message": "Hey Bob!"
639        }),
640        log_template: None,
641    }
642}
643
644fn send_dc_search_action() -> ActionDefinition {
645    ActionDefinition {
646        name: "send_dc_search".to_string(),
647        description: "Search for files in the hub".to_string(),
648        parameters: vec![
649            Parameter {
650                name: "query".to_string(),
651                type_hint: "string".to_string(),
652                description: "Search query".to_string(),
653                required: true,
654            },
655            Parameter {
656                name: "size_restricted".to_string(),
657                type_hint: "boolean".to_string(),
658                description: "Whether to restrict by size".to_string(),
659                required: false,
660            },
661            Parameter {
662                name: "min_size".to_string(),
663                type_hint: "number".to_string(),
664                description: "Minimum file size (if size_restricted)".to_string(),
665                required: false,
666            },
667        ],
668        example: json!({
669            "type": "send_dc_search",
670            "query": "ubuntu iso"
671        }),
672        log_template: None,
673    }
674}
675
676fn send_dc_myinfo_action() -> ActionDefinition {
677    ActionDefinition {
678        name: "send_dc_myinfo".to_string(),
679        description: "Send client information to hub".to_string(),
680        parameters: vec![
681            Parameter {
682                name: "description".to_string(),
683                type_hint: "string".to_string(),
684                description: "Client description".to_string(),
685                required: false,
686            },
687            Parameter {
688                name: "email".to_string(),
689                type_hint: "string".to_string(),
690                description: "Email address advertised in the client's MyINFO line, which every \
691                              other user on the hub can read. Optional; hubs rarely \
692                              require it and many users leave it empty."
693                    .to_string(),
694                required: false,
695            },
696            Parameter {
697                name: "share_size".to_string(),
698                type_hint: "number".to_string(),
699                description: "Total bytes shared".to_string(),
700                required: false,
701            },
702        ],
703        example: json!({
704            "type": "send_dc_myinfo",
705            "description": "NetGet DC Client",
706            "email": "user@example.com",
707            "share_size": 10737418240u64
708        }),
709        log_template: None,
710    }
711}
712
713fn send_dc_get_nicklist_action() -> ActionDefinition {
714    ActionDefinition {
715        name: "send_dc_get_nicklist".to_string(),
716        description: "Request list of connected users from hub".to_string(),
717        parameters: vec![],
718        example: json!({
719            "type": "send_dc_get_nicklist"
720        }),
721        log_template: None,
722    }
723}
724
725fn send_dc_filelist_action() -> ActionDefinition {
726    ActionDefinition {
727        name: "send_dc_filelist".to_string(),
728        description: "Configure and send file list in response to hub request. File list is XML format with fake/empty entries.".to_string(),
729        parameters: vec![
730            Parameter {
731                name: "files".to_string(),
732                type_hint: "array".to_string(),
733                description: "Array of file objects with name, size, and optional TTH hash".to_string(),
734                required: false,
735            },
736        ],
737        example: json!({
738            "type": "send_dc_filelist",
739            "files": [
740                {
741                    "name": "example.txt",
742                    "size": 1024,
743                    "tth": "ABCD1234567890"
744                }
745            ]
746        }),
747        log_template: None,
748    }
749}
750
751fn send_dc_raw_command_action() -> ActionDefinition {
752    ActionDefinition {
753        name: "send_dc_raw_command".to_string(),
754        description: "Send raw NMDC command to hub (for advanced use)".to_string(),
755        parameters: vec![Parameter {
756            name: "command".to_string(),
757            type_hint: "string".to_string(),
758            description: "Raw NMDC command (will auto-add | if missing)".to_string(),
759            required: true,
760        }],
761        example: json!({
762            "type": "send_dc_raw_command",
763            "command": "$Version 1,0091"
764        }),
765        log_template: None,
766    }
767}
768
769fn disconnect_action() -> ActionDefinition {
770    ActionDefinition {
771        name: "disconnect".to_string(),
772        description: "Disconnect from the DC hub".to_string(),
773        parameters: vec![],
774        example: json!({
775            "type": "disconnect"
776        }),
777        log_template: None,
778    }
779}
780
781fn wait_for_more_action() -> ActionDefinition {
782    ActionDefinition {
783        name: "wait_for_more".to_string(),
784        description: "Wait for more messages before responding".to_string(),
785        parameters: vec![],
786        example: json!({
787            "type": "wait_for_more"
788        }),
789        log_template: None,
790    }
791}