From 4e2523a3adcbed41ce113f4bce1a8a84c9c41d5e Mon Sep 17 00:00:00 2001 From: Soony123 Date: Tue, 11 Aug 2026 22:23:13 +0900 Subject: [PATCH] slack: promote legacy-attachment content to message body text Notification bots (GitHub's Slack app among them) post messages whose text is empty, with the content living only in legacy attachments (fallback/ pretext/title/text). Every ingestion path read m.text alone, so these messages were invisible end to end: the thread dispatch dropped them as empty unprompted input, the ambient judge filtered them out of its batch, and the conversation view rendered them as blank lines. A channel with an action-mode bot policy or standing orders aimed at such a bot could never fire, because the judged batch never contained the notification body. messageBodyText (message-gating) returns the text verbatim when present and otherwise composes the body from attachment content, preferring the richer pretext/title/text fields over the fallback summary. Applied at every ingestion site: both events.ts dispatches, mirror.ts surface ingestion, and conversation-view rendering (including thread openers). Verified against a live instance: a label-filtered GitHub PR notification in a channel with standing orders now reaches the ambient judge with its body visible and produces an engagement; before the change the same notification produced no judgment at all. --- src/slack/conversation-view.ts | 5 +++-- src/slack/events.ts | 5 +++-- src/slack/lib.ts | 2 ++ src/slack/message-gating.ts | 21 +++++++++++++++++++++ src/slack/mirror.ts | 4 ++-- test/slack-message-gating.test.ts | 27 +++++++++++++++++++++++++++ 6 files changed, 58 insertions(+), 6 deletions(-) diff --git a/src/slack/conversation-view.ts b/src/slack/conversation-view.ts index 1fd85b04..26d3c66a 100644 --- a/src/slack/conversation-view.ts +++ b/src/slack/conversation-view.ts @@ -10,6 +10,7 @@ import { collectEarlierThreadFiles, decodeSlackEntities, isOversize, + messageBodyText, recentWindow, } from "./lib.ts"; import type { BotIdentity, Directory } from "./directory.ts"; @@ -157,7 +158,7 @@ export function createConversationSerializer(deps: { ts: m.ts as string, name: messageAuthorName(m, nameById, botNameById), ...(m.user || m.bot_id ? { authorId: String(m.user || m.bot_id) } : {}), - text: decodeSlackEntities(String(m.text ?? "").trim()), + text: decodeSlackEntities(messageBodyText(m).trim()), ...(m.ts === triggerTs ? { isTrigger: true } : {}), ...(m.bot_id ? { isBot: true } : {}), ...(isSelfMessage(m) ? { isSelf: true } : {}), @@ -234,7 +235,7 @@ export function createConversationSerializer(deps: { kind: "thread", youOpenedIt, ...(starterName ? { starterName } : {}), - ...(youOpenedIt && root?.text ? { openerText: decodeSlackEntities(String(root.text).trim()) } : {}), + ...(youOpenedIt && root && messageBodyText(root).trim() ? { openerText: decodeSlackEntities(messageBodyText(root).trim()) } : {}), }; } else { here = { kind: "top-level" }; diff --git a/src/slack/events.ts b/src/slack/events.ts index b964e3f7..1905bc7f 100644 --- a/src/slack/events.ts +++ b/src/slack/events.ts @@ -6,6 +6,7 @@ import { isGroupMembershipMessage, isThreadReply, mentionsBot, + messageBodyText, onBotJoinedChannel, type SurfaceHeaderClient, shouldProcessMessage, @@ -114,7 +115,7 @@ export function registerSlackEvents( channel: m.channel, userId: m.user, ...(m.bot_profile?.name || m.username ? { authorName: String(m.bot_profile?.name || m.username) } : {}), - rawText: m.text ?? "", + rawText: messageBodyText(m), files: (m.files as SlackFile[]) ?? [], threadTs: m.thread_ts, ts: m.ts, @@ -152,7 +153,7 @@ export function registerSlackEvents( channel: m.channel, userId: m.user, ...(m.bot_profile?.name || m.username ? { authorName: String(m.bot_profile?.name || m.username) } : {}), - rawText: m.text ?? "", + rawText: messageBodyText(m), files: (m.files as SlackFile[]) ?? [], threadTs: m.thread_ts, ts: m.ts, diff --git a/src/slack/lib.ts b/src/slack/lib.ts index d0d33980..7265691e 100644 --- a/src/slack/lib.ts +++ b/src/slack/lib.ts @@ -37,6 +37,8 @@ export { channelPrivacyChange, createRefreshCoalescer, hasContent, + messageBodyText, + type LegacyAttachment, isThreadReply, createThreadTracker, dmThreadRef, diff --git a/src/slack/message-gating.ts b/src/slack/message-gating.ts index fd175b6a..555aa56a 100644 --- a/src/slack/message-gating.ts +++ b/src/slack/message-gating.ts @@ -75,6 +75,27 @@ export function hasContent(text: string, files: readonly unknown[]): boolean { return text.trim().length > 0 || files.length > 0; } +export interface LegacyAttachment { + fallback?: string; + pretext?: string; + title?: string; + text?: string; +} + +export function messageBodyText(m: { text?: string; attachments?: LegacyAttachment[] }): string { + if (m.text?.trim()) return m.text; + return (m.attachments ?? []) + .map((a) => { + const composed = [a.pretext, a.title, a.text] + .map((s) => s?.trim()) + .filter(Boolean) + .join("\n"); + return composed || a.fallback?.trim() || ""; + }) + .filter(Boolean) + .join("\n"); +} + export function isThreadReply(m: { thread_ts?: string; ts?: string }): boolean { return Boolean(m.thread_ts) && m.thread_ts !== m.ts; } diff --git a/src/slack/mirror.ts b/src/slack/mirror.ts index 323f665f..4a1d8746 100644 --- a/src/slack/mirror.ts +++ b/src/slack/mirror.ts @@ -1,5 +1,5 @@ import { swallow } from "../util/errors.ts"; -import { decodeSlackEntities, mentionsBot, resolveMentionsInText } from "./lib.ts"; +import { decodeSlackEntities, mentionsBot, messageBodyText, resolveMentionsInText } from "./lib.ts"; import type { SlackCoreClient } from "../api/slack-core-client.ts"; import type { IngestEvent } from "../surface-cache/surface-cache.ts"; import type { BotIdentity, Directory } from "./directory.ts"; @@ -104,7 +104,7 @@ export function createMirror(deps: { } if (!gate.allowed) return; } - const raw = String(m.text ?? ""); + const raw = messageBodyText(m); const { text, mentions } = await resolveTextMentions(client, decodeSlackEntities(raw)); await pushSurfaceEvents([ { diff --git a/test/slack-message-gating.test.ts b/test/slack-message-gating.test.ts index 8f6e1512..ddf2488d 100644 --- a/test/slack-message-gating.test.ts +++ b/test/slack-message-gating.test.ts @@ -7,6 +7,7 @@ import { channelPrivacyChange, createRefreshCoalescer, hasContent, + messageBodyText, dmThreadRef, mentionsBot, threadHasBotStake, @@ -288,3 +289,29 @@ test("createInFlightThreadMap: clear is runId-guarded so a finished run can't un runs.clear("dm:C1", "run-2"); assert.equal(runs.get("dm:C1"), undefined); }); + +test("messageBodyText returns text verbatim when present", () => { + assert.equal(messageBodyText({ text: "hello" }), "hello"); + assert.equal(messageBodyText({ text: "hello", attachments: [{ fallback: "ignored" }] }), "hello"); +}); + +test("messageBodyText falls back to attachment content for empty-text bot notifications", () => { + assert.equal(messageBodyText({ text: "" }), ""); + assert.equal(messageBodyText({}), ""); + assert.equal( + messageBodyText({ text: "", attachments: [{ fallback: "[org/repo] Pull request opened by someone" }] }), + "[org/repo] Pull request opened by someone", + ); + assert.equal( + messageBodyText({ text: " ", attachments: [{ pretext: "pre", title: "PR #1", text: "body" }] }), + "pre\nPR #1\nbody", + ); + assert.equal( + messageBodyText({ text: "", attachments: [{ fallback: "first" }, { title: "second" }, {}] }), + "first\nsecond", + ); + assert.equal( + messageBodyText({ text: "", attachments: [{ fallback: "fb", title: "title only" }] }), + "title only", + ); +});