diff --git a/apps/slack-agent/README.md b/apps/slack-agent/README.md index e57b6f107..a938a1da5 100644 --- a/apps/slack-agent/README.md +++ b/apps/slack-agent/README.md @@ -43,6 +43,8 @@ agent/ tools/render_chart.ts # renders a PNG chart in-process and posts it into the thread tools/{bash,glob,grep,read_file,write_file,web_fetch,web_search}.ts # `disableTool()` sentinels — see Framework tools below + lib/thread-context.ts # full-thread turn context (renders Block Kit / alert cards too) + lib/bot-identity.ts # per-team bot user id learned from the webhook envelope lib/chart.ts # pure SVG chart renderer + unicode-sparkline fallback lib/slack-upload.ts # Slack external-upload flow (files.getUploadURLExternal → complete) lib/env.ts # shared is-this-deployed predicate (route auth + token fallback) @@ -337,7 +339,8 @@ eve's native idiom: - **Modes → skills:** the web chat's dashboard-builder and investigate modes are progressive- disclosure skills (`agent/skills/dashboard-builder/`, `agent/skills/incident-investigation/`) the model loads via `load_skill`. Alert context comes from the Slack thread (Maple delivers - alert notifications into Slack), not from a request payload. + alert notifications into Slack), not from a request payload — including the alert card itself, + which `agent/lib/thread-context.ts` renders out of its Block Kit attachment (see Notes). - **Approvals — both sides now interrupt, by different mechanisms:** the web chat stops the turn on a gated tool and emits a `tool-call` with `proposed: true` and no result (`apps/api/src/chat/agent.ts`); the user approves and `POST /api/chat/apply` performs the @@ -490,6 +493,19 @@ and **activate public distribution** so the app can be installed into any worksp The SSE must carry `delta.tool_calls`, not a JSON blob inside the text content. Also set `OPENROUTER_CONTEXT_WINDOW` to the new model's window. +- **Thread context is ours, not `slackChannel({ threadContext })`.** Every mention and DM ships the + whole thread transcript with the turn, rendered by `agent/lib/thread-context.ts` and returned as + the mention result's `context` from `onAppMention` / `onDirectMessage`. eve's built-in option + couldn't carry an alert thread — the case that matters most, since the user is replying to + something Maple posted rather than opening a topic. It reads a message's content from `text` + alone, and Maple's alert notifications have none: the blocks ride inside a colored attachment + (`apps/api/src/services/alerts/AlertDeliveryDispatch.ts`), so the model saw an empty + ``. Worse, `since: "last-agent-reply"` counted the alert as the agent's own + reply (eve's `isMe` is `bot_id !== undefined` — any bot) and cut context off _after_ it, dropping + the alert entirely. The user had to hand the bot a recap of the alert it had just sent. Ours + falls back to blocks/attachments for content, keeps the full thread (which also survives a + session lost to a redeploy), and attributes speakers with the workspace's real bot user id from + `agent/lib/bot-identity.ts` so a third-party app in the channel isn't quoted back as the agent. - **Auth:** `agent/channels/eve.ts` fails closed in deployed environments (`RAILWAY_ENVIRONMENT_NAME` set, or `NODE_ENV=production`): the browser/API routes always require HTTP Basic there. With `ROUTE_AUTH_BASIC_PASSWORD` set that's your stable credential; without it, a random per-boot diff --git a/apps/slack-agent/agent/channels/slack.ts b/apps/slack-agent/agent/channels/slack.ts index 0200bbbd9..276d947c4 100644 --- a/apps/slack-agent/agent/channels/slack.ts +++ b/apps/slack-agent/agent/channels/slack.ts @@ -1,8 +1,10 @@ -import { slackChannel } from "eve/channels/slack" -import type { SlackWebhookVerifier } from "eve/channels/slack" +import { defaultSlackAuth, slackChannel } from "eve/channels/slack" +import type { SlackContext, SlackMentionResult, SlackMessage, SlackWebhookVerifier } from "eve/channels/slack" import { acknowledgeIncomingMessage } from "#lib/ack-reaction.js" import { describeActions, truncateTypingStatus } from "#lib/action-status.js" +import { botUserIdForTeam, rememberBotUserId } from "#lib/bot-identity.js" import { resolveBotToken, verifySlackV0Signature, type SlackTokenContext } from "#lib/maple.js" +import { loadThreadContext } from "#lib/thread-context.js" import { promoteThreadFollowUp } from "#lib/thread-follow-up.js" import { forwardUninstallEvent } from "#lib/uninstall-detection.js" @@ -38,6 +40,11 @@ const webhookVerifier: SlackWebhookVerifier = async (request, body) => { return false } + // Every event_callback names this workspace's bot user under + // `authorizations`, so thread-context attribution gets it for free — no + // auth.test round-trip (#lib/bot-identity.js). + rememberBotUserId(body) + // app_uninstalled / tokens_revoked: eve only dispatches app_mention + DM // events downstream, so it would otherwise drop these as "unsupported". // Fired without awaiting — it must never delay this webhook's ack, and it @@ -75,6 +82,34 @@ const webhookVerifier: SlackWebhookVerifier = async (request, body) => { return body } +/** + * Stands in for eve's default mention/DM pipeline: same two steps — a + * "Thinking..." typing indicator and workspace-scoped auth derivation — plus + * the thread transcript as turn context. + * + * The context is ours rather than the channel's `threadContext` option because + * eve reads a message's content from `text` alone and treats every bot post as + * the agent's own last reply. Maple's alert notifications are Block Kit inside + * an attachment with no top-level text, so both rules misfired at once and an + * @mention in an alert thread reached the model with the alert blank and + * everything before it cut away — see #lib/thread-context.js. + * + * Runs after eve has already returned 200 to Slack (`waitUntil`), so the thread + * fetch is off the webhook's delivery budget. It must not throw: eve drops the + * whole mention when this handler does, so `loadThreadContext` degrades to no + * context instead. + */ +async function dispatchWithThreadContext( + ctx: SlackContext, + message: SlackMessage, +): Promise { + await ctx.thread.startTyping("Thinking...") + const context = await loadThreadContext(ctx.thread, message, { + botUserId: botUserIdForTeam(message.teamId), + }) + return { auth: defaultSlackAuth(message, ctx), context } +} + export default slackChannel({ credentials: { webhookVerifier, @@ -83,9 +118,10 @@ export default slackChannel({ // SLACK_BOT_TOKEN. botToken: async (context?: SlackTokenContext) => resolveBotToken(context), }, - // Repeated mentions in a thread only inject what's new since the agent's - // last reply, instead of re-reading the whole thread each time. - threadContext: { since: "last-agent-reply" }, + // Thread context is loaded by these two handlers, not by the channel's + // `threadContext` option — see dispatchWithThreadContext above. + onAppMention: dispatchWithThreadContext, + onDirectMessage: dispatchWithThreadContext, events: { // Eve's default flashes the raw tool-call label (`maple__list_services // startTime=...`) into the typing status. Replace it with a plain diff --git a/apps/slack-agent/agent/instructions.md b/apps/slack-agent/agent/instructions.md index 9748e0b10..d5426a163 100644 --- a/apps/slack-agent/agent/instructions.md +++ b/apps/slack-agent/agent/instructions.md @@ -108,6 +108,12 @@ reader's next move. never mention or describe the reaction in your reply text. - You are replying inside a Slack thread; stay on topic for that thread, and when several people are involved, pay attention to who is asking. +- The turn carries the thread so far in ``. Read it + before answering and never ask the user to restate what is already there. + When it opens with a Maple alert notification (`sender_type: bot`, a rule + name with a severity, an observed value, and an incident link), that alert is + the subject: take the rule, window, and incident id straight from it and load + the incident-investigation skill. - When you don't know something, say so plainly rather than guessing. ### Length calibration diff --git a/apps/slack-agent/agent/lib/bot-identity.ts b/apps/slack-agent/agent/lib/bot-identity.ts new file mode 100644 index 000000000..139240f6f --- /dev/null +++ b/apps/slack-agent/agent/lib/bot-identity.ts @@ -0,0 +1,88 @@ +/** + * Per-workspace bot user id, learned from inbound webhook envelopes. + * + * Every `event_callback` Slack delivers names the app's own bot user under + * `authorizations[].user_id`, so the id is free — no `auth.test` round-trip and + * no extra secret. The webhook verifier records it (`rememberBotUserId`) and + * later stages read it back per team (`botUserIdForTeam`). + * + * Why it is needed at all: eve marks a thread message as the agent's own with + * `isMe = bot_id !== undefined`, i.e. *any* bot. In a channel that also hosts a + * CI or GitHub app, that mislabels a stranger's post as something this agent + * said. Thread context (`#lib/thread-context.js`) attributes speakers with this + * id instead. + * + * The cache is process-global and keyed by team id, which is the only + * multi-tenant-safe key here: this app serves every workspace that installed + * the Slack app. + */ + +import { createTtlCache } from "./ttl-cache.js" + +// A workspace's bot user id is stable for the life of the installation, so the +// TTL only bounds how long an uninstalled workspace lingers in memory. +const BOT_USER_ID_TTL_MS = 24 * 60 * 60_000 +const CACHE_MAX_ENTRIES = 500 +const CACHE_SWEEP_INTERVAL_MS = 60 * 60_000 + +interface BotUserIdEntry { + readonly botUserId: string + readonly expiresAt: number +} + +const botUserIds = createTtlCache({ + maxEntries: CACHE_MAX_ENTRIES, + sweepIntervalMs: CACHE_SWEEP_INTERVAL_MS, +}) + +/** Test-only: clears the cache so each test starts cold. */ +export function resetBotUserIdCacheForTests(): void { + botUserIds.clear() +} + +/** + * The event envelope's `authorizations` names the app's bot user — no + * `auth.test` round-trip needed. Returns null when the envelope carries none. + */ +export function botUserIdFromEnvelope(envelope: Record): string | null { + const authorizations = envelope.authorizations + if (!Array.isArray(authorizations)) return null + for (const auth of authorizations) { + if (isRecord(auth) && typeof auth.user_id === "string" && auth.user_id.length > 0) { + return auth.user_id + } + } + return null +} + +/** + * Records the bot user id carried by a verified inbound webhook body. Never + * throws — a body we cannot read simply teaches us nothing. + */ +export function rememberBotUserId(rawBody: string): void { + let parsed: unknown + try { + parsed = JSON.parse(rawBody) + } catch { + return + } + if (!isRecord(parsed)) return + const teamId = typeof parsed.team_id === "string" ? parsed.team_id : "" + const botUserId = botUserIdFromEnvelope(parsed) + if (!teamId || !botUserId) return + botUserIds.set(teamId, { botUserId, expiresAt: Date.now() + BOT_USER_ID_TTL_MS }) +} + +/** + * The bot user id for a workspace, or undefined when no webhook has taught us + * one yet. Callers must degrade gracefully rather than block on it: the id is + * an attribution refinement, never a gate. + */ +export function botUserIdForTeam(teamId: string | undefined): string | undefined { + if (!teamId) return undefined + return botUserIds.get(teamId)?.botUserId +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/apps/slack-agent/agent/lib/thread-context.test.ts b/apps/slack-agent/agent/lib/thread-context.test.ts new file mode 100644 index 000000000..d74374c48 --- /dev/null +++ b/apps/slack-agent/agent/lib/thread-context.test.ts @@ -0,0 +1,273 @@ +import { afterEach, describe, expect, test } from "bun:test" +import type { SlackThreadMessage } from "eve/channels/slack" +import { botUserIdForTeam, rememberBotUserId, resetBotUserIdCacheForTests } from "./bot-identity.js" +import { formatThreadContext, loadThreadContext, slackMessageContent } from "./thread-context.js" + +afterEach(() => { + resetBotUserIdCacheForTests() +}) + +const BOT_USER_ID = "UBOT" + +const message = (overrides: Partial = {}): SlackThreadMessage => ({ + text: "", + markdown: "", + user: undefined, + botId: undefined, + ts: "1700000000.000100", + threadTs: "1700000000.000100", + isMe: false, + raw: {}, + ...overrides, +}) + +const userMessage = (text: string, ts: string): SlackThreadMessage => + message({ text, markdown: text, user: "U1", ts, threadTs: "1700000000.000100" }) + +/** + * Shape of a Maple alert notification as it comes back from + * `conversations.replies`: no top-level text, blocks inside one colored + * attachment. Mirrors apps/api/src/services/alerts/AlertDeliveryDispatch.ts. + */ +const alertMessage = (): SlackThreadMessage => + message({ + user: BOT_USER_ID, + botId: "B1", + isMe: true, + raw: { + attachments: [ + { + color: "#e11d48", + fallback: "🚨 checkout p95 — Firing", + blocks: [ + { + type: "header", + text: { type: "plain_text", text: "🚨 checkout p95 — Firing", emoji: true }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: "*p95 latency* is *2.4s* — above 1s, measured over the last 5m.", + }, + fields: [{ type: "mrkdwn", text: "*Severity*\n🔴 Critical" }], + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Open in Maple", emoji: true }, + url: "https://app.maple.dev/alerts/incidents/inc_1", + style: "primary", + }, + ], + }, + { + type: "context", + elements: [{ type: "mrkdwn", text: "🍁 Maple Alerts · Incident `inc_1`" }], + }, + ], + }, + ], + }, + }) + +describe("slackMessageContent", () => { + test("renders a Maple alert card from its attachment blocks", () => { + const { content, fromAttachment } = slackMessageContent(alertMessage()) + expect(fromAttachment).toBe(true) + expect(content).toBe( + [ + "🚨 checkout p95 — Firing", + "*p95 latency* is *2.4s* — above 1s, measured over the last 5m.", + "*Severity*\n🔴 Critical", + "Open in Maple: https://app.maple.dev/alerts/incidents/inc_1", + "🍁 Maple Alerts · Incident `inc_1`", + ].join("\n"), + ) + }) + + test("prefers plain text over any block rendering of it", () => { + const { content, fromAttachment } = slackMessageContent( + message({ + text: "p95 is up", + markdown: "p95 is up", + raw: { blocks: [{ type: "section", text: { type: "mrkdwn", text: "p95 is up" } }] }, + }), + ) + expect(content).toBe("p95 is up") + expect(fromAttachment).toBe(false) + }) + + test("renders top-level rich_text blocks as paragraphs, not one word per line", () => { + const { content } = slackMessageContent( + message({ + raw: { + blocks: [ + { + type: "rich_text", + elements: [ + { + type: "rich_text_section", + elements: [ + { type: "text", text: "see " }, + { + type: "link", + url: "https://app.maple.dev/traces/t1", + text: "this trace", + }, + { type: "text", text: " " }, + { type: "user", user_id: "U2" }, + ], + }, + ], + }, + ], + }, + }), + ) + expect(content).toBe("see this trace (https://app.maple.dev/traces/t1) <@U2>") + }) + + test("falls back to the attachment fallback line when it carries no blocks", () => { + const { content, fromAttachment } = slackMessageContent( + message({ raw: { attachments: [{ fallback: "🚨 checkout p95 — Firing" }] } }), + ) + expect(content).toBe("🚨 checkout p95 — Firing") + expect(fromAttachment).toBe(true) + }) + + test("sweeps text out of block types it has no case for", () => { + const { content } = slackMessageContent( + message({ + raw: { blocks: [{ type: "some_future_block", body: { type: "mrkdwn", text: "kept" } }] }, + }), + ) + expect(content).toBe("kept") + }) + + test("is empty rather than throwing on a message with nothing readable", () => { + expect(slackMessageContent(message({ raw: { blocks: "not-an-array" } })).content).toBe("") + }) +}) + +describe("formatThreadContext", () => { + test("returns undefined for an empty thread", () => { + expect(formatThreadContext([])).toBeUndefined() + }) + + test("attributes an alert card to a bot, not to the agent", () => { + const rendered = formatThreadContext([alertMessage()], { botUserId: BOT_USER_ID }) + expect(rendered).toContain("sender_type: bot") + expect(rendered).not.toContain("sender_type: agent") + expect(rendered).toContain("🚨 checkout p95 — Firing") + }) + + test("keeps the alert and the discussion under it in thread order", () => { + const rendered = formatThreadContext( + [ + alertMessage(), + userMessage("is this the deploy from 10 minutes ago?", "1700000000.000200"), + message({ + text: "Checking the last release now.", + markdown: "Checking the last release now.", + user: BOT_USER_ID, + botId: "B1", + isMe: true, + ts: "1700000000.000300", + threadTs: "1700000000.000100", + }), + ], + { botUserId: BOT_USER_ID }, + ) + expect(rendered?.startsWith("")).toBe(true) + expect(rendered?.endsWith("")).toBe(true) + expect(rendered?.match(/sender_type: (\w+)/gu)).toEqual([ + "sender_type: bot", + "sender_type: user", + "sender_type: agent", + ]) + }) + + test("labels a third-party bot as a bot, not as the agent", () => { + const github = message({ + text: "PR #338 merged", + markdown: "PR #338 merged", + user: "UGH", + botId: "B9", + isMe: true, + }) + expect(formatThreadContext([github], { botUserId: BOT_USER_ID })).toContain("sender_type: bot") + // Without a learned bot user id we can only fall back to eve's coarser + // "any bot is me". + expect(formatThreadContext([github])).toContain("sender_type: agent") + }) +}) + +describe("loadThreadContext", () => { + const thread = (messages: readonly SlackThreadMessage[]) => { + const recentMessages: SlackThreadMessage[] = [] + return { + recentMessages, + refresh: async () => { + recentMessages.length = 0 + recentMessages.push(...messages) + }, + } + } + + test("includes the whole thread before the triggering message", async () => { + const trigger = userMessage("<@UBOT> why did this fire?", "1700000000.000300") + const context = await loadThreadContext( + thread([alertMessage(), userMessage("hm", "1700000000.000200"), trigger]), + { threadTs: trigger.threadTs, ts: trigger.ts }, + { botUserId: BOT_USER_ID }, + ) + expect(context).toHaveLength(1) + // The alert survives — this is the bug: `since: "last-agent-reply"` used to + // cut context off after it, leaving the model nothing to answer from. + expect(context[0]).toContain("🚨 checkout p95 — Firing") + expect(context[0]).toContain("hm") + expect(context[0]).not.toContain("why did this fire?") + }) + + test("adds nothing for a thread root", async () => { + const root = userMessage("<@UBOT> hello", "1700000000.000100") + expect(await loadThreadContext(thread([root]), { threadTs: root.ts, ts: root.ts })).toEqual([]) + }) + + test("degrades to no context when Slack fails, so the turn still dispatches", async () => { + const failing = { + recentMessages: [] as SlackThreadMessage[], + refresh: async () => { + throw new Error("slack down") + }, + } + expect( + await loadThreadContext(failing, { threadTs: "1700000000.000100", ts: "1700000000.000300" }), + ).toEqual([]) + }) +}) + +describe("bot identity", () => { + test("learns the bot user id from a webhook envelope, per team", () => { + rememberBotUserId( + JSON.stringify({ + type: "event_callback", + team_id: "T1", + authorizations: [{ user_id: BOT_USER_ID, is_bot: true }], + event: { type: "app_mention" }, + }), + ) + expect(botUserIdForTeam("T1")).toBe(BOT_USER_ID) + expect(botUserIdForTeam("T2")).toBeUndefined() + expect(botUserIdForTeam(undefined)).toBeUndefined() + }) + + test("ignores bodies it cannot read", () => { + rememberBotUserId("not json") + rememberBotUserId(JSON.stringify({ type: "event_callback", team_id: "T1" })) + expect(botUserIdForTeam("T1")).toBeUndefined() + }) +}) diff --git a/apps/slack-agent/agent/lib/thread-context.ts b/apps/slack-agent/agent/lib/thread-context.ts new file mode 100644 index 000000000..afaa18f6d --- /dev/null +++ b/apps/slack-agent/agent/lib/thread-context.ts @@ -0,0 +1,300 @@ +import { loadThreadContextMessages, type SlackThread, type SlackThreadMessage } from "eve/channels/slack" + +/** + * Background context for a turn: the Slack thread the bot was tagged in, + * rendered for the model. + * + * eve ships this as `slackChannel({ threadContext })`, but two of its choices + * made an @mention inside a Maple alert thread arrive with no usable context at + * all — the exact case where context matters most, since the user is replying + * to something the bot said rather than starting a topic: + * + * 1. **Content comes from `message.text` only.** Maple's alert notifications + * carry no top-level text: they are `chat.postMessage` with one colored + * attachment holding the Block Kit blocks + * (`apps/api/src/services/alerts/AlertDeliveryDispatch.ts` — the color bar + * has no block equivalent, and top-level text would render as a duplicate + * line above it). eve rendered them as an empty ``, so + * "why did this fire?" hung on nothing and the user had to paste a recap + * of the alert by hand. + * 2. **`since: "last-agent-reply"` treated the alert as the agent's own last + * reply** (eve's `isMe` is `bot_id !== undefined`, i.e. any bot), and cut + * the context off *after* it — dropping the alert and everything before it. + * + * So we render context ourselves: same `` / + * `` envelope eve uses (the model's view is unchanged in shape), + * but content falls back to blocks and attachments when `text` is empty, and + * the whole thread is included — the `thread-root` boundary. Full-thread is + * also what makes this survive a lost session: eve sessions are keyed + * `channelId:threadTs` and a Railway redeploy can drop the history that an + * incremental boundary silently assumes is still there. + * + * Wired in `#channels/slack.js` through `onAppMention` / `onDirectMessage`, + * which return these strings as the mention result's `context`. + * + * Two bounds worth knowing: every turn re-injects the transcript (so a long + * thread pays for its history once per mention), and eve's `thread.refresh()` + * fetches one oldest-first page of 50 replies, so past 50 the tail is the part + * that goes missing. Alert threads are short; revisit if that stops holding. + */ + +/** Who posted a context message, from the model's point of view. */ +export type ThreadContextSenderType = "agent" | "bot" | "unknown" | "user" + +export interface ThreadContextOptions { + /** + * This workspace's bot user id (`#lib/bot-identity.js`). Distinguishes the + * agent's own replies from every other bot in the channel. When absent we + * fall back to eve's coarser "any bot is me". + */ + readonly botUserId?: string | undefined +} + +/** + * Loads the thread the triggering message belongs to and renders it as one + * context string, or `[]` when there is nothing to add (thread root, empty + * thread, or a Slack call that failed). + * + * Never throws: eve drops the whole mention when an `onAppMention` handler + * throws, and losing the reply entirely is far worse than losing its context. + */ +export async function loadThreadContext( + thread: Pick, + message: { readonly threadTs: string; readonly ts: string }, + options: ThreadContextOptions = {}, +): Promise { + let messages: readonly SlackThreadMessage[] + try { + messages = await loadThreadContextMessages(thread, message, { since: "thread-root" }) + } catch (error) { + console.warn("[slack-thread-context] Failed to load thread context; dispatching without it.", error) + return [] + } + const rendered = formatThreadContext(messages, options) + return rendered === undefined ? [] : [rendered] +} + +/** + * Renders thread messages as attributed background context, or `undefined` + * when there are none. Mirrors eve's `formatSlackThreadContext` envelope. + */ +export function formatThreadContext( + messages: readonly SlackThreadMessage[], + options: ThreadContextOptions = {}, +): string | undefined { + if (messages.length === 0) return undefined + return [ + "", + ...messages.map((message) => formatThreadContextMessage(message, options)), + "", + ].join("\n") +} + +function formatThreadContextMessage(message: SlackThreadMessage, options: ThreadContextOptions): string { + const { content, fromAttachment } = slackMessageContent(message) + const senderId = message.user ?? message.botId + return [ + "", + `sender_type: ${senderType(message, fromAttachment, options)}`, + ...(senderId ? [`sender_id: ${senderId}`] : []), + `thread_ts: ${message.threadTs}`, + `message_ts: ${message.ts}`, + "", + content, + "", + "", + ].join("\n") +} + +function senderType( + message: SlackThreadMessage, + fromAttachment: boolean, + options: ThreadContextOptions, +): ThreadContextSenderType { + // An app notification posted through the same bot user — a Maple alert card — + // is not something the agent said. Labelling it `agent` would have the model + // believe it already reported the incident it is being asked about. The + // agent's own replies never use attachments (eve posts `markdown_text` or + // top-level blocks), so "content came from an attachment" separates them. + if (fromAttachment) return "bot" + if (options.botUserId !== undefined) { + if (message.user === options.botUserId) return "agent" + if (message.botId !== undefined) return "bot" + } else if (message.isMe) { + return "agent" + } + if (message.user !== undefined) return "user" + if (message.botId !== undefined) return "bot" + return "unknown" +} + +/** + * The readable content of a thread message, and whether it had to be recovered + * from an attachment (Slack's legacy surface, which is where Block Kit cards + * with a color bar live — Maple alerts among them). + * + * Precedence is plain text → top-level blocks → attachments, so a message that + * has real text is never re-derived from its block rendering of that same text. + */ +export function slackMessageContent(message: SlackThreadMessage): { + readonly content: string + readonly fromAttachment: boolean +} { + // `markdown` is eve's mrkdwn → GFM rendering of `text`. + const text = message.markdown.trim() || message.text.trim() + if (text) return { content: text, fromAttachment: false } + + const blocks = blocksText(message.raw.blocks) + if (blocks) return { content: blocks, fromAttachment: false } + + const attachments = attachmentsText(message.raw.attachments) + if (attachments) return { content: attachments, fromAttachment: true } + + return { content: "", fromAttachment: false } +} + +function attachmentsText(value: unknown): string { + return joinLines( + asArray(value).map((attachment) => { + if (!isRecord(attachment)) return "" + const blocks = blocksText(attachment.blocks) + if (blocks) return joinLines([str(attachment.title), blocks]) + // Legacy attachments carry their body in `text`; `fallback` is the + // notification-preview one-liner and the last thing worth showing. + return joinLines([str(attachment.title), str(attachment.text)]) || str(attachment.fallback) + }), + ) +} + +function blocksText(value: unknown): string { + return joinLines(asArray(value).map(blockText)) +} + +function blockText(block: unknown): string { + if (!isRecord(block)) return "" + switch (block.type) { + case "divider": + return "" + case "header": + case "section": + return joinLines([textObject(block.text), ...asArray(block.fields).map(textObject)]) + case "context": + case "actions": + return joinLines(asArray(block.elements).map(elementText)) + case "rich_text": + return joinLines(asArray(block.elements).map(richTextBlockText)) + case "markdown": + return str(block.text) + case "image": + return imageText(block) + default: + // Unknown / future block types: sweep every text object out of them + // rather than dropping content we simply don't have a case for. + return joinLines(collectTextObjects(block)) + } +} + +function elementText(element: unknown): string { + if (!isRecord(element)) return "" + if (element.type === "image") return imageText(element) + const label = textObject(element.text) + // Buttons: the label alone ("Open in Maple") says nothing the model can act + // on — the link is the payload. + const url = str(element.url) + if (label && url) return `${label}: ${url}` + return label || url || joinLines(collectTextObjects(element)) +} + +function richTextBlockText(element: unknown): string { + if (!isRecord(element)) return "" + switch (element.type) { + case "rich_text_list": + return joinLines(asArray(element.elements).map((item) => `- ${richTextBlockText(item)}`)) + case "rich_text_quote": + return joinLines( + inlineText(element.elements) + .split("\n") + .map((line) => `> ${line}`), + ) + default: + // rich_text_section / rich_text_preformatted, and anything Slack adds. + return inlineText(element.elements) + } +} + +/** Inline rich-text runs concatenate — they are one paragraph, not a list. */ +function inlineText(value: unknown): string { + return asArray(value) + .map((element) => { + if (!isRecord(element)) return "" + switch (element.type) { + case "text": + // Not `str()`: the whitespace between runs is the sentence's own + // spacing, and trimming each run glues the words together. + return typeof element.text === "string" ? element.text : "" + case "link": { + const label = str(element.text) + const url = str(element.url) + return label && label !== url ? `${label} (${url})` : url + } + case "user": + return `<@${str(element.user_id)}>` + case "usergroup": + return `` + case "channel": + return `<#${str(element.channel_id)}>` + case "emoji": + return `:${str(element.name)}:` + case "broadcast": + return `` + default: + return "" + } + }) + .join("") + .trim() +} + +function imageText(block: Record): string { + const alt = str(block.alt_text) || textObject(block.title) + return alt ? `[image: ${alt}]` : "" +} + +/** The text of a Slack `{ type: "mrkdwn" | "plain_text", text }` object. */ +function textObject(value: unknown): string { + return isRecord(value) ? str(value.text) : "" +} + +function collectTextObjects(value: unknown, out: string[] = []): string[] { + if (Array.isArray(value)) { + for (const item of value) collectTextObjects(item, out) + return out + } + if (!isRecord(value)) return out + if ((value.type === "mrkdwn" || value.type === "plain_text") && typeof value.text === "string") { + const text = value.text.trim() + if (text) out.push(text) + return out + } + for (const item of Object.values(value)) collectTextObjects(item, out) + return out +} + +function joinLines(parts: readonly string[]): string { + return parts + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .join("\n") +} + +function str(value: unknown): string { + return typeof value === "string" ? value.trim() : "" +} + +function asArray(value: unknown): readonly unknown[] { + return Array.isArray(value) ? value : [] +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/apps/slack-agent/agent/lib/thread-follow-up.ts b/apps/slack-agent/agent/lib/thread-follow-up.ts index 7084151c1..68906a30e 100644 --- a/apps/slack-agent/agent/lib/thread-follow-up.ts +++ b/apps/slack-agent/agent/lib/thread-follow-up.ts @@ -1,3 +1,4 @@ +import { botUserIdFromEnvelope } from "./bot-identity.js" import { resolveBotToken } from "./maple.js" import { createTtlCache } from "./ttl-cache.js" @@ -217,21 +218,6 @@ function parseFollowUpCandidate(rawBody: string): FollowUpCandidate | null { } } -/** - * The event envelope's `authorizations` names the app's bot user — no - * `auth.test` round-trip needed. - */ -function botUserIdFromEnvelope(envelope: Record): string | null { - const authorizations = envelope.authorizations - if (!Array.isArray(authorizations)) return null - for (const auth of authorizations) { - if (isRecord(auth) && typeof auth.user_id === "string" && auth.user_id.length > 0) { - return auth.user_id - } - } - return null -} - async function isBotEngagedInThread( candidate: FollowUpCandidate, deps: ThreadFollowUpDeps,