refactor: centralize group sender identity
This commit is contained in:
@@ -328,20 +328,21 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
|||||||
|
|
||||||
const messageId = event.getId() ?? "";
|
const messageId = event.getId() ?? "";
|
||||||
const threadRootId = resolveMatrixThreadRootId({ event, content });
|
const threadRootId = resolveMatrixThreadRootId({ event, content });
|
||||||
const threadTarget = resolveMatrixThreadTarget({
|
const threadTarget = resolveMatrixThreadTarget({
|
||||||
threadReplies,
|
threadReplies,
|
||||||
messageId,
|
messageId,
|
||||||
threadRootId,
|
threadRootId,
|
||||||
isThreadRoot: event.isThreadRoot,
|
isThreadRoot: event.isThreadRoot,
|
||||||
});
|
});
|
||||||
|
|
||||||
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
|
||||||
const body = formatAgentEnvelope({
|
const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
|
||||||
channel: "Matrix",
|
const body = formatAgentEnvelope({
|
||||||
from: senderName,
|
channel: "Matrix",
|
||||||
timestamp: event.getTs() ?? undefined,
|
from: envelopeFrom,
|
||||||
body: textWithId,
|
timestamp: event.getTs() ?? undefined,
|
||||||
});
|
body: textWithId,
|
||||||
|
});
|
||||||
|
|
||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg,
|
cfg,
|
||||||
|
|||||||
@@ -352,15 +352,16 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
|||||||
channelData: activity.channelData,
|
channelData: activity.channelData,
|
||||||
},
|
},
|
||||||
log,
|
log,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
const mediaPayload = buildMSTeamsMediaPayload(mediaList);
|
||||||
const body = formatAgentEnvelope({
|
const envelopeFrom = isDirectMessage ? senderName : conversationType;
|
||||||
channel: "Teams",
|
const body = formatAgentEnvelope({
|
||||||
from: senderName,
|
channel: "Teams",
|
||||||
timestamp,
|
from: envelopeFrom,
|
||||||
body: rawBody,
|
timestamp,
|
||||||
});
|
body: rawBody,
|
||||||
|
});
|
||||||
let combinedBody = body;
|
let combinedBody = body;
|
||||||
const isRoomish = !isDirectMessage;
|
const isRoomish = !isDirectMessage;
|
||||||
const historyKey = isRoomish ? conversationId : undefined;
|
const historyKey = isRoomish ? conversationId : undefined;
|
||||||
|
|||||||
@@ -495,13 +495,13 @@ async function processMessageWithPipeline(params: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
||||||
const fromLabel = isGroup
|
const fromLabel = isGroup
|
||||||
? `group:${chatId} from ${senderName || senderId}`
|
? `group:${chatId}`
|
||||||
: senderName || `user:${senderId}`;
|
: senderName || `user:${senderId}`;
|
||||||
const body = deps.formatAgentEnvelope({
|
const body = deps.formatAgentEnvelope({
|
||||||
channel: "Zalo",
|
channel: "Zalo",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: date ? date * 1000 : undefined,
|
timestamp: date ? date * 1000 : undefined,
|
||||||
body: rawBody,
|
body: rawBody,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -170,13 +170,13 @@ async function processMessage(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const rawBody = content.trim();
|
const rawBody = content.trim();
|
||||||
const fromLabel = isGroup
|
const fromLabel = isGroup
|
||||||
? `group:${chatId} from ${senderName || senderId}`
|
? `group:${chatId}`
|
||||||
: senderName || `user:${senderId}`;
|
: senderName || `user:${senderId}`;
|
||||||
const body = deps.formatAgentEnvelope({
|
const body = deps.formatAgentEnvelope({
|
||||||
channel: "Zalo Personal",
|
channel: "Zalo Personal",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: timestamp ? timestamp * 1000 : undefined,
|
timestamp: timestamp ? timestamp * 1000 : undefined,
|
||||||
body: rawBody,
|
body: rawBody,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -184,77 +184,4 @@ describe("dispatchReplyFromConfig", () => {
|
|||||||
|
|
||||||
expect(replyResolver).toHaveBeenCalledTimes(1);
|
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> {
|
}): Promise<DispatchFromConfigResult> {
|
||||||
const { ctx, cfg, dispatcher } = params;
|
const { ctx, cfg, dispatcher } = params;
|
||||||
|
|
||||||
maybeAppendSenderMeta(ctx);
|
|
||||||
|
|
||||||
if (shouldSkipDuplicateInbound(ctx)) {
|
if (shouldSkipDuplicateInbound(ctx)) {
|
||||||
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
return { queuedFinal: false, counts: dispatcher.getQueuedCounts() };
|
||||||
}
|
}
|
||||||
@@ -162,40 +160,3 @@ export async function dispatchReplyFromConfig(params: {
|
|||||||
counts.final += routedFinalCount;
|
counts.final += routedFinalCount;
|
||||||
return { queuedFinal, counts };
|
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 { resolveCommandAuthorization } from "../command-auth.js";
|
||||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||||
|
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
|
||||||
|
|
||||||
export type SessionInitResult = {
|
export type SessionInitResult = {
|
||||||
sessionCtx: TemplateContext;
|
sessionCtx: TemplateContext;
|
||||||
@@ -305,7 +306,10 @@ export async function initSessionState(params: {
|
|||||||
...ctx,
|
...ctx,
|
||||||
// Keep BodyStripped aligned with Body (best default for agent prompts).
|
// Keep BodyStripped aligned with Body (best default for agent prompts).
|
||||||
// RawBody is reserved for command/directive parsing and may omit context.
|
// 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,
|
SessionId: sessionId,
|
||||||
IsNewSession: isNewSession ? "true" : "false",
|
IsNewSession: isNewSession ? "true" : "false",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -149,11 +149,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!isDirectMessage) {
|
|
||||||
const name = formatDiscordUserTag(author);
|
|
||||||
const id = author.id;
|
|
||||||
combinedBody = `${combinedBody}\n[from: ${name} user id:${id}]`;
|
|
||||||
}
|
|
||||||
const replyContext = resolveReplyContext(message, resolveDiscordMessageText);
|
const replyContext = resolveReplyContext(message, resolveDiscordMessageText);
|
||||||
if (replyContext) {
|
if (replyContext) {
|
||||||
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
|
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
|
||||||
|
|||||||
@@ -330,10 +330,11 @@ export async function prepareSlackMessage(params: {
|
|||||||
contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`,
|
contextKey: `slack:message:${message.channel}:${message.ts ?? "unknown"}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const envelopeFrom = isDirectMessage ? senderName : roomLabel;
|
||||||
const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`;
|
const textWithId = `${rawBody}\n[slack message id: ${message.ts} channel: ${message.channel}]`;
|
||||||
const body = formatAgentEnvelope({
|
const body = formatAgentEnvelope({
|
||||||
channel: "Slack",
|
channel: "Slack",
|
||||||
from: senderName,
|
from: envelopeFrom,
|
||||||
timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
timestamp: message.ts ? Math.round(Number(message.ts) * 1000) : undefined,
|
||||||
body: textWithId,
|
body: textWithId,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { recordChannelActivity } from "../infra/channel-activity.js";
|
|||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import { resolveMentionGating } from "../channels/mention-gating.js";
|
import { resolveMentionGating } from "../channels/mention-gating.js";
|
||||||
import {
|
import {
|
||||||
buildGroupFromLabel,
|
|
||||||
buildGroupLabel,
|
buildGroupLabel,
|
||||||
buildSenderLabel,
|
buildSenderLabel,
|
||||||
buildSenderName,
|
buildSenderName,
|
||||||
@@ -324,15 +323,15 @@ export const buildTelegramMessageContext = async ({
|
|||||||
replyTarget.id ? ` id:${replyTarget.id}` : ""
|
replyTarget.id ? ` id:${replyTarget.id}` : ""
|
||||||
}]\n${replyTarget.body}\n[/Replying]`
|
}]\n${replyTarget.body}\n[/Replying]`
|
||||||
: "";
|
: "";
|
||||||
const groupLabel = isGroup ? buildGroupLabel(msg, chatId, resolvedThreadId) : undefined;
|
const groupLabel = isGroup ? buildGroupLabel(msg, chatId, resolvedThreadId) : undefined;
|
||||||
const body = formatAgentEnvelope({
|
const body = formatAgentEnvelope({
|
||||||
channel: "Telegram",
|
channel: "Telegram",
|
||||||
from: isGroup
|
from: isGroup
|
||||||
? buildGroupFromLabel(msg, chatId, senderId, resolvedThreadId)
|
? (groupLabel ?? `group:${chatId}`)
|
||||||
: buildSenderLabel(msg, senderId || chatId),
|
: buildSenderLabel(msg, senderId || chatId),
|
||||||
timestamp: msg.date ? msg.date * 1000 : undefined,
|
timestamp: msg.date ? msg.date * 1000 : undefined,
|
||||||
body: `${bodyText}${replySuffix}`,
|
body: `${bodyText}${replySuffix}`,
|
||||||
});
|
});
|
||||||
let combinedBody = body;
|
let combinedBody = body;
|
||||||
if (isGroup && historyKey && historyLimit > 0) {
|
if (isGroup && historyKey && historyLimit > 0) {
|
||||||
combinedBody = buildPendingHistoryContextFromMap({
|
combinedBody = buildPendingHistoryContextFromMap({
|
||||||
|
|||||||
@@ -171,15 +171,17 @@ describe("createTelegramBot", () => {
|
|||||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0];
|
const payload = replySpy.mock.calls[0][0];
|
||||||
expect(payload.WasMentioned).toBe(true);
|
expect(payload.WasMentioned).toBe(true);
|
||||||
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 from Ada id:9 2025-01-09T00:00Z\]/);
|
expect(payload.SenderName).toBe("Ada");
|
||||||
});
|
expect(payload.SenderId).toBe("9");
|
||||||
it("includes sender identity in group envelope headers", async () => {
|
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 2025-01-09T00:00Z\]/);
|
||||||
onSpy.mockReset();
|
});
|
||||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
it("keeps group envelope headers stable (sender identity is separate)", async () => {
|
||||||
replySpy.mockReset();
|
onSpy.mockReset();
|
||||||
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
replySpy.mockReset();
|
||||||
|
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
channels: {
|
channels: {
|
||||||
@@ -210,12 +212,13 @@ describe("createTelegramBot", () => {
|
|||||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0];
|
const payload = replySpy.mock.calls[0][0];
|
||||||
expect(payload.Body).toMatch(
|
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||||
/^\[Telegram Ops id:42 from Ada Lovelace \(@ada\) id:99 2025-01-09T00:00Z\]/,
|
expect(payload.SenderId).toBe("99");
|
||||||
);
|
expect(payload.SenderUsername).toBe("ada");
|
||||||
});
|
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 2025-01-09T00:00Z\]/);
|
||||||
|
});
|
||||||
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
setMessageReactionSpy.mockReset();
|
setMessageReactionSpy.mockReset();
|
||||||
|
|||||||
@@ -584,7 +584,9 @@ describe("createTelegramBot", () => {
|
|||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0];
|
const payload = replySpy.mock.calls[0][0];
|
||||||
expect(payload.WasMentioned).toBe(true);
|
expect(payload.WasMentioned).toBe(true);
|
||||||
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 from Ada id:9 2025-01-09T00:00Z\]/);
|
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 2025-01-09T00:00Z\]/);
|
||||||
|
expect(payload.SenderName).toBe("Ada");
|
||||||
|
expect(payload.SenderId).toBe("9");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes sender identity in group envelope headers", async () => {
|
it("includes sender identity in group envelope headers", async () => {
|
||||||
@@ -623,9 +625,10 @@ describe("createTelegramBot", () => {
|
|||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0];
|
const payload = replySpy.mock.calls[0][0];
|
||||||
expect(payload.Body).toMatch(
|
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 2025-01-09T00:00Z\]/);
|
||||||
/^\[Telegram Ops id:42 from Ada Lovelace \(@ada\) id:99 2025-01-09T00:00Z\]/,
|
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||||
);
|
expect(payload.SenderId).toBe("99");
|
||||||
|
expect(payload.SenderUsername).toBe("ada");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
||||||
|
|||||||
@@ -98,17 +98,6 @@ export function buildGroupLabel(
|
|||||||
return `group:${chatId}${topicSuffix}`;
|
return `group:${chatId}${topicSuffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGroupFromLabel(
|
|
||||||
msg: TelegramMessage,
|
|
||||||
chatId: number | string,
|
|
||||||
senderId?: number | string,
|
|
||||||
messageThreadId?: number,
|
|
||||||
) {
|
|
||||||
const groupLabel = buildGroupLabel(msg, chatId, messageThreadId);
|
|
||||||
const senderLabel = buildSenderLabel(msg, senderId);
|
|
||||||
return `${groupLabel} from ${senderLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasBotMention(msg: TelegramMessage, botUsername: string) {
|
export function hasBotMention(msg: TelegramMessage, botUsername: string) {
|
||||||
const text = (msg.text ?? msg.caption ?? "").toLowerCase();
|
const text = (msg.text ?? msg.caption ?? "").toLowerCase();
|
||||||
if (text.includes(`@${botUsername}`)) return true;
|
if (text.includes(`@${botUsername}`)) return true;
|
||||||
|
|||||||
@@ -216,12 +216,14 @@ describe("broadcast groups", () => {
|
|||||||
|
|
||||||
expect(resolver).toHaveBeenCalledTimes(2);
|
expect(resolver).toHaveBeenCalledTimes(2);
|
||||||
for (const call of resolver.mock.calls.slice(0, 2)) {
|
for (const call of resolver.mock.calls.slice(0, 2)) {
|
||||||
const payload = call[0] as { Body: string };
|
const payload = call[0] as { Body: string; SenderName?: string; SenderE164?: string; SenderId?: string };
|
||||||
expect(payload.Body).toContain("Chat messages since your last reply");
|
expect(payload.Body).toContain("Chat messages since your last reply");
|
||||||
expect(payload.Body).toContain("Alice (+111): hello group");
|
expect(payload.Body).toContain("Alice (+111): hello group");
|
||||||
expect(payload.Body).toContain("[message_id: g1]");
|
expect(payload.Body).toContain("[message_id: g1]");
|
||||||
expect(payload.Body).toContain("@bot ping");
|
expect(payload.Body).toContain("@bot ping");
|
||||||
expect(payload.Body).toContain("[from: Bob (+222)]");
|
expect(payload.SenderName).toBe("Bob");
|
||||||
|
expect(payload.SenderE164).toBe("+222");
|
||||||
|
expect(payload.SenderId).toBe("+222");
|
||||||
}
|
}
|
||||||
|
|
||||||
await capturedOnMessage?.({
|
await capturedOnMessage?.({
|
||||||
|
|||||||
@@ -166,7 +166,9 @@ describe("web auto-reply", () => {
|
|||||||
expect(payload.Body).toContain("Alice (+111): hello group");
|
expect(payload.Body).toContain("Alice (+111): hello group");
|
||||||
expect(payload.Body).toContain("[message_id: g1]");
|
expect(payload.Body).toContain("[message_id: g1]");
|
||||||
expect(payload.Body).toContain("@bot ping");
|
expect(payload.Body).toContain("@bot ping");
|
||||||
expect(payload.Body).toContain("[from: Bob (+222)]");
|
expect(payload.SenderName).toBe("Bob");
|
||||||
|
expect(payload.SenderE164).toBe("+222");
|
||||||
|
expect(payload.SenderId).toBe("+222");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("bypasses mention gating for owner /new in group chats", async () => {
|
it("bypasses mention gating for owner /new in group chats", async () => {
|
||||||
|
|||||||
@@ -183,7 +183,9 @@ describe("web auto-reply", () => {
|
|||||||
expect(payload.Body).not.toContain("Chat messages since your last reply");
|
expect(payload.Body).not.toContain("Chat messages since your last reply");
|
||||||
expect(payload.Body).not.toContain("Alice (+111): first");
|
expect(payload.Body).not.toContain("Alice (+111): first");
|
||||||
expect(payload.Body).not.toContain("[message_id: g-always-1]");
|
expect(payload.Body).not.toContain("[message_id: g-always-1]");
|
||||||
expect(payload.Body).toContain("Bob: second");
|
expect(payload.Body).toContain("second");
|
||||||
|
expect(payload.SenderName).toBe("Bob");
|
||||||
|
expect(payload.SenderE164).toBe("+222");
|
||||||
expect(reply).toHaveBeenCalledTimes(1);
|
expect(reply).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
await cleanup();
|
await cleanup();
|
||||||
|
|||||||
@@ -22,12 +22,8 @@ export function buildInboundLine(params: {
|
|||||||
hasAllowFrom: (cfg.channels?.whatsapp?.allowFrom?.length ?? 0) > 0,
|
hasAllowFrom: (cfg.channels?.whatsapp?.allowFrom?.length ?? 0) > 0,
|
||||||
});
|
});
|
||||||
const prefixStr = messagePrefix ? `${messagePrefix} ` : "";
|
const prefixStr = messagePrefix ? `${messagePrefix} ` : "";
|
||||||
const senderLabel =
|
|
||||||
msg.chatType === "group" ? `${msg.senderName ?? msg.senderE164 ?? "Someone"}: ` : "";
|
|
||||||
const replyContext = formatReplyContext(msg);
|
const replyContext = formatReplyContext(msg);
|
||||||
const baseLine = `${prefixStr}${senderLabel}${msg.body}${
|
const baseLine = `${prefixStr}${msg.body}${replyContext ? `\n\n${replyContext}` : ""}`;
|
||||||
replyContext ? `\n\n${replyContext}` : ""
|
|
||||||
}`;
|
|
||||||
|
|
||||||
// Wrap with standardized envelope for the agent.
|
// Wrap with standardized envelope for the agent.
|
||||||
return formatAgentEnvelope({
|
return formatAgentEnvelope({
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from "../../../auto-reply/reply/response-prefix-template.js";
|
} from "../../../auto-reply/reply/response-prefix-template.js";
|
||||||
import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
|
import { resolveTextChunkLimit } from "../../../auto-reply/chunk.js";
|
||||||
import { formatAgentEnvelope } from "../../../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../../../auto-reply/envelope.js";
|
||||||
import { buildHistoryContext } from "../../../auto-reply/reply/history.js";
|
import { buildHistoryContextFromEntries, type HistoryEntry } from "../../../auto-reply/reply/history.js";
|
||||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../../../auto-reply/reply/provider-dispatcher.js";
|
import { dispatchReplyWithBufferedBlockDispatcher } from "../../../auto-reply/reply/provider-dispatcher.js";
|
||||||
import type { getReplyFromConfig } from "../../../auto-reply/reply.js";
|
import type { getReplyFromConfig } from "../../../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../../../auto-reply/types.js";
|
import type { ReplyPayload } from "../../../auto-reply/types.js";
|
||||||
@@ -77,30 +77,27 @@ export async function processMessage(params: {
|
|||||||
if (params.msg.chatType === "group") {
|
if (params.msg.chatType === "group") {
|
||||||
const history = params.groupHistory ?? params.groupHistories.get(params.groupHistoryKey) ?? [];
|
const history = params.groupHistory ?? params.groupHistories.get(params.groupHistoryKey) ?? [];
|
||||||
if (history.length > 0) {
|
if (history.length > 0) {
|
||||||
const lineBreak = "\\n";
|
const historyEntries: HistoryEntry[] = history.map((m) => ({
|
||||||
const historyText = history
|
sender: m.sender,
|
||||||
.map((m) => {
|
body: m.body,
|
||||||
const bodyWithId = m.id ? `${m.body}\n[message_id: ${m.id}]` : m.body;
|
timestamp: m.timestamp,
|
||||||
|
messageId: m.id,
|
||||||
|
}));
|
||||||
|
combinedBody = buildHistoryContextFromEntries({
|
||||||
|
entries: historyEntries,
|
||||||
|
currentMessage: combinedBody,
|
||||||
|
excludeLast: false,
|
||||||
|
formatEntry: (entry) => {
|
||||||
|
const bodyWithId = entry.messageId ? `${entry.body}\n[message_id: ${entry.messageId}]` : entry.body;
|
||||||
return formatAgentEnvelope({
|
return formatAgentEnvelope({
|
||||||
channel: "WhatsApp",
|
channel: "WhatsApp",
|
||||||
from: conversationId,
|
from: conversationId,
|
||||||
timestamp: m.timestamp,
|
timestamp: entry.timestamp,
|
||||||
body: `${m.sender}: ${bodyWithId}`,
|
body: `${entry.sender}: ${bodyWithId}`,
|
||||||
});
|
});
|
||||||
})
|
},
|
||||||
.join(lineBreak);
|
|
||||||
combinedBody = buildHistoryContext({
|
|
||||||
historyText,
|
|
||||||
currentMessage: combinedBody,
|
|
||||||
lineBreak,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Always surface who sent the triggering message so the agent can address them.
|
|
||||||
const senderLabel =
|
|
||||||
params.msg.senderName && params.msg.senderE164
|
|
||||||
? `${params.msg.senderName} (${params.msg.senderE164})`
|
|
||||||
: (params.msg.senderName ?? params.msg.senderE164 ?? "Unknown");
|
|
||||||
combinedBody = `${combinedBody}\\n[from: ${senderLabel}]`;
|
|
||||||
shouldClearGroupHistory = !(params.suppressGroupHistoryClear ?? false);
|
shouldClearGroupHistory = !(params.suppressGroupHistoryClear ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user