Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
14 changes: 13 additions & 1 deletion apps/worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,7 +34,9 @@
createTrackerSyncHandler,
handleJobCleanup,
handleGithubReactionPoll,
handleSlackMirror,
handlePendingResponseSweep,
createJob,

Check warning on line 39 in apps/worker/src/index.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Test

'createJob' is defined but never used
} from '@copilotkit/outpost/queue';
import { buildSyncEngine } from './build-sync-engine.js';

Expand Down Expand Up @@ -71,12 +74,20 @@
[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,
},
});

Expand All @@ -91,6 +102,7 @@
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 ──────────────────────────────────────────────────────
Expand Down
14 changes: 14 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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

```
Expand Down
149 changes: 148 additions & 1 deletion packages/outpost/queue/src/__tests__/ai-response.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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);
});
});
});

/**
Expand Down
Loading
Loading