refactor(signal): normalize sender identity
This commit is contained in:
114
src/signal/identity.ts
Normal file
114
src/signal/identity.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { normalizeE164 } from "../utils.js";
|
||||
|
||||
export type SignalSender =
|
||||
| { kind: "phone"; raw: string; e164: string }
|
||||
| { kind: "uuid"; raw: string };
|
||||
|
||||
type SignalAllowEntry =
|
||||
| { kind: "any" }
|
||||
| { kind: "phone"; e164: string }
|
||||
| { kind: "uuid"; raw: string };
|
||||
|
||||
const UUID_HYPHENATED_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const UUID_COMPACT_RE = /^[0-9a-f]{32}$/i;
|
||||
|
||||
function looksLikeUuid(value: string): boolean {
|
||||
if (UUID_HYPHENATED_RE.test(value) || UUID_COMPACT_RE.test(value)) {
|
||||
return true;
|
||||
}
|
||||
const compact = value.replace(/-/g, "");
|
||||
if (!/^[0-9a-f]+$/i.test(compact)) return false;
|
||||
return /[a-f]/i.test(compact);
|
||||
}
|
||||
|
||||
function stripSignalPrefix(value: string): string {
|
||||
return value.replace(/^signal:/i, "").trim();
|
||||
}
|
||||
|
||||
export function resolveSignalSender(params: {
|
||||
sourceNumber?: string | null;
|
||||
sourceUuid?: string | null;
|
||||
}): SignalSender | null {
|
||||
const sourceNumber = params.sourceNumber?.trim();
|
||||
if (sourceNumber) {
|
||||
return {
|
||||
kind: "phone",
|
||||
raw: sourceNumber,
|
||||
e164: normalizeE164(sourceNumber),
|
||||
};
|
||||
}
|
||||
const sourceUuid = params.sourceUuid?.trim();
|
||||
if (sourceUuid) {
|
||||
return { kind: "uuid", raw: sourceUuid };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatSignalSenderId(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
|
||||
}
|
||||
|
||||
export function formatSignalSenderDisplay(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
|
||||
}
|
||||
|
||||
export function resolveSignalRecipient(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : sender.raw;
|
||||
}
|
||||
|
||||
export function resolveSignalPeerId(sender: SignalSender): string {
|
||||
return sender.kind === "phone" ? sender.e164 : `uuid:${sender.raw}`;
|
||||
}
|
||||
|
||||
function parseSignalAllowEntry(entry: string): SignalAllowEntry | null {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed === "*") return { kind: "any" };
|
||||
|
||||
const stripped = stripSignalPrefix(trimmed);
|
||||
const lower = stripped.toLowerCase();
|
||||
if (lower.startsWith("uuid:")) {
|
||||
const raw = stripped.slice("uuid:".length).trim();
|
||||
if (!raw) return null;
|
||||
return { kind: "uuid", raw };
|
||||
}
|
||||
|
||||
if (looksLikeUuid(stripped)) {
|
||||
return { kind: "uuid", raw: stripped };
|
||||
}
|
||||
|
||||
return { kind: "phone", e164: normalizeE164(stripped) };
|
||||
}
|
||||
|
||||
export function isSignalSenderAllowed(
|
||||
sender: SignalSender,
|
||||
allowFrom: string[],
|
||||
): boolean {
|
||||
if (allowFrom.length === 0) return false;
|
||||
const parsed = allowFrom
|
||||
.map(parseSignalAllowEntry)
|
||||
.filter((entry): entry is SignalAllowEntry => entry !== null);
|
||||
if (parsed.some((entry) => entry.kind === "any")) return true;
|
||||
return parsed.some((entry) => {
|
||||
if (entry.kind === "phone" && sender.kind === "phone") {
|
||||
return entry.e164 === sender.e164;
|
||||
}
|
||||
if (entry.kind === "uuid" && sender.kind === "uuid") {
|
||||
return entry.raw === sender.raw;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function isSignalGroupAllowed(params: {
|
||||
groupPolicy: "open" | "disabled" | "allowlist";
|
||||
allowFrom: string[];
|
||||
sender: SignalSender;
|
||||
}): boolean {
|
||||
const { groupPolicy, allowFrom, sender } = params;
|
||||
if (groupPolicy === "disabled") return false;
|
||||
if (groupPolicy === "open") return true;
|
||||
if (allowFrom.length === 0) return false;
|
||||
return isSignalSenderAllowed(sender, allowFrom);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isSignalGroupAllowed } from "./monitor.js";
|
||||
import { isSignalGroupAllowed } from "./identity.js";
|
||||
|
||||
describe("signal groupPolicy gating", () => {
|
||||
it("allows when policy is open", () => {
|
||||
@@ -8,7 +8,7 @@ describe("signal groupPolicy gating", () => {
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "open",
|
||||
allowFrom: [],
|
||||
sender: "+15550001111",
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -18,7 +18,7 @@ describe("signal groupPolicy gating", () => {
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "disabled",
|
||||
allowFrom: ["+15550001111"],
|
||||
sender: "+15550001111",
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -28,7 +28,7 @@ describe("signal groupPolicy gating", () => {
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
sender: "+15550001111",
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -38,7 +38,7 @@ describe("signal groupPolicy gating", () => {
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["+15550001111"],
|
||||
sender: "+15550001111",
|
||||
sender: { kind: "phone", raw: "+15550001111", e164: "+15550001111" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -48,7 +48,20 @@ describe("signal groupPolicy gating", () => {
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["*"],
|
||||
sender: "+15550002222",
|
||||
sender: { kind: "phone", raw: "+15550002222", e164: "+15550002222" },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows allowlist when uuid sender matches", () => {
|
||||
expect(
|
||||
isSignalGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["uuid:123e4567-e89b-12d3-a456-426614174000"],
|
||||
sender: {
|
||||
kind: "uuid",
|
||||
raw: "123e4567-e89b-12d3-a456-426614174000",
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -197,6 +197,48 @@ describe("monitorSignalProvider tool results", () => {
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("pairs uuid-only senders with a uuid allowlist entry", async () => {
|
||||
config = {
|
||||
...config,
|
||||
signal: { autoStart: false, dmPolicy: "pairing", allowFrom: [] },
|
||||
};
|
||||
const abortController = new AbortController();
|
||||
const uuid = "123e4567-e89b-12d3-a456-426614174000";
|
||||
|
||||
streamMock.mockImplementation(async ({ onEvent }) => {
|
||||
const payload = {
|
||||
envelope: {
|
||||
sourceUuid: uuid,
|
||||
sourceName: "Ada",
|
||||
timestamp: 1,
|
||||
dataMessage: {
|
||||
message: "hello",
|
||||
},
|
||||
},
|
||||
};
|
||||
await onEvent({
|
||||
event: "receive",
|
||||
data: JSON.stringify(payload),
|
||||
});
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
await monitorSignalProvider({
|
||||
autoStart: false,
|
||||
baseUrl: "http://127.0.0.1:8080",
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
expect(replyMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ provider: "signal", id: `uuid:${uuid}` }),
|
||||
);
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0]?.[0]).toBe(`signal:${uuid}`);
|
||||
});
|
||||
|
||||
it("reconnects after stream errors until aborted", async () => {
|
||||
vi.useFakeTimers();
|
||||
const abortController = new AbortController();
|
||||
|
||||
@@ -19,6 +19,14 @@ import { normalizeE164 } from "../utils.js";
|
||||
import { resolveSignalAccount } from "./accounts.js";
|
||||
import { signalCheck, signalRpcRequest } from "./client.js";
|
||||
import { spawnSignalDaemon } from "./daemon.js";
|
||||
import {
|
||||
formatSignalSenderDisplay,
|
||||
formatSignalSenderId,
|
||||
isSignalSenderAllowed,
|
||||
resolveSignalPeerId,
|
||||
resolveSignalRecipient,
|
||||
resolveSignalSender,
|
||||
} from "./identity.js";
|
||||
import { sendMessageSignal } from "./send.js";
|
||||
import { runSignalSseLoop } from "./sse-reconnect.js";
|
||||
|
||||
@@ -92,28 +100,6 @@ function normalizeAllowList(raw?: Array<string | number>): string[] {
|
||||
return (raw ?? []).map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function isAllowedSender(sender: string, allowFrom: string[]): boolean {
|
||||
if (allowFrom.length === 0) return false;
|
||||
if (allowFrom.includes("*")) return true;
|
||||
const normalizedAllow = allowFrom
|
||||
.map((entry) => entry.replace(/^signal:/i, ""))
|
||||
.map((entry) => normalizeE164(entry));
|
||||
const normalizedSender = normalizeE164(sender);
|
||||
return normalizedAllow.includes(normalizedSender);
|
||||
}
|
||||
|
||||
export function isSignalGroupAllowed(params: {
|
||||
groupPolicy: "open" | "disabled" | "allowlist";
|
||||
allowFrom: string[];
|
||||
sender: string;
|
||||
}): boolean {
|
||||
const { groupPolicy, allowFrom, sender } = params;
|
||||
if (groupPolicy === "disabled") return false;
|
||||
if (groupPolicy === "open") return true;
|
||||
if (allowFrom.length === 0) return false;
|
||||
return isAllowedSender(sender, allowFrom);
|
||||
}
|
||||
|
||||
async function waitForSignalDaemonReady(params: {
|
||||
baseUrl: string;
|
||||
abortSignal?: AbortSignal;
|
||||
@@ -320,11 +306,18 @@ export async function monitorSignalProvider(
|
||||
envelope.dataMessage ?? envelope.editMessage?.dataMessage;
|
||||
if (!dataMessage) return;
|
||||
|
||||
const sender = envelope.sourceNumber?.trim() || envelope.sourceUuid?.trim();
|
||||
const sender = resolveSignalSender(envelope);
|
||||
if (!sender) return;
|
||||
if (account && envelope.sourceNumber && normalizeE164(envelope.sourceNumber) === normalizeE164(account)) {
|
||||
return;
|
||||
if (account && sender.kind === "phone") {
|
||||
if (sender.e164 === normalizeE164(account)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const senderDisplay = formatSignalSenderDisplay(sender);
|
||||
const senderRecipient = resolveSignalRecipient(sender);
|
||||
const senderPeerId = resolveSignalPeerId(sender);
|
||||
const senderAllowId = formatSignalSenderId(sender);
|
||||
if (!senderRecipient) return;
|
||||
const groupId = dataMessage.groupInfo?.groupId ?? undefined;
|
||||
const groupName = dataMessage.groupInfo?.groupName ?? undefined;
|
||||
const isGroup = Boolean(groupId);
|
||||
@@ -334,13 +327,15 @@ export async function monitorSignalProvider(
|
||||
const effectiveDmAllow = [...allowFrom, ...storeAllowFrom];
|
||||
const effectiveGroupAllow = [...groupAllowFrom, ...storeAllowFrom];
|
||||
const dmAllowed =
|
||||
dmPolicy === "open" ? true : isAllowedSender(sender, effectiveDmAllow);
|
||||
dmPolicy === "open"
|
||||
? true
|
||||
: isSignalSenderAllowed(sender, effectiveDmAllow);
|
||||
|
||||
if (!isGroup) {
|
||||
if (dmPolicy === "disabled") return;
|
||||
if (!dmAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const senderId = normalizeE164(sender);
|
||||
const senderId = senderAllowId;
|
||||
const { code, created } = await upsertProviderPairingRequest({
|
||||
provider: "signal",
|
||||
id: senderId,
|
||||
@@ -352,7 +347,7 @@ export async function monitorSignalProvider(
|
||||
logVerbose(`signal pairing request sender=${senderId}`);
|
||||
try {
|
||||
await sendMessageSignal(
|
||||
senderId,
|
||||
`signal:${senderRecipient}`,
|
||||
[
|
||||
"Clawdbot: access not configured.",
|
||||
"",
|
||||
@@ -376,7 +371,7 @@ export async function monitorSignalProvider(
|
||||
}
|
||||
} else {
|
||||
logVerbose(
|
||||
`Blocked signal sender ${sender} (dmPolicy=${dmPolicy})`,
|
||||
`Blocked signal sender ${senderDisplay} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -393,9 +388,9 @@ export async function monitorSignalProvider(
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isAllowedSender(sender, effectiveGroupAllow)) {
|
||||
if (!isSignalSenderAllowed(sender, effectiveGroupAllow)) {
|
||||
logVerbose(
|
||||
`Blocked signal group sender ${sender} (not in groupAllowFrom)`,
|
||||
`Blocked signal group sender ${senderDisplay} (not in groupAllowFrom)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -403,7 +398,7 @@ export async function monitorSignalProvider(
|
||||
|
||||
const commandAuthorized = isGroup
|
||||
? effectiveGroupAllow.length > 0
|
||||
? isAllowedSender(sender, effectiveGroupAllow)
|
||||
? isSignalSenderAllowed(sender, effectiveGroupAllow)
|
||||
: true
|
||||
: dmAllowed;
|
||||
const messageText = (dataMessage.message ?? "").trim();
|
||||
@@ -418,7 +413,7 @@ export async function monitorSignalProvider(
|
||||
baseUrl,
|
||||
account,
|
||||
attachment: firstAttachment,
|
||||
sender,
|
||||
sender: senderRecipient,
|
||||
groupId,
|
||||
maxBytes: mediaMaxBytes,
|
||||
});
|
||||
@@ -445,7 +440,7 @@ export async function monitorSignalProvider(
|
||||
|
||||
const fromLabel = isGroup
|
||||
? `${groupName ?? "Signal Group"} id:${groupId}`
|
||||
: `${envelope.sourceName ?? sender} id:${sender}`;
|
||||
: `${envelope.sourceName ?? senderDisplay} id:${senderDisplay}`;
|
||||
const body = formatAgentEnvelope({
|
||||
provider: "Signal",
|
||||
from: fromLabel,
|
||||
@@ -459,20 +454,24 @@ export async function monitorSignalProvider(
|
||||
accountId: accountInfo.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: isGroup ? (groupId ?? "unknown") : normalizeE164(sender),
|
||||
id: isGroup ? (groupId ?? "unknown") : senderPeerId,
|
||||
},
|
||||
});
|
||||
const signalTo = isGroup ? `group:${groupId}` : `signal:${sender}`;
|
||||
const signalTo = isGroup
|
||||
? `group:${groupId}`
|
||||
: `signal:${senderRecipient}`;
|
||||
const ctxPayload = {
|
||||
Body: body,
|
||||
From: isGroup ? `group:${groupId ?? "unknown"}` : `signal:${sender}`,
|
||||
From: isGroup
|
||||
? `group:${groupId ?? "unknown"}`
|
||||
: `signal:${senderRecipient}`,
|
||||
To: signalTo,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
GroupSubject: isGroup ? (groupName ?? undefined) : undefined,
|
||||
SenderName: envelope.sourceName ?? sender,
|
||||
SenderId: sender,
|
||||
SenderName: envelope.sourceName ?? senderDisplay,
|
||||
SenderId: senderDisplay,
|
||||
Provider: "signal" as const,
|
||||
Surface: "signal" as const,
|
||||
MessageSid: envelope.timestamp ? String(envelope.timestamp) : undefined,
|
||||
@@ -495,7 +494,7 @@ export async function monitorSignalProvider(
|
||||
storePath,
|
||||
sessionKey: route.mainSessionKey,
|
||||
provider: "signal",
|
||||
to: normalizeE164(sender),
|
||||
to: senderRecipient,
|
||||
accountId: route.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user