// crates/nazarick/src/chat/synology.rs // // Synology Chat Bot — Webhook Handler. // // Flow: // 1. Synology POST → handle_incoming // 2. Sofort 200 OK zurück (Synology happy) // 3. Async: Auth → Agent → Antwort via Webhook use axum::{extract::State, http::StatusCode, Form}; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::Mutex; use tokio::spawn; use tracing::{error, info, warn}; use sebas_tian::Sebas; use lyra::Lyra; use crate::chat::types::{AgentChatConfig, AuthResult}; // ─── Synology Form-Payload ──────────────────────────────────────────────────── // // Synology sendet application/x-www-form-urlencoded — kein JSON. // axum::Form deserialisiert das automatisch. #[derive(Debug, Deserialize)] pub struct SynologyIncoming { /// Bot-Token — identifiziert welcher Agent angesprochen wird pub token: String, /// Numerische User-ID in Synology Chat pub user_id: u64, /// Anzeigename des Users pub username: String, /// Die eigentliche Nachricht pub text: String, } // ─── Outgoing ───────────────────────────────────────────────────────────────── // // Synology erwartet Form-encoded payload mit JSON drin. // user_ids bestimmt wer die Nachricht bekommt. #[derive(Debug, Serialize)] struct SynologyOutgoing { /// Nachrichtentext text: String, /// Empfänger als Liste von Synology User-IDs user_ids: Vec, } // ─── Shared State ───────────────────────────────────────────────────────────── // // Wird beim Start einmal gebaut und an alle Handler weitergegeben. // Arc = mehrere Threads teilen sich den State (lesen). // Mutex = exklusiver Zugriff auf Sebas beim Schreiben (chat() braucht &mut self). pub struct AppState { /// Alle konfigurierten Bot-Agenten aus config.toml pub agents: Vec, /// Synology User-ID des Admins — bekommt System-Benachrichtigungen pub admin_user_id: u64, /// Basis Webhook URL für Admin-Nachrichten — ohne user_ids pub admin_webhook_url: String, /// HTTP Client — geteilt für alle ausgehenden Requests pub http: Client, /// Sebas Tian — Mutex weil chat() &mut self braucht pub sebas: Mutex, /// Lyra — Companion Agent, eigenes Modell pub lyra: Mutex, } // ─── Handler ────────────────────────────────────────────────────────────────── // // POST /chat/synology // // Antwortet sofort 200 OK damit Synology nicht auf Timeout läuft. // Verarbeitung läuft im Hintergrund via tokio::spawn. pub async fn handle_incoming( State(state): State>, Form(payload): Form, ) -> StatusCode { info!( user_id = payload.user_id, username = %payload.username, "Nachricht empfangen" ); // Agent anhand des Bot-Tokens identifizieren let agent = match state.agents.iter().find(|a| a.bot_token == payload.token) { Some(a) => a.clone(), None => { // Unbekannter Token — kein Hinweis nach außen warn!(token = %payload.token, "Unbekannter Token"); return StatusCode::OK; } }; // Async verarbeiten — Caller bekommt sofort 200 let state = Arc::clone(&state); spawn(async move { process(state, payload, agent).await; }); StatusCode::OK } // ─── Verarbeitung ───────────────────────────────────────────────────────────── // // Läuft im Hintergrund nach dem 200 OK. // Reihenfolge: Auth → Agent aufrufen → Antwort senden. async fn process(state: Arc, payload: SynologyIncoming, agent: AgentChatConfig) { // 1. Auth prüfen let auth = if agent.allowed_user_ids.contains(&payload.user_id) { AuthResult::Allowed } else { AuthResult::Denied { user_id: payload.user_id, username: payload.username.clone(), } }; match auth { AuthResult::Denied { user_id, ref username } => { // Unbekannten User informieren send( &state.http, &agent.incoming_webhook_url, user_id, "Zugriff verweigert. Bitte wende dich an den Administrator.", ).await; // Admin benachrichtigen send( &state.http, &state.admin_webhook_url, state.admin_user_id, &format!( "⚠️ Unbekannter User **{}** (ID: `{}`) hat **{}** kontaktiert.", username, user_id, agent.agent_id ), ).await; warn!(user_id, username = %username, agent = %agent.agent_id, "Zugriff verweigert"); return; } AuthResult::Allowed => { info!(user_id = payload.user_id, "Auth OK"); } } // 2. Richtigen Agent aufrufen anhand agent_id let response = match agent.agent_id.as_str() { "sebas_tian" => { let mut sebas = state.sebas.lock().await; match sebas.chat(&payload.text).await { Ok(text) => text, Err(e) => { error!(error = %e, "Sebas Fehler"); "Entschuldigung, es ist ein interner Fehler aufgetreten.".to_string() } } } "lyra" => { let mut lyra = state.lyra.lock().await; match lyra.chat(&payload.text).await { Ok(text) => text, Err(e) => { error!(error = %e, "Lyra Fehler"); "Entschuldigung, es ist ein interner Fehler aufgetreten.".to_string() } } } unknown => { warn!(agent_id = %unknown, "Unbekannter Agent"); "Dieser Agent ist noch nicht verfügbar.".to_string() } }; // 3. Antwort zurückschicken send(&state.http, &agent.incoming_webhook_url, payload.user_id, &response).await; } // ─── HTTP Sender ────────────────────────────────────────────────────────────── // // Sendet eine Nachricht an einen Synology Chat User. // Lange Nachrichten werden automatisch in Chunks aufgeteilt. // Synology erlaubt max. ~2000 Zeichen pro Nachricht. const MAX_CHUNK_SIZE: usize = 1800; // Puffer unter dem Limit async fn send(client: &Client, base_url: &str, user_id: u64, text: &str) { let chunks = split_message(text); for chunk in chunks { send_chunk(client, base_url, user_id, &chunk).await; } } /// Sendet einen einzelnen Chunk. async fn send_chunk(client: &Client, base_url: &str, user_id: u64, text: &str) { let body = SynologyOutgoing { text: text.to_string(), user_ids: vec![user_id], }; let payload = serde_json::to_string(&body).unwrap_or_default(); match client .post(base_url) .form(&[("payload", payload.as_str())]) .send() .await { Ok(r) if r.status().is_success() => { let response_body = r.text().await.unwrap_or_default(); info!("Chunk gesendet an user_id={} body={}", user_id, response_body); } Ok(r) => error!(status = %r.status(), "Synology hat abgelehnt"), Err(e) => error!(error = %e, "Senden fehlgeschlagen"), } } /// Teilt einen Text in Chunks auf die Synology verarbeiten kann. /// Schneidet an Zeilenumbrüchen oder Satzenden — nie mitten im Wort. fn split_message(text: &str) -> Vec { if text.len() <= MAX_CHUNK_SIZE { return vec![text.to_string()]; } let mut chunks = Vec::new(); let mut remaining = text; while remaining.len() > MAX_CHUNK_SIZE { // Sicherstellen dass wir auf einer char-Grenze starten let safe_max = { let mut idx = MAX_CHUNK_SIZE; while !remaining.is_char_boundary(idx) { idx -= 1; } idx }; let cut = remaining[..safe_max] .rfind('\n') .or_else(|| remaining[..safe_max].rfind(". ")) .or_else(|| remaining[..safe_max].rfind(' ')) .unwrap_or(safe_max); chunks.push(remaining[..cut].trim().to_string()); remaining = remaining[cut..].trim_start(); } if !remaining.is_empty() { chunks.push(remaining.to_string()); } chunks }