refactor: centralize group sender identity
This commit is contained in:
@@ -184,77 +184,4 @@ describe("dispatchReplyFromConfig", () => {
|
||||
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("appends sender meta for non-direct chats when missing", async () => {
|
||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||
handled: false,
|
||||
aborted: false,
|
||||
});
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx: MsgContext = {
|
||||
Provider: "imessage",
|
||||
ChatType: "group",
|
||||
Body: "[iMessage group:1] hello",
|
||||
SenderName: "+15555550123",
|
||||
SenderId: "+15555550123",
|
||||
};
|
||||
|
||||
const replyResolver = vi.fn(async (resolvedCtx: MsgContext) => {
|
||||
expect(resolvedCtx.Body).toContain("\n[from: +15555550123]");
|
||||
return { text: "ok" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not append sender meta when Body already includes a from line", async () => {
|
||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||
handled: false,
|
||||
aborted: false,
|
||||
});
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx: MsgContext = {
|
||||
Provider: "whatsapp",
|
||||
ChatType: "group",
|
||||
Body: "[WhatsApp group:1] hello\\n[from: Bob (+222)]",
|
||||
SenderName: "Bob",
|
||||
SenderId: "+222",
|
||||
};
|
||||
|
||||
const replyResolver = vi.fn(async (resolvedCtx: MsgContext) => {
|
||||
expect(resolvedCtx.Body.match(/\\n\[from:/g)?.length ?? 0).toBe(1);
|
||||
return { text: "ok" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not append sender meta for other providers (scope is signal/imessage only)", async () => {
|
||||
mocks.tryFastAbortFromMessage.mockResolvedValue({
|
||||
handled: false,
|
||||
aborted: false,
|
||||
});
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx: MsgContext = {
|
||||
Provider: "slack",
|
||||
OriginatingChannel: "slack",
|
||||
ChatType: "group",
|
||||
Body: "[Slack #room 2026-01-01T00:00Z] hi",
|
||||
SenderName: "Bob",
|
||||
SenderId: "U123",
|
||||
};
|
||||
|
||||
const replyResolver = vi.fn(async (resolvedCtx: MsgContext) => {
|
||||
expect(resolvedCtx.Body).not.toContain("[from:");
|
||||
return { text: "ok" } satisfies ReplyPayload;
|
||||
});
|
||||
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,8 +22,6 @@ export async function dispatchReplyFromConfig(params: {
|
||||
}): Promise<DispatchFromConfigResult> {
|
||||
const { ctx, cfg, dispatcher } = params;
|
||||
|
||||
maybeAppendSenderMeta(ctx);
|
||||
|
||||
if (shouldSkipDuplicateInbound(ctx)) {
|
||||
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
||||
}
|
||||
@@ -162,40 +160,3 @@ export async function dispatchReplyFromConfig(params: {
|
||||
counts.final += routedFinalCount;
|
||||
return { queuedFinal, counts };
|
||||
}
|
||||
|
||||
function maybeAppendSenderMeta(ctx: MsgContext): void {
|
||||
if (!ctx.Body?.trim()) return;
|
||||
if (ctx.ChatType !== "group") return;
|
||||
if (!shouldInjectSenderMeta(ctx)) return;
|
||||
if (hasSenderMetaLine(ctx.Body)) return;
|
||||
|
||||
const senderLabel = formatSenderLabel(ctx);
|
||||
if (!senderLabel) return;
|
||||
|
||||
const lineBreak = resolveBodyLineBreak(ctx.Body);
|
||||
ctx.Body = `${ctx.Body}${lineBreak}[from: ${senderLabel}]`;
|
||||
}
|
||||
|
||||
function shouldInjectSenderMeta(ctx: MsgContext): boolean {
|
||||
const origin = (ctx.OriginatingChannel ?? ctx.Provider ?? "").toLowerCase();
|
||||
return origin === "imessage" || origin === "signal";
|
||||
}
|
||||
|
||||
function resolveBodyLineBreak(body: string): string {
|
||||
if (body.includes("\n")) return "\n";
|
||||
if (body.includes("\\n")) return "\\n";
|
||||
return "\n";
|
||||
}
|
||||
|
||||
function hasSenderMetaLine(body: string): boolean {
|
||||
return /(^|\n|\\n)\[from:/i.test(body);
|
||||
}
|
||||
|
||||
function formatSenderLabel(ctx: MsgContext): string | null {
|
||||
const senderName = ctx.SenderName?.trim();
|
||||
const senderId = ctx.SenderId?.trim();
|
||||
if (senderName && senderId && senderName !== senderId) {
|
||||
return `${senderName} (${senderId})`;
|
||||
}
|
||||
return senderName ?? senderId ?? null;
|
||||
}
|
||||
|
||||
41
src/auto-reply/reply/inbound-sender-meta.test.ts
Normal file
41
src/auto-reply/reply/inbound-sender-meta.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
|
||||
|
||||
describe("formatInboundBodyWithSenderMeta", () => {
|
||||
it("does nothing for direct messages", () => {
|
||||
const ctx: MsgContext = { ChatType: "direct", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi");
|
||||
});
|
||||
|
||||
it("appends a sender meta line for non-direct messages", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi\n[from: Alice (A1)]");
|
||||
});
|
||||
|
||||
it("prefers SenderE164 in the label when present", () => {
|
||||
const ctx: MsgContext = {
|
||||
ChatType: "group",
|
||||
SenderName: "Bob",
|
||||
SenderId: "bob@s.whatsapp.net",
|
||||
SenderE164: "+222",
|
||||
};
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi\n[from: Bob (+222)]");
|
||||
});
|
||||
|
||||
it("preserves escaped newline style when body uses literal \\\\n", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe(
|
||||
"[X] one\\n[X] two\\n[from: Bob (+222)]",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate a sender meta line when one is already present", () => {
|
||||
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
|
||||
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi\n[from: Alice (A1)]" })).toBe(
|
||||
"[X] hi\n[from: Alice (A1)]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
38
src/auto-reply/reply/inbound-sender-meta.ts
Normal file
38
src/auto-reply/reply/inbound-sender-meta.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { MsgContext } from "../templating.js";
|
||||
|
||||
export function formatInboundBodyWithSenderMeta(params: {
|
||||
body: string;
|
||||
ctx: MsgContext;
|
||||
}): string {
|
||||
const body = params.body;
|
||||
if (!body.trim()) return body;
|
||||
const chatType = params.ctx.ChatType?.trim().toLowerCase();
|
||||
if (!chatType || chatType === "direct") return body;
|
||||
if (hasSenderMetaLine(body)) return body;
|
||||
|
||||
const senderLabel = formatSenderLabel(params.ctx);
|
||||
if (!senderLabel) return body;
|
||||
|
||||
const lineBreak = resolveBodyLineBreak(body);
|
||||
return `${body}${lineBreak}[from: ${senderLabel}]`;
|
||||
}
|
||||
|
||||
function resolveBodyLineBreak(body: string): string {
|
||||
const hasEscaped = body.includes("\\n");
|
||||
const hasNewline = body.includes("\n");
|
||||
if (hasEscaped && !hasNewline) return "\\n";
|
||||
return "\n";
|
||||
}
|
||||
|
||||
function hasSenderMetaLine(body: string): boolean {
|
||||
return /(^|\n|\\n)\[from:/i.test(body);
|
||||
}
|
||||
|
||||
function formatSenderLabel(ctx: MsgContext): string | null {
|
||||
const senderName = ctx.SenderName?.trim();
|
||||
const senderId = (ctx.SenderE164?.trim() || ctx.SenderId?.trim()) ?? "";
|
||||
if (senderName && senderId && senderName !== senderId) {
|
||||
return `${senderName} (${senderId})`;
|
||||
}
|
||||
return senderName ?? (senderId || null);
|
||||
}
|
||||
52
src/auto-reply/reply/session.sender-meta.test.ts
Normal file
52
src/auto-reply/reply/session.sender-meta.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { initSessionState } from "./session.js";
|
||||
|
||||
describe("initSessionState sender meta", () => {
|
||||
it("injects sender meta into BodyStripped for group chats", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "[WhatsApp 123@g.us] ping",
|
||||
ChatType: "group",
|
||||
SenderName: "Bob",
|
||||
SenderE164: "+222",
|
||||
SenderId: "222@s.whatsapp.net",
|
||||
SessionKey: "agent:main:whatsapp:group:123@g.us",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp 123@g.us] ping\n[from: Bob (+222)]");
|
||||
});
|
||||
|
||||
it("does not inject sender meta for direct chats", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-direct-"));
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const cfg = { session: { store: storePath } } as ClawdbotConfig;
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
Body: "[WhatsApp +1] ping",
|
||||
ChatType: "direct",
|
||||
SenderName: "Bob",
|
||||
SenderE164: "+222",
|
||||
SessionKey: "agent:main:whatsapp:dm:+222",
|
||||
},
|
||||
cfg,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp +1] ping");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
|
||||
|
||||
export type SessionInitResult = {
|
||||
sessionCtx: TemplateContext;
|
||||
@@ -305,7 +306,10 @@ export async function initSessionState(params: {
|
||||
...ctx,
|
||||
// Keep BodyStripped aligned with Body (best default for agent prompts).
|
||||
// RawBody is reserved for command/directive parsing and may omit context.
|
||||
BodyStripped: bodyStripped ?? ctx.Body ?? ctx.CommandBody ?? ctx.RawBody,
|
||||
BodyStripped: formatInboundBodyWithSenderMeta({
|
||||
ctx,
|
||||
body: bodyStripped ?? ctx.Body ?? ctx.CommandBody ?? ctx.RawBody ?? "",
|
||||
}),
|
||||
SessionId: sessionId,
|
||||
IsNewSession: isNewSession ? "true" : "false",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user