diff --git a/docs/content/docs/(deployment)/metrics.mdx b/docs/content/docs/(deployment)/metrics.mdx index 797dc8151..d24e96f06 100644 --- a/docs/content/docs/(deployment)/metrics.mdx +++ b/docs/content/docs/(deployment)/metrics.mdx @@ -126,10 +126,10 @@ docker run -d \ -v spacebot-data:/data \ -p 19898:19898 \ -p 9090:9090 \ - ghcr.io/spacedriveapp/spacebot:slim + ghcr.io/spacedriveapp/spacebot:latest ``` -The Docker image must be built with `--features metrics` for this to work. The default images do not include metrics support. +The published Docker image already includes metrics support. ## Histogram Buckets diff --git a/docs/content/docs/(getting-started)/docker.mdx b/docs/content/docs/(getting-started)/docker.mdx index 86a085fb5..f710eb5b2 100644 --- a/docs/content/docs/(getting-started)/docker.mdx +++ b/docs/content/docs/(getting-started)/docker.mdx @@ -1,11 +1,11 @@ --- title: Docker -description: Run Spacebot in a container with slim or full image variants. +description: Run Spacebot in a container with the unified Spacebot image. --- # Docker -Run Spacebot in a container. Two image variants: `slim` (no browser) and `full` (includes Chromium for browser workers). +Run Spacebot in a container with the unified Spacebot image. ## Quick Start @@ -15,28 +15,19 @@ docker run -d \ -e ANTHROPIC_API_KEY="sk-ant-..." \ -v spacebot-data:/data \ -p 19898:19898 \ - ghcr.io/spacedriveapp/spacebot:slim + ghcr.io/spacedriveapp/spacebot:latest ``` The web UI is available at `http://localhost:19898`. -## Image Variants +## Image Behavior -### `spacebot:slim` +There is one published image: `ghcr.io/spacedriveapp/spacebot`. -Minimal runtime. Everything works except the browser tool. - -- Base: `debian:bookworm-slim` -- Size: ~150MB -- Includes: Spacebot binary, CA certs, SQLite libs, bubblewrap (process sandbox), embedded frontend - -### `spacebot:full` - -Includes Chromium for browser workers (headless Chrome automation via CDP). - -- Base: `debian:bookworm-slim` + Chromium -- Size: ~800MB -- Includes: everything in slim + Chromium + browser dependencies +- `latest` tracks the rolling release +- `vX.Y.Z` pins a specific release +- Browser support is built in: Chromium is downloaded on first browser-tool use and cached under `/data` +- Legacy `-slim` / `-full` tags are deprecated ## Data Volume @@ -72,7 +63,7 @@ docker run -d \ -e DISCORD_BOT_TOKEN="..." \ -v spacebot-data:/data \ -p 19898:19898 \ - ghcr.io/spacedriveapp/spacebot:slim + ghcr.io/spacedriveapp/spacebot:latest ``` Available environment variables: @@ -99,7 +90,7 @@ docker run -d \ -v spacebot-data:/data \ -v ./config.toml:/data/config.toml:ro \ -p 19898:19898 \ - ghcr.io/spacedriveapp/spacebot:slim + ghcr.io/spacedriveapp/spacebot:latest ``` Config values can reference environment variables with `env:VAR_NAME`: @@ -116,7 +107,7 @@ See [Configuration](/docs/config) for the full config reference. ```yaml services: spacebot: - image: ghcr.io/spacedriveapp/spacebot:slim + image: ghcr.io/spacedriveapp/spacebot:latest container_name: spacebot restart: unless-stopped ports: @@ -132,48 +123,23 @@ volumes: spacebot-data: ``` -### With Browser Workers +### Browser Workers -```yaml -services: - spacebot: - image: ghcr.io/spacedriveapp/spacebot:full - container_name: spacebot - restart: unless-stopped - ports: - - "19898:19898" - volumes: - - spacebot-data:/data - environment: - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - # Chromium needs these for headless operation - security_opt: - - seccomp=unconfined - shm_size: 1gb - -volumes: - spacebot-data: -``` - -The `shm_size` and `seccomp` settings are needed for Chromium to run properly in a container. +No alternate image is needed. The browser tool downloads Chromium on demand and +caches it on the mounted `/data` volume. ## Building the Image From the spacebot repo root: ```bash -# Slim (no browser) -docker build --target slim -t spacebot:slim . - -# Full (with Chromium) -docker build --target full -t spacebot:full . +docker build -t spacebot:local . ``` -The multi-stage Dockerfile: +The multi-stage Dockerfile has two stages: 1. **Builder stage** -- Rust toolchain + Bun. Compiles the React frontend, then builds the Rust binary with the frontend embedded. -2. **Slim stage** -- Minimal Debian runtime with the compiled binary. -3. **Full stage** -- Slim + Chromium and its dependencies. +2. **Runtime stage** -- Minimal Debian runtime with Chrome's shared-library dependencies. The browser binary itself is fetched on demand. Build time is ~5-10 minutes on first build (downloading and compiling Rust dependencies). Subsequent builds use the cargo cache. @@ -211,7 +177,7 @@ Spacebot checks for new releases on startup and every hour. When a new version i You can also open **Settings → Updates** for update status, one-click apply controls (Docker), and manual command snippets. -`latest` is supported and continues to receive updates (it tracks the rolling `full` image). Use explicit version tags only when you want controlled rollouts. +`latest` is supported and continues to receive updates. Use explicit version tags only when you want controlled rollouts. ### Manual Update @@ -235,7 +201,7 @@ Mount the Docker socket to enable updating directly from the web UI: ```yaml services: spacebot: - image: ghcr.io/spacedriveapp/spacebot:slim + image: ghcr.io/spacedriveapp/spacebot:latest container_name: spacebot restart: unless-stopped ports: @@ -289,17 +255,14 @@ Images are built and pushed to `ghcr.io/spacedriveapp/spacebot` via GitHub Actio **Tags pushed per release:** -| Tag | Description | -| ------------- | ------------------------ | -| `v0.1.0-slim` | Versioned slim | -| `v0.1.0-full` | Versioned full | -| `slim` | Rolling slim | -| `full` | Rolling full | -| `latest` | Rolling (points to full) | +| Tag | Description | +| -------- | ----------------- | +| `v0.1.0` | Versioned release | +| `latest` | Rolling release | ```bash # Manual single-instance deploy -fly launch --image ghcr.io/spacedriveapp/spacebot:slim +fly launch --image ghcr.io/spacedriveapp/spacebot:latest fly volumes create spacebot_data --size 5 fly secrets set ANTHROPIC_API_KEY="sk-ant-..." fly deploy diff --git a/docs/content/docs/(getting-started)/quickstart.mdx b/docs/content/docs/(getting-started)/quickstart.mdx index c2755ce29..1c33d0beb 100644 --- a/docs/content/docs/(getting-started)/quickstart.mdx +++ b/docs/content/docs/(getting-started)/quickstart.mdx @@ -28,7 +28,7 @@ docker run -d \ ghcr.io/spacedriveapp/spacebot:latest ``` -See [Docker deployment](/docs/docker) for image variants, compose files, and configuration options. +See [Docker deployment](/docs/docker) for image tags, compose files, and configuration options. To update Docker installs, pull and recreate the container: diff --git a/docs/content/docs/(messaging)/discord-setup.mdx b/docs/content/docs/(messaging)/discord-setup.mdx index 01c7e927e..f817b8350 100644 --- a/docs/content/docs/(messaging)/discord-setup.mdx +++ b/docs/content/docs/(messaging)/discord-setup.mdx @@ -136,6 +136,19 @@ require_mention = true This works nicely for busy channels where you only want direct interactions. +### Quiet mode + +Inside Discord you can also toggle the channel's runtime behavior with chat +commands: + +- `/quiet` switches the current conversation into listen-only mode +- `/active` returns it to normal reply behavior +- In quiet mode, Spacebot only replies to slash commands, `@mentions`, or + replies to one of its messages + +Use this when you want the bot present in a busy room without responding to +general chatter. + ### DM filtering By default, all DMs are ignored. To allow specific users, add their Discord user IDs. diff --git a/docs/docker.md b/docs/docker.md index dc3e5b161..b2425d671 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -1,6 +1,6 @@ # Docker -Run Spacebot in a container. Two image variants: `slim` (no browser) and `full` (includes Chromium for browser workers). +Run Spacebot in a container with the unified Spacebot image. ## Quick Start @@ -10,28 +10,19 @@ docker run -d \ -e ANTHROPIC_API_KEY="sk-ant-..." \ -v spacebot-data:/data \ -p 19898:19898 \ - ghcr.io/spacedriveapp/spacebot:slim + ghcr.io/spacedriveapp/spacebot:latest ``` The web UI is available at `http://localhost:19898`. -## Image Variants +## Image Behavior -### `spacebot:slim` +There is one published image: `ghcr.io/spacedriveapp/spacebot`. -Minimal runtime. Everything works except the browser tool. - -- Base: `debian:bookworm-slim` -- Size: ~150MB -- Includes: Spacebot binary, CA certs, SQLite libs, embedded frontend - -### `spacebot:full` - -Includes Chromium for browser workers (headless Chrome automation via CDP). - -- Base: `debian:bookworm-slim` + Chromium -- Size: ~800MB -- Includes: everything in slim + Chromium + browser dependencies +- `latest` tracks the rolling release +- `vX.Y.Z` pins a specific release +- Browser support is built in: Chromium is downloaded on first browser-tool use and cached under `/data` +- Legacy `-slim` / `-full` tags are deprecated ## Data Volume @@ -67,7 +58,7 @@ docker run -d \ -e DISCORD_BOT_TOKEN="..." \ -v spacebot-data:/data \ -p 19898:19898 \ - ghcr.io/spacedriveapp/spacebot:slim + ghcr.io/spacedriveapp/spacebot:latest ``` Available environment variables: @@ -94,7 +85,7 @@ docker run -d \ -v spacebot-data:/data \ -v ./config.toml:/data/config.toml:ro \ -p 19898:19898 \ - ghcr.io/spacedriveapp/spacebot:slim + ghcr.io/spacedriveapp/spacebot:latest ``` Config values can reference environment variables with `env:VAR_NAME`: @@ -111,7 +102,7 @@ See [config.md](config.md) for the full config reference. ```yaml services: spacebot: - image: ghcr.io/spacedriveapp/spacebot:slim + image: ghcr.io/spacedriveapp/spacebot:latest container_name: spacebot restart: unless-stopped ports: @@ -127,48 +118,23 @@ volumes: spacebot-data: ``` -### With Browser Workers - -```yaml -services: - spacebot: - image: ghcr.io/spacedriveapp/spacebot:full - container_name: spacebot - restart: unless-stopped - ports: - - "19898:19898" - volumes: - - spacebot-data:/data - environment: - - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} - # Chromium needs these for headless operation - security_opt: - - seccomp=unconfined - shm_size: 1gb - -volumes: - spacebot-data: -``` +### Browser Workers -The `shm_size` and `seccomp` settings are needed for Chromium to run properly in a container. +No alternate image is needed. The browser tool downloads Chromium on demand and +caches it on the mounted `/data` volume. ## Building the Image From the spacebot repo root: ```bash -# Slim (no browser) -docker build --target slim -t spacebot:slim . - -# Full (with Chromium) -docker build --target full -t spacebot:full . +docker build -t spacebot:local . ``` -The multi-stage Dockerfile: +The multi-stage Dockerfile has two stages: 1. **Builder stage** -- Rust toolchain + Bun. Compiles the React frontend, then builds the Rust binary with the frontend embedded. -2. **Slim stage** -- Minimal Debian runtime with the compiled binary. -3. **Full stage** -- Slim + Chromium and its dependencies. +2. **Runtime stage** -- Minimal Debian runtime with Chrome's shared-library dependencies. The browser binary itself is fetched on demand. Build time is ~5-10 minutes on first build (downloading and compiling Rust dependencies). Subsequent builds use the cargo cache. @@ -206,7 +172,7 @@ Spacebot checks for new releases on startup and every hour. When a new version i The web dashboard also includes **Settings → Updates** with status details, one-click controls (Docker), and manual command snippets. -`latest` is supported and continues to receive updates (it tracks the rolling `full` image). Use explicit version tags only when you want controlled rollouts. +`latest` is supported and continues to receive updates. Use explicit version tags only when you want controlled rollouts. ### Manual Update @@ -236,13 +202,7 @@ Images are built and pushed to `ghcr.io/spacedriveapp/spacebot` via GitHub Actio **Tags pushed per release:** -| Tag | Description | -| ------------- | -------------------------- | -| `v0.1.0-slim` | Versioned slim | -| `v0.1.0-full` | Versioned full | -| `v0.1.0` | Versioned (points to full) | -| `slim` | Rolling slim | -| `full` | Rolling full | -| `latest` | Rolling (points to full) | - -The `latest` tag always points to the `full` variant. +| Tag | Description | +| -------- | ----------------- | +| `v0.1.0` | Versioned release | +| `latest` | Rolling release | diff --git a/docs/metrics.md b/docs/metrics.md index 133fa8739..6f9431c7f 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -203,7 +203,7 @@ docker run -d \ -v spacebot-data:/data \ -p 19898:19898 \ -p 9090:9090 \ - ghcr.io/spacedriveapp/spacebot:full + ghcr.io/spacedriveapp/spacebot:latest ``` The Docker image always includes metrics. For local builds without metrics, omit the `--features metrics` flag. diff --git a/src/agent/channel.rs b/src/agent/channel.rs index eadc0c069..3857a31d0 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -698,88 +698,7 @@ impl Channel { message: &InboundMessage, raw_text: &str, ) -> (bool, bool, bool) { - let text = raw_text.trim(); - let invoked_by_command = text.starts_with('/'); - let invoked_by_mention = match message.source.as_str() { - "telegram" => { - let text_lower = text.to_lowercase(); - message - .metadata - .get("telegram_bot_username") - .and_then(|v| v.as_str()) - .map(|username| { - let mention = format!("@{}", username.to_lowercase()); - text_lower.match_indices(&mention).any(|(start, _)| { - let end = start + mention.len(); - let before_ok = start == 0 - || text_lower[..start].chars().next_back().is_none_or( - |character| { - !(character.is_ascii_alphanumeric() || character == '_') - }, - ); - let after_ok = end == text_lower.len() - || text_lower[end..].chars().next().is_none_or(|character| { - !(character.is_ascii_alphanumeric() || character == '_') - }); - before_ok && after_ok - }) - }) - .unwrap_or(false) - } - "discord" => message - .metadata - .get("discord_mentioned_bot") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - "slack" => message - .metadata - .get("slack_mentions_or_replies_to_bot") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - "twitch" => message - .metadata - .get("twitch_mentions_or_replies_to_bot") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - _ => false, - }; - let invoked_by_reply = match message.source.as_str() { - // Use bot-specific reply metadata; generic reply_to_is_bot can - // match unrelated bots and cause false invokes. - "discord" => message - .metadata - .get("discord_reply_to_bot") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - "telegram" => { - let reply_to_is_bot = message - .metadata - .get("reply_to_is_bot") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let bot_username = message - .metadata - .get("telegram_bot_username") - .and_then(|v| v.as_str()) - .map(str::to_lowercase); - let reply_username = message - .metadata - .get("reply_to_username") - .and_then(|v| v.as_str()) - .map(str::to_lowercase); - reply_to_is_bot - && reply_username - .zip(bot_username) - .is_some_and(|(reply, bot)| bot == reply) - } - _ => message - .metadata - .get("reply_to_is_bot") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - }; - - (invoked_by_command, invoked_by_mention, invoked_by_reply) + compute_listen_mode_invocation(message, raw_text) } /// Send a routed response paired with the current inbound message. @@ -1660,16 +1579,8 @@ impl Channel { // Deterministic liveness ping for Telegram mentions. // This avoids model/provider flakiness for simple "you there?" style checks. if message.source == "telegram" { - let text = raw_text.trim().to_lowercase(); let (_, has_mention, _) = self.compute_listen_mode_invocation(&message, &raw_text); - let looks_like_ping = text.contains("you here") - || text.contains("ping") - || text.ends_with(" yo") - || text == "yo" - || text.contains("alive") - || text.contains("there?"); - - if has_mention && looks_like_ping { + if has_mention && looks_like_liveness_ping(&raw_text) { self.send_builtin_text("yeah i'm here".to_string(), "telegram-ping") .await; return Ok(()); @@ -1678,22 +1589,10 @@ impl Channel { // Deterministic ping ack for Discord quiet-mode mentions/replies to avoid // flaky model behavior (e.g. skipping or over-formatting simple liveness checks). - if message.source == "discord" && self.listen_only_mode { - let text = raw_text.trim().to_lowercase(); - let (_, invoked_by_mention, invoked_by_reply) = - self.compute_listen_mode_invocation(&message, &raw_text); - let directed = invoked_by_mention || invoked_by_reply; - let looks_like_ping = text.contains("you here") - || text.contains("ping") - || text.ends_with(" yo") - || text == "yo" - || text.contains("alive") - || text.contains("there?"); - if directed && looks_like_ping { - self.send_builtin_text("yeah i'm here".to_string(), "discord-ping") - .await; - return Ok(()); - } + if should_send_discord_quiet_mode_ping_ack(&message, &raw_text, self.listen_only_mode) { + self.send_builtin_text("yeah i'm here".to_string(), "discord-ping") + .await; + return Ok(()); } // Capture conversation context from the first message (platform, channel, server) @@ -1818,17 +1717,18 @@ impl Channel { .await; // Safety-net: in quiet mode, explicit mention/reply should never be dropped silently. - if self.listen_only_mode - && !is_retrigger - && !invoked_by_command - && (invoked_by_mention || invoked_by_reply) - && skip_flag.load(std::sync::atomic::Ordering::Relaxed) - && !replied_flag.load(std::sync::atomic::Ordering::Relaxed) - && matches!( - message.source.as_str(), - "discord" | "telegram" | "slack" | "twitch" | "signal" - ) - { + if should_send_quiet_mode_fallback( + &message, + QuietModeFallbackState { + listen_only_mode: self.listen_only_mode, + is_retrigger, + invoked_by_command, + invoked_by_mention, + invoked_by_reply, + skip_flag: skip_flag.load(std::sync::atomic::Ordering::Relaxed), + replied_flag: replied_flag.load(std::sync::atomic::Ordering::Relaxed), + }, + ) { self.send_builtin_text( "yeah i'm here — tell me what you need.".to_string(), "quiet-mode-fallback", @@ -3189,13 +3089,179 @@ impl Channel { } } +fn compute_listen_mode_invocation(message: &InboundMessage, raw_text: &str) -> (bool, bool, bool) { + let text = raw_text.trim(); + let invoked_by_command = text.starts_with('/'); + let invoked_by_mention = match message.source.as_str() { + "telegram" => { + let text_lower = text.to_lowercase(); + message + .metadata + .get("telegram_bot_username") + .and_then(|v| v.as_str()) + .map(|username| { + let mention = format!("@{}", username.to_lowercase()); + text_lower.match_indices(&mention).any(|(start, _)| { + let end = start + mention.len(); + let before_ok = start == 0 + || text_lower[..start] + .chars() + .next_back() + .is_none_or(|character| { + !(character.is_ascii_alphanumeric() || character == '_') + }); + let after_ok = end == text_lower.len() + || text_lower[end..].chars().next().is_none_or(|character| { + !(character.is_ascii_alphanumeric() || character == '_') + }); + before_ok && after_ok + }) + }) + .unwrap_or(false) + } + "discord" => message + .metadata + .get("discord_mentioned_bot") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + "slack" => message + .metadata + .get("slack_mentions_or_replies_to_bot") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + "twitch" => message + .metadata + .get("twitch_mentions_or_replies_to_bot") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + _ => false, + }; + let invoked_by_reply = match message.source.as_str() { + // Use bot-specific reply metadata; generic reply_to_is_bot can + // match unrelated bots and cause false invokes. + "discord" => message + .metadata + .get("discord_reply_to_bot") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + "telegram" => { + let reply_to_is_bot = message + .metadata + .get("reply_to_is_bot") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let bot_username = message + .metadata + .get("telegram_bot_username") + .and_then(|v| v.as_str()) + .map(str::to_lowercase); + let reply_username = message + .metadata + .get("reply_to_username") + .and_then(|v| v.as_str()) + .map(str::to_lowercase); + reply_to_is_bot + && reply_username + .zip(bot_username) + .is_some_and(|(reply, bot)| bot == reply) + } + _ => message + .metadata + .get("reply_to_is_bot") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + }; + + (invoked_by_command, invoked_by_mention, invoked_by_reply) +} + +fn looks_like_liveness_ping(text: &str) -> bool { + let text = text.trim().to_lowercase(); + text.contains("you here") + || text.contains("ping") + || text.ends_with(" yo") + || text == "yo" + || text.contains("alive") + || text.contains("there?") +} + +fn should_send_discord_quiet_mode_ping_ack( + message: &InboundMessage, + raw_text: &str, + listen_only_mode: bool, +) -> bool { + if message.source != "discord" || !listen_only_mode { + return false; + } + + let (_, invoked_by_mention, invoked_by_reply) = + compute_listen_mode_invocation(message, raw_text); + (invoked_by_mention || invoked_by_reply) && looks_like_liveness_ping(raw_text) +} + +#[derive(Debug, Clone, Copy)] +struct QuietModeFallbackState { + listen_only_mode: bool, + is_retrigger: bool, + invoked_by_command: bool, + invoked_by_mention: bool, + invoked_by_reply: bool, + skip_flag: bool, + replied_flag: bool, +} + +fn should_send_quiet_mode_fallback( + message: &InboundMessage, + state: QuietModeFallbackState, +) -> bool { + state.listen_only_mode + && !state.is_retrigger + && !state.invoked_by_command + && (state.invoked_by_mention || state.invoked_by_reply) + && state.skip_flag + && !state.replied_flag + && matches!( + message.source.as_str(), + "discord" | "telegram" | "slack" | "twitch" | "signal" + ) +} + #[cfg(test)] mod tests { - use super::{recv_channel_event, should_process_event_for_channel}; + use super::{ + QuietModeFallbackState, compute_listen_mode_invocation, recv_channel_event, + should_process_event_for_channel, should_send_discord_quiet_mode_ping_ack, + should_send_quiet_mode_fallback, + }; use crate::memory::MemoryType; - use crate::{AgentId, ChannelId, ProcessEvent, ProcessId}; + use crate::{AgentId, ChannelId, InboundMessage, MessageContent, ProcessEvent, ProcessId}; + use std::collections::HashMap; use std::sync::Arc; + fn inbound_message( + source: &str, + metadata: &[(&str, serde_json::Value)], + content: &str, + ) -> InboundMessage { + let mut message_metadata = HashMap::new(); + for (key, value) in metadata { + message_metadata.insert((*key).to_string(), value.clone()); + } + + InboundMessage { + id: "message-1".into(), + source: source.into(), + adapter: None, + conversation_id: format!("{source}:conversation"), + sender_id: "user-1".into(), + agent_id: None, + content: MessageContent::Text(content.into()), + timestamp: chrono::Utc::now(), + metadata: message_metadata, + formatted_author: None, + } + } + #[tokio::test] async fn channel_event_loop_continues_after_lagged_broadcast() { let (event_tx, mut event_rx) = tokio::sync::broadcast::channel::(2); @@ -3335,4 +3401,107 @@ mod tests { assert!(!should_process_event_for_channel(&event, &channel_id)); } + + #[test] + fn quiet_mode_invocation_uses_discord_mention_and_reply_metadata() { + let message = inbound_message( + "discord", + &[ + ("discord_mentioned_bot", true.into()), + ("discord_reply_to_bot", false.into()), + ], + "@bot ping", + ); + + let (invoked_by_command, invoked_by_mention, invoked_by_reply) = + compute_listen_mode_invocation(&message, "@bot ping"); + + assert!(!invoked_by_command); + assert!(invoked_by_mention); + assert!(!invoked_by_reply); + } + + #[test] + fn discord_quiet_mode_ping_ack_requires_directed_ping() { + let directed_message = inbound_message( + "discord", + &[("discord_reply_to_bot", true.into())], + "ping are you there?", + ); + let ambient_message = inbound_message( + "discord", + &[("discord_reply_to_bot", false.into())], + "ping are you there?", + ); + + assert!(should_send_discord_quiet_mode_ping_ack( + &directed_message, + "ping are you there?", + true + )); + assert!(!should_send_discord_quiet_mode_ping_ack( + &ambient_message, + "ping are you there?", + true + )); + assert!(!should_send_discord_quiet_mode_ping_ack( + &directed_message, + "ping are you there?", + false + )); + } + + #[test] + fn quiet_mode_fallback_requires_directed_skipped_turn_without_reply() { + let message = inbound_message("discord", &[], "hey"); + + assert!(should_send_quiet_mode_fallback( + &message, + QuietModeFallbackState { + listen_only_mode: true, + is_retrigger: false, + invoked_by_command: false, + invoked_by_mention: true, + invoked_by_reply: false, + skip_flag: true, + replied_flag: false, + } + )); + assert!(!should_send_quiet_mode_fallback( + &message, + QuietModeFallbackState { + listen_only_mode: true, + is_retrigger: false, + invoked_by_command: false, + invoked_by_mention: true, + invoked_by_reply: false, + skip_flag: false, + replied_flag: false, + } + )); + assert!(!should_send_quiet_mode_fallback( + &message, + QuietModeFallbackState { + listen_only_mode: true, + is_retrigger: false, + invoked_by_command: false, + invoked_by_mention: true, + invoked_by_reply: false, + skip_flag: true, + replied_flag: true, + } + )); + assert!(!should_send_quiet_mode_fallback( + &message, + QuietModeFallbackState { + listen_only_mode: true, + is_retrigger: true, + invoked_by_command: false, + invoked_by_mention: true, + invoked_by_reply: false, + skip_flag: true, + replied_flag: false, + } + )); + } } diff --git a/src/messaging/discord.rs b/src/messaging/discord.rs index dc204dd2a..006c268ab 100644 --- a/src/messaging/discord.rs +++ b/src/messaging/discord.rs @@ -164,7 +164,7 @@ impl Messaging for DiscordAdapter { } } OutboundResponse::RichMessage { - mut text, + text, cards, interactive_elements, poll, @@ -172,37 +172,15 @@ impl Messaging for DiscordAdapter { } => { self.stop_typing(message).await; let reply_to = Self::extract_reply_message_id(message); - - // Derive a plaintext fallback from cards when text is empty so - // the message is never blank (notifications, logging, etc.). - if text.trim().is_empty() { - let derived = crate::OutboundResponse::text_from_cards(&cards); - if !derived.trim().is_empty() { - text = derived; - } - } - - // Enforce Discord API limits: max 10 embeds, 5 action rows. - let cards = if cards.len() > 10 { - tracing::warn!( - count = cards.len(), - "truncating cards to Discord embed limit (10)" - ); - &cards[..10] - } else { - &cards - }; - let interactive_elements = if interactive_elements.len() > 5 { + let parts = + prepare_rich_message_parts(text, &cards, &interactive_elements, poll.as_ref()); + if parts.dropped_invalid_poll { tracing::warn!( - count = interactive_elements.len(), - "truncating interactive elements to Discord action row limit (5)" + "dropping invalid discord poll payload while sending rich message" ); - &interactive_elements[..5] - } else { - &interactive_elements - }; + } - let chunks = split_message(&text, 2000); + let chunks = split_message(&parts.text, 2000); for (i, chunk) in chunks.iter().enumerate() { let is_last = i == chunks.len() - 1; let mut msg = CreateMessage::new(); @@ -212,19 +190,22 @@ impl Messaging for DiscordAdapter { // Attach rich content only to the final chunk if is_last { - let embeds: Vec<_> = cards.iter().map(build_embed).collect(); + let embeds: Vec<_> = parts.cards.iter().map(build_embed).collect(); if !embeds.is_empty() { msg = msg.embeds(embeds); } - let components: Vec<_> = - interactive_elements.iter().map(build_action_row).collect(); + let components: Vec<_> = parts + .interactive_elements + .iter() + .map(build_action_row) + .collect(); if !components.is_empty() { msg = msg.components(components); } - if let Some(poll_data) = &poll { - msg = msg.poll(build_poll(poll_data)); + if let Some(poll_data) = parts.poll.as_ref().and_then(build_poll) { + msg = msg.poll(poll_data); } } @@ -448,42 +429,22 @@ impl Messaging for DiscordAdapter { .context("failed to broadcast discord message")?; } } else if let OutboundResponse::RichMessage { - mut text, + text, cards, interactive_elements, poll, .. } = response { - // Derive a plaintext fallback from cards when text is empty. - if text.trim().is_empty() { - let derived = crate::OutboundResponse::text_from_cards(&cards); - if !derived.trim().is_empty() { - text = derived; - } - } - - // Enforce Discord API limits: max 10 embeds, 5 action rows. - let cards = if cards.len() > 10 { - tracing::warn!( - count = cards.len(), - "truncating cards to Discord embed limit (10)" - ); - &cards[..10] - } else { - &cards - }; - let interactive_elements = if interactive_elements.len() > 5 { + let parts = + prepare_rich_message_parts(text, &cards, &interactive_elements, poll.as_ref()); + if parts.dropped_invalid_poll { tracing::warn!( - count = interactive_elements.len(), - "truncating interactive elements to Discord action row limit (5)" + "dropping invalid discord poll payload while broadcasting rich message" ); - &interactive_elements[..5] - } else { - &interactive_elements - }; + } - let chunks = split_message(&text, 2000); + let chunks = split_message(&parts.text, 2000); for (i, chunk) in chunks.iter().enumerate() { let is_last = i == chunks.len() - 1; let mut msg = CreateMessage::new(); @@ -493,19 +454,22 @@ impl Messaging for DiscordAdapter { // Attach rich content only to the final chunk if is_last { - let embeds: Vec<_> = cards.iter().map(build_embed).collect(); + let embeds: Vec<_> = parts.cards.iter().map(build_embed).collect(); if !embeds.is_empty() { msg = msg.embeds(embeds); } - let components: Vec<_> = - interactive_elements.iter().map(build_action_row).collect(); + let components: Vec<_> = parts + .interactive_elements + .iter() + .map(build_action_row) + .collect(); if !components.is_empty() { msg = msg.components(components); } - if let Some(poll_data) = &poll { - msg = msg.poll(build_poll(poll_data)); + if let Some(poll_data) = parts.poll.as_ref().and_then(build_poll) { + msg = msg.poll(poll_data); } } @@ -1149,23 +1113,90 @@ fn build_action_row(elements: &crate::InteractiveElements) -> CreateActionRow { } } +struct RichMessageParts<'a> { + text: String, + cards: &'a [crate::Card], + interactive_elements: &'a [crate::InteractiveElements], + poll: Option, + dropped_invalid_poll: bool, +} + +fn prepare_rich_message_parts<'a>( + mut text: String, + cards: &'a [crate::Card], + interactive_elements: &'a [crate::InteractiveElements], + poll: Option<&crate::Poll>, +) -> RichMessageParts<'a> { + // Derive a plaintext fallback from cards when text is empty so the message + // is never blank for notifications, logs, or non-rich adapters. + if text.trim().is_empty() { + let derived = crate::OutboundResponse::text_from_cards(cards); + if !derived.trim().is_empty() { + text = derived; + } + } + + let cards = if cards.len() > 10 { + tracing::warn!( + count = cards.len(), + "truncating cards to Discord embed limit (10)" + ); + &cards[..10] + } else { + cards + }; + + let interactive_elements = if interactive_elements.len() > 5 { + tracing::warn!( + count = interactive_elements.len(), + "truncating interactive elements to Discord action row limit (5)" + ); + &interactive_elements[..5] + } else { + interactive_elements + }; + + let had_poll = poll.is_some(); + let poll = poll.filter(|poll| build_poll(poll).is_some()).cloned(); + let dropped_invalid_poll = had_poll && poll.is_none(); + + RichMessageParts { + text, + cards, + interactive_elements, + poll, + dropped_invalid_poll, + } +} + fn build_poll( poll: &crate::Poll, -) -> serenity::builder::CreatePoll { +) -> Option> { + let question = poll.question.trim(); + if question.is_empty() { + return None; + } + // Discord limits: max 10 answers let answers: Vec<_> = poll .answers .iter() + .map(|answer| answer.trim()) + .filter(|answer| !answer.is_empty()) .take(10) - .map(|a| CreatePollAnswer::new().text(a)) + .map(|answer| CreatePollAnswer::new().text(answer)) .collect(); + if answers.len() < 2 { + return None; + } + // Duration must be at least 1 hour, usually up to 720 hours (30 days). // The builder just takes std::time::Duration but it has specific allowed values. let hours = poll.duration_hours.clamp(1, 720); let mut p = CreatePoll::new() - .question(&poll.question) + .question(question) .answers(answers) .duration(std::time::Duration::from_secs((hours as u64) * 3600)); @@ -1173,7 +1204,7 @@ fn build_poll( p = p.allow_multiselect(); } - p + Some(p) } #[cfg(test)] @@ -1234,7 +1265,65 @@ mod tests { } // build_poll should limit answers to 10 and duration to 720 - let _ = build_poll(&poll); + let built = build_poll(&poll); + assert!(built.is_some()); // Again, can't easily inspect CreatePoll fields, but we verify it runs. } + + #[test] + fn test_build_poll_rejects_blank_question() { + let poll = Poll { + question: " ".into(), + answers: vec!["Yes".into(), "No".into()], + allow_multiselect: false, + duration_hours: 24, + }; + + assert!(build_poll(&poll).is_none()); + } + + #[test] + fn test_build_poll_rejects_single_non_empty_answer() { + let poll = Poll { + question: "Question?".into(), + answers: vec!["Yes".into(), " ".into()], + allow_multiselect: false, + duration_hours: 24, + }; + + assert!(build_poll(&poll).is_none()); + } + + #[test] + fn test_prepare_rich_message_parts_drops_invalid_poll_but_keeps_text() { + let poll = Poll { + question: " ".into(), + answers: vec!["Yes".into(), "No".into()], + allow_multiselect: false, + duration_hours: 24, + }; + + let parts = prepare_rich_message_parts("plain text reply".into(), &[], &[], Some(&poll)); + + assert_eq!(parts.text, "plain text reply"); + assert!(parts.poll.is_none()); + assert!(parts.dropped_invalid_poll); + } + + #[test] + fn test_prepare_rich_message_parts_derives_text_fallback_from_cards() { + let cards = vec![Card { + title: Some("Status".into()), + description: Some("All green".into()), + color: None, + url: None, + fields: Vec::new(), + footer: None, + }]; + + let parts = prepare_rich_message_parts(String::new(), &cards, &[], None); + + assert_eq!(parts.text, "Status\n\nAll green"); + assert!(!parts.dropped_invalid_poll); + } } diff --git a/src/tools/reply.rs b/src/tools/reply.rs index 57e645bf7..1bd47a648 100644 --- a/src/tools/reply.rs +++ b/src/tools/reply.rs @@ -219,6 +219,31 @@ pub(crate) fn normalize_discord_mention_tokens(content: &str, source: &str) -> S normalized } +fn normalize_poll_payload(poll: crate::Poll) -> Option { + let question = poll.question.trim().to_string(); + if question.is_empty() { + return None; + } + + let answers: Vec = poll + .answers + .into_iter() + .map(|answer| answer.trim().to_string()) + .filter(|answer| !answer.is_empty()) + .collect(); + + if answers.len() < 2 { + return None; + } + + Some(crate::Poll { + question, + answers, + allow_multiselect: poll.allow_multiselect, + duration_hours: poll.duration_hours, + }) +} + impl Tool for ReplyTool { const NAME: &'static str = "reply"; @@ -376,6 +401,7 @@ impl Tool for ReplyTool { .as_ref() .map(|name| name.trim()) .filter(|name| !name.is_empty()); + let poll = args.poll.and_then(normalize_poll_payload); if let Some(leak) = crate::secrets::scrub::scan_for_leaks(&converted_content) { tracing::error!( @@ -399,14 +425,13 @@ impl Tool for ReplyTool { thread_name, text: converted_content.clone(), } - } else if args.cards.is_some() || args.interactive_elements.is_some() || args.poll.is_some() - { + } else if args.cards.is_some() || args.interactive_elements.is_some() || poll.is_some() { OutboundResponse::RichMessage { text: converted_content.clone(), blocks: vec![], cards: args.cards.unwrap_or_default(), interactive_elements: args.interactive_elements.unwrap_or_default(), - poll: args.poll, + poll, } } else { OutboundResponse::Text(converted_content.clone()) @@ -438,7 +463,10 @@ impl Tool for ReplyTool { #[cfg(test)] mod tests { - use super::{normalize_discord_mention_tokens, sanitize_discord_user_id}; + use super::{ + normalize_discord_mention_tokens, normalize_poll_payload, sanitize_discord_user_id, + }; + use crate::Poll; #[test] fn normalizes_broken_discord_mentions() { @@ -469,4 +497,45 @@ mod tests { let parsed = sanitize_discord_user_id(">234152400653385729").expect("should parse id"); assert_eq!(parsed, "234152400653385729"); } + + #[test] + fn drops_poll_with_blank_question() { + let poll = Poll { + question: " ".into(), + answers: vec!["Yes".into(), "No".into()], + allow_multiselect: false, + duration_hours: 24, + }; + + assert!(normalize_poll_payload(poll).is_none()); + } + + #[test] + fn drops_poll_with_fewer_than_two_non_empty_answers() { + let poll = Poll { + question: "Ship it?".into(), + answers: vec!["Yes".into(), " ".into()], + allow_multiselect: false, + duration_hours: 24, + }; + + assert!(normalize_poll_payload(poll).is_none()); + } + + #[test] + fn trims_poll_question_and_answers() { + let poll = Poll { + question: " Ship it? ".into(), + answers: vec![" Yes ".into(), " No ".into()], + allow_multiselect: true, + duration_hours: 12, + }; + + let normalized = normalize_poll_payload(poll).expect("poll should remain valid"); + + assert_eq!(normalized.question, "Ship it?"); + assert_eq!(normalized.answers, vec!["Yes", "No"]); + assert!(normalized.allow_multiselect); + assert_eq!(normalized.duration_hours, 12); + } }