diff --git a/packages/junior/src/chat/runtime/reply-executor.ts b/packages/junior/src/chat/runtime/reply-executor.ts index 54ab5e43..9628e6d9 100644 --- a/packages/junior/src/chat/runtime/reply-executor.ts +++ b/packages/junior/src/chat/runtime/reply-executor.ts @@ -50,6 +50,7 @@ import { createSlackAdapterAssistantStatusSession } from "@/chat/slack/assistant import { buildSlackReplyFooter } from "@/chat/slack/footer"; import { maybeUpdateAssistantTitle } from "@/chat/slack/assistant-thread/title"; import { appendSlackLegacyAttachmentText } from "@/chat/slack/legacy-attachments"; +import { maybeRefetchSlackUnfurlAttachments } from "@/chat/slack/unfurl-fetch"; import { type ThreadArtifactsState } from "@/chat/state/artifacts"; import { lookupSlackUser } from "@/chat/slack/user"; import type { TurnContinuationRequest } from "@/chat/services/timeout-resume"; @@ -231,9 +232,16 @@ export function createReplyToThread(deps: ReplyExecutorDeps) { stripLeadingSlackMentionToken: options.explicitMention || Boolean(message.isMention), }); + const enrichedRaw = await maybeRefetchSlackUnfurlAttachments({ + channelId, + threadTs, + messageTs, + originalRaw: message.raw, + text: message.text, + }); const userText = appendSlackLegacyAttachmentText( strippedUserText, - message.raw, + enrichedRaw, ); const preparedState = diff --git a/packages/junior/src/chat/slack/unfurl-fetch.ts b/packages/junior/src/chat/slack/unfurl-fetch.ts new file mode 100644 index 00000000..46a12c5c --- /dev/null +++ b/packages/junior/src/chat/slack/unfurl-fetch.ts @@ -0,0 +1,99 @@ +import { listThreadReplies } from "@/chat/slack/channel"; +import { renderSlackLegacyAttachmentText } from "@/chat/slack/legacy-attachments"; + +const URL_PATTERN = /\bhttps?:\/\/\S+/i; + +/** Return true when the raw message object already carries attachment data. */ +function hasAttachments(raw: unknown): boolean { + if (!raw || typeof raw !== "object") return false; + const attachments = (raw as Record).attachments; + return Array.isArray(attachments) && attachments.length > 0; +} + +/** Return true when the text contains at least one URL that Slack might unfurl. */ +function containsUrl(text: string | undefined): boolean { + return URL_PATTERN.test(text ?? ""); +} + +/** Sleep for the given number of milliseconds. */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Attempt to enrich the raw message object with Slack unfurl attachment data + * fetched from the Slack API. + * + * Slack delivers unfurls asynchronously via `message_changed` events, so the + * original inbound `message.raw` often has an empty `attachments` array even + * when a URL preview is already visible in the Slack UI. This helper retries + * the `conversations.replies` endpoint a few times with short delays so that + * Junior can see unfurl content when constructing the user turn text. + * + * @returns The original `raw` object when no enrichment is needed or possible; + * otherwise a shallow copy of `raw` with `attachments` from the API response. + */ +export async function maybeRefetchSlackUnfurlAttachments(input: { + channelId: string | undefined; + threadTs: string | undefined; + messageTs: string | undefined; + originalRaw: unknown; + text: string | undefined; +}): Promise { + const { channelId, threadTs, messageTs, originalRaw, text } = input; + + // Skip: already has attachment data. + if (hasAttachments(originalRaw)) { + return originalRaw; + } + + // Skip: no URLs means Slack won't generate unfurls. + if (!containsUrl(text)) { + return originalRaw; + } + + // Skip: missing identifiers needed for the API call. + if (!channelId || !messageTs) { + return originalRaw; + } + + const resolvedThreadTs = threadTs ?? messageTs; + + // Retry with short delays — Slack generates most unfurls within 1–2 s. + const delaysMs = [400, 800, 1300]; + + for (const delayMs of delaysMs) { + await sleep(delayMs); + + let replies: Awaited>; + try { + replies = await listThreadReplies({ + channelId, + threadTs: resolvedThreadTs, + targetMessageTs: [messageTs], + limit: 1, + maxPages: 1, + }); + } catch { + // Best-effort; a single failed attempt should not block the turn. + break; + } + + const matched = replies.find((r) => r.ts === messageTs); + if (matched?.attachments?.length) { + const rendered = renderSlackLegacyAttachmentText(matched.attachments); + if (rendered) { + // Merge fetched attachments into the original raw shape so that + // appendSlackLegacyAttachmentText and other consumers work unchanged. + return { + ...(originalRaw && typeof originalRaw === "object" + ? originalRaw + : {}), + attachments: matched.attachments, + }; + } + } + } + + return originalRaw; +} diff --git a/packages/junior/tests/unit/slack/unfurl-fetch.test.ts b/packages/junior/tests/unit/slack/unfurl-fetch.test.ts new file mode 100644 index 00000000..56ac5504 --- /dev/null +++ b/packages/junior/tests/unit/slack/unfurl-fetch.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Module mocks must be declared before importing the module under test ────── + +vi.mock("@/chat/slack/channel", () => ({ + listThreadReplies: vi.fn(), +})); + +// Use fake timers so sleep() resolves instantly. +// Must be set up before the module under test resolves its timer calls. + +import { listThreadReplies } from "@/chat/slack/channel"; +import { maybeRefetchSlackUnfurlAttachments } from "@/chat/slack/unfurl-fetch"; + +const mockListThreadReplies = vi.mocked(listThreadReplies); + +const CHANNEL = "C_TEST"; +const THREAD_TS = "1700000000.000001"; +const MESSAGE_TS = "1700000000.000001"; + +const UNFURL_ATTACHMENT = [ + { + title: "Discord – Some Channel", + title_link: "https://discord.com/channels/123/456/789", + text: "sentry-self-hosted-generic-metrics-consumer-1 is unhealthy upon start", + footer: "Discord", + }, +]; + +function baseInput(overrides?: { + channelId?: string | undefined; + threadTs?: string | undefined; + messageTs?: string | undefined; + originalRaw?: unknown; + text?: string | undefined; +}) { + return { + channelId: CHANNEL, + threadTs: THREAD_TS, + messageTs: MESSAGE_TS, + originalRaw: { channel: CHANNEL, ts: MESSAGE_TS, attachments: [] }, + text: "check https://discord.com/channels/123/456/789", + ...overrides, + }; +} + +describe("maybeRefetchSlackUnfurlAttachments", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.resetAllMocks(); + }); + + it("returns originalRaw unchanged when raw already has attachments", async () => { + const rawWithAttachments = { + channel: CHANNEL, + ts: MESSAGE_TS, + attachments: UNFURL_ATTACHMENT, + }; + + const promise = maybeRefetchSlackUnfurlAttachments( + baseInput({ originalRaw: rawWithAttachments }), + ); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toBe(rawWithAttachments); + expect(mockListThreadReplies).not.toHaveBeenCalled(); + }); + + it("returns originalRaw unchanged when text has no URLs", async () => { + const promise = maybeRefetchSlackUnfurlAttachments( + baseInput({ text: "just a plain message, no links" }), + ); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toEqual(baseInput().originalRaw); + expect(mockListThreadReplies).not.toHaveBeenCalled(); + }); + + it("returns originalRaw unchanged when channelId is undefined", async () => { + const promise = maybeRefetchSlackUnfurlAttachments( + baseInput({ channelId: undefined }), + ); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toEqual(baseInput().originalRaw); + expect(mockListThreadReplies).not.toHaveBeenCalled(); + }); + + it("returns originalRaw unchanged when messageTs is undefined", async () => { + const promise = maybeRefetchSlackUnfurlAttachments( + baseInput({ messageTs: undefined }), + ); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toEqual(baseInput().originalRaw); + expect(mockListThreadReplies).not.toHaveBeenCalled(); + }); + + it("returns enriched raw when Slack returns attachments on first retry", async () => { + mockListThreadReplies.mockResolvedValueOnce([ + { ts: MESSAGE_TS, attachments: UNFURL_ATTACHMENT }, + ]); + + const promise = maybeRefetchSlackUnfurlAttachments(baseInput()); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toMatchObject({ attachments: UNFURL_ATTACHMENT }); + expect(mockListThreadReplies).toHaveBeenCalledOnce(); + expect(mockListThreadReplies).toHaveBeenCalledWith({ + channelId: CHANNEL, + threadTs: THREAD_TS, + targetMessageTs: [MESSAGE_TS], + limit: 1, + maxPages: 1, + }); + }); + + it("retries when first call returns no attachments and second succeeds", async () => { + mockListThreadReplies + .mockResolvedValueOnce([{ ts: MESSAGE_TS, attachments: [] }]) + .mockResolvedValueOnce([ + { ts: MESSAGE_TS, attachments: UNFURL_ATTACHMENT }, + ]); + + const promise = maybeRefetchSlackUnfurlAttachments(baseInput()); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toMatchObject({ attachments: UNFURL_ATTACHMENT }); + expect(mockListThreadReplies).toHaveBeenCalledTimes(2); + }); + + it("returns originalRaw gracefully when all retries find no attachments", async () => { + mockListThreadReplies.mockResolvedValue([ + { ts: MESSAGE_TS, attachments: [] }, + ]); + + const promise = maybeRefetchSlackUnfurlAttachments(baseInput()); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toEqual(baseInput().originalRaw); + expect(mockListThreadReplies).toHaveBeenCalledTimes(3); + }); + + it("returns originalRaw gracefully when listThreadReplies throws", async () => { + mockListThreadReplies.mockRejectedValueOnce(new Error("network error")); + + const promise = maybeRefetchSlackUnfurlAttachments(baseInput()); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toEqual(baseInput().originalRaw); + }); + + it("uses messageTs as threadTs fallback when threadTs is undefined", async () => { + mockListThreadReplies.mockResolvedValueOnce([ + { ts: MESSAGE_TS, attachments: UNFURL_ATTACHMENT }, + ]); + + const promise = maybeRefetchSlackUnfurlAttachments( + baseInput({ threadTs: undefined }), + ); + await vi.runAllTimersAsync(); + await promise; + + expect(mockListThreadReplies).toHaveBeenCalledWith( + expect.objectContaining({ threadTs: MESSAGE_TS }), + ); + }); + + it("preserves existing raw fields on the returned enriched object", async () => { + const rawWithMeta = { + channel: CHANNEL, + ts: MESSAGE_TS, + user: "U_SERGIY", + attachments: [], + }; + mockListThreadReplies.mockResolvedValueOnce([ + { ts: MESSAGE_TS, attachments: UNFURL_ATTACHMENT }, + ]); + + const promise = maybeRefetchSlackUnfurlAttachments( + baseInput({ originalRaw: rawWithMeta }), + ); + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toMatchObject({ + channel: CHANNEL, + ts: MESSAGE_TS, + user: "U_SERGIY", + attachments: UNFURL_ATTACHMENT, + }); + }); +});