1use 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
14pub 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
37pub 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
52pub 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
87pub 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
134pub 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
149pub 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
172pub 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
187pub 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
202pub 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
231pub 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
246impl 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 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 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 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
459impl 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
597fn 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}