Browse Source

feat: add F8/web relink with QR pairing, config live reload and VISION.md rename

- src/whatsapp.rs: make the session replaceable; relink() logs out,
  deletes or clears session.db and opens a new session.
- src/app.rs: F8 twice (Esc cancels) and web relink; apply reloaded
  configuration; F3 targets the selected chat when the list is focused;
  expose screen and pairing QR in the web state.
- src/web.rs: add POST /api/relink, qr_svg() and screen/qr_svg state
  fields, with tests.
- src/web_page.html: add "Nuovo QR" button and connecting/pairing view
  with the QR code; refresh every 2s while not connected.
- src/ui.rs: show F8 key help in the footer and pairing screens.
- src/config_watch.rs: poll config.toml, SOUL.md and VISION.md every 2s
  and send ConfigReloaded on change.
- src/config.rs: rename vision.md to VISION.md; add source_files().
- src/main.rs: start the config watcher; clear the screen on exit and on
  list mode change without querying the cursor position.
- .gitignore: ignore VISION.md.
- README.md: document live reload, F8, web QR pairing, /api/relink and
  the ring-only TLS build.
softword 3 days ago
parent
commit
5c2fe04925
10 changed files with 721 additions and 53 deletions
  1. 1 0
      .gitignore
  2. 14 9
      README.md
  3. 168 15
      src/app.rs
  4. 12 4
      src/config.rs
  5. 74 0
      src/config_watch.rs
  6. 26 5
      src/main.rs
  7. 7 3
      src/ui.rs
  8. 59 1
      src/web.rs
  9. 133 5
      src/web_page.html
  10. 227 11
      src/whatsapp.rs

+ 1 - 0
.gitignore

@@ -15,5 +15,6 @@
 Thumbs.db
 Thumbs.db
 .DS_Store
 .DS_Store
 SOUL.md
 SOUL.md
+VISION.md
 *.log
 *.log
 
 

+ 14 - 9
README.md

@@ -12,7 +12,7 @@ cargo build --release
 
 
 ## Cross-compile: Linux x86_64 static (musl)
 ## Cross-compile: Linux x86_64 static (musl)
 
 
-Built from Windows with cargo-zigbuild. Zig provides the C cross-compiler and linker for the bundled C code (SQLite, aws-lc-sys, ring).
+Built from Windows with cargo-zigbuild. Zig provides the C cross-compiler and linker for the bundled C code (SQLite, ring). TLS uses `ring` only (reqwest is built with `rustls-no-provider` and the ring provider is installed at startup), so the large `aws-lc-sys` C build is avoided. A rebuild after a source change takes seconds; a full rebuild is needed only when dependencies change.
 
 
 One-time prerequisites:
 One-time prerequisites:
 
 
@@ -45,12 +45,12 @@ On Linux everything lives in `~/.config/wh/` of the user running `wh`; on Window
 | --- | --- |
 | --- | --- |
 | `config.toml` | settings (optional) |
 | `config.toml` | settings (optional) |
 | `SOUL.md` | system prompt (required for the AI) |
 | `SOUL.md` | system prompt (required for the AI) |
-| `vision.md` | request used to describe images (optional) |
+| `VISION.md` | request used to describe images (optional) |
 | `session.db` | WhatsApp linked-device session |
 | `session.db` | WhatsApp linked-device session |
 | `history.db` | chats, messages, autoreply flags, image descriptions |
 | `history.db` | chats, messages, autoreply flags, image descriptions |
 | `ai_requests.log` | AI request log, only with `ai_log = true` |
 | `ai_requests.log` | AI request log, only with `ai_log = true` |
 
 
-Copy `session.db` from Windows to reuse the linked session instead of scanning the QR again.
+Copy `session.db` from Windows to reuse the linked session instead of scanning the QR again; never run both copies at the same time. To pair again, use `F8` or the web panel "Nuovo QR" button.
 
 
 ## Configuration
 ## Configuration
 
 
@@ -68,9 +68,13 @@ Settings live in `config.toml` in the data directory. Start from `config.toml.gl
 | `ai_retries` | `2` | retries of a failed AI request, 2s, 4s, ... backoff (0 = off; HTTP 4xx except 429 never retried) |
 | `ai_retries` | `2` | retries of a failed AI request, 2s, 4s, ... backoff (0 = off; HTTP 4xx except 429 never retried) |
 | `web_port` | unset | port of the web control panel (1-65535); unset disables it |
 | `web_port` | unset | port of the web control panel (1-65535); unset disables it |
 
 
+### Live reload
+
+`config.toml`, `SOUL.md` and `VISION.md` are checked every 2 seconds and reloaded when changed, without restarting `wh` (status `Configurazione ricaricata`). An invalid file keeps the current configuration (`Configurazione non ricaricata: ...`). Only `web_port` needs a restart.
+
 ### System prompt: `SOUL.md`
 ### System prompt: `SOUL.md`
 
 
-The system prompt (the responder's identity and rules) lives in its own file, `SOUL.md`, next to `config.toml`, and is sent as written. Start from `soul.md.template`. Without `openai_api_key` and a non-blank `SOUL.md` the app runs as a plain WhatsApp client and the status line says `AI non configurata`. The file is read only at startup: restart `wh` after editing it.
+The system prompt (the responder's identity and rules) lives in its own file, `SOUL.md`, next to `config.toml`, and is sent as written. Start from `soul.md.template`. Without `openai_api_key` and a non-blank `SOUL.md` the app runs as a plain WhatsApp client and the status line says `AI non configurata`. `SOUL.md` and `VISION.md` are personal and ignored by git.
 
 
 The app appends to the prompt only factual context: whether the chat is private (`Chat privata con «nome».`) or a group (`Chat di gruppo «nome».`) and who writes the `user`/`assistant` messages. Everything about behaviour belongs in `SOUL.md`. Texts the model can find in the conversation:
 The app appends to the prompt only factual context: whether the chat is private (`Chat privata con «nome».`) or a group (`Chat di gruppo «nome».`) and who writes the `user`/`assistant` messages. Everything about behaviour belongs in `SOUL.md`. Texts the model can find in the conversation:
 
 
@@ -107,7 +111,7 @@ Enable it per chat with `F3` or for all chats with `F4` (private chats and group
 - Images are described just before the reply that covers them, all in parallel; at most the 5 most recent images per reply are described, older ones of the same batch stay without description by design.
 - Images are described just before the reply that covers them, all in parallel; at most the 5 most recent images per reply are described, older ones of the same batch stay without description by design.
 - Each download is retried up to 3 times (60 s timeout each); an image that still fails stays without description, the others are not affected; the status line shows `Descrizione immagine fallita (N): ...`.
 - Each download is retried up to 3 times (60 s timeout each); an image that still fails stays without description, the others are not affected; the status line shows `Descrizione immagine fallita (N): ...`.
 - Images received while the app was off are described for the catch-up reply, or on their own if they arrive after it.
 - Images received while the app was off are described for the catch-up reply, or on their own if they arrive after it.
-- `vision.md`, next to `SOUL.md`, replaces the built-in request used to describe images; when missing or blank the built-in request (objective, concise, in Italian) is used.
+- `VISION.md`, next to `SOUL.md`, replaces the built-in request used to describe images; when missing or blank the built-in request (objective, concise, in Italian) is used. The vision model receives only this request and the image, never the conversation: tone and reply style belong in `SOUL.md`, which can react to what the description contains.
 
 
 ## Keys
 ## Keys
 
 
@@ -119,9 +123,10 @@ Enable it per chat with `F3` or for all chats with `F4` (private chats and group
 | `F2` | chats/contacts |
 | `F2` | chats/contacts |
 | typing (list focused) | filter names; `Backspace` edits, `Esc` clears |
 | typing (list focused) | filter names; `Backspace` edits, `Esc` clears |
 | `Canc`/`Delete` twice | delete chat locally (history only, not on WhatsApp) |
 | `Canc`/`Delete` twice | delete chat locally (history only, not on WhatsApp) |
-| `F3` | AI autoreply for the open chat |
+| `F3` | AI autoreply for the selected chat (list focus) or the open chat (input focus) |
 | `F4` | AI autoreply for all chats (persistent; footer shortcut turns green when on) |
 | `F4` | AI autoreply for all chats (persistent; footer shortcut turns green when on) |
-| `F10` | quit |
+| `F8` twice (any screen) | delete the WhatsApp session and pair again: unlinks this device, empties `session.db` and shows a new QR without restarting (`Esc` cancels) |
+| `F10` | quit (the screen is cleared, also on terminals without an alternate screen) |
 
 
 Only function keys are used for commands, so they work inside GNU screen, tmux and over SSH.
 Only function keys are used for commands, so they work inside GNU screen, tmux and over SSH.
 
 
@@ -130,8 +135,8 @@ Only function keys are used for commands, so they work inside GNU screen, tmux a
 - Enabled by `web_port` in `config.toml` (e.g. `web_port = 8080`); open `http://<host>:<port>/`.
 - Enabled by `web_port` in `config.toml` (e.g. `web_port = 8080`); open `http://<host>:<port>/`.
 - Listens on `0.0.0.0` without authentication: use it only on a trusted network (e.g. a VPN).
 - Listens on `0.0.0.0` without authentication: use it only on a trusted network (e.g. a VPN).
 - The page (HTML/CSS/JS) is embedded in the binary: no extra files to deploy.
 - The page (HTML/CSS/JS) is embedded in the binary: no extra files to deploy.
-- Features: "AI tutte" switch (same as `F4`), per-chat AI toggle grid (private chats and groups only), "Chiudi bot" button to quit. The page refreshes every 5 seconds; changes show up in the TUI too.
-- API (for scripts): `GET /api/state` (JSON `{global_autoreply, chats:[{jid,name,autoreply}], status}`); `POST /api/global`; `POST /api/chat` with the jid as plain-text body; `POST /api/quit`. Every POST requires the header `X-Wh-Request: 1` (otherwise 403), which blocks cross-site requests from other pages.
+- Features: "AI tutte" switch (same as `F4`), per-chat AI toggle grid (private chats and groups only), "Nuovo QR" button (same as `F8`, with confirmation), "Chiudi bot" button to quit. While WhatsApp is not connected the page shows the connection progress or the pairing QR code, to scan from the phone. The page refreshes every 5 seconds (2 while not connected); changes show up in the TUI too.
+- API (for scripts): `GET /api/state` (JSON `{global_autoreply, chats:[{jid,name,autoreply}], status, screen, qr_svg}`, `screen` = `connecting`/`pairing`/`main`, `qr_svg` = QR code as SVG while pairing, else `null`); `POST /api/global`; `POST /api/chat` with the jid as plain-text body; `POST /api/relink`; `POST /api/quit`. Every POST requires the header `X-Wh-Request: 1` (otherwise 403), which blocks cross-site requests from other pages.
 
 
 Example:
 Example:
 
 

+ 168 - 15
src/app.rs

@@ -14,7 +14,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::web::{self, 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`.
@@ -22,10 +22,12 @@ pub const MESSAGES_SHOWN: usize = 200;
 
 
 /// Key code of the function key that toggles the list mode.
 /// Key code of the function key that toggles the list mode.
 const LIST_MODE_FUNCTION_KEY: u8 = 2;
 const LIST_MODE_FUNCTION_KEY: u8 = 2;
-/// Key code of the function key (F3) that toggles the autoreply of the open chat.
+/// Key code of the function key (F3) that toggles the autoreply of the focused chat.
 const AUTOREPLY_FUNCTION_KEY: u8 = 3;
 const AUTOREPLY_FUNCTION_KEY: u8 = 3;
 /// Key code of the function key (F4) that toggles the global autoreply.
 /// Key code of the function key (F4) that toggles the global autoreply.
 const GLOBAL_AUTOREPLY_FUNCTION_KEY: u8 = 4;
 const GLOBAL_AUTOREPLY_FUNCTION_KEY: u8 = 4;
+/// Key code of the function key (F8) that deletes the WhatsApp session and pairs again.
+const RELINK_FUNCTION_KEY: u8 = 8;
 /// Key code of the function key (F10) that quits the application.
 /// Key code of the function key (F10) that quits the application.
 const QUIT_FUNCTION_KEY: u8 = 10;
 const QUIT_FUNCTION_KEY: u8 = 10;
 /// Rows the list selection moves with PageUp/PageDown.
 /// Rows the list selection moves with PageUp/PageDown.
@@ -84,6 +86,20 @@ 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 asking to confirm the deletion of the WhatsApp session (F8).
+const STATUS_RELINK_CONFIRM: &str = "Cancellare la sessione WhatsApp e rifare l'abbinamento? F8 conferma, Esc annulla";
+/// Status shown when a pending relink is cancelled by another key.
+const STATUS_RELINK_CANCELLED: &str = "Nuovo abbinamento annullato";
+/// Status shown while the old session is logged out and deleted.
+const STATUS_RELINKING: &str = "Cancellazione della sessione in corso...";
+/// Status shown once the new session is started and its QR code is coming.
+const STATUS_RELINK_READY: &str = "Sessione cancellata: scansiona il nuovo QR";
+/// Prefix of the status shown when the session cannot be deleted or the new one started.
+const STATUS_RELINK_FAILED_PREFIX: &str = "Nuovo abbinamento fallito";
+/// Status shown when the configuration files changed and were reloaded.
+const STATUS_CONFIG_RELOADED: &str = "Configurazione ricaricata";
+/// Prefix of the status shown when the changed configuration cannot be loaded.
+const STATUS_CONFIG_RELOAD_FAILED_PREFIX: &str = "Configurazione non ricaricata";
 /// Status shown when the web control panel toggles a chat that is not in its list.
 /// 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";
 const STATUS_WEB_CHAT_UNKNOWN: &str = "Chat non trovata";
 
 
@@ -118,6 +134,10 @@ pub enum AppEvent {
     },
     },
     /// Command from the web control panel; the reply carries the state snapshot.
     /// Command from the web control panel; the reply carries the state snapshot.
     Web(WebRequest),
     Web(WebRequest),
+    /// A configuration file changed: the configuration reloaded from disk, or the load error.
+    ConfigReloaded(Result<Box<Config>, String>),
+    /// Outcome of a relink: the new unpaired session is started, or the error.
+    Relinked(Result<(), String>),
 }
 }
 
 
 /// The chat whose images are being described, and what to do once they are.
 /// The chat whose images are being described, and what to do once they are.
@@ -210,6 +230,10 @@ pub struct App {
     catch_up_done: bool,
     catch_up_done: bool,
     /// Jid of the chat awaiting the second Delete press that confirms its local deletion.
     /// Jid of the chat awaiting the second Delete press that confirms its local deletion.
     pending_delete: Option<String>,
     pending_delete: Option<String>,
+    /// Whether a first F8 press awaits the second one that confirms the relink.
+    pending_relink: bool,
+    /// Whether a relink is running (further requests are ignored until it ends).
+    relinking: bool,
     wa: WaHandle,
     wa: WaHandle,
     events: UnboundedSender<AppEvent>,
     events: UnboundedSender<AppEvent>,
 }
 }
@@ -254,6 +278,8 @@ impl App {
             own_jids_known: false,
             own_jids_known: false,
             catch_up_done: false,
             catch_up_done: false,
             pending_delete: None,
             pending_delete: None,
+            pending_relink: false,
+            relinking: false,
             wa,
             wa,
             events,
             events,
         })
         })
@@ -279,7 +305,98 @@ impl App {
                 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),
             AppEvent::Web(request) => self.handle_web_request(request),
+            AppEvent::ConfigReloaded(result) => {
+                self.apply_reloaded_config(result);
+                Ok(())
+            }
+            AppEvent::Relinked(result) => {
+                self.finish_relink(result);
+                Ok(())
+            }
+        }
+    }
+
+    /// Purpose: delete the WhatsApp session and start an unpaired one, which offers a new QR
+    /// code (F8 confirmed, or the web panel).
+    /// Inputs: none.
+    /// Output: none.
+    /// Side effects: ignored while a relink is running; otherwise shows the connecting screen,
+    /// forgets the own JIDs (the new pairing may be another account) and spawns the relink task,
+    /// which sends `AppEvent::Relinked`. Messages sent meanwhile fail with a send error.
+    fn start_relink(&mut self) {
+        self.pending_relink = false;
+        if self.relinking {
+            return;
         }
         }
+        self.relinking = true;
+        self.screen = Screen::Connecting;
+        self.qr_code = None;
+        self.status = STATUS_RELINKING.to_owned();
+        self.own_jids_known = false;
+        let (wa, events) = (self.wa.clone(), self.events.clone());
+        tokio::spawn(async move {
+            let result = wa.relink().await.map_err(|err| format!("{err:#}"));
+            send_event(&events, AppEvent::Relinked(result));
+        });
+    }
+
+    /// Purpose: report the outcome of a relink.
+    /// Inputs: `result`, `Ok` once the new session is started, otherwise the error text.
+    /// Output: none.
+    /// Side effects: allows new relinks and updates `status`; the screen follows the events of
+    /// the new session (QR codes, then connection).
+    fn finish_relink(&mut self, result: Result<(), String>) {
+        self.relinking = false;
+        self.status = match result {
+            Ok(()) => STATUS_RELINK_READY.to_owned(),
+            Err(err) => format!("{STATUS_RELINK_FAILED_PREFIX}: {err}"),
+        };
+    }
+
+    /// Purpose: handle F8: ask for confirmation, or start the relink on the second press.
+    /// Inputs: none.
+    /// Output: none.
+    /// Side effects: cancels a pending chat deletion; sets `pending_relink` and `status`, or
+    /// starts the relink (see `start_relink`).
+    fn request_relink(&mut self) {
+        self.cancel_pending_delete();
+        if self.pending_relink {
+            self.start_relink();
+            return;
+        }
+        self.pending_relink = true;
+        self.status = STATUS_RELINK_CONFIRM.to_owned();
+    }
+
+    /// Purpose: drop a pending relink confirmation, if any.
+    /// Inputs: none.
+    /// Output: `true` if a relink was awaiting confirmation.
+    /// Side effects: clears `pending_relink` and, when it was set, shows the cancellation status.
+    fn cancel_pending_relink(&mut self) -> bool {
+        let was_pending = std::mem::take(&mut self.pending_relink);
+        if was_pending {
+            self.status = STATUS_RELINK_CANCELLED.to_owned();
+        }
+        was_pending
+    }
+
+    /// Purpose: apply a configuration reloaded after a change of its files.
+    /// Inputs: `result`, the new configuration or the load error.
+    /// Output: none.
+    /// Side effects: on success replaces `config` and rebuilds the AI client (replies already
+    /// running keep the previous one); updates `status`. On error the current configuration stays.
+    fn apply_reloaded_config(&mut self, result: Result<Box<Config>, String>) {
+        let config = match result {
+            Ok(config) => *config,
+            Err(err) => {
+                self.status = format!("{STATUS_CONFIG_RELOAD_FAILED_PREFIX}: {err}");
+                return;
+            }
+        };
+        let (ai, status) = build_ai(&config);
+        self.config = config;
+        self.ai = ai;
+        self.status = if status.is_empty() { STATUS_CONFIG_RELOADED.to_owned() } else { status };
     }
     }
 
 
     /// Purpose: route a WhatsApp layer event.
     /// Purpose: route a WhatsApp layer event.
@@ -767,8 +884,9 @@ impl App {
     /// Purpose: handle a key press on any screen.
     /// Purpose: handle a key press on any screen.
     /// Inputs: `key`, the pressed key.
     /// Inputs: `key`, the pressed key.
     /// Output: `Ok(())`, or a history error.
     /// Output: `Ok(())`, or a history error.
-    /// Side effects: F10 quits on any screen; on the main screen F2 toggles the list mode, F3 the
-    /// autoreply of the open chat and F4 the global autoreply (any focus); Ctrl command chords are
+    /// Side effects: F10 quits and F8 asks/confirms a relink on any screen; any other key cancels a
+    /// pending relink (Esc is then consumed); on the main screen F2 toggles the list mode, F3 the
+    /// autoreply of the focused chat and F4 the global autoreply (any focus); Ctrl command chords are
     /// ignored so they never type into the input or the filter; otherwise may change
     /// ignored so they never type into the input or the filter; otherwise may change
     /// focus/selection, edit the input, send or open chats.
     /// focus/selection, edit the input, send or open chats.
     fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
     fn handle_key(&mut self, key: KeyEvent) -> Result<()> {
@@ -776,6 +894,13 @@ impl App {
             self.should_quit = true;
             self.should_quit = true;
             return Ok(());
             return Ok(());
         }
         }
+        if key.code == KeyCode::F(RELINK_FUNCTION_KEY) {
+            self.request_relink();
+            return Ok(());
+        }
+        if self.cancel_pending_relink() && key.code == KeyCode::Esc {
+            return Ok(());
+        }
         if self.screen != Screen::Main {
         if self.screen != Screen::Main {
             return Ok(());
             return Ok(());
         }
         }
@@ -973,13 +1098,18 @@ impl App {
         self.selected = 0;
         self.selected = 0;
     }
     }
 
 
-    /// Purpose: toggle the AI autoreply of the open chat (F3).
+    /// Purpose: toggle the AI autoreply of the focused chat (F3): the selected row when the list
+    /// has the focus, the open chat when the input has it.
     /// Inputs: none.
     /// Inputs: none.
     /// Output: `Ok(())`, or a history error.
     /// Output: `Ok(())`, or a history error.
     /// Side effects: writes the history, updates `status`, reloads chats; refuses to enable
     /// Side effects: writes the history, updates `status`, reloads chats; refuses to enable
     /// without an AI client or outside private chats and groups (disabling is always allowed).
     /// without an AI client or outside private chats and groups (disabling is always allowed).
     fn toggle_autoreply(&mut self) -> Result<()> {
     fn toggle_autoreply(&mut self) -> Result<()> {
-        let Some(jid) = self.open_chat.clone() else {
+        let target = match self.focus {
+            Focus::List => self.selected_jid(),
+            Focus::Input => self.open_chat.clone(),
+        };
+        let Some(jid) = target else {
             return Ok(());
             return Ok(());
         };
         };
         self.toggle_chat_autoreply(&jid)
         self.toggle_chat_autoreply(&jid)
@@ -1010,12 +1140,13 @@ impl App {
     /// Inputs: `request`, the command and the channel on which the state snapshot is sent back.
     /// Inputs: `request`, the command and the channel on which the state snapshot is sent back.
     /// Output: `Ok(())`, or a history error.
     /// Output: `Ok(())`, or a history error.
     /// Side effects: may toggle the global or a per-chat autoreply (writing the history), update
     /// 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.
+    /// `status`, start a relink or request the application to quit; always sends the state snapshot.
     fn handle_web_request(&mut self, request: WebRequest) -> Result<()> {
     fn handle_web_request(&mut self, request: WebRequest) -> Result<()> {
         match request.command {
         match request.command {
             WebCommand::State => {}
             WebCommand::State => {}
             WebCommand::ToggleGlobal => self.toggle_global_autoreply()?,
             WebCommand::ToggleGlobal => self.toggle_global_autoreply()?,
             WebCommand::ToggleChat(jid) => self.toggle_web_chat(&jid)?,
             WebCommand::ToggleChat(jid) => self.toggle_web_chat(&jid)?,
+            WebCommand::Relink => self.start_relink(),
             WebCommand::Quit => self.should_quit = true,
             WebCommand::Quit => self.should_quit = true,
         }
         }
 
 
@@ -1039,13 +1170,16 @@ impl App {
 
 
     /// Purpose: snapshot of the state shown by the web control panel.
     /// Purpose: snapshot of the state shown by the web control panel.
     /// Inputs: none.
     /// Inputs: none.
-    /// Output: the global flag, the toggleable chats and the status line.
+    /// Output: the global flag, the toggleable chats, the status line, the current screen and,
+    /// while pairing, the QR code as SVG.
     /// Side effects: none.
     /// Side effects: none.
     fn web_state(&self) -> WebState {
     fn web_state(&self) -> WebState {
         WebState {
         WebState {
             global_autoreply: self.global_autoreply,
             global_autoreply: self.global_autoreply,
             chats: web_chats(&self.chats),
             chats: web_chats(&self.chats),
             status: self.status.clone(),
             status: self.status.clone(),
+            screen: web_screen(self.screen),
+            qr_svg: self.qr_code.as_deref().and_then(web::qr_svg),
         }
         }
     }
     }
 
 
@@ -1073,13 +1207,7 @@ impl App {
     /// Output: `Ok(())`, or a history error.
     /// Output: `Ok(())`, or a history error.
     /// Side effects: loads name, autoreply flag and messages; moves the focus to the input.
     /// Side effects: loads name, autoreply flag and messages; moves the focus to the input.
     fn open_selected(&mut self) -> Result<()> {
     fn open_selected(&mut self) -> Result<()> {
-        let jid = match self.list_mode {
-            ListMode::Chats => self.visible_chats().get(self.selected).map(|chat| chat.jid.clone()),
-            ListMode::Contacts => {
-                self.visible_contacts().get(self.selected).map(|contact| contact.jid.clone())
-            }
-        };
-        let Some(jid) = jid else {
+        let Some(jid) = self.selected_jid() else {
             return Ok(());
             return Ok(());
         };
         };
         self.open_chat_name = self.history.display_name(&jid)?;
         self.open_chat_name = self.history.display_name(&jid)?;
@@ -1090,6 +1218,19 @@ impl App {
         Ok(())
         Ok(())
     }
     }
 
 
+    /// Purpose: identify the chat or contact on the selected row of the active list.
+    /// Inputs: none.
+    /// Output: its JID, or `None` when the visible list is empty.
+    /// Side effects: none.
+    fn selected_jid(&self) -> Option<String> {
+        match self.list_mode {
+            ListMode::Chats => self.visible_chats().get(self.selected).map(|chat| chat.jid.clone()),
+            ListMode::Contacts => {
+                self.visible_contacts().get(self.selected).map(|contact| contact.jid.clone())
+            }
+        }
+    }
+
     /// Purpose: number of rows of the active list.
     /// Purpose: number of rows of the active list.
     /// Inputs: none.
     /// Inputs: none.
     /// Output: length of the visible (filtered) chat or contact list according to `list_mode`.
     /// Output: length of the visible (filtered) chat or contact list according to `list_mode`.
@@ -1415,6 +1556,18 @@ fn can_enable_autoreply(jid: &str, ai_configured: bool) -> Result<(), &'static s
     Ok(())
     Ok(())
 }
 }
 
 
+/// Purpose: name the current screen for the web control panel.
+/// Inputs: `screen`, the screen shown by the TUI.
+/// Output: `web::SCREEN_CONNECTING`, `web::SCREEN_PAIRING` or `web::SCREEN_MAIN`.
+/// Side effects: none.
+fn web_screen(screen: Screen) -> &'static str {
+    match screen {
+        Screen::Connecting => web::SCREEN_CONNECTING,
+        Screen::Pairing => web::SCREEN_PAIRING,
+        Screen::Main => web::SCREEN_MAIN,
+    }
+}
+
 /// Purpose: build the chat list exposed by the web control panel.
 /// Purpose: build the chat list exposed by the web control panel.
 /// Inputs: `chats`, the chats shown by the TUI.
 /// Inputs: `chats`, the chats shown by the TUI.
 /// Output: the chats whose autoreply can be toggled (private chats and groups), in the same order.
 /// Output: the chats whose autoreply can be toggled (private chats and groups), in the same order.

+ 12 - 4
src/config.rs

@@ -22,7 +22,7 @@
 //! The prompt defining the responder's identity lives only in `SOUL.md`, next to
 //! The prompt defining the responder's identity lives only in `SOUL.md`, next to
 //! `config.toml`, read as written; the binary embeds no prompt text. A missing or blank
 //! `config.toml`, read as written; the binary embeds no prompt text. A missing or blank
 //! `SOUL.md` means the AI is not configured; an unreadable one is an error. The optional
 //! `SOUL.md` means the AI is not configured; an unreadable one is an error. The optional
-//! `vision.md`, next to it, replaces the built-in request used to describe images.
+//! `VISION.md`, next to it, replaces the built-in request used to describe images.
 
 
 use std::fmt;
 use std::fmt;
 use std::io::ErrorKind;
 use std::io::ErrorKind;
@@ -55,7 +55,7 @@ const CONFIG_FILE: &str = "config.toml";
 /// File name of the system prompt (the responder's identity) inside the data directory.
 /// File name of the system prompt (the responder's identity) inside the data directory.
 const SOUL_FILE: &str = "SOUL.md";
 const SOUL_FILE: &str = "SOUL.md";
 /// File name of the optional request used to describe images, inside the data directory.
 /// File name of the optional request used to describe images, inside the data directory.
-const VISION_FILE: &str = "vision.md";
+const VISION_FILE: &str = "VISION.md";
 /// 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.
 /// Name of the file key holding the web control panel port, used in validation messages.
@@ -83,7 +83,7 @@ pub struct Config {
     /// System prompt from `SOUL.md`, as written (not trimmed); `None` if the file is missing
     /// System prompt from `SOUL.md`, as written (not trimmed); `None` if the file is missing
     /// or blank, meaning the AI is not configured. There is no built-in default.
     /// or blank, meaning the AI is not configured. There is no built-in default.
     pub system_prompt: Option<String>,
     pub system_prompt: Option<String>,
-    /// Request sent with each image to obtain its description, from `vision.md`, as written;
+    /// Request sent with each image to obtain its description, from `VISION.md`, as written;
     /// `None` if the file is missing or blank, meaning the built-in request is used.
     /// `None` if the file is missing or blank, meaning the built-in request is used.
     pub image_description_prompt: Option<String>,
     pub image_description_prompt: Option<String>,
     /// Whether to log every request sent to the AI model, from `ai_log` (file); default `false`.
     /// Whether to log every request sent to the AI model, from `ai_log` (file); default `false`.
@@ -209,7 +209,7 @@ fn read_config_file(path: &Path) -> Result<FileConfig> {
         .with_context(|| format!("invalid config file {}", path.display()))
         .with_context(|| format!("invalid config file {}", path.display()))
 }
 }
 
 
-/// Purpose: load a prompt file (`SOUL.md` or `vision.md`) at `path`.
+/// Purpose: load a prompt file (`SOUL.md` or `VISION.md`) at `path`.
 /// Inputs: `path`, the location of the file.
 /// Inputs: `path`, the location of the file.
 /// Output: the content as written (a leading UTF-8 BOM removed); `None` if the file does not
 /// Output: the content as written (a leading UTF-8 BOM removed); `None` if the file does not
 /// exist or is blank; error naming the path if unreadable or not UTF-8.
 /// exist or is blank; error naming the path if unreadable or not UTF-8.
@@ -314,6 +314,14 @@ impl Config {
             .with_context(|| format!("cannot create data directory {}", self.data_dir.display()))
             .with_context(|| format!("cannot create data directory {}", self.data_dir.display()))
     }
     }
 
 
+    /// Purpose: list the files the configuration is loaded from.
+    /// Inputs: none.
+    /// Output: `config.toml`, `SOUL.md` and `VISION.md` inside `data_dir`.
+    /// Side effects: none.
+    pub fn source_files(&self) -> Vec<PathBuf> {
+        [CONFIG_FILE, SOUL_FILE, VISION_FILE].iter().map(|file| self.data_dir.join(file)).collect()
+    }
+
     /// Purpose: locate the WhatsApp session database.
     /// Purpose: locate the WhatsApp session database.
     /// Inputs: none.
     /// Inputs: none.
     /// Output: `data_dir/session.db`.
     /// Output: `data_dir/session.db`.

+ 74 - 0
src/config_watch.rs

@@ -0,0 +1,74 @@
+//! Polls the configuration files (`config.toml`, `SOUL.md`, `VISION.md`) and reloads the
+//! configuration when any of them changes, so edits apply without restarting `wh`.
+//!
+//! Polling the modification times avoids a file-notification dependency and behaves the same on
+//! Windows and Linux.
+
+use std::path::PathBuf;
+use std::time::{Duration, SystemTime};
+
+use tokio::sync::mpsc::UnboundedSender;
+
+use crate::app::AppEvent;
+use crate::config::Config;
+
+/// Seconds between two checks of the configuration files.
+const POLL_INTERVAL_SECONDS: u64 = 2;
+
+/// Purpose: start the background task that watches the configuration files.
+/// Inputs: `files`, the paths to watch; `app_tx`, the application event sender.
+/// Output: none.
+/// Side effects: spawns a tokio task that reads the files' metadata every
+/// `POLL_INTERVAL_SECONDS` and, on any change, reloads the configuration and sends
+/// `AppEvent::ConfigReloaded`; the task ends when the receiver is dropped.
+pub fn spawn(files: Vec<PathBuf>, app_tx: UnboundedSender<AppEvent>) {
+    tokio::spawn(async move {
+        let mut last_seen = modification_times(&files);
+        while !app_tx.is_closed() {
+            tokio::time::sleep(Duration::from_secs(POLL_INTERVAL_SECONDS)).await;
+            let current = modification_times(&files);
+            if current == last_seen {
+                continue;
+            }
+            last_seen = current;
+            let result = Config::load().map(Box::new).map_err(|err| format!("{err:#}"));
+            if app_tx.send(AppEvent::ConfigReloaded(result)).is_err() {
+                break;
+            }
+        }
+    });
+}
+
+/// Purpose: snapshot the modification time of each watched file.
+/// Inputs: `files`, the paths to inspect.
+/// Output: one entry per file, `None` when its metadata cannot be read (typically a missing file:
+/// creating or deleting it then counts as a change, and `Config::load` reports real read errors).
+/// Side effects: reads file metadata.
+fn modification_times(files: &[PathBuf]) -> Vec<Option<SystemTime>> {
+    files
+        .iter()
+        .map(|file| std::fs::metadata(file).and_then(|meta| meta.modified()).ok())
+        .collect()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    /// Directory (relative to the package root) where file tests create scratch files.
+    const TEST_SCRATCH_DIR: &str = "target";
+
+    #[test]
+    fn modification_times_track_creation_and_deletion() {
+        let path = PathBuf::from(TEST_SCRATCH_DIR)
+            .join(format!("wh-config-watch-test-{}.md", std::process::id()));
+        let files = vec![path.clone()];
+        assert_eq!(modification_times(&files), vec![None]);
+
+        std::fs::write(&path, "prompt").expect("temp file must be writable");
+        let created = modification_times(&files);
+        std::fs::remove_file(&path).expect("temp file must be removable");
+        assert!(created[0].is_some());
+        assert_eq!(modification_times(&files), vec![None]);
+    }
+}

+ 26 - 5
src/main.rs

@@ -4,14 +4,20 @@ mod ai;
 mod app;
 mod app;
 mod autoreply;
 mod autoreply;
 mod config;
 mod config;
+mod config_watch;
 mod history;
 mod history;
 mod ui;
 mod ui;
 mod web;
 mod web;
 mod whatsapp;
 mod whatsapp;
 
 
+use std::io::stdout;
+
 use anyhow::{Context, Result};
 use anyhow::{Context, Result};
 use ratatui::DefaultTerminal;
 use ratatui::DefaultTerminal;
+use ratatui::crossterm::cursor::MoveTo;
 use ratatui::crossterm::event::{self, Event, KeyEventKind};
 use ratatui::crossterm::event::{self, Event, KeyEventKind};
+use ratatui::crossterm::execute;
+use ratatui::crossterm::terminal::{Clear, ClearType};
 use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
 use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
 
 
 use crate::app::{App, AppDeps, AppEvent};
 use crate::app::{App, AppDeps, AppEvent};
@@ -31,8 +37,8 @@ const VERSION_FLAGS: [&str; 2] = ["--version", "-V"];
 /// Inputs: the command-line arguments; reads config.toml and terminal events.
 /// Inputs: the command-line arguments; 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: with `--version`/`-V` only prints `wh <BUILD_VERSION>`; otherwise creates the data
 /// Side effects: with `--version`/`-V` only prints `wh <BUILD_VERSION>`; otherwise 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.
+/// directory and databases, connects to WhatsApp, takes over the terminal, starts the
+/// optional web control panel when `web_port` is configured and watches the configuration files.
 #[tokio::main]
 #[tokio::main]
 async fn main() -> Result<()> {
 async fn main() -> Result<()> {
     if wants_version(std::env::args().skip(1)) {
     if wants_version(std::env::args().skip(1)) {
@@ -51,6 +57,7 @@ async fn main() -> Result<()> {
         .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)?;
     start_web_panel(&config, &app_tx)?;
+    config_watch::spawn(config.source_files(), app_tx.clone());
 
 
     let app = App::new(AppDeps {
     let app = App::new(AppDeps {
         config,
         config,
@@ -149,12 +156,22 @@ fn map_terminal_event(raw: Event) -> Option<AppEvent> {
 /// Purpose: run the TUI and guarantee the terminal is restored on every exit path.
 /// Purpose: run the TUI and guarantee the terminal is restored on every exit path.
 /// Inputs: `app`, the application state; `app_rx`, the receiver of application events.
 /// Inputs: `app`, the application state; `app_rx`, the receiver of application events.
 /// Output: `Ok(())` after a clean quit, otherwise the loop error (after restoring the terminal).
 /// Output: `Ok(())` after a clean quit, otherwise the loop error (after restoring the terminal).
-/// Side effects: enters and leaves raw mode and the alternate screen.
+/// Side effects: enters and leaves raw mode and the alternate screen; wipes the UI before leaving.
 async fn run_tui(app: App, app_rx: UnboundedReceiver<AppEvent>) -> Result<()> {
 async fn run_tui(app: App, app_rx: UnboundedReceiver<AppEvent>) -> Result<()> {
     let mut terminal = ratatui::init();
     let mut terminal = ratatui::init();
     let result = event_loop(&mut terminal, app, app_rx).await;
     let result = event_loop(&mut terminal, app, app_rx).await;
+    let cleared = clear_screen().context("cannot clear terminal on exit");
     ratatui::restore();
     ratatui::restore();
-    result
+    result.and(cleared)
+}
+
+/// Purpose: wipe the UI from the screen, so terminals without an alternate screen are left clean.
+/// Inputs: none.
+/// Output: `Ok(())`, or the terminal write error.
+/// Side effects: clears the whole screen and moves the cursor to the top-left corner; uses no
+/// cursor-position query, which some terminals never answer.
+fn clear_screen() -> std::io::Result<()> {
+    execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))
 }
 }
 
 
 /// Purpose: draw the UI and dispatch events until quit or the event channel closes.
 /// Purpose: draw the UI and dispatch events until quit or the event channel closes.
@@ -177,7 +194,11 @@ async fn event_loop(
         let list_mode_before = app.list_mode;
         let list_mode_before = app.list_mode;
         app.handle_event(event)?;
         app.handle_event(event)?;
         if app.list_mode != list_mode_before {
         if app.list_mode != list_mode_before {
-            terminal.clear().context("cannot clear terminal")?;
+
+            // `Terminal::clear` reads the cursor position, which times out on some terminals;
+            // resizing to the current size clears the fullscreen viewport without that query.
+            let size = terminal.size().context("cannot read terminal size")?;
+            terminal.resize(size.into()).context("cannot clear terminal")?;
         }
         }
     }
     }
     Ok(())
     Ok(())

+ 7 - 3
src/ui.rs

@@ -56,7 +56,7 @@ const KEY_HELP_BEFORE_GLOBAL: &str =
 /// Key help of the global autoreply shortcut, highlighted when it is on.
 /// Key help of the global autoreply shortcut, highlighted when it is on.
 const KEY_HELP_GLOBAL: &str = "F4:AI tutte";
 const KEY_HELP_GLOBAL: &str = "F4:AI tutte";
 /// Key help shown after the global autoreply shortcut.
 /// Key help shown after the global autoreply shortcut.
-const KEY_HELP_AFTER_GLOBAL: &str = "  F10:esci";
+const KEY_HELP_AFTER_GLOBAL: &str = "  F8:nuovo QR  F10:esci";
 /// Gap between the status text and the key help.
 /// Gap between the status text and the key help.
 const FOOTER_GAP: &str = "   ";
 const FOOTER_GAP: &str = "   ";
 /// Leading space of the footer line (also trailing space after the build version).
 /// Leading space of the footer line (also trailing space after the build version).
@@ -77,8 +77,10 @@ const TEXT_QR_WAITING: &str = "In attesa del codice QR...";
 const TEXT_QR_TOO_SMALL: &str = "Terminale troppo piccolo per il QR: ingrandisci la finestra";
 const TEXT_QR_TOO_SMALL: &str = "Terminale troppo piccolo per il QR: ingrandisci la finestra";
 /// Prefix of the QR encoding error message.
 /// Prefix of the QR encoding error message.
 const TEXT_QR_ERROR: &str = "Errore generazione QR";
 const TEXT_QR_ERROR: &str = "Errore generazione QR";
-/// Terminal rows used by the pairing screen besides the QR (title, 2 blanks, steps, status).
-const PAIRING_TEXT_ROWS: usize = 5;
+/// Key help of the connecting and pairing screens.
+const TEXT_SCREEN_KEYS: &str = "F8: nuovo QR   F10: esci";
+/// Terminal rows used by the pairing screen besides the QR (title, 2 blanks, steps, status, keys).
+const PAIRING_TEXT_ROWS: usize = 6;
 
 
 /// Sender label of messages sent from this account.
 /// Sender label of messages sent from this account.
 const SENDER_ME: &str = "Tu";
 const SENDER_ME: &str = "Tu";
@@ -122,6 +124,7 @@ fn render_connecting(frame: &mut Frame, area: Rect, app: &App) {
         Line::styled(TEXT_CONNECTING, Style::new().add_modifier(Modifier::BOLD)),
         Line::styled(TEXT_CONNECTING, Style::new().add_modifier(Modifier::BOLD)),
         Line::raw(""),
         Line::raw(""),
         Line::raw(app.status.clone()),
         Line::raw(app.status.clone()),
+        Line::styled(TEXT_SCREEN_KEYS, Style::new().add_modifier(Modifier::DIM)),
     ];
     ];
     render_centered(frame, area, lines);
     render_centered(frame, area, lines);
 }
 }
@@ -142,6 +145,7 @@ fn render_pairing(frame: &mut Frame, area: Rect, app: &App) {
     lines.push(Line::raw(""));
     lines.push(Line::raw(""));
     lines.push(Line::raw(TEXT_PAIRING_STEPS));
     lines.push(Line::raw(TEXT_PAIRING_STEPS));
     lines.push(Line::raw(app.status.clone()));
     lines.push(Line::raw(app.status.clone()));
+    lines.push(Line::styled(TEXT_SCREEN_KEYS, Style::new().add_modifier(Modifier::DIM)));
     render_centered(frame, area, lines);
     render_centered(frame, area, lines);
 }
 }
 
 

+ 59 - 1
src/web.rs

@@ -4,6 +4,8 @@
 use std::io::Read;
 use std::io::Read;
 
 
 use anyhow::{Context, Result, anyhow};
 use anyhow::{Context, Result, anyhow};
+use qrcode::QrCode;
+use qrcode::render::svg;
 use serde::Serialize;
 use serde::Serialize;
 use tiny_http::{Header, Method, Request, Response, Server};
 use tiny_http::{Header, Method, Request, Response, Server};
 use tokio::sync::mpsc::UnboundedSender;
 use tokio::sync::mpsc::UnboundedSender;
@@ -26,6 +28,22 @@ const PATH_GLOBAL: &str = "/api/global";
 const PATH_CHAT: &str = "/api/chat";
 const PATH_CHAT: &str = "/api/chat";
 /// Route path that quits the bot.
 /// Route path that quits the bot.
 const PATH_QUIT: &str = "/api/quit";
 const PATH_QUIT: &str = "/api/quit";
+/// Route path that deletes the WhatsApp session and pairs again.
+const PATH_RELINK: &str = "/api/relink";
+
+/// Screen name sent while connecting to WhatsApp.
+pub const SCREEN_CONNECTING: &str = "connecting";
+/// Screen name sent while a pairing QR code is shown.
+pub const SCREEN_PAIRING: &str = "pairing";
+/// Screen name sent while connected.
+pub const SCREEN_MAIN: &str = "main";
+
+/// Size in pixels of one QR module in the SVG sent to the web panel.
+const QR_MODULE_PIXELS: u32 = 6;
+/// Color of the dark QR modules.
+const QR_DARK_COLOR: &str = "#000000";
+/// Color of the light QR modules and of the quiet zone.
+const QR_LIGHT_COLOR: &str = "#ffffff";
 
 
 /// Maximum accepted size, in bytes, of a request body.
 /// Maximum accepted size, in bytes, of a request body.
 const MAX_BODY_BYTES: usize = 1024;
 const MAX_BODY_BYTES: usize = 1024;
@@ -85,6 +103,8 @@ pub enum WebCommand {
     ToggleGlobal,
     ToggleGlobal,
     /// Toggle the autoreply of the chat with this JID.
     /// Toggle the autoreply of the chat with this JID.
     ToggleChat(String),
     ToggleChat(String),
+    /// Delete the WhatsApp session and pair again with a new QR code.
+    Relink,
     /// Quit the bot.
     /// Quit the bot.
     Quit,
     Quit,
 }
 }
@@ -109,6 +129,10 @@ pub struct WebState {
     pub chats: Vec<WebChat>,
     pub chats: Vec<WebChat>,
     /// Human readable status line.
     /// Human readable status line.
     pub status: String,
     pub status: String,
+    /// Current screen: `SCREEN_CONNECTING`, `SCREEN_PAIRING` or `SCREEN_MAIN`.
+    pub screen: &'static str,
+    /// Pairing QR code as an SVG document, while one is shown.
+    pub qr_svg: Option<String>,
 }
 }
 
 
 /// Request forwarded to the application through `AppEvent::Web`.
 /// Request forwarded to the application through `AppEvent::Web`.
@@ -131,6 +155,8 @@ enum Route {
     ToggleGlobal,
     ToggleGlobal,
     /// `POST /api/chat`.
     /// `POST /api/chat`.
     ToggleChat,
     ToggleChat,
+    /// `POST /api/relink`.
+    Relink,
     /// `POST /api/quit`.
     /// `POST /api/quit`.
     Quit,
     Quit,
     /// Any other method/path combination.
     /// Any other method/path combination.
@@ -143,7 +169,7 @@ impl Route {
     /// Output: `true` for the POST routes, which must carry the mandatory custom header.
     /// Output: `true` for the POST routes, which must carry the mandatory custom header.
     /// Side effects: none.
     /// Side effects: none.
     fn requires_request_header(self) -> bool {
     fn requires_request_header(self) -> bool {
-        matches!(self, Route::ToggleGlobal | Route::ToggleChat | Route::Quit)
+        matches!(self, Route::ToggleGlobal | Route::ToggleChat | Route::Relink | Route::Quit)
     }
     }
 }
 }
 
 
@@ -222,6 +248,7 @@ fn build_reply(request: &mut Request, route: Route, events: &UnboundedSender<App
             Some(jid) => forward(events, WebCommand::ToggleChat(jid)),
             Some(jid) => forward(events, WebCommand::ToggleChat(jid)),
             None => text_reply(STATUS_BAD_REQUEST, BAD_BODY_MESSAGE),
             None => text_reply(STATUS_BAD_REQUEST, BAD_BODY_MESSAGE),
         },
         },
+        Route::Relink => forward(events, WebCommand::Relink),
         Route::Quit | Route::NotFound => text_reply(STATUS_NOT_FOUND, NOT_FOUND_MESSAGE),
         Route::Quit | Route::NotFound => text_reply(STATUS_NOT_FOUND, NOT_FOUND_MESSAGE),
     }
     }
 }
 }
@@ -328,11 +355,29 @@ fn resolve_route(method: &Method, path: &str) -> Route {
         (Method::Get, PATH_STATE) => Route::State,
         (Method::Get, PATH_STATE) => Route::State,
         (Method::Post, PATH_GLOBAL) => Route::ToggleGlobal,
         (Method::Post, PATH_GLOBAL) => Route::ToggleGlobal,
         (Method::Post, PATH_CHAT) => Route::ToggleChat,
         (Method::Post, PATH_CHAT) => Route::ToggleChat,
+        (Method::Post, PATH_RELINK) => Route::Relink,
         (Method::Post, PATH_QUIT) => Route::Quit,
         (Method::Post, PATH_QUIT) => Route::Quit,
         _ => Route::NotFound,
         _ => Route::NotFound,
     }
     }
 }
 }
 
 
+/// Purpose: draw a pairing QR code for the web panel.
+/// Inputs: `payload`, the QR payload received from WhatsApp.
+/// Output: an SVG document with dark modules on a light quiet zone, or `None` when the payload
+/// cannot be encoded (the TUI pairing screen shows that encoding error).
+/// Side effects: none.
+pub fn qr_svg(payload: &str) -> Option<String> {
+    let code = QrCode::new(payload.as_bytes()).ok()?;
+    Some(
+        code.render::<svg::Color<'_>>()
+            .module_dimensions(QR_MODULE_PIXELS, QR_MODULE_PIXELS)
+            .dark_color(svg::Color(QR_DARK_COLOR))
+            .light_color(svg::Color(QR_LIGHT_COLOR))
+            .quiet_zone(true)
+            .build(),
+    )
+}
+
 /// Purpose: check that a request carries the custom header required on POST routes.
 /// Purpose: check that a request carries the custom header required on POST routes.
 /// Inputs: `headers`, the request headers.
 /// Inputs: `headers`, the request headers.
 /// Output: `true` if a header named `REQUEST_HEADER_NAME` (name matched case-insensitively) has
 /// Output: `true` if a header named `REQUEST_HEADER_NAME` (name matched case-insensitively) has
@@ -380,9 +425,22 @@ mod tests {
         assert_eq!(resolve_route(&Method::Get, PATH_STATE), Route::State);
         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_GLOBAL), Route::ToggleGlobal);
         assert_eq!(resolve_route(&Method::Post, PATH_CHAT), Route::ToggleChat);
         assert_eq!(resolve_route(&Method::Post, PATH_CHAT), Route::ToggleChat);
+        assert_eq!(resolve_route(&Method::Post, PATH_RELINK), Route::Relink);
         assert_eq!(resolve_route(&Method::Post, PATH_QUIT), Route::Quit);
         assert_eq!(resolve_route(&Method::Post, PATH_QUIT), Route::Quit);
     }
     }
 
 
+    #[test]
+    fn relink_requires_the_request_header() {
+        assert!(Route::Relink.requires_request_header());
+        assert_eq!(resolve_route(&Method::Get, PATH_RELINK), Route::NotFound);
+    }
+
+    #[test]
+    fn qr_svg_draws_an_svg_document() {
+        let svg = qr_svg("2@ref,noise,identity,adv,1").expect("payload must encode");
+        assert!(svg.contains("<svg") && svg.contains(QR_DARK_COLOR));
+    }
+
     #[test]
     #[test]
     fn resolve_route_rejects_wrong_method_or_path() {
     fn resolve_route_rejects_wrong_method_or_path() {
         assert_eq!(resolve_route(&Method::Post, PATH_STATE), Route::NotFound);
         assert_eq!(resolve_route(&Method::Post, PATH_STATE), Route::NotFound);

+ 133 - 5
src/web_page.html

@@ -94,8 +94,30 @@ body {
 
 
 .topbar__global { margin-left: 4px; }
 .topbar__global { margin-left: 4px; }
 
 
-.btn-quit {
+.topbar__actions {
+  display: flex;
+  gap: 8px;
   margin-left: auto;
   margin-left: auto;
+}
+
+.btn-relink {
+  min-height: var(--tap-size);
+  padding: 0 16px;
+  background: transparent;
+  color: var(--accent);
+  border: 1.5px solid var(--accent);
+  border-radius: var(--radius-control);
+  font: inherit;
+  font-weight: 600;
+  cursor: pointer;
+  transition: background var(--transition);
+}
+
+.btn-relink:hover { background: var(--surface-hover); }
+.btn-relink:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
+.btn-relink:disabled { opacity: 0.5; cursor: progress; }
+
+.btn-quit {
   min-height: var(--tap-size);
   min-height: var(--tap-size);
   padding: 0 16px;
   padding: 0 16px;
   background: transparent;
   background: transparent;
@@ -172,6 +194,27 @@ body {
   color: var(--muted);
   color: var(--muted);
 }
 }
 
 
+/* Pairing card: shown while WhatsApp is not connected */
+.pairing {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 20px;
+  padding: 24px 16px;
+  text-align: center;
+}
+
+.pairing__title { margin: 0; font-size: 1.125rem; font-weight: 700; }
+.pairing__steps { margin: 0; color: var(--muted); font-size: 0.875rem; }
+
+.pairing__qr {
+  width: min(100%, 280px);
+  height: auto;
+  border-radius: var(--radius-control);
+  image-rendering: pixelated;
+}
+
 /* Slider switch: hidden checkbox + styled track and knob */
 /* Slider switch: hidden checkbox + styled track and knob */
 .switch {
 .switch {
   --track-width: 52px;
   --track-width: 52px;
@@ -290,7 +333,7 @@ body {
   .topbar__title { font-size: 1.25rem; }
   .topbar__title { font-size: 1.25rem; }
   .topbar__global { margin-left: 0; }
   .topbar__global { margin-left: 0; }
   .switch--large { gap: 8px; }
   .switch--large { gap: 8px; }
-  .btn-quit { padding: 0 12px; }
+  .btn-quit, .btn-relink { padding: 0 12px; }
 }
 }
 
 
 @media (prefers-reduced-motion: reduce) {
 @media (prefers-reduced-motion: reduce) {
@@ -306,10 +349,19 @@ body {
     <input class="switch__input" type="checkbox" id="global-switch">
     <input class="switch__input" type="checkbox" id="global-switch">
     <span class="switch__track"><span class="switch__knob"></span></span>
     <span class="switch__track"><span class="switch__knob"></span></span>
   </label>
   </label>
-  <button class="btn-quit" type="button" id="quit-button">Chiudi bot</button>
+  <div class="topbar__actions">
+    <button class="btn-relink" type="button" id="relink-button">Nuovo QR</button>
+    <button class="btn-quit" type="button" id="quit-button">Chiudi bot</button>
+  </div>
 </header>
 </header>
 
 
 <main class="page">
 <main class="page">
+  <section class="card pairing" id="pairing" aria-label="Collega WhatsApp" hidden>
+    <p class="pairing__title" id="pairing-title"></p>
+    <img class="pairing__qr" id="pairing-qr" alt="Codice QR di abbinamento" hidden>
+    <p class="pairing__steps" id="pairing-steps" hidden>WhatsApp &gt; Impostazioni &gt; Dispositivi collegati &gt; Collega un dispositivo</p>
+  </section>
+
   <section class="card" aria-label="Chat">
   <section class="card" aria-label="Chat">
     <div class="card__head"><span>Nome</span><span>AI</span></div>
     <div class="card__head"><span>Nome</span><span>AI</span></div>
     <ul class="chat-list" id="chat-list"></ul>
     <ul class="chat-list" id="chat-list"></ul>
@@ -328,10 +380,19 @@ body {
 "use strict";
 "use strict";
 
 
 const POLL_INTERVAL_MS = 5000;
 const POLL_INTERVAL_MS = 5000;
+const POLL_PAIRING_INTERVAL_MS = 2000;
 const API_STATE = "/api/state";
 const API_STATE = "/api/state";
 const API_GLOBAL = "/api/global";
 const API_GLOBAL = "/api/global";
 const API_CHAT = "/api/chat";
 const API_CHAT = "/api/chat";
+const API_RELINK = "/api/relink";
 const API_QUIT = "/api/quit";
 const API_QUIT = "/api/quit";
+const SCREEN_MAIN = "main";
+const SCREEN_PAIRING = "pairing";
+const SVG_DATA_URL_PREFIX = "data:image/svg+xml;charset=utf-8,";
+const PAIRING_TITLE_TEXT = "Collega WhatsApp: scansiona il codice QR";
+const CONNECTING_TITLE_TEXT = "Connessione a WhatsApp in corso...";
+const QR_WAITING_TITLE_TEXT = "In attesa del codice QR...";
+const RELINK_CONFIRM_TEXT = "Cancellare la sessione WhatsApp e rifare l'abbinamento? Il dispositivo verrà scollegato dal telefono.";
 const METHOD_GET = "GET";
 const METHOD_GET = "GET";
 const METHOD_POST = "POST";
 const METHOD_POST = "POST";
 const CONTENT_TYPE_TEXT = "text/plain";
 const CONTENT_TYPE_TEXT = "text/plain";
@@ -345,6 +406,11 @@ const CLASS_STATUS_ERROR = "status--error";
 
 
 const globalSwitch = document.getElementById("global-switch");
 const globalSwitch = document.getElementById("global-switch");
 const quitButton = document.getElementById("quit-button");
 const quitButton = document.getElementById("quit-button");
+const relinkButton = document.getElementById("relink-button");
+const pairingCard = document.getElementById("pairing");
+const pairingTitle = document.getElementById("pairing-title");
+const pairingQr = document.getElementById("pairing-qr");
+const pairingSteps = document.getElementById("pairing-steps");
 const chatList = document.getElementById("chat-list");
 const chatList = document.getElementById("chat-list");
 const emptyState = document.getElementById("empty-state");
 const emptyState = document.getElementById("empty-state");
 const statusBar = document.getElementById("status");
 const statusBar = document.getElementById("status");
@@ -355,6 +421,8 @@ const rowsByJid = new Map();
 const pendingKeys = new Set();
 const pendingKeys = new Set();
 let lastState = null;
 let lastState = null;
 let pollTimer = null;
 let pollTimer = null;
+let pollIntervalMs = POLL_INTERVAL_MS;
+let shownQrSvg = null;
 let ticketCounter = 0;
 let ticketCounter = 0;
 let lastAppliedTicket = 0;
 let lastAppliedTicket = 0;
 let quitRequested = false;
 let quitRequested = false;
@@ -521,13 +589,54 @@ function renderChats(chats) {
 }
 }
 
 
 /**
 /**
- * Renders the global switch and the chat list from a state object.
+ * Title of the pairing card for a state that is not connected.
+ * Input: the state. Output: the text to show above the QR code.
+ */
+function pairingTitleText(state) {
+  if (state.qr_svg) return PAIRING_TITLE_TEXT;
+  return state.screen === SCREEN_PAIRING ? QR_WAITING_TITLE_TEXT : CONNECTING_TITLE_TEXT;
+}
+
+/**
+ * Shows the pairing card (QR code or connection progress) while WhatsApp is not connected.
+ * Side effect: updates the pairing elements; the image is replaced only when the QR changes.
+ */
+function renderPairing(state) {
+  const isConnected = state.screen === SCREEN_MAIN;
+  pairingCard.hidden = isConnected;
+  if (isConnected) return;
+
+  pairingTitle.textContent = pairingTitleText(state);
+  const qrSvg = state.qr_svg || null;
+  if (qrSvg !== shownQrSvg) {
+    shownQrSvg = qrSvg;
+    pairingQr.src = qrSvg ? SVG_DATA_URL_PREFIX + encodeURIComponent(qrSvg) : "";
+  }
+  pairingQr.hidden = !qrSvg;
+  pairingSteps.hidden = !qrSvg;
+}
+
+/**
+ * Polls faster while pairing, so a rotated QR code shows up quickly.
+ * Side effect: restarts the poll timer when the interval changes.
+ */
+function adjustPolling(state) {
+  const wanted = state.screen === SCREEN_MAIN ? POLL_INTERVAL_MS : POLL_PAIRING_INTERVAL_MS;
+  if (wanted === pollIntervalMs) return;
+  pollIntervalMs = wanted;
+  clearInterval(pollTimer);
+  pollTimer = setInterval(onPollTick, pollIntervalMs);
+}
+
+/**
+ * Renders the global switch, the pairing card and the chat list from a state object.
  * A pending global switch keeps its position until the server reply arrives.
  * A pending global switch keeps its position until the server reply arrives.
  */
  */
 function renderState(state) {
 function renderState(state) {
   const isGlobalPending = pendingKeys.has(GLOBAL_KEY);
   const isGlobalPending = pendingKeys.has(GLOBAL_KEY);
   if (!isGlobalPending) globalSwitch.checked = Boolean(state.global_autoreply);
   if (!isGlobalPending) globalSwitch.checked = Boolean(state.global_autoreply);
   globalSwitch.disabled = isGlobalPending;
   globalSwitch.disabled = isGlobalPending;
+  renderPairing(state);
   renderChats(Array.isArray(state.chats) ? state.chats : []);
   renderChats(Array.isArray(state.chats) ? state.chats : []);
 }
 }
 
 
@@ -540,6 +649,7 @@ function applyState(state, ticket) {
   lastAppliedTicket = ticket;
   lastAppliedTicket = ticket;
   lastState = state;
   lastState = state;
   renderState(state);
   renderState(state);
+  adjustPolling(state);
   showStatus(state.status, false);
   showStatus(state.status, false);
   return true;
   return true;
 }
 }
@@ -590,6 +700,23 @@ function toggleChat(jid) {
   return runToggle(jid, () => requestJson(API_CHAT, METHOD_POST, jid));
   return runToggle(jid, () => requestJson(API_CHAT, METHOD_POST, jid));
 }
 }
 
 
+/**
+ * Asks for confirmation, then asks the bot to delete the WhatsApp session and pair again.
+ * Side effect: the returned state shows the pairing card; the QR code follows with the polls.
+ */
+async function relinkBot() {
+  if (!window.confirm(RELINK_CONFIRM_TEXT)) return;
+  relinkButton.disabled = true;
+
+  try {
+    applyState(await requestJson(API_RELINK, METHOD_POST), ++ticketCounter);
+  } catch (error) {
+    handleFailure(error);
+  } finally {
+    relinkButton.disabled = false;
+  }
+}
+
 /**
 /**
  * Asks for confirmation, then asks the bot to quit.
  * Asks for confirmation, then asks the bot to quit.
  * Side effect: shows the closed overlay on success or on a following network error.
  * Side effect: shows the closed overlay on success or on a following network error.
@@ -629,9 +756,10 @@ function onPollTick() {
 function init() {
 function init() {
   globalSwitch.addEventListener("change", toggleGlobal);
   globalSwitch.addEventListener("change", toggleGlobal);
   quitButton.addEventListener("click", quitBot);
   quitButton.addEventListener("click", quitBot);
+  relinkButton.addEventListener("click", relinkBot);
   document.addEventListener("visibilitychange", onVisibilityChange);
   document.addEventListener("visibilitychange", onVisibilityChange);
   refresh();
   refresh();
-  pollTimer = setInterval(onPollTick, POLL_INTERVAL_MS);
+  pollTimer = setInterval(onPollTick, pollIntervalMs);
 }
 }
 
 
 init();
 init();

+ 227 - 11
src/whatsapp.rs

@@ -19,11 +19,15 @@
 //!   [`WaEvent::Error`].
 //!   [`WaEvent::Error`].
 
 
 use std::collections::HashMap;
 use std::collections::HashMap;
-use std::path::Path;
+use std::io::ErrorKind;
+use std::path::{Path, PathBuf};
 use std::sync::{Arc, Mutex, MutexGuard};
 use std::sync::{Arc, Mutex, MutexGuard};
+use std::time::Duration;
 
 
 use anyhow::{Context, Result, anyhow, bail};
 use anyhow::{Context, Result, anyhow, bail};
 use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
 use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
+use tokio::sync::watch;
+use tokio::task::AbortHandle;
 use whatsapp_rust::prelude::*;
 use whatsapp_rust::prelude::*;
 use whatsapp_rust::types::events::{
 use whatsapp_rust::types::events::{
     ConnectFailure, ContactUpdate, LazyHistorySync, MessageBatch, PushNameUpdate,
     ConnectFailure, ContactUpdate, LazyHistorySync, MessageBatch, PushNameUpdate,
@@ -78,6 +82,25 @@ const CLIENT_STOPPED_REASON: &str = "client stopped";
 /// Reason reported when another session replaced this one.
 /// Reason reported when another session replaced this one.
 const STREAM_REPLACED_REASON: &str = "stream replaced by another session";
 const STREAM_REPLACED_REASON: &str = "stream replaced by another session";
 
 
+/// Error returned by client operations while no session is running (during a relink).
+const NO_SESSION_MESSAGE: &str = "WhatsApp session not running";
+
+/// Seconds a relink waits for the old client run loop to stop after the logout.
+const STOP_TIMEOUT_SECONDS: u64 = 10;
+
+/// Suffixes of the SQLite side files of the session database.
+const SESSION_SIDE_FILE_SUFFIXES: [&str; 2] = ["-wal", "-shm"];
+
+/// Seconds the session reset waits for SQLite locks held by the stopped client.
+const SESSION_BUSY_TIMEOUT_SECONDS: u64 = 5;
+
+/// Table where the storage library records its applied migrations (kept by the session reset).
+const MIGRATIONS_TABLE: &str = "__diesel_schema_migrations";
+
+/// Query listing the session tables to empty, except SQLite internals and the migrations table.
+const SESSION_TABLES_QUERY: &str =
+    "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> ?1";
+
 /// Event emitted by the WhatsApp client wrapper.
 /// Event emitted by the WhatsApp client wrapper.
 #[derive(Debug, Clone)]
 #[derive(Debug, Clone)]
 pub enum WaEvent {
 pub enum WaEvent {
@@ -134,14 +157,85 @@ pub struct DownloadedImage {
     pub mime: String,
     pub mime: String,
 }
 }
 
 
-/// Handle used to send messages through the running client.
+/// Handle to the running WhatsApp session. Clones share the same session, which
+/// [`WaHandle::relink`] can replace with a new one.
 #[derive(Clone)]
 #[derive(Clone)]
 pub struct WaHandle {
 pub struct WaHandle {
+    /// Running session; `None` while a relink replaces it (or after a failed relink).
+    session: Arc<Mutex<Option<Session>>>,
+    /// SQLite session file, deleted and recreated by a relink.
+    session_db: PathBuf,
+    /// Destination of the events of every session.
+    events: UnboundedSender<WaEvent>,
+}
+
+/// One running client and the tasks serving it.
+struct Session {
     /// Shared client driving the connection.
     /// Shared client driving the connection.
     client: Arc<Client>,
     client: Arc<Client>,
+    /// Translator task; it holds the client, so it is aborted once the session stops.
+    translator: AbortHandle,
+    /// Becomes `true` once the client run loop has stopped.
+    stopped: watch::Receiver<bool>,
+}
+
+impl Session {
+    /// Logs out and releases the client, so its session database can be deleted.
+    ///
+    /// Inputs: the session (consumed). Output: none.
+    /// Side effects: deregisters this device on the phone when connected (best effort),
+    /// disconnects, waits up to `STOP_TIMEOUT_SECONDS` for the run loop to stop and aborts the
+    /// translator task. After a timeout the database may stay open for a while: the deletion
+    /// retries and reports it.
+    async fn stop(mut self) {
+        self.client.logout().await;
+        let stopped = self.stopped.wait_for(|stopped| *stopped);
+
+        // Elapsed or closed both mean there is nothing more to wait for.
+        let _ = tokio::time::timeout(Duration::from_secs(STOP_TIMEOUT_SECONDS), stopped).await;
+        self.translator.abort();
+    }
 }
 }
 
 
 impl WaHandle {
 impl WaHandle {
+    /// Returns the client of the running session.
+    ///
+    /// Inputs: none. Output: the client, or an error while no session is running.
+    /// Side effects: none.
+    fn client(&self) -> Result<Arc<Client>> {
+        self.lock_session()
+            .as_ref()
+            .map(|session| Arc::clone(&session.client))
+            .ok_or_else(|| anyhow!(NO_SESSION_MESSAGE))
+    }
+
+    /// Locks the session slot, recovering from a poisoned lock.
+    ///
+    /// Inputs: none. Output: the guard. Side effects: none.
+    fn lock_session(&self) -> MutexGuard<'_, Option<Session>> {
+        self.session
+            .lock()
+            .unwrap_or_else(|poisoned| poisoned.into_inner())
+    }
+
+    /// Replaces the session with a new, unpaired one, so a new QR code is offered.
+    ///
+    /// Inputs: none. Output: `Ok(())` once the new client is started, or the deletion or start
+    /// error (the handle then has no session until the next successful relink).
+    /// Side effects: logs out and stops the current client (see [`Session::stop`]), empties the
+    /// session database (see [`reset_session_files`]) and starts a new client on it, whose
+    /// pairing QR codes arrive as [`WaEvent::Qr`].
+    pub async fn relink(&self) -> Result<()> {
+        let current = self.lock_session().take();
+        if let Some(current) = current {
+            current.stop().await;
+        }
+        reset_session_files(&self.session_db)?;
+        let session = open_session(&self.session_db, self.events.clone()).await?;
+        *self.lock_session() = Some(session);
+        Ok(())
+    }
+
     /// Sends a plain text message.
     /// Sends a plain text message.
     ///
     ///
     /// Inputs: `chat_jid` is a JID in `user@server` form, `text` the body (non-empty).
     /// Inputs: `chat_jid` is a JID in `user@server` form, `text` the body (non-empty).
@@ -155,7 +249,7 @@ impl WaHandle {
             .parse()
             .parse()
             .map_err(|e| anyhow!("invalid JID '{chat_jid}': {e}"))?;
             .map_err(|e| anyhow!("invalid JID '{chat_jid}': {e}"))?;
         let sent = self
         let sent = self
-            .client
+            .client()?
             .send_message(jid, wa::Message::text(text))
             .send_message(jid, wa::Message::text(text))
             .await
             .await
             .with_context(|| format!("failed to send message to {chat_jid}"))?;
             .with_context(|| format!("failed to send message to {chat_jid}"))?;
@@ -169,7 +263,7 @@ impl WaHandle {
     /// Side effects: HTTPS request to the WhatsApp CDN; the bytes stay in memory.
     /// Side effects: HTTPS request to the WhatsApp CDN; the bytes stay in memory.
     pub async fn download_image(&self, image: &ImageRef) -> Result<DownloadedImage> {
     pub async fn download_image(&self, image: &ImageRef) -> Result<DownloadedImage> {
         let bytes = self
         let bytes = self
-            .client
+            .client()?
             .download(image.message.as_ref())
             .download(image.message.as_ref())
             .await
             .await
             .context("failed to download the image from WhatsApp")?;
             .context("failed to download the image from WhatsApp")?;
@@ -186,10 +280,14 @@ impl WaHandle {
     ///
     ///
     /// Inputs: none (reads the client's device identity).
     /// Inputs: none (reads the client's device identity).
     /// Output: the phone-number JID and the LID JID, whichever are known, in
     /// Output: the phone-number JID and the LID JID, whichever are known, in
-    /// `user@server` form without device suffix; empty before pairing completes.
+    /// `user@server` form without device suffix; empty before pairing completes and while no
+    /// session is running.
     /// Side effects: none.
     /// Side effects: none.
     pub fn own_jids(&self) -> Vec<String> {
     pub fn own_jids(&self) -> Vec<String> {
-        [self.client.pn(), self.client.lid()]
+        let Ok(client) = self.client() else {
+            return Vec::new();
+        };
+        [client.pn(), client.lid()]
             .into_iter()
             .into_iter()
             .flatten()
             .flatten()
             .map(|jid| jid_to_string(&jid))
             .map(|jid| jid_to_string(&jid))
@@ -200,12 +298,83 @@ impl WaHandle {
 /// Starts the WhatsApp client in the background.
 /// Starts the WhatsApp client in the background.
 ///
 ///
 /// Inputs: `session_db` is the SQLite session file (created when missing),
 /// Inputs: `session_db` is the SQLite session file (created when missing),
-/// `events` receives every [`WaEvent`].
+/// `events` receives every [`WaEvent`], of this session and of the ones started by
+/// [`WaHandle::relink`].
 /// Output: a [`WaHandle`] for sending messages.
 /// Output: a [`WaHandle`] for sending messages.
+/// Side effects: see [`open_session`].
+pub async fn start(session_db: &Path, events: UnboundedSender<WaEvent>) -> Result<WaHandle> {
+    let session = open_session(session_db, events.clone()).await?;
+    Ok(WaHandle {
+        session: Arc::new(Mutex::new(Some(session))),
+        session_db: session_db.to_path_buf(),
+        events,
+    })
+}
+
+/// Leaves an empty session database, so the next client starts unpaired.
+///
+/// Inputs: `session_db`, the database path. Output: `Ok(())`, or the error naming the path.
+/// Side effects: deletes the database and its SQLite side files. When the database is still
+/// open (Windows refuses to delete it: the library never releases a stopped client before the
+/// process exits) its tables are emptied in place instead; the stopped client writes nothing more,
+/// because its saver flushes once on shutdown and then exits.
+fn reset_session_files(session_db: &Path) -> Result<()> {
+    match std::fs::remove_file(session_db) {
+        Ok(()) => remove_side_files(session_db),
+        Err(e) if e.kind() == ErrorKind::NotFound => remove_side_files(session_db),
+        Err(e) => clear_session_tables(session_db).with_context(|| {
+            format!("cannot delete ({e}) nor clear session database {}", session_db.display())
+        }),
+    }
+}
+
+/// Deletes the SQLite side files (`-wal`, `-shm`) of a deleted session database.
+///
+/// Inputs: `session_db`, the database path. Output: `Ok(())`, or the error naming the file.
+/// Side effects: removes files; missing ones are fine.
+fn remove_side_files(session_db: &Path) -> Result<()> {
+    for suffix in SESSION_SIDE_FILE_SUFFIXES {
+        let mut file = session_db.as_os_str().to_owned();
+        file.push(suffix);
+        match std::fs::remove_file(&file) {
+            Err(e) if e.kind() != ErrorKind::NotFound => {
+                return Err(e).with_context(|| format!("cannot delete session file {}", Path::new(&file).display()));
+            }
+            _ => {}
+        }
+    }
+    Ok(())
+}
+
+/// Empties every table of the session database, keeping the schema and its migration record.
+///
+/// Inputs: `session_db`, the database path. Output: `Ok(())`, or the SQLite error.
+/// Side effects: deletes all rows in one transaction; waits up to `SESSION_BUSY_TIMEOUT_SECONDS`
+/// for locks held by other connections.
+fn clear_session_tables(session_db: &Path) -> Result<()> {
+    let mut connection = rusqlite::Connection::open(session_db)?;
+    connection.busy_timeout(Duration::from_secs(SESSION_BUSY_TIMEOUT_SECONDS))?;
+    let transaction = connection.transaction()?;
+    let tables = transaction
+        .prepare(SESSION_TABLES_QUERY)?
+        .query_map([MIGRATIONS_TABLE], |row| row.get::<_, String>(0))?
+        .collect::<rusqlite::Result<Vec<_>>>()?;
+    for table in tables {
+        transaction.execute(&format!("DELETE FROM \"{table}\""), [])?;
+    }
+    transaction.commit()?;
+    Ok(())
+}
+
+/// Opens the session database and starts a client on it.
+///
+/// Inputs: `session_db` is the SQLite session file (created when missing),
+/// `events` receives every [`WaEvent`] of this session.
+/// Output: the running [`Session`].
 /// Side effects: creates the parent directory and the session database,
 /// Side effects: creates the parent directory and the session database,
 /// spawns tokio tasks (client run loop and event translator) and opens the
 /// spawns tokio tasks (client run loop and event translator) and opens the
 /// network connection. Pairing and connection results arrive as events.
 /// network connection. Pairing and connection results arrive as events.
-pub async fn start(session_db: &Path, events: UnboundedSender<WaEvent>) -> Result<WaHandle> {
+async fn open_session(session_db: &Path, events: UnboundedSender<WaEvent>) -> Result<Session> {
     let db_url = session_db
     let db_url = session_db
         .to_str()
         .to_str()
         .ok_or_else(|| anyhow!("session database path is not valid UTF-8"))?;
         .ok_or_else(|| anyhow!("session database path is not valid UTF-8"))?;
@@ -228,16 +397,22 @@ pub async fn start(session_db: &Path, events: UnboundedSender<WaEvent>) -> Resul
 
 
     let client = bot.client();
     let client = bot.client();
     let translator = Arc::new(Translator::new(client.clone(), events));
     let translator = Arc::new(Translator::new(client.clone(), events));
-    tokio::spawn(Arc::clone(&translator).run(raw_rx));
+    let translator_task = tokio::spawn(Arc::clone(&translator).run(raw_rx)).abort_handle();
 
 
+    let (stopped_tx, stopped) = watch::channel(false);
     let bot_handle = bot.spawn();
     let bot_handle = bot.spawn();
     tokio::spawn(async move {
     tokio::spawn(async move {
         // Keeping the handle alive keeps the bot running; resolves when it stops.
         // Keeping the handle alive keeps the bot running; resolves when it stops.
         bot_handle.await;
         bot_handle.await;
         translator.emit(WaEvent::Disconnected(CLIENT_STOPPED_REASON.to_owned()));
         translator.emit(WaEvent::Disconnected(CLIENT_STOPPED_REASON.to_owned()));
+        stopped_tx.send_replace(true);
     });
     });
 
 
-    Ok(WaHandle { client })
+    Ok(Session {
+        client,
+        translator: translator_task,
+        stopped,
+    })
 }
 }
 
 
 /// Event-bus handler that forwards raw library events, in order, to the translator task.
 /// Event-bus handler that forwards raw library events, in order, to the translator task.
@@ -414,7 +589,7 @@ impl Translator {
         match event {
         match event {
             Event::PairingQrCode(qr) => self.emit(WaEvent::Qr(qr.code.clone())),
             Event::PairingQrCode(qr) => self.emit(WaEvent::Qr(qr.code.clone())),
             Event::PairingQrCodesExhausted(_) => {
             Event::PairingQrCodesExhausted(_) => {
-                self.report("pairing QR codes exhausted; restart to pair again".to_owned());
+                self.report("pairing QR codes exhausted; request a new QR to pair again".to_owned());
             }
             }
             Event::PairError(e) => self.report(format!("pairing failed: {}", e.error)),
             Event::PairError(e) => self.report(format!("pairing failed: {}", e.error)),
             Event::Connected(_) => self.emit(WaEvent::Connected),
             Event::Connected(_) => self.emit(WaEvent::Connected),
@@ -966,6 +1141,47 @@ fn image_caption_part(trimmed: &str) -> Option<&str> {
 mod tests {
 mod tests {
     use super::*;
     use super::*;
 
 
+    /// Seconds a live test waits for a pairing QR code.
+    const LIVE_QR_TIMEOUT_SECONDS: u64 = 60;
+
+    /// Waits for the next pairing QR code, skipping every other event.
+    async fn next_qr(events: &mut UnboundedReceiver<WaEvent>) -> String {
+        let wait = async {
+            loop {
+                match events.recv().await {
+                    Some(WaEvent::Qr(code)) => return code,
+                    Some(_) => {}
+                    None => panic!("event channel closed before a QR code"),
+                }
+            }
+        };
+        tokio::time::timeout(Duration::from_secs(LIVE_QR_TIMEOUT_SECONDS), wait)
+            .await
+            .expect("no QR code in time")
+    }
+
+    /// Live check against the WhatsApp servers (needs network): run with `--ignored`.
+    #[tokio::test(flavor = "multi_thread")]
+    #[ignore = "connects to the WhatsApp servers"]
+    async fn relink_deletes_the_session_and_offers_a_new_qr() {
+        let session_db = PathBuf::from("target").join(format!("wh-relink-test-{}.db", std::process::id()));
+        let (events_tx, mut events) = unbounded_channel();
+        let handle = start(&session_db, events_tx).await.expect("client must start");
+        let first = next_qr(&mut events).await;
+
+        handle.relink().await.expect("relink must succeed");
+        let second = next_qr(&mut events).await;
+        let session = handle.lock_session().take().expect("relink must leave a session");
+        session.stop().await;
+
+        // The file stays open until the process exits on Windows: only empty it.
+        reset_session_files(&session_db).expect("test session must be resettable");
+
+        // QR payload: `ref,noise key,identity key,adv secret,client type`; a new device has new keys.
+        let device_keys = |qr: &str| qr.split(',').skip(1).take(3).collect::<Vec<_>>().join(",");
+        assert_ne!(device_keys(&first), device_keys(&second));
+    }
+
     /// Builds a message with the given plain text.
     /// Builds a message with the given plain text.
     fn text_message(text: &str) -> wa::Message {
     fn text_message(text: &str) -> wa::Message {
         wa::Message {
         wa::Message {