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