Skip to main content

netget/client/dc/
mod.rs

1//! DC (Direct Connect) client implementation
2pub mod actions;
3
4pub use actions::DcClientProtocol;
5
6use anyhow::{Context, Result};
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::io::{AsyncWriteExt, BufReader};
10use tokio::net::TcpStream;
11use tokio::sync::{mpsc, Mutex};
12use tokio_rustls::client::TlsStream;
13use tokio_rustls::rustls::pki_types::ServerName;
14use tokio_rustls::rustls::{ClientConfig, RootCertStore};
15use tokio_rustls::TlsConnector;
16use tracing::{debug, error, info, trace};
17
18use crate::client::dc::actions::{
19    DC_CLIENT_AUTHENTICATED_EVENT, DC_CLIENT_CONNECTED_EVENT, DC_CLIENT_HUBINFO_EVENT,
20    DC_CLIENT_KICKED_EVENT, DC_CLIENT_MESSAGE_RECEIVED_EVENT, DC_CLIENT_REDIRECT_EVENT,
21    DC_CLIENT_SEARCH_RESULT_EVENT, DC_CLIENT_USERLIST_EVENT,
22};
23use crate::client::llm_budget::call_llm_for_client;
24use crate::llm::actions::client_trait::Client;
25use crate::llm::ollama_client::OllamaClient;
26use crate::llm::ClientLlmResult;
27use crate::protocol::Event;
28use crate::state::app_state::AppState;
29use crate::state::{ClientId, ClientStatus};
30
31/// Longest wait between two reconnection attempts, in seconds.
32pub const MAX_RECONNECT_DELAY_SECS: u64 = 60;
33
34/// Delay before reconnection attempt `attempt` (1-based): `initial_delay_secs` doubled for
35/// every attempt after the first, capped at [`MAX_RECONNECT_DELAY_SECS`].
36///
37/// The arithmetic saturates, so `max_reconnect_attempts: 0` ("unlimited") and a large
38/// `initial_reconnect_delay_secs` reach the cap instead of overflowing.
39pub fn reconnect_delay_secs(initial_delay_secs: u64, attempt: u32) -> u64 {
40    let factor = 2u64
41        .checked_pow(attempt.saturating_sub(1))
42        .unwrap_or(u64::MAX);
43    initial_delay_secs
44        .saturating_mul(factor)
45        .min(MAX_RECONNECT_DELAY_SECS)
46}
47
48/// Escape free text (a chat line, a private message, a search query, a `$MyINFO`
49/// description or email) for an NMDC frame, the way DC++ does: `$` becomes `$`, `|`
50/// becomes `|`, and a `&` that would otherwise read as the start of `$`, `|`
51/// or `&` becomes `&`. The text can then neither end the frame nor open a new
52/// command, and the hub decodes exactly the characters given.
53pub fn escape_nmdc_text(text: &str) -> String {
54    let mut out = String::with_capacity(text.len());
55    for (i, c) in text.char_indices() {
56        match c {
57            '$' => out.push_str("$"),
58            '|' => out.push_str("|"),
59            // `&` is one byte, so `i + 1` is a char boundary.
60            '&' if ["amp;", "#36;", "#124;"]
61                .iter()
62                .any(|entity| text[i + 1..].starts_with(entity)) =>
63            {
64                out.push_str("&")
65            }
66            _ => out.push(c),
67        }
68    }
69    out
70}
71
72/// Check a nickname before it is written into `$ValidateNick`, `$MyINFO`, a chat prefix or
73/// a `$To:` line.
74///
75/// NMDC has no escape for a nickname: `$` and `|` delimit commands and fields, whitespace
76/// ends the nickname in `$ValidateNick` and `$MyINFO`, and `<`/`>` frame it in chat. A
77/// nickname containing any of them, or a control character, or nothing at all, is refused
78/// rather than rewritten, because the hub would register a different name from the one asked
79/// for. Non-ASCII nicknames are accepted.
80pub fn validate_nmdc_nickname(nickname: &str) -> Result<&str> {
81    anyhow::ensure!(
82        !nickname.is_empty()
83            && !crate::utils::sanitize::has_controls(nickname)
84            && !nickname
85                .chars()
86                .any(|c| c.is_whitespace() || matches!(c, '$' | '|' | '<' | '>')),
87        "invalid NMDC nickname {:?}: it must be non-empty and contain no whitespace, control \
88         characters, '$', '|', '<' or '>'",
89        nickname
90    );
91    Ok(nickname)
92}
93
94/// Enum to handle both TCP and TLS write halves
95enum DcWriteHalf {
96    Plain(tokio::io::WriteHalf<TcpStream>),
97    Tls(tokio::io::WriteHalf<TlsStream<TcpStream>>),
98}
99
100impl DcWriteHalf {
101    async fn write_all(&mut self, buf: &[u8]) -> Result<()> {
102        match self {
103            DcWriteHalf::Plain(w) => w.write_all(buf).await.map_err(Into::into),
104            DcWriteHalf::Tls(w) => w.write_all(buf).await.map_err(Into::into),
105        }
106    }
107
108    async fn flush(&mut self) -> Result<()> {
109        match self {
110            DcWriteHalf::Plain(w) => w.flush().await.map_err(Into::into),
111            DcWriteHalf::Tls(w) => w.flush().await.map_err(Into::into),
112        }
113    }
114
115    async fn shutdown(&mut self) -> Result<()> {
116        match self {
117            DcWriteHalf::Plain(w) => w.shutdown().await.map_err(Into::into),
118            DcWriteHalf::Tls(w) => w.shutdown().await.map_err(Into::into),
119        }
120    }
121}
122
123/// Connection state for DC authentication
124#[derive(Debug, Clone, PartialEq)]
125enum DcConnectionState {
126    AwaitingLock,  // Waiting for hub's $Lock challenge
127    AwaitingHello, // Sent $Key, waiting for $Hello acceptance
128    Authenticated, // Fully authenticated, can chat/search
129}
130
131/// File entry for file list
132#[derive(Debug, Clone)]
133struct DcFileEntry {
134    name: String,
135    size: u64,
136    tth: Option<String>, // Tiger Tree Hash (optional)
137}
138
139/// Per-client state for DC
140struct DcClientState {
141    auth_state: DcConnectionState,
142    nickname: String,
143    description: String,
144    email: String,
145    share_size: u64,
146    memory: String,
147    file_list: Vec<DcFileEntry>, // Files to advertise
148    /// Set once a `disconnect` action has written `$Quit|`; the read loop stops at the next
149    /// check instead of handing further hub messages to the model.
150    quit_requested: bool,
151}
152
153/// DC client that connects to a DC hub
154pub struct DcClient;
155
156impl DcClient {
157    /// Connect to a DC hub with integrated LLM actions
158    pub async fn connect_with_llm_actions(
159        remote_addr: String,
160        llm_client: OllamaClient,
161        app_state: Arc<AppState>,
162        status_tx: mpsc::UnboundedSender<String>,
163        client_id: ClientId,
164        startup_params: Option<crate::protocol::StartupParams>,
165    ) -> Result<SocketAddr> {
166        // Get startup parameters
167        let nickname = startup_params
168            .as_ref()
169            .map(|p| p.get_optional_string("nickname"))
170            .transpose()?
171            .flatten()
172            .unwrap_or_else(|| "NetGetUser".to_string());
173        validate_nmdc_nickname(&nickname)?;
174        let description = startup_params
175            .as_ref()
176            .map(|p| p.get_optional_string("description"))
177            .transpose()?
178            .flatten()
179            .unwrap_or_else(|| "NetGet DC Client".to_string());
180        let email = startup_params
181            .as_ref()
182            .map(|p| p.get_optional_string("email"))
183            .transpose()?
184            .flatten()
185            .unwrap_or_else(|| String::new());
186        let share_size = startup_params
187            .as_ref()
188            .map(|p| p.get_optional_u64("share_size"))
189            .transpose()?
190            .flatten()
191            .unwrap_or(0);
192        let use_tls = startup_params
193            .as_ref()
194            .map(|p| p.get_optional_bool("use_tls"))
195            .transpose()?
196            .flatten()
197            .unwrap_or(false);
198        let auto_reconnect = startup_params
199            .as_ref()
200            .map(|p| p.get_optional_bool("auto_reconnect"))
201            .transpose()?
202            .flatten()
203            .unwrap_or(false);
204        let max_reconnect_attempts = startup_params
205            .as_ref()
206            .map(|p| p.get_optional_u64("max_reconnect_attempts"))
207            .transpose()?
208            .flatten()
209            .unwrap_or(5);
210        let max_reconnect_attempts =
211            u32::try_from(max_reconnect_attempts).context("max_reconnect_attempts exceeds u32")?;
212        let initial_reconnect_delay_secs = startup_params
213            .as_ref()
214            .map(|p| p.get_optional_u64("initial_reconnect_delay_secs"))
215            .transpose()?
216            .flatten()
217            .unwrap_or(2);
218
219        // Attempt connection with reconnection loop
220        Self::connect_with_reconnect(
221            remote_addr,
222            llm_client,
223            app_state,
224            status_tx,
225            client_id,
226            nickname,
227            description,
228            email,
229            share_size,
230            use_tls,
231            auto_reconnect,
232            max_reconnect_attempts,
233            initial_reconnect_delay_secs,
234        )
235        .await
236    }
237
238    /// Internal function to handle connection with reconnection logic
239    async fn connect_with_reconnect(
240        remote_addr: String,
241        llm_client: OllamaClient,
242        app_state: Arc<AppState>,
243        status_tx: mpsc::UnboundedSender<String>,
244        client_id: ClientId,
245        nickname: String,
246        description: String,
247        email: String,
248        share_size: u64,
249        use_tls: bool,
250        auto_reconnect: bool,
251        max_reconnect_attempts: u32,
252        initial_reconnect_delay_secs: u64,
253    ) -> Result<SocketAddr> {
254        let mut reconnect_attempt = 0u32;
255
256        loop {
257            // Attempt to connect
258            let connect_result = Self::connect_once(
259                remote_addr.clone(),
260                llm_client.clone(),
261                app_state.clone(),
262                status_tx.clone(),
263                client_id,
264                nickname.clone(),
265                description.clone(),
266                email.clone(),
267                share_size,
268                use_tls,
269                auto_reconnect,
270                max_reconnect_attempts,
271                initial_reconnect_delay_secs,
272                reconnect_attempt,
273            )
274            .await;
275
276            match connect_result {
277                Ok(local_addr) => return Ok(local_addr),
278                Err(e) if !auto_reconnect => {
279                    // Not configured for auto-reconnect, return error
280                    return Err(e);
281                }
282                Err(e)
283                    if max_reconnect_attempts > 0
284                        && reconnect_attempt >= max_reconnect_attempts =>
285                {
286                    // Exceeded max attempts
287                    error!(
288                        "DC client {} exhausted reconnection attempts ({}/{}): {}",
289                        client_id, reconnect_attempt, max_reconnect_attempts, e
290                    );
291                    return Err(anyhow::anyhow!(
292                        "Failed to connect after {} attempts: {}",
293                        max_reconnect_attempts,
294                        e
295                    ));
296                }
297                Err(e) => {
298                    // Will reconnect
299                    reconnect_attempt = reconnect_attempt.saturating_add(1);
300
301                    // Raise dc_client_disconnected.
302                    //
303                    // It was declared and nothing ever raised it, so the model was never
304                    // told the hub had dropped -- despite the event carrying `reason`,
305                    // `will_reconnect` and `reconnect_attempt` fields plainly written for
306                    // exactly this moment. Its own example answer is `wait_for_more`,
307                    // which the model could never have been asked for.
308                    if let Some(instruction) = app_state.get_instruction_for_client(client_id).await
309                    {
310                        let event = Event::new(
311                            &crate::client::dc::actions::DC_CLIENT_DISCONNECTED_EVENT,
312                            serde_json::json!({
313                                "reason": e.to_string(),
314                                "will_reconnect": true,
315                                "reconnect_attempt": reconnect_attempt,
316                            }),
317                        );
318                        let memory = app_state
319                            .get_memory_for_client(client_id)
320                            .await
321                            .unwrap_or_default();
322                        // Actions are deliberately not executed here: there is no open
323                        // connection to send on, which is the whole meaning of this event.
324                        // What the model *can* do is record what it learned, and that is
325                        // carried by the memory update.
326                        //
327                        // This used to be an `if let Err(..)` with no success arm, which
328                        // dropped the memory update too — so the comment above claimed a
329                        // mechanism the code did not have, and a model asked "you just lost
330                        // the hub, what should I remember?" was answered into a void.
331                        //
332                        // Actions are reported rather than silently discarded, so an operator
333                        // watching a reconnect can see that the model tried to act and why it
334                        // could not.
335                        match call_llm_for_client(
336                            &llm_client,
337                            &app_state,
338                            client_id.to_string(),
339                            &instruction,
340                            &memory,
341                            Some(&event),
342                            &DcClientProtocol::new(),
343                            &status_tx,
344                        )
345                        .await
346                        {
347                            Ok(result) => {
348                                if let Some(mem) = result.memory_updates {
349                                    app_state.set_memory_for_client(client_id, mem).await;
350                                }
351                                if !result.actions.is_empty() {
352                                    let _ = status_tx.send(format!(
353                                        "[CLIENT] DC client {} is disconnected; {} action(s) \
354                                         from dc_client_disconnected were not sent",
355                                        client_id,
356                                        result.actions.len()
357                                    ));
358                                }
359                            }
360                            Err(le) => error!(
361                                "DC client {} LLM error on dc_client_disconnected: {}",
362                                client_id, le
363                            ),
364                        }
365                    }
366                    let delay_secs =
367                        reconnect_delay_secs(initial_reconnect_delay_secs, reconnect_attempt);
368
369                    info!(
370                        "DC client {} connection failed (attempt {}/{}), retrying in {}s: {}",
371                        client_id,
372                        reconnect_attempt,
373                        if max_reconnect_attempts == 0 {
374                            "∞".to_string()
375                        } else {
376                            max_reconnect_attempts.to_string()
377                        },
378                        delay_secs,
379                        e
380                    );
381
382                    let _ = status_tx.send(format!(
383                        "[CLIENT] DC client {} reconnecting in {}s (attempt {}/{})",
384                        client_id,
385                        delay_secs,
386                        reconnect_attempt,
387                        if max_reconnect_attempts == 0 {
388                            "∞".to_string()
389                        } else {
390                            max_reconnect_attempts.to_string()
391                        }
392                    ));
393
394                    // Sleep with exponential backoff
395                    tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs)).await;
396                }
397            }
398        }
399    }
400
401    /// Perform a single connection attempt
402    async fn connect_once(
403        remote_addr: String,
404        llm_client: OllamaClient,
405        app_state: Arc<AppState>,
406        status_tx: mpsc::UnboundedSender<String>,
407        client_id: ClientId,
408        nickname: String,
409        description: String,
410        email: String,
411        share_size: u64,
412        use_tls: bool,
413        _auto_reconnect: bool,
414        _max_reconnect_attempts: u32,
415        _initial_reconnect_delay_secs: u64,
416        _reconnect_attempt: u32,
417    ) -> Result<SocketAddr> {
418        // Connect to DC hub
419        let tcp_stream = TcpStream::connect(&remote_addr)
420            .await
421            .context(format!("Failed to connect to DC hub at {}", remote_addr))?;
422
423        let local_addr = tcp_stream.local_addr()?;
424        let remote_sock_addr = tcp_stream.peer_addr()?;
425
426        // Wrap with TLS if requested
427        if use_tls {
428            info!(
429                "DC client {} establishing TLS connection to {}",
430                client_id, remote_sock_addr
431            );
432
433            // Extract hostname for SNI
434            let server_name_str = remote_addr.split(':').next().unwrap_or("dc.hub");
435
436            // Install a rustls CryptoProvider before building the config, or
437            // `ClientConfig::builder()` panics instead of erroring. See the fuller note in
438            // `src/client/tls/mod.rs`.
439            let _ = tokio_rustls::rustls::crypto::ring::default_provider().install_default();
440
441            // Create TLS config
442            let root_store = RootCertStore {
443                roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
444            };
445
446            let config = ClientConfig::builder()
447                .with_root_certificates(root_store)
448                .with_no_client_auth();
449
450            let connector = TlsConnector::from(Arc::new(config));
451
452            // Parse server name for SNI
453            let server_name = match ServerName::try_from(server_name_str.to_string()) {
454                Ok(name) => name,
455                Err(_) => {
456                    debug!("Failed to parse server name, using IP");
457                    ServerName::try_from(remote_sock_addr.ip().to_string())
458                        .map_err(|e| anyhow::anyhow!("Invalid server name: {}", e))?
459                }
460            };
461
462            // Perform TLS handshake
463            let tls_stream = connector
464                .connect(server_name, tcp_stream)
465                .await
466                .context("TLS handshake failed")?;
467
468            info!(
469                "DC client {} connected via TLS to {} (local: {})",
470                client_id, remote_sock_addr, local_addr
471            );
472
473            // Update client state
474            app_state
475                .update_client_status(client_id, ClientStatus::Connected)
476                .await;
477            let _ = status_tx.send(format!("[CLIENT] DC client {} connected (TLS)", client_id));
478            let _ = status_tx.send("__UPDATE_UI__".to_string());
479
480            // Split TLS stream
481            let (read_half, write_half) = tokio::io::split(tls_stream);
482            let write_half_arc = Arc::new(Mutex::new(DcWriteHalf::Tls(write_half)));
483
484            // Continue with common logic
485            return run_dc_client_loop(
486                read_half,
487                write_half_arc,
488                client_id,
489                app_state,
490                llm_client,
491                status_tx,
492                nickname,
493                description,
494                email,
495                share_size,
496                local_addr,
497            )
498            .await;
499        }
500
501        info!(
502            "DC client {} connected to {} (local: {})",
503            client_id, remote_sock_addr, local_addr
504        );
505
506        // Update client state
507        app_state
508            .update_client_status(client_id, ClientStatus::Connected)
509            .await;
510        let _ = status_tx.send(format!("[CLIENT] DC client {} connected", client_id));
511        let _ = status_tx.send("__UPDATE_UI__".to_string());
512
513        // Split stream
514        let (read_half, write_half) = tokio::io::split(tcp_stream);
515        let write_half_arc = Arc::new(Mutex::new(DcWriteHalf::Plain(write_half)));
516
517        // Continue with common logic
518        run_dc_client_loop(
519            read_half,
520            write_half_arc,
521            client_id,
522            app_state,
523            llm_client,
524            status_tx,
525            nickname,
526            description,
527            email,
528            share_size,
529            local_addr,
530        )
531        .await
532    }
533}
534
535/// Run the DC client read loop (common for both TCP and TLS)
536async fn run_dc_client_loop<R>(
537    read_half: R,
538    write_half_arc: Arc<Mutex<DcWriteHalf>>,
539    client_id: ClientId,
540    app_state: Arc<AppState>,
541    llm_client: OllamaClient,
542    status_tx: mpsc::UnboundedSender<String>,
543    nickname: String,
544    description: String,
545    email: String,
546    share_size: u64,
547    local_addr: SocketAddr,
548) -> Result<SocketAddr>
549where
550    R: tokio::io::AsyncRead + Unpin + Send + 'static,
551{
552    // Wrap in BufReader for pipe-delimited reading
553    let mut reader = BufReader::new(read_half);
554    // Initialize client state
555    let client_state = Arc::new(Mutex::new(DcClientState {
556        auth_state: DcConnectionState::AwaitingLock,
557        nickname: nickname.clone(),
558        description,
559        email,
560        share_size,
561        memory: String::new(),
562        file_list: Vec::new(), // Start with empty file list
563        quit_requested: false,
564    }));
565
566    // Command channel for injected actions (the dashboard's [ send_dc_chat ] and friends).
567    // Registered BEFORE the read loop starts, so it exists before the connected-event LLM
568    // call that $Lock triggers - a manual `*` rule can park that call for minutes and the
569    // operator must be able to reach the client while it waits.
570    let command_rx =
571        crate::client::command_support::register_command_channel(&app_state, client_id).await;
572
573    // `read_until` is not cancellation-safe, so commands are drained by their own task rather
574    // than a `select!` arm in the read loop. Both tasks share the write half; the channel
575    // closes when the read loop removes the handle, which ends this task.
576    let cmd_task = tokio::spawn(command_loop(
577        command_rx,
578        client_state.clone(),
579        write_half_arc.clone(),
580        client_id,
581        app_state.clone(),
582        status_tx.clone(),
583    ));
584    app_state.register_client_task(client_id, cmd_task).await;
585
586    // Spawn read loop for DC messages
587    // Registered with AppState so stop_client can abort this task —
588    // dropping a JoinHandle only detaches it in Tokio.
589    let task_registrar = app_state.clone();
590    let task_handle = tokio::spawn(async move {
591        info!("DC client {} read loop started", client_id);
592
593        // Read pipe-delimited messages. NMDC frames on '|', not newline — a real hub's
594        // $Lock greeting carries no '\n', so `read_line` would sit on it until the
595        // connection closed. `read_until(b'|', ..)` returns one complete message per call
596        // and BufReader keeps any bytes after the delimiter buffered for the next call,
597        // so partial messages are carried across reads correctly.
598        loop {
599            let framed = tokio::time::timeout(
600                std::time::Duration::from_secs(600),
601                crate::utils::line_reader::read_bounded_delimited(&mut reader, b'|', 64 * 1024),
602            )
603            .await
604            .unwrap_or_else(|_| {
605                Err(std::io::Error::new(
606                    std::io::ErrorKind::TimedOut,
607                    "DC frame deadline",
608                ))
609            });
610            match framed {
611                Ok(None) => {
612                    info!("DC client {} disconnected from hub", client_id);
613                    app_state
614                        .update_client_status(client_id, ClientStatus::Disconnected)
615                        .await;
616                    let _ =
617                        status_tx.send(format!("[CLIENT] DC client {} disconnected", client_id));
618                    let _ = status_tx.send("__UPDATE_UI__".to_string());
619                    break;
620                }
621                Ok(Some(frame)) => {
622                    let buf = frame;
623                    // Strip the trailing '|' delimiter (absent only on EOF with a partial
624                    // trailing message, which we still process). Trim whitespace so hubs
625                    // that send "|\r\n" between messages don't produce phantom segments.
626                    let message = String::from_utf8_lossy(&buf);
627                    let segment = message.trim_end_matches('|').trim();
628                    if segment.is_empty() {
629                        continue;
630                    }
631
632                    trace!("DC client {} received: {}", client_id, segment);
633
634                    // An injected `disconnect` sets the flag from the command task while this
635                    // loop waits on the socket; nothing that arrives after it is processed.
636                    if session_end_requested(&client_state).await {
637                        end_session(&app_state, &status_tx, client_id).await;
638                        break;
639                    }
640
641                    // Process DC message
642                    if let Err(e) = process_dc_message(
643                        segment,
644                        &client_state,
645                        &write_half_arc,
646                        &llm_client,
647                        &app_state,
648                        &status_tx,
649                        client_id,
650                    )
651                    .await
652                    {
653                        error!("Error processing DC message: {}", e);
654                    }
655
656                    // The model answered this message with `disconnect`: `$Quit|` is on the
657                    // wire and the write half is shut, so stop reading now.
658                    if session_end_requested(&client_state).await {
659                        end_session(&app_state, &status_tx, client_id).await;
660                        break;
661                    }
662                }
663                Err(e) => {
664                    error!("DC client {} read error: {}", client_id, e);
665                    app_state
666                        .update_client_status(client_id, ClientStatus::Error(e.to_string()))
667                        .await;
668                    let _ = status_tx.send("__UPDATE_UI__".to_string());
669                    break;
670                }
671            }
672        }
673
674        // Every exit (EOF, read error, a `disconnect` action) lands here: the session is over,
675        // so stop offering [ send ] on it. Dropping the handle also closes the command task's
676        // channel, which drops the last reference to the write half and closes the socket.
677        app_state.remove_client_handle(client_id).await;
678    });
679    task_registrar
680        .register_client_task(client_id, task_handle)
681        .await;
682
683    Ok(local_addr)
684}
685
686/// The fields of an NMDC private message.
687#[derive(Debug, Clone, PartialEq, Eq)]
688pub struct PrivateMessageParts {
689    pub target: String,
690    pub source: String,
691    pub message: String,
692}
693
694/// Parse an NMDC private message: `$To: <target> From: <source> $<<source>> <message>`.
695///
696/// Every range is taken with `str::get`, so a frame the hub controls cannot index outside
697/// the string or inside a multi-byte character: anything malformed is an `Err`, never a
698/// panic.
699pub fn parse_private_message(message: &str) -> Result<PrivateMessageParts> {
700    let malformed = |what: &str| anyhow::anyhow!("Malformed private message: {}", what);
701
702    if !message.starts_with("$To:") {
703        return Err(anyhow::anyhow!("Not a private message"));
704    }
705
706    // Find "From:" to split target and source
707    let from_pos = message
708        .find(" From:")
709        .ok_or_else(|| malformed("missing 'From:'"))?;
710
711    // Target: between "$To: " and " From:"
712    let target = message
713        .get(5..from_pos)
714        .ok_or_else(|| malformed("no target"))?
715        .trim()
716        .to_string();
717
718    // The '$' after "From:" opens the message body.
719    let msg_start = message
720        .get(from_pos..)
721        .and_then(|rest| rest.find('$'))
722        .map(|p| p + from_pos)
723        .ok_or_else(|| malformed("missing message delimiter '$'"))?;
724
725    // Source: between " From:" and that '$'
726    let source = message
727        .get(from_pos + 6..msg_start)
728        .ok_or_else(|| malformed("no source"))?
729        .trim()
730        .to_string();
731
732    // Body: "<source> text". Strip the "<source>" prefix and the one space after it; a body
733    // without that prefix is taken whole.
734    let body = message
735        .get(msg_start + 1..)
736        .ok_or_else(|| malformed("no body"))?;
737    let text = body
738        .strip_prefix('<')
739        .and_then(|rest| rest.split_once('>'))
740        .map(|(_, after)| after.strip_prefix(' ').unwrap_or(after))
741        .unwrap_or(body);
742
743    Ok(PrivateMessageParts {
744        target,
745        source,
746        message: text.to_string(),
747    })
748}
749
750/// Parse NMDC chat message format: <nickname> message
751fn parse_chat_message(message: &str) -> Result<(String, String)> {
752    if !message.starts_with('<') {
753        return Err(anyhow::anyhow!("Not a chat message"));
754    }
755
756    // Find closing bracket using char indices to support Unicode
757    let close_bracket_pos = message
758        .char_indices()
759        .find(|(_, c)| *c == '>')
760        .map(|(i, _)| i)
761        .ok_or_else(|| anyhow::anyhow!("Malformed chat message: missing '>'"))?;
762
763    let source = message[1..close_bracket_pos].to_string();
764
765    // Skip "> " (closing bracket, space)
766    let msg_start = close_bracket_pos + 1;
767    let msg = if msg_start < message.len() {
768        let msg_slice = &message[msg_start..];
769        // Trim leading space if present
770        msg_slice.trim_start().to_string()
771    } else {
772        String::new()
773    };
774
775    Ok((source, msg))
776}
777
778/// Process a single DC message (works with both TCP and TLS)
779async fn process_dc_message(
780    message: &str,
781    client_state: &Arc<Mutex<DcClientState>>,
782    write_half: &Arc<Mutex<DcWriteHalf>>,
783    llm_client: &OllamaClient,
784    app_state: &Arc<AppState>,
785    status_tx: &mpsc::UnboundedSender<String>,
786    client_id: ClientId,
787) -> Result<()> {
788    let state = client_state.lock().await;
789    let current_auth_state = state.auth_state.clone();
790    let nickname = state.nickname.clone();
791    let description = state.description.clone();
792    let email = state.email.clone();
793    let share_size = state.share_size;
794    let memory = state.memory.clone();
795    drop(state);
796
797    // Parse message type
798    if message.starts_with("$Lock ") {
799        // Lock challenge from hub
800        handle_lock_message(
801            message,
802            client_state,
803            write_half,
804            llm_client,
805            app_state,
806            status_tx,
807            client_id,
808            &nickname,
809            &description,
810            &email,
811            share_size,
812        )
813        .await?;
814    } else if message.starts_with("$Hello ") {
815        // Hello acceptance from hub
816        handle_hello_message(
817            message,
818            client_state,
819            write_half,
820            llm_client,
821            app_state,
822            status_tx,
823            client_id,
824            &memory,
825        )
826        .await?;
827    } else if message.starts_with("<") {
828        // Chat message
829        handle_chat_message(
830            message,
831            client_state,
832            write_half,
833            llm_client,
834            app_state,
835            status_tx,
836            client_id,
837            &memory,
838            current_auth_state,
839        )
840        .await?;
841    } else if message.starts_with("$To:") {
842        // Private message
843        handle_private_message(
844            message,
845            client_state,
846            write_half,
847            llm_client,
848            app_state,
849            status_tx,
850            client_id,
851            &memory,
852            current_auth_state,
853        )
854        .await?;
855    } else if message.starts_with("$SR ") {
856        // Search result
857        handle_search_result(
858            message,
859            client_state,
860            write_half,
861            llm_client,
862            app_state,
863            status_tx,
864            client_id,
865            &memory,
866            current_auth_state,
867        )
868        .await?;
869    } else if message.starts_with("$NickList ") {
870        // User list
871        handle_nicklist(
872            message,
873            client_state,
874            write_half,
875            llm_client,
876            app_state,
877            status_tx,
878            client_id,
879            &memory,
880            current_auth_state,
881        )
882        .await?;
883    } else if message.starts_with("$HubName ") {
884        // Hub name
885        handle_hub_name(
886            message,
887            client_state,
888            write_half,
889            llm_client,
890            app_state,
891            status_tx,
892            client_id,
893            &memory,
894            current_auth_state,
895        )
896        .await?;
897    } else if message.starts_with("$HubTopic ") {
898        // Hub topic
899        handle_hub_topic(
900            message,
901            client_state,
902            write_half,
903            llm_client,
904            app_state,
905            status_tx,
906            client_id,
907            &memory,
908            current_auth_state,
909        )
910        .await?;
911    } else if message.starts_with("$Kick ") {
912        // Kicked from hub
913        handle_kick(
914            message,
915            client_state,
916            llm_client,
917            app_state,
918            status_tx,
919            client_id,
920            &memory,
921        )
922        .await?;
923    } else if message.starts_with("$ForceMove ") {
924        // Redirect to another hub
925        handle_redirect(
926            message,
927            client_state,
928            llm_client,
929            app_state,
930            status_tx,
931            client_id,
932            &memory,
933        )
934        .await?;
935    } else if message.starts_with("$GetListLen") || message.starts_with("$UGetBlock") {
936        // Hub is requesting file list
937        // Respond with XML file list
938        let state = client_state.lock().await;
939        let files = state.file_list.clone();
940        let nick = state.nickname.clone();
941        drop(state);
942
943        let xml = generate_xml_filelist(&files, &nick);
944        let xml_bytes = xml.as_bytes();
945
946        debug!(
947            "DC client {} responding to file list request with {} files ({} bytes)",
948            client_id,
949            files.len(),
950            xml_bytes.len()
951        );
952
953        // Note: In a real implementation, we would need to handle ZLIB compression
954        // and send the file list via $Send or similar. For now, this is a simplified
955        // implementation that just logs the request.
956        // The LLM can use send_dc_raw_command to send the actual response if needed.
957
958        info!(
959            "DC client {} received file list request (not fully implemented - use send_dc_raw_command to respond)",
960            client_id
961        );
962    } else {
963        // Unknown message type - log but don't error
964        debug!(
965            "DC client {} received unknown message: {}",
966            client_id, message
967        );
968    }
969
970    Ok(())
971}
972
973/// Handle $Lock challenge
974#[allow(clippy::too_many_arguments)]
975async fn handle_lock_message(
976    message: &str,
977    client_state: &Arc<Mutex<DcClientState>>,
978    write_half: &Arc<Mutex<DcWriteHalf>>,
979    llm_client: &OllamaClient,
980    app_state: &Arc<AppState>,
981    status_tx: &mpsc::UnboundedSender<String>,
982    client_id: ClientId,
983    nickname: &str,
984    description: &str,
985    email: &str,
986    share_size: u64,
987) -> Result<()> {
988    // Parse Lock: "$Lock EXTENDEDPROTOCOLABCABCABCABCABCABC Pk=HubName"
989    let parts: Vec<&str> = message.split_whitespace().collect();
990    if parts.len() < 2 {
991        return Err(anyhow::anyhow!("Invalid Lock format"));
992    }
993
994    let lock_str = parts[1];
995    let pk = parts
996        .get(2)
997        .and_then(|s| s.strip_prefix("Pk="))
998        .unwrap_or("UnknownHub");
999
1000    info!("DC client {} received Lock from hub '{}'", client_id, pk);
1001
1002    // Call LLM with connected event
1003    let event = Event::new(
1004        &DC_CLIENT_CONNECTED_EVENT,
1005        serde_json::json!({
1006            "lock": lock_str,
1007            "pk": pk,
1008        }),
1009    );
1010
1011    let instruction = app_state
1012        .get_instruction_for_client(client_id)
1013        .await
1014        .unwrap_or_default();
1015
1016    // Copied out in its own statement so the guard is dropped before the model is called;
1017    // the answer is applied below through the same mutex.
1018    let memory = client_state.lock().await.memory.clone();
1019
1020    match call_llm_for_client(
1021        llm_client,
1022        app_state,
1023        client_id.to_string(),
1024        &instruction,
1025        &memory,
1026        Some(&event),
1027        &DcClientProtocol::new(),
1028        status_tx,
1029    )
1030    .await
1031    {
1032        Ok(result) => {
1033            // The handshake events are where a Direct Connect client is expected to speak,
1034            // so the model's actions go on the wire. A `disconnect` here ends the session
1035            // before `$Key` is sent.
1036            if execute_dc_actions(result, client_state, write_half, status_tx, client_id).await {
1037                return Ok(());
1038            }
1039        }
1040        Err(e) => {
1041            error!("LLM error on dc_connected event: {}", e);
1042        }
1043    }
1044
1045    // Calculate key from lock
1046    let key = calculate_dc_key(lock_str.as_bytes())?;
1047
1048    // Send Key response
1049    let mut key_cmd = b"$Key ".to_vec();
1050    key_cmd.extend(key);
1051    key_cmd.push(b'|');
1052    {
1053        let mut writer = write_half.lock().await;
1054        writer.write_all(&key_cmd).await?;
1055        writer.flush().await?;
1056    }
1057    info!("DC client {} sent Key", client_id);
1058
1059    // Send ValidateNick
1060    let validate_cmd = format!("$ValidateNick {}|", nickname);
1061    send_dc_command(write_half, &validate_cmd).await?;
1062    info!("DC client {} sent ValidateNick: {}", client_id, nickname);
1063
1064    // Send Version (optional but common)
1065    let version_cmd = "$Version 1,0091|".to_string();
1066    send_dc_command(write_half, &version_cmd).await?;
1067
1068    // Send MyINFO
1069    let myinfo_cmd = format!(
1070        "$MyINFO $ALL {} {}$ $LAN(T3)A${}${}$|",
1071        nickname,
1072        escape_nmdc_text(description),
1073        escape_nmdc_text(email),
1074        share_size
1075    );
1076    send_dc_command(write_half, &myinfo_cmd).await?;
1077    info!("DC client {} sent MyINFO", client_id);
1078
1079    // Update state
1080    client_state.lock().await.auth_state = DcConnectionState::AwaitingHello;
1081
1082    Ok(())
1083}
1084
1085/// Handle $Hello acceptance
1086async fn handle_hello_message(
1087    message: &str,
1088    client_state: &Arc<Mutex<DcClientState>>,
1089    write_half: &Arc<Mutex<DcWriteHalf>>,
1090    llm_client: &OllamaClient,
1091    app_state: &Arc<AppState>,
1092    status_tx: &mpsc::UnboundedSender<String>,
1093    client_id: ClientId,
1094    memory: &str,
1095) -> Result<()> {
1096    // Parse Hello: "$Hello nickname"
1097    let nickname = message.strip_prefix("$Hello ").unwrap_or("").trim();
1098
1099    info!("DC client {} authenticated as '{}'", client_id, nickname);
1100
1101    // Update state
1102    client_state.lock().await.auth_state = DcConnectionState::Authenticated;
1103
1104    // Call LLM with authenticated event
1105    let event = Event::new(
1106        &DC_CLIENT_AUTHENTICATED_EVENT,
1107        serde_json::json!({
1108            "nickname": nickname,
1109        }),
1110    );
1111
1112    let instruction = app_state
1113        .get_instruction_for_client(client_id)
1114        .await
1115        .unwrap_or_default();
1116
1117    match call_llm_for_client(
1118        llm_client,
1119        app_state,
1120        client_id.to_string(),
1121        &instruction,
1122        memory,
1123        Some(&event),
1124        &DcClientProtocol::new(),
1125        status_tx,
1126    )
1127    .await
1128    {
1129        Ok(result) => {
1130            // The model's answer to $Hello goes on the wire: this is the moment a hub
1131            // expects a client to announce itself.
1132            execute_dc_actions(result, client_state, write_half, status_tx, client_id).await;
1133        }
1134        Err(e) => {
1135            error!("LLM error on dc_authenticated event: {}", e);
1136        }
1137    }
1138
1139    Ok(())
1140}
1141
1142/// Handle chat message
1143#[allow(clippy::too_many_arguments)]
1144async fn handle_chat_message(
1145    message: &str,
1146    client_state: &Arc<Mutex<DcClientState>>,
1147    write_half: &Arc<Mutex<DcWriteHalf>>,
1148    llm_client: &OllamaClient,
1149    app_state: &Arc<AppState>,
1150    status_tx: &mpsc::UnboundedSender<String>,
1151    client_id: ClientId,
1152    memory: &str,
1153    auth_state: DcConnectionState,
1154) -> Result<()> {
1155    if auth_state != DcConnectionState::Authenticated {
1156        return Ok(()); // Ignore messages before authentication
1157    }
1158
1159    // Parse chat using improved parser
1160    let (source, msg) = match parse_chat_message(message) {
1161        Ok((s, m)) => (s, m),
1162        Err(e) => {
1163            debug!("Failed to parse chat message '{}': {}", message, e);
1164            return Ok(());
1165        }
1166    };
1167
1168    debug!(
1169        "DC client {} received chat from {}: {}",
1170        client_id, source, msg
1171    );
1172
1173    // Call LLM
1174    let event = Event::new(
1175        &DC_CLIENT_MESSAGE_RECEIVED_EVENT,
1176        serde_json::json!({
1177            "source": source,
1178            "message": msg,
1179            "is_private": false,
1180        }),
1181    );
1182
1183    let instruction = app_state
1184        .get_instruction_for_client(client_id)
1185        .await
1186        .unwrap_or_default();
1187
1188    match call_llm_for_client(
1189        llm_client,
1190        app_state,
1191        client_id.to_string(),
1192        &instruction,
1193        memory,
1194        Some(&event),
1195        &DcClientProtocol::new(),
1196        status_tx,
1197    )
1198    .await
1199    {
1200        Ok(result) => {
1201            execute_dc_actions(result, client_state, write_half, status_tx, client_id).await;
1202        }
1203        Err(e) => {
1204            error!("LLM error on dc_message_received: {}", e);
1205        }
1206    }
1207
1208    Ok(())
1209}
1210
1211/// Handle private message
1212#[allow(clippy::too_many_arguments)]
1213async fn handle_private_message(
1214    message: &str,
1215    client_state: &Arc<Mutex<DcClientState>>,
1216    write_half: &Arc<Mutex<DcWriteHalf>>,
1217    llm_client: &OllamaClient,
1218    app_state: &Arc<AppState>,
1219    status_tx: &mpsc::UnboundedSender<String>,
1220    client_id: ClientId,
1221    memory: &str,
1222    auth_state: DcConnectionState,
1223) -> Result<()> {
1224    if auth_state != DcConnectionState::Authenticated {
1225        return Ok(());
1226    }
1227
1228    // Parse private message using improved parser
1229    let parts = match parse_private_message(message) {
1230        Ok(p) => p,
1231        Err(e) => {
1232            debug!("Failed to parse private message '{}': {}", message, e);
1233            return Ok(());
1234        }
1235    };
1236
1237    let source = parts.source;
1238    let target = parts.target;
1239    let msg = parts.message;
1240
1241    debug!(
1242        "DC client {} received private message from {} (to {}): {}",
1243        client_id, source, target, msg
1244    );
1245
1246    // Call LLM
1247    let event = Event::new(
1248        &DC_CLIENT_MESSAGE_RECEIVED_EVENT,
1249        serde_json::json!({
1250            "source": source,
1251            "message": msg,
1252            "is_private": true,
1253            "target": target,
1254        }),
1255    );
1256
1257    let instruction = app_state
1258        .get_instruction_for_client(client_id)
1259        .await
1260        .unwrap_or_default();
1261
1262    match call_llm_for_client(
1263        llm_client,
1264        app_state,
1265        client_id.to_string(),
1266        &instruction,
1267        memory,
1268        Some(&event),
1269        &DcClientProtocol::new(),
1270        status_tx,
1271    )
1272    .await
1273    {
1274        Ok(result) => {
1275            execute_dc_actions(result, client_state, write_half, status_tx, client_id).await;
1276        }
1277        Err(e) => {
1278            error!("LLM error on private message: {}", e);
1279        }
1280    }
1281
1282    Ok(())
1283}
1284
1285/// Handle search result
1286#[allow(clippy::too_many_arguments)]
1287async fn handle_search_result(
1288    message: &str,
1289    client_state: &Arc<Mutex<DcClientState>>,
1290    write_half: &Arc<Mutex<DcWriteHalf>>,
1291    llm_client: &OllamaClient,
1292    app_state: &Arc<AppState>,
1293    status_tx: &mpsc::UnboundedSender<String>,
1294    client_id: ClientId,
1295    memory: &str,
1296    auth_state: DcConnectionState,
1297) -> Result<()> {
1298    if auth_state != DcConnectionState::Authenticated {
1299        return Ok(());
1300    }
1301
1302    // Parse: "$SR sender filename\x05size free/total\x05hubname"
1303    // Simplified parsing
1304    let without_prefix = message.strip_prefix("$SR ").unwrap_or("");
1305    let parts: Vec<&str> = without_prefix.split('\x05').collect();
1306
1307    if parts.is_empty() {
1308        return Ok(());
1309    }
1310
1311    let first_parts: Vec<&str> = parts[0].split_whitespace().collect();
1312    let source = first_parts.first().unwrap_or(&"").to_string();
1313    let filename = first_parts.get(1).unwrap_or(&"").to_string();
1314    let size = parts
1315        .get(1)
1316        .and_then(|s| s.parse::<u64>().ok())
1317        .unwrap_or(0);
1318
1319    let slots_str = parts.get(2).unwrap_or(&"");
1320    let slots_parts: Vec<&str> = slots_str.split('/').collect();
1321    let free_slots = slots_parts
1322        .first()
1323        .and_then(|s| s.trim().parse::<u64>().ok())
1324        .unwrap_or(0);
1325    let total_slots = slots_parts
1326        .get(1)
1327        .and_then(|s| s.trim().parse::<u64>().ok())
1328        .unwrap_or(0);
1329
1330    debug!(
1331        "DC client {} received search result: {} from {} ({} bytes, {}/{} slots)",
1332        client_id, filename, source, size, free_slots, total_slots
1333    );
1334
1335    // Call LLM
1336    let event = Event::new(
1337        &DC_CLIENT_SEARCH_RESULT_EVENT,
1338        serde_json::json!({
1339            "source": source,
1340            "filename": filename,
1341            "size": size,
1342            "free_slots": free_slots,
1343            "total_slots": total_slots,
1344        }),
1345    );
1346
1347    let instruction = app_state
1348        .get_instruction_for_client(client_id)
1349        .await
1350        .unwrap_or_default();
1351
1352    match call_llm_for_client(
1353        llm_client,
1354        app_state,
1355        client_id.to_string(),
1356        &instruction,
1357        memory,
1358        Some(&event),
1359        &DcClientProtocol::new(),
1360        status_tx,
1361    )
1362    .await
1363    {
1364        Ok(result) => {
1365            execute_dc_actions(result, client_state, write_half, status_tx, client_id).await;
1366        }
1367        Err(e) => {
1368            error!("LLM error on search result: {}", e);
1369        }
1370    }
1371
1372    Ok(())
1373}
1374
1375/// Handle nicklist
1376#[allow(clippy::too_many_arguments)]
1377async fn handle_nicklist(
1378    message: &str,
1379    client_state: &Arc<Mutex<DcClientState>>,
1380    write_half: &Arc<Mutex<DcWriteHalf>>,
1381    llm_client: &OllamaClient,
1382    app_state: &Arc<AppState>,
1383    status_tx: &mpsc::UnboundedSender<String>,
1384    client_id: ClientId,
1385    memory: &str,
1386    auth_state: DcConnectionState,
1387) -> Result<()> {
1388    if auth_state != DcConnectionState::Authenticated {
1389        return Ok(());
1390    }
1391
1392    // Parse: "$NickList nick1$$nick2$$nick3$$"
1393    let without_prefix = message.strip_prefix("$NickList ").unwrap_or("");
1394    let users: Vec<String> = without_prefix
1395        .split("$$")
1396        .filter(|s| !s.is_empty())
1397        .map(|s| s.to_string())
1398        .collect();
1399
1400    debug!(
1401        "DC client {} received user list: {} users",
1402        client_id,
1403        users.len()
1404    );
1405
1406    // Call LLM
1407    let event = Event::new(
1408        &DC_CLIENT_USERLIST_EVENT,
1409        serde_json::json!({
1410            "users": users,
1411        }),
1412    );
1413
1414    let instruction = app_state
1415        .get_instruction_for_client(client_id)
1416        .await
1417        .unwrap_or_default();
1418
1419    match call_llm_for_client(
1420        llm_client,
1421        app_state,
1422        client_id.to_string(),
1423        &instruction,
1424        memory,
1425        Some(&event),
1426        &DcClientProtocol::new(),
1427        status_tx,
1428    )
1429    .await
1430    {
1431        Ok(result) => {
1432            execute_dc_actions(result, client_state, write_half, status_tx, client_id).await;
1433        }
1434        Err(e) => {
1435            error!("LLM error on userlist: {}", e);
1436        }
1437    }
1438
1439    Ok(())
1440}
1441
1442/// Handle hub name
1443#[allow(clippy::too_many_arguments)]
1444async fn handle_hub_name(
1445    message: &str,
1446    client_state: &Arc<Mutex<DcClientState>>,
1447    write_half: &Arc<Mutex<DcWriteHalf>>,
1448    llm_client: &OllamaClient,
1449    app_state: &Arc<AppState>,
1450    status_tx: &mpsc::UnboundedSender<String>,
1451    client_id: ClientId,
1452    memory: &str,
1453    auth_state: DcConnectionState,
1454) -> Result<()> {
1455    if auth_state != DcConnectionState::Authenticated {
1456        return Ok(());
1457    }
1458
1459    let hub_name = message.strip_prefix("$HubName ").unwrap_or("").to_string();
1460
1461    debug!("DC client {} hub name: {}", client_id, hub_name);
1462
1463    // Call LLM
1464    let event = Event::new(
1465        &DC_CLIENT_HUBINFO_EVENT,
1466        serde_json::json!({
1467            "hub_name": hub_name,
1468        }),
1469    );
1470
1471    let instruction = app_state
1472        .get_instruction_for_client(client_id)
1473        .await
1474        .unwrap_or_default();
1475
1476    match call_llm_for_client(
1477        llm_client,
1478        app_state,
1479        client_id.to_string(),
1480        &instruction,
1481        memory,
1482        Some(&event),
1483        &DcClientProtocol::new(),
1484        status_tx,
1485    )
1486    .await
1487    {
1488        Ok(result) => {
1489            execute_dc_actions(result, client_state, write_half, status_tx, client_id).await;
1490        }
1491        Err(e) => {
1492            error!("LLM error on hub name: {}", e);
1493        }
1494    }
1495
1496    Ok(())
1497}
1498
1499/// Handle hub topic
1500#[allow(clippy::too_many_arguments)]
1501async fn handle_hub_topic(
1502    message: &str,
1503    client_state: &Arc<Mutex<DcClientState>>,
1504    write_half: &Arc<Mutex<DcWriteHalf>>,
1505    llm_client: &OllamaClient,
1506    app_state: &Arc<AppState>,
1507    status_tx: &mpsc::UnboundedSender<String>,
1508    client_id: ClientId,
1509    memory: &str,
1510    auth_state: DcConnectionState,
1511) -> Result<()> {
1512    if auth_state != DcConnectionState::Authenticated {
1513        return Ok(());
1514    }
1515
1516    let hub_topic = message.strip_prefix("$HubTopic ").unwrap_or("").to_string();
1517
1518    debug!("DC client {} hub topic: {}", client_id, hub_topic);
1519
1520    // Call LLM
1521    let event = Event::new(
1522        &DC_CLIENT_HUBINFO_EVENT,
1523        serde_json::json!({
1524            "hub_topic": hub_topic,
1525        }),
1526    );
1527
1528    let instruction = app_state
1529        .get_instruction_for_client(client_id)
1530        .await
1531        .unwrap_or_default();
1532
1533    match call_llm_for_client(
1534        llm_client,
1535        app_state,
1536        client_id.to_string(),
1537        &instruction,
1538        memory,
1539        Some(&event),
1540        &DcClientProtocol::new(),
1541        status_tx,
1542    )
1543    .await
1544    {
1545        Ok(result) => {
1546            execute_dc_actions(result, client_state, write_half, status_tx, client_id).await;
1547        }
1548        Err(e) => {
1549            error!("LLM error on hub topic: {}", e);
1550        }
1551    }
1552
1553    Ok(())
1554}
1555
1556/// Handle kick
1557async fn handle_kick(
1558    message: &str,
1559    _client_state: &Arc<Mutex<DcClientState>>,
1560    llm_client: &OllamaClient,
1561    app_state: &Arc<AppState>,
1562    status_tx: &mpsc::UnboundedSender<String>,
1563    client_id: ClientId,
1564    memory: &str,
1565) -> Result<()> {
1566    let nickname = message.strip_prefix("$Kick ").unwrap_or("").to_string();
1567
1568    info!("DC client {} was kicked: {}", client_id, nickname);
1569
1570    // Call LLM
1571    let event = Event::new(
1572        &DC_CLIENT_KICKED_EVENT,
1573        serde_json::json!({
1574            "nickname": nickname,
1575        }),
1576    );
1577
1578    let instruction = app_state
1579        .get_instruction_for_client(client_id)
1580        .await
1581        .unwrap_or_default();
1582
1583    // The model's answer used to be dropped here with `let _ =`. It is now applied.
1584    //
1585    // Its actions cannot be sent: being kicked means this connection is going away,
1586    // which is the whole meaning of the event. What CAN be honoured is the memory the
1587    // model sets -- carried into the next connection -- so a lesson learned here is not
1588    // lost. Anything it tried to send is reported rather than vanishing.
1589    match call_llm_for_client(
1590        llm_client,
1591        app_state,
1592        client_id.to_string(),
1593        &instruction,
1594        memory,
1595        Some(&event),
1596        &DcClientProtocol::new(),
1597        status_tx,
1598    )
1599    .await
1600    {
1601        Ok(result) => {
1602            if let Some(mem) = result.memory_updates {
1603                app_state.set_memory_for_client(client_id, mem).await;
1604            }
1605            if !result.actions.is_empty() {
1606                info!(
1607                    "DC client {}: {} action(s) answered at kicked cannot be sent \
1608                     -- the connection is closing",
1609                    client_id,
1610                    result.actions.len()
1611                );
1612            }
1613        }
1614        Err(e) => error!("DC client {} LLM error on kicked: {}", client_id, e),
1615    }
1616
1617    Ok(())
1618}
1619
1620/// Handle redirect
1621async fn handle_redirect(
1622    message: &str,
1623    _client_state: &Arc<Mutex<DcClientState>>,
1624    llm_client: &OllamaClient,
1625    app_state: &Arc<AppState>,
1626    status_tx: &mpsc::UnboundedSender<String>,
1627    client_id: ClientId,
1628    memory: &str,
1629) -> Result<()> {
1630    let address = message
1631        .strip_prefix("$ForceMove ")
1632        .unwrap_or("")
1633        .to_string();
1634
1635    info!("DC client {} redirected to {}", client_id, address);
1636
1637    // Call LLM
1638    let event = Event::new(
1639        &DC_CLIENT_REDIRECT_EVENT,
1640        serde_json::json!({
1641            "address": address,
1642        }),
1643    );
1644
1645    let instruction = app_state
1646        .get_instruction_for_client(client_id)
1647        .await
1648        .unwrap_or_default();
1649
1650    // The model's answer used to be dropped here with `let _ =`. It is now applied.
1651    //
1652    // Its actions cannot be sent: being redirect means this connection is going away,
1653    // which is the whole meaning of the event. What CAN be honoured is the memory the
1654    // model sets -- carried into the next connection -- so a lesson learned here is not
1655    // lost. Anything it tried to send is reported rather than vanishing.
1656    match call_llm_for_client(
1657        llm_client,
1658        app_state,
1659        client_id.to_string(),
1660        &instruction,
1661        memory,
1662        Some(&event),
1663        &DcClientProtocol::new(),
1664        status_tx,
1665    )
1666    .await
1667    {
1668        Ok(result) => {
1669            if let Some(mem) = result.memory_updates {
1670                app_state.set_memory_for_client(client_id, mem).await;
1671            }
1672            if !result.actions.is_empty() {
1673                info!(
1674                    "DC client {}: {} action(s) answered at redirect cannot be sent \
1675                     -- the connection is closing",
1676                    client_id,
1677                    result.actions.len()
1678                );
1679            }
1680        }
1681        Err(e) => error!("DC client {} LLM error on redirect: {}", client_id, e),
1682    }
1683
1684    Ok(())
1685}
1686
1687/// Apply the model's answer to one event: store its memory update, then run its actions in
1688/// order. Returns `true` when one of them ended the session.
1689///
1690/// Each action stands alone. One the protocol rejects (an unknown name, a missing field) or
1691/// one that cannot be written is logged and reported on the status channel, and the rest
1692/// still run. A `disconnect` writes `$Quit|`, shuts the write half, marks the session as
1693/// ended for the read loop and stops the batch, so nothing after it is sent.
1694/// [`apply_dc_action`] raises no event, so nothing here calls the model again.
1695async fn execute_dc_actions(
1696    result: ClientLlmResult,
1697    client_state: &Arc<Mutex<DcClientState>>,
1698    write_half: &Arc<Mutex<DcWriteHalf>>,
1699    status_tx: &mpsc::UnboundedSender<String>,
1700    client_id: ClientId,
1701) -> bool {
1702    if let Some(mem) = result.memory_updates {
1703        client_state.lock().await.memory = mem;
1704    }
1705
1706    let protocol = DcClientProtocol::new();
1707
1708    for action in result.actions {
1709        let action_type = action
1710            .get("type")
1711            .and_then(|v| v.as_str())
1712            .unwrap_or("<missing type>")
1713            .to_string();
1714        let outcome = match protocol.execute_action(action) {
1715            Ok(action_result) => {
1716                apply_dc_action(action_result, client_state, write_half, client_id).await
1717            }
1718            Err(e) => Err(e),
1719        };
1720        match outcome {
1721            Ok(Applied::Sent(_)) => {}
1722            Ok(Applied::Disconnect) => {
1723                client_state.lock().await.quit_requested = true;
1724                let _ = write_half.lock().await.shutdown().await;
1725                info!(
1726                    "DC client {} ending the session: decision=model_disconnect",
1727                    client_id
1728                );
1729                return true;
1730            }
1731            Err(e) => {
1732                error!(
1733                    "DC client {} action '{}' failed: {}",
1734                    client_id, action_type, e
1735                );
1736                let _ = status_tx.send(format!(
1737                    "[WARN] DC client {} action '{}' failed: {}",
1738                    client_id, action_type, e
1739                ));
1740            }
1741        }
1742    }
1743
1744    false
1745}
1746
1747/// Whether a `disconnect` (from the model or injected) has ended this session.
1748async fn session_end_requested(client_state: &Arc<Mutex<DcClientState>>) -> bool {
1749    client_state.lock().await.quit_requested
1750}
1751
1752/// Record a session ended by a `disconnect` action.
1753async fn end_session(
1754    app_state: &Arc<AppState>,
1755    status_tx: &mpsc::UnboundedSender<String>,
1756    client_id: ClientId,
1757) {
1758    info!("DC client {} session ended by disconnect", client_id);
1759    app_state
1760        .update_client_status(client_id, ClientStatus::Disconnected)
1761        .await;
1762    let _ = status_tx.send(format!("[CLIENT] DC client {} disconnected", client_id));
1763    let _ = status_tx.send("__UPDATE_UI__".to_string());
1764}
1765
1766/// What [`apply_dc_action`] did with one action.
1767enum Applied {
1768    /// Bytes written (0 when the action produced no wire output, e.g. `send_dc_filelist`).
1769    Sent(usize),
1770    /// `$Quit|` was written and the session should end.
1771    Disconnect,
1772}
1773
1774/// Put one executed action on the wire. Shared by the LLM path and injected commands so the
1775/// NMDC encoding of every `send_dc_*` verb exists exactly once.
1776///
1777/// Free text from the action is escaped with [`escape_nmdc_text`]; a private-message target
1778/// that is not a valid nickname ([`validate_nmdc_nickname`]) is refused with an error. The
1779/// client's own nickname was validated when the client started. `send_dc_raw_command` is
1780/// written as given: sending an arbitrary frame is its declared purpose.
1781async fn apply_dc_action(
1782    action_result: crate::llm::actions::client_trait::ClientActionResult,
1783    client_state: &Arc<Mutex<DcClientState>>,
1784    write_half: &Arc<Mutex<DcWriteHalf>>,
1785    client_id: ClientId,
1786) -> Result<Applied> {
1787    use crate::llm::actions::client_trait::ClientActionResult;
1788
1789    let nickname = client_state.lock().await.nickname.clone();
1790    let mut sent = 0usize;
1791
1792    {
1793        match action_result {
1794            ClientActionResult::Custom { name, data } => match name.as_str() {
1795                "dc_chat" => {
1796                    if let Some(message) = data.get("message").and_then(|v| v.as_str()) {
1797                        let cmd = format!("<{}> {}|", nickname, escape_nmdc_text(message));
1798                        send_dc_command(write_half, &cmd).await?;
1799                        sent = cmd.len();
1800                        info!("DC client {} sent chat: {}", client_id, message);
1801                    }
1802                }
1803                "dc_private_message" => {
1804                    if let (Some(target), Some(message)) = (
1805                        data.get("target").and_then(|v| v.as_str()),
1806                        data.get("message").and_then(|v| v.as_str()),
1807                    ) {
1808                        let target = validate_nmdc_nickname(target)
1809                            .context("send_dc_private_message target")?;
1810                        let cmd = format!(
1811                            "$To: {} From: {} $<{}> {}|",
1812                            target,
1813                            nickname,
1814                            nickname,
1815                            escape_nmdc_text(message)
1816                        );
1817                        send_dc_command(write_half, &cmd).await?;
1818                        sent = cmd.len();
1819                        info!(
1820                            "DC client {} sent private message to {}: {}",
1821                            client_id, target, message
1822                        );
1823                    }
1824                }
1825                "dc_search" => {
1826                    if let Some(query) = data.get("query").and_then(|v| v.as_str()) {
1827                        // Simple search format: "$Search Hub:nickname F?F?0?1?query"
1828                        let cmd = format!(
1829                            "$Search Hub:{} F?F?0?1?{}|",
1830                            nickname,
1831                            escape_nmdc_text(query)
1832                        );
1833                        send_dc_command(write_half, &cmd).await?;
1834                        sent = cmd.len();
1835                        info!("DC client {} sent search: {}", client_id, query);
1836                    }
1837                }
1838                "dc_myinfo" => {
1839                    if let (Some(description), Some(email), Some(share_size)) = (
1840                        data.get("description").and_then(|v| v.as_str()),
1841                        data.get("email").and_then(|v| v.as_str()),
1842                        data.get("share_size").and_then(|v| v.as_u64()),
1843                    ) {
1844                        let cmd = format!(
1845                            "$MyINFO $ALL {} {}$ $LAN(T3)A${}${}$|",
1846                            nickname,
1847                            escape_nmdc_text(description),
1848                            escape_nmdc_text(email),
1849                            share_size
1850                        );
1851                        send_dc_command(write_half, &cmd).await?;
1852                        sent = cmd.len();
1853                        info!("DC client {} sent MyINFO", client_id);
1854                    }
1855                }
1856                "dc_get_nicklist" => {
1857                    let cmd = "$GetNickList|".to_string();
1858                    send_dc_command(write_half, &cmd).await?;
1859                    sent = cmd.len();
1860                    info!("DC client {} requested user list", client_id);
1861                }
1862                "dc_filelist" => {
1863                    if let Some(files_array) = data.get("files").and_then(|v| v.as_array()) {
1864                        // Parse files array into DcFileEntry structs
1865                        let mut file_list = Vec::new();
1866                        for file in files_array {
1867                            if let (Some(name), Some(size)) = (
1868                                file.get("name").and_then(|v| v.as_str()),
1869                                file.get("size").and_then(|v| v.as_u64()),
1870                            ) {
1871                                let tth = file
1872                                    .get("tth")
1873                                    .and_then(|v| v.as_str())
1874                                    .map(|s| s.to_string());
1875
1876                                file_list.push(DcFileEntry {
1877                                    name: name.to_string(),
1878                                    size,
1879                                    tth,
1880                                });
1881                            }
1882                        }
1883
1884                        // Store in client state
1885                        client_state.lock().await.file_list = file_list.clone();
1886                        info!(
1887                            "DC client {} configured file list with {} files",
1888                            client_id,
1889                            file_list.len()
1890                        );
1891                    }
1892                }
1893                "dc_raw_command" => {
1894                    if let Some(command) = data.get("command").and_then(|v| v.as_str()) {
1895                        let mut cmd = command.to_string();
1896                        if !cmd.ends_with('|') {
1897                            cmd.push('|');
1898                        }
1899                        send_dc_command(write_half, &cmd).await?;
1900                        sent = cmd.len();
1901                        info!("DC client {} sent raw command: {}", client_id, command);
1902                    }
1903                }
1904                _ => {}
1905            },
1906            ClientActionResult::Disconnect => {
1907                info!("DC client {} disconnecting", client_id);
1908                // Send quit
1909                let cmd = "$Quit|".to_string();
1910                let _ = send_dc_command(write_half, &cmd).await;
1911                return Ok(Applied::Disconnect);
1912            }
1913            ClientActionResult::WaitForMore => {
1914                // Just wait
1915            }
1916            _ => {}
1917        }
1918    }
1919
1920    Ok(Applied::Sent(sent))
1921}
1922
1923/// Drain injected commands until the channel closes (the read loop removed the handle) or
1924/// an injected `disconnect` ends the session.
1925///
1926/// The generic `command_support::handle_stream_client_command` cannot run this client's
1927/// vocabulary because every `send_dc_*` verb yields `ClientActionResult::Custom`, so the
1928/// action goes through [`apply_dc_action`] - the same function the LLM path uses - and the
1929/// outcome is recorded and replied exactly the way the generic arm does it.
1930async fn command_loop(
1931    mut command_rx: mpsc::Receiver<crate::state::client_handles::ClientCommand>,
1932    client_state: Arc<Mutex<DcClientState>>,
1933    write_half: Arc<Mutex<DcWriteHalf>>,
1934    client_id: ClientId,
1935    app_state: Arc<AppState>,
1936    status_tx: mpsc::UnboundedSender<String>,
1937) {
1938    use crate::llm::actions::protocol_trait::Protocol;
1939    use crate::state::client_handles::ClientSendOutcome;
1940    use crate::state::AccessLogOwner;
1941
1942    let protocol = DcClientProtocol::new();
1943
1944    while let Some(command) = command_rx.recv().await {
1945        let action = command.action.clone();
1946        let outcome = match protocol.execute_action(action.clone()) {
1947            Err(e) => Ok(ClientSendOutcome::Rejected {
1948                error: e.to_string(),
1949            }),
1950            Ok(result) => apply_dc_action(result, &client_state, &write_half, client_id)
1951                .await
1952                .map(|applied| match applied {
1953                    Applied::Disconnect => ClientSendOutcome::Disconnected,
1954                    Applied::Sent(0) => ClientSendOutcome::Executed {
1955                        detail: "executed (nothing to write)".to_string(),
1956                    },
1957                    Applied::Sent(bytes_sent) => ClientSendOutcome::Sent { bytes_sent },
1958                }),
1959        };
1960
1961        let outcome_json = match &outcome {
1962            Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
1963            Err(e) => serde_json::json!({"error": e.to_string()}),
1964        };
1965        app_state
1966            .record_access_log(
1967                AccessLogOwner::Client(client_id.as_u32()),
1968                protocol.protocol_name(),
1969                None,
1970                "injected_action",
1971                action,
1972                vec![outcome_json],
1973            )
1974            .await;
1975
1976        let disconnect = matches!(outcome, Ok(ClientSendOutcome::Disconnected));
1977        if let Err(e) = &outcome {
1978            error!("DC client {} injected action failed: {}", client_id, e);
1979            let _ = status_tx.send(format!(
1980                "[WARN] Client {} injected action failed: {}",
1981                client_id, e
1982            ));
1983        }
1984        let _ = status_tx.send("__UPDATE_UI__".to_string());
1985        crate::client::command_support::reply(command, outcome);
1986
1987        if disconnect {
1988            // $Quit| is already on the wire. Mark the session ended so the read loop stops at
1989            // its next frame, and half-close so the hub reads EOF.
1990            client_state.lock().await.quit_requested = true;
1991            let _ = write_half.lock().await.shutdown().await;
1992            break;
1993        }
1994    }
1995}
1996
1997/// Send a DC command (ensures pipe termination)
1998async fn send_dc_command(write_half: &Arc<Mutex<DcWriteHalf>>, command: &str) -> Result<()> {
1999    let mut write_guard = write_half.lock().await;
2000    write_guard.write_all(command.as_bytes()).await?;
2001    write_guard.flush().await?;
2002    Ok(())
2003}
2004
2005/// Generate XML file list from file entries (NMDC format)
2006fn generate_xml_filelist(files: &[DcFileEntry], nickname: &str) -> String {
2007    let mut xml = format!("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
2008    xml.push_str(&format!(
2009        "<FileListing Version=\"1\" CID=\"{}\" Base=\"/\" Generator=\"NetGet DC Client\">\n",
2010        nickname
2011    ));
2012
2013    for file in files {
2014        let tth_attr = file
2015            .tth
2016            .as_ref()
2017            .map(|t| format!(" TTH=\"{}\"", t))
2018            .unwrap_or_default();
2019
2020        xml.push_str(&format!(
2021            "  <File Name=\"{}\" Size=\"{}\"{}/>\n",
2022            escape_xml(&file.name),
2023            file.size,
2024            tth_attr
2025        ));
2026    }
2027
2028    xml.push_str("</FileListing>\n");
2029    xml
2030}
2031
2032/// Escape XML special characters
2033fn escape_xml(s: &str) -> String {
2034    s.replace('&', "&amp;")
2035        .replace('<', "&lt;")
2036        .replace('>', "&gt;")
2037        .replace('"', "&quot;")
2038        .replace('\'', "&apos;")
2039}
2040
2041/// Calculate DC key from lock using NMDC algorithm
2042pub fn calculate_dc_key(lock_bytes: &[u8]) -> Result<Vec<u8>> {
2043    let len = lock_bytes.len();
2044
2045    anyhow::ensure!(
2046        (2..=64 * 1024).contains(&len),
2047        "NMDC Lock must contain 2..65536 bytes"
2048    );
2049
2050    let mut key = vec![0u8; len];
2051
2052    // NMDC key algorithm
2053    for i in 1..len {
2054        key[i] = lock_bytes[i] ^ lock_bytes[i - 1];
2055    }
2056
2057    key[0] = lock_bytes[0] ^ lock_bytes[len - 1] ^ lock_bytes[len - 2] ^ 5;
2058
2059    // Nibble swap each byte
2060    for byte in &mut key {
2061        // Nibble swap
2062        *byte = ((*byte << 4) & 0xF0) | ((*byte >> 4) & 0x0F);
2063    }
2064
2065    // The key is binary, not UTF-8. Escape only the six bytes mandated by NMDC.
2066    let mut escaped = Vec::with_capacity(len);
2067    for byte in key {
2068        if matches!(byte, 0 | 5 | 36 | 96 | 124 | 126) {
2069            escaped.extend_from_slice(format!("/%DCN{byte:03}%/").as_bytes());
2070        } else {
2071            escaped.push(byte);
2072        }
2073    }
2074    Ok(escaped)
2075}