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

@@ -34,6 +34,8 @@ describe("signal createSignalEventHandler inbound contract", () => {
reactionAllowlist: [],
mediaMaxBytes: 1024,
ignoreAttachments: true,
sendReadReceipts: false,
readReceiptsViaDaemon: false,
fetchAttachment: async () => null,
deliverReplies: async () => {},
resolveSignalReactionTargets: () => [],

View File

@@ -23,7 +23,7 @@ import {
clearHistoryEntries,
} from "../../auto-reply/reply/history.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 {
readSessionUpdatedAt,
recordSessionMetaFromInbound,
@@ -50,7 +50,7 @@ import {
resolveSignalRecipient,
resolveSignalSender,
} from "../identity.js";
import { sendMessageSignal } from "../send.js";
import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.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),
};
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,
responsePrefixContextProvider: () => prefixContext,
humanDelay: resolveHumanDelayConfig(deps.cfg, route.agentId),
@@ -209,6 +222,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
onError: (err, info) => {
deps.runtime.error?.(danger(`signal ${info.kind} reply failed: ${String(err)}`));
},
onReplyStart,
});
const { queuedFinal } = await dispatchReplyFromConfig({
@@ -216,6 +230,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
cfg: deps.cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof deps.blockStreaming === "boolean" ? !deps.blockStreaming : undefined,
onModelSelected: (ctx) => {
@@ -227,6 +242,7 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
},
},
});
markDispatchIdle();
if (!queuedFinal) {
if (entry.isGroup && historyKey && deps.historyLimit > 0) {
clearHistoryEntries({ historyMap: deps.groupHistories, historyKey });
@@ -479,6 +495,31 @@ export function createSignalEventHandler(deps: SignalEventHandlerDeps) {
const bodyText = messageText || placeholder || dataMessage.quote?.text?.trim() || "";
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 messageId =
typeof envelope.timestamp === "number" ? String(envelope.timestamp) : undefined;

View File

@@ -76,6 +76,8 @@ export type SignalEventHandlerDeps = {
reactionAllowlist: string[];
mediaMaxBytes: number;
ignoreAttachments: boolean;
sendReadReceipts: boolean;
readReceiptsViaDaemon: boolean;
fetchAttachment: (params: {
baseUrl: string;
account?: string;