1use 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#[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
42pub struct DcProtocol {
44 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 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 pub async fn remove_connection(&self, connection_id: &ConnectionId) {
65 self.clients.lock().await.remove(connection_id);
66 }
67
68 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 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 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 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 pub async fn get_client_state(&self, connection_id: &ConnectionId) -> Option<DcClientState> {
112 self.clients.lock().await.get(connection_id).cloned()
113 }
114
115 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
126impl 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 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 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 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 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 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
260impl 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 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 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 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
443pub 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 .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
496fn 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
761fn wait_for_more_action() -> ActionDefinition {
768 ActionDefinition {
769 name: "wait_for_more".to_string(),
770 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}