261 lines
8.9 KiB
Rust
261 lines
8.9 KiB
Rust
// 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<T> 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<u64>,
|
|
}
|
|
|
|
// ─── 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<AgentChatConfig>,
|
|
/// 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<Sebas>,
|
|
/// Lyra — Companion Agent, eigenes Modell
|
|
pub lyra: Mutex<Lyra>,
|
|
}
|
|
|
|
// ─── 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<Arc<AppState>>,
|
|
Form(payload): Form<SynologyIncoming>,
|
|
) -> 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<AppState>, 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<String> {
|
|
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
|
|
} |