diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index e89ca0abb..da400b604 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -1207,6 +1207,9 @@ export interface CreateMessagingInstanceRequest { webhook_port?: number; webhook_bind?: string; webhook_auth_token?: string; + signal_http_url?: string; + signal_account?: string; + signal_dm_allowed_users?: string; }; } diff --git a/interface/src/components/ChannelEditModal.tsx b/interface/src/components/ChannelEditModal.tsx index e29a72251..90eddbe18 100644 --- a/interface/src/components/ChannelEditModal.tsx +++ b/interface/src/components/ChannelEditModal.tsx @@ -1,6 +1,7 @@ import {useState} from "react"; import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"; import {api, type PlatformStatus, type BindingInfo} from "@/api/client"; +import {isValidE164, E164_ERROR_TEXT} from "@/lib/format"; import { Button, Input, @@ -18,7 +19,7 @@ import { import {PlatformIcon} from "@/lib/platformIcons"; import {TagInput} from "@/components/TagInput"; -type Platform = "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook"; +type Platform = "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "signal"; interface ChannelEditModalProps { platform: Platform; @@ -168,6 +169,26 @@ export function ChannelEditModal({platform, name, status, open, onOpenChange}: C twitch_client_secret: credentialInputs.twitch_client_secret?.trim(), twitch_refresh_token: credentialInputs.twitch_refresh_token?.trim(), }; + } else if (platform === "signal") { + if (!credentialInputs.signal_http_url?.trim()) { + setMessage({text: "HTTP URL is required", type: "error"}); + return; + } + if (!credentialInputs.signal_account?.trim()) { + setMessage({text: "Account phone number is required", type: "error"}); + return; + } + // Basic E.164 validation + const account = credentialInputs.signal_account.trim(); + if (!isValidE164(account)) { + setMessage({text: E164_ERROR_TEXT, type: "error"}); + return; + } + request.platform_credentials = { + signal_http_url: credentialInputs.signal_http_url.trim(), + signal_account: account, + signal_dm_allowed_users: credentialInputs.signal_dm_allowed_users?.trim() || undefined, + }; } saveCreds.mutate(request); } @@ -358,6 +379,41 @@ export function ChannelEditModal({platform, name, status, open, onOpenChange}: C )} + {platform === "signal" && ( + <> +
+
+ + setCredentialInputs({...credentialInputs, signal_http_url: e.target.value})} + placeholder="http://127.0.0.1:8686" + /> +
+
+ + setCredentialInputs({...credentialInputs, signal_account: e.target.value})} + placeholder="+1234567890" + onKeyDown={(e) => { if (e.key === "Enter") handleSaveCredentials(); }} + /> +

+ Your Signal phone number in E.164 format (+ followed by 6-15 digits, first digit 1-9) +

+
+
+

+ Need help?{" "} + + Read the Signal setup docs → + +

+ + )} + {platform === "webhook" && (

Webhook receiver requires no additional credentials. diff --git a/interface/src/components/ChannelSettingCard.tsx b/interface/src/components/ChannelSettingCard.tsx index 198babecb..ce92f67b5 100644 --- a/interface/src/components/ChannelSettingCard.tsx +++ b/interface/src/components/ChannelSettingCard.tsx @@ -23,11 +23,12 @@ import { Toggle, } from "@/ui"; import {PlatformIcon} from "@/lib/platformIcons"; +import {isValidE164, E164_ERROR_TEXT} from "@/lib/format"; import {TagInput} from "@/components/TagInput"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faChevronDown, faPlus} from "@fortawesome/free-solid-svg-icons"; -type Platform = "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook"; +type Platform = "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "signal"; const PLATFORM_LABELS: Record = { discord: "Discord", @@ -36,6 +37,7 @@ const PLATFORM_LABELS: Record = { twitch: "Twitch", email: "Email", webhook: "Webhook", + signal: "Signal", }; const DOC_LINKS: Partial> = { @@ -43,6 +45,7 @@ const DOC_LINKS: Partial> = { slack: "https://docs.spacebot.sh/slack-setup", telegram: "https://docs.spacebot.sh/telegram-setup", twitch: "https://docs.spacebot.sh/twitch-setup", + signal: "https://docs.spacebot.sh/signal-setup", }; // --- Platform Catalog (Left Column) --- @@ -59,6 +62,7 @@ export function PlatformCatalog({onAddInstance}: PlatformCatalogProps) { "twitch", "email", "webhook", + "signal", ]; const COMING_SOON = [ @@ -636,6 +640,57 @@ export function AddInstanceCard({platform, isDefault, onCancel, onCreated}: AddI credentials.webhook_bind = credentialInputs.webhook_bind.trim(); if (credentialInputs.webhook_auth_token?.trim()) credentials.webhook_auth_token = credentialInputs.webhook_auth_token.trim(); + } else if (platform === "signal") { + if (!credentialInputs.signal_http_url?.trim()) { + setMessage({text: "HTTP URL is required", type: "error"}); + return; + } + if (!credentialInputs.signal_account?.trim()) { + setMessage({text: "Account phone number is required", type: "error"}); + return; + } + // Basic E.164 validation (frontend) - match backend rules + const account = credentialInputs.signal_account.trim(); + if (!isValidE164(account)) { + setMessage({ + text: E164_ERROR_TEXT, + type: "error" + }); + return; + } + credentials.signal_http_url = credentialInputs.signal_http_url.trim(); + credentials.signal_account = account; + if (credentialInputs.signal_dm_allowed_users?.trim()) { + // Split, trim, and validate each phone number + const dm_users = credentialInputs.signal_dm_allowed_users + .split(',') + .map((s: string) => s.trim()) + .filter((s: string) => s.length > 0); + + // Validate each entry is E.164 format + const invalid_users: string[] = []; + const valid_users: string[] = []; + + for (const user of dm_users) { + if (!isValidE164(user)) { + invalid_users.push(user); + } else { + valid_users.push(user); + } + } + + if (invalid_users.length > 0) { + setMessage({ + text: `Invalid phone number(s): ${invalid_users.join(', ')}. ${E164_ERROR_TEXT}`, + type: "error" + }); + return; + } + + if (valid_users.length > 0) { + credentials.signal_dm_allowed_users = valid_users.join(','); + } + } } if (!isDefault && !instanceName.trim()) { @@ -925,7 +980,51 @@ export function AddInstanceCard({platform, isDefault, onCancel, onCreated}: AddI )} - {docLink && ( + {platform === "signal" && ( + <> +

+ + setCredentialInputs({...credentialInputs, signal_http_url: e.target.value})} + placeholder="http://127.0.0.1:8686" + onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }} + /> +

+ URL of your signal-cli daemon (e.g., http://127.0.0.1:8686) +

+
+
+ + setCredentialInputs({...credentialInputs, signal_account: e.target.value})} + placeholder="+1234567890" + onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }} + /> +

+ Your Signal phone number in E.164 format (+ followed by 6-15 digits, first digit 1-9) +

+
+
+ + setCredentialInputs({...credentialInputs, signal_dm_allowed_users: e.target.value})} + placeholder="+1234567890, +1987654321" + onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }} + /> +

+ Comma-separated list of phone numbers allowed to DM this bot (if empty, only the bot's own account can DM) +

+
+ + )} + + {docLink && (

Need help?{" "} diff --git a/interface/src/lib/format.ts b/interface/src/lib/format.ts index a0dd54075..4d323f456 100644 --- a/interface/src/lib/format.ts +++ b/interface/src/lib/format.ts @@ -57,3 +57,25 @@ export function platformColor(platform: string): string { default: return "bg-gray-500/20 text-gray-400"; } } + +// E.164 Phone Number Validation +// Validates international phone numbers in format: + followed by country code and 6-15 digits +export const E164_REGEX = /^\+[1-9]\d{5,14}$/; + +export const E164_ERROR_TEXT = + "Phone number must be in E.164 format: + followed by country code and 6-15 digits (e.g., +1234567890)"; + +export function isValidE164(phoneNumber: string): boolean { + return E164_REGEX.test(phoneNumber.trim()); +} + +export function validateE164(phoneNumber: string): { valid: boolean; error?: string } { + const trimmed = phoneNumber.trim(); + if (!trimmed) { + return { valid: false, error: "Phone number is required" }; + } + if (!E164_REGEX.test(trimmed)) { + return { valid: false, error: E164_ERROR_TEXT }; + } + return { valid: true }; +} diff --git a/interface/src/lib/platformIcons.tsx b/interface/src/lib/platformIcons.tsx index a6e982375..114e077e3 100644 --- a/interface/src/lib/platformIcons.tsx +++ b/interface/src/lib/platformIcons.tsx @@ -17,6 +17,7 @@ export function PlatformIcon({ platform, className = "text-ink-faint", size = "1 webhook: faLink, email: faEnvelope, whatsapp: faWhatsapp, + signal: faComment, matrix: faComments, imessage: faComment, irc: faComments, diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 8086d9194..5d3dc1e69 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -884,7 +884,7 @@ function ThemePreview({ themeId }: { themeId: ThemeId }) { ); } -type Platform = "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook"; +type Platform = "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "signal"; function ChannelsSection() { const [expandedKey, setExpandedKey] = useState(null); diff --git a/prompts/en/adapters/cron.md.j2 b/prompts/en/adapters/cron.md.j2 index 7c5828506..dc31f629b 100644 --- a/prompts/en/adapters/cron.md.j2 +++ b/prompts/en/adapters/cron.md.j2 @@ -7,3 +7,5 @@ This is an automated scheduled task, not a live conversation. There is no human - Be concise and data-driven in the final output. Lead with findings, not preamble. Include specifics — numbers, dates, names, links. - Your entire text output will be delivered as-is to the configured channel. Write it like a finished report. - If a worker fails or data is unavailable, say so clearly and include what you were able to gather. +- Use the `reply` tool for your primary output — it will be delivered to the configured destination automatically. +- Only use `send_message_to_another_channel` if the task explicitly requires sending to additional channels beyond the primary delivery target. diff --git a/prompts/en/fragments/conversation_context.md.j2 b/prompts/en/fragments/conversation_context.md.j2 index a3b0e0344..2e73df246 100644 --- a/prompts/en/fragments/conversation_context.md.j2 +++ b/prompts/en/fragments/conversation_context.md.j2 @@ -3,6 +3,6 @@ Platform: {{ platform }} Server: {{ server_name }} {%- endif %} {%- if channel_name %} -Channel: #{{ channel_name }} +Channel: {{ channel_name }} ({{ platform }}{% if conversation_id %}, id: `{{ conversation_id }}`{% endif %}) {%- endif %} Multiple users may be present. Each message is prefixed with [username]. diff --git a/src/agent/channel.rs b/src/agent/channel.rs index 6f992b20c..2e59caba7 100644 --- a/src/agent/channel.rs +++ b/src/agent/channel.rs @@ -1254,6 +1254,7 @@ impl Channel { &first.source, server_name, channel_name, + self.conversation_id.as_deref(), )?); } @@ -1690,6 +1691,7 @@ impl Channel { &message.source, server_name, channel_name, + self.conversation_id.as_deref(), )?); } diff --git a/src/api/channels.rs b/src/api/channels.rs index e18937b75..ad5657c6c 100644 --- a/src/api/channels.rs +++ b/src/api/channels.rs @@ -456,6 +456,7 @@ pub(super) async fn inspect_prompt( &info.platform, server_name, info.display_name.as_deref(), + Some(&info.id), ) .ok() } diff --git a/src/api/messaging.rs b/src/api/messaging.rs index 692dea009..0484628af 100644 --- a/src/api/messaging.rs +++ b/src/api/messaging.rs @@ -6,6 +6,9 @@ use axum::http::StatusCode; use serde::{Deserialize, Serialize}; use std::sync::Arc; +// Re-export E.164 validation from messaging target module +pub use crate::messaging::target::is_valid_e164; + #[derive(Serialize, Clone)] pub(super) struct PlatformStatus { configured: bool, @@ -31,6 +34,7 @@ pub(super) struct MessagingStatusResponse { email: PlatformStatus, webhook: PlatformStatus, twitch: PlatformStatus, + signal: PlatformStatus, instances: Vec, } @@ -95,6 +99,13 @@ pub(super) struct InstanceCredentials { webhook_bind: Option, #[serde(default)] webhook_auth_token: Option, + // Signal credentials + #[serde(default)] + signal_http_url: Option, + #[serde(default)] + signal_account: Option, + #[serde(default)] + signal_dm_allowed_users: Option, } #[derive(Deserialize)] @@ -180,13 +191,127 @@ fn push_instance_status( }); } +/// Parse and validate Signal credentials from the request. +/// Returns (http_url, account, dm_allowed_users) on success, or an error response on failure. +fn parse_signal_credentials( + credentials: &InstanceCredentials, +) -> Result<(String, String, Option>), MessagingInstanceActionResponse> { + // Validate required fields + let http_url = match credentials + .signal_http_url + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + Some(url_str) => match reqwest::Url::parse(url_str) { + Ok(url) => { + // Validate URL scheme is http or https + let scheme = url.scheme(); + if scheme != "http" && scheme != "https" { + return Err(MessagingInstanceActionResponse { + success: false, + message: format!( + "signal: URL scheme must be 'http' or 'https', got '{}'", + scheme + ), + }); + } + url.to_string() + } + Err(e) => { + tracing::warn!(%e, url = %url_str, "signal: invalid http_url format"); + return Err(MessagingInstanceActionResponse { + success: false, + message: format!("signal: invalid http_url format: {}", e), + }); + } + }, + None => { + tracing::warn!("signal: http_url is required"); + return Err(MessagingInstanceActionResponse { + success: false, + message: "signal: http_url is required (e.g., http://127.0.0.1:8686)".to_string(), + }); + } + }; + + let account = match credentials + .signal_account + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { + Some(account) => account, + None => { + tracing::warn!("signal: account is required"); + return Err(MessagingInstanceActionResponse { + success: false, + message: "signal: account is required".to_string(), + }); + } + }; + + // Validate E.164 format + if !is_valid_e164(account) { + return Err(MessagingInstanceActionResponse { + success: false, + message: format!( + "Invalid Signal account format: '{}'. Must be E.164 format (+1234567890, 6-15 digits after '+', first digit cannot be 0)", + account + ), + }); + } + + // Parse and validate dm_allowed_users if provided + let dm_users = if let Some(dm_str) = credentials.signal_dm_allowed_users.as_ref() { + let entries: Vec = dm_str + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + + // Validate each entry is a valid Signal target format + let invalid_entries: Vec<&String> = entries + .iter() + .filter(|entry| { + // Valid formats: uuid:xxx, group:xxx, +E.164 + !(entry.starts_with("uuid:") || entry.starts_with("group:") || is_valid_e164(entry)) + }) + .collect(); + + if !invalid_entries.is_empty() { + let invalid_list: String = invalid_entries + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", "); + return Err(MessagingInstanceActionResponse { + success: false, + message: format!( + "Invalid DM allow-list entries: {}. Must be 'uuid:xxx', 'group:xxx', or E.164 phone number (+1234567890)", + invalid_list + ), + }); + } + + Some(entries) + } else { + None + }; + + Ok((http_url, account.to_string(), dm_users)) +} + /// Get which messaging platforms are configured and enabled. pub(super) async fn messaging_status( State(state): State>, ) -> Result, StatusCode> { let config_path = state.config_path.read().await.clone(); - let (discord, slack, telegram, email, webhook, twitch, instances) = if config_path.exists() { + let (discord, slack, telegram, email, webhook, twitch, signal, instances) = if config_path + .exists() + { let content = tokio::fs::read_to_string(&config_path) .await .map_err(|error| { @@ -510,6 +635,73 @@ pub(super) async fn messaging_status( enabled: false, }); + // Signal status and instances + let signal_status = doc + .get("messaging") + .and_then(|m| m.get("signal")) + .map(|s| { + let has_http_url = s + .get("http_url") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + let has_account = s + .get("account") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + let enabled = s.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false); + + if has_http_url && has_account { + push_instance_status(&mut instances, bindings, "signal", None, true, enabled); + } + + if let Some(named_instances) = s + .get("instances") + .and_then(|value| value.as_array_of_tables()) + { + for instance in named_instances { + let instance_name = normalize_adapter_selector( + instance.get("name").and_then(|value| value.as_str()), + ); + let instance_enabled = instance + .get("enabled") + .and_then(|value| value.as_bool()) + .unwrap_or(true) + && enabled; + let instance_has_http_url = instance + .get("http_url") + .and_then(|value| value.as_str()) + .is_some_and(|value| !value.is_empty()); + let instance_has_account = instance + .get("account") + .and_then(|value| value.as_str()) + .is_some_and(|value| !value.is_empty()); + + if let Some(instance_name) = instance_name + && instance_has_http_url + && instance_has_account + { + push_instance_status( + &mut instances, + bindings, + "signal", + Some(instance_name), + true, + instance_enabled, + ); + } + } + } + + PlatformStatus { + configured: has_http_url && has_account, + enabled: has_http_url && has_account && enabled, + } + }) + .unwrap_or(PlatformStatus { + configured: false, + enabled: false, + }); + ( discord_status, slack_status, @@ -517,6 +709,7 @@ pub(super) async fn messaging_status( email_status, webhook_status, twitch_status, + signal_status, instances, ) } else { @@ -530,7 +723,8 @@ pub(super) async fn messaging_status( default.clone(), default.clone(), default.clone(), - default, + default.clone(), + default.clone(), Vec::new(), ) }; @@ -542,6 +736,7 @@ pub(super) async fn messaging_status( email, webhook, twitch, + signal, instances, })) } @@ -1086,6 +1281,127 @@ pub(super) async fn toggle_platform( } } } + "signal" => { + if let Some(signal_config) = &new_config.messaging.signal { + match request.adapter.as_ref() { + None => { + // Toggle default adapter only + if !signal_config.http_url.is_empty() + && !signal_config.account.is_empty() + { + let permissions = { + let perms_guard = state.signal_permissions.read().await; + match perms_guard.as_ref() { + Some(existing) => { + // Update existing ArcSwap pointee + let perms = + crate::config::SignalPermissions::from_config( + signal_config, + ); + existing.store(std::sync::Arc::new(perms)); + existing.clone() + } + None => { + drop(perms_guard); + let perms = + crate::config::SignalPermissions::from_config( + signal_config, + ); + let arc_swap = std::sync::Arc::new( + arc_swap::ArcSwap::from_pointee(perms), + ); + state + .set_signal_permissions(arc_swap.clone()) + .await; + arc_swap + } + } + }; + let instance_dir = state.instance_dir.load(); + let tmp_dir = instance_dir.join("tmp"); + let adapter = crate::messaging::signal::SignalAdapter::new( + "signal", + &signal_config.http_url, + &signal_config.account, + signal_config.ignore_stories, + permissions, + tmp_dir, + ); + if let Err(error) = manager.register_and_start(adapter).await { + tracing::error!(%error, "failed to start signal adapter on toggle"); + } + } + + // Also start all enabled named instances + for instance in signal_config + .instances + .iter() + .filter(|instance| instance.enabled) + { + let runtime_key = crate::config::binding_runtime_adapter_key( + "signal", + Some(instance.name.as_str()), + ); + if manager.has_adapter(runtime_key.as_str()).await { + continue; + } + let permissions = + std::sync::Arc::new(arc_swap::ArcSwap::from_pointee( + crate::config::SignalPermissions::from_instance_config( + instance, + ), + )); + let instance_dir = state.instance_dir.load(); + let tmp_dir = instance_dir.join("tmp"); + let adapter = crate::messaging::signal::SignalAdapter::new( + runtime_key, + &instance.http_url, + &instance.account, + instance.ignore_stories, + permissions, + tmp_dir, + ); + if let Err(error) = manager.register_and_start(adapter).await { + tracing::error!(%error, adapter = %instance.name, "failed to start named signal adapter on toggle"); + } + } + } + Some(adapter_name) => { + // Toggle specific named instance only + let adapter_key = adapter_name.trim(); + if let Some(instance) = + signal_config.instances.iter().find(|instance| { + instance.name == adapter_key && instance.enabled + }) + { + let runtime_key = crate::config::binding_runtime_adapter_key( + "signal", + Some(instance.name.as_str()), + ); + let permissions = + std::sync::Arc::new(arc_swap::ArcSwap::from_pointee( + crate::config::SignalPermissions::from_instance_config( + instance, + ), + )); + let instance_dir = state.instance_dir.load(); + let tmp_dir = instance_dir.join("tmp"); + let adapter = crate::messaging::signal::SignalAdapter::new( + runtime_key, + &instance.http_url, + &instance.account, + instance.ignore_stories, + permissions, + tmp_dir, + ); + if let Err(error) = manager.register_and_start(adapter).await { + tracing::error!(%error, adapter = %instance.name, "failed to start named signal adapter on toggle"); + } + } + } + } + } + } _ => {} } } @@ -1128,7 +1444,7 @@ pub(super) async fn create_messaging_instance( if !matches!( platform.as_str(), - "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" + "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "signal" ) { return Ok(Json(MessagingInstanceActionResponse { success: false, @@ -1151,6 +1467,16 @@ pub(super) async fn create_messaging_instance( message: "instance name cannot contain ':' or spaces".to_string(), })); } + // Validate instance name format (rejects all-digits, Slack-style IDs, etc.) + if !crate::messaging::target::is_valid_instance_name(trimmed) { + return Ok(Json(MessagingInstanceActionResponse { + success: false, + message: format!( + "instance name '{}' is invalid. Names must: not be all digits, not match Slack workspace IDs (Txxxxx/Cxxxxx), be 1-20 characters, and contain only alphanumeric characters, underscores, or hyphens", + trimmed + ), + })); + } } let config_path = state.config_path.read().await.clone(); @@ -1269,6 +1595,32 @@ pub(super) async fn create_messaging_instance( platform_table["auth_token"] = toml_edit::value(token.as_str()); } } + "signal" => { + // Use helper function to parse and validate Signal credentials + let (http_url, account, dm_users) = match parse_signal_credentials(credentials) + { + Ok(result) => result, + Err(response) => return Ok(Json(response)), + }; + + // Store normalized URL (strip trailing slash) + platform_table["http_url"] = toml_edit::value(http_url.trim_end_matches('/')); + platform_table["account"] = toml_edit::value(account); + + // Store dm_allowed_users if provided, remove if empty/cleared + if let Some(dm_users) = dm_users + && !dm_users.is_empty() + { + let mut dm_array = toml_edit::Array::new(); + for user in dm_users { + dm_array.push(user); + } + platform_table["dm_allowed_users"] = toml_edit::value(dm_array); + } else { + // Remove the key if dm_users is empty or None (cleared by user) + platform_table.remove("dm_allowed_users"); + } + } _ => {} } platform_table["enabled"] = toml_edit::value(enabled); @@ -1378,6 +1730,32 @@ pub(super) async fn create_messaging_instance( instance_table["auth_token"] = toml_edit::value(token.as_str()); } } + "signal" => { + // Use helper function to parse and validate Signal credentials + let (http_url, account, dm_users) = match parse_signal_credentials(credentials) + { + Ok(result) => result, + Err(response) => return Ok(Json(response)), + }; + + // Store normalized URL (strip trailing slash) + instance_table["http_url"] = toml_edit::value(http_url.trim_end_matches('/')); + instance_table["account"] = toml_edit::value(account); + + // Store dm_allowed_users if provided, remove if empty/cleared + if let Some(dm_users) = dm_users + && !dm_users.is_empty() + { + let mut dm_array = toml_edit::Array::new(); + for user in dm_users { + dm_array.push(user); + } + instance_table["dm_allowed_users"] = toml_edit::value(dm_array); + } else { + // Remove the key if dm_users is empty or None (cleared by user) + instance_table.remove("dm_allowed_users"); + } + } _ => {} } @@ -1443,7 +1821,7 @@ pub(super) async fn delete_messaging_instance( if !matches!( platform.as_str(), - "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" + "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "signal" ) { return Ok(Json(MessagingInstanceActionResponse { success: false, @@ -1540,6 +1918,14 @@ pub(super) async fn delete_messaging_instance( table.remove("bind"); table.remove("auth_token"); } + "signal" => { + table.remove("http_url"); + table.remove("account"); + table.remove("dm_allowed_users"); + table.remove("group_ids"); + table.remove("group_allowed_users"); + table.remove("ignore_stories"); + } _ => {} } } @@ -1633,3 +2019,49 @@ pub(super) async fn delete_messaging_instance( message: format!("{runtime_key} instance deleted"), })) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_valid_e164_valid_numbers() { + // Valid E.164 numbers (6-15 digits after +, 7-16 total) + assert!(is_valid_e164("+1234567890")); + assert!(is_valid_e164("+123456")); // Minimum: 6 digits after + + assert!(is_valid_e164("+14155552671")); + assert!(is_valid_e164("+12345678901234")); // 14 digits after + + assert!(is_valid_e164("+123456789012345")); // Maximum: 15 digits after + + } + + #[test] + fn test_is_valid_e164_invalid_numbers() { + // Missing + + assert!(!is_valid_e164("1234567890")); + + // Too short (less than 6 digits after +) + assert!(!is_valid_e164("+12345")); + assert!(!is_valid_e164("+123")); + + // Empty or just + + assert!(!is_valid_e164("+")); + assert!(!is_valid_e164("")); + + // Non-digit characters + assert!(!is_valid_e164("+1234567890a")); + assert!(!is_valid_e164("+123-456-7890")); + assert!(!is_valid_e164("+123 456 7890")); + + // Spaces + assert!(!is_valid_e164(" +1234567890")); + assert!(!is_valid_e164("+1234567890 ")); + + // First digit is 0 (E.164 requires 1-9) + assert!(!is_valid_e164("+0123456")); + assert!(!is_valid_e164("+01234567890")); + + // Too long (more than 15 digits after +) + assert!(!is_valid_e164("+1234567890123456")); + assert!(!is_valid_e164("+12345678901234567")); + } +} diff --git a/src/api/state.rs b/src/api/state.rs index 9500617e1..55416c854 100644 --- a/src/api/state.rs +++ b/src/api/state.rs @@ -3,7 +3,9 @@ use crate::agent::channel::ChannelState; use crate::agent::cortex_chat::CortexChatSession; use crate::agent::status::StatusBlock; -use crate::config::{Binding, DefaultsConfig, DiscordPermissions, RuntimeConfig, SlackPermissions}; +use crate::config::{ + Binding, DefaultsConfig, DiscordPermissions, RuntimeConfig, SignalPermissions, SlackPermissions, +}; use crate::conversation::worker_transcript::{ActionContent, TranscriptStep}; use crate::cron::{CronStore, Scheduler}; use crate::llm::LlmManager; @@ -96,6 +98,8 @@ pub struct ApiState { pub discord_permissions: RwLock>>>, /// Shared reference to the Slack permissions ArcSwap (same instance used by the adapter and file watcher). pub slack_permissions: RwLock>>>, + /// Shared reference to the Signal permissions ArcSwap (same instance used by the adapter and file watcher). + pub signal_permissions: RwLock>>>, /// Shared reference to the bindings ArcSwap (same instance used by the main loop and file watcher). pub bindings: RwLock>>>>, /// Shared messaging manager for runtime adapter addition. @@ -316,6 +320,7 @@ impl ApiState { secrets_store: ArcSwap::from_pointee(None), discord_permissions: RwLock::new(None), slack_permissions: RwLock::new(None), + signal_permissions: RwLock::new(None), bindings: RwLock::new(None), messaging_manager: RwLock::new(None), provider_setup_tx, @@ -786,6 +791,11 @@ impl ApiState { *self.slack_permissions.write().await = Some(permissions); } + /// Share the Signal permissions ArcSwap with the API so reads get hot-reloaded values. + pub async fn set_signal_permissions(&self, permissions: Arc>) { + *self.signal_permissions.write().await = Some(permissions); + } + /// Share the bindings ArcSwap with the API so reads get hot-reloaded values. pub async fn set_bindings(&self, bindings: Arc>>) { *self.bindings.write().await = Some(bindings); diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index adf59101b..8d30e864c 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -873,10 +873,13 @@ async fn run_cron_job(job: &CronJob, context: &CronContext) -> Result<()> { }); // Send the cron job prompt as a synthetic message + // Use the delivery target's adapter as the source adapter so the channel + // correctly identifies as being "from" that messaging platform (e.g., signal:gvoice1). + // This ensures tools like reply and send_message resolve to the correct adapter instance. let message = InboundMessage { id: uuid::Uuid::new_v4().to_string(), source: "cron".into(), - adapter: None, + adapter: Some(job.delivery_target.adapter.clone()), conversation_id: format!("cron:{}", job.id), sender_id: "system".into(), agent_id: Some(context.deps.agent_id.clone()), diff --git a/src/main.rs b/src/main.rs index 0679c27f3..06643a4e1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3107,6 +3107,9 @@ async fn initialize_agents( let perms = spacebot::config::SignalPermissions::from_config(signal_config); Arc::new(ArcSwap::from_pointee(perms)) }); + if let Some(perms) = &*signal_permissions { + api_state.set_signal_permissions(perms.clone()).await; + } if let Some(signal_config) = &config.messaging.signal && signal_config.enabled diff --git a/src/messaging/signal.rs b/src/messaging/signal.rs index b9f16a0a6..ff4fe72b5 100644 --- a/src/messaging/signal.rs +++ b/src/messaging/signal.rs @@ -247,7 +247,7 @@ impl SignalAdapter { Self { runtime_key: runtime_key.into(), - http_url: http_url.into(), + http_url: http_url.into().trim_end_matches('/').to_string(), account: account.into(), ignore_stories, permissions, diff --git a/src/messaging/target.rs b/src/messaging/target.rs index 17676afab..dbae8fe58 100644 --- a/src/messaging/target.rs +++ b/src/messaging/target.rs @@ -16,7 +16,26 @@ impl std::fmt::Display for BroadcastTarget { } /// Parse and normalize a delivery target in `adapter:target` format. +/// +/// Signal targets may contain named instance prefixes (e.g., +/// `signal:gvoice1:uuid:xxx`). The generic `split_once(':')` approach +/// cannot distinguish the instance segment from the target, so we +/// delegate to `parse_signal_target_parts` which already handles this. pub fn parse_delivery_target(raw: &str) -> Option { + // Signal needs special handling because named instances add an extra + // colon-separated segment that the generic parser can't distinguish. + if raw.starts_with("signal:") { + let parts: Vec<&str> = raw.split(':').collect(); + return parse_signal_target_parts(parts.get(1..).unwrap_or(&[])); + } + + // Handle other platforms with named instances (telegram, discord, slack) + // Format: platform:: or platform: + if raw.starts_with("telegram:") || raw.starts_with("discord:") || raw.starts_with("slack:") { + let parts: Vec<&str> = raw.split(':').collect(); + return parse_named_instance_target(&parts); + } + let (adapter, raw_target) = raw.split_once(':')?; if adapter.is_empty() || raw_target.is_empty() { return None; @@ -258,6 +277,29 @@ fn normalize_email_target(raw_target: &str) -> Option { None } +/// Validate E.164 phone number format. +/// +/// Requirements: +/// - Must start with '+' +/// - First digit after '+' must be 1-9 (not 0) +/// - Minimum 7 digits total (6 after '+') +/// - Maximum 16 digits total (15 after '+', E.164 standard) +/// - All characters after '+' must be ASCII digits +pub fn is_valid_e164(phone: &str) -> bool { + if let Some(digits) = phone.strip_prefix('+') { + if digits.len() < 6 || digits.len() > 15 { + return false; + } + // First digit must be 1-9 (not 0) + if digits.chars().next().map(|c| c == '0').unwrap_or(true) { + return false; + } + digits.chars().all(|c| c.is_ascii_digit()) + } else { + false + } +} + fn normalize_signal_target(raw_target: &str) -> Option { let target = strip_repeated_prefix(raw_target, "signal"); @@ -277,18 +319,18 @@ fn normalize_signal_target(raw_target: &str) -> Option { return None; } - // Handle e164:+123 or bare +123 format + // Handle e164:+123 format if let Some(phone) = target.strip_prefix("e164:") { - let phone = phone.trim_start_matches('+'); - if !phone.is_empty() && phone.len() >= 7 && phone.chars().all(|c| c.is_ascii_digit()) { - return Some(format!("+{phone}")); + let normalized = format!("+{}", phone.trim_start_matches('+')); + if is_valid_e164(&normalized) { + return Some(normalized); } return None; } // Bare +123 format - if let Some(phone) = target.strip_prefix('+') { - if !phone.is_empty() && phone.len() >= 7 && phone.chars().all(|c| c.is_ascii_digit()) { + if target.starts_with('+') { + if is_valid_e164(target) { return Some(target.to_string()); } return None; @@ -299,9 +341,12 @@ fn normalize_signal_target(raw_target: &str) -> Option { return Some(format!("uuid:{target}")); } - // Check if it's a bare phone number (7+ digits required for E.164) - if target.chars().all(|c| c.is_ascii_digit()) && target.len() >= 7 { - return Some(format!("+{target}")); + // Check if it's a bare phone number (E.164 format) + if target.chars().all(|c| c.is_ascii_digit()) { + let with_plus = format!("+{target}"); + if is_valid_e164(&with_plus) { + return Some(with_plus); + } } None @@ -365,64 +410,162 @@ fn extract_signal_adapter_from_channel_id(channel_id: &str) -> String { pub fn parse_signal_target_parts(parts: &[&str]) -> Option { match parts { // Default adapter: signal:uuid:xxx, signal:group:xxx, signal:e164:+xxx, signal:+xxx - ["uuid", uuid] => Some(BroadcastTarget { + ["uuid", uuid] if !uuid.is_empty() => Some(BroadcastTarget { adapter: "signal".to_string(), target: format!("uuid:{uuid}"), }), - ["group", group_id] => Some(BroadcastTarget { + ["group", group_id] if !group_id.is_empty() => Some(BroadcastTarget { adapter: "signal".to_string(), target: format!("group:{group_id}"), }), // Use normalize_signal_target for phone/e164 to ensure consistent parsing - ["e164", phone] => { - normalize_signal_target(&format!("e164:{phone}")).map(|target| BroadcastTarget { + ["e164", phone] if !phone.is_empty() => normalize_signal_target(&format!("e164:{phone}")) + .map(|target| BroadcastTarget { adapter: "signal".to_string(), target, - }) - } - [phone] if phone.starts_with('+') => { - normalize_signal_target(phone).map(|target| BroadcastTarget { + }), + [phone] if phone.starts_with('+') && !phone.is_empty() => normalize_signal_target(phone) + .map(|target| BroadcastTarget { + adapter: "signal".to_string(), + target, + }), + // Single-part targets: delegate to normalize_signal_target for bare UUIDs/phones + [single] if !single.is_empty() => { + normalize_signal_target(single).map(|target| BroadcastTarget { adapter: "signal".to_string(), target, }) } - // Single-part targets: delegate to normalize_signal_target for bare UUIDs/phones - [single] => normalize_signal_target(single).map(|target| BroadcastTarget { - adapter: "signal".to_string(), - target, - }), // Named adapter: signal:instance:uuid:xxx, signal:instance:group:xxx - [instance, "uuid", uuid] => Some(BroadcastTarget { - adapter: format!("signal:{instance}"), - target: format!("uuid:{uuid}"), - }), - [instance, "group", group_id] => Some(BroadcastTarget { - adapter: format!("signal:{instance}"), - target: format!("group:{group_id}"), - }), + [instance, "uuid", uuid] if !instance.is_empty() && !uuid.is_empty() => { + Some(BroadcastTarget { + adapter: format!("signal:{instance}"), + target: format!("uuid:{uuid}"), + }) + } + [instance, "group", group_id] if !instance.is_empty() && !group_id.is_empty() => { + Some(BroadcastTarget { + adapter: format!("signal:{instance}"), + target: format!("group:{group_id}"), + }) + } // Named adapter: signal:instance:e164:+xxx - use normalize_signal_target - [instance, "e164", phone] => { + [instance, "e164", phone] if !instance.is_empty() && !phone.is_empty() => { normalize_signal_target(&format!("e164:{phone}")).map(|target| BroadcastTarget { adapter: format!("signal:{instance}"), target, }) } // Named adapter: signal:instance:+xxx - use normalize_signal_target - [instance, phone] if phone.starts_with('+') => { + [instance, phone] + if !instance.is_empty() && phone.starts_with('+') && !phone.is_empty() => + { normalize_signal_target(phone).map(|target| BroadcastTarget { adapter: format!("signal:{instance}"), target, }) } // Named adapter with single-part target: delegate to normalize_signal_target - [instance, single] => normalize_signal_target(single).map(|target| BroadcastTarget { - adapter: format!("signal:{instance}"), - target, + // Reject all-digit identifiers to avoid misinterpreting numeric IDs as phone numbers + [instance, single] + if !instance.is_empty() + && !single.is_empty() + && !single.chars().all(|c| c.is_ascii_digit()) => + { + normalize_signal_target(single).map(|target| BroadcastTarget { + adapter: format!("signal:{instance}"), + target, + }) + } + _ => None, + } +} + +/// Parse targets for platforms with named instance support (telegram, discord, slack). +/// +/// Handles formats: +/// - Default adapter: ["telegram", target], ["discord", target], ["slack", target] +/// - Legacy format: ["discord", guild_id, channel_id], ["slack", workspace_id, channel_id] +/// - Named adapter: ["telegram", instance, target], ["discord", instance, target], ["slack", instance, target] +/// +/// Returns None for invalid formats. +fn parse_named_instance_target(parts: &[&str]) -> Option { + if parts.len() < 2 { + return None; + } + + let platform = parts[0]; + let is_telegram = platform == "telegram"; + let is_discord = platform == "discord"; + let is_slack = platform == "slack"; + + if !is_telegram && !is_discord && !is_slack { + return None; + } + + // Reject any cases with empty parts + if parts.iter().any(|part| part.is_empty()) { + return None; + } + + match parts { + // Named adapter: platform:instance:target + // Heuristic: instance names are simple alphanumeric identifiers (not all digits like guild IDs) + // Reject "dm" as instance name for Discord/Slack (reserved for DM format) + [platform, instance, target, ..] + if parts.len() >= 3 + && !instance.is_empty() + && !target.is_empty() + && is_valid_instance_name(instance) + && !((*platform == "discord" || *platform == "slack") && *instance == "dm") => + { + // Reconstruct target from remaining parts (in case target contains colons) + let full_target = parts[2..].join(":"); + if full_target.is_empty() { + return None; + } + Some(BroadcastTarget { + adapter: format!("{}:{}", platform, instance), + target: full_target, + }) + } + // Legacy multi-part format: platform:workspace_id:target_id (use last part as target) + [platform, .., target] if parts.len() > 2 && !target.is_empty() => Some(BroadcastTarget { + adapter: (*platform).to_string(), + target: (*target).to_string(), + }), + // Default adapter: platform:target + [platform, target] if !target.is_empty() => Some(BroadcastTarget { + adapter: (*platform).to_string(), + target: (*target).to_string(), }), _ => None, } } +/// Check if a string looks like a valid instance name (not an ID). +/// Instance names are short identifiers, not numeric IDs or workspace identifiers. +pub fn is_valid_instance_name(name: &str) -> bool { + // Must not be all digits (Discord guild ID, etc.) + if name.chars().all(|c| c.is_ascii_digit()) { + return false; + } + // Must not look like a Slack workspace ID (Txxxxx, Cxxxxx, etc.) + if name.len() > 6 + && name.starts_with(|c: char| c.is_ascii_uppercase()) + && name[1..].chars().all(|c| c.is_ascii_digit()) + { + return false; + } + // Must be reasonably short (instance names are short, not long IDs) + if name.len() > 20 { + return false; + } + // Must contain only alphanumeric characters, underscores, and hyphens + name.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + #[cfg(test)] mod tests { use super::{parse_delivery_target, resolve_broadcast_target}; @@ -703,4 +846,140 @@ mod tests { assert!(super::parse_signal_target_parts(&["uuid"]).is_none()); // missing UUID value assert!(super::parse_signal_target_parts(&["gvoice1", "unknown"]).is_none()); } + + // Tests for parse_named_instance_target + #[test] + fn parse_named_instance_target_telegram() { + let parsed = super::parse_named_instance_target(&["telegram", "mybot", "12345"]); + assert_eq!( + parsed, + Some(super::BroadcastTarget { + adapter: "telegram:mybot".to_string(), + target: "12345".to_string(), + }) + ); + } + + #[test] + fn parse_named_instance_target_discord_named() { + // Named instance: discord:myinstance:channel_id + let parsed = super::parse_named_instance_target(&["discord", "myinstance", "987654321"]); + assert_eq!( + parsed, + Some(super::BroadcastTarget { + adapter: "discord:myinstance".to_string(), + target: "987654321".to_string(), + }) + ); + } + + #[test] + fn parse_named_instance_target_discord_legacy() { + // Legacy format: discord:guild_id:channel_id (all digits = not a named instance) + let parsed = super::parse_named_instance_target(&["discord", "123456789", "987654321"]); + assert_eq!( + parsed, + Some(super::BroadcastTarget { + adapter: "discord".to_string(), + target: "987654321".to_string(), + }) + ); + } + + #[test] + fn parse_named_instance_target_slack_named() { + // Named instance: slack:work:channel_id + let parsed = super::parse_named_instance_target(&["slack", "work", "C012345"]); + assert_eq!( + parsed, + Some(super::BroadcastTarget { + adapter: "slack:work".to_string(), + target: "C012345".to_string(), + }) + ); + } + + #[test] + fn parse_named_instance_target_slack_legacy() { + // Legacy format: slack:workspace_id:channel_id (workspace_id pattern = not named) + let parsed = super::parse_named_instance_target(&["slack", "T012345", "C012345"]); + assert_eq!( + parsed, + Some(super::BroadcastTarget { + adapter: "slack".to_string(), + target: "C012345".to_string(), + }) + ); + } + + #[test] + fn parse_named_instance_target_default_adapter() { + // Default adapter (2 parts): telegram:target + let parsed = super::parse_named_instance_target(&["telegram", "12345"]); + assert_eq!( + parsed, + Some(super::BroadcastTarget { + adapter: "telegram".to_string(), + target: "12345".to_string(), + }) + ); + } + + #[test] + fn parse_named_instance_target_invalid() { + // Too few parts + assert!(super::parse_named_instance_target(&["telegram"]).is_none()); + // Empty instance name + assert!(super::parse_named_instance_target(&["telegram", "", "12345"]).is_none()); + // Empty target + assert!(super::parse_named_instance_target(&["telegram", "work", ""]).is_none()); + // Unsupported platform + assert!(super::parse_named_instance_target(&["unknown", "work", "12345"]).is_none()); + } + + // Tests for is_valid_instance_name + #[test] + fn is_valid_instance_name_valid() { + assert!(super::is_valid_instance_name("work")); + assert!(super::is_valid_instance_name("my_bot")); + assert!(super::is_valid_instance_name("my-bot")); + assert!(super::is_valid_instance_name("instance123")); + assert!(super::is_valid_instance_name("a")); + } + + #[test] + fn is_valid_instance_name_invalid_all_digits() { + // All digits = numeric ID, not an instance name + assert!(!super::is_valid_instance_name("12345")); + assert!(!super::is_valid_instance_name("123456789")); + } + + #[test] + fn is_valid_instance_name_invalid_slack_workspace() { + // Slack workspace ID pattern + assert!(!super::is_valid_instance_name("T012345")); + assert!(!super::is_valid_instance_name("C012345")); + } + + #[test] + fn is_valid_instance_name_invalid_length() { + // Too long (>20 chars) + assert!(!super::is_valid_instance_name( + "this_is_a_very_long_instance_name" + )); + } + + #[test] + fn is_valid_instance_name_invalid_empty() { + // Empty string + assert!(!super::is_valid_instance_name("")); + } + + #[test] + fn is_valid_instance_name_edge_cases() { + // Exactly 20 characters (boundary) + assert!(super::is_valid_instance_name("exactly_twenty_chars")); + // 21 characters (over boundary) + assert!(!super::is_valid_instance_name("exactly_twenty_chars_")); + } } diff --git a/src/prompts/engine.rs b/src/prompts/engine.rs index e706b59d1..13f11b5bb 100644 --- a/src/prompts/engine.rs +++ b/src/prompts/engine.rs @@ -228,6 +228,7 @@ impl PromptEngine { platform: &str, server_name: Option<&str>, channel_name: Option<&str>, + conversation_id: Option<&str>, ) -> Result { self.render( "fragments/conversation_context", @@ -235,6 +236,7 @@ impl PromptEngine { platform => platform, server_name => server_name, channel_name => channel_name, + conversation_id => conversation_id, }, ) } diff --git a/src/tools.rs b/src/tools.rs index 677bd3e85..10943d7af 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -432,9 +432,12 @@ pub async fn add_channel_tools( .await?; handle.add_tool(ReactTool::new(response_tx.clone())).await?; if let Some(cron_tool) = cron_tool { - let cron_tool = cron_tool.with_default_delivery_target( - default_delivery_target_for_conversation(&conversation_id, slack_thread_ts), - ); + let cron_tool = cron_tool + .with_default_delivery_target(default_delivery_target_for_conversation( + &conversation_id, + slack_thread_ts, + )) + .with_current_adapter(current_adapter.clone()); handle.add_tool(cron_tool).await?; } if let Some(mut agent_msg) = send_agent_message_tool { diff --git a/src/tools/cron.rs b/src/tools/cron.rs index 6a1ffddfe..080a7d33d 100644 --- a/src/tools/cron.rs +++ b/src/tools/cron.rs @@ -21,6 +21,7 @@ pub struct CronTool { store: Arc, scheduler: Arc, default_delivery_target: Option, + current_adapter: Option, } impl CronTool { @@ -29,6 +30,7 @@ impl CronTool { store, scheduler, default_delivery_target: None, + current_adapter: None, } } @@ -36,6 +38,11 @@ impl CronTool { self.default_delivery_target = default_delivery_target; self } + + pub fn with_current_adapter(mut self, current_adapter: Option) -> Self { + self.current_adapter = current_adapter; + self + } } #[derive(Debug, thiserror::Error)] @@ -208,6 +215,9 @@ impl CronTool { ) })?; + // Normalize delivery target for named instances + let delivery_target = normalize_delivery_target(&delivery_target, &self.current_adapter); + // Validate cron job ID: alphanumeric, hyphens, underscores only if id.is_empty() || id.len() > 50 @@ -411,3 +421,130 @@ fn format_interval(secs: u64) -> String { format!("every {secs} seconds") } } + +/// Normalize delivery target for named instances. +/// +/// If the LLM provided a bare platform adapter (e.g., "signal", "slack") but we're in +/// a named instance conversation (e.g., "signal:gvoice1", "slack:work"), rewrite to +/// include the instance name. This ensures the cron job can find the correct adapter +/// at runtime. +fn normalize_delivery_target(delivery_target: &str, current_adapter: &Option) -> String { + if let Some(parsed) = crate::messaging::target::parse_delivery_target(delivery_target) + && let Some(current_adapter) = current_adapter.as_ref() + { + let expected_prefix = format!("{}:", parsed.adapter); + if current_adapter.starts_with(&expected_prefix) { + // current_adapter is a named instance of the parsed platform + return format!("{current_adapter}:{}", parsed.target); + } + } + delivery_target.to_string() +} + +#[cfg(test)] +mod tests { + use super::normalize_delivery_target; + + #[test] + fn test_normalize_signal_named_instance() { + // Bare signal + named instance context → rewrite + let result = normalize_delivery_target( + "signal:uuid:550e8400-e29b-41d4-a716-446655440000", + &Some("signal:gvoice1".to_string()), + ); + assert_eq!( + result, + "signal:gvoice1:uuid:550e8400-e29b-41d4-a716-446655440000" + ); + } + + #[test] + fn test_normalize_signal_group_named_instance() { + // Signal group + named instance context → rewrite + let result = + normalize_delivery_target("signal:group:grp123", &Some("signal:work".to_string())); + assert_eq!(result, "signal:work:group:grp123"); + } + + #[test] + fn test_normalize_signal_default_instance_no_rewrite() { + // Bare signal + default signal context → no change + let result = normalize_delivery_target( + "signal:uuid:550e8400-e29b-41d4-a716-446655440000", + &Some("signal".to_string()), + ); + assert_eq!(result, "signal:uuid:550e8400-e29b-41d4-a716-446655440000"); + } + + #[test] + fn test_normalize_signal_no_context_no_rewrite() { + // Bare signal + no context → no change + let result = + normalize_delivery_target("signal:uuid:550e8400-e29b-41d4-a716-446655440000", &None); + assert_eq!(result, "signal:uuid:550e8400-e29b-41d4-a716-446655440000"); + } + + #[test] + fn test_normalize_slack_named_instance() { + // Bare slack + named instance context → rewrite + let result = normalize_delivery_target("slack:C123456", &Some("slack:work".to_string())); + assert_eq!(result, "slack:work:C123456"); + } + + #[test] + fn test_normalize_discord_named_instance() { + // Bare discord + named instance context → rewrite + let result = + normalize_delivery_target("discord:987654321", &Some("discord:personal".to_string())); + assert_eq!(result, "discord:personal:987654321"); + } + + #[test] + fn test_normalize_telegram_named_instance() { + // Bare telegram + named instance context → rewrite + let result = + normalize_delivery_target("telegram:-1001234", &Some("telegram:bot1".to_string())); + assert_eq!(result, "telegram:bot1:-1001234"); + } + + #[test] + fn test_normalize_different_platform_no_rewrite() { + // Signal target but in Discord context → no change + let result = normalize_delivery_target( + "signal:uuid:550e8400-e29b-41d4-a716-446655440000", + &Some("discord:general".to_string()), + ); + assert_eq!(result, "signal:uuid:550e8400-e29b-41d4-a716-446655440000"); + } + + #[test] + fn test_normalize_cron_context_no_rewrite() { + // Any target in cron context → no change (cron can't receive delivery) + let result = normalize_delivery_target( + "signal:uuid:550e8400-e29b-41d4-a716-446655440000", + &Some("cron".to_string()), + ); + assert_eq!(result, "signal:uuid:550e8400-e29b-41d4-a716-446655440000"); + } + + #[test] + fn test_normalize_already_qualified_no_rewrite() { + // Already qualified with instance name → no change + let result = normalize_delivery_target( + "signal:gvoice1:uuid:550e8400-e29b-41d4-a716-446655440000", + &Some("signal:gvoice1".to_string()), + ); + assert_eq!( + result, + "signal:gvoice1:uuid:550e8400-e29b-41d4-a716-446655440000" + ); + } + + #[test] + fn test_normalize_email_no_rewrite() { + // Email doesn't use named instances → no change + let result = + normalize_delivery_target("email:alice@example.com", &Some("email".to_string())); + assert_eq!(result, "email:alice@example.com"); + } +} diff --git a/src/tools/send_message_to_another_channel.rs b/src/tools/send_message_to_another_channel.rs index b2232f637..f68660068 100644 --- a/src/tools/send_message_to_another_channel.rs +++ b/src/tools/send_message_to_another_channel.rs @@ -88,12 +88,8 @@ impl Tool for SendMessageTool { async fn definition(&self, _prompt: String) -> ToolDefinition { let email_adapter_available = self.messaging_manager.has_adapter("email").await; - // Check if current adapter is Signal (e.g., "signal:gvoice1" starts with "signal") - let signal_adapter_available = self - .current_adapter - .as_ref() - .map(|adapter| adapter.starts_with("signal")) - .unwrap_or(false); + // Check if any Signal adapter is registered (works in any context, including cron) + let signal_adapter_available = self.messaging_manager.has_platform_adapters("signal").await; let mut description = crate::prompts::text::get("tools/send_message_to_another_channel").to_string(); @@ -109,12 +105,62 @@ impl Tool for SendMessageTool { } if signal_adapter_available { - description.push_str( - " Signal messaging is enabled: you can target `signal:uuid:{uuid}`, `signal:group:{group_id}`, or `signal:+{phone}`.", - ); - target_description.push_str( - " With Signal enabled, explicit targets are also allowed: `signal:uuid:{uuid}`, `signal:group:{group_id}`, `signal:+{phone}`", - ); + // Get actual Signal adapter names to determine if default or named-only + let adapter_names = self.messaging_manager.adapter_names().await; + let signal_adapters: Vec = adapter_names + .into_iter() + .filter(|name| name == "signal" || name.starts_with("signal:")) + .collect(); + let has_default_signal = signal_adapters.iter().any(|name| name == "signal"); + let named_adapters: Vec = signal_adapters + .into_iter() + .filter(|name| name.starts_with("signal:")) + .collect(); + + if has_default_signal { + // Default adapter exists - show generic syntax + description.push_str( + " Signal messaging is enabled: you can target `signal:uuid:{uuid}`, `signal:group:{group_id}`, or `signal:+{phone}`.", + ); + target_description.push_str( + " With Signal enabled, explicit targets are also allowed: `signal:uuid:{uuid}`, `signal:group:{group_id}`, `signal:+{phone}`", + ); + + // Also mention named instances if they exist + if !named_adapters.is_empty() { + let named_examples: Vec = named_adapters + .iter() + .take(2) + .map(|adapter| format!("`{}:+{{phone}}`", adapter)) + .collect(); + description.push_str(&format!( + " Named instances are also available: {}.", + named_examples.join(", ") + )); + target_description + .push_str(&format!(" Named instances: {}", named_examples.join(", "))); + } + } else { + // Only named adapters - show specific instance names + let instance_examples: Vec = named_adapters + .iter() + .take(3) + .map(|adapter| format!("`{}:+{{phone}}`", adapter)) + .collect(); + + if !instance_examples.is_empty() { + description.push_str(&format!( + " Signal messaging is enabled with named instances: target using `{}:{{instance_name}}:{{target}}` format (e.g., {}).", + "signal", + instance_examples.join(", ") + )); + target_description.push_str(&format!( + " With Signal enabled, use named instance format: `{}:{{instance_name}}:{{target}}` (e.g., {})", + "signal", + instance_examples.join(", ") + )); + } + } } ToolDefinition { @@ -147,16 +193,51 @@ impl Tool for SendMessageTool { // Check for explicit signal: prefix first - always honored regardless of current adapter. // This allows users to explicitly target Signal even when in Discord/Telegram/etc. if let Some(mut target) = parse_explicit_signal_prefix(&args.target) { - // If explicit prefix returned default "signal" adapter but we're in a named - // Signal adapter conversation (e.g., signal:gvoice1), use the current adapter - // to ensure the message goes through the correct account. - if target.adapter == "signal" - && let Some(current_adapter) = self + // If explicit prefix returned default "signal" adapter, try to resolve + // to a specific named instance for correct routing. + if target.adapter == "signal" { + if let Some(current_adapter) = self .current_adapter .as_ref() .filter(|adapter| adapter.starts_with("signal:")) - { - target.adapter = current_adapter.clone(); + { + // In a named Signal conversation — use that instance + target.adapter = current_adapter.clone(); + } else { + // Not in a Signal conversation (e.g., cron context) — look up + // registered Signal adapters and resolve appropriately. + let all_signal_adapters: Vec = self + .messaging_manager + .adapter_names() + .await + .into_iter() + .filter(|name| name == "signal" || name.starts_with("signal:")) + .collect(); + + // Check if default "signal" adapter exists + let has_default_signal = + all_signal_adapters.iter().any(|name| name == "signal"); + // Get only named adapters (exclude default "signal") + let named_adapters: Vec = all_signal_adapters + .into_iter() + .filter(|name| name.starts_with("signal:")) + .collect(); + + if has_default_signal { + // Default "signal" exists - let broadcast() use it for exact lookup + // Don't change target.adapter, leave as "signal" + } else if named_adapters.len() == 1 { + // No default, but exactly one named adapter - use it + target.adapter = named_adapters.into_iter().next().unwrap(); + } else if named_adapters.len() > 1 { + // Multiple named adapters and no default - ambiguity error + return Err(SendMessageError(format!( + "Multiple Signal adapters are configured ({}). Please specify which instance to use by targeting 'signal::' instead of 'signal:'.", + named_adapters.join(", ") + ))); + } + // If 0 adapters, leave as "signal" and let broadcast() fail with appropriate error + } } self.messaging_manager @@ -187,7 +268,7 @@ impl Tool for SendMessageTool { if let Some(current_adapter) = self .current_adapter .as_ref() - .filter(|adapter| adapter.starts_with("signal")) + .filter(|adapter| *adapter == "signal" || adapter.starts_with("signal:")) { match parse_implicit_signal_shorthand(&args.target, current_adapter) { Ok(Some(target)) => { @@ -369,28 +450,44 @@ fn parse_implicit_signal_shorthand( )); } - // Phone number format: starts with + followed by 7+ digits - if trimmed.starts_with('+') - && trimmed[1..].len() >= 7 - && trimmed[1..].chars().all(|c| c.is_ascii_digit()) - { - return Ok(Some(BroadcastTarget { - adapter: current_adapter.to_string(), - target: trimmed.to_string(), - })); - } - - // Starts with + but doesn't meet phone number requirements. - if let Some(digits) = trimmed.strip_prefix('+') { - if digits.chars().all(|c| c.is_ascii_digit()) { - return Err(format!( - "'{trimmed}' looks like a phone number but is too short. Phone numbers need at least 7 digits after the + prefix." - )); + // Phone number format: use strict E.164 validation + if trimmed.starts_with('+') { + if crate::messaging::target::is_valid_e164(trimmed) { + return Ok(Some(BroadcastTarget { + adapter: current_adapter.to_string(), + target: trimmed.to_string(), + })); } - if !digits.is_empty() { - return Err(format!( - "'{trimmed}' looks like a phone number but contains non-digit characters. Use format: +1234567890" - )); + // Invalid phone number - provide specific error + if let Some(digits) = trimmed.strip_prefix('+') { + if digits.is_empty() { + return Err( + "Phone number cannot be empty after + prefix. Use format: +1234567890" + .to_string(), + ); + } + if !digits.chars().all(|c| c.is_ascii_digit()) { + return Err(format!( + "'{trimmed}' contains non-digit characters. Use format: +1234567890" + )); + } + if digits.len() < 6 { + return Err(format!( + "'{trimmed}' is too short. Phone numbers need 6-15 digits after the + prefix (7-16 total)." + )); + } + if digits.len() > 15 { + return Err(format!( + "'{trimmed}' is too long. Phone numbers need 6-15 digits after the + prefix (7-16 total)." + )); + } + if digits.starts_with('0') { + return Err(format!( + "'{trimmed}' has invalid country code. Country codes cannot start with 0." + )); + } + // Catch-all for any other '+' prefixed input that failed validation + return Err(format!("'{trimmed}' is not a valid E.164 phone number")); } } diff --git a/tests/context_dump.rs b/tests/context_dump.rs index 4fad60d8f..2cc80618b 100644 --- a/tests/context_dump.rs +++ b/tests/context_dump.rs @@ -186,7 +186,7 @@ fn build_channel_system_prompt(rc: &spacebot::config::RuntimeConfig) -> String { .expect("failed to render worker capabilities"); let conversation_context = prompt_engine - .render_conversation_context("discord", Some("Test Server"), Some("#general")) + .render_conversation_context("discord", Some("Test Server"), Some("#general"), None) .ok(); let empty_to_none = |s: String| if s.is_empty() { None } else { Some(s) };