Browse Source

feat(web): add optional web control panel enabled by web_port

- config: new web_port key (1-65535, 0 rejected; unset disables the panel) with tests
- web: tiny_http server on 0.0.0.0 without auth, on its own thread; GET /, GET /api/state,
  POST /api/global, /api/chat (jid as text body), /api/quit (responds before quitting);
  every POST requires the X-Wh-Request: 1 header (403 otherwise) to block cross-site requests
- web_page.html: page embedded in the binary via include_str!, grid of chats with AI switch,
  "AI tutte" switch (global autoreply), quit button, 5s polling, dark/light theme
- app: AppEvent::Web handled by the single-owner App with a state snapshot reply;
  toggle_chat_autoreply extracted from Ctrl+A and reused by the web panel (behavior unchanged)
- main: start the panel when web_port is set; bind failure is fatal at startup
- ai: add web_port to the test Config literal
- deps: tiny_http 0.12
softword 2 days ago
parent
commit
ec7ea4eb1d
8 changed files with 1289 additions and 7 deletions
  1. 31 0
      Cargo.lock
  2. 2 0
      Cargo.toml
  3. 1 0
      src/ai.rs
  4. 108 5
      src/app.rs
  5. 47 1
      src/config.rs
  6. 15 1
      src/main.rs
  7. 445 0
      src/web.rs
  8. 640 0
      src/web_page.html

+ 31 - 0
Cargo.lock

@@ -52,6 +52,12 @@ dependencies = [
  "num-traits",
  "num-traits",
 ]
 ]
 
 
+[[package]]
+name = "ascii"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
+
 [[package]]
 [[package]]
 name = "async-channel"
 name = "async-channel"
 version = "2.5.0"
 version = "2.5.0"
@@ -364,6 +370,12 @@ dependencies = [
  "windows-link",
  "windows-link",
 ]
 ]
 
 
+[[package]]
+name = "chunked_transfer"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
+
 [[package]]
 [[package]]
 name = "cipher"
 name = "cipher"
 version = "0.5.2"
 version = "0.5.2"
@@ -1225,6 +1237,12 @@ version = "1.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
 checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
 
 
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
 [[package]]
 [[package]]
 name = "hybrid-array"
 name = "hybrid-array"
 version = "0.4.15"
 version = "0.4.15"
@@ -3102,6 +3120,18 @@ dependencies = [
  "time-core",
  "time-core",
 ]
 ]
 
 
+[[package]]
+name = "tiny_http"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
+dependencies = [
+ "ascii",
+ "chunked_transfer",
+ "httpdate",
+ "log",
+]
+
 [[package]]
 [[package]]
 name = "tinystr"
 name = "tinystr"
 version = "0.8.4"
 version = "0.8.4"
@@ -3848,6 +3878,7 @@ dependencies = [
  "rusqlite",
  "rusqlite",
  "serde",
  "serde",
  "serde_json",
  "serde_json",
+ "tiny_http",
  "tokio",
  "tokio",
  "toml 1.1.8+spec-1.1.0",
  "toml 1.1.8+spec-1.1.0",
  "whatsapp-rust",
  "whatsapp-rust",

+ 2 - 0
Cargo.toml

@@ -21,6 +21,8 @@ qrcode = { version = "0.14", default-features = false }
 dirs = "7"
 dirs = "7"
 base64 = "0.23"
 base64 = "0.23"
 chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
 chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
+# Minimal synchronous HTTP server for the optional web control panel (web_port).
+tiny_http = "0.12"
 
 
 [profile.release]
 [profile.release]
 # Remove symbols and debug info from the binary.
 # Remove symbols and debug info from the binary.

+ 1 - 0
src/ai.rs

@@ -546,6 +546,7 @@ mod tests {
             data_dir: PathBuf::from("data"),
             data_dir: PathBuf::from("data"),
             openai_vision_model: None,
             openai_vision_model: None,
             ai_retries: crate::config::DEFAULT_AI_RETRIES,
             ai_retries: crate::config::DEFAULT_AI_RETRIES,
+            web_port: None,
         }
         }
     }
     }
 
 

+ 108 - 5
src/app.rs

@@ -13,6 +13,7 @@ use crate::autoreply::{
 };
 };
 use crate::config::Config;
 use crate::config::Config;
 use crate::history::{ChatSummary, Contact, History, StoredMessage};
 use crate::history::{ChatSummary, Contact, History, StoredMessage};
+use crate::web::{WebChat, WebCommand, WebRequest, WebState};
 use crate::whatsapp::{ImageRef, WaEvent, WaHandle};
 use crate::whatsapp::{ImageRef, WaEvent, WaHandle};
 
 
 /// Maximum number of messages of the open chat kept in `App::messages`.
 /// Maximum number of messages of the open chat kept in `App::messages`.
@@ -65,6 +66,8 @@ const STATUS_DELETE_CONFIRM_HINT: &str = "Canc conferma, Esc annulla";
 const STATUS_CHAT_DELETED_PREFIX: &str = "Chat";
 const STATUS_CHAT_DELETED_PREFIX: &str = "Chat";
 /// Suffix of the status shown once a chat was deleted locally.
 /// Suffix of the status shown once a chat was deleted locally.
 const STATUS_CHAT_DELETED_SUFFIX: &str = "eliminata";
 const STATUS_CHAT_DELETED_SUFFIX: &str = "eliminata";
+/// Status shown when the web control panel toggles a chat that is not in its list.
+const STATUS_WEB_CHAT_UNKNOWN: &str = "Chat non trovata";
 
 
 /// Event delivered to `App::handle_event` by the main loop and by background tasks.
 /// Event delivered to `App::handle_event` by the main loop and by background tasks.
 #[derive(Debug)]
 #[derive(Debug)]
@@ -89,6 +92,8 @@ pub enum AppEvent {
         is_ai: bool,
         is_ai: bool,
         result: Result<String, String>,
         result: Result<String, String>,
     },
     },
+    /// Command from the web control panel; the reply carries the state snapshot.
+    Web(WebRequest),
 }
 }
 
 
 /// Screen currently displayed.
 /// Screen currently displayed.
@@ -240,6 +245,7 @@ impl App {
             AppEvent::Sent { chat_jid, text, is_ai, result } => {
             AppEvent::Sent { chat_jid, text, is_ai, result } => {
                 self.handle_sent(OutgoingMessage { chat_jid, text, is_ai }, result)
                 self.handle_sent(OutgoingMessage { chat_jid, text, is_ai }, result)
             }
             }
+            AppEvent::Web(request) => self.handle_web_request(request),
         }
         }
     }
     }
 
 
@@ -758,18 +764,73 @@ impl App {
         let Some(jid) = self.open_chat.clone() else {
         let Some(jid) = self.open_chat.clone() else {
             return Ok(());
             return Ok(());
         };
         };
-        let enable = !self.open_chat_autoreply;
-        if enable && let Err(refusal) = can_enable_autoreply(&jid, self.ai.is_some()) {
+        self.toggle_chat_autoreply(&jid)
+    }
+
+    /// Purpose: toggle the AI autoreply of a given chat.
+    /// Inputs: `jid`, the chat identifier.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: writes the history, updates `status` (and `open_chat_autoreply` when `jid`
+    /// is the open chat), reloads chats; refuses to enable without an AI client or outside
+    /// private chats and groups (disabling is always allowed).
+    fn toggle_chat_autoreply(&mut self, jid: &str) -> Result<()> {
+        let enable = !self.history.is_autoreply(jid)?;
+        if enable && let Err(refusal) = can_enable_autoreply(jid, self.ai.is_some()) {
             self.status = refusal.to_owned();
             self.status = refusal.to_owned();
             return Ok(());
             return Ok(());
         }
         }
-        self.history.set_autoreply(&jid, enable)?;
-        self.open_chat_autoreply = enable;
+        self.history.set_autoreply(jid, enable)?;
+        if self.open_chat.as_deref() == Some(jid) {
+            self.open_chat_autoreply = enable;
+        }
         let state = if enable { "ON" } else { "OFF" };
         let state = if enable { "ON" } else { "OFF" };
-        self.status = format!("Autoreply {state} per {}", self.open_chat_name);
+        self.status = format!("Autoreply {state} per {}", self.history.display_name(jid)?);
         self.reload_chats()
         self.reload_chats()
     }
     }
 
 
+    /// Purpose: apply a command from the web control panel and reply with the new state.
+    /// Inputs: `request`, the command and the channel on which the state snapshot is sent back.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: may toggle the global or a per-chat autoreply (writing the history), update
+    /// `status` or request the application to quit; always sends the state snapshot.
+    fn handle_web_request(&mut self, request: WebRequest) -> Result<()> {
+        match request.command {
+            WebCommand::State => {}
+            WebCommand::ToggleGlobal => self.toggle_global_autoreply()?,
+            WebCommand::ToggleChat(jid) => self.toggle_web_chat(&jid)?,
+            WebCommand::Quit => self.should_quit = true,
+        }
+
+        // The receiver is gone when the HTTP client disconnected or the app is quitting.
+        let _ = request.reply.send(self.web_state());
+        Ok(())
+    }
+
+    /// Purpose: toggle the autoreply of a chat requested by the web control panel.
+    /// Inputs: `jid`, the chat identifier sent by the web client.
+    /// Output: `Ok(())`, or a history error.
+    /// Side effects: as `toggle_chat_autoreply`; sets `status` to `STATUS_WEB_CHAT_UNKNOWN`
+    /// when `jid` is not among the chats listed by the web panel.
+    fn toggle_web_chat(&mut self, jid: &str) -> Result<()> {
+        if !web_chats(&self.chats).iter().any(|chat| chat.jid == jid) {
+            self.status = STATUS_WEB_CHAT_UNKNOWN.to_owned();
+            return Ok(());
+        }
+        self.toggle_chat_autoreply(jid)
+    }
+
+    /// Purpose: snapshot of the state shown by the web control panel.
+    /// Inputs: none.
+    /// Output: the global flag, the toggleable chats and the status line.
+    /// Side effects: none.
+    fn web_state(&self) -> WebState {
+        WebState {
+            global_autoreply: self.global_autoreply,
+            chats: web_chats(&self.chats),
+            status: self.status.clone(),
+        }
+    }
+
     /// Purpose: toggle the global AI autoreply, which answers every chat regardless of the
     /// Purpose: toggle the global AI autoreply, which answers every chat regardless of the
     /// per-chat flags.
     /// per-chat flags.
     /// Inputs: none.
     /// Inputs: none.
@@ -1073,6 +1134,22 @@ fn can_enable_autoreply(jid: &str, ai_configured: bool) -> Result<(), &'static s
     Ok(())
     Ok(())
 }
 }
 
 
+/// Purpose: build the chat list exposed by the web control panel.
+/// Inputs: `chats`, the chats shown by the TUI.
+/// Output: the chats whose autoreply can be toggled (private chats and groups), in the same order.
+/// Side effects: none.
+fn web_chats(chats: &[ChatSummary]) -> Vec<WebChat> {
+    chats
+        .iter()
+        .filter(|chat| supports_autoreply(&chat.jid))
+        .map(|chat| WebChat {
+            jid: chat.jid.clone(),
+            name: chat.name.clone(),
+            autoreply: chat.autoreply,
+        })
+        .collect()
+}
+
 /// Purpose: tell whether the modifiers denote a Ctrl command chord rather than AltGr.
 /// Purpose: tell whether the modifiers denote a Ctrl command chord rather than AltGr.
 /// Inputs: `modifiers`, the modifiers of a key event.
 /// Inputs: `modifiers`, the modifiers of a key event.
 /// Output: `true` when Control is held and Alt is not; `false` otherwise.
 /// Output: `true` when Control is held and Alt is not; `false` otherwise.
@@ -1130,6 +1207,32 @@ mod tests {
         );
         );
     }
     }
 
 
+    #[test]
+    fn web_chats_keeps_private_chats_and_groups_in_order() {
+        let chats = [
+            chat_summary("393331234567@s.whatsapp.net", "Mario", true),
+            chat_summary("status@broadcast", "Stato", false),
+            chat_summary("120363000000@g.us", "Famiglia", false),
+            chat_summary("120363111111111111@newsletter", "Notizie", false),
+        ];
+        let web = web_chats(&chats);
+        let jids: Vec<&str> = web.iter().map(|chat| chat.jid.as_str()).collect();
+        assert_eq!(jids, ["393331234567@s.whatsapp.net", "120363000000@g.us"]);
+        assert_eq!(web[0].name, "Mario");
+        assert!(web[0].autoreply);
+        assert!(!web[1].autoreply);
+    }
+
+    /// Builds a `ChatSummary` for the tests.
+    fn chat_summary(jid: &str, name: &str, autoreply: bool) -> ChatSummary {
+        ChatSummary {
+            jid: jid.to_owned(),
+            name: name.to_owned(),
+            last_timestamp: 0,
+            autoreply,
+        }
+    }
+
     #[test]
     #[test]
     fn vision_applies_requires_image_and_vision_model() {
     fn vision_applies_requires_image_and_vision_model() {
         assert!(vision_applies(true, true));
         assert!(vision_applies(true, true));

+ 47 - 1
src/config.rs

@@ -15,7 +15,9 @@
 //! private chat is answered once if it is at most this old; 0 disables the catch-up),
 //! private chat is answered once if it is at most this old; 0 disables the catch-up),
 //! `ai_retries` (non-negative integer, default 2: how many times a failed AI request is
 //! `ai_retries` (non-negative integer, default 2: how many times a failed AI request is
 //! retried, waiting 2s, 4s, ... between attempts; 0 disables retrying; HTTP 4xx errors other
 //! retried, waiting 2s, 4s, ... between attempts; 0 disables retrying; HTTP 4xx errors other
-//! than 429 are never retried). Unknown keys are ignored. A missing file means
+//! than 429 are never retried), `web_port` (optional integer 1-65535: when set, a web control
+//! panel listens on `0.0.0.0:<port>` without authentication; unset disables it).
+//! Unknown keys are ignored. A missing file means
 //! all defaults; an unreadable or invalid file is an error. The data directory is fixed and
 //! all defaults; an unreadable or invalid file is an error. The data directory is fixed and
 //! cannot be changed from the file.
 //! cannot be changed from the file.
 //!
 //!
@@ -54,6 +56,8 @@ const AI_LOG_FILE: &str = "ai_requests.log";
 const CONFIG_FILE: &str = "config.toml";
 const CONFIG_FILE: &str = "config.toml";
 /// Name of the file key holding the base URL, used in validation messages.
 /// Name of the file key holding the base URL, used in validation messages.
 const KEY_BASE_URL: &str = "openai_base_url";
 const KEY_BASE_URL: &str = "openai_base_url";
+/// Name of the file key holding the web control panel port, used in validation messages.
+const KEY_WEB_PORT: &str = "web_port";
 /// Byte order mark that Windows editors may prepend to UTF-8 files.
 /// Byte order mark that Windows editors may prepend to UTF-8 files.
 const UTF8_BOM: char = '\u{feff}';
 const UTF8_BOM: char = '\u{feff}';
 
 
@@ -91,6 +95,9 @@ pub struct Config {
     /// Number of retries of a failed AI request, from `ai_retries` (file); default 2, 0
     /// Number of retries of a failed AI request, from `ai_retries` (file); default 2, 0
     /// disables retrying.
     /// disables retrying.
     pub ai_retries: u32,
     pub ai_retries: u32,
+    /// Port of the web control panel from `web_port` (file); `None` = web panel disabled.
+    /// It listens on all interfaces, with no authentication.
+    pub web_port: Option<u16>,
     /// Directory holding `config.toml` and the databases: `<platform config dir>/wh`.
     /// Directory holding `config.toml` and the databases: `<platform config dir>/wh`.
     pub data_dir: PathBuf,
     pub data_dir: PathBuf,
 }
 }
@@ -119,6 +126,9 @@ struct FileConfig {
     catch_up_seconds: Option<u32>,
     catch_up_seconds: Option<u32>,
     /// Retries of a failed AI request (unsigned: negatives are rejected).
     /// Retries of a failed AI request (unsigned: negatives are rejected).
     ai_retries: Option<u32>,
     ai_retries: Option<u32>,
+    /// Port of the optional web control panel (unsigned 16-bit: negatives and values above
+    /// 65535 are rejected by TOML parsing).
+    web_port: Option<u16>,
 }
 }
 
 
 impl fmt::Debug for Config {
 impl fmt::Debug for Config {
@@ -143,6 +153,7 @@ impl fmt::Debug for Config {
             .field("group_cooldown_seconds", &self.group_cooldown_seconds)
             .field("group_cooldown_seconds", &self.group_cooldown_seconds)
             .field("catch_up_seconds", &self.catch_up_seconds)
             .field("catch_up_seconds", &self.catch_up_seconds)
             .field("ai_retries", &self.ai_retries)
             .field("ai_retries", &self.ai_retries)
+            .field("web_port", &self.web_port)
             .field("data_dir", &self.data_dir)
             .field("data_dir", &self.data_dir)
             .finish()
             .finish()
     }
     }
@@ -229,6 +240,17 @@ fn resolve_base_url(raw: Option<String>) -> Result<String> {
     Ok(url)
     Ok(url)
 }
 }
 
 
+/// Purpose: validate the optional web control panel port.
+/// Inputs: `raw`, the optional `web_port` file value (already range-checked to 0-65535 by parsing).
+/// Output: `raw` unchanged; error naming the key and the file if it is 0.
+/// Side effects: none.
+fn resolve_web_port(raw: Option<u16>) -> Result<Option<u16>> {
+    if raw == Some(0) {
+        bail!("{KEY_WEB_PORT} in {CONFIG_FILE} must be between 1 and 65535");
+    }
+    Ok(raw)
+}
+
 impl Config {
 impl Config {
     /// Purpose: load the configuration from `<data_dir>/config.toml` and built-in defaults.
     /// Purpose: load the configuration from `<data_dir>/config.toml` and built-in defaults.
     /// Inputs: none (the data dir is `<platform config dir>/wh`).
     /// Inputs: none (the data dir is `<platform config dir>/wh`).
@@ -259,6 +281,7 @@ impl Config {
                 .map_or(DEFAULT_GROUP_COOLDOWN_SECONDS, i64::from),
                 .map_or(DEFAULT_GROUP_COOLDOWN_SECONDS, i64::from),
             catch_up_seconds: file.catch_up_seconds.map_or(DEFAULT_CATCH_UP_SECONDS, i64::from),
             catch_up_seconds: file.catch_up_seconds.map_or(DEFAULT_CATCH_UP_SECONDS, i64::from),
             ai_retries: file.ai_retries.unwrap_or(DEFAULT_AI_RETRIES),
             ai_retries: file.ai_retries.unwrap_or(DEFAULT_AI_RETRIES),
+            web_port: resolve_web_port(file.web_port)?,
             data_dir,
             data_dir,
         })
         })
     }
     }
@@ -424,6 +447,29 @@ mod tests {
         assert!(format!("{unset:?}").contains("openai_vision_model"));
         assert!(format!("{unset:?}").contains("openai_vision_model"));
     }
     }
 
 
+    #[test]
+    fn web_port_unset_is_none_and_set_is_kept() {
+        assert_eq!(ok_config("").web_port, None);
+        assert_eq!(ok_config("web_port = 8080\n").web_port, Some(8080));
+        assert!(format!("{:?}", ok_config("web_port = 8080\n")).contains("web_port"));
+    }
+
+    #[test]
+    fn zero_web_port_is_error_naming_key() {
+        let Err(err) = config_from("web_port = 0\n") else {
+            panic!("web_port = 0 must fail");
+        };
+        let msg = format!("{err:#}");
+        assert!(msg.contains(KEY_WEB_PORT), "unexpected message: {msg}");
+        assert!(msg.contains(CONFIG_FILE), "unexpected message: {msg}");
+    }
+
+    #[test]
+    fn out_of_range_web_port_is_error() {
+        assert!(config_from("web_port = 70000\n").is_err());
+        assert!(config_from("web_port = -1\n").is_err());
+    }
+
     #[test]
     #[test]
     fn db_paths_are_under_data_dir() {
     fn db_paths_are_under_data_dir() {
         let cfg = ok_config("");
         let cfg = ok_config("");

+ 15 - 1
src/main.rs

@@ -6,6 +6,7 @@ mod autoreply;
 mod config;
 mod config;
 mod history;
 mod history;
 mod ui;
 mod ui;
+mod web;
 mod whatsapp;
 mod whatsapp;
 
 
 use anyhow::{Context, Result};
 use anyhow::{Context, Result};
@@ -24,7 +25,8 @@ const INPUT_THREAD_NAME: &str = "wh-input";
 /// Purpose: program entry point; builds the application and runs it until quit.
 /// Purpose: program entry point; builds the application and runs it until quit.
 /// Inputs: none (reads config.toml and terminal events).
 /// Inputs: none (reads config.toml and terminal events).
 /// Output: `Ok(())` on a clean exit, otherwise the first fatal error.
 /// Output: `Ok(())` on a clean exit, otherwise the first fatal error.
-/// Side effects: creates the data directory and databases, connects to WhatsApp, takes over the terminal.
+/// Side effects: creates the data directory and databases, connects to WhatsApp, takes over the terminal,
+/// and starts the optional web control panel when `web_port` is configured.
 #[tokio::main]
 #[tokio::main]
 async fn main() -> Result<()> {
 async fn main() -> Result<()> {
     let config = Config::load().context("cannot load configuration")?;
     let config = Config::load().context("cannot load configuration")?;
@@ -38,6 +40,7 @@ async fn main() -> Result<()> {
         .await
         .await
         .context("cannot start WhatsApp client")?;
         .context("cannot start WhatsApp client")?;
     spawn_wa_forwarder(wa_rx, app_tx.clone());
     spawn_wa_forwarder(wa_rx, app_tx.clone());
+    start_web_panel(&config, &app_tx)?;
 
 
     let app = App::new(AppDeps {
     let app = App::new(AppDeps {
         config,
         config,
@@ -64,6 +67,17 @@ fn spawn_wa_forwarder(mut wa_rx: UnboundedReceiver<WaEvent>, app_tx: UnboundedSe
     });
     });
 }
 }
 
 
+/// Purpose: start the optional web control panel when a port is configured.
+/// Inputs: `config`, the loaded configuration; `app_tx`, the application event sender.
+/// Output: `Ok(())` if the panel is disabled or started, otherwise the bind/spawn error with context.
+/// Side effects: spawns the web server thread, which binds the configured port.
+fn start_web_panel(config: &Config, app_tx: &UnboundedSender<AppEvent>) -> Result<()> {
+    let Some(port) = config.web_port else {
+        return Ok(());
+    };
+    web::start(port, app_tx.clone()).with_context(|| format!("cannot start web server on port {port}"))
+}
+
 /// Purpose: start the dedicated thread that reads blocking terminal events.
 /// Purpose: start the dedicated thread that reads blocking terminal events.
 /// Inputs: `app_tx`, the application event sender.
 /// Inputs: `app_tx`, the application event sender.
 /// Output: `Ok(())` if the thread was spawned, otherwise the spawn error.
 /// Output: `Ok(())` if the thread was spawned, otherwise the spawn error.

+ 445 - 0
src/web.rs

@@ -0,0 +1,445 @@
+//! Optional web control panel (enabled by `web_port`): a minimal HTTP server with no authentication
+//! that listens on every network interface (`0.0.0.0`) and drives the application through `AppEvent::Web`.
+
+use std::io::Read;
+
+use anyhow::{Context, Result, anyhow};
+use serde::Serialize;
+use tiny_http::{Header, Method, Request, Response, Server};
+use tokio::sync::mpsc::UnboundedSender;
+use tokio::sync::oneshot;
+
+use crate::app::AppEvent;
+
+/// Name of the thread that serves HTTP requests.
+const WEB_THREAD_NAME: &str = "wh-web";
+/// Network address the control server binds to (all interfaces, no authentication).
+const BIND_ADDRESS: &str = "0.0.0.0";
+
+/// Route path of the control panel page.
+const PATH_PAGE: &str = "/";
+/// Route path that returns the current state.
+const PATH_STATE: &str = "/api/state";
+/// Route path that toggles the global autoreply.
+const PATH_GLOBAL: &str = "/api/global";
+/// Route path that toggles the autoreply of one chat.
+const PATH_CHAT: &str = "/api/chat";
+/// Route path that quits the bot.
+const PATH_QUIT: &str = "/api/quit";
+
+/// Maximum accepted size, in bytes, of a request body.
+const MAX_BODY_BYTES: usize = 1024;
+
+/// HTTP status: success.
+const STATUS_OK: u16 = 200;
+/// HTTP status: malformed request.
+const STATUS_BAD_REQUEST: u16 = 400;
+/// HTTP status: unknown route.
+const STATUS_NOT_FOUND: u16 = 404;
+/// HTTP status: the response could not be serialized.
+const STATUS_INTERNAL_ERROR: u16 = 500;
+/// HTTP status: the request lacks the mandatory custom header.
+const STATUS_FORBIDDEN: u16 = 403;
+/// HTTP status: the application cannot answer.
+const STATUS_UNAVAILABLE: u16 = 503;
+
+/// Name of the custom header every POST request must carry (forces a CORS preflight, never approved).
+const REQUEST_HEADER_NAME: &str = "X-Wh-Request";
+/// Required value of the custom request header.
+const REQUEST_HEADER_VALUE: &str = "1";
+
+/// JSON key holding the human readable status in API responses.
+const JSON_KEY_STATUS: &str = "status";
+
+/// Name of the content type header.
+const HEADER_CONTENT_TYPE: &str = "Content-Type";
+/// Content type of the control panel page.
+const CONTENT_TYPE_HTML: &str = "text/html; charset=utf-8";
+/// Content type of API responses.
+const CONTENT_TYPE_JSON: &str = "application/json";
+/// Content type of plain text responses.
+const CONTENT_TYPE_TEXT: &str = "text/plain; charset=utf-8";
+
+/// Control panel page served at `/`.
+const PAGE_HTML: &str = include_str!("web_page.html");
+
+/// Message returned when the bot is shutting down.
+const QUIT_MESSAGE: &str = "Bot in chiusura";
+/// Body returned when the application cannot answer.
+const UNAVAILABLE_MESSAGE: &str = "bot non disponibile";
+/// Body returned for unknown routes.
+const NOT_FOUND_MESSAGE: &str = "non trovato";
+/// Body returned when a POST request lacks the mandatory custom header.
+const FORBIDDEN_MESSAGE: &str = "richiesta non autorizzata";
+/// Body returned when the chat request body is missing, unreadable or too large.
+const BAD_BODY_MESSAGE: &str = "corpo della richiesta non valido";
+/// Body returned when the state cannot be serialized.
+const SERIALIZE_ERROR_MESSAGE: &str = "errore di serializzazione";
+
+/// Command sent by the web panel to the application.
+#[derive(Debug)]
+pub enum WebCommand {
+    /// Only read the current state.
+    State,
+    /// Toggle the global autoreply.
+    ToggleGlobal,
+    /// Toggle the autoreply of the chat with this JID.
+    ToggleChat(String),
+    /// Quit the bot.
+    Quit,
+}
+
+/// One chat shown in the web panel.
+#[derive(Debug, Serialize)]
+pub struct WebChat {
+    /// Chat JID.
+    pub jid: String,
+    /// Display name of the chat.
+    pub name: String,
+    /// Whether the autoreply is enabled for this chat.
+    pub autoreply: bool,
+}
+
+/// Application state returned to the web panel.
+#[derive(Debug, Serialize)]
+pub struct WebState {
+    /// Whether the global autoreply is enabled.
+    pub global_autoreply: bool,
+    /// Chats known to the application.
+    pub chats: Vec<WebChat>,
+    /// Human readable status line.
+    pub status: String,
+}
+
+/// Request forwarded to the application through `AppEvent::Web`.
+#[derive(Debug)]
+pub struct WebRequest {
+    /// Command to execute.
+    pub command: WebCommand,
+    /// Channel on which the application sends the state after executing the command.
+    pub reply: oneshot::Sender<WebState>,
+}
+
+/// Route resolved from an HTTP method and path.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Route {
+    /// `GET /`.
+    Page,
+    /// `GET /api/state`.
+    State,
+    /// `POST /api/global`.
+    ToggleGlobal,
+    /// `POST /api/chat`.
+    ToggleChat,
+    /// `POST /api/quit`.
+    Quit,
+    /// Any other method/path combination.
+    NotFound,
+}
+
+impl Route {
+    /// Purpose: tell whether the route is a state-changing POST route.
+    /// Inputs: none.
+    /// Output: `true` for the POST routes, which must carry the mandatory custom header.
+    /// Side effects: none.
+    fn requires_request_header(self) -> bool {
+        matches!(self, Route::ToggleGlobal | Route::ToggleChat | Route::Quit)
+    }
+}
+
+/// HTTP response ready to be written.
+struct HttpReply {
+    /// HTTP status code.
+    status: u16,
+    /// Value of the `Content-Type` header.
+    content_type: &'static str,
+    /// Response body.
+    body: String,
+}
+
+/// Purpose: start the web control server.
+/// Inputs: `port`, the TCP port to listen on; `events`, the application event sender.
+/// Output: `Ok(())` once the server is bound and its thread is running, otherwise the bind or spawn error.
+/// Side effects: binds a TCP socket on all interfaces and spawns a detached OS thread that ends when
+/// the server stops or the application channel is closed.
+pub fn start(port: u16, events: UnboundedSender<AppEvent>) -> Result<()> {
+    let server = Server::http((BIND_ADDRESS, port))
+        .map_err(|err| anyhow!("cannot bind web server on {BIND_ADDRESS}:{port}: {err}"))?;
+    std::thread::Builder::new()
+        .name(WEB_THREAD_NAME.to_owned())
+        .spawn(move || serve(&server, &events))
+        .context("cannot spawn web thread")?;
+    Ok(())
+}
+
+/// Purpose: handle incoming requests one at a time until the server or the application is gone.
+/// Inputs: `server`, the bound HTTP server; `events`, the application event sender.
+/// Output: none.
+/// Side effects: blocks on the network; forwards commands to the application and writes responses.
+fn serve(server: &Server, events: &UnboundedSender<AppEvent>) {
+    for request in server.incoming_requests() {
+        handle_request(request, events);
+        if events.is_closed() {
+            break;
+        }
+    }
+}
+
+/// Purpose: route one request and answer it.
+/// Inputs: `request`, the incoming request; `events`, the application event sender.
+/// Output: none.
+/// Side effects: may read the request body, block until the application replies, and write the response;
+/// a POST route without the mandatory custom header is answered with 403 and nothing is forwarded.
+fn handle_request(mut request: Request, events: &UnboundedSender<AppEvent>) {
+    let route = resolve_route(request.method(), path_without_query(request.url()));
+    if route.requires_request_header() && !has_request_header(request.headers()) {
+        respond(request, text_reply(STATUS_FORBIDDEN, FORBIDDEN_MESSAGE));
+        return;
+    }
+    if route == Route::Quit {
+        handle_quit(request, events);
+        return;
+    }
+    let reply = build_reply(&mut request, route, events);
+    respond(request, reply);
+}
+
+/// Purpose: compute the response of every route except quit.
+/// Inputs: `request`, the incoming request (its body is read only for the chat route);
+/// `route`, the resolved route; `events`, the application event sender.
+/// Output: the response to write.
+/// Side effects: may read the request body and block until the application replies.
+fn build_reply(request: &mut Request, route: Route, events: &UnboundedSender<AppEvent>) -> HttpReply {
+    match route {
+        Route::Page => HttpReply {
+            status: STATUS_OK,
+            content_type: CONTENT_TYPE_HTML,
+            body: PAGE_HTML.to_owned(),
+        },
+        Route::State => forward(events, WebCommand::State),
+        Route::ToggleGlobal => forward(events, WebCommand::ToggleGlobal),
+        Route::ToggleChat => match read_limited_body(request.as_reader()) {
+            Some(jid) => forward(events, WebCommand::ToggleChat(jid)),
+            None => text_reply(STATUS_BAD_REQUEST, BAD_BODY_MESSAGE),
+        },
+        Route::Quit | Route::NotFound => text_reply(STATUS_NOT_FOUND, NOT_FOUND_MESSAGE),
+    }
+}
+
+/// Purpose: answer a quit request and then ask the application to quit.
+/// Inputs: `request`, the incoming request; `events`, the application event sender.
+/// Output: none.
+/// Side effects: writes the response first (the process exits right after the app handles the event),
+/// then sends `WebCommand::Quit` without waiting for the reply.
+fn handle_quit(request: Request, events: &UnboundedSender<AppEvent>) {
+    let body = serde_json::json!({ JSON_KEY_STATUS: QUIT_MESSAGE }).to_string();
+    respond(request, json_reply(STATUS_OK, body));
+
+    let (reply, _receiver) = oneshot::channel();
+    let quit = WebRequest {
+        command: WebCommand::Quit,
+        reply,
+    };
+
+    // Ignoring a send failure is fine here: it means the app is already shutting down.
+    let _ = events.send(AppEvent::Web(quit));
+}
+
+/// Purpose: send a command to the application and wait for the resulting state.
+/// Inputs: `events`, the application event sender; `command`, the command to execute.
+/// Output: a JSON response with the state, 503 if the app is unreachable, 500 if serialization fails.
+/// Side effects: blocks the calling (non-runtime) thread until the application replies.
+fn forward(events: &UnboundedSender<AppEvent>, command: WebCommand) -> HttpReply {
+    let (reply, receiver) = oneshot::channel();
+    if events.send(AppEvent::Web(WebRequest { command, reply })).is_err() {
+        return text_reply(STATUS_UNAVAILABLE, UNAVAILABLE_MESSAGE);
+    }
+    match receiver.blocking_recv() {
+        Ok(state) => state_reply(&state),
+        Err(_) => text_reply(STATUS_UNAVAILABLE, UNAVAILABLE_MESSAGE),
+    }
+}
+
+/// Purpose: serialize the application state into a response.
+/// Inputs: `state`, the state to serialize.
+/// Output: a 200 JSON response, or a 500 plain text response if serialization fails.
+/// Side effects: none.
+fn state_reply(state: &WebState) -> HttpReply {
+    match serde_json::to_string(state) {
+        Ok(body) => json_reply(STATUS_OK, body),
+        Err(_) => text_reply(STATUS_INTERNAL_ERROR, SERIALIZE_ERROR_MESSAGE),
+    }
+}
+
+/// Purpose: build a JSON response.
+/// Inputs: `status`, the HTTP status code; `body`, the JSON text.
+/// Output: the response.
+/// Side effects: none.
+fn json_reply(status: u16, body: String) -> HttpReply {
+    HttpReply {
+        status,
+        content_type: CONTENT_TYPE_JSON,
+        body,
+    }
+}
+
+/// Purpose: build a plain text response.
+/// Inputs: `status`, the HTTP status code; `body`, the text.
+/// Output: the response.
+/// Side effects: none.
+fn text_reply(status: u16, body: &str) -> HttpReply {
+    HttpReply {
+        status,
+        content_type: CONTENT_TYPE_TEXT,
+        body: body.to_owned(),
+    }
+}
+
+/// Purpose: write a response to the client.
+/// Inputs: `request`, the request to answer; `reply`, the response to write.
+/// Output: none.
+/// Side effects: writes to the client connection; if the (constant) content type header cannot be
+/// built the response is sent without it rather than failing.
+fn respond(request: Request, reply: HttpReply) {
+    let mut response = Response::from_string(reply.body).with_status_code(reply.status);
+    if let Ok(header) = Header::from_bytes(HEADER_CONTENT_TYPE.as_bytes(), reply.content_type.as_bytes()) {
+        response = response.with_header(header);
+    }
+
+    // Ignoring a write failure is fine here: it means the client already disconnected.
+    let _ = request.respond(response);
+}
+
+/// Purpose: strip the query string from a request URL.
+/// Inputs: `url`, the request URL as received.
+/// Output: the part of `url` before the first `?`.
+/// Side effects: none.
+fn path_without_query(url: &str) -> &str {
+    url.split_once('?').map_or(url, |(path, _)| path)
+}
+
+/// Purpose: map an HTTP method and path to a route.
+/// Inputs: `method`, the HTTP method; `path`, the URL path without query.
+/// Output: the matching route, or `Route::NotFound`.
+/// Side effects: none.
+fn resolve_route(method: &Method, path: &str) -> Route {
+    match (method, path) {
+        (Method::Get, PATH_PAGE) => Route::Page,
+        (Method::Get, PATH_STATE) => Route::State,
+        (Method::Post, PATH_GLOBAL) => Route::ToggleGlobal,
+        (Method::Post, PATH_CHAT) => Route::ToggleChat,
+        (Method::Post, PATH_QUIT) => Route::Quit,
+        _ => Route::NotFound,
+    }
+}
+
+/// Purpose: check that a request carries the custom header required on POST routes.
+/// Inputs: `headers`, the request headers.
+/// Output: `true` if a header named `REQUEST_HEADER_NAME` (name matched case-insensitively) has
+/// exactly the value `REQUEST_HEADER_VALUE`, otherwise `false`.
+/// Side effects: none.
+fn has_request_header(headers: &[Header]) -> bool {
+    headers
+        .iter()
+        .any(|header| header.field.equiv(REQUEST_HEADER_NAME) && header.value.as_str() == REQUEST_HEADER_VALUE)
+}
+
+/// Purpose: read a request body as trimmed UTF-8 text, refusing oversized bodies.
+/// Inputs: `reader`, the body reader.
+/// Output: `Some(text)` for a non-empty body of at most `MAX_BODY_BYTES`, otherwise `None`
+/// (read error, invalid UTF-8, empty or too large).
+/// Side effects: consumes at most `MAX_BODY_BYTES + 1` bytes from `reader`.
+fn read_limited_body<R: Read>(reader: R) -> Option<String> {
+    let mut bytes = Vec::new();
+    reader
+        .take(MAX_BODY_BYTES as u64 + 1)
+        .read_to_end(&mut bytes)
+        .ok()?;
+    if bytes.len() > MAX_BODY_BYTES {
+        return None;
+    }
+    let text = String::from_utf8(bytes).ok()?;
+    let trimmed = text.trim();
+    (!trimmed.is_empty()).then(|| trimmed.to_owned())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn path_without_query_strips_query_string() {
+        assert_eq!(path_without_query("/api/state?x=1&y=2"), "/api/state");
+        assert_eq!(path_without_query("/api/state"), "/api/state");
+        assert_eq!(path_without_query("/?"), "/");
+    }
+
+    #[test]
+    fn resolve_route_matches_known_routes() {
+        assert_eq!(resolve_route(&Method::Get, PATH_PAGE), Route::Page);
+        assert_eq!(resolve_route(&Method::Get, PATH_STATE), Route::State);
+        assert_eq!(resolve_route(&Method::Post, PATH_GLOBAL), Route::ToggleGlobal);
+        assert_eq!(resolve_route(&Method::Post, PATH_CHAT), Route::ToggleChat);
+        assert_eq!(resolve_route(&Method::Post, PATH_QUIT), Route::Quit);
+    }
+
+    #[test]
+    fn resolve_route_rejects_wrong_method_or_path() {
+        assert_eq!(resolve_route(&Method::Post, PATH_STATE), Route::NotFound);
+        assert_eq!(resolve_route(&Method::Get, PATH_QUIT), Route::NotFound);
+        assert_eq!(resolve_route(&Method::Get, "/unknown"), Route::NotFound);
+    }
+
+    /// Builds a header from raw name and value for the tests.
+    fn header(name: &str, value: &str) -> Header {
+        Header::from_bytes(name.as_bytes(), value.as_bytes()).expect("valid test header")
+    }
+
+    #[test]
+    fn has_request_header_accepts_required_header_any_case() {
+        assert!(has_request_header(&[header(REQUEST_HEADER_NAME, REQUEST_HEADER_VALUE)]));
+        assert!(has_request_header(&[header("x-wh-request", REQUEST_HEADER_VALUE)]));
+        assert!(has_request_header(&[
+            header("Accept", "*/*"),
+            header("X-WH-REQUEST", REQUEST_HEADER_VALUE),
+        ]));
+    }
+
+    #[test]
+    fn has_request_header_rejects_missing_or_wrong_value() {
+        assert!(!has_request_header(&[]));
+        assert!(!has_request_header(&[header("Accept", "*/*")]));
+        assert!(!has_request_header(&[header(REQUEST_HEADER_NAME, "0")]));
+        assert!(!has_request_header(&[header(REQUEST_HEADER_NAME, "")]));
+    }
+
+    #[test]
+    fn only_post_routes_require_request_header() {
+        assert!(Route::ToggleGlobal.requires_request_header());
+        assert!(Route::ToggleChat.requires_request_header());
+        assert!(Route::Quit.requires_request_header());
+        assert!(!Route::Page.requires_request_header());
+        assert!(!Route::State.requires_request_header());
+        assert!(!Route::NotFound.requires_request_header());
+    }
+
+    #[test]
+    fn read_limited_body_trims_text() {
+        let body = read_limited_body(&b"  123@s.whatsapp.net\n"[..]);
+        assert_eq!(body.as_deref(), Some("123@s.whatsapp.net"));
+    }
+
+    #[test]
+    fn read_limited_body_rejects_empty_and_invalid() {
+        assert_eq!(read_limited_body(&b" \n"[..]), None);
+        assert_eq!(read_limited_body(&[0xff, 0xfe][..]), None);
+    }
+
+    #[test]
+    fn read_limited_body_enforces_size_limit() {
+        let at_limit = vec![b'a'; MAX_BODY_BYTES];
+        let over_limit = vec![b'a'; MAX_BODY_BYTES + 1];
+        assert!(read_limited_body(&at_limit[..]).is_some());
+        assert_eq!(read_limited_body(&over_limit[..]), None);
+    }
+}

+ 640 - 0
src/web_page.html

@@ -0,0 +1,640 @@
+<!DOCTYPE html>
+<html lang="it">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+<meta name="color-scheme" content="dark light">
+<title>wh</title>
+<style>
+:root {
+  color-scheme: dark light;
+  --accent: #25d366;
+  --accent-ink: #062b14;
+  --danger: #ff6b7a;
+  --danger-soft: rgba(255, 107, 122, 0.14);
+  --bg: #0b141a;
+  --surface: #162129;
+  --surface-hover: #1d2b34;
+  --header-bg: rgba(11, 20, 26, 0.88);
+  --text: #e9edef;
+  --muted: #8696a0;
+  --line: rgba(233, 237, 239, 0.09);
+  --track-off: #3b4a54;
+  --knob: #ffffff;
+  --shadow: 0 10px 30px rgba(0, 0, 0, 0.35), 0 2px 6px rgba(0, 0, 0, 0.25);
+  --overlay-bg: rgba(5, 10, 13, 0.94);
+  --radius-card: 18px;
+  --radius-control: 12px;
+  --gutter: 16px;
+  --tap-size: 44px;
+  --bar-height: 64px;
+  --transition: 180ms ease;
+  --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+}
+
+@media (prefers-color-scheme: light) {
+  :root {
+    --accent-ink: #ffffff;
+    --accent: #1fa855;
+    --danger: #c62839;
+    --danger-soft: rgba(198, 40, 57, 0.1);
+    --bg: #eef1f3;
+    --surface: #ffffff;
+    --surface-hover: #f4f7f8;
+    --header-bg: rgba(238, 241, 243, 0.9);
+    --text: #111b21;
+    --muted: #5b6b75;
+    --line: rgba(17, 27, 33, 0.09);
+    --track-off: #b9c4cb;
+    --shadow: 0 10px 30px rgba(17, 27, 33, 0.1), 0 2px 6px rgba(17, 27, 33, 0.06);
+    --overlay-bg: rgba(238, 241, 243, 0.96);
+  }
+}
+
+* { box-sizing: border-box; }
+
+html { -webkit-text-size-adjust: 100%; }
+
+body {
+  margin: 0;
+  min-height: 100vh;
+  background: var(--bg);
+  color: var(--text);
+  font-family: var(--font);
+  font-size: 16px;
+  line-height: 1.4;
+  overflow-x: hidden;
+  padding-bottom: calc(var(--tap-size) + 48px + env(safe-area-inset-bottom, 0px));
+}
+
+/* Sticky top bar */
+.topbar {
+  position: sticky;
+  top: 0;
+  z-index: 10;
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 4px 12px;
+  min-height: var(--bar-height);
+  padding: 8px calc(var(--gutter) + env(safe-area-inset-right, 0px)) 8px calc(var(--gutter) + env(safe-area-inset-left, 0px));
+  background: var(--header-bg);
+  border-bottom: 1px solid var(--line);
+  -webkit-backdrop-filter: blur(12px);
+  backdrop-filter: blur(12px);
+}
+
+.topbar__title {
+  margin: 0;
+  font-size: 1.5rem;
+  font-weight: 800;
+  letter-spacing: -0.03em;
+  color: var(--accent);
+}
+
+.topbar__global { margin-left: 4px; }
+
+.btn-quit {
+  margin-left: auto;
+  min-height: var(--tap-size);
+  padding: 0 16px;
+  background: transparent;
+  color: var(--danger);
+  border: 1.5px solid var(--danger);
+  border-radius: var(--radius-control);
+  font: inherit;
+  font-weight: 600;
+  cursor: pointer;
+  transition: background var(--transition);
+}
+
+.btn-quit:hover { background: var(--danger-soft); }
+.btn-quit:focus-visible { outline: 3px solid var(--danger); outline-offset: 2px; }
+.btn-quit:disabled { opacity: 0.5; cursor: progress; }
+
+/* Chat card */
+.page {
+  width: 100%;
+  max-width: 720px;
+  margin: 0 auto;
+  padding: 20px var(--gutter) 0;
+}
+
+.card {
+  background: var(--surface);
+  border: 1px solid var(--line);
+  border-radius: var(--radius-card);
+  box-shadow: var(--shadow);
+  overflow: hidden;
+}
+
+.card__head,
+.row__label {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  align-items: center;
+  column-gap: 16px;
+  padding: 0 16px;
+}
+
+.card__head {
+  min-height: 40px;
+  color: var(--muted);
+  font-size: 0.875rem;
+  font-weight: 600;
+  border-bottom: 1px solid var(--line);
+}
+
+.chat-list { list-style: none; margin: 0; padding: 0; }
+
+.row + .row { border-top: 1px solid var(--line); }
+
+.row__label {
+  position: relative;
+  min-height: 56px;
+  cursor: pointer;
+  transition: background var(--transition);
+}
+
+.row__label:hover { background: var(--surface-hover); }
+
+.row__name {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.empty {
+  margin: 0;
+  padding: 40px 16px;
+  text-align: center;
+  color: var(--muted);
+}
+
+/* Slider switch: hidden checkbox + styled track and knob */
+.switch {
+  --track-width: 52px;
+  --track-height: 30px;
+  --knob-gap: 3px;
+  position: relative;
+  display: inline-flex;
+  align-items: center;
+  gap: 10px;
+  min-height: var(--tap-size);
+  cursor: pointer;
+}
+
+.switch--large {
+  --track-width: 60px;
+  --track-height: 34px;
+  font-weight: 700;
+}
+
+.switch__input {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0 0 0 0);
+  clip-path: inset(50%);
+  white-space: nowrap;
+  opacity: 0;
+}
+
+.switch__track {
+  position: relative;
+  flex: none;
+  width: var(--track-width);
+  height: var(--track-height);
+  background: var(--track-off);
+  border-radius: 999px;
+  transition: background var(--transition);
+}
+
+.switch__knob {
+  position: absolute;
+  top: var(--knob-gap);
+  left: var(--knob-gap);
+  width: calc(var(--track-height) - 2 * var(--knob-gap));
+  height: calc(var(--track-height) - 2 * var(--knob-gap));
+  background: var(--knob);
+  border-radius: 50%;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
+  transition: transform var(--transition);
+}
+
+.switch__input:checked + .switch__track { background: var(--accent); }
+
+.switch__input:checked + .switch__track .switch__knob {
+  transform: translateX(calc(var(--track-width) - var(--track-height)));
+}
+
+.switch__input:focus-visible + .switch__track {
+  outline: 3px solid var(--accent);
+  outline-offset: 3px;
+}
+
+.switch__input:disabled + .switch__track { opacity: 0.5; }
+.switch:has(.switch__input:disabled),
+.row__label:has(.switch__input:disabled) { cursor: progress; }
+
+/* Status bar */
+.status {
+  position: fixed;
+  left: 50%;
+  bottom: calc(16px + env(safe-area-inset-bottom, 0px));
+  z-index: 20;
+  width: calc(100% - 2 * var(--gutter));
+  max-width: 720px;
+  padding: 12px 16px;
+  transform: translateX(-50%);
+  background: var(--surface);
+  color: var(--text);
+  border: 1px solid var(--line);
+  border-radius: var(--radius-control);
+  box-shadow: var(--shadow);
+  overflow-wrap: anywhere;
+}
+
+.status--error {
+  color: var(--danger);
+  border-color: var(--danger);
+  background: var(--surface);
+}
+
+/* Closed overlay */
+.closed {
+  position: fixed;
+  inset: 0;
+  z-index: 30;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  padding: var(--gutter);
+  background: var(--overlay-bg);
+  text-align: center;
+}
+
+.closed__title { margin: 0; font-size: 2rem; font-weight: 800; letter-spacing: -0.02em; }
+.closed__detail { margin: 0; color: var(--muted); }
+
+[hidden] { display: none !important; }
+
+/* Narrow screens: tighter top bar so it fits 320px without horizontal scroll */
+@media (max-width: 380px) {
+  .topbar { gap: 4px 8px; }
+  .topbar__title { font-size: 1.25rem; }
+  .topbar__global { margin-left: 0; }
+  .switch--large { gap: 8px; }
+  .btn-quit { padding: 0 12px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  *, *::before, *::after { transition: none !important; }
+}
+</style>
+</head>
+<body>
+<header class="topbar">
+  <h1 class="topbar__title">wh</h1>
+  <label class="switch switch--large topbar__global">
+    <span>AI tutte</span>
+    <input class="switch__input" type="checkbox" id="global-switch">
+    <span class="switch__track"><span class="switch__knob"></span></span>
+  </label>
+  <button class="btn-quit" type="button" id="quit-button">Chiudi bot</button>
+</header>
+
+<main class="page">
+  <section class="card" aria-label="Chat">
+    <div class="card__head"><span>Nome</span><span>AI</span></div>
+    <ul class="chat-list" id="chat-list"></ul>
+    <p class="empty" id="empty-state" hidden>Nessuna chat</p>
+  </section>
+</main>
+
+<div class="status" id="status" role="status" aria-live="polite" hidden></div>
+
+<div class="closed" id="closed" role="alertdialog" aria-labelledby="closed-title" hidden>
+  <p class="closed__title" id="closed-title">Bot chiuso</p>
+  <p class="closed__detail" id="closed-detail"></p>
+</div>
+
+<script>
+"use strict";
+
+const POLL_INTERVAL_MS = 5000;
+const API_STATE = "/api/state";
+const API_GLOBAL = "/api/global";
+const API_CHAT = "/api/chat";
+const API_QUIT = "/api/quit";
+const METHOD_GET = "GET";
+const METHOD_POST = "POST";
+const CONTENT_TYPE_TEXT = "text/plain";
+const CSRF_HEADER_NAME = "X-Wh-Request";
+const CSRF_HEADER_VALUE = "1";
+const GLOBAL_KEY = "\u0000global";
+const QUIT_CONFIRM_TEXT = "Chiudere il bot?";
+const NETWORK_ERROR_TEXT = "Connessione al bot non riuscita";
+const UNNAMED_CHAT_TEXT = "(senza nome)";
+const CLASS_STATUS_ERROR = "status--error";
+
+const globalSwitch = document.getElementById("global-switch");
+const quitButton = document.getElementById("quit-button");
+const chatList = document.getElementById("chat-list");
+const emptyState = document.getElementById("empty-state");
+const statusBar = document.getElementById("status");
+const closedOverlay = document.getElementById("closed");
+const closedDetail = document.getElementById("closed-detail");
+
+const rowsByJid = new Map();
+const pendingKeys = new Set();
+let lastState = null;
+let pollTimer = null;
+let ticketCounter = 0;
+let lastAppliedTicket = 0;
+let quitRequested = false;
+let botClosed = false;
+
+/**
+ * Error raised by requestJson; isNetwork distinguishes a failed fetch from an HTTP error.
+ */
+class RequestError extends Error {
+  constructor(message, isNetwork) {
+    super(message);
+    this.isNetwork = isNetwork;
+  }
+}
+
+/**
+ * Builds the request headers: every POST carries the CSRF header, a body adds its content type.
+ * Input: HTTP method and whether a body is sent. Output: headers object.
+ */
+function buildHeaders(method, hasBody) {
+  const headers = {};
+  if (method === METHOD_POST) headers[CSRF_HEADER_NAME] = CSRF_HEADER_VALUE;
+  if (hasBody) headers["Content-Type"] = CONTENT_TYPE_TEXT;
+  return headers;
+}
+
+/**
+ * Sends a request to the bot API and parses the JSON reply.
+ * Input: path, HTTP method, optional plain text body. Output: parsed JSON.
+ * Throws RequestError on network failure or non-200 status.
+ */
+async function requestJson(path, method, body) {
+  const options = { method, cache: "no-store", headers: buildHeaders(method, body !== undefined) };
+  if (body !== undefined) options.body = body;
+
+  let response;
+  try {
+    response = await fetch(path, options);
+  } catch (error) {
+    throw new RequestError(NETWORK_ERROR_TEXT, true);
+  }
+
+  if (!response.ok) {
+    const text = (await response.text()).trim();
+    throw new RequestError(text || `Errore HTTP ${response.status}`, false);
+  }
+  return response.json();
+}
+
+/**
+ * Shows a message in the status bar; an empty message hides it.
+ * Side effect: updates the status element.
+ */
+function showStatus(message, isError) {
+  statusBar.textContent = message || "";
+  statusBar.hidden = !message;
+  statusBar.classList.toggle(CLASS_STATUS_ERROR, Boolean(isError));
+}
+
+/**
+ * Shows the full-screen "Bot chiuso" overlay and stops polling.
+ * Side effect: sets botClosed, clears the poll timer.
+ */
+function showClosed(detail) {
+  botClosed = true;
+  clearInterval(pollTimer);
+  closedDetail.textContent = detail || "";
+  closedOverlay.hidden = false;
+  showStatus("", false);
+}
+
+/**
+ * Handles a failed request: a network error after quit means the bot is gone.
+ * Side effect: shows the closed overlay or an error in the status bar.
+ */
+function handleFailure(error) {
+  if (quitRequested && error.isNetwork) {
+    showClosed("");
+    return;
+  }
+  showStatus(error.message, true);
+}
+
+/**
+ * Builds a chat row (list item with label, name and switch) for a jid.
+ * Output: the li element, with its parts exposed as properties.
+ */
+function createRow(jid) {
+  const item = document.createElement("li");
+  item.className = "row";
+
+  const label = document.createElement("label");
+  label.className = "row__label";
+  const name = document.createElement("span");
+  name.className = "row__name";
+
+  const switchWrap = document.createElement("span");
+  switchWrap.className = "switch";
+  const input = document.createElement("input");
+  input.type = "checkbox";
+  input.className = "switch__input";
+  const track = document.createElement("span");
+  track.className = "switch__track";
+  const knob = document.createElement("span");
+  knob.className = "switch__knob";
+
+  track.append(knob);
+  switchWrap.append(input, track);
+  label.append(name, switchWrap);
+  item.append(label);
+
+  input.addEventListener("change", () => toggleChat(jid));
+  item.nameElement = name;
+  item.inputElement = input;
+  return item;
+}
+
+/**
+ * Updates a row from a chat entry using textContent only.
+ * A pending switch keeps the position given by the user: only the server reply may change it.
+ */
+function updateRow(row, chat) {
+  const displayName = chat.name || chat.jid || UNNAMED_CHAT_TEXT;
+  const isPending = pendingKeys.has(chat.jid);
+  row.nameElement.textContent = displayName;
+  row.nameElement.title = displayName;
+  if (!isPending) row.inputElement.checked = Boolean(chat.autoreply);
+  row.inputElement.disabled = isPending;
+}
+
+/**
+ * Returns true when the list already contains exactly these rows in this order.
+ */
+function hasSameOrder(rows) {
+  const current = chatList.children;
+  return current.length === rows.length && rows.every((row, index) => current[index] === row);
+}
+
+/**
+ * Drops cached rows whose jid is no longer present in the state.
+ */
+function pruneRows(chats) {
+  const activeJids = new Set(chats.map((chat) => chat.jid));
+  for (const jid of [...rowsByJid.keys()]) {
+    if (!activeJids.has(jid)) rowsByJid.delete(jid);
+  }
+}
+
+/**
+ * Renders the chat list from the state, reusing rows to keep keyboard focus.
+ * Side effect: mutates the chat list and the empty-state element.
+ */
+function renderChats(chats) {
+  pruneRows(chats);
+  const rows = chats.map((chat) => {
+    if (!rowsByJid.has(chat.jid)) rowsByJid.set(chat.jid, createRow(chat.jid));
+    const row = rowsByJid.get(chat.jid);
+    updateRow(row, chat);
+    return row;
+  });
+
+  if (!hasSameOrder(rows)) chatList.replaceChildren(...rows);
+  emptyState.hidden = rows.length > 0;
+}
+
+/**
+ * Renders the global switch and the chat list from a state object.
+ * A pending global switch keeps its position until the server reply arrives.
+ */
+function renderState(state) {
+  const isGlobalPending = pendingKeys.has(GLOBAL_KEY);
+  if (!isGlobalPending) globalSwitch.checked = Boolean(state.global_autoreply);
+  globalSwitch.disabled = isGlobalPending;
+  renderChats(Array.isArray(state.chats) ? state.chats : []);
+}
+
+/**
+ * Applies a server state if its ticket is newer than the last applied one.
+ * Output: true when applied. Side effect: re-renders and updates the status bar.
+ */
+function applyState(state, ticket) {
+  if (botClosed || ticket <= lastAppliedTicket) return false;
+  lastAppliedTicket = ticket;
+  lastState = state;
+  renderState(state);
+  showStatus(state.status, false);
+  return true;
+}
+
+/**
+ * Fetches the state and applies it; polls take their ticket before the request.
+ */
+async function refresh() {
+  if (botClosed || pendingKeys.size > 0) return;
+  const ticket = ++ticketCounter;
+  try {
+    applyState(await requestJson(API_STATE, METHOD_GET), ticket);
+  } catch (error) {
+    handleFailure(error);
+  }
+}
+
+/**
+ * Runs a toggle request while keeping its switch disabled.
+ * Input: pending key and a function performing the POST. Output: none.
+ * Side effect: the returned state replaces the rendered one.
+ */
+async function runToggle(key, sendRequest) {
+  pendingKeys.add(key);
+  if (lastState) renderState(lastState);
+
+  try {
+    applyState(await sendRequest(), ++ticketCounter);
+  } catch (error) {
+    handleFailure(error);
+  } finally {
+    pendingKeys.delete(key);
+    if (lastState && !botClosed) renderState(lastState);
+  }
+}
+
+/**
+ * Toggles the global "AI tutte" autoreply on the server.
+ */
+function toggleGlobal() {
+  return runToggle(GLOBAL_KEY, () => requestJson(API_GLOBAL, METHOD_POST));
+}
+
+/**
+ * Toggles the autoreply of one chat on the server.
+ */
+function toggleChat(jid) {
+  return runToggle(jid, () => requestJson(API_CHAT, METHOD_POST, jid));
+}
+
+/**
+ * Asks for confirmation, then asks the bot to quit.
+ * Side effect: shows the closed overlay on success or on a following network error.
+ */
+async function quitBot() {
+  if (!window.confirm(QUIT_CONFIRM_TEXT)) return;
+  quitRequested = true;
+  quitButton.disabled = true;
+
+  try {
+    const reply = await requestJson(API_QUIT, METHOD_POST);
+    showClosed(reply.status);
+  } catch (error) {
+    quitButton.disabled = false;
+    quitRequested = error.isNetwork;
+    handleFailure(error);
+  }
+}
+
+/**
+ * Refreshes when the tab becomes visible again.
+ */
+function onVisibilityChange() {
+  if (!document.hidden) refresh();
+}
+
+/**
+ * Periodic refresh tick, skipped while the page is hidden.
+ */
+function onPollTick() {
+  if (!document.hidden) refresh();
+}
+
+/**
+ * Wires events, performs the first refresh and starts polling.
+ */
+function init() {
+  globalSwitch.addEventListener("change", toggleGlobal);
+  quitButton.addEventListener("click", quitBot);
+  document.addEventListener("visibilitychange", onVisibilityChange);
+  refresh();
+  pollTimer = setInterval(onPollTick, POLL_INTERVAL_MS);
+}
+
+init();
+</script>
+</body>
+</html>