Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,8 @@ export interface MessagingStatusResponse {
webhook: PlatformStatus;
twitch: PlatformStatus;
email: PlatformStatus;
mattermost: PlatformStatus;
signal: PlatformStatus;
instances: AdapterInstanceStatus[];
}

Expand Down Expand Up @@ -1230,6 +1232,9 @@ export interface CreateMessagingInstanceRequest {
webhook_auth_token?: string;
mattermost_base_url?: string;
mattermost_token?: string;
signal_http_url?: string;
signal_account?: string;
signal_dm_allowed_users?: string;
};
}

Expand Down
85 changes: 83 additions & 2 deletions interface/src/components/ChannelEditModal.tsx
Original file line number Diff line number Diff line change
@@ -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, validateSignalDmAllowedUsers} from "@/lib/format";
import {
Button,
Input,
Expand All @@ -18,7 +19,7 @@ import {
import {PlatformIcon} from "@/lib/platformIcons";
import {TagInput} from "@/components/TagInput";

type Platform = "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "mattermost";
type Platform = "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "mattermost" | "signal";

interface ChannelEditModalProps {
platform: Platform;
Expand Down Expand Up @@ -168,6 +169,40 @@ 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;
}
let dmUsers: string | undefined;
const rawDmUsers = credentialInputs.signal_dm_allowed_users;
if (rawDmUsers !== undefined) {
if (!rawDmUsers.trim()) {
dmUsers = "";
} else {
const result = validateSignalDmAllowedUsers(rawDmUsers);
if (!result.valid) {
setMessage({text: result.error, type: "error"});
return;
}
dmUsers = result.entries.length > 0 ? result.entries.join(",") : "";
}
}
Comment thread
ibhagwan marked this conversation as resolved.
request.platform_credentials = {
signal_http_url: credentialInputs.signal_http_url.trim(),
signal_account: account,
signal_dm_allowed_users: dmUsers,
};
Comment thread
ibhagwan marked this conversation as resolved.
}
saveCreds.mutate(request);
}
Expand Down Expand Up @@ -358,13 +393,59 @@ export function ChannelEditModal({platform, name, status, open, onOpenChange}: C
</>
)}

{platform === "signal" && (
<>
<div className="space-y-3">
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">HTTP URL</label>
<Input
value={credentialInputs.signal_http_url ?? ""}
onChange={(e) => setCredentialInputs({...credentialInputs, signal_http_url: e.target.value})}
placeholder="http://127.0.0.1:8686"
onKeyDown={(e) => { if (e.key === "Enter") handleSaveCredentials(); }}
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">Account Phone Number</label>
<Input
value={credentialInputs.signal_account ?? ""}
onChange={(e) => setCredentialInputs({...credentialInputs, signal_account: e.target.value})}
placeholder="+1234567890"
onKeyDown={(e) => { if (e.key === "Enter") handleSaveCredentials(); }}
/>
<p className="mt-1 text-xs text-ink-faint">
Your Signal phone number in E.164 format (+ followed by 6-15 digits, first digit 1-9)
</p>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">DM Allowed Users (Optional)</label>
<Input
value={credentialInputs.signal_dm_allowed_users ?? ""}
onChange={(e) => setCredentialInputs({...credentialInputs, signal_dm_allowed_users: e.target.value})}
placeholder="+1234567890, +1987654321"
onKeyDown={(e) => { if (e.key === "Enter") handleSaveCredentials(); }}
/>
<p className="mt-1 text-xs text-ink-faint">
Allowed DM senders: E.164 phone numbers (+1234567890) or uuid:xxx identifiers. Comma-separated. If empty, DMs are blocked.
</p>
Comment thread
ibhagwan marked this conversation as resolved.
</div>
</div>
<p className="mt-3 text-xs text-ink-faint">
Need help?{" "}
<a href="https://docs.spacebot.sh/signal-setup" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">
Read the Signal setup docs &rarr;
</a>
Comment thread
ibhagwan marked this conversation as resolved.
</p>
</>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{platform === "webhook" && (
<p className="text-sm text-ink-dull">
Webhook receiver requires no additional credentials.
</p>
)}

{platform !== "webhook" && Object.values(credentialInputs).some((v) => v?.trim()) && (
{platform !== "webhook" && (Object.values(credentialInputs).some((v) => v?.trim()) || credentialInputs.signal_dm_allowed_users === "") && (
<Button onClick={handleSaveCredentials} loading={saveCreds.isPending} size="sm">
{configured ? "Update Credentials" : "Connect"}
</Button>
Expand Down
81 changes: 80 additions & 1 deletion interface/src/components/ChannelSettingCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ import {
Toggle,
} from "@/ui";
import {PlatformIcon} from "@/lib/platformIcons";
import {isValidE164, E164_ERROR_TEXT, validateSignalDmAllowedUsers} 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" | "mattermost";
type Platform = "discord" | "slack" | "telegram" | "twitch" | "email" | "webhook" | "mattermost" | "signal";

const PLATFORM_LABELS: Record<Platform, string> = {
discord: "Discord",
Expand All @@ -37,6 +38,7 @@ const PLATFORM_LABELS: Record<Platform, string> = {
email: "Email",
webhook: "Webhook",
mattermost: "Mattermost",
signal: "Signal",
};

const DOC_LINKS: Partial<Record<Platform, string>> = {
Expand All @@ -45,6 +47,7 @@ const DOC_LINKS: Partial<Record<Platform, string>> = {
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",
Comment thread
ibhagwan marked this conversation as resolved.
};

// --- Platform Catalog (Left Column) ---
Expand All @@ -62,6 +65,7 @@ export function PlatformCatalog({onAddInstance}: PlatformCatalogProps) {
"email",
"webhook",
"mattermost",
"signal",
];

const COMING_SOON = [
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -964,6 +999,50 @@ export function AddInstanceCard({platform, isDefault, onCancel, onCreated}: AddI
</>
)}

{platform === "signal" && (
<>
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">HTTP URL</label>
<Input
size="lg"
value={credentialInputs.signal_http_url ?? ""}
onChange={(e) => setCredentialInputs({...credentialInputs, signal_http_url: e.target.value})}
placeholder="http://127.0.0.1:8686"
onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }}
/>
<p className="mt-1 text-xs text-ink-faint">
URL of your signal-cli daemon (e.g., http://127.0.0.1:8686)
</p>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">Account Phone Number</label>
<Input
size="lg"
value={credentialInputs.signal_account ?? ""}
onChange={(e) => setCredentialInputs({...credentialInputs, signal_account: e.target.value})}
placeholder="+1234567890"
onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }}
/>
<p className="mt-1 text-xs text-ink-faint">
Your Signal phone number in E.164 format (+ followed by 6-15 digits, first digit 1-9)
</p>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-ink-dull">DM Allowed Users (Optional)</label>
<Input
size="lg"
value={credentialInputs.signal_dm_allowed_users ?? ""}
onChange={(e) => setCredentialInputs({...credentialInputs, signal_dm_allowed_users: e.target.value})}
placeholder="+1234567890, +1987654321"
onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }}
/>
<p className="mt-1 text-xs text-ink-faint">
Allowed DM senders: E.164 phone numbers (+1234567890) or uuid:xxx identifiers. Comma-separated. If empty, DMs are blocked.
</p>
</div>
</>
)}

{docLink && (
<p className="text-xs text-ink-faint">
Need help?{" "}
Expand Down
54 changes: 54 additions & 0 deletions interface/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
1 change: 1 addition & 0 deletions interface/src/lib/platformIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion interface/src/routes/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Comment thread
ibhagwan marked this conversation as resolved.

function ChannelsSection() {
const [expandedKey, setExpandedKey] = useState<string | null>(null);
Expand Down
2 changes: 2 additions & 0 deletions prompts/en/adapters/cron.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
4 changes: 3 additions & 1 deletion prompts/en/fragments/conversation_context.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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 %})
Comment thread
ibhagwan marked this conversation as resolved.
{%- elif conversation_id %}
Channel ID: `{{ conversation_id }}`
{%- endif %}
Multiple users may be present. Each message is prefixed with [username].
2 changes: 2 additions & 0 deletions src/agent/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,7 @@ impl Channel {
&first.source,
server_name,
channel_name,
self.conversation_id.as_deref(),
)?);
}

Expand Down Expand Up @@ -1736,6 +1737,7 @@ impl Channel {
&message.source,
server_name,
channel_name,
self.conversation_id.as_deref(),
)?);
}

Expand Down
Loading
Loading