diff --git a/.env.example b/.env.example index 2d476a34..10288d95 100644 --- a/.env.example +++ b/.env.example @@ -66,6 +66,52 @@ SLACK_APP_TOKEN=xapp-... SLACK_SIGNING_SECRET= SLACK_SOCKET_MODE=true +# ─── Slack Ticket Mirror ───────────────────────────────────────────────────── +# Mirrors tickets into one Slack channel: the ticket opens a thread, community +# follow-ups and the AI reply thread under it. Read-only (replying in Slack does +# NOT post back to the source). +# +# Which tickets: the allowlist in isMirrorableSource() +# (packages/outpost/shared/src/platforms/slack-mirror-config.ts) — today Discord, +# GitHub issues, and GitHub discussions. Slack-sourced tickets are excluded +# (they already live in Slack); so are Teams/Email/Web/Manual/Linear. +# +# Mode: off (default, nothing is enqueued and the handler no-ops) +# | shadow (logs what it would post; posts nothing; needs NO token) +# | live (posts; requires SLACK_BOT_TOKEN, or the mirror stays disabled +# and logs why once) +# Any mode does nothing at all while SLACK_MIRROR_CHANNEL_ID is unset. +# +# Deliberately INDEPENDENT of SHADOW_MODE — that flag protects community +# surfaces (Discord/GitHub) where real reporters watch; this posts to an +# internal team channel, so staging posting here is intended. +# +# WHICH SERVICES need these vars: the handler runs in outpost-worker, but the +# PRODUCERS gate on the same config — readSlackMirrorConfig() is called from +# InboundHandler, which runs inside outpost-discord-bot and outpost-github-app. +# Set SLACK_MIRROR_MODE + SLACK_MIRROR_CHANNEL_ID on the worker AND on every +# service that creates tickets, or nothing is ever enqueued and the mirror is +# silently dead. +# +# SCOPE IN v1: ticket-opens from Discord and GitHub, AI replies on both, and +# community follow-ups from DISCORD ONLY. A follow-up comment on a GitHub issue +# or discussion never reaches the mirror, and nothing logs that it did not — so +# a Slack thread that stops after the AI reply does not mean the reporter went +# quiet. See docs/deployment.md. +SLACK_MIRROR_MODE=off +# Channel ID, NOT a channel name. Slack: open channel -> click its name -> +# bottom of the details pane -> Channel ID. Looks like C09AB2CD3EF. +SLACK_MIRROR_CHANNEL_ID= +# NOTE: SLACK_BOT_TOKEN above needs chat:write and must be set on the WORKER +# service (outpost-worker) — the mirror handler posts from there, not from the +# Slack bot. Invite the bot to the channel or posts fail not_in_channel (the +# handler reports that as permanent and does not retry it). +# Keep this channel OUT of MONITORED_CHANNEL_IDS. The Slack bot drops events +# carrying a bot_id (apps/slack-bot/src/events/message.ts), so its own mirror +# posts would not become tickets today — but monitoring the mirror channel +# would duplicate every ticket's context into the bot's inbound path and makes +# the loop one filter change away. Keep the two channel sets disjoint. + # ─── Teams Bot ─────────────────────────────────────────────────────────────── TEAMS_APP_ID= TEAMS_APP_PASSWORD= diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 1eb3cb74..9d78a3a4 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -15,7 +15,8 @@ * - TRACKER_SYNC: Push changes to external trackers * - JOB_CLEANUP: Periodic cleanup of old jobs and sync events * - GITHUB_REACTION_POLL: Poll GitHub reactions on AI comments (no webhook exists) - * - PENDING_RESPONSE_SWEEP: Settle AI responses stranded in PENDING by a dead job + * - SLACK_MIRROR: mirror a ticket or reply into the internal Slack channel + * - PENDING_RESPONSE_SWEEP: settle AI responses stranded in PENDING by a dead job */ import http from 'node:http'; @@ -33,7 +34,9 @@ import { createTrackerSyncHandler, handleJobCleanup, handleGithubReactionPoll, + handleSlackMirror, handlePendingResponseSweep, + createJob, } from '@copilotkit/outpost/queue'; import { buildSyncEngine } from './build-sync-engine.js'; @@ -71,12 +74,20 @@ const worker = new Worker({ [JobType.TRACKER_SYNC]: 1, [JobType.JOB_CLEANUP]: 1, [JobType.GITHUB_REACTION_POLL]: 1, + // 1, not 2: the ticket and reply jobs for one ticket race to claim the + // same TicketExternalLink row. The handler survives the race, but serial + // processing keeps one ticket's thread in one Slack thread by construction. + [JobType.SLACK_MIRROR]: 1, [JobType.PENDING_RESPONSE_SWEEP]: 1, }, jobTimeouts: { [JobType.AI_RESPONSE]: 120_000, // 2 minutes — AI pipeline is slow [JobType.HUBSPOT_SYNC]: 300_000, // 5 minutes — full sync can be large [JobType.ACCOUNT_SCORING]: 300_000, // 5 minutes — many accounts + // 60s, above the 30s default: a reply that has to open its thread first + // makes two chat.postMessage calls, and WebClient sleeps through Slack's + // rate-limit retries. Timing out mid-post would re-post on the retry. + [JobType.SLACK_MIRROR]: 60_000, }, }); @@ -91,6 +102,7 @@ worker.on(JobType.HUBSPOT_SYNC, handleHubSpotSync); worker.on(JobType.TRACKER_SYNC, handleTrackerSync); worker.on(JobType.JOB_CLEANUP, handleJobCleanup); worker.on(JobType.GITHUB_REACTION_POLL, handleGithubReactionPoll); +worker.on(JobType.SLACK_MIRROR, handleSlackMirror); worker.on(JobType.PENDING_RESPONSE_SWEEP, handlePendingResponseSweep); // ─── Start Scheduler ────────────────────────────────────────────────────── diff --git a/docs/deployment.md b/docs/deployment.md index 1c0fa261..456e9f51 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -79,6 +79,7 @@ Copy `.env.example` and fill in all values. Key groups: - **Discord**: `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `GUILD_ID`, `MONITORED_CHANNEL_IDS` - **GitHub App**: `GITHUB_APP_ID`, `GITHUB_PRIVATE_KEY`, `GITHUB_INSTALLATION_ID`, `GITHUB_WEBHOOK_SECRET`, `GITHUB_TEAM_LOGINS` (optional) - **Slack**: `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `SLACK_SIGNING_SECRET`, `MONITORED_CHANNEL_IDS`, `TEAM_MEMBER_IDS` (optional) +- **Slack ticket mirror**: `SLACK_MIRROR_MODE` (`off` | `shadow` | `live`, default `off`), `SLACK_MIRROR_CHANNEL_ID` (channel ID, not name). **Set both on `outpost-worker` AND on every service that creates tickets** (`outpost-discord-bot`, `outpost-github-app`): the handler runs in the worker, but the producers gate on the same config via `readSlackMirrorConfig()` inside `InboundHandler`, so vars present only on the worker mean no job is ever enqueued and the mirror is silently dead. `live` also needs `SLACK_BOT_TOKEN` with `chat:write` **on the worker**. Without it the mirror does NOT simply log once and carry on: each mirror job dead-letters, one per ticket and one per reply, and that content is gone rather than retried once the token appears. Set the token before setting `live`, or leave the mode `off`. `shadow` needs no token. Any mode is inert while `SLACK_MIRROR_CHANNEL_ID` is unset. Invite the bot to the channel or posts fail `not_in_channel`, which the handler treats as permanent and does not retry. Which tickets get mirrored is decided by `isMirrorableSource()` in `packages/outpost/shared/src/platforms/slack-mirror-config.ts` (today: Discord + GitHub issues and discussions; Slack-sourced tickets are excluded because they already live in Slack). **Scope in v1: ticket-opens from Discord and GitHub, AI replies on both, and community follow-ups from Discord only.** A follow-up comment on a GitHub issue or discussion does NOT reach the mirror: `apps/github-app/src/webhooks/issue-comment.ts` appends its `Message` row directly rather than through `InboundHandler`, so it never enqueues a mirror job, and GitHub Discussions have no comment webhook at all. Nothing logs the omission, so a Slack thread that stops after the AI reply means "no Discord follow-ups", not "the reporter went quiet" — check the GitHub thread itself before concluding anything from the mirror. `SLACK_MIRROR_MODE` is intentionally independent of `SHADOW_MODE`: that flag protects community surfaces, while the mirror targets an internal channel — see the shadow-mode section for the documented exception. Keep `SLACK_MIRROR_CHANNEL_ID` out of the Slack bot's `MONITORED_CHANNEL_IDS`: the bot drops `bot_id` events today, so its own mirror posts do not become tickets, but monitoring the mirror channel would duplicate ticket context into the inbound path and leave the loop one filter change away. - **Teams**: `TEAMS_APP_ID`, `TEAMS_APP_PASSWORD`, `TEAMS_TENANT_ID` (optional, blank for multi-tenant), `MONITORED_CHANNEL_IDS` - **Linear sync**: `LINEAR_API_KEY`, `LINEAR_WEBHOOK_SECRET`, `LINEAR_TEAM_ID` - **Monitoring**: `SENTRY_DSN` (optional), `LOG_LEVEL` @@ -212,6 +213,19 @@ have it set on `outpost-worker`, not only on a bot. When adding any new outbound post path, check `SHADOW_MODE` before posting — otherwise staging will deliver to real users regardless of the flag. +**Documented exception — the Slack ticket mirror.** `SLACK_MIRROR_MODE` gates the mirror +instead of `SHADOW_MODE`, and the two are deliberately independent. The rule above exists to +protect community surfaces where real reporters are watching; the mirror posts to an internal +team channel, so a staging environment mirroring into it is intended rather than a leak. Any +future outbound path that is NOT a community surface may take the same exemption, but it needs +its own flag and a line in this section — the default remains `SHADOW_MODE`. + +| Path | Gated by | +| ---------------------------------------- | -------------------------------------- | +| Discord / GitHub replies (`AI_RESPONSE`) | `SHADOW_MODE` | +| Onboarding digest (`ONBOARDING_DIGEST`) | `SHADOW_MODE` | +| Slack ticket mirror (`SLACK_MIRROR`) | `SLACK_MIRROR_MODE` (internal channel) | + ### Promotion workflow ``` diff --git a/packages/outpost/queue/src/__tests__/ai-response.test.ts b/packages/outpost/queue/src/__tests__/ai-response.test.ts index 8c5f849c..ead4f4fa 100644 --- a/packages/outpost/queue/src/__tests__/ai-response.test.ts +++ b/packages/outpost/queue/src/__tests__/ai-response.test.ts @@ -5,7 +5,22 @@ * classification, message persistence, and escalation triggering. * All external dependencies (Prisma, AIPipeline, etc.) are mocked. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; + +// Seven tests in this file assert the non-shadow path. An inherited +// SHADOW_MODE=true flips the handler and fails them, so the ambient value is +// cleared for the whole suite and restored afterwards. +const AMBIENT_SHADOW = { value: undefined as string | undefined }; + +beforeAll(() => { + AMBIENT_SHADOW.value = process.env.SHADOW_MODE; + delete process.env.SHADOW_MODE; +}); + +afterAll(() => { + if (AMBIENT_SHADOW.value !== undefined) process.env.SHADOW_MODE = AMBIENT_SHADOW.value; + else delete process.env.SHADOW_MODE; +}); import type { JobHandlerContext } from '../types.js'; // ─── Mock Setup ───────────────────────────────────────────────────────────── @@ -72,6 +87,15 @@ const mockGetAdapter = vi.fn().mockReturnValue({ fetchUserInfo: vi.fn(), }); +/** Mutable so a test can switch the mirror on and assert the enqueue. */ +const mockMirrorConfig: { mode: string; channelId: string | null; token: string | null } = { + mode: 'off', + channelId: null, + // A real token: the mirror suite below runs in `live`, and a null token is a + // config the production predicate treats as unable to post. + token: 'xoxb-test', +}; + vi.mock('@copilotkit/outpost/shared', () => ({ AI_CONFIDENCE: { AUTO_RESPOND: 0.9, @@ -89,6 +113,14 @@ vi.mock('@copilotkit/outpost/shared', () => ({ vi.mock('@copilotkit/outpost/shared/platforms', () => ({ hasAdapter: mockHasAdapter, getAdapter: mockGetAdapter, + readSlackMirrorConfig: () => mockMirrorConfig, + isSlackMirrorEnabled: (config: { mode: string; channelId: string | null }) => + config.mode !== 'off' && config.channelId !== null, + // Mirrors the real allowlist in shared/src/platforms/slack-mirror-config.ts. + // That predicate's own truth table is pinned in the mirror handler's suite; + // here it only has to route this producer the way production does. + isMirrorableSource: (source: string) => + ['DISCORD', 'GITHUB_ISSUE', 'GITHUB_DISCUSSION'].includes(source), })); // Import after mocks @@ -2592,6 +2624,121 @@ describe('handleAiResponse', () => { ); }); }); + + // ── Slack ticket mirror ────────────────────────────────────────────── + + describe('Slack ticket mirror enqueue', () => { + function mirrorJobs() { + return mockPrismaJob.create.mock.calls + .map((c) => c[0].data) + .filter((d: { type: string }) => d.type === 'SLACK_MIRROR'); + } + + beforeEach(() => { + // This suite lives inside describe('handleAiResponse'), so the outer + // beforeEach has already cleared mocks and primed the happy path. + // mockPostResponse is re-stubbed deliberately: an earlier version of + // this suite sat OUTSIDE that beforeEach, and its delivered case + // passed only on leftover state from a previous suite. + mockPostResponse.mockResolvedValue('999888'); + mockMirrorConfig.mode = 'live'; + mockMirrorConfig.channelId = 'C0MIRROR'; + }); + + afterEach(() => { + mockMirrorConfig.mode = 'off'; + mockMirrorConfig.channelId = null; + }); + + it('enqueues nothing while the mirror is off', async () => { + mockMirrorConfig.mode = 'off'; + mockMirrorConfig.channelId = null; + + await handleAiResponse({ ticketId: 'tkt-1', source: 'discord' }, makeContext()); + + expect(mirrorJobs()).toHaveLength(0); + }); + + it('marks the reply delivered when the adapter posted it', async () => { + await handleAiResponse({ ticketId: 'tkt-1', source: 'discord' }, makeContext()); + + // Pin that a post actually happened — otherwise this passes even if + // the handler stops posting entirely. + expect(mockPostResponse).toHaveBeenCalledTimes(1); + const jobs = mirrorJobs(); + expect(jobs).toHaveLength(1); + expect(jobs[0].payload).toEqual( + expect.objectContaining({ + kind: 'reply', + ticketId: 'tkt-1', + delivery: 'delivered', + // Pin the RESOLVED source; the handler used to forward the + // AI job's optional hint, yielding source: undefined. + source: 'discord', + }), + ); + }); + + it('reports shadow when SHADOW_MODE is on', async () => { + const originalShadow = process.env.SHADOW_MODE; + try { + process.env.SHADOW_MODE = 'true'; + + await handleAiResponse({ ticketId: 'tkt-1', source: 'discord' }, makeContext()); + + expect(mockPostResponse).not.toHaveBeenCalled(); + expect(mirrorJobs()[0].payload).toEqual( + expect.objectContaining({ kind: 'reply', delivery: 'shadow' }), + ); + } finally { + restoreShadowMode(originalShadow); + } + }); + + // A suppressed run posts safe replacement copy, not the draft the mirror + // renders — the draft reached nobody even though the post succeeded. + it('reports withheld for a suppressed draft even though a post succeeded', async () => { + mockGenerateSupportResponse.mockResolvedValue(suppressedResult); + + await handleAiResponse({ ticketId: 'tkt-1', source: 'discord' }, makeContext()); + + expect(mockPostResponse).toHaveBeenCalledTimes(1); + expect(mirrorJobs()[0].payload).toEqual( + expect.objectContaining({ kind: 'reply', delivery: 'withheld' }), + ); + }); + + it('reports post-failed when the adapter throws', async () => { + mockPostResponse.mockRejectedValue(new Error('discord 500')); + + await handleAiResponse({ ticketId: 'tkt-1', source: 'discord' }, makeContext()); + + expect(mirrorJobs()[0].payload).toEqual( + expect.objectContaining({ kind: 'reply', delivery: 'post-failed' }), + ); + }); + + it('reports no-adapter when the source has none registered', async () => { + mockHasAdapter.mockReturnValue(false); + + await handleAiResponse({ ticketId: 'tkt-1', source: 'discord' }, makeContext()); + + expect(mockPostResponse).not.toHaveBeenCalled(); + expect(mirrorJobs()[0].payload).toEqual( + expect.objectContaining({ kind: 'reply', delivery: 'no-adapter' }), + ); + }); + + // This producer had no source check at all, so a Slack-sourced ticket's + // AI reply opened a thread in the mirror channel. + it('enqueues nothing for a ticket whose source is not mirrorable', async () => { + mockPrismaTicket.findUnique.mockResolvedValue({ ...sampleTicket, source: 'SLACK' }); + + await handleAiResponse({ ticketId: 'tkt-1', source: 'discord' }, makeContext()); + + expect(mirrorJobs()).toHaveLength(0); + }); + }); }); /** diff --git a/packages/outpost/queue/src/__tests__/queue.test.ts b/packages/outpost/queue/src/__tests__/queue.test.ts index befdd4d9..da667364 100644 --- a/packages/outpost/queue/src/__tests__/queue.test.ts +++ b/packages/outpost/queue/src/__tests__/queue.test.ts @@ -219,6 +219,61 @@ describe('Worker', () => { ); }); + // A handler that reports retryable:false has told the worker the failure + // cannot succeed on a retry (malformed payload, missing referenced row, + // permanent API rejection like Slack's not_in_channel). Retrying those burns + // every attempt and leaves a dead-letter trail that reads like a transient + // fault. + it('dead-letters immediately when a handler reports the failure as permanent', async () => { + const jobRow = makeJobRow({ attempts: 0, maxAttempts: 5 }); // attempt would be 1 + mockPrisma.$queryRaw.mockResolvedValueOnce([jobRow]); + mockPrisma.$queryRaw.mockResolvedValue([]); + mockPrismaJob.update.mockResolvedValue({}); + + worker.on(JobType.AI_RESPONSE, async () => { + return { success: false, error: 'not_in_channel', retryable: false }; + }); + + worker.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(mockPrismaJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'job-1' }, + data: expect.objectContaining({ + status: 'DEAD_LETTER', + // The TRUE attempt count. Writing maxAttempts here would + // fabricate an exhausted-retry trail for a job that ran once. + attempts: 1, + error: 'not_in_channel', + }), + }), + ); + }); + + // The flag is opt-in: every pre-existing handler omits it and must keep + // retrying exactly as before. + it('still retries a failure that does not set retryable', async () => { + const jobRow = makeJobRow({ attempts: 0, maxAttempts: 5 }); + mockPrisma.$queryRaw.mockResolvedValueOnce([jobRow]); + mockPrisma.$queryRaw.mockResolvedValue([]); + mockPrismaJob.update.mockResolvedValue({}); + + worker.on(JobType.AI_RESPONSE, async () => { + return { success: false, error: 'transient' }; + }); + + worker.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(mockPrismaJob.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'job-1' }, + data: expect.objectContaining({ status: 'PENDING', attempts: 1 }), + }), + ); + }); + it('retries failed jobs with backoff when attempts remain', async () => { const jobRow = makeJobRow({ attempts: 1, maxAttempts: 5 }); // attempt will be 2 mockPrisma.$queryRaw.mockResolvedValueOnce([jobRow]); @@ -538,10 +593,16 @@ describe('JobType enum', () => { expect(JobType.SLA_CHECK).toBe('SLA_CHECK'); expect(JobType.ESCALATION).toBe('ESCALATION'); expect(JobType.ONBOARDING_DIGEST).toBe('ONBOARDING_DIGEST'); + expect(JobType.SLACK_MIRROR).toBe('SLACK_MIRROR'); + expect(JobType.PENDING_RESPONSE_SWEEP).toBe('PENDING_RESPONSE_SWEEP'); }); - it('has exactly 11 job types', () => { + // The count is here so adding a type without registering a handler in + // apps/worker/src/index.ts is caught. A bare length assertion says nothing + // about WHICH type is missing, so the two most recently added are named + // above — this merge landed both at once and only the count moved. + it('has exactly 12 job types', () => { const values = Object.values(JobType); - expect(values).toHaveLength(11); + expect(values).toHaveLength(12); }); }); diff --git a/packages/outpost/queue/src/handlers/__tests__/slack-mirror.test.ts b/packages/outpost/queue/src/handlers/__tests__/slack-mirror.test.ts new file mode 100644 index 00000000..681fa588 --- /dev/null +++ b/packages/outpost/queue/src/handlers/__tests__/slack-mirror.test.ts @@ -0,0 +1,667 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockTicketFindUnique = vi.fn(); +const mockMessageFindUnique = vi.fn(); +const mockLinkFindUnique = vi.fn(); +const mockLinkCreate = vi.fn(); +const mockLinkUpdate = vi.fn(); + +vi.mock('@copilotkit/outpost/db', () => ({ + prisma: { + ticket: { findUnique: (...a: unknown[]) => mockTicketFindUnique(...a) }, + message: { findUnique: (...a: unknown[]) => mockMessageFindUnique(...a) }, + ticketExternalLink: { + findUnique: (...a: unknown[]) => mockLinkFindUnique(...a), + create: (...a: unknown[]) => mockLinkCreate(...a), + update: (...a: unknown[]) => mockLinkUpdate(...a), + }, + }, +})); + +import { handleSlackMirror, SLACK_MIRROR_PLUGIN } from '../slack-mirror.js'; +import { + readSlackMirrorConfig, + isSlackMirrorEnabled, + canSlackMirrorPost, + isMirrorableSource, +} from '@copilotkit/outpost/shared/platforms'; +import type { SlackMirrorConfig } from '@copilotkit/outpost/shared/platforms'; + +const context = { reportProgress: vi.fn().mockResolvedValue(undefined), jobId: 'job-1' }; + +const TICKET = { + id: 'tkt-1', + displayId: 'OUT-101', + title: 'Sidebar crashes on mount', + description: 'Repro: render CopilotSidebar with no props.', + source: 'GITHUB_ISSUE', + sourceUrl: 'https://github.com/CopilotKit/CopilotKit/issues/42', +}; + +const liveConfig: SlackMirrorConfig = { mode: 'live', channelId: 'C0MIRROR', token: 'xoxb-1' }; + +function makePoster(ts = '1712345678.000100') { + const postMessage = vi.fn().mockResolvedValue({ ts }); + return { poster: { postMessage }, postMessage }; +} + +describe('readSlackMirrorConfig', () => { + it('defaults to off when the mode is unset', () => { + expect(readSlackMirrorConfig({}).mode).toBe('off'); + }); + + it('fails closed on an unrecognized mode rather than posting', () => { + expect(readSlackMirrorConfig({ SLACK_MIRROR_MODE: 'on' }).mode).toBe('off'); + expect(readSlackMirrorConfig({ SLACK_MIRROR_MODE: 'LIVE' }).mode).toBe('live'); + }); + + it('treats a blank channel ID as unset', () => { + expect(readSlackMirrorConfig({ SLACK_MIRROR_CHANNEL_ID: ' ' }).channelId).toBeNull(); + }); +}); + +describe('isSlackMirrorEnabled', () => { + it('is disabled when off, even with a channel configured', () => { + expect(isSlackMirrorEnabled({ mode: 'off', channelId: 'C1', token: 'x' })).toBe(false); + }); + + it('is disabled when live but no channel is configured', () => { + expect(isSlackMirrorEnabled({ mode: 'live', channelId: null, token: 'x' })).toBe(false); + }); + + it('is enabled in shadow without a token, since shadow never posts', () => { + expect(isSlackMirrorEnabled({ mode: 'shadow', channelId: 'C1', token: null })).toBe(true); + }); +}); + +describe('handleSlackMirror', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockTicketFindUnique.mockResolvedValue(TICKET); + mockLinkFindUnique.mockResolvedValue(null); + mockLinkCreate.mockResolvedValue({}); + mockLinkUpdate.mockResolvedValue({}); + }); + + it('posts nothing and touches no DB when the mirror is off', async () => { + const { poster, postMessage } = makePoster(); + const result = await handleSlackMirror({ ticketId: 'tkt-1', kind: 'ticket' }, context, { + config: { mode: 'off', channelId: 'C0MIRROR', token: 'xoxb-1' }, + poster, + }); + + expect(result).toEqual({ success: true, data: { skipped: 'mirror-disabled' } }); + expect(postMessage).not.toHaveBeenCalled(); + expect(mockTicketFindUnique).not.toHaveBeenCalled(); + }); + + it('opens a thread and records the link under plugin slack', async () => { + const { poster, postMessage } = makePoster('1712345678.000100'); + + const result = await handleSlackMirror({ ticketId: 'tkt-1', kind: 'ticket' }, context, { + config: liveConfig, + poster, + }); + + expect(result.success).toBe(true); + expect(postMessage).toHaveBeenCalledTimes(1); + const posted = postMessage.mock.calls[0][0]; + expect(posted.channel).toBe('C0MIRROR'); + expect(posted.thread_ts).toBeUndefined(); + expect(posted.text).toContain('OUT-101'); + expect(posted.text).toContain('Sidebar crashes on mount'); + expect(posted.text).toContain('https://github.com/CopilotKit/CopilotKit/issues/42'); + + expect(mockLinkCreate).toHaveBeenCalledTimes(1); + expect(mockLinkCreate.mock.calls[0][0].data).toMatchObject({ + ticketId: 'tkt-1', + plugin: SLACK_MIRROR_PLUGIN, + externalId: 'C0MIRROR:1712345678.000100', + }); + }); + + it('does not open a second thread for an already-mirrored ticket', async () => { + mockLinkFindUnique.mockResolvedValue({ externalId: 'C0MIRROR:111.222' }); + const { poster, postMessage } = makePoster(); + + const result = await handleSlackMirror({ ticketId: 'tkt-1', kind: 'ticket' }, context, { + config: liveConfig, + poster, + }); + + expect(result).toEqual({ success: true, data: { skipped: 'already-mirrored' } }); + expect(postMessage).not.toHaveBeenCalled(); + expect(mockLinkCreate).not.toHaveBeenCalled(); + }); + + it('threads a reply under the existing ticket thread', async () => { + mockLinkFindUnique.mockResolvedValue({ externalId: 'C0MIRROR:111.222' }); + mockMessageFindUnique.mockResolvedValue({ + id: 'msg-9', + author: 'octocat (12345)', + content: 'Still broken on 1.10.2', + isAiGenerated: false, + }); + const { poster, postMessage } = makePoster(); + + const result = await handleSlackMirror( + { ticketId: 'tkt-1', kind: 'reply', messageId: 'msg-9' }, + context, + { config: liveConfig, poster }, + ); + + expect(result.success).toBe(true); + expect(postMessage).toHaveBeenCalledTimes(1); + const posted = postMessage.mock.calls[0][0]; + expect(posted.thread_ts).toBe('111.222'); + expect(posted.text).toContain('Still broken on 1.10.2'); + expect(mockLinkCreate).not.toHaveBeenCalled(); + }); + + it('opens the thread first when a reply arrives with no thread yet', async () => { + mockLinkFindUnique.mockResolvedValue(null); + mockMessageFindUnique.mockResolvedValue({ + id: 'msg-9', + author: 'octocat (12345)', + content: 'Still broken', + isAiGenerated: false, + }); + const { poster, postMessage } = makePoster('999.111'); + + const result = await handleSlackMirror( + { ticketId: 'tkt-1', kind: 'reply', messageId: 'msg-9' }, + context, + { config: liveConfig, poster }, + ); + + expect(result.success).toBe(true); + expect(postMessage).toHaveBeenCalledTimes(2); + expect(postMessage.mock.calls[0][0].thread_ts).toBeUndefined(); + expect(postMessage.mock.calls[1][0].thread_ts).toBe('999.111'); + expect(mockLinkCreate).toHaveBeenCalledTimes(1); + }); + + /** + * Each undelivered cause must render its OWN reason. The handler used to + * print "withheld or shadow mode" for every one of them, naming a cause that + * was not established for four of the five. + */ + it.each([ + ['shadow', 'SHADOW_MODE was on'], + ['withheld', 'groundedness gate withheld'], + ['post-failed', 'posting to the source platform failed'], + ['no-adapter', 'no delivery was attempted'], + ] as const)('renders the %s reason on an AI reply', async (delivery, expected) => { + mockLinkFindUnique.mockResolvedValue({ externalId: 'C0MIRROR:111.222' }); + mockMessageFindUnique.mockResolvedValue({ + id: 'msg-ai', + author: 'outpost-ai', + content: '## Bug Confirmed…', + isAiGenerated: true, + }); + const { poster, postMessage } = makePoster(); + + await handleSlackMirror( + { ticketId: 'tkt-1', kind: 'reply', messageId: 'msg-ai', delivery }, + context, + { config: liveConfig, poster }, + ); + + const text = postMessage.mock.calls[0][0].text; + expect(text).toContain(expected); + expect(text).toContain('not sent'); + }); + + it('does not mark a delivered AI reply', async () => { + mockLinkFindUnique.mockResolvedValue({ externalId: 'C0MIRROR:111.222' }); + mockMessageFindUnique.mockResolvedValue({ + id: 'msg-ai', + author: 'outpost-ai', + content: 'Here is the fix', + isAiGenerated: true, + }); + const { poster, postMessage } = makePoster(); + + await handleSlackMirror( + { ticketId: 'tkt-1', kind: 'reply', messageId: 'msg-ai', delivery: 'delivered' }, + context, + { config: liveConfig, poster }, + ); + + const text = postMessage.mock.calls[0][0].text; + expect(text).not.toContain('not sent'); + expect(text).not.toContain('unconfirmed'); + }); + + // Unknown is not "fine": an AI reply whose fate the payload never recorded + // must not read as delivered. + it('renders an AI reply with no delivery status as unconfirmed', async () => { + mockLinkFindUnique.mockResolvedValue({ externalId: 'C0MIRROR:111.222' }); + mockMessageFindUnique.mockResolvedValue({ + id: 'msg-ai', + author: 'outpost-ai', + content: 'Here is the fix', + isAiGenerated: true, + }); + const { poster, postMessage } = makePoster(); + + await handleSlackMirror( + { ticketId: 'tkt-1', kind: 'reply', messageId: 'msg-ai' }, + context, + { config: liveConfig, poster }, + ); + + expect(postMessage.mock.calls[0][0].text).toContain('delivery unconfirmed'); + }); + + // A human reply is delivered by definition — no label belongs on it. + it('puts no delivery note on a community reply', async () => { + mockLinkFindUnique.mockResolvedValue({ externalId: 'C0MIRROR:111.222' }); + mockMessageFindUnique.mockResolvedValue({ + id: 'msg-9', + author: 'octocat (12345)', + content: 'Still broken', + isAiGenerated: false, + }); + const { poster, postMessage } = makePoster(); + + await handleSlackMirror({ ticketId: 'tkt-1', kind: 'reply', messageId: 'msg-9' }, context, { + config: liveConfig, + poster, + }); + + const text = postMessage.mock.calls[0][0].text; + expect(text).not.toContain('not sent'); + expect(text).not.toContain('unconfirmed'); + }); + + it('shadow mode posts nothing and records no link', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { poster, postMessage } = makePoster(); + + const result = await handleSlackMirror({ ticketId: 'tkt-1', kind: 'ticket' }, context, { + config: { mode: 'shadow', channelId: 'C0MIRROR', token: null }, + poster, + }); + + expect(result.success).toBe(true); + expect(postMessage).not.toHaveBeenCalled(); + expect(mockLinkCreate).not.toHaveBeenCalled(); + expect(logSpy.mock.calls.flat().join(' ')).toContain('would open thread'); + logSpy.mockRestore(); + }); + + // ── Idempotent link lifecycle ──────────────────────────────────────────── + + it('loses the create race without opening a second thread and replies on the winner ts', async () => { + // Both jobs for this ticket read "no link", so both post a root message. + // The loser's create hits @@unique([ticketId, plugin]). + mockLinkFindUnique.mockResolvedValueOnce(null).mockResolvedValueOnce({ + externalId: 'C0MIRROR:winner.0001', + }); + mockLinkCreate.mockRejectedValue( + Object.assign(new Error('Unique constraint'), { + code: 'P2002', + }), + ); + mockMessageFindUnique.mockResolvedValue({ + id: 'msg-9', + author: 'octocat (12345)', + content: 'Still broken', + isAiGenerated: false, + }); + const { poster, postMessage } = makePoster('loser.0002'); + + const result = await handleSlackMirror( + { ticketId: 'tkt-1', kind: 'reply', messageId: 'msg-9' }, + context, + { config: liveConfig, poster }, + ); + + expect(result.success).toBe(true); + expect(mockLinkCreate).toHaveBeenCalledTimes(1); + expect(mockLinkUpdate).not.toHaveBeenCalled(); + // The reply threads under the winner's ts, not the loser's orphaned post. + const reply = postMessage.mock.calls.at(-1)![0]; + expect(reply.thread_ts).toBe('winner.0001'); + }); + + it('repairs a malformed link with an update instead of a second create', async () => { + mockLinkFindUnique.mockResolvedValue({ externalId: 'no-colon-here' }); + const { poster, postMessage } = makePoster('repair.0003'); + + const result = await handleSlackMirror({ ticketId: 'tkt-1', kind: 'ticket' }, context, { + config: liveConfig, + poster, + }); + + expect(result.success).toBe(true); + expect(postMessage).toHaveBeenCalledTimes(1); + expect(mockLinkCreate).not.toHaveBeenCalled(); + expect(mockLinkUpdate).toHaveBeenCalledTimes(1); + const update = mockLinkUpdate.mock.calls[0][0]; + expect(update.where).toEqual({ + ticketId_plugin: { ticketId: 'tkt-1', plugin: SLACK_MIRROR_PLUGIN }, + }); + expect(update.data).toMatchObject({ externalId: 'C0MIRROR:repair.0003' }); + }); + + it('replies in the channel recorded on the link, not the currently configured one', async () => { + mockLinkFindUnique.mockResolvedValue({ externalId: 'C0OLDCHAN:111.222' }); + mockMessageFindUnique.mockResolvedValue({ + id: 'msg-9', + author: 'octocat (12345)', + content: 'Still broken', + isAiGenerated: false, + }); + const { poster, postMessage } = makePoster(); + + const result = await handleSlackMirror( + { ticketId: 'tkt-1', kind: 'reply', messageId: 'msg-9' }, + context, + { config: { ...liveConfig, channelId: 'C0NEWCHAN' }, poster }, + ); + + expect(result.success).toBe(true); + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage.mock.calls[0][0]).toMatchObject({ + channel: 'C0OLDCHAN', + thread_ts: '111.222', + }); + }); + + it('a duplicate reply job opens no thread and writes no link', async () => { + mockLinkFindUnique.mockResolvedValue({ externalId: 'C0MIRROR:111.222' }); + mockMessageFindUnique.mockResolvedValue({ + id: 'msg-9', + author: 'octocat (12345)', + content: 'Still broken', + isAiGenerated: false, + }); + const { poster, postMessage } = makePoster(); + + for (let i = 0; i < 2; i++) { + const result = await handleSlackMirror( + { ticketId: 'tkt-1', kind: 'reply', messageId: 'msg-9' }, + context, + { config: liveConfig, poster }, + ); + expect(result.success).toBe(true); + } + + // Two reply posts, but never a root post and never a link write. + expect(postMessage).toHaveBeenCalledTimes(2); + for (const [args] of postMessage.mock.calls) { + expect(args.thread_ts).toBe('111.222'); + } + expect(mockLinkCreate).not.toHaveBeenCalled(); + expect(mockLinkUpdate).not.toHaveBeenCalled(); + }); + + it('fails the job when the ticket is gone', async () => { + mockTicketFindUnique.mockResolvedValue(null); + const { poster } = makePoster(); + + const result = await handleSlackMirror({ ticketId: 'tkt-gone', kind: 'ticket' }, context, { + config: liveConfig, + poster, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('tkt-gone'); + expect(result.error).toContain('not found'); + }); + + it('fails a reply job that carries no messageId', async () => { + mockLinkFindUnique.mockResolvedValue({ externalId: 'C0MIRROR:111.222' }); + const { poster } = makePoster(); + + const result = await handleSlackMirror({ ticketId: 'tkt-1', kind: 'reply' }, context, { + config: liveConfig, + poster, + }); + + expect(result.success).toBe(false); + // Assert the FIELD name. The original assertion here was + // toContain('missing'), which matched the ticket id rather than the + // message — it passed for the wrong reason. + expect(result.error).toContain('messageId'); + expect(result.retryable).toBe(false); + }); + // ── Validation owes no side effects ────────────────────────────────────── + // A reply job used to reach the thread-opening post BEFORE discovering it + // had no messageId, so every retry posted another root message to Slack. + + it('posts nothing when a reply job carries no messageId', async () => { + mockLinkFindUnique.mockResolvedValue(null); + const { poster, postMessage } = makePoster(); + + const result = await handleSlackMirror({ ticketId: 'tkt-1', kind: 'reply' }, context, { + config: liveConfig, + poster, + }); + + expect(result.success).toBe(false); + expect(result.retryable).toBe(false); + expect(postMessage).not.toHaveBeenCalled(); + expect(mockLinkCreate).not.toHaveBeenCalled(); + }); + + it('posts nothing when a reply job names a message that does not exist', async () => { + mockLinkFindUnique.mockResolvedValue(null); + mockMessageFindUnique.mockResolvedValue(null); + const { poster, postMessage } = makePoster(); + + const result = await handleSlackMirror( + { ticketId: 'tkt-1', kind: 'reply', messageId: 'msg-gone' }, + context, + { config: liveConfig, poster }, + ); + + expect(result.success).toBe(false); + expect(result.retryable).toBe(false); + expect(result.error).toContain('msg-gone'); + expect(postMessage).not.toHaveBeenCalled(); + }); + + // ── Permanent Slack errors must not burn retries ───────────────────────── + + it('reports not_in_channel as permanent, with the remedy', async () => { + mockLinkFindUnique.mockResolvedValue(null); + const postMessage = vi.fn().mockRejectedValue( + Object.assign(new Error('An API error occurred'), { + data: { error: 'not_in_channel' }, + }), + ); + + const result = await handleSlackMirror({ ticketId: 'tkt-1', kind: 'ticket' }, context, { + config: liveConfig, + poster: { postMessage }, + }); + + expect(result.success).toBe(false); + expect(result.retryable).toBe(false); + expect(result.error).toContain('not_in_channel'); + expect(result.error).toContain('invite'); + expect(mockLinkCreate).not.toHaveBeenCalled(); + }); + + it('lets a transient Slack error retry', async () => { + mockLinkFindUnique.mockResolvedValue(null); + const postMessage = vi.fn().mockRejectedValue(new Error('ratelimited')); + + await expect( + handleSlackMirror({ ticketId: 'tkt-1', kind: 'ticket' }, context, { + config: liveConfig, + poster: { postMessage }, + }), + ).rejects.toThrow('ratelimited'); + }); +}); + +describe('canSlackMirrorPost — the consumer needs a token, the producers do not', () => { + // The producer gate must NOT require a token: the bots only enqueue, and + // demanding a Slack token there would either spread it to services that + // never post or leave the mirror silently dead. + it('stays enabled for the producers when live has no token', () => { + expect(isSlackMirrorEnabled({ mode: 'live', channelId: 'C1', token: null })).toBe(true); + }); + + it('cannot post when live has no token', () => { + expect(canSlackMirrorPost({ mode: 'live', channelId: 'C1', token: null })).toBe(false); + }); + + it('can post in shadow with no token, because shadow posts nothing', () => { + expect(canSlackMirrorPost({ mode: 'shadow', channelId: 'C1', token: null })).toBe(true); + }); + + it('can post when live has a token', () => { + expect(canSlackMirrorPost({ mode: 'live', channelId: 'C1', token: 'xoxb-1' })).toBe(true); + }); + + it('treats an empty channel id as unset', () => { + expect(isSlackMirrorEnabled({ mode: 'live', channelId: '', token: 'x' })).toBe(false); + }); +}); + +describe('isMirrorableSource', () => { + it('covers GitHub and Discord only', () => { + expect(isMirrorableSource('DISCORD')).toBe(true); + expect(isMirrorableSource('GITHUB_ISSUE')).toBe(true); + expect(isMirrorableSource('GITHUB_DISCUSSION')).toBe(true); + }); + + // The old denylist ("everything except SLACK") silently mirrored these. + it.each(['SLACK', 'TEAMS', 'EMAIL', 'WEB', 'MANUAL', 'LINEAR', 'ORCA'])( + 'excludes %s', + (source) => { + expect(isMirrorableSource(source)).toBe(false); + }, + ); +}); + +describe('handleSlackMirror — permanent vs retryable', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockTicketFindUnique.mockResolvedValue(TICKET); + mockLinkFindUnique.mockResolvedValue(null); + mockLinkCreate.mockResolvedValue({}); + mockLinkUpdate.mockResolvedValue({}); + }); + + // The post landed but Slack gave us no ts. Retrying would post again. + it('does not retry when Slack accepts the post but returns no ts', async () => { + const postMessage = vi.fn().mockResolvedValue({}); + + const result = await handleSlackMirror({ ticketId: 'tkt-1', kind: 'ticket' }, context, { + config: liveConfig, + poster: { postMessage }, + }); + + expect(result.success).toBe(false); + expect(result.retryable).toBe(false); + expect(mockLinkCreate).not.toHaveBeenCalled(); + }); + + // A deleted ticket IS retryable on purpose: the producers enqueue outside the + // ticket's transaction, so a worker can legitimately arrive first. + it('leaves a missing ticket retryable', async () => { + mockTicketFindUnique.mockResolvedValue(null); + const { poster } = makePoster(); + + const result = await handleSlackMirror({ ticketId: 'tkt-gone', kind: 'ticket' }, context, { + config: liveConfig, + poster, + }); + + expect(result.success).toBe(false); + expect(result.retryable).toBeUndefined(); + }); + + it('rejects an unknown kind without posting', async () => { + const { poster, postMessage } = makePoster(); + + const result = await handleSlackMirror( + { ticketId: 'tkt-1', kind: 'bogus' as 'ticket' }, + context, + { config: liveConfig, poster }, + ); + + expect(result.success).toBe(false); + expect(result.retryable).toBe(false); + expect(postMessage).not.toHaveBeenCalled(); + }); + + it('reports live-without-token as a permanent misconfiguration', async () => { + const { poster, postMessage } = makePoster(); + + const result = await handleSlackMirror({ ticketId: 'tkt-1', kind: 'ticket' }, context, { + config: { mode: 'live', channelId: 'C0MIRROR', token: null }, + poster, + }); + + expect(result.success).toBe(false); + expect(result.retryable).toBe(false); + expect(result.error).toContain('SLACK_BOT_TOKEN'); + expect(postMessage).not.toHaveBeenCalled(); + }); +}); + +describe('handleSlackMirror — untrusted text', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockTicketFindUnique.mockResolvedValue({ + ...TICKET, + title: 'Crash