feat(signal): add typing + read receipts

This commit is contained in:
Peter Steinberger
2026-01-22 02:04:51 +00:00
parent 5bf7a9d0db
commit cc74e0d188
11 changed files with 261 additions and 12 deletions

View File

@@ -22,6 +22,10 @@ export type SignalSendResult = {
timestamp?: number;
};
export type SignalRpcOpts = Pick<SignalSendOpts, "baseUrl" | "account" | "accountId" | "timeoutMs">;
export type SignalReceiptType = "read" | "viewed";
type SignalTarget =
| { type: "recipient"; recipient: string }
| { type: "group"; groupId: string }
@@ -50,6 +54,59 @@ function parseTarget(raw: string): SignalTarget {
return { type: "recipient", recipient: value };
}
type SignalTargetParams = {
recipient?: string[];
groupId?: string;
username?: string[];
};
type SignalTargetAllowlist = {
recipient?: boolean;
group?: boolean;
username?: boolean;
};
function buildTargetParams(
target: SignalTarget,
allow: SignalTargetAllowlist,
): SignalTargetParams | null {
if (target.type === "recipient") {
if (!allow.recipient) return null;
return { recipient: [target.recipient] };
}
if (target.type === "group") {
if (!allow.group) return null;
return { groupId: target.groupId };
}
if (target.type === "username") {
if (!allow.username) return null;
return { username: [target.username] };
}
return null;
}
function resolveSignalRpcContext(
opts: SignalRpcOpts,
accountInfo?: ReturnType<typeof resolveSignalAccount>,
) {
const hasBaseUrl = Boolean(opts.baseUrl?.trim());
const hasAccount = Boolean(opts.account?.trim());
const resolvedAccount =
accountInfo ||
(!hasBaseUrl || !hasAccount
? resolveSignalAccount({
cfg: loadConfig(),
accountId: opts.accountId,
})
: undefined);
const baseUrl = opts.baseUrl?.trim() || resolvedAccount?.baseUrl;
if (!baseUrl) {
throw new Error("Signal base URL is required");
}
const account = opts.account?.trim() || resolvedAccount?.config.account?.trim();
return { baseUrl, account };
}
async function resolveAttachment(
mediaUrl: string,
maxBytes: number,
@@ -74,8 +131,7 @@ export async function sendMessageSignal(
cfg,
accountId: opts.accountId,
});
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
const account = opts.account?.trim() || accountInfo.config.account?.trim();
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
const target = parseTarget(to);
let message = text ?? "";
let messageFromPlaceholder = false;
@@ -129,13 +185,15 @@ export async function sendMessageSignal(
params.attachments = attachments;
}
if (target.type === "recipient") {
params.recipient = [target.recipient];
} else if (target.type === "group") {
params.groupId = target.groupId;
} else if (target.type === "username") {
params.username = [target.username];
const targetParams = buildTargetParams(target, {
recipient: true,
group: true,
username: true,
});
if (!targetParams) {
throw new Error("Signal recipient is required");
}
Object.assign(params, targetParams);
const result = await signalRpcRequest<{ timestamp?: number }>("send", params, {
baseUrl,
@@ -147,3 +205,47 @@ export async function sendMessageSignal(
timestamp,
};
}
export async function sendTypingSignal(
to: string,
opts: SignalRpcOpts & { stop?: boolean } = {},
): Promise<boolean> {
const { baseUrl, account } = resolveSignalRpcContext(opts);
const targetParams = buildTargetParams(parseTarget(to), {
recipient: true,
group: true,
});
if (!targetParams) return false;
const params: Record<string, unknown> = { ...targetParams };
if (account) params.account = account;
if (opts.stop) params.stop = true;
await signalRpcRequest("sendTyping", params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
return true;
}
export async function sendReadReceiptSignal(
to: string,
targetTimestamp: number,
opts: SignalRpcOpts & { type?: SignalReceiptType } = {},
): Promise<boolean> {
if (!Number.isFinite(targetTimestamp) || targetTimestamp <= 0) return false;
const { baseUrl, account } = resolveSignalRpcContext(opts);
const targetParams = buildTargetParams(parseTarget(to), {
recipient: true,
});
if (!targetParams) return false;
const params: Record<string, unknown> = {
...targetParams,
targetTimestamp,
type: opts.type ?? "read",
};
if (account) params.account = account;
await signalRpcRequest("sendReceipt", params, {
baseUrl,
timeoutMs: opts.timeoutMs,
});
return true;
}