import { describe, it, expect } from "bun:test"; import { normalizeSlackDirectMessage, normalizeSlackChannelMessage, normalizeSlackAppMention, } from "./message-normalizer.js"; import { normalizeSlackMessageEdit, normalizeSlackMessageDelete, } from "./message-change-normalizer.js"; import { normalizeSlackBlockActions } from "./block-actions-normalizer.js"; import { normalizeSlackReactionAdded, normalizeSlackReactionRemoved, } from "./reaction-normalizer.js"; import { enrichNormalizedActor, slackBotContactNote } from "./actor.js"; import type { SlackBlockActionsPayload, SlackDirectMessageEvent, SlackChannelMessageEvent, SlackAppMentionEvent, SlackMessageChangedEvent, SlackMessageDeletedEvent, SlackFile, } from "./message-schemas.js"; import type { GatewayConfig } from "../config.js"; function makeConfig(overrides?: Partial): GatewayConfig { return { routingEntries: [], unmappedPolicy: "default", defaultAssistantId: "ast-1", ...overrides, } as GatewayConfig; } function makeBlockActionsPayload( overrides?: Partial<{ actionId: string; actionValue: string; userId: string; channelId: string; messageTs: string; triggerId: string; }>, ): SlackBlockActionsPayload { return { type: "block_actions", trigger_id: overrides?.triggerId ?? "trigger-123", user: { id: overrides?.userId ?? "U123", username: "alice", name: "Alice" }, channel: { id: overrides?.channelId ?? "C456", name: "general" }, message: { ts: overrides?.messageTs ?? "1234567890.123456", text: "Choose an option", }, actions: [ { action_id: overrides?.actionId ?? "approve_btn", value: overrides?.actionValue ?? "apr:run1:approve", type: "button", block_id: "block-1", action_ts: "1234567890.654321", }, ], }; } function makeReactionAddedEvent( overrides?: Partial<{ user: string; reaction: string; channelId: string; messageTs: string; }>, ) { return { type: "reaction_added", user: overrides?.user ?? "U123", reaction: overrides?.reaction ?? "thumbsup", item: { type: "message", channel: overrides?.channelId ?? "C456", ts: overrides?.messageTs ?? "1234567890.123456", }, }; } function makeReactionRemovedEvent( overrides?: Partial<{ user: string; reaction: string; channelId: string; messageTs: string; }>, ) { return { type: "reaction_removed", user: overrides?.user ?? "U123", reaction: overrides?.reaction ?? "thumbsup", item: { type: "message", channel: overrides?.channelId ?? "C456", ts: overrides?.messageTs ?? "1234567890.123456", }, }; } describe("normalizeSlackBlockActions", () => { it("normalizes a block_actions payload with callbackData", () => { const config = makeConfig(); const payload = makeBlockActionsPayload(); const result = normalizeSlackBlockActions(payload, "env-1", config); expect(result).not.toBeNull(); expect(result!.event.sourceChannel).toBe("slack"); expect(result!.event.message.callbackData).toBe("apr:run1:approve"); expect(result!.event.message.callbackQueryId).toBe("trigger-123"); expect(result!.event.message.content).toBe("apr:run1:approve"); expect(result!.event.message.conversationExternalId).toBe("C456"); expect(result!.event.actor.actorExternalId).toBe("U123"); expect(result!.event.actor.username).toBe("alice"); expect(result!.event.actor.displayName).toBe("Alice"); expect(result!.event.message.externalMessageId).toBe( "C456:1234567890.123456:1234567890.654321", ); expect(result!.event.source.messageId).toBe("1234567890.123456"); expect(result!.channel).toBe("C456"); expect(result!.threadTs).toBe("1234567890.123456"); }); it("uses thread root timestamp when message is a threaded reply", () => { const config = makeConfig(); const payload = makeBlockActionsPayload(); // Simulate a button click on a message that lives inside a thread payload.message = { ts: "1234567890.999999", thread_ts: "1234567890.000001", text: "Choose an option", }; const result = normalizeSlackBlockActions(payload, "env-thread", config); expect(result).not.toBeNull(); // threadTs should be the thread root, not the clicked message's ts expect(result!.threadTs).toBe("1234567890.000001"); }); it("falls back to message ts when no thread_ts is present", () => { const config = makeConfig(); const payload = makeBlockActionsPayload(); const result = normalizeSlackBlockActions(payload, "env-no-thread", config); expect(result).not.toBeNull(); expect(result!.threadTs).toBe("1234567890.123456"); }); it("generates unique externalMessageId per click via action_ts", () => { const config = makeConfig(); const payload1 = makeBlockActionsPayload(); payload1.actions![0].action_ts = "1000000000.000001"; const payload2 = makeBlockActionsPayload(); payload2.actions![0].action_ts = "1000000000.000002"; const result1 = normalizeSlackBlockActions(payload1, "env-same", config); const result2 = normalizeSlackBlockActions(payload2, "env-same", config); expect(result1).not.toBeNull(); expect(result2).not.toBeNull(); expect(result1!.event.message.externalMessageId).not.toBe( result2!.event.message.externalMessageId, ); }); it("falls back to action_id when value is undefined", () => { const config = makeConfig(); const payload = makeBlockActionsPayload({ actionValue: undefined as unknown as string, }); payload.actions![0].value = undefined; const result = normalizeSlackBlockActions(payload, "env-2", config); expect(result).not.toBeNull(); expect(result!.event.message.callbackData).toBe("approve_btn"); expect(result!.event.message.content).toBe("approve_btn"); }); it("returns null when actions array is empty", () => { const config = makeConfig(); const payload = makeBlockActionsPayload(); payload.actions = []; const result = normalizeSlackBlockActions(payload, "env-3", config); expect(result).toBeNull(); }); it("returns null when user ID is missing", () => { const config = makeConfig(); const payload = makeBlockActionsPayload(); payload.user = { id: "", username: "alice" }; const result = normalizeSlackBlockActions(payload, "env-4", config); expect(result).toBeNull(); }); it("returns null when channel is missing", () => { const config = makeConfig(); const payload = makeBlockActionsPayload(); payload.channel = undefined; const result = normalizeSlackBlockActions(payload, "env-5", config); expect(result).toBeNull(); }); it("returns null when routing rejects", () => { const config = makeConfig({ unmappedPolicy: "reject", defaultAssistantId: undefined, }); const payload = makeBlockActionsPayload(); const result = normalizeSlackBlockActions(payload, "env-6", config); expect(result).toBeNull(); }); // LUM-2414: a guardian's Block Kit button click (Approve/Reject on an // access-request card) arrives on their DM channel (D...). When that channel // isn't in the routing table the normalizer must fall back to the default // assistant rather than silently dropping the click — mirroring the fallback // in normalizeSlackDirectMessage, normalizeSlackReaction, and the message // edit/delete normalizers. it("falls back to the default assistant for an unrouted DM channel", () => { const config = makeConfig({ defaultAssistantId: "default-ast", unmappedPolicy: "reject", }); const payload = makeBlockActionsPayload({ channelId: "D999" }); const result = normalizeSlackBlockActions( payload, "env-dm-fallback", config, ); expect(result).not.toBeNull(); expect(result!.channel).toBe("D999"); expect(result!.routing.assistantId).toBe("default-ast"); expect(result!.routing.routeSource).toBe("default"); }); it("uses explicit routing for a DM channel that is in the routing table", () => { // The DM fallback only fires when routing rejects; an explicit // conversation_id route for the DM channel must still win. const config = makeConfig({ defaultAssistantId: "default-ast", unmappedPolicy: "reject", routingEntries: [ { type: "conversation_id", key: "D789", assistantId: "explicit-ast" }, ], }); const payload = makeBlockActionsPayload({ channelId: "D789" }); const result = normalizeSlackBlockActions( payload, "env-dm-explicit", config, ); expect(result).not.toBeNull(); expect(result!.routing.assistantId).toBe("explicit-ast"); expect(result!.routing.routeSource).toBe("conversation_id"); }); it("returns null for an unrouted non-DM channel even when a default assistant exists", () => { // Non-DM channels keep strict routing: the DM-only fallback must not leak // into public channels, so a rejected route stays dropped even though // defaultAssistantId is set. (The "returns null when routing rejects" case // above uses defaultAssistantId: undefined and so can't pin this guard.) const config = makeConfig({ defaultAssistantId: "default-ast", unmappedPolicy: "reject", }); const payload = makeBlockActionsPayload({ channelId: "C456" }); const result = normalizeSlackBlockActions( payload, "env-non-dm-reject", config, ); expect(result).toBeNull(); }); }); describe("block_actions tolerant validation", () => { const config = makeConfig(); it("drops a non-object payload instead of throwing", () => { expect(normalizeSlackBlockActions("nope", "env-x1", config)).toBeNull(); expect(normalizeSlackBlockActions(null, "env-x2", config)).toBeNull(); expect(normalizeSlackBlockActions(42, "env-x3", config)).toBeNull(); }); it("collapses a non-array actions field and drops the payload", () => { const result = normalizeSlackBlockActions( { type: "block_actions", trigger_id: "t1", user: { id: "U123" }, channel: { id: "C456" }, actions: "not-an-array", }, "env-x4", config, ); expect(result).toBeNull(); }); it("drops an action carrying neither value nor action_id", () => { const result = normalizeSlackBlockActions( { type: "block_actions", trigger_id: "t1", user: { id: "U123" }, channel: { id: "C456" }, actions: [{ type: "button" }], }, "env-x5", config, ); expect(result).toBeNull(); }); it("drops a payload missing the user id", () => { const result = normalizeSlackBlockActions( { type: "block_actions", trigger_id: "t1", channel: { id: "C456" }, actions: [{ action_id: "approve", type: "button" }], }, "env-x6", config, ); expect(result).toBeNull(); }); it("drops a payload missing the channel id", () => { const result = normalizeSlackBlockActions( { type: "block_actions", trigger_id: "t1", user: { id: "U123" }, actions: [{ action_id: "approve", type: "button" }], }, "env-x7", config, ); expect(result).toBeNull(); }); it("collapses a non-string action value, falling back to action_id", () => { const result = normalizeSlackBlockActions( { type: "block_actions", trigger_id: "t1", user: { id: "U123" }, channel: { id: "C456" }, actions: [ { action_id: "approve_btn", value: { bad: true }, type: "button" }, ], }, "env-x8", config, ); expect(result).not.toBeNull(); expect(result!.event.message.callbackData).toBe("approve_btn"); }); it("preserves unknown extra keys verbatim in raw", () => { const payload = { type: "block_actions", trigger_id: "t1", user: { id: "U123" }, channel: { id: "C456" }, actions: [ { action_id: "approve", value: "apr:run1:approve", type: "button" }, ], unexpected_field: "surprise", }; const result = normalizeSlackBlockActions(payload, "env-x9", config); expect(result).not.toBeNull(); expect(result!.event.raw).toEqual(payload); }); }); describe("normalizeSlackReactionAdded", () => { it("normalizes a reaction_added event with callbackData", () => { const config = makeConfig(); const event = makeReactionAddedEvent(); const result = normalizeSlackReactionAdded(event, "evt-1", config); expect(result).not.toBeNull(); expect(result!.event.sourceChannel).toBe("slack"); expect(result!.event.message.callbackData).toBe("reaction:thumbsup"); expect(result!.event.message.content).toBe("reaction:thumbsup"); expect(result!.event.message.conversationExternalId).toBe("C456"); expect(result!.event.message.externalMessageId).toBe( "C456:1234567890.123456:thumbsup:U123", ); expect(result!.event.actor.actorExternalId).toBe("U123"); expect(result!.event.source.messageId).toBe("1234567890.123456"); expect(result!.channel).toBe("C456"); expect(result!.threadTs).toBe("1234567890.123456"); }); it("returns null when user is missing", () => { const config = makeConfig(); const event = makeReactionAddedEvent({ user: "" }); const result = normalizeSlackReactionAdded(event, "evt-2", config); expect(result).toBeNull(); }); it("returns null when item channel is missing", () => { const config = makeConfig(); const event = makeReactionAddedEvent(); event.item.channel = ""; const result = normalizeSlackReactionAdded(event, "evt-3", config); expect(result).toBeNull(); }); it("returns null when item ts is missing", () => { const config = makeConfig(); const event = makeReactionAddedEvent(); event.item.ts = ""; const result = normalizeSlackReactionAdded(event, "evt-4", config); expect(result).toBeNull(); }); it("returns null when routing rejects", () => { const config = makeConfig({ unmappedPolicy: "reject", defaultAssistantId: undefined, }); const event = makeReactionAddedEvent(); const result = normalizeSlackReactionAdded(event, "evt-5", config); expect(result).toBeNull(); }); it("uses the reaction name in callbackData", () => { const config = makeConfig(); const event = makeReactionAddedEvent({ reaction: "white_check_mark" }); const result = normalizeSlackReactionAdded(event, "evt-6", config); expect(result).not.toBeNull(); expect(result!.event.message.callbackData).toBe( "reaction:white_check_mark", ); }); it("normalizes reactions on a non-bot-thread channel message", () => { // Filter expansion: PR 3 admits reactions on any subscribed channel, // not just tracked bot-thread messages. The normalizer itself doesn't // gate on thread tracking — that filter lives in socket-mode.ts. // Verify the normalizer happily produces a valid event for an arbitrary // public-channel message ts when routing matches the channel. const config = makeConfig({ defaultAssistantId: undefined, unmappedPolicy: "reject", routingEntries: [ { type: "conversation_id", key: "C500", assistantId: "ast-1" }, ], }); const event = makeReactionAddedEvent({ channelId: "C500", messageTs: "1700000000.999999", }); const result = normalizeSlackReactionAdded(event, "evt-expand", config); expect(result).not.toBeNull(); expect(result!.event.message.callbackData).toBe("reaction:thumbsup"); expect(result!.channel).toBe("C500"); expect(result!.threadTs).toBe("1700000000.999999"); expect(result!.routing.assistantId).toBe("ast-1"); expect(result!.event.message.externalMessageId).toBe( "C500:1700000000.999999:thumbsup:U123", ); }); }); describe("normalizeSlackReactionRemoved", () => { it("normalizes a reaction_removed event with reaction_removed: prefix", () => { const config = makeConfig(); const event = makeReactionRemovedEvent(); const result = normalizeSlackReactionRemoved(event, "evt-r-1", config); expect(result).not.toBeNull(); expect(result!.event.sourceChannel).toBe("slack"); expect(result!.event.message.callbackData).toBe( "reaction_removed:thumbsup", ); expect(result!.event.message.content).toBe("reaction_removed:thumbsup"); expect(result!.event.message.conversationExternalId).toBe("C456"); expect(result!.event.message.externalMessageId).toBe( "C456:1234567890.123456:thumbsup:U123:removed", ); expect(result!.event.actor.actorExternalId).toBe("U123"); expect(result!.event.source.messageId).toBe("1234567890.123456"); expect(result!.channel).toBe("C456"); expect(result!.threadTs).toBe("1234567890.123456"); }); it("uses the reaction name in callbackData", () => { const config = makeConfig(); const event = makeReactionRemovedEvent({ reaction: "white_check_mark" }); const result = normalizeSlackReactionRemoved(event, "evt-r-2", config); expect(result).not.toBeNull(); expect(result!.event.message.callbackData).toBe( "reaction_removed:white_check_mark", ); }); it("returns null when user is missing", () => { const config = makeConfig(); const event = makeReactionRemovedEvent({ user: "" }); const result = normalizeSlackReactionRemoved(event, "evt-r-3", config); expect(result).toBeNull(); }); it("returns null when item channel is missing", () => { const config = makeConfig(); const event = makeReactionRemovedEvent(); event.item.channel = ""; const result = normalizeSlackReactionRemoved(event, "evt-r-4", config); expect(result).toBeNull(); }); it("returns null when item ts is missing", () => { const config = makeConfig(); const event = makeReactionRemovedEvent(); event.item.ts = ""; const result = normalizeSlackReactionRemoved(event, "evt-r-5", config); expect(result).toBeNull(); }); // Self-authored reactions are now filtered upstream in processEventPayload, // not by the normalizer. it("does not filter reactions by bot user (handled upstream)", () => { const config = makeConfig(); const event = makeReactionRemovedEvent({ user: "UBOT" }); const result = normalizeSlackReactionRemoved(event, "evt-r-6", config); expect(result).not.toBeNull(); expect(result!.event.actor.actorExternalId).toBe("UBOT"); }); it("returns null when routing rejects on a public channel", () => { const config = makeConfig({ unmappedPolicy: "reject", defaultAssistantId: undefined, }); const event = makeReactionRemovedEvent(); const result = normalizeSlackReactionRemoved(event, "evt-r-7", config); expect(result).toBeNull(); }); it("falls back to default assistant for unrouted DM channels", () => { const config = makeConfig({ defaultAssistantId: "default-ast", unmappedPolicy: "reject", }); const event = makeReactionRemovedEvent({ channelId: "D999", messageTs: "111.222", }); const result = normalizeSlackReactionRemoved(event, "evt-r-8", config); expect(result).not.toBeNull(); expect(result!.channel).toBe("D999"); expect(result!.event.message.externalMessageId).toBe( "D999:111.222:thumbsup:U123:removed", ); }); it("normalizes reaction_removed in a DM channel", () => { const config = makeConfig(); const event = makeReactionRemovedEvent({ channelId: "D789", messageTs: "1700000000.000001", }); const result = normalizeSlackReactionRemoved(event, "evt-r-9", config); expect(result).not.toBeNull(); expect(result!.channel).toBe("D789"); expect(result!.event.message.callbackData).toBe( "reaction_removed:thumbsup", ); expect(result!.event.message.externalMessageId).toBe( "D789:1700000000.000001:thumbsup:U123:removed", ); }); it("normalizes reaction_removed on a non-bot-thread channel message", () => { // Filter expansion: PR 3 admits reactions on any subscribed channel, // not just tracked bot-thread messages. Verify the removed normalizer // produces a valid event for an arbitrary public-channel message ts. const config = makeConfig({ defaultAssistantId: undefined, unmappedPolicy: "reject", routingEntries: [ { type: "conversation_id", key: "C500", assistantId: "ast-1" }, ], }); const event = makeReactionRemovedEvent({ channelId: "C500", messageTs: "1700000000.999999", }); const result = normalizeSlackReactionRemoved(event, "evt-r-10", config); expect(result).not.toBeNull(); expect(result!.routing.assistantId).toBe("ast-1"); expect(result!.event.message.externalMessageId).toBe( "C500:1700000000.999999:thumbsup:U123:removed", ); }); it("produces a different externalMessageId than reaction_added for the same emoji+user+message", () => { // Critical for dedup: an add followed by a remove of the same emoji by // the same user on the same message must not collide on externalMessageId. const config = makeConfig(); const addEvent = makeReactionAddedEvent({ reaction: "fire", messageTs: "111.222", }); const removeEvent = makeReactionRemovedEvent({ reaction: "fire", messageTs: "111.222", }); const addResult = normalizeSlackReactionAdded(addEvent, "evt-add", config); const removeResult = normalizeSlackReactionRemoved( removeEvent, "evt-remove", config, ); expect(addResult).not.toBeNull(); expect(removeResult).not.toBeNull(); expect(addResult!.event.message.externalMessageId).not.toBe( removeResult!.event.message.externalMessageId, ); }); }); describe("reaction event tolerant validation", () => { const config = makeConfig(); it("drops a non-object payload instead of throwing", () => { // The socket frame is unvalidated JSON.parse output; a scalar where an // object is expected must be dropped at the boundary, not crash the batch. expect( normalizeSlackReactionAdded("not-an-object", "evt-x1", config), ).toBeNull(); expect(normalizeSlackReactionAdded(null, "evt-x2", config)).toBeNull(); expect(normalizeSlackReactionAdded(42, "evt-x3", config)).toBeNull(); }); it("collapses a non-object item to undefined and drops the event", () => { // A malformed `item` (here a string) is caught to undefined by the schema // rather than rejecting the whole payload; the downstream null-check on // `item.channel` then drops the unroutable event. const result = normalizeSlackReactionAdded( { type: "reaction_added", user: "U123", reaction: "thumbsup", item: "bogus", }, "evt-x4", config, ); expect(result).toBeNull(); }); it("collapses a non-string user to undefined and drops the event", () => { const result = normalizeSlackReactionAdded( { type: "reaction_added", user: { id: "U123" }, reaction: "thumbsup", item: { type: "message", channel: "C456", ts: "1700000000.000100" }, }, "evt-x5", config, ); expect(result).toBeNull(); }); it("drops a missing reaction rather than emitting reaction:undefined", () => { // `reaction` forms the callbackData and part of the dedup externalMessageId. // A missing reaction must be dropped, not stringified into a bogus // `reaction:undefined` that the assistant parser treats as a real emoji. const result = normalizeSlackReactionAdded( { type: "reaction_added", user: "U123", item: { type: "message", channel: "C456", ts: "1700000000.000100" }, }, "evt-x5a", config, ); expect(result).toBeNull(); }); it("collapses a non-string reaction to undefined and drops the event", () => { const result = normalizeSlackReactionAdded( { type: "reaction_added", user: "U123", reaction: { name: "thumbsup" }, item: { type: "message", channel: "C456", ts: "1700000000.000100" }, }, "evt-x5b", config, ); expect(result).toBeNull(); }); it("collapses a malformed item field but keeps the rest of the event routable", () => { // A single bad field (non-string `ts`) collapses to undefined without // taking down the sibling fields; the event is dropped only because the // now-missing `ts` fails the identity null-check. const result = normalizeSlackReactionAdded( { type: "reaction_added", user: "U123", reaction: "thumbsup", item: { type: "message", channel: "C456", ts: { nested: true } }, }, "evt-x6", config, ); expect(result).toBeNull(); }); it("preserves unknown extra keys verbatim in raw", () => { // `raw` carries the original untrusted payload, not the schema-stripped // working copy, so downstream consumers and debugging see it as-sent. const payload = { type: "reaction_added", user: "U123", reaction: "thumbsup", item: { type: "message", channel: "C456", ts: "1700000000.000100" }, unexpected_field: "surprise", nested_extra: { a: 1 }, }; const result = normalizeSlackReactionAdded(payload, "evt-x7", config); expect(result).not.toBeNull(); expect(result!.event.raw).toEqual(payload); expect( (result!.event.raw as Record).unexpected_field, ).toBe("surprise"); }); }); describe("DM threading", () => { it("non-threaded DM has no threadTs", () => { const config = makeConfig(); const event = makeDmEvent(); const result = normalizeSlackDirectMessage(event, "evt-dm-1", config); expect(result).not.toBeNull(); expect(result!.event.source.chatType).toBe("im"); expect(result!.threadTs).toBeUndefined(); expect(result!.event.source.threadId).toBeUndefined(); }); it("threaded DM preserves threadTs", () => { const config = makeConfig(); const event = makeDmEvent({ thread_ts: "1700000000.000050" }); const result = normalizeSlackDirectMessage(event, "evt-dm-2", config); expect(result).not.toBeNull(); expect(result!.event.source.chatType).toBe("im"); expect(result!.threadTs).toBe("1700000000.000050"); expect(result!.event.source.threadId).toBe("1700000000.000050"); }); }); describe("Slack text rendering in normalized message content", () => { it("renders channel refs from render context channel labels", () => { const config = makeConfig(); const event = makeAppMentionEvent({ text: "<@UBOT> please continue in <#CFEEDBACK>", }); const result = normalizeSlackAppMention( event, "evt-channel-label", config, undefined, { userLabels: { UBOT: "assistant" }, channelLabels: { CFEEDBACK: "user-feedback" }, }, ); expect(result).not.toBeNull(); expect(result!.event.message.content).toBe( "@assistant please continue in #user-feedback", ); expect(result!.event.message.content).not.toContain("CFEEDBACK"); }); it("falls back to unknown-channel when no channel label is available", () => { const config = makeConfig(); const event = makeChannelEvent({ text: "please continue in <#CUNKNOWN>", }); const result = normalizeSlackChannelMessage( event, "evt-channel-fallback", config, ); expect(result).not.toBeNull(); expect(result!.event.message.content).toBe( "please continue in #unknown-channel", ); expect(result!.event.message.content).not.toContain("CUNKNOWN"); }); }); // --- Attachment extraction tests --- function makeSlackFile(overrides?: Partial): SlackFile { return { id: "F001", name: "photo.png", mimetype: "image/png", size: 12345, url_private_download: "https://files.slack.com/download/photo.png", url_private: "https://files.slack.com/files/photo.png", ...overrides, }; } function makeDmEvent( overrides?: Partial, ): SlackDirectMessageEvent { return { type: "message", user: "U123", text: "hello", ts: "1234567890.123456", channel: "D789", channel_type: "im", ...overrides, }; } function makeChannelEvent( overrides?: Partial, ): SlackChannelMessageEvent { return { type: "message", user: "U123", text: "hello", ts: "1234567890.123456", channel: "C456", channel_type: "channel", ...overrides, }; } function makeAppMentionEvent( overrides?: Partial, ): SlackAppMentionEvent { return { type: "app_mention", user: "U123", text: "<@UBOT> hello", ts: "1234567890.123456", channel: "C456", ...overrides, }; } describe("attachment extraction in normalize functions", () => { describe("normalizeSlackDirectMessage", () => { it("populates attachments with type 'image' for image files", () => { const config = makeConfig(); const event = makeDmEvent({ files: [ makeSlackFile({ id: "F001", mimetype: "image/png", name: "photo.png", }), makeSlackFile({ id: "F002", mimetype: "image/jpeg", name: "pic.jpg", }), ], }); const result = normalizeSlackDirectMessage(event, "evt-1", config); expect(result).not.toBeNull(); expect(result!.event.message.attachments).toHaveLength(2); expect(result!.event.message.attachments![0]).toEqual({ type: "image", fileId: "F001", fileName: "photo.png", mimeType: "image/png", fileSize: 12345, }); expect(result!.event.message.attachments![1]).toEqual({ type: "image", fileId: "F002", fileName: "pic.jpg", mimeType: "image/jpeg", fileSize: 12345, }); // slackFiles map should be populated expect(result!.slackFiles).toBeDefined(); expect(result!.slackFiles!.size).toBe(2); expect(result!.slackFiles!.get("F001")!.id).toBe("F001"); expect(result!.slackFiles!.get("F002")!.id).toBe("F002"); }); it("populates attachments with type 'document' for non-image files", () => { const config = makeConfig(); const event = makeDmEvent({ files: [ makeSlackFile({ id: "F003", mimetype: "application/pdf", name: "doc.pdf", size: 99999, }), ], }); const result = normalizeSlackDirectMessage(event, "evt-2", config); expect(result).not.toBeNull(); expect(result!.event.message.attachments).toHaveLength(1); expect(result!.event.message.attachments![0]).toEqual({ type: "document", fileId: "F003", fileName: "doc.pdf", mimeType: "application/pdf", fileSize: 99999, }); }); it("filters out files missing download URLs", () => { const config = makeConfig(); const event = makeDmEvent({ files: [ makeSlackFile({ id: "F004" }), makeSlackFile({ id: "F005", url_private_download: undefined, url_private: undefined, }), ], }); const result = normalizeSlackDirectMessage(event, "evt-3", config); expect(result).not.toBeNull(); // Only F004 has download URLs expect(result!.event.message.attachments).toHaveLength(1); expect(result!.event.message.attachments![0].fileId).toBe("F004"); expect(result!.slackFiles!.size).toBe(1); expect(result!.slackFiles!.has("F005")).toBe(false); }); it("omits attachments field when files is empty", () => { const config = makeConfig(); const event = makeDmEvent({ files: [] }); const result = normalizeSlackDirectMessage(event, "evt-4", config); expect(result).not.toBeNull(); expect(result!.event.message.attachments).toBeUndefined(); expect(result!.slackFiles).toBeUndefined(); }); it("omits attachments field when files is undefined", () => { const config = makeConfig(); const event = makeDmEvent(); const result = normalizeSlackDirectMessage(event, "evt-5", config); expect(result).not.toBeNull(); expect(result!.event.message.attachments).toBeUndefined(); expect(result!.slackFiles).toBeUndefined(); }); }); describe("normalizeSlackChannelMessage", () => { it("populates attachments for channel messages with files", () => { const config = makeConfig(); const event = makeChannelEvent({ files: [ makeSlackFile({ id: "F010", mimetype: "image/gif", name: "anim.gif", }), ], }); const result = normalizeSlackChannelMessage(event, "evt-ch-1", config); expect(result).not.toBeNull(); expect(result!.event.message.attachments).toHaveLength(1); expect(result!.event.message.attachments![0]).toEqual({ type: "image", fileId: "F010", fileName: "anim.gif", mimeType: "image/gif", fileSize: 12345, }); expect(result!.slackFiles).toBeDefined(); expect(result!.slackFiles!.get("F010")!.name).toBe("anim.gif"); }); }); describe("file_share subtype handling", () => { describe("normalizeSlackDirectMessage", () => { it("normalizes DM with file_share subtype and files", () => { const config = makeConfig(); const event = makeDmEvent({ subtype: "file_share", files: [ makeSlackFile({ id: "F030", mimetype: "image/png", name: "screenshot.png", }), ], }); const result = normalizeSlackDirectMessage(event, "evt-fs-1", config); expect(result).not.toBeNull(); expect(result!.event.message.attachments).toHaveLength(1); expect(result!.event.message.attachments![0].fileId).toBe("F030"); expect(result!.event.message.attachments![0].type).toBe("image"); expect(result!.slackFiles).toBeDefined(); expect(result!.slackFiles!.get("F030")!.id).toBe("F030"); }); it("normalizes DM file_share mentions without stripping the bot mention", () => { const config = makeConfig(); const event = makeDmEvent({ subtype: "file_share", text: "<@UBOT> <@ULEO> shared this", files: [ makeSlackFile({ id: "F032", mimetype: "image/png", name: "mention.png", }), ], }); const result = normalizeSlackDirectMessage( event, "evt-fs-mentions-dm", config, undefined, { userLabels: { UBOT: "assistant", ULEO: "leo" } }, ); expect(result).not.toBeNull(); expect(result!.event.message.content).toBe( "@assistant @leo shared this", ); expect(result!.event.message.content).not.toContain("ULEO"); expect(result!.event.message.attachments).toHaveLength(1); expect(result!.event.message.attachments![0].fileId).toBe("F032"); expect(result!.slackFiles).toBeDefined(); expect(result!.slackFiles!.get("F032")!.id).toBe("F032"); }); it("normalizes DM with file_share subtype without files", () => { const config = makeConfig(); const event = makeDmEvent({ subtype: "file_share" }); const result = normalizeSlackDirectMessage(event, "evt-fs-2", config); expect(result).not.toBeNull(); expect(result!.event.message.content).toBe("hello"); expect(result!.event.message.attachments).toBeUndefined(); }); it("drops DM with bot_message subtype", () => { const config = makeConfig(); const event = makeDmEvent({ subtype: "bot_message" }); const result = normalizeSlackDirectMessage(event, "evt-fs-3", config); expect(result).toBeNull(); }); }); describe("normalizeSlackChannelMessage", () => { it("normalizes channel message with file_share subtype and files", () => { const config = makeConfig(); const event = makeChannelEvent({ subtype: "file_share", files: [ makeSlackFile({ id: "F031", mimetype: "image/jpeg", name: "photo.jpg", }), ], }); const result = normalizeSlackChannelMessage(event, "evt-fs-4", config); expect(result).not.toBeNull(); expect(result!.event.message.attachments).toHaveLength(1); expect(result!.event.message.attachments![0].fileId).toBe("F031"); expect(result!.event.message.attachments![0].type).toBe("image"); expect(result!.slackFiles).toBeDefined(); }); it("normalizes channel file_share mentions and renders the bot mention", () => { const config = makeConfig(); const event = makeChannelEvent({ subtype: "file_share", text: "<@UBOT> <@ULEO> shared this", files: [ makeSlackFile({ id: "F033", mimetype: "application/pdf", name: "mention.pdf", }), ], }); const result = normalizeSlackChannelMessage( event, "evt-fs-mentions-channel", config, undefined, { userLabels: { UBOT: "vex", ULEO: "leo" } }, ); expect(result).not.toBeNull(); expect(result!.event.message.content).toBe("@vex @leo shared this"); expect(result!.event.message.content).not.toContain("ULEO"); expect(result!.event.message.attachments).toHaveLength(1); expect(result!.event.message.attachments![0]).toEqual({ type: "document", fileId: "F033", fileName: "mention.pdf", mimeType: "application/pdf", fileSize: 12345, }); expect(result!.slackFiles).toBeDefined(); expect(result!.slackFiles!.get("F033")!.id).toBe("F033"); }); it("normalizes channel message with file_share subtype without files", () => { const config = makeConfig(); const event = makeChannelEvent({ subtype: "file_share" }); const result = normalizeSlackChannelMessage(event, "evt-fs-5", config); expect(result).not.toBeNull(); expect(result!.event.message.content).toBe("hello"); expect(result!.event.message.attachments).toBeUndefined(); }); it("drops channel message with bot_message subtype", () => { const config = makeConfig(); const event = makeChannelEvent({ subtype: "bot_message" }); const result = normalizeSlackChannelMessage(event, "evt-fs-6", config); expect(result).toBeNull(); }); }); }); describe("normalizeSlackAppMention", () => { it("populates attachments for app mention events with files", () => { const config = makeConfig(); const event = makeAppMentionEvent({ files: [ makeSlackFile({ id: "F020", mimetype: "text/plain", name: "notes.txt", size: 500, }), ], }); const result = normalizeSlackAppMention(event, "evt-am-1", config); expect(result).not.toBeNull(); expect(result!.event.message.attachments).toHaveLength(1); expect(result!.event.message.attachments![0]).toEqual({ type: "document", fileId: "F020", fileName: "notes.txt", mimeType: "text/plain", fileSize: 500, }); expect(result!.slackFiles).toBeDefined(); expect(result!.slackFiles!.get("F020")!.id).toBe("F020"); }); }); }); // Shared factory for SlackMessageChangedEvent used by the edit-normalizer // tests (PR 4) and the source.threadId propagation tests (PR 2). function makeMessageChangedEvent( overrides?: Partial<{ channel: string; channelType: SlackMessageChangedEvent["channel_type"]; eventTs: string; messageTs: string; user: string; text: string; threadTs?: string; }>, ): SlackMessageChangedEvent { return { type: "message", subtype: "message_changed", channel: overrides?.channel ?? "C456", channel_type: overrides?.channelType ?? "channel", ts: overrides?.eventTs ?? "1700000000.000200", event_ts: overrides?.eventTs ?? "1700000000.000200", message: { user: overrides?.user ?? "U123", text: overrides?.text ?? "edited content", ts: overrides?.messageTs ?? "1700000000.000100", edited: { user: overrides?.user ?? "U123", ts: overrides?.eventTs ?? "1700000000.000200", }, ...(overrides?.threadTs ? { thread_ts: overrides.threadTs } : {}), }, previous_message: { user: overrides?.user ?? "U123", text: "original content", ts: overrides?.messageTs ?? "1700000000.000100", }, }; } describe("message event tolerant validation", () => { const config = makeConfig(); it("drops non-object message payloads instead of throwing", () => { expect(normalizeSlackDirectMessage("nope", "evt-t1", config)).toBeNull(); expect(normalizeSlackDirectMessage(null, "evt-t2", config)).toBeNull(); expect(normalizeSlackChannelMessage(42, "evt-t3", config)).toBeNull(); expect(normalizeSlackAppMention(null, "evt-t4", config)).toBeNull(); }); it("renders empty content for a non-string DM text instead of crashing", () => { // The live crash this closes: the normalizer calls renderSlackInboundText // (which `matchAll`s the text). A non-string text collapses to undefined at // the schema, and `?? ""` yields empty content rather than throwing. const result = normalizeSlackDirectMessage( { type: "message", channel_type: "im", user: "U123", channel: "D789", ts: "1.2", text: 12345, }, "evt-t5", config, ); expect(result).not.toBeNull(); expect(result!.event.message.content).toBe(""); }); it("renders empty content for a non-string app_mention text", () => { const result = normalizeSlackAppMention( { type: "app_mention", user: "U123", channel: "C456", ts: "1.2", text: { rich: "obj" }, }, "evt-t6", config, ); expect(result).not.toBeNull(); expect(result!.event.message.content).toBe(""); }); it("drops a channel message missing its user", () => { const result = normalizeSlackChannelMessage( { type: "message", channel_type: "channel", channel: "C456", ts: "1.2", text: "hi", }, "evt-t7", config, ); expect(result).toBeNull(); }); it("drops a message missing its channel", () => { const result = normalizeSlackDirectMessage( { type: "message", channel_type: "im", user: "U123", ts: "1.2", text: "hi", }, "evt-t8", config, ); expect(result).toBeNull(); }); it("collapses a malformed files array to no attachments rather than throwing", () => { const result = normalizeSlackChannelMessage( { type: "message", channel_type: "channel", channel: "C456", user: "U123", ts: "1.2", text: "hi", files: "not-an-array", }, "evt-t9", config, ); expect(result).not.toBeNull(); expect(result!.event.message.attachments).toBeUndefined(); }); it("preserves unknown extra keys verbatim in raw", () => { const payload = { type: "message", channel_type: "channel", channel: "C456", user: "U123", ts: "1.2", text: "hi", unexpected_field: "surprise", }; const result = normalizeSlackChannelMessage(payload, "evt-t10", config); expect(result).not.toBeNull(); expect(result!.event.raw).toEqual(payload); }); it("drops a message with no usable timestamp (dedup key)", () => { // Neither ts nor client_msg_id → the externalMessageId would collapse to // `channel:undefined` and collide across every such malformed message in // the channel, so the message is dropped instead. const result = normalizeSlackChannelMessage( { type: "message", channel_type: "channel", channel: "C456", user: "U123", text: "hi", }, "evt-t11", config, ); expect(result).toBeNull(); }); }); describe("normalizeSlackMessageEdit", () => { it("normalizes an edit in a subscribed channel (not DM, not bot thread)", () => { // Bot is subscribed to the channel via a conversation_id routing entry — // this is the case PR 4 expanded the gateway filter to admit. const config = makeConfig({ routingEntries: [ { type: "conversation_id", key: "C456", assistantId: "ast-2" }, ], unmappedPolicy: "reject", defaultAssistantId: undefined, }); const event = makeMessageChangedEvent({ channel: "C456", channelType: "channel", messageTs: "1700000000.000100", eventTs: "1700000000.000200", user: "U123", }); const eventId = "Ev0XYZ"; const result = normalizeSlackMessageEdit(event, eventId, config); expect(result).not.toBeNull(); expect(result!.event.message.isEdit).toBe(true); expect(result!.event.message.conversationExternalId).toBe("C456"); expect(result!.event.actor.actorExternalId).toBe("U123"); expect(result!.event.source.chatType).toBe("channel"); expect(result!.routing.assistantId).toBe("ast-2"); expect(result!.routing.routeSource).toBe("conversation_id"); // PR 3 invariant: source.messageId is the original Slack ts (the lookup // key the daemon uses to find the prior message), and externalMessageId // is the per-edit eventId. They MUST be distinct so successive edits do // not collide in the dedup pipeline. expect(result!.event.source.messageId).toBe("1700000000.000100"); expect(result!.event.message.externalMessageId).toBe(eventId); expect(result!.event.source.messageId).not.toBe( result!.event.message.externalMessageId, ); }); it("uses message ts as threadTs for top-level channel edits", () => { const config = makeConfig({ routingEntries: [ { type: "conversation_id", key: "C456", assistantId: "ast-2" }, ], unmappedPolicy: "reject", defaultAssistantId: undefined, }); const event = makeMessageChangedEvent({ channel: "C456", channelType: "channel", messageTs: "1700000000.000100", }); const result = normalizeSlackMessageEdit(event, "Ev1", config); expect(result).not.toBeNull(); // For channel edits without a thread_ts, fall back to the message ts so // any reply lands on the same root message. expect(result!.threadTs).toBe("1700000000.000100"); expect(result!.channel).toBe("C456"); }); it("preserves thread_ts on a threaded channel edit", () => { const config = makeConfig({ routingEntries: [ { type: "conversation_id", key: "C456", assistantId: "ast-2" }, ], unmappedPolicy: "reject", defaultAssistantId: undefined, }); const event = makeMessageChangedEvent({ channel: "C456", channelType: "channel", messageTs: "1700000000.000150", threadTs: "1700000000.000050", }); const result = normalizeSlackMessageEdit(event, "Ev2", config); expect(result).not.toBeNull(); expect(result!.threadTs).toBe("1700000000.000050"); expect(result!.event.source.messageId).toBe("1700000000.000150"); }); it("returns null when channel has no routing entry and not a DM", () => { // Without a route and without DM fallback, an edit in an unknown channel // is unroutable — normalize must return null so the gateway drops it. const config = makeConfig({ routingEntries: [], unmappedPolicy: "reject", defaultAssistantId: undefined, }); const event = makeMessageChangedEvent({ channel: "C999", channelType: "channel", }); const result = normalizeSlackMessageEdit(event, "Ev3", config); expect(result).toBeNull(); }); // Self-authored edits are now filtered upstream in processEventPayload. it("does not filter edits by bot user (handled upstream)", () => { const config = makeConfig({ routingEntries: [ { type: "conversation_id", key: "C456", assistantId: "ast-2" }, ], }); const event = makeMessageChangedEvent({ user: "UBOT" }); const result = normalizeSlackMessageEdit(event, "Ev4", config); expect(result).not.toBeNull(); expect(result!.event.actor.actorExternalId).toBe("UBOT"); }); it("infers DM from channel ID prefix when channel_type is absent", () => { const config = makeConfig({ unmappedPolicy: "reject" }); // Build the event directly so `channel_type` is truly absent — the // makeMessageChangedEvent helper coalesces undefined back to "channel". const event: SlackMessageChangedEvent = { type: "message", subtype: "message_changed", channel: "D789", ts: "1700000000.000200", event_ts: "1700000000.000200", message: { user: "U123", text: "edited content", ts: "1700000000.000100", edited: { user: "U123", ts: "1700000000.000200" }, }, previous_message: { user: "U123", text: "original content", ts: "1700000000.000100", }, }; const result = normalizeSlackMessageEdit(event, "Ev5", config); // Without the DM-prefix fallback this would be null (unmapped + reject). expect(result).not.toBeNull(); expect(result!.routing.assistantId).toBe("ast-1"); // DMs should not be tagged as channel chat even when inferred. expect(result!.event.source.chatType).toBeUndefined(); // DM without thread_ts — threadTs should be omitted so replies go inline. expect(result!.threadTs).toBeUndefined(); }); }); // --- normalizeSlackMessageDelete --- function makeMessageDeletedEvent( overrides?: Partial, ): SlackMessageDeletedEvent { return { type: "message", subtype: "message_deleted", channel: "C456", channel_type: "channel", hidden: true, ts: "1700000000.999999", deleted_ts: "1700000000.000100", previous_message: { user: "U123", text: "the original message", ts: "1700000000.000100", }, ...overrides, }; } describe("normalizeSlackMessageDelete", () => { it("normalizes a DM delete with the message_deleted sentinel", () => { const config = makeConfig(); const event = makeMessageDeletedEvent({ channel: "D789", channel_type: "im", previous_message: { user: "U123", text: "hi", ts: "1700000000.000100", }, }); const result = normalizeSlackMessageDelete(event, "evt-del-dm", config); expect(result).not.toBeNull(); expect(result!.event.sourceChannel).toBe("slack"); expect(result!.event.message.callbackData).toBe("message_deleted"); expect(result!.event.message.content).toBe(""); expect(result!.event.message.externalMessageId).toBe("evt-del-dm"); expect(result!.event.message.conversationExternalId).toBe("D789"); expect(result!.event.source.messageId).toBe("1700000000.000100"); expect(result!.event.source.updateId).toBe("evt-del-dm"); // DMs should not be tagged as channel chat expect(result!.event.source.chatType).toBeUndefined(); // No thread_ts on the previous message means no threadTs propagated expect(result!.threadTs).toBeUndefined(); expect(result!.channel).toBe("D789"); expect(result!.event.actor.actorExternalId).toBe("U123"); }); it("normalizes a channel delete with the message_deleted sentinel", () => { const config = makeConfig(); const event = makeMessageDeletedEvent(); const result = normalizeSlackMessageDelete(event, "evt-del-ch", config); expect(result).not.toBeNull(); expect(result!.event.message.callbackData).toBe("message_deleted"); expect(result!.event.message.content).toBe(""); expect(result!.event.message.externalMessageId).toBe("evt-del-ch"); // source.messageId carries the original deleted message's ts expect(result!.event.source.messageId).toBe("1700000000.000100"); expect(result!.event.source.chatType).toBe("channel"); expect(result!.event.message.conversationExternalId).toBe("C456"); expect(result!.channel).toBe("C456"); expect(result!.event.actor.actorExternalId).toBe("U123"); }); it("preserves threadTs from the previous_message thread root", () => { const config = makeConfig(); const event = makeMessageDeletedEvent({ previous_message: { user: "U123", text: "thread reply", ts: "1700000000.000100", thread_ts: "1700000000.000050", }, }); const result = normalizeSlackMessageDelete(event, "evt-del-thr", config); expect(result).not.toBeNull(); expect(result!.threadTs).toBe("1700000000.000050"); }); it("returns null when deleted_ts is missing", () => { const config = makeConfig(); const event = makeMessageDeletedEvent({ deleted_ts: undefined as unknown as string, }); const result = normalizeSlackMessageDelete(event, "evt-del-bad", config); expect(result).toBeNull(); }); it("falls back to a synthetic actor when previous_message.user is missing", () => { const config = makeConfig(); const event = makeMessageDeletedEvent({ previous_message: { text: "[no user]", ts: "1700000000.000100", }, }); const result = normalizeSlackMessageDelete( event, "evt-del-no-user", config, ); expect(result).not.toBeNull(); expect(result!.event.actor.actorExternalId).toBe("slack-system"); }); it("returns null when channel routing rejects without a default", () => { const config = makeConfig({ unmappedPolicy: "reject", defaultAssistantId: undefined, }); const event = makeMessageDeletedEvent(); const result = normalizeSlackMessageDelete( event, "evt-del-noroute", config, ); expect(result).toBeNull(); }); it("falls back to default assistant for DM deletes when channel is unrouted", () => { const config = makeConfig({ unmappedPolicy: "reject" }); const event = makeMessageDeletedEvent({ channel: "D789", channel_type: "im", }); const result = normalizeSlackMessageDelete( event, "evt-del-dm-default", config, ); expect(result).not.toBeNull(); expect(result!.routing.assistantId).toBe("ast-1"); }); it("infers DM from channel ID prefix when channel_type is absent", () => { const config = makeConfig({ unmappedPolicy: "reject" }); const event = makeMessageDeletedEvent({ channel: "D789", channel_type: undefined, }); const result = normalizeSlackMessageDelete( event, "evt-del-dm-infer", config, ); // Without the DM-prefix fallback this would be null (unmapped + reject). expect(result).not.toBeNull(); expect(result!.routing.assistantId).toBe("ast-1"); // DMs should not be tagged as channel chat even when inferred. expect(result!.event.source.chatType).toBeUndefined(); }); it("does not filter deletes by bot user (handled upstream)", () => { // Self-authored deletes are filtered in processEventPayload's single // self-filter, not in the normalizer. const config = makeConfig(); const event = makeMessageDeletedEvent({ channel: "D789", channel_type: "im", previous_message: { user: "UBOT", text: "deleted bot message", ts: "1700000000.000100", }, }); // Self-authored deletes are now filtered upstream in processEventPayload. const result = normalizeSlackMessageDelete(event, "evt-del-self", config); expect(result).not.toBeNull(); expect(result!.event.actor.actorExternalId).toBe("UBOT"); }); }); describe("message edit/delete tolerant validation", () => { const config = makeConfig(); it("drops non-object edit/delete payloads instead of throwing", () => { // Socket frames are unvalidated JSON.parse output; a scalar where an object // is expected must be dropped at the boundary, not crash the batch. expect(normalizeSlackMessageEdit("nope", "evt-me1", config)).toBeNull(); expect(normalizeSlackMessageEdit(null, "evt-me2", config)).toBeNull(); expect(normalizeSlackMessageDelete(42, "evt-md1", config)).toBeNull(); expect(normalizeSlackMessageDelete(null, "evt-md2", config)).toBeNull(); }); it("collapses a non-object edit message to undefined and drops the event", () => { const result = normalizeSlackMessageEdit( { type: "message", subtype: "message_changed", channel: "C456", message: "not-an-object", }, "evt-me3", config, ); expect(result).toBeNull(); }); it("drops an edit missing the channel", () => { const result = normalizeSlackMessageEdit( { type: "message", subtype: "message_changed", message: { user: "U123", text: "hi", ts: "1700000000.000100" }, }, "evt-me4", config, ); expect(result).toBeNull(); }); it("drops an edit whose message ts collapsed (no correlation key)", () => { const result = normalizeSlackMessageEdit( { type: "message", subtype: "message_changed", channel: "C456", message: { user: "U123", text: "hi", ts: { bogus: true } }, }, "evt-me5", config, ); expect(result).toBeNull(); }); it("tolerates a missing edit text, rendering empty content", () => { // A collapsed `text` must not crash the renderer (which requires a string); // the edit still normalizes so the runtime can correlate it by ts. const result = normalizeSlackMessageEdit( { type: "message", subtype: "message_changed", channel: "C456", message: { user: "U123", ts: "1700000000.000100" }, }, "evt-me6", config, ); expect(result).not.toBeNull(); expect(result!.event.message.content).toBe(""); expect(result!.event.message.isEdit).toBe(true); }); it("preserves unknown extra keys verbatim in an edit's raw", () => { const payload = { type: "message", subtype: "message_changed", channel: "C456", message: { user: "U123", text: "hi", ts: "1700000000.000100" }, unexpected_field: "surprise", }; const result = normalizeSlackMessageEdit(payload, "evt-me7", config); expect(result).not.toBeNull(); expect(result!.event.raw).toEqual(payload); }); it("collapses a non-string delete channel to undefined and drops the event", () => { const result = normalizeSlackMessageDelete( { type: "message", subtype: "message_deleted", channel: { id: "C456" }, deleted_ts: "1700000000.000100", }, "evt-md3", config, ); expect(result).toBeNull(); }); it("preserves unknown extra keys verbatim in a delete's raw", () => { const payload = { type: "message", subtype: "message_deleted", channel: "C456", deleted_ts: "1700000000.000100", previous_message: { user: "U123", text: "gone", ts: "1700000000.000100" }, unexpected_field: "surprise", }; const result = normalizeSlackMessageDelete(payload, "evt-md4", config); expect(result).not.toBeNull(); expect(result!.event.raw).toEqual(payload); }); }); // --- source.threadId propagation --- // // Asserts that PR 2's new `source.threadId` field is populated on every // Slack normalizer that has thread info, and absent for top-level messages // without a thread. describe("source.threadId propagation", () => { describe("normalizeSlackDirectMessage", () => { it("populates source.threadId when thread_ts is present", () => { const config = makeConfig(); const event = makeDmEvent({ thread_ts: "1700000000.111111" }); const result = normalizeSlackDirectMessage(event, "evt-tid-1", config); expect(result).not.toBeNull(); expect(result!.event.source.threadId).toBe("1700000000.111111"); }); it("omits source.threadId when thread_ts is absent", () => { const config = makeConfig(); const event = makeDmEvent(); const result = normalizeSlackDirectMessage(event, "evt-tid-2", config); expect(result).not.toBeNull(); expect(result!.event.source.threadId).toBeUndefined(); }); }); describe("normalizeSlackChannelMessage", () => { it("populates source.threadId when thread_ts is present", () => { const config = makeConfig(); const event = makeChannelEvent({ thread_ts: "1700000000.222222" }); const result = normalizeSlackChannelMessage(event, "evt-tid-3", config); expect(result).not.toBeNull(); expect(result!.event.source.threadId).toBe("1700000000.222222"); }); it("omits source.threadId when thread_ts is absent (top-level channel message)", () => { const config = makeConfig(); const event = makeChannelEvent(); const result = normalizeSlackChannelMessage(event, "evt-tid-4", config); expect(result).not.toBeNull(); expect(result!.event.source.threadId).toBeUndefined(); }); }); describe("normalizeSlackAppMention", () => { it("populates source.threadId when thread_ts is present", () => { const config = makeConfig(); const event = makeAppMentionEvent({ thread_ts: "1700000000.333333" }); const result = normalizeSlackAppMention(event, "evt-tid-5", config); expect(result).not.toBeNull(); expect(result!.event.source.threadId).toBe("1700000000.333333"); }); it("omits source.threadId when thread_ts is absent", () => { const config = makeConfig(); const event = makeAppMentionEvent(); const result = normalizeSlackAppMention(event, "evt-tid-6", config); expect(result).not.toBeNull(); expect(result!.event.source.threadId).toBeUndefined(); }); }); describe("actor team ID capture", () => { it("captures event.team as actor.teamId for channel messages", () => { const config = makeConfig(); const event = makeChannelEvent({ team: "T999" }); const result = normalizeSlackChannelMessage(event, "evt-team-1", config); expect(result).not.toBeNull(); expect(result!.event.actor.teamId).toBe("T999"); }); it("captures event.team as actor.teamId for app mentions", () => { const config = makeConfig(); const event = makeAppMentionEvent({ team: "T999" }); const result = normalizeSlackAppMention(event, "evt-team-2", config); expect(result).not.toBeNull(); expect(result!.event.actor.teamId).toBe("T999"); }); it("omits actor.teamId when the event carries no team", () => { const config = makeConfig(); const event = makeChannelEvent(); const result = normalizeSlackChannelMessage(event, "evt-team-3", config); expect(result).not.toBeNull(); expect(result!.event.actor.teamId).toBeUndefined(); }); }); describe("normalizeSlackReactionAdded", () => { it("populates source.threadId with the reacted message's ts", () => { const config = makeConfig(); const event = makeReactionAddedEvent({ messageTs: "1700000000.444444", }); const result = normalizeSlackReactionAdded(event, "evt-tid-7", config); expect(result).not.toBeNull(); // Reactions route replies against the reacted message, so threadId // mirrors the wrapper's threadTs (which equals item.ts). expect(result!.event.source.threadId).toBe("1700000000.444444"); expect(result!.threadTs).toBe("1700000000.444444"); }); }); describe("normalizeSlackMessageEdit", () => { it("populates source.threadId for channel edits inside a thread", () => { const config = makeConfig({ routingEntries: [ { type: "conversation_id", key: "C456", assistantId: "ast-2" }, ], }); const event = makeMessageChangedEvent({ threadTs: "1700000000.555555", }); const result = normalizeSlackMessageEdit(event, "evt-tid-8", config); expect(result).not.toBeNull(); expect(result!.event.source.threadId).toBe("1700000000.555555"); }); it("omits source.threadId for channel edits with no thread", () => { const config = makeConfig({ routingEntries: [ { type: "conversation_id", key: "C456", assistantId: "ast-2" }, ], }); const event = makeMessageChangedEvent(); const result = normalizeSlackMessageEdit(event, "evt-tid-9", config); expect(result).not.toBeNull(); expect(result!.event.source.threadId).toBeUndefined(); }); it("omits source.threadId for DM edits with no thread", () => { const config = makeConfig(); const event = makeMessageChangedEvent({ channel: "D789", channelType: "im", }); const result = normalizeSlackMessageEdit(event, "evt-tid-10", config); expect(result).not.toBeNull(); expect(result!.event.source.threadId).toBeUndefined(); }); }); describe("normalizeSlackBlockActions", () => { it("populates source.threadId when message.thread_ts is present", () => { const config = makeConfig(); const payload = makeBlockActionsPayload(); payload.message = { ts: "1234567890.999999", thread_ts: "1234567890.000001", text: "Choose an option", }; const result = normalizeSlackBlockActions(payload, "env-tid-1", config); expect(result).not.toBeNull(); expect(result!.event.source.threadId).toBe("1234567890.000001"); }); it("omits source.threadId when message.thread_ts is absent", () => { const config = makeConfig(); const payload = makeBlockActionsPayload(); const result = normalizeSlackBlockActions(payload, "env-tid-2", config); expect(result).not.toBeNull(); expect(result!.event.source.threadId).toBeUndefined(); }); }); describe("normalizeSlackMessageDelete", () => { it("populates source.threadId for deletes of threaded messages", () => { const config = makeConfig(); const event = makeMessageDeletedEvent({ previous_message: { user: "U123", text: "thread reply", ts: "1700000000.000100", thread_ts: "parent_ts", }, }); const result = normalizeSlackMessageDelete(event, "evt-tid-11", config); expect(result).not.toBeNull(); expect(result!.event.source.threadId).toBe("parent_ts"); }); it("omits source.threadId for deletes of top-level messages", () => { const config = makeConfig(); const event = makeMessageDeletedEvent(); const result = normalizeSlackMessageDelete(event, "evt-tid-12", config); expect(result).not.toBeNull(); expect(result!.event.source.threadId).toBeUndefined(); }); }); }); describe("bot sender classification", () => { it("marks a DM from another bot as a bot sender", () => { const config = makeConfig(); const event = makeDmEvent({ user: "UBOT99", bot_id: "B0AGENT", bot_profile: { id: "B0AGENT", name: "Peer Assistant", app_id: "A0EXAMPLE", team_id: "T0EXAMPLE", }, }); const result = normalizeSlackDirectMessage(event, "evt-bot-1", config); expect(result).not.toBeNull(); expect(result!.event.actor.isBot).toBe(true); expect(result!.botSender).toEqual({ botId: "B0AGENT", botName: "Peer Assistant", appId: "A0EXAMPLE", teamId: "T0EXAMPLE", }); }); it("marks a channel message from another bot as a bot sender", () => { const config = makeConfig(); const event = makeChannelEvent({ user: "UBOT99", bot_id: "B0AGENT", }); const result = normalizeSlackChannelMessage(event, "evt-bot-2", config); expect(result).not.toBeNull(); expect(result!.event.actor.isBot).toBe(true); expect(result!.botSender).toEqual({ botId: "B0AGENT" }); }); it("marks an app_mention from another bot as a bot sender", () => { const config = makeConfig(); const event = makeAppMentionEvent({ user: "UBOT99", bot_id: "B0AGENT", bot_profile: { name: "Peer Assistant" }, }); const result = normalizeSlackAppMention(event, "evt-bot-3", config); expect(result).not.toBeNull(); expect(result!.event.actor.isBot).toBe(true); expect(result!.botSender).toEqual({ botId: "B0AGENT", botName: "Peer Assistant", }); }); it("does not mark a human sender as a bot", () => { const config = makeConfig(); const result = normalizeSlackDirectMessage( makeDmEvent(), "evt-bot-4", config, ); expect(result).not.toBeNull(); expect(result!.event.actor.isBot).toBeUndefined(); expect(result!.botSender).toBeUndefined(); }); }); describe("slackBotContactNote", () => { it("includes bot name, app id, and workspace when available", () => { expect( slackBotContactNote({ botId: "B0AGENT", botName: "Peer Assistant", appId: "A0EXAMPLE", teamId: "T0EXAMPLE", }), ).toBe( 'Automated Slack bot "Peer Assistant" (Slack app A0EXAMPLE, workspace T0EXAMPLE) — messages from this contact are sent by an app, not a person.', ); }); it("degrades gracefully when only bot_id is known", () => { expect(slackBotContactNote({ botId: "B0AGENT" })).toBe( "Automated Slack bot — messages from this contact are sent by an app, not a person.", ); }); }); describe("enrichNormalizedActor", () => { it("classifies an is_bot-only sender as a bot after profile resolution", () => { const config = makeConfig(); // No bot_id on the event and no cached profile — normalization alone // cannot detect the bot. const normalized = normalizeSlackDirectMessage( makeDmEvent({ user: "UBOT99" }), "evt-enrich-1", config, ); expect(normalized).not.toBeNull(); expect(normalized!.botSender).toBeUndefined(); enrichNormalizedActor(normalized!, { displayName: "Peer Assistant", username: "peer-assistant", isBot: true, }); expect(normalized!.event.actor.isBot).toBe(true); expect(normalized!.event.actor.displayName).toBe("Peer Assistant"); expect(normalized!.botSender).toEqual({ botName: "Peer Assistant" }); }); it("does not classify a human sender as a bot", () => { const config = makeConfig(); const normalized = normalizeSlackDirectMessage( makeDmEvent(), "evt-enrich-2", config, ); expect(normalized).not.toBeNull(); enrichNormalizedActor(normalized!, { displayName: "Alice", username: "alice", }); expect(normalized!.event.actor.isBot).toBeUndefined(); expect(normalized!.event.actor.displayName).toBe("Alice"); expect(normalized!.botSender).toBeUndefined(); }); it("preserves an existing botSender derived from bot_id", () => { const config = makeConfig(); const normalized = normalizeSlackDirectMessage( makeDmEvent({ user: "UBOT99", bot_id: "B0AGENT", bot_profile: { name: "Peer Assistant", app_id: "A0EXAMPLE" }, }), "evt-enrich-3", config, ); expect(normalized).not.toBeNull(); enrichNormalizedActor(normalized!, { displayName: "Peer Assistant", username: "peer-assistant", isBot: true, }); expect(normalized!.botSender).toEqual({ botId: "B0AGENT", botName: "Peer Assistant", appId: "A0EXAMPLE", }); }); }); /** * Slack attaches `app_context` to `message.im` once the app subscribes to * `app_context_changed` (which requires `agent_view`). It names what the sender * had open — the channel, thread, or canvas a DM like "summarise this" refers * to. It cannot live on the cross-checked message schema, so these cover the * separate raw-payload parse. */ describe("DM app_context", () => { const CHANNEL_ENTITY = { type: "slack#/types/channel_id", value: "C0INCIDENTS", team_id: "T0TEAM", }; it("carries the entities the sender had open", () => { const config = makeConfig(); const event = makeDmEvent({ app_context: { entities: [CHANNEL_ENTITY] } }); const result = normalizeSlackDirectMessage(event, "evt-ctx-1", config); expect(result).not.toBeNull(); expect(result!.event.source.appContext).toEqual({ entities: [ { type: "slack#/types/channel_id", value: "C0INCIDENTS", teamId: "T0TEAM", }, ], }); }); it("preserves entity order, which Slack sends by relevance", () => { const config = makeConfig(); const second = { type: "slack#/types/channel_id", value: "C0SECOND" }; const event = makeDmEvent({ app_context: { entities: [CHANNEL_ENTITY, second] }, }); const result = normalizeSlackDirectMessage(event, "evt-ctx-2", config); expect( result!.event.source.appContext?.entities.map((e) => e.value), ).toEqual(["C0INCIDENTS", "C0SECOND"]); }); it("omits appContext when the sender had nothing open", () => { const config = makeConfig(); // Slack sends an empty context object rather than dropping the field. const event = makeDmEvent({ app_context: {} }); const result = normalizeSlackDirectMessage(event, "evt-ctx-3", config); expect(result!.event.source.appContext).toBeUndefined(); }); it("omits appContext when Slack sends no context at all", () => { const config = makeConfig(); const result = normalizeSlackDirectMessage( makeDmEvent(), "evt-ctx-4", config, ); expect(result!.event.source.appContext).toBeUndefined(); }); it("drops a malformed context rather than failing the message", () => { const config = makeConfig(); // A DM whose context is garbage must still deliver — the message is the // payload, the context is an enrichment. const event = makeDmEvent({ app_context: { entities: "not-an-array" }, } as unknown as Partial); const result = normalizeSlackDirectMessage(event, "evt-ctx-5", config); expect(result).not.toBeNull(); expect(result!.event.message.content).toBeTruthy(); expect(result!.event.source.appContext).toBeUndefined(); }); it("drops only the malformed entity, keeping its valid siblings", () => { const config = makeConfig(); // One bad entity must not take the usable ones with it: the message would // still deliver, but "summarize this" would go unresolved with the answer // sitting right there in the payload. const event = makeDmEvent({ app_context: { entities: [ { type: "slack#/types/channel_id" }, CHANNEL_ENTITY, { type: "slack#/types/canvas_id", value: 42 }, ], }, } as unknown as Partial); const result = normalizeSlackDirectMessage(event, "evt-ctx-9", config); expect(result!.event.source.appContext?.entities).toEqual([ { type: CHANNEL_ENTITY.type, value: CHANNEL_ENTITY.value, teamId: CHANNEL_ENTITY.team_id, }, ]); }); it("omits the context when every entity is malformed", () => { const config = makeConfig(); const event = makeDmEvent({ app_context: { entities: [{ type: "slack#/types/channel_id" }] }, } as unknown as Partial); const result = normalizeSlackDirectMessage(event, "evt-ctx-10", config); expect(result).not.toBeNull(); expect(result!.event.source.appContext).toBeUndefined(); }); it("carries a message_context entity, whose value is an object not an id", () => { const config = makeConfig(); // The thread/message case — the one that resolves "summarise this". Its // `value` is an object, so a string-only schema silently drops it. const event = makeDmEvent({ app_context: { entities: [ { type: "slack#/types/message_context", value: { message_ts: "1700000000.000100", channel_id: "C0INCIDENTS", }, team_id: "T0TEAM", }, ], }, }); const result = normalizeSlackDirectMessage(event, "evt-ctx-7", config); expect(result!.event.source.appContext?.entities[0]).toEqual({ type: "slack#/types/message_context", value: { messageTs: "1700000000.000100", channelId: "C0INCIDENTS" }, teamId: "T0TEAM", }); }); it("carries enterprise_id when Slack sends one", () => { const config = makeConfig(); const event = makeDmEvent({ app_context: { entities: [{ ...CHANNEL_ENTITY, enterprise_id: "E0GRID" }], }, }); const result = normalizeSlackDirectMessage(event, "evt-ctx-8", config); expect(result!.event.source.appContext?.entities[0]).toMatchObject({ enterpriseId: "E0GRID", }); }); it("keeps the raw context verbatim on the event", () => { const config = makeConfig(); const event = makeDmEvent({ app_context: { entities: [CHANNEL_ENTITY] } }); const result = normalizeSlackDirectMessage(event, "evt-ctx-6", config); expect((result!.event.raw as Record).app_context).toEqual({ entities: [CHANNEL_ENTITY], }); }); });