From 9b12275fe113ca6d857072b711610ada46b88bcb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Jan 2026 08:55:44 +0000 Subject: [PATCH] fix(hooks): emit message_received metadata --- .../reply/dispatch-from-config.test.ts | 64 +++++++++++++++++++ src/auto-reply/reply/dispatch-from-config.ts | 51 +++++++++++++++ src/auto-reply/templating.ts | 1 + 3 files changed, 116 insertions(+) diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 0e8905ccb..4e1982674 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -18,6 +18,12 @@ const diagnosticMocks = vi.hoisted(() => ({ logMessageProcessed: vi.fn(), logSessionStateChange: vi.fn(), })); +const hookMocks = vi.hoisted(() => ({ + runner: { + hasHooks: vi.fn(() => false), + runMessageReceived: vi.fn(async () => {}), + }, +})); vi.mock("./route-reply.js", () => ({ isRoutableChannel: (channel: string | undefined) => @@ -45,6 +51,10 @@ vi.mock("../../logging/diagnostic.js", () => ({ logSessionStateChange: diagnosticMocks.logSessionStateChange, })); +vi.mock("../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => hookMocks.runner, +})); + const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js"); const { resetInboundDedupe } = await import("./inbound-dedupe.js"); @@ -64,6 +74,9 @@ describe("dispatchReplyFromConfig", () => { diagnosticMocks.logMessageQueued.mockReset(); diagnosticMocks.logMessageProcessed.mockReset(); diagnosticMocks.logSessionStateChange.mockReset(); + hookMocks.runner.hasHooks.mockReset(); + hookMocks.runner.hasHooks.mockReturnValue(false); + hookMocks.runner.runMessageReceived.mockReset(); }); it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => { mocks.tryFastAbortFromMessage.mockResolvedValue({ @@ -201,6 +214,57 @@ describe("dispatchReplyFromConfig", () => { expect(replyResolver).toHaveBeenCalledTimes(1); }); + it("emits message_received hook with originating channel metadata", async () => { + mocks.tryFastAbortFromMessage.mockResolvedValue({ + handled: false, + aborted: false, + }); + hookMocks.runner.hasHooks.mockReturnValue(true); + const cfg = {} as ClawdbotConfig; + const dispatcher = createDispatcher(); + const ctx = buildTestCtx({ + Provider: "slack", + Surface: "slack", + OriginatingChannel: "Telegram", + OriginatingTo: "telegram:999", + CommandBody: "/search hello", + RawBody: "raw text", + Body: "body text", + Timestamp: 1710000000000, + MessageSidFull: "sid-full", + SenderId: "user-1", + SenderName: "Alice", + SenderUsername: "alice", + SenderE164: "+15555550123", + AccountId: "acc-1", + }); + + const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; + await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); + + expect(hookMocks.runner.runMessageReceived).toHaveBeenCalledWith( + expect.objectContaining({ + from: ctx.From, + content: "/search hello", + timestamp: 1710000000000, + metadata: expect.objectContaining({ + originatingChannel: "Telegram", + originatingTo: "telegram:999", + messageId: "sid-full", + senderId: "user-1", + senderName: "Alice", + senderUsername: "alice", + senderE164: "+15555550123", + }), + }), + expect.objectContaining({ + channelId: "telegram", + accountId: "acc-1", + conversationId: "telegram:999", + }), + ); + }); + it("emits diagnostics when enabled", async () => { mocks.tryFastAbortFromMessage.mockResolvedValue({ handled: false, diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index eb8d303b7..5885d729e 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -6,6 +6,7 @@ import { logMessageQueued, logSessionStateChange, } from "../../logging/diagnostic.js"; +import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { getReplyFromConfig } from "../reply.js"; import type { FinalizedMsgContext } from "../templating.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; @@ -80,6 +81,56 @@ export async function dispatchReplyFromConfig(params: { return { queuedFinal: false, counts: dispatcher.getQueuedCounts() }; } + const hookRunner = getGlobalHookRunner(); + if (hookRunner?.hasHooks("message_received")) { + const timestamp = + typeof ctx.Timestamp === "number" && Number.isFinite(ctx.Timestamp) + ? ctx.Timestamp + : undefined; + const messageIdForHook = + ctx.MessageSidFull ?? ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast; + const content = + typeof ctx.BodyForCommands === "string" + ? ctx.BodyForCommands + : typeof ctx.RawBody === "string" + ? ctx.RawBody + : typeof ctx.Body === "string" + ? ctx.Body + : ""; + const channelId = (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "").toLowerCase(); + const conversationId = ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? undefined; + + void hookRunner + .runMessageReceived( + { + from: ctx.From ?? "", + content, + timestamp, + metadata: { + to: ctx.To, + provider: ctx.Provider, + surface: ctx.Surface, + threadId: ctx.MessageThreadId, + originatingChannel: ctx.OriginatingChannel, + originatingTo: ctx.OriginatingTo, + messageId: messageIdForHook, + senderId: ctx.SenderId, + senderName: ctx.SenderName, + senderUsername: ctx.SenderUsername, + senderE164: ctx.SenderE164, + }, + }, + { + channelId, + accountId: ctx.AccountId, + conversationId, + }, + ) + .catch((err) => { + logVerbose(`dispatch-from-config: message_received hook failed: ${String(err)}`); + }); + } + // Check if we should route replies to originating channel instead of dispatcher. // Only route when the originating channel is DIFFERENT from the current surface. // This handles cross-provider routing (e.g., message from Telegram being processed diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index d71d39ce3..e9cd6d229 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -87,6 +87,7 @@ export type MsgContext = { SenderUsername?: string; SenderTag?: string; SenderE164?: string; + Timestamp?: number; /** Provider label (e.g. whatsapp, telegram). */ Provider?: string; /** Provider surface label (e.g. discord, slack). Prefer this over `Provider` when available. */