Skip to content
Draft
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
20 changes: 20 additions & 0 deletions apps/apollo-vertex/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Slack integration for the Invoice Processing demo (Socket Mode).
# Copy this file to `.env` and fill in the real values from the "Invoice Agent"
# Slack app in the "VS Demos" workspace. NEVER commit `.env` (it is gitignored).

# Bot User OAuth Token — starts with xoxb-
SLACK_BOT_TOKEN=xoxb-your-token-here

# App-Level Token (Socket Mode, scope connections:write) — starts with xapp-
SLACK_APP_TOKEN=xapp-your-token-here

# Signing Secret — 32-char hex. Not strictly required for Socket Mode,
# included for completeness.
SLACK_SIGNING_SECRET=your-32-char-hex-signing-secret

# Channel ID for #ap-exceptions — starts with C
SLACK_CHANNEL_ID=C0XXXXXXXXX

# Port for the Slack listener's small local HTTP endpoint (escalation trigger).
# Kept off 3000 so it never collides with the Next.js dev server.
LISTENER_PORT=3010
7 changes: 7 additions & 0 deletions apps/apollo-vertex/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,10 @@ public/r/
app/theme.generated.css
.vercel
.env*.local

# Slack demo runtime state (regenerated by the listener / reset script)
data/demo-state.json

# Isolated Slack listener deps (installed with npm, not pnpm)
slack/node_modules
slack/package-lock.json
5 changes: 4 additions & 1 deletion apps/apollo-vertex/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@
".dependency-cruiser.js",
"scripts/**",
"next-env.d.ts",
"types/**/*.d.ts"
"types/**/*.d.ts",
"slack/**",
"templates/invoice-review/**",
"app/api/demo-*/**"
],
"overrides": [
{
Expand Down
3 changes: 3 additions & 0 deletions apps/apollo-vertex/app/_meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export default {
"data-querying": "Data Querying",
localization: "Localization",
mcp: "MCP Server",
"invoice-review": {
display: "hidden",
},
auth_callback: {
display: "hidden",
},
Expand Down
34 changes: 34 additions & 0 deletions apps/apollo-vertex/app/api/demo-reply/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { type NextRequest, NextResponse } from "next/server";

// Server-side proxy: the Comms reply input POSTs here, and we forward to the
// Slack listener, which posts the reply into the card's thread (as the
// reviewer) and records it in the shared store.
export const dynamic = "force-dynamic";

const LISTENER_PORT = process.env.LISTENER_PORT || "3010";

export async function POST(request: NextRequest) {
let body = "{}";

Check warning

Code scanning / CodeQL

Useless assignment to local variable Warning

The initial value of body is unused, since it is always overwritten.
try {
body = JSON.stringify(await request.json());
} catch {
body = "{}";
}
try {
const res = await fetch(`http://localhost:${LISTENER_PORT}/reply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
const data = await res.json();
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
} catch {
return NextResponse.json(
{
ok: false,
error: `Slack listener not reachable on :${LISTENER_PORT}. Start it with: cd slack && npm start`,
},
{ status: 502 },
);
}
}
18 changes: 18 additions & 0 deletions apps/apollo-vertex/app/api/demo-state/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";

// Reads the shared demo store that the Slack listener writes to. The invoice
// review UI polls this every couple seconds to reflect Slack-driven actions.
export const dynamic = "force-dynamic";

export async function GET() {
try {
const file = path.join(process.cwd(), "data", "demo-state.json");
const raw = await readFile(file, "utf8");
return NextResponse.json(JSON.parse(raw));
} catch {
// Store not created yet (listener never ran) — return an empty overlay.
return NextResponse.json({ invoices: {}, updated_at: null });
}
}
37 changes: 37 additions & 0 deletions apps/apollo-vertex/app/api/demo-trigger/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type NextRequest, NextResponse } from "next/server";

// Server-side proxy: the prototype's "Escalate to manager" flag action POSTs
// here, and we forward to the standalone Slack listener's local HTTP endpoint.
// Keeps the listener port server-side (no CORS, no client hardcoding).
export const dynamic = "force-dynamic";

const LISTENER_PORT = process.env.LISTENER_PORT || "3010";

export async function POST(request: NextRequest) {
let body = "{}";

Check warning

Code scanning / CodeQL

Useless assignment to local variable Warning

The initial value of body is unused, since it is always overwritten.
try {
body = JSON.stringify(await request.json());
} catch {
body = "{}"; // no body is fine — listener falls back to demo defaults
}
try {
const res = await fetch(
`http://localhost:${LISTENER_PORT}/trigger-escalation`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body,
},
);
const data = await res.json();
return NextResponse.json(data, { status: res.ok ? 200 : 502 });
} catch {
return NextResponse.json(
{
ok: false,
error: `Slack listener not reachable on :${LISTENER_PORT}. Start it with: cd slack && npm start`,
},
{ status: 502 },
);
}
}
10 changes: 10 additions & 0 deletions apps/apollo-vertex/app/invoice-review/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"use client";
import { InvoiceReviewTemplate } from "@/templates/invoice-review/InvoiceReviewTemplate";

export default function InvoiceReviewPage() {
return (
<div className="fixed inset-0 z-50 bg-background">
<InvoiceReviewTemplate />
</div>
);
}
4 changes: 4 additions & 0 deletions apps/apollo-vertex/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export const SUPPORTED_LOCALES = Object.keys(
const DEFAULT_LOCALE: SupportedLocale = "en";

export const configurei18n = async () => {
if (i18n.isInitialized) {
document.documentElement.lang = i18n.language;
return;
}
await i18n
.use({
type: "backend",
Expand Down
1 change: 1 addition & 0 deletions apps/apollo-vertex/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"image_preview": "Image preview",
"import": "Import",
"info": "Info",
"invoices": "Invoices",
"japanese": "Japanese",
"korean": "Korean",
"language": "Language",
Expand Down
2 changes: 2 additions & 0 deletions apps/apollo-vertex/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
"main": "index.js",
"scripts": {
"dev": "pnpm generate:theme && next --turbopack",
"demo": "slack/node_modules/.bin/concurrently --names APP,SLACK --prefix-colors cyan,magenta \"pnpm dev\" \"cd slack && npm start\"",
"demo:reset": "cd slack && npm run reset-demo",
"build": "pnpm generate:theme && pnpm registry:build && next build",
"start": "next start",
"generate:theme": "node --experimental-strip-types scripts/generate-theme-css.ts",
Expand Down
Binary file added apps/apollo-vertex/public/peter-vachon.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions apps/apollo-vertex/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,16 @@
"chart-3": "oklch(0.8300 0.1550 75.2000)",
"chart-4": "oklch(0.7200 0.1800 320.8000)",
"chart-5": "oklch(0.6800 0.1500 245.5000)",
"insight-50": "oklch(0.96 0.03 277)",
"insight-100": "oklch(0.92 0.05 277)",
"insight-200": "oklch(0.86 0.09 277)",
"insight-300": "oklch(0.78 0.14 277)",
"insight-400": "oklch(0.70 0.19 277)",
"insight-500": "oklch(0.62 0.22 277)",
"insight-600": "oklch(0.56 0.20 277)",
"insight-700": "oklch(0.48 0.17 277)",
"insight-800": "oklch(0.38 0.13 278)",
"insight-900": "oklch(0.30 0.10 278)",
"sidebar": "oklch(0.9723 0.0074 260.7300)",
"sidebar-foreground": "oklch(0.2394 0.0455 252.4450)",
"sidebar-primary": "oklch(0.64 0.115 208)",
Expand Down Expand Up @@ -230,6 +240,16 @@
"chart-3": "oklch(0.8300 0.1550 75.2000)",
"chart-4": "oklch(0.7200 0.1800 320.8000)",
"chart-5": "oklch(0.6800 0.1500 245.5000)",
"insight-50": "oklch(0.96 0.03 277)",
"insight-100": "oklch(0.92 0.05 277)",
"insight-200": "oklch(0.86 0.09 277)",
"insight-300": "oklch(0.78 0.14 277)",
"insight-400": "oklch(0.70 0.19 277)",
"insight-500": "oklch(0.62 0.22 277)",
"insight-600": "oklch(0.56 0.20 277)",
"insight-700": "oklch(0.48 0.17 277)",
"insight-800": "oklch(0.38 0.13 278)",
"insight-900": "oklch(0.30 0.10 278)",
"sidebar": "oklch(0.1620 0.0310 257.7000)",
"sidebar-foreground": "oklch(0.9525 0.0110 225.9830)",
"sidebar-primary": "oklch(0.69 0.112 207)",
Expand Down
2 changes: 1 addition & 1 deletion apps/apollo-vertex/registry/button/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as React from "react";
import { cn } from "@/lib/utils";

const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
Expand Down
120 changes: 120 additions & 0 deletions apps/apollo-vertex/slack/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Slack listener — Invoice Processing demo

A standalone Node listener that drives the bidirectional Slack flow in the
Invoice Processing prototype. The prototype itself runs fine without this
listener — the Slack escalation just won't post, and a toast surfaces the
error. Wire it up if you want the full roundtrip.

## What it does

- Connects to Slack in **Socket Mode** (no public webhook required).
- Posts the agent's escalation card to `#ap-exceptions` when the reviewer
flags an invoice with reason "Escalating to manager".
- Handles button clicks on the card (Approve / Hold / Reject) and writes
the resolution to a shared JSON file the prototype polls.
- Ingests human thread replies on the card and surfaces them in the
prototype's Comms feed.
- Posts replies from the prototype's Comms input back into the thread,
attributed to the human via `chat:write.customize`.

Everything is intentionally demo-grade — the team is expected to rewrite
this against real infra later.

## Run it

```bash
cd apps/apollo-vertex/slack
npm install
npm start
```

Reset between rehearsals (deletes the bot's own messages in the channel
and clears the shared store):

```bash
npm run reset-demo
```

## Configuration

The listener reads env vars from `apps/apollo-vertex/.env` (one level up).
Copy `.env.example` to `.env` and fill in real values:

| Var | What it is |
| ---------------------- | --------------------------------------------------- |
| `SLACK_BOT_TOKEN` | Bot User OAuth Token — starts `xoxb-` |
| `SLACK_APP_TOKEN` | App-Level Token for Socket Mode — starts `xapp-` |
| `SLACK_SIGNING_SECRET` | Optional under Socket Mode; included for parity |
| `SLACK_CHANNEL_ID` | Channel ID for `#ap-exceptions` — starts `C` |
| `LISTENER_PORT` | Local HTTP endpoint port (default `3010`) |

`.env` is gitignored. `.env.example` is the template you copy from.

## Slack app setup

In the Slack workspace, create an app **from manifest** with the YAML
below, then install it to the workspace. Grab the tokens from
**OAuth & Permissions** (bot token) and **Basic Information → App-Level
Tokens** (app token with `connections:write`).

```yaml
display_information:
name: Invoice Agent
features:
bot_user:
display_name: Invoice Agent
always_online: true
oauth_config:
scopes:
bot:
- chat:write
- chat:write.customize
- users:read
- channels:history
settings:
event_subscriptions:
bot_events:
- message.channels
interactivity:
is_enabled: true
socket_mode_enabled: true
```

After installing the app, invite the bot into `#ap-exceptions`
(`/invite @Invoice Agent`) so it can post.

## Hardcoded reviewer identity

Two places assume the reviewer is **Peter Vachon**:

- `REVIEWER_NAME` in `slack/server.js` (fallback for Comms attribution
when the prototype's request body omits identity).
- `REVIEWER_NAME` / `REVIEWER_AVATAR_PUBLIC` in
`templates/invoice-review/InvoiceReviewTemplate.tsx`. The public avatar
URL points at Peter's Slack CDN image so `chat:write.customize` can
render it.

If someone else demos, either:
1. Swap those constants for the new presenter, or
2. Stub the prototype to read identity from a config — TODO when the team
rewrites this.

## File layout

```
slack/
├── server.js # Bolt app + HTTP endpoint
├── store.js # JSON-file shared store (data/demo-state.json)
├── escalation-card.js # Block Kit card builder
├── reset-demo.js # Deletes bot's messages + resets store
└── package.json # Isolated npm install (not part of pnpm workspace)
```

The shared store lives at `apps/apollo-vertex/data/demo-state.json`
(gitignored; auto-created on first listener run).

## Architecture in one sentence

Listener writes a JSON file → prototype polls `/api/demo-state` every 2s
→ store changes appear in the UI; user replies hit `/api/demo-reply`
which proxies to the listener at `localhost:3010/reply`.
Loading
Loading