feat(signal): add typing + read receipts
This commit is contained in:
@@ -7,6 +7,7 @@ Docs: https://docs.clawd.bot
|
|||||||
### Changes
|
### Changes
|
||||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||||
|
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Doctor: warn when gateway.mode is unset with configure/config guidance.
|
- Doctor: warn when gateway.mode is unset with configure/config guidance.
|
||||||
|
|||||||
@@ -100,6 +100,11 @@ Groups:
|
|||||||
- Use `channels.signal.ignoreAttachments` to skip downloading media.
|
- Use `channels.signal.ignoreAttachments` to skip downloading media.
|
||||||
- Group history context uses `channels.signal.historyLimit` (or `channels.signal.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
- Group history context uses `channels.signal.historyLimit` (or `channels.signal.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||||
|
|
||||||
|
## Typing + read receipts
|
||||||
|
- **Typing indicators**: Clawdbot sends typing signals via `signal-cli sendTyping` and refreshes them while a reply is running.
|
||||||
|
- **Read receipts**: when `channels.signal.sendReadReceipts` is true, Clawdbot forwards read receipts for allowed DMs.
|
||||||
|
- Signal-cli does not expose read receipts for groups.
|
||||||
|
|
||||||
## Delivery targets (CLI/cron)
|
## Delivery targets (CLI/cron)
|
||||||
- DMs: `signal:+15551234567` (or plain E.164).
|
- DMs: `signal:+15551234567` (or plain E.164).
|
||||||
- Groups: `signal:group:<groupId>`.
|
- Groups: `signal:group:<groupId>`.
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ describe("signal event handler sender prefix", () => {
|
|||||||
reactionAllowlist: [],
|
reactionAllowlist: [],
|
||||||
mediaMaxBytes: 1000,
|
mediaMaxBytes: 1000,
|
||||||
ignoreAttachments: true,
|
ignoreAttachments: true,
|
||||||
|
sendReadReceipts: false,
|
||||||
|
readReceiptsViaDaemon: false,
|
||||||
fetchAttachment: async () => null,
|
fetchAttachment: async () => null,
|
||||||
deliverReplies: async () => undefined,
|
deliverReplies: async () => undefined,
|
||||||
resolveSignalReactionTargets: () => [],
|
resolveSignalReactionTargets: () => [],
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const sendTypingMock = vi.fn();
|
||||||
|
const sendReadReceiptMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("./send.js", () => ({
|
||||||
|
sendMessageSignal: vi.fn(),
|
||||||
|
sendTypingSignal: (...args: unknown[]) => sendTypingMock(...args),
|
||||||
|
sendReadReceiptSignal: (...args: unknown[]) => sendReadReceiptMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../auto-reply/reply/dispatch-from-config.js", () => ({
|
||||||
|
dispatchReplyFromConfig: vi.fn(
|
||||||
|
async (params: { replyOptions?: { onReplyStart?: () => void } }) => {
|
||||||
|
await params.replyOptions?.onReplyStart?.();
|
||||||
|
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 } };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../pairing/pairing-store.js", () => ({
|
||||||
|
readChannelAllowFromStore: vi.fn().mockResolvedValue([]),
|
||||||
|
upsertChannelPairingRequest: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("signal event handler typing + read receipts", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sendTypingMock.mockReset().mockResolvedValue(true);
|
||||||
|
sendReadReceiptMock.mockReset().mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends typing + read receipt for allowed DMs", async () => {
|
||||||
|
const { createSignalEventHandler } = await import("./monitor/event-handler.js");
|
||||||
|
const handler = createSignalEventHandler({
|
||||||
|
runtime: { log: () => {}, error: () => {} } as any,
|
||||||
|
cfg: {
|
||||||
|
messages: { inbound: { debounceMs: 0 } },
|
||||||
|
channels: { signal: { dmPolicy: "open", allowFrom: ["*"] } },
|
||||||
|
} as any,
|
||||||
|
baseUrl: "http://localhost",
|
||||||
|
account: "+15550009999",
|
||||||
|
accountId: "default",
|
||||||
|
blockStreaming: false,
|
||||||
|
historyLimit: 0,
|
||||||
|
groupHistories: new Map(),
|
||||||
|
textLimit: 4000,
|
||||||
|
dmPolicy: "open",
|
||||||
|
allowFrom: ["*"],
|
||||||
|
groupAllowFrom: ["*"],
|
||||||
|
groupPolicy: "open",
|
||||||
|
reactionMode: "off",
|
||||||
|
reactionAllowlist: [],
|
||||||
|
mediaMaxBytes: 1024,
|
||||||
|
ignoreAttachments: true,
|
||||||
|
sendReadReceipts: true,
|
||||||
|
readReceiptsViaDaemon: false,
|
||||||
|
fetchAttachment: async () => null,
|
||||||
|
deliverReplies: async () => {},
|
||||||
|
resolveSignalReactionTargets: () => [],
|
||||||
|
isSignalReactionMessage: () => false as any,
|
||||||
|
shouldEmitSignalReactionNotification: () => false,
|
||||||
|
buildSignalReactionSystemEventText: () => "reaction",
|
||||||
|
});
|
||||||
|
|
||||||
|
await handler({
|
||||||
|
event: "receive",
|
||||||
|
data: JSON.stringify({
|
||||||
|
envelope: {
|
||||||
|
sourceNumber: "+15550001111",
|
||||||
|
sourceName: "Alice",
|
||||||
|
timestamp: 1700000000000,
|
||||||
|
dataMessage: {
|
||||||
|
message: "hi",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendTypingMock).toHaveBeenCalledWith("signal:+15550001111", expect.any(Object));
|
||||||
|
expect(sendReadReceiptMock).toHaveBeenCalledWith(
|
||||||
|
"signal:+15550001111",
|
||||||
|
1700000000000,
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,6 +25,8 @@ vi.mock("../auto-reply/reply.js", () => ({
|
|||||||
|
|
||||||
vi.mock("./send.js", () => ({
|
vi.mock("./send.js", () => ({
|
||||||
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
|
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
|
||||||
|
sendTypingSignal: vi.fn().mockResolvedValue(true),
|
||||||
|
sendReadReceiptSignal: vi.fn().mockResolvedValue(true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../pairing/pairing-store.js", () => ({
|
vi.mock("../pairing/pairing-store.js", () => ({
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ vi.mock("../auto-reply/reply.js", () => ({
|
|||||||
|
|
||||||
vi.mock("./send.js", () => ({
|
vi.mock("./send.js", () => ({
|
||||||
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
|
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
|
||||||
|
sendTypingSignal: vi.fn().mockResolvedValue(true),
|
||||||
|
sendReadReceiptSignal: vi.fn().mockResolvedValue(true),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../pairing/pairing-store.js", () => ({
|
vi.mock("../pairing/pairing-store.js", () => ({
|
||||||
|
|||||||
@@ -279,8 +279,10 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
|||||||
const reactionAllowlist = normalizeAllowList(accountInfo.config.reactionAllowlist);
|
const reactionAllowlist = normalizeAllowList(accountInfo.config.reactionAllowlist);
|
||||||
const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
|
const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||||
const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
|
const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
|
||||||
|
const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts);
|
||||||
|
|
||||||
const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl;
|
const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl;
|
||||||
|
const readReceiptsViaDaemon = Boolean(autoStart && sendReadReceipts);
|
||||||
let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null;
|
let daemonHandle: ReturnType<typeof spawnSignalDaemon> | null = null;
|
||||||
|
|
||||||
if (autoStart) {
|
if (autoStart) {
|
||||||
@@ -295,7 +297,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
|||||||
receiveMode: opts.receiveMode ?? accountInfo.config.receiveMode,
|
receiveMode: opts.receiveMode ?? accountInfo.config.receiveMode,
|
||||||
ignoreAttachments: opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments,
|
ignoreAttachments: opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments,
|
||||||
ignoreStories: opts.ignoreStories ?? accountInfo.config.ignoreStories,
|
ignoreStories: opts.ignoreStories ?? accountInfo.config.ignoreStories,
|
||||||
sendReadReceipts: opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts,
|
sendReadReceipts,
|
||||||
runtime,
|
runtime,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -335,6 +337,8 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
|||||||
reactionAllowlist,
|
reactionAllowlist,
|
||||||
mediaMaxBytes,
|
mediaMaxBytes,
|
||||||
ignoreAttachments,
|
ignoreAttachments,
|
||||||
|
sendReadReceipts,
|
||||||
|
readReceiptsViaDaemon,
|
||||||
fetchAttachment,
|
fetchAttachment,
|
||||||
deliverReplies,
|
deliverReplies,
|
||||||
resolveSignalReactionTargets,
|
resolveSignalReactionTargets,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ describe("signal createSignalEventHandler inbound contract", () => {
|
|||||||
reactionAllowlist: [],
|
reactionAllowlist: [],
|
||||||
mediaMaxBytes: 1024,
|
mediaMaxBytes: 1024,
|
||||||
ignoreAttachments: true,
|
ignoreAttachments: true,
|
||||||
|
sendReadReceipts: false,
|
||||||
|
readReceiptsViaDaemon: false,
|
||||||
fetchAttachment: async () => null,
|
fetchAttachment: async () => null,
|
||||||
deliverReplies: async () => {},
|
deliverReplies: async () => {},
|
||||||
resolveSignalReactionTargets: () => [],
|
resolveSignalReactionTargets: () => [],
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
clearHistoryEntries,
|
clearHistoryEntries,
|
||||||
} from "../../auto-reply/reply/history.js";
|
} from "../../auto-reply/reply/history.js";
|
||||||
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
||||||
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
import { createReplyDispatcherWithTyping } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||||
import {
|
import {
|
||||||
readSessionUpdatedAt,
|
readSessionUpdatedAt,
|
||||||
recordSessionMetaFromInbound,
|
recordSessionMetaFromInbound,
|
||||||
@@ -50,7 +50,7 @@ import {
|
|||||||
resolveSignalRecipient,
|
resolveSignalRecipient,
|
||||||
resolveSignalSender,
|
resolveSignalSender,
|
||||||
} from "../identity.js";
|
} from "../identity.js";
|
||||||
import { sendMessageSignal } from "../send.js";
|
import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js";
|
||||||
|
|
||||||
import type { SignalEventHandlerDeps, SignalReceivePayload } from "./event-handler.types.js";
|
import type { SignalEventHandlerDeps, SignalReceivePayload } from "./event-handler.types.js";
|
||||||
|
|
||||||
@@ -190,7 +190,20 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
|||||||
identityName: resolveIdentityName(deps.cfg, route.agentId),
|
identityName: resolveIdentityName(deps.cfg, route.agentId),
|
||||||
};
|
};
|
||||||
|
|
||||||
const dispatcher = createReplyDispatcher({
|
const onReplyStart = async () => {
|
||||||
|
try {
|
||||||
|
if (!ctxPayload.To) return;
|
||||||
|
await sendTypingSignal(ctxPayload.To, {
|
||||||
|
baseUrl: deps.baseUrl,
|
||||||
|
account: deps.account,
|
||||||
|
accountId: deps.accountId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`signal typing cue failed for ${ctxPayload.To}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
|
||||||
responsePrefix: resolveEffectiveMessagesConfig(deps.cfg, route.agentId).responsePrefix,
|
responsePrefix: resolveEffectiveMessagesConfig(deps.cfg, route.agentId).responsePrefix,
|
||||||
responsePrefixContextProvider: () => prefixContext,
|
responsePrefixContextProvider: () => prefixContext,
|
||||||
humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId),
|
humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId),
|
||||||
@@ -209,6 +222,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
|||||||
onError: (err, info) => {
|
onError: (err, info) => {
|
||||||
deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`));
|
deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`));
|
||||||
},
|
},
|
||||||
|
onReplyStart,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { queuedFinal } = await dispatchReplyFromConfig({
|
const { queuedFinal } = await dispatchReplyFromConfig({
|
||||||
@@ -216,6 +230,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
|||||||
cfg: deps.cfg,
|
cfg: deps.cfg,
|
||||||
dispatcher,
|
dispatcher,
|
||||||
replyOptions: {
|
replyOptions: {
|
||||||
|
...replyOptions,
|
||||||
disableBlockStreaming:
|
disableBlockStreaming:
|
||||||
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
|
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
|
||||||
onModelSelected: (ctx) => {
|
onModelSelected: (ctx) => {
|
||||||
@@ -227,6 +242,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
markDispatchIdle();
|
||||||
if (!queuedFinal) {
|
if (!queuedFinal) {
|
||||||
if (entry.isGroup && historyKey && deps.historyLimit > 0) {
|
if (entry.isGroup && historyKey && deps.historyLimit > 0) {
|
||||||
clearHistoryEntries({ historyMap: deps.groupHistories, historyKey });
|
clearHistoryEntries({ historyMap: deps.groupHistories, historyKey });
|
||||||
@@ -479,6 +495,31 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
|
|||||||
const bodyText = messageText || placeholder || dataMessage.quote?.text?.trim() || "";
|
const bodyText = messageText || placeholder || dataMessage.quote?.text?.trim() || "";
|
||||||
if (!bodyText) return;
|
if (!bodyText) return;
|
||||||
|
|
||||||
|
const receiptTimestamp =
|
||||||
|
typeof envelope.timestamp === "number"
|
||||||
|
? envelope.timestamp
|
||||||
|
: typeof dataMessage.timestamp === "number"
|
||||||
|
? dataMessage.timestamp
|
||||||
|
: undefined;
|
||||||
|
if (deps.sendReadReceipts && !deps.readReceiptsViaDaemon && !isGroup && receiptTimestamp) {
|
||||||
|
try {
|
||||||
|
await sendReadReceiptSignal(`signal:${senderRecipient}`, receiptTimestamp, {
|
||||||
|
baseUrl: deps.baseUrl,
|
||||||
|
account: deps.account,
|
||||||
|
accountId: deps.accountId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logVerbose(`signal read receipt failed for ${senderDisplay}: ${String(err)}`);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
deps.sendReadReceipts &&
|
||||||
|
!deps.readReceiptsViaDaemon &&
|
||||||
|
!isGroup &&
|
||||||
|
!receiptTimestamp
|
||||||
|
) {
|
||||||
|
logVerbose(`signal read receipt skipped (missing timestamp) for ${senderDisplay}`);
|
||||||
|
}
|
||||||
|
|
||||||
const senderName = envelope.sourceName ?? senderDisplay;
|
const senderName = envelope.sourceName ?? senderDisplay;
|
||||||
const messageId =
|
const messageId =
|
||||||
typeof envelope.timestamp === "number" ? String(envelope.timestamp) : undefined;
|
typeof envelope.timestamp === "number" ? String(envelope.timestamp) : undefined;
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export type SignalEventHandlerDeps = {
|
|||||||
reactionAllowlist: string[];
|
reactionAllowlist: string[];
|
||||||
mediaMaxBytes: number;
|
mediaMaxBytes: number;
|
||||||
ignoreAttachments: boolean;
|
ignoreAttachments: boolean;
|
||||||
|
sendReadReceipts: boolean;
|
||||||
|
readReceiptsViaDaemon: boolean;
|
||||||
fetchAttachment: (params: {
|
fetchAttachment: (params: {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
account?: string;
|
account?: string;
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export type SignalSendResult = {
|
|||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SignalRpcOpts = Pick<SignalSendOpts, "baseUrl" | "account" | "accountId" | "timeoutMs">;
|
||||||
|
|
||||||
|
export type SignalReceiptType = "read" | "viewed";
|
||||||
|
|
||||||
type SignalTarget =
|
type SignalTarget =
|
||||||
| { type: "recipient"; recipient: string }
|
| { type: "recipient"; recipient: string }
|
||||||
| { type: "group"; groupId: string }
|
| { type: "group"; groupId: string }
|
||||||
@@ -50,6 +54,59 @@ function parseTarget(raw: string): SignalTarget {
|
|||||||
return { type: "recipient", recipient: value };
|
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(
|
async function resolveAttachment(
|
||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
@@ -74,8 +131,7 @@ export async function sendMessageSignal(
|
|||||||
cfg,
|
cfg,
|
||||||
accountId: opts.accountId,
|
accountId: opts.accountId,
|
||||||
});
|
});
|
||||||
const baseUrl = opts.baseUrl?.trim() || accountInfo.baseUrl;
|
const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo);
|
||||||
const account = opts.account?.trim() || accountInfo.config.account?.trim();
|
|
||||||
const target = parseTarget(to);
|
const target = parseTarget(to);
|
||||||
let message = text ?? "";
|
let message = text ?? "";
|
||||||
let messageFromPlaceholder = false;
|
let messageFromPlaceholder = false;
|
||||||
@@ -129,13 +185,15 @@ export async function sendMessageSignal(
|
|||||||
params.attachments = attachments;
|
params.attachments = attachments;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.type === "recipient") {
|
const targetParams = buildTargetParams(target, {
|
||||||
params.recipient = [target.recipient];
|
recipient: true,
|
||||||
} else if (target.type === "group") {
|
group: true,
|
||||||
params.groupId = target.groupId;
|
username: true,
|
||||||
} else if (target.type === "username") {
|
});
|
||||||
params.username = [target.username];
|
if (!targetParams) {
|
||||||
|
throw new Error("Signal recipient is required");
|
||||||
}
|
}
|
||||||
|
Object.assign(params, targetParams);
|
||||||
|
|
||||||
const result = await signalRpcRequest<{ timestamp?: number }>("send", params, {
|
const result = await signalRpcRequest<{ timestamp?: number }>("send", params, {
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -147,3 +205,47 @@ export async function sendMessageSignal(
|
|||||||
timestamp,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user