> = {
telegram: "https://docs.spacebot.sh/telegram-setup",
twitch: "https://docs.spacebot.sh/twitch-setup",
mattermost: "https://docs.spacebot.sh/mattermost-setup",
+ signal: "https://docs.spacebot.sh/signal-setup",
};
// --- Platform Catalog (Left Column) ---
@@ -62,6 +65,7 @@ export function PlatformCatalog({onAddInstance}: PlatformCatalogProps) {
"email",
"webhook",
"mattermost",
+ "signal",
];
const COMING_SOON = [
@@ -650,6 +654,37 @@ export function AddInstanceCard({platform, isDefault, onCancel, onCreated}: AddI
}
credentials.mattermost_base_url = credentialInputs.mattermost_base_url.trim();
credentials.mattermost_token = credentialInputs.mattermost_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;
+ // Normalize: always omit when blank (empty or undefined) for consistent empty-state behavior
+ if (credentialInputs.signal_dm_allowed_users?.trim()) {
+ const result = validateSignalDmAllowedUsers(credentialInputs.signal_dm_allowed_users);
+ if (!result.valid) {
+ setMessage({text: result.error, type: "error"});
+ return;
+ }
+ if (result.entries.length > 0) {
+ credentials.signal_dm_allowed_users = result.entries.join(',');
+ }
+ }
}
if (!isDefault && !instanceName.trim()) {
@@ -964,6 +999,50 @@ export function AddInstanceCard({platform, isDefault, onCancel, onCreated}: AddI
>
)}
+ {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(); }}
+ />
+
+ Allowed DM senders: E.164 phone numbers (+1234567890) or uuid:xxx identifiers. Comma-separated. If empty, DMs are blocked.
+
+
+ >
+ )}
+
{docLink && (
Need help?{" "}
diff --git a/interface/src/lib/format.ts b/interface/src/lib/format.ts
index a0dd54075..81745fa3b 100644
--- a/interface/src/lib/format.ts
+++ b/interface/src/lib/format.ts
@@ -57,3 +57,57 @@ 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 6-15 digits after '+', with the first digit 1-9 (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 };
+}
+
+/**
+ * Validate Signal DM allowed-users entries.
+ * Each entry must be E.164 phone or uuid:xxx.
+ */
+export function validateSignalDmAllowedUsers(
+ raw: string
+): { valid: true; entries: string[] } | { valid: false; error: string } {
+ const entries = raw.split(',').map(s => s.trim()).filter(s => s.length > 0);
+ const invalid: string[] = [];
+ const valid: string[] = [];
+
+ for (const entry of entries) {
+ if (
+ isValidE164(entry) ||
+ (entry.startsWith('uuid:') && entry.length > 5)
+ ) {
+ valid.push(entry);
+ } else {
+ invalid.push(entry);
+ }
+ }
+
+ if (invalid.length > 0) {
+ return {
+ valid: false,
+ error: `Invalid entries: ${invalid.join(', ')}. Must be E.164 phone numbers (+1234567890) or uuid:xxx`,
+ };
+ }
+
+ return { valid: true, entries: valid };
+}
diff --git a/interface/src/lib/platformIcons.tsx b/interface/src/lib/platformIcons.tsx
index 1193986b4..48c57f7a8 100644
--- a/interface/src/lib/platformIcons.tsx
+++ b/interface/src/lib/platformIcons.tsx
@@ -18,6 +18,7 @@ export function PlatformIcon({ platform, className = "text-ink-faint", size = "1
email: faEnvelope,
mattermost: faServer,
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 46a230399..b7e834190 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" | "mattermost";
+type Platform = "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "mattermost" | "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..082446e12 100644
--- a/prompts/en/fragments/conversation_context.md.j2
+++ b/prompts/en/fragments/conversation_context.md.j2
@@ -3,6 +3,8 @@ Platform: {{ platform }}
Server: {{ server_name }}
{%- endif %}
{%- if channel_name %}
-Channel: #{{ channel_name }}
+Channel: {{ channel_name }} ({{ platform }}{% if conversation_id %}, id: `{{ conversation_id }}`{% endif %})
+{%- elif conversation_id %}
+Channel ID: `{{ conversation_id }}`
{%- 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 e1c5a4e5e..b153c2457 100644
--- a/src/agent/channel.rs
+++ b/src/agent/channel.rs
@@ -1257,6 +1257,7 @@ impl Channel {
&first.source,
server_name,
channel_name,
+ self.conversation_id.as_deref(),
)?);
}
@@ -1736,6 +1737,7 @@ impl Channel {
&message.source,
server_name,
channel_name,
+ self.conversation_id.as_deref(),
)?);
}
diff --git a/src/api/agents.rs b/src/api/agents.rs
index 9ae1060da..f1d1cbfdb 100644
--- a/src/api/agents.rs
+++ b/src/api/agents.rs
@@ -528,6 +528,18 @@ pub async fn create_agent_internal(
// Acquire the config write mutex to prevent concurrent read-modify-write races.
let _config_guard = state.config_write_mutex.lock().await;
+ // Fail early if messaging manager is unavailable — before any config write,
+ // directory creation, or database init that would leave a half-created agent.
+ let messaging_manager = {
+ let guard = state.messaging_manager.read().await;
+ guard
+ .as_ref()
+ .cloned()
+ .ok_or_else(|| {
+ "Messaging manager not initialized. Please ensure messaging adapters are configured before creating agents.".to_string()
+ })?
+ };
+
let content = if config_path.exists() {
tokio::fs::read_to_string(&config_path)
.await
@@ -550,6 +562,19 @@ pub async fn create_agent_internal(
.as_array_of_tables_mut()
.ok_or_else(|| "agents is not an array of tables in config.toml".to_string())?;
+ // Revalidate uniqueness under the lock — another request may have written the
+ // same agent_id to config.toml between our first check and mutex acquisition.
+ // Check against the parsed TOML document (agents_array) rather than the stale
+ // in-memory cache to ensure we catch concurrent writes.
+ if agents_array.iter().any(|t| {
+ t.get("id")
+ .and_then(|v| v.as_str())
+ .map(|id| id == agent_id)
+ .unwrap_or(false)
+ }) {
+ return Err(format!("Agent '{agent_id}' already exists"));
+ }
+
let mut new_table = toml_edit::Table::new();
new_table["id"] = toml_edit::value(&agent_id);
if let Some(display_name) = &request.display_name
@@ -786,10 +811,7 @@ pub async fn create_agent_internal(
event_tx: event_tx.clone(),
memory_event_tx: memory_event_tx.clone(),
sqlite_pool: db.sqlite.clone(),
- messaging_manager: {
- let guard = state.messaging_manager.read().await;
- guard.as_ref().cloned()
- },
+ messaging_manager: Some(messaging_manager.clone()),
sandbox: sandbox.clone(),
links: Arc::new(arc_swap::ArcSwap::from_pointee(
(**state.agent_links.load()).clone(),
@@ -841,19 +863,14 @@ pub async fn create_agent_internal(
deps: deps.clone(),
screenshot_dir: agent_config.screenshot_dir(),
logs_dir: agent_config.logs_dir(),
- messaging_manager: {
- let guard = state.messaging_manager.read().await;
- guard
- .as_ref()
- .cloned()
- .unwrap_or_else(|| std::sync::Arc::new(crate::messaging::MessagingManager::new()))
- },
+ messaging_manager: messaging_manager.clone(),
store: cron_store.clone(),
};
let scheduler = std::sync::Arc::new(crate::cron::Scheduler::new(cron_context));
runtime_config.set_cron(cron_store.clone(), scheduler.clone());
- let cron_tool = crate::tools::CronTool::new(cron_store.clone(), scheduler.clone());
+ let cron_tool =
+ crate::tools::CronTool::new(cron_store.clone(), scheduler.clone(), messaging_manager);
let browser_config = (**runtime_config.browser_config.load()).clone();
let brave_search_key = (**runtime_config.brave_search_key.load()).clone();
diff --git a/src/api/channels.rs b/src/api/channels.rs
index 0469a546c..cccc7a545 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 a83154b9e..e0e5b26f7 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,8 @@ pub(super) struct MessagingStatusResponse {
email: PlatformStatus,
webhook: PlatformStatus,
twitch: PlatformStatus,
+ mattermost: PlatformStatus,
+ signal: PlatformStatus,
instances: Vec,
}
@@ -100,6 +105,13 @@ pub(super) struct InstanceCredentials {
mattermost_base_url: Option,
#[serde(default)]
mattermost_token: Option,
+ // Signal credentials
+ #[serde(default)]
+ signal_http_url: Option,
+ #[serde(default)]
+ signal_account: Option,
+ #[serde(default)]
+ signal_dm_allowed_users: Option,
}
#[derive(Deserialize)]
@@ -185,414 +197,698 @@ fn push_instance_status(
});
}
+/// Merge incoming Signal credentials with existing TOML values for patch-style updates.
+/// Fields omitted from the request are filled from the existing platform table,
+/// so callers can update individual fields without resubmitting every credential.
+///
+/// **Note on `signal_dm_allowed_users`:** This field intentionally does NOT fall back
+/// to existing TOML values. It uses tri-state semantics:
+/// - `None` → caller handles as "preserve existing" (do nothing)
+/// - `Some("")` → clear the allow-list
+/// - `Some(entries)` → set the allow-list
+///
+/// The caller (`create_messaging_instance`) must interpret the `None` case correctly.
+fn merge_signal_credentials_with_existing(
+ credentials: &InstanceCredentials,
+ existing: &toml_edit::Table,
+) -> InstanceCredentials {
+ InstanceCredentials {
+ signal_http_url: credentials.signal_http_url.clone().or_else(|| {
+ existing
+ .get("http_url")
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string())
+ }),
+ signal_account: credentials.signal_account.clone().or_else(|| {
+ existing
+ .get("account")
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string())
+ }),
+ signal_dm_allowed_users: credentials.signal_dm_allowed_users.clone(),
+ ..Default::default()
+ }
+}
+
+/// 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() normalizes the URL (lowercase host, percent-encoding).
+ // The stored value may differ from user input (e.g. HTTP://LOCALHOST → http://localhost).
+ // SignalAdapter::new() additionally strips trailing slashes at runtime.
+ url.to_string()
+ }
+ Err(e) => {
+ tracing::warn!(%e, "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 (non-empty), +E.164
+ let is_uuid = entry.starts_with("uuid:") && entry.len() > 5;
+ !(is_uuid || 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' 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 content = tokio::fs::read_to_string(&config_path)
- .await
- .map_err(|error| {
- tracing::warn!(%error, "failed to read config.toml for messaging status");
+ let (discord, slack, telegram, email, webhook, twitch, mattermost, signal, instances) =
+ if config_path.exists() {
+ let content = tokio::fs::read_to_string(&config_path)
+ .await
+ .map_err(|error| {
+ tracing::warn!(%error, "failed to read config.toml for messaging status");
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
+ let doc: toml_edit::DocumentMut = content.parse().map_err(|error| {
+ tracing::warn!(%error, "failed to parse config.toml for messaging status");
StatusCode::INTERNAL_SERVER_ERROR
})?;
- let doc: toml_edit::DocumentMut = content.parse().map_err(|error| {
- tracing::warn!(%error, "failed to parse config.toml for messaging status");
- StatusCode::INTERNAL_SERVER_ERROR
- })?;
-
- let mut instances: Vec = Vec::new();
- let bindings = doc
- .get("bindings")
- .and_then(|value| value.as_array_of_tables());
-
- let discord_status = doc
- .get("messaging")
- .and_then(|m| m.get("discord"))
- .map(|d| {
- let has_token = d
- .get("token")
- .and_then(|v| v.as_str())
- .is_some_and(|s| !s.is_empty());
- let enabled = d.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
-
- if has_token {
- push_instance_status(&mut instances, bindings, "discord", None, true, enabled);
- }
- if let Some(named_instances) = d
- .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 mut instances: Vec = Vec::new();
+ let bindings = doc
+ .get("bindings")
+ .and_then(|value| value.as_array_of_tables());
+
+ let discord_status = doc
+ .get("messaging")
+ .and_then(|m| m.get("discord"))
+ .map(|d| {
+ let has_token = d
+ .get("token")
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| !s.is_empty());
+ let enabled = d.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
+
+ if has_token {
+ push_instance_status(
+ &mut instances,
+ bindings,
+ "discord",
+ None,
+ true,
+ enabled,
);
- let instance_enabled = instance
- .get("enabled")
- .and_then(|value| value.as_bool())
- .unwrap_or(true)
- && enabled;
- let instance_configured = instance
- .get("token")
- .and_then(|value| value.as_str())
- .is_some_and(|token| !token.is_empty());
-
- if let Some(instance_name) = instance_name
- && instance_configured
- {
- push_instance_status(
- &mut instances,
- bindings,
- "discord",
- Some(instance_name),
- true,
- instance_enabled,
+ }
+
+ if let Some(named_instances) = d
+ .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_configured = instance
+ .get("token")
+ .and_then(|value| value.as_str())
+ .is_some_and(|token| !token.is_empty());
+
+ if let Some(instance_name) = instance_name
+ && instance_configured
+ {
+ push_instance_status(
+ &mut instances,
+ bindings,
+ "discord",
+ Some(instance_name),
+ true,
+ instance_enabled,
+ );
+ }
}
}
- }
-
- PlatformStatus {
- configured: has_token,
- enabled: has_token && enabled,
- }
- })
- .unwrap_or(PlatformStatus {
- configured: false,
- enabled: false,
- });
- let slack_status = doc
- .get("messaging")
- .and_then(|m| m.get("slack"))
- .map(|s| {
- let has_bot_token = s
- .get("bot_token")
- .and_then(|v| v.as_str())
- .is_some_and(|t| !t.is_empty());
- let has_app_token = s
- .get("app_token")
- .and_then(|v| v.as_str())
- .is_some_and(|t| !t.is_empty());
- let enabled = s.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
-
- if has_bot_token && has_app_token {
- push_instance_status(&mut instances, bindings, "slack", 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()),
+ PlatformStatus {
+ configured: has_token,
+ enabled: has_token && enabled,
+ }
+ })
+ .unwrap_or(PlatformStatus {
+ configured: false,
+ enabled: false,
+ });
+
+ let slack_status = doc
+ .get("messaging")
+ .and_then(|m| m.get("slack"))
+ .map(|s| {
+ let has_bot_token = s
+ .get("bot_token")
+ .and_then(|v| v.as_str())
+ .is_some_and(|t| !t.is_empty());
+ let has_app_token = s
+ .get("app_token")
+ .and_then(|v| v.as_str())
+ .is_some_and(|t| !t.is_empty());
+ let enabled = s.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
+
+ if has_bot_token && has_app_token {
+ push_instance_status(
+ &mut instances,
+ bindings,
+ "slack",
+ None,
+ true,
+ enabled,
);
- let has_instance_bot = instance
- .get("bot_token")
- .and_then(|value| value.as_str())
- .is_some_and(|value| !value.is_empty());
- let has_instance_app = instance
- .get("app_token")
- .and_then(|value| value.as_str())
- .is_some_and(|value| !value.is_empty());
- let instance_enabled = instance
- .get("enabled")
- .and_then(|value| value.as_bool())
- .unwrap_or(true)
- && enabled;
+ }
- if let Some(instance_name) = instance_name
- && has_instance_bot
- && has_instance_app
- {
- push_instance_status(
- &mut instances,
- bindings,
- "slack",
- Some(instance_name),
- true,
- instance_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 has_instance_bot = instance
+ .get("bot_token")
+ .and_then(|value| value.as_str())
+ .is_some_and(|value| !value.is_empty());
+ let has_instance_app = instance
+ .get("app_token")
+ .and_then(|value| value.as_str())
+ .is_some_and(|value| !value.is_empty());
+ let instance_enabled = instance
+ .get("enabled")
+ .and_then(|value| value.as_bool())
+ .unwrap_or(true)
+ && enabled;
+
+ if let Some(instance_name) = instance_name
+ && has_instance_bot
+ && has_instance_app
+ {
+ push_instance_status(
+ &mut instances,
+ bindings,
+ "slack",
+ Some(instance_name),
+ true,
+ instance_enabled,
+ );
+ }
}
}
- }
-
- PlatformStatus {
- configured: has_bot_token && has_app_token,
- enabled: has_bot_token && has_app_token && enabled,
- }
- })
- .unwrap_or(PlatformStatus {
- configured: false,
- enabled: false,
- });
-
- let webhook_status = doc
- .get("messaging")
- .and_then(|m| m.get("webhook"))
- .map(|w| {
- let enabled = w.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
-
- push_instance_status(&mut instances, bindings, "webhook", None, true, enabled);
-
- PlatformStatus {
- configured: true,
- enabled,
- }
- })
- .unwrap_or(PlatformStatus {
- configured: false,
- enabled: false,
- });
-
- let email_status = doc
- .get("messaging")
- .and_then(|m| m.get("email"))
- .map(|email| {
- let has_imap_host = email
- .get("imap_host")
- .and_then(|v| v.as_str())
- .is_some_and(|s| !s.is_empty());
- let has_imap_username = email
- .get("imap_username")
- .and_then(|v| v.as_str())
- .is_some_and(|s| !s.is_empty());
- let has_imap_password = email
- .get("imap_password")
- .and_then(|v| v.as_str())
- .is_some_and(|s| !s.is_empty());
- let has_smtp_host = email
- .get("smtp_host")
- .and_then(|v| v.as_str())
- .is_some_and(|s| !s.is_empty());
-
- let configured =
- has_imap_host && has_imap_username && has_imap_password && has_smtp_host;
- let enabled = email
- .get("enabled")
- .and_then(|v| v.as_bool())
- .unwrap_or(false);
-
- if configured {
- push_instance_status(&mut instances, bindings, "email", None, true, enabled);
- }
-
- PlatformStatus {
- configured,
- enabled: configured && enabled,
- }
- })
- .unwrap_or(PlatformStatus {
- configured: false,
- enabled: false,
- });
-
- let telegram_status = doc
- .get("messaging")
- .and_then(|m| m.get("telegram"))
- .map(|t| {
- let has_token = t
- .get("token")
- .and_then(|v| v.as_str())
- .is_some_and(|s| !s.is_empty());
- let enabled = t.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
+ PlatformStatus {
+ configured: has_bot_token && has_app_token,
+ enabled: has_bot_token && has_app_token && enabled,
+ }
+ })
+ .unwrap_or(PlatformStatus {
+ configured: false,
+ enabled: false,
+ });
+
+ let webhook_status = doc
+ .get("messaging")
+ .and_then(|m| m.get("webhook"))
+ .map(|w| {
+ let enabled = w.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
+
+ push_instance_status(&mut instances, bindings, "webhook", None, true, enabled);
+
+ PlatformStatus {
+ configured: true,
+ enabled,
+ }
+ })
+ .unwrap_or(PlatformStatus {
+ configured: false,
+ enabled: false,
+ });
+
+ let email_status = doc
+ .get("messaging")
+ .and_then(|m| m.get("email"))
+ .map(|email| {
+ let has_imap_host = email
+ .get("imap_host")
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| !s.is_empty());
+ let has_imap_username = email
+ .get("imap_username")
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| !s.is_empty());
+ let has_imap_password = email
+ .get("imap_password")
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| !s.is_empty());
+ let has_smtp_host = email
+ .get("smtp_host")
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| !s.is_empty());
+
+ let configured =
+ has_imap_host && has_imap_username && has_imap_password && has_smtp_host;
+
+ let enabled = email
+ .get("enabled")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
- if has_token {
- push_instance_status(&mut instances, bindings, "telegram", None, true, enabled);
- }
+ if configured {
+ push_instance_status(
+ &mut instances,
+ bindings,
+ "email",
+ None,
+ true,
+ enabled,
+ );
+ }
- if let Some(named_instances) = t
- .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()),
+ PlatformStatus {
+ configured,
+ enabled: configured && enabled,
+ }
+ })
+ .unwrap_or(PlatformStatus {
+ configured: false,
+ enabled: false,
+ });
+
+ let telegram_status = doc
+ .get("messaging")
+ .and_then(|m| m.get("telegram"))
+ .map(|t| {
+ let has_token = t
+ .get("token")
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| !s.is_empty());
+ let enabled = t.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
+
+ if has_token {
+ push_instance_status(
+ &mut instances,
+ bindings,
+ "telegram",
+ None,
+ true,
+ enabled,
);
- let instance_enabled = instance
- .get("enabled")
- .and_then(|value| value.as_bool())
- .unwrap_or(true)
- && enabled;
- let instance_configured = instance
- .get("token")
- .and_then(|value| value.as_str())
- .is_some_and(|value| !value.is_empty());
-
- if let Some(instance_name) = instance_name
- && instance_configured
- {
- push_instance_status(
- &mut instances,
- bindings,
- "telegram",
- Some(instance_name),
- true,
- instance_enabled,
+ }
+
+ if let Some(named_instances) = t
+ .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_configured = instance
+ .get("token")
+ .and_then(|value| value.as_str())
+ .is_some_and(|value| !value.is_empty());
+
+ if let Some(instance_name) = instance_name
+ && instance_configured
+ {
+ push_instance_status(
+ &mut instances,
+ bindings,
+ "telegram",
+ Some(instance_name),
+ true,
+ instance_enabled,
+ );
+ }
}
}
- }
-
- PlatformStatus {
- configured: has_token,
- enabled: has_token && enabled,
- }
- })
- .unwrap_or(PlatformStatus {
- configured: false,
- enabled: false,
- });
-
- let twitch_status = doc
- .get("messaging")
- .and_then(|m| m.get("twitch"))
- .map(|t| {
- let has_username = t
- .get("username")
- .and_then(|v| v.as_str())
- .is_some_and(|s| !s.is_empty());
- let has_token = t
- .get("oauth_token")
- .and_then(|v| v.as_str())
- .is_some_and(|s| !s.is_empty());
- let enabled = t.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
-
- if has_username && has_token {
- push_instance_status(&mut instances, bindings, "twitch", None, true, enabled);
- }
- if let Some(named_instances) = t
- .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()),
+ PlatformStatus {
+ configured: has_token,
+ enabled: has_token && enabled,
+ }
+ })
+ .unwrap_or(PlatformStatus {
+ configured: false,
+ enabled: false,
+ });
+
+ let twitch_status = doc
+ .get("messaging")
+ .and_then(|m| m.get("twitch"))
+ .map(|t| {
+ let has_username = t
+ .get("username")
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| !s.is_empty());
+ let has_token = t
+ .get("oauth_token")
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| !s.is_empty());
+ let enabled = t.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
+
+ if has_username && has_token {
+ push_instance_status(
+ &mut instances,
+ bindings,
+ "twitch",
+ None,
+ true,
+ enabled,
);
- let instance_enabled = instance
- .get("enabled")
- .and_then(|value| value.as_bool())
- .unwrap_or(true)
- && enabled;
- let has_instance_username = instance
- .get("username")
- .and_then(|value| value.as_str())
- .is_some_and(|value| !value.is_empty());
- let has_instance_token = instance
- .get("oauth_token")
- .and_then(|value| value.as_str())
- .is_some_and(|value| !value.is_empty());
-
- if let Some(instance_name) = instance_name
- && has_instance_username
- && has_instance_token
- {
- push_instance_status(
- &mut instances,
- bindings,
- "twitch",
- Some(instance_name),
- true,
- instance_enabled,
+ }
+
+ if let Some(named_instances) = t
+ .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 has_instance_username = instance
+ .get("username")
+ .and_then(|value| value.as_str())
+ .is_some_and(|value| !value.is_empty());
+ let has_instance_token = instance
+ .get("oauth_token")
+ .and_then(|value| value.as_str())
+ .is_some_and(|value| !value.is_empty());
+
+ if let Some(instance_name) = instance_name
+ && has_instance_username
+ && has_instance_token
+ {
+ push_instance_status(
+ &mut instances,
+ bindings,
+ "twitch",
+ Some(instance_name),
+ true,
+ instance_enabled,
+ );
+ }
}
}
- }
-
- PlatformStatus {
- configured: has_username && has_token,
- enabled: has_username && has_token && enabled,
- }
- })
- .unwrap_or(PlatformStatus {
- configured: false,
- enabled: false,
- });
-
- // Populate instances for Mattermost (not in the legacy per-platform status fields)
- if let Some(mm) = doc.get("messaging").and_then(|m| m.get("mattermost")) {
- let has_url = mm
- .get("base_url")
- .and_then(|v| v.as_str())
- .is_some_and(|s| !s.is_empty());
- let has_token = mm
- .get("token")
- .and_then(|v| v.as_str())
- .is_some_and(|s| !s.is_empty());
- let enabled = mm.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
-
- if has_url && has_token {
- push_instance_status(&mut instances, bindings, "mattermost", None, true, enabled);
- }
- if let Some(named_instances) = mm
- .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_configured = instance
+ PlatformStatus {
+ configured: has_username && has_token,
+ enabled: has_username && has_token && enabled,
+ }
+ })
+ .unwrap_or(PlatformStatus {
+ configured: false,
+ enabled: false,
+ });
+
+ // Populate instances for Mattermost (not in the legacy per-platform status fields)
+ let mattermost_status = doc
+ .get("messaging")
+ .and_then(|m| m.get("mattermost"))
+ .map(|mm| {
+ let has_url = mm
.get("base_url")
- .and_then(|value| value.as_str())
- .is_some_and(|value| !value.is_empty())
- && instance
- .get("token")
- .and_then(|value| value.as_str())
- .is_some_and(|value| !value.is_empty());
-
- if let Some(instance_name) = instance_name
- && instance_configured
- {
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| !s.is_empty());
+ let has_token = mm
+ .get("token")
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| !s.is_empty());
+ let enabled = mm.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
+
+ if has_url && has_token {
push_instance_status(
&mut instances,
bindings,
"mattermost",
- Some(instance_name),
+ None,
true,
- instance_enabled,
+ enabled,
);
}
- }
- }
- }
- (
- discord_status,
- slack_status,
- telegram_status,
- email_status,
- webhook_status,
- twitch_status,
- instances,
- )
- } else {
- let default = PlatformStatus {
- configured: false,
- enabled: false,
+ if let Some(named_instances) = mm
+ .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_configured = instance
+ .get("base_url")
+ .and_then(|value| value.as_str())
+ .is_some_and(|value| !value.is_empty())
+ && instance
+ .get("token")
+ .and_then(|value| value.as_str())
+ .is_some_and(|value| !value.is_empty());
+
+ if let Some(instance_name) = instance_name
+ && instance_configured
+ {
+ push_instance_status(
+ &mut instances,
+ bindings,
+ "mattermost",
+ Some(instance_name),
+ true,
+ instance_enabled,
+ );
+ }
+ }
+ }
+
+ PlatformStatus {
+ configured: has_url && has_token,
+ enabled: has_url && has_token && enabled,
+ }
+ })
+ .unwrap_or(PlatformStatus {
+ configured: false,
+ 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()),
+ );
+ // Named instances are enabled based only on their own flag (independent of root enabled)
+ let instance_enabled = instance
+ .get("enabled")
+ .and_then(|value| value.as_bool())
+ .unwrap_or(true);
+ 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,
+ telegram_status,
+ email_status,
+ webhook_status,
+ twitch_status,
+ mattermost_status,
+ signal_status,
+ instances,
+ )
+ } else {
+ let default = PlatformStatus {
+ configured: false,
+ enabled: false,
+ };
+ (
+ default.clone(),
+ default.clone(),
+ default.clone(),
+ default.clone(),
+ default.clone(),
+ default.clone(),
+ default.clone(),
+ default.clone(),
+ Vec::new(),
+ )
};
- (
- default.clone(),
- default.clone(),
- default.clone(),
- default.clone(),
- default.clone(),
- default,
- Vec::new(),
- )
- };
Ok(Json(MessagingStatusResponse {
discord,
@@ -601,6 +897,8 @@ pub(super) async fn messaging_status(
email,
webhook,
twitch,
+ mattermost,
+ signal,
instances,
}))
}
@@ -1145,6 +1443,93 @@ 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");
+ }
+ }
+ }
+ 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");
+ }
+ }
+ }
+ }
+ }
+ }
_ => {}
}
}
@@ -1156,6 +1541,22 @@ pub(super) async fn toggle_platform(
if let Err(error) = manager.remove_adapter(&runtime_key).await {
tracing::warn!(%error, adapter = %runtime_key, "failed to shut down named adapter on toggle");
}
+ } else if platform == "signal" {
+ // Root toggle disable: only remove the default adapter, not named instances
+ let runtime_key = crate::config::binding_runtime_adapter_key(platform, None);
+ if let Err(error) = manager.remove_adapter(&runtime_key).await {
+ tracing::warn!(%error, adapter = %runtime_key, "failed to shut down default adapter on toggle");
+ }
+ // Clear cached Signal permissions so the allow-list is no longer in memory
+ let perms_guard = state.signal_permissions.read().await;
+ if perms_guard.is_some() {
+ drop(perms_guard);
+ state
+ .set_signal_permissions(std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
+ crate::config::SignalPermissions::default(),
+ )))
+ .await;
+ }
} else if let Err(error) = manager.remove_platform_adapters(platform).await {
tracing::warn!(%error, platform = %platform, "failed to shut down adapters on toggle");
}
@@ -1187,7 +1588,7 @@ pub(super) async fn create_messaging_instance(
if !matches!(
platform.as_str(),
- "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "mattermost"
+ "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "mattermost" | "signal"
) {
return Ok(Json(MessagingInstanceActionResponse {
success: false,
@@ -1210,6 +1611,25 @@ pub(super) async fn create_messaging_instance(
message: "instance name cannot contain ':' or spaces".to_string(),
}));
}
+ // Block reserved instance name "dm" for Slack/Discord (but allow for Signal)
+ if trimmed.eq_ignore_ascii_case("dm") && matches!(platform.as_str(), "slack" | "discord") {
+ return Ok(Json(MessagingInstanceActionResponse {
+ success: false,
+ message: "instance name 'dm' is reserved and cannot be used for Slack or Discord"
+ .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();
@@ -1348,9 +1768,45 @@ pub(super) async fn create_messaging_instance(
platform_table["token"] = toml_edit::value(token.as_str());
}
}
+ "signal" => {
+ // Merge incoming credentials with existing TOML values for patch-style updates.
+ // Fields omitted from the request are filled from the current table,
+ // so callers can update individual fields without resubmitting every credential.
+ let effective =
+ merge_signal_credentials_with_existing(credentials, platform_table);
+ let (http_url, account, dm_users) = match parse_signal_credentials(&effective) {
+ Ok(result) => result,
+ Err(response) => return Ok(Json(response)),
+ };
+
+ // Store URL as-is; SignalAdapter::new() normalizes trailing slashes.
+ platform_table["http_url"] = toml_edit::value(http_url.as_str());
+ platform_table["account"] = toml_edit::value(account);
+
+ // Store dm_allowed_users: preserve existing when omitted (None),
+ // set when provided with values, remove only when explicitly cleared (Some(empty))
+ match dm_users {
+ Some(dm_users) if !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);
+ }
+ Some(_) => {
+ // Explicitly cleared (empty vec) - remove the key
+ platform_table.remove("dm_allowed_users");
+ }
+ None => {
+ // Omitted - preserve existing value by doing nothing
+ }
+ }
+ }
_ => {}
}
- platform_table["enabled"] = toml_edit::value(enabled);
+ if let Some(value) = request.enabled {
+ platform_table["enabled"] = toml_edit::value(value);
+ }
}
Some(ref name) => {
// Named instance — add to [[messaging..instances]]
@@ -1477,6 +1933,39 @@ pub(super) async fn create_messaging_instance(
instance_table["token"] = toml_edit::value(token.as_str());
}
}
+ "signal" => {
+ // New instance — no existing values to merge, validate directly.
+ let (http_url, account, dm_users) = match parse_signal_credentials(credentials)
+ {
+ Ok(result) => result,
+ Err(response) => return Ok(Json(response)),
+ };
+
+ // Store URL as-is; SignalAdapter::new() normalizes trailing slashes.
+ instance_table["http_url"] = toml_edit::value(http_url.as_str());
+ instance_table["account"] = toml_edit::value(account);
+
+ // Store dm_allowed_users: preserve existing when omitted (None),
+ // set when provided with values, remove only when explicitly cleared (Some(empty))
+ match dm_users {
+ Some(dm_users) if !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);
+ }
+ Some(_) => {
+ // Explicitly cleared (empty vec) - remove the key
+ instance_table.remove("dm_allowed_users");
+ }
+ None => {
+ // Omitted - don't set the field. When loaded, the absence
+ // of dm_allowed_users results in an empty Vec which blocks
+ // all DMs (the safe default).
+ }
+ }
+ }
_ => {}
}
@@ -1542,7 +2031,7 @@ pub(super) async fn delete_messaging_instance(
if !matches!(
platform.as_str(),
- "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "mattermost"
+ "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "mattermost" | "signal"
) {
return Ok(Json(MessagingInstanceActionResponse {
success: false,
@@ -1646,6 +2135,14 @@ pub(super) async fn delete_messaging_instance(
table.remove("dm_allowed_users");
table.remove("max_attachment_bytes");
}
+ "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");
+ }
_ => {}
}
}
@@ -1704,6 +2201,20 @@ pub(super) async fn delete_messaging_instance(
{
tracing::warn!(%error, adapter = %runtime_key, "failed to shut down adapter during instance delete");
}
+ drop(manager_guard);
+
+ // Clear cached Signal permissions when deleting the default Signal adapter
+ if platform == "signal" && adapter_name.is_none() {
+ let perms_guard = state.signal_permissions.read().await;
+ if perms_guard.is_some() {
+ drop(perms_guard);
+ state
+ .set_signal_permissions(std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
+ crate::config::SignalPermissions::default(),
+ )))
+ .await;
+ }
+ }
// Clean up twitch token file if applicable
if platform == "twitch" {
@@ -1739,3 +2250,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