fix: normalize routed replies
This commit is contained in:
49
src/auto-reply/reply/normalize-reply.ts
Normal file
49
src/auto-reply/reply/normalize-reply.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
|
||||
export type NormalizeReplyOptions = {
|
||||
responsePrefix?: string;
|
||||
onHeartbeatStrip?: () => void;
|
||||
stripHeartbeat?: boolean;
|
||||
silentToken?: string;
|
||||
};
|
||||
|
||||
export function normalizeReplyPayload(
|
||||
payload: ReplyPayload,
|
||||
opts: NormalizeReplyOptions = {},
|
||||
): ReplyPayload | null {
|
||||
const hasMedia = Boolean(
|
||||
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
const trimmed = payload.text?.trim() ?? "";
|
||||
if (!trimmed && !hasMedia) return null;
|
||||
|
||||
const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN;
|
||||
if (trimmed === silentToken && !hasMedia) return null;
|
||||
|
||||
let text = payload.text ?? undefined;
|
||||
if (text && !trimmed) {
|
||||
// Keep empty text when media exists so media-only replies still send.
|
||||
text = "";
|
||||
}
|
||||
|
||||
const shouldStripHeartbeat = opts.stripHeartbeat ?? true;
|
||||
if (shouldStripHeartbeat && text?.includes(HEARTBEAT_TOKEN)) {
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
||||
if (stripped.shouldSkip && !hasMedia) return null;
|
||||
text = stripped.text;
|
||||
}
|
||||
|
||||
if (
|
||||
opts.responsePrefix &&
|
||||
text &&
|
||||
text.trim() !== HEARTBEAT_TOKEN &&
|
||||
!text.startsWith(opts.responsePrefix)
|
||||
) {
|
||||
text = `${opts.responsePrefix} ${text}`;
|
||||
}
|
||||
|
||||
return { ...payload, text };
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { stripHeartbeatToken } from "../heartbeat.js";
|
||||
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
@@ -45,41 +44,14 @@ export type ReplyDispatcher = {
|
||||
getQueuedCounts: () => Record<ReplyDispatchKind, number>;
|
||||
};
|
||||
|
||||
function normalizeReplyPayload(
|
||||
function normalizeReplyPayloadInternal(
|
||||
payload: ReplyPayload,
|
||||
opts: Pick<ReplyDispatcherOptions, "responsePrefix" | "onHeartbeatStrip">,
|
||||
): ReplyPayload | null {
|
||||
const hasMedia = Boolean(
|
||||
payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0,
|
||||
);
|
||||
const trimmed = payload.text?.trim() ?? "";
|
||||
if (!trimmed && !hasMedia) return null;
|
||||
|
||||
// Avoid sending the explicit silent token when no media is attached.
|
||||
if (trimmed === SILENT_REPLY_TOKEN && !hasMedia) return null;
|
||||
|
||||
let text = payload.text ?? undefined;
|
||||
if (text && !trimmed) {
|
||||
// Keep empty text when media exists so media-only replies still send.
|
||||
text = "";
|
||||
}
|
||||
if (text?.includes(HEARTBEAT_TOKEN)) {
|
||||
const stripped = stripHeartbeatToken(text, { mode: "message" });
|
||||
if (stripped.didStrip) opts.onHeartbeatStrip?.();
|
||||
if (stripped.shouldSkip && !hasMedia) return null;
|
||||
text = stripped.text;
|
||||
}
|
||||
|
||||
if (
|
||||
opts.responsePrefix &&
|
||||
text &&
|
||||
text.trim() !== HEARTBEAT_TOKEN &&
|
||||
!text.startsWith(opts.responsePrefix)
|
||||
) {
|
||||
text = `${opts.responsePrefix} ${text}`;
|
||||
}
|
||||
|
||||
return { ...payload, text };
|
||||
return normalizeReplyPayload(payload, {
|
||||
responsePrefix: opts.responsePrefix,
|
||||
onHeartbeatStrip: opts.onHeartbeatStrip,
|
||||
});
|
||||
}
|
||||
|
||||
export function createReplyDispatcher(
|
||||
@@ -96,7 +68,7 @@ export function createReplyDispatcher(
|
||||
};
|
||||
|
||||
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
|
||||
const normalized = normalizeReplyPayload(payload, options);
|
||||
const normalized = normalizeReplyPayloadInternal(payload, options);
|
||||
if (!normalized) return false;
|
||||
queuedCounts[kind] += 1;
|
||||
pending += 1;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
sendMessageDiscord: vi.fn(async () => ({ messageId: "m1", channelId: "c1" })),
|
||||
@@ -68,6 +69,36 @@ describe("routeReply", () => {
|
||||
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops silent token payloads", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const res = await routeReply({
|
||||
payload: { text: SILENT_REPLY_TOKEN },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(mocks.sendMessageSlack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies responsePrefix when routing", async () => {
|
||||
mocks.sendMessageSlack.mockClear();
|
||||
const cfg = {
|
||||
messages: { responsePrefix: "[clawdbot]" },
|
||||
} as unknown as ClawdbotConfig;
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
cfg,
|
||||
});
|
||||
expect(mocks.sendMessageSlack).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
"[clawdbot] hi",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes thread id to Telegram sends", async () => {
|
||||
mocks.sendMessageTelegram.mockClear();
|
||||
await routeReply({
|
||||
|
||||
@@ -17,6 +17,7 @@ import { sendMessageTelegram } from "../../telegram/send.js";
|
||||
import { sendMessageWhatsApp } from "../../web/outbound.js";
|
||||
import type { OriginatingChannelType } from "../templating.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { normalizeReplyPayload } from "./normalize-reply.js";
|
||||
|
||||
export type RouteReplyParams = {
|
||||
/** The reply payload to send. */
|
||||
@@ -59,13 +60,18 @@ export async function routeReply(
|
||||
params;
|
||||
|
||||
// Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts`
|
||||
const text = payload.text ?? "";
|
||||
const mediaUrls = (payload.mediaUrls?.filter(Boolean) ?? []).length
|
||||
? (payload.mediaUrls?.filter(Boolean) as string[])
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
const normalized = normalizeReplyPayload(payload, {
|
||||
responsePrefix: cfg.messages?.responsePrefix,
|
||||
});
|
||||
if (!normalized) return { ok: true };
|
||||
|
||||
const text = normalized.text ?? "";
|
||||
const mediaUrls = (normalized.mediaUrls?.filter(Boolean) ?? []).length
|
||||
? (normalized.mediaUrls?.filter(Boolean) as string[])
|
||||
: normalized.mediaUrl
|
||||
? [normalized.mediaUrl]
|
||||
: [];
|
||||
const replyToId = payload.replyToId;
|
||||
const replyToId = normalized.replyToId;
|
||||
|
||||
// Skip empty replies.
|
||||
if (!text.trim() && mediaUrls.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user