diff --git a/.env-example b/.env-example index 49740d9..77895e1 100644 --- a/.env-example +++ b/.env-example @@ -44,3 +44,14 @@ CLERK_SECRET_KEY="" # SMTP_URL="smtp://localhost:1025" # Mailpit: docker compose --profile tools up -d # DIGEST_FROM_EMAIL="digest@pointup.local" # DIGEST_RECIPIENT_OVERRIDE="you@example.com" # dev fallback when Clerk is not configured + +# Optional: post the weekly digest to chat via the Notifier port (in addition +# to email). Set either/both incoming-webhook URLs. +# SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..." +# DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/..." + +# PointBot chat surface (apps/bot) — Slack slash-command server. +# SLACK_SIGNING_SECRET="" # from your Slack app's Basic Information +# BOT_DEFAULT_USER_ID="" # self-hosted: run every command as this app user +# PORT="8080" # bot HTTP port +# The bot reuses the same LLM_PROVIDER / BEDROCK_MODEL_ID / LLM_* vars as the web app. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a67c7f3..f118675 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - name: Build worker bundle run: npm run build --workspace @pointup/worker + - name: Build bot bundle + run: npm run build --workspace @pointup/bot + infra: name: Typecheck & synth infrastructure runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index b660462..c8610a3 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ yarn-error.log* # cdk infra/cdk.out/ +infra/cdk.context.json **/dist/ diff --git a/Dockerfile.bot b/Dockerfile.bot new file mode 100644 index 0000000..924ff76 --- /dev/null +++ b/Dockerfile.bot @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1 +# PointBot chat surface: an HTTP server handling Slack (and future Discord) +# commands over @pointup/core use cases. Exposes /health and /slack/commands. + +##### DEPENDENCIES ##### +FROM node:22-alpine AS deps +WORKDIR /repo + +COPY package.json package-lock.json ./ +COPY apps/web/package.json apps/web/ +COPY apps/worker/package.json apps/worker/ +COPY apps/bot/package.json apps/bot/ +COPY packages/core/package.json packages/core/ +COPY packages/api-client/package.json packages/api-client/ +RUN npm ci + +##### BUILDER ##### +FROM node:22-alpine AS builder +WORKDIR /repo + +COPY --from=deps /repo/node_modules ./node_modules +COPY . . +RUN npm run build --workspace @pointup/bot + +##### RUNNER ##### +FROM node:22-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=8080 + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 bot + +# The bot is a single esbuild bundle - no node_modules at runtime. +COPY --from=builder --chown=bot:nodejs /repo/apps/bot/dist/index.cjs ./index.cjs + +USER bot +EXPOSE 8080 +ENTRYPOINT ["node", "index.cjs"] diff --git a/README.md b/README.md index e40fdb3..b7f67c0 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Track airline miles, hotel points, credit card rewards, and every other loyalty - [docs/integrations.md](./docs/integrations.md) — loyalty providers (airlines, hotels, credit cards, rail, shopping) and credential vaults (1Password, Apple Keychain, Chrome) - [docs/brand.md](./docs/brand.md) — brand kit: logo assets, color tokens, typography, voice - [docs/migration-from-pointup.md](./docs/migration-from-pointup.md) — how the modernization was ported into `point_bot`, feature-parity checklist, and the Bedrock assistant +- [docs/bot.md](./docs/bot.md) — the PointBot chat surface: Slack/Discord commands, the `Notifier` port, digests, and deployment ## Repository layout @@ -37,7 +38,8 @@ Track airline miles, hotel points, credit card rewards, and every other loyalty │ ├── web/ # Next.js app: pages, components, API routes, composition root │ │ ├── src/components/ # branded UI components (logo, cards, forms) │ │ └── public/brand/ # brand kit assets (SVG logomarks, lockup) -│ └── worker/ # Background jobs: scheduled syncs + email digests +│ ├── worker/ # Background jobs: scheduled syncs + email + chat digests +│ └── bot/ # PointBot chat surface: Slack/Discord commands over the core ├── packages/ │ ├── core/ # Domain + application + infrastructure (framework-free) │ │ ├── src/domain/ # entities, provider catalog, repository ports, errors diff --git a/apps/bot/package.json b/apps/bot/package.json new file mode 100644 index 0000000..347f623 --- /dev/null +++ b/apps/bot/package.json @@ -0,0 +1,24 @@ +{ + "name": "@pointup/bot", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "PointBot chat surface: Slack/Discord commands over @pointup/core use cases", + "scripts": { + "dev": "tsx src/index.ts", + "build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=cjs --outfile=dist/index.cjs", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@pointup/core": "*", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "esbuild": "^0.25.0", + "tsx": "^4.19.0", + "typescript": "^5.9.0", + "vitest": "^3.0.0" + } +} diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts new file mode 100644 index 0000000..81f4037 --- /dev/null +++ b/apps/bot/src/commands.ts @@ -0,0 +1,108 @@ +import { + computePortfolioSummary, + type LoyaltyAccountReadModel, + type ValueAdviceReadModel, +} from "@pointup/core"; + +import { + formatExpiring, + formatPortfolio, + formatValueAdvice, + TRANSFER_DISCLAIMER, +} from "./format"; + +/** + * The use cases the bot needs, as structural interfaces so command handling + * can be unit-tested with plain fakes (no DB, no LLM). + */ +export interface BotUseCases { + listAccounts: { + execute(userId: string): Promise; + }; + getValueAdvice: { + execute(userId: string): Promise; + }; + chatWithAssistant: { + execute(input: { + userId: string; + message: string; + }): Promise<{ reply: string }>; + }; +} + +export interface CommandInput { + /** The application user id the chat identity maps to. */ + readonly userId: string; + /** Everything the user typed after the slash command / bot mention. */ + readonly text: string; +} + +const HELP = [ + "PointBot commands:", + "• `portfolio` — your balances and total value", + "• `expiring` — programs at risk of expiring", + "• `value` — best transfers and affordable deals", + "• `ask ` — grounded advice from the assistant", + "• `help` — this message", +].join("\n"); + +/** + * Framework-free command router. Slack/Discord transports parse their own + * payloads and call this with a resolved `userId` and the raw text. + */ +export async function handleCommand( + input: CommandInput, + useCases: BotUseCases, +): Promise { + const text = input.text.trim(); + const [verb, ...rest] = text.split(/\s+/); + const keyword = (verb ?? "").toLowerCase(); + + switch (keyword) { + case "": + case "help": + return HELP; + + case "portfolio": + case "balances": + case "points": { + const accounts = await useCases.listAccounts.execute(input.userId); + return formatPortfolio(computePortfolioSummary(accounts), accounts); + } + + case "expiring": + case "expire": + case "expirations": { + const accounts = await useCases.listAccounts.execute(input.userId); + return formatExpiring(accounts); + } + + case "value": + case "deals": + case "advice": + case "transfer": + case "transfers": { + const advice = await useCases.getValueAdvice.execute(input.userId); + return `${formatValueAdvice(advice)}\n\n_${TRANSFER_DISCLAIMER}_`; + } + + case "ask": { + const question = rest.join(" ").trim(); + if (!question) return "Ask me something, e.g. `ask should I transfer UR to Hyatt?`"; + return askAssistant(useCases, input.userId, question); + } + + default: + // Anything unrecognized is treated as a free-form assistant question. + return askAssistant(useCases, input.userId, text); + } +} + +async function askAssistant( + useCases: BotUseCases, + userId: string, + message: string, +): Promise { + const { reply } = await useCases.chatWithAssistant.execute({ userId, message }); + return reply; +} diff --git a/apps/bot/src/container.ts b/apps/bot/src/container.ts new file mode 100644 index 0000000..996bc95 --- /dev/null +++ b/apps/bot/src/container.ts @@ -0,0 +1,56 @@ +import { + BedrockAssistant, + ChatWithAssistant, + createDb, + DrizzleBalanceSnapshotRepository, + DrizzleLoyaltyAccountRepository, + DrizzleTripGoalRepository, + GetValueAdvice, + HeuristicAssistant, + ListLoyaltyAccounts, + ListTripGoals, + OpenAiCompatibleAssistant, + type LlmAssistant, +} from "@pointup/core"; + +import type { BotEnv } from "./env"; +import type { BotUseCases } from "./commands"; + +/** Assistant selection mirrors the web composition root. */ +function buildLlm(env: BotEnv): LlmAssistant { + if (env.LLM_PROVIDER === "bedrock" && env.BEDROCK_MODEL_ID) { + return new BedrockAssistant({ + modelId: env.BEDROCK_MODEL_ID, + region: env.AWS_REGION, + }); + } + if (env.LLM_API_KEY) { + return new OpenAiCompatibleAssistant({ + apiKey: env.LLM_API_KEY, + model: env.LLM_MODEL, + baseUrl: env.LLM_BASE_URL, + }); + } + return new HeuristicAssistant(); +} + +/** The bot's composition root — read-only use cases over the shared core. */ +export function createContainer(env: BotEnv): BotUseCases { + const db = createDb(env.DATABASE_URL); + const accounts = new DrizzleLoyaltyAccountRepository(db); + const balances = new DrizzleBalanceSnapshotRepository(db); + const tripGoals = new DrizzleTripGoalRepository(db); + + const listAccounts = new ListLoyaltyAccounts(accounts, balances); + const listGoals = new ListTripGoals(tripGoals, balances); + + return { + listAccounts, + getValueAdvice: new GetValueAdvice(listAccounts), + chatWithAssistant: new ChatWithAssistant( + listAccounts, + listGoals, + buildLlm(env), + ), + }; +} diff --git a/apps/bot/src/env.ts b/apps/bot/src/env.ts new file mode 100644 index 0000000..df0cfea --- /dev/null +++ b/apps/bot/src/env.ts @@ -0,0 +1,47 @@ +import { composeDatabaseUrl } from "@pointup/core"; +import { z } from "zod"; + +/** Same DATABASE_URL resolution as the web app and worker. */ +function getDatabaseUrl(): string | undefined { + if (process.env.DATABASE_URL) return process.env.DATABASE_URL; + const { DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME } = process.env; + if (DB_HOST && DB_USER && DB_PASSWORD && DB_NAME) { + return composeDatabaseUrl({ + host: DB_HOST, + port: DB_PORT, + user: DB_USER, + password: DB_PASSWORD, + database: DB_NAME, + }); + } + return undefined; +} + +const envSchema = z.object({ + DATABASE_URL: z.url(), + PORT: z.coerce.number().int().positive().default(8080), + + /** Slack request signing secret; when unset, the Slack route is disabled. */ + SLACK_SIGNING_SECRET: z.string().min(1).optional(), + + /** + * Self-hosted / personal mode: run every command as this app user id, + * ignoring the platform-supplied identity. Leave unset in multi-user setups, + * where the platform user id is used as the app user id. + */ + BOT_DEFAULT_USER_ID: z.string().min(1).optional(), + + // Assistant provider selection — same precedence as the web app. + LLM_PROVIDER: z.enum(["bedrock", "openai"]).optional(), + LLM_API_KEY: z.string().min(1).optional(), + LLM_MODEL: z.string().min(1).optional(), + LLM_BASE_URL: z.url().optional(), + BEDROCK_MODEL_ID: z.string().min(1).optional(), + AWS_REGION: z.string().min(1).optional(), +}); + +export type BotEnv = z.infer; + +export function loadEnv(): BotEnv { + return envSchema.parse({ ...process.env, DATABASE_URL: getDatabaseUrl() }); +} diff --git a/apps/bot/src/format.ts b/apps/bot/src/format.ts new file mode 100644 index 0000000..db70ebe --- /dev/null +++ b/apps/bot/src/format.ts @@ -0,0 +1,92 @@ +import type { + LoyaltyAccountReadModel, + PortfolioSummaryReadModel, + ValueAdviceReadModel, +} from "@pointup/core"; + +const num = new Intl.NumberFormat("en-US"); +const usd = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, +}); + +/** `~$1,234` style short value. */ +function value(cents: number): string { + return `~${usd.format(cents / 100)}`; +} + +/** Portfolio snapshot: totals plus the most valuable programs. */ +export function formatPortfolio( + summary: PortfolioSummaryReadModel, + accounts: readonly LoyaltyAccountReadModel[], +): string { + if (summary.accountCount === 0) { + return "You have no linked programs yet. Link one on the dashboard, or try the demo portfolio."; + } + const top = [...accounts] + .sort((a, b) => b.estimatedValueCents - a.estimatedValueCents) + .slice(0, 8) + .map((a) => { + const pts = a.latestBalance ? num.format(a.latestBalance.points) : "—"; + return `• ${a.provider.displayName}: ${pts} ${a.provider.pointsCurrency} (${value(a.estimatedValueCents)})`; + }); + const header = `You're tracking ${summary.accountCount} program${summary.accountCount === 1 ? "" : "s"}: ${num.format(summary.totalPoints)} points, worth about ${usd.format(summary.totalValueCents / 100)}.`; + return [header, ...top].join("\n"); +} + +/** Accounts expiring within the warning window, soonest first. */ +export function formatExpiring( + accounts: readonly LoyaltyAccountReadModel[], +): string { + const expiring = accounts + .filter((a) => a.daysUntilExpiry !== null) + .sort((a, b) => (a.daysUntilExpiry ?? 0) - (b.daysUntilExpiry ?? 0)); + if (expiring.length === 0) { + return "Nothing expiring soon. 🎉"; + } + const lines = expiring.slice(0, 10).map((a) => { + const days = a.daysUntilExpiry ?? 0; + const when = + days < 0 ? "may have expired" : `in ${days} day${days === 1 ? "" : "s"}`; + return `• ${a.provider.displayName}: ${when}`; + }); + return [`${expiring.length} program(s) at risk:`, ...lines].join("\n"); +} + +/** Best transfer moves and affordable deals for the user's balances. */ +export function formatValueAdvice(advice: ValueAdviceReadModel): string { + const parts: string[] = []; + + if (advice.transfers.length > 0) { + parts.push("Best transfers right now:"); + for (const t of advice.transfers.slice(0, 5)) { + const bonus = t.bonusLabel ? ` — ${t.bonusLabel}` : ""; + parts.push( + `• ${t.from.displayName} ${num.format(t.sourcePoints)} → ${num.format(t.destinationPoints)} ${t.to.displayName} (~${t.effectiveCentsPerPoint}¢/pt${bonus})`, + ); + } + } + + const affordable = advice.deals.filter((d) => d.affordable).slice(0, 5); + if (affordable.length > 0) { + if (parts.length > 0) parts.push(""); + parts.push("Deals you can afford:"); + for (const d of affordable) { + const cpp = + d.realizedCentsPerPoint !== null + ? ` (~${d.realizedCentsPerPoint}¢/pt)` + : ""; + parts.push(`• ${d.deal.title}${cpp} — ${d.affordabilityNote}`); + } + } + + if (parts.length === 0) { + return "No transfer or deal advice yet — link a transferable currency (Chase UR, Amex MR, Bilt) and record a balance."; + } + return parts.join("\n"); +} + +/** Confirm live transfer ratios before moving points — they're irreversible. */ +export const TRANSFER_DISCLAIMER = + "Confirm live transfer ratios and bonus windows before moving points — transfers are usually irreversible."; diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts new file mode 100644 index 0000000..ecb42d8 --- /dev/null +++ b/apps/bot/src/index.ts @@ -0,0 +1,119 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; + +import { handleCommand } from "./commands"; +import { createContainer } from "./container"; +import { loadEnv } from "./env"; +import { + parseSlackCommand, + resolveUserId, + verifySlackSignature, +} from "./slack"; + +const env = loadEnv(); +const useCases = createContainer(env); + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function json(res: ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body); + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(payload); +} + +/** Post the final command result to Slack's response_url (deferred reply). */ +async function postToSlack(responseUrl: string, text: string): Promise { + await fetch(responseUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ response_type: "ephemeral", text }), + signal: AbortSignal.timeout(10_000), + }).catch((error: unknown) => { + console.warn("[bot] failed to post deferred Slack reply", error); + }); +} + +async function handleSlack( + req: IncomingMessage, + res: ServerResponse, +): Promise { + if (!env.SLACK_SIGNING_SECRET) { + json(res, 503, { error: "Slack integration is not configured" }); + return; + } + + const rawBody = await readBody(req); + const ok = verifySlackSignature({ + rawBody, + signature: header(req, "x-slack-signature"), + timestamp: header(req, "x-slack-request-timestamp"), + signingSecret: env.SLACK_SIGNING_SECRET, + nowMs: Date.now(), + }); + if (!ok) { + json(res, 401, { error: "invalid signature" }); + return; + } + + const command = parseSlackCommand(rawBody); + const userId = resolveUserId(env.BOT_DEFAULT_USER_ID, command.userId); + const responseUrl = new URLSearchParams(rawBody).get("response_url"); + + // Ack within Slack's 3s window, then reply via response_url so slow paths + // (a real LLM call) don't time out the request. + if (responseUrl) { + json(res, 200, { response_type: "ephemeral", text: "On it… 🧮" }); + void handleCommand({ userId, text: command.text }, useCases) + .then((text) => postToSlack(responseUrl, text)) + .catch((error: unknown) => { + console.error("[bot] command failed", error); + return postToSlack( + responseUrl, + "Something went wrong handling that command.", + ); + }); + return; + } + + // No response_url (e.g. manual curl): reply inline. + try { + const text = await handleCommand({ userId, text: command.text }, useCases); + json(res, 200, { response_type: "ephemeral", text }); + } catch (error) { + console.error("[bot] command failed", error); + json(res, 200, { + response_type: "ephemeral", + text: "Something went wrong handling that command.", + }); + } +} + +function header(req: IncomingMessage, name: string): string | undefined { + const value = req.headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +const server = createServer((req, res) => { + if (req.method === "GET" && req.url === "/health") { + json(res, 200, { status: "ok" }); + return; + } + if (req.method === "POST" && req.url === "/slack/commands") { + void handleSlack(req, res).catch((error: unknown) => { + console.error("[bot] request error", error); + if (!res.headersSent) json(res, 500, { error: "internal error" }); + }); + return; + } + json(res, 404, { error: "not found" }); +}); + +server.listen(env.PORT, () => { + console.info(`[bot] listening on :${env.PORT}`); +}); diff --git a/apps/bot/src/slack.ts b/apps/bot/src/slack.ts new file mode 100644 index 0000000..d51ff51 --- /dev/null +++ b/apps/bot/src/slack.ts @@ -0,0 +1,60 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** + * Verify a Slack slash-command request signature. + * https://api.slack.com/authentication/verifying-requests-from-slack + * + * signature == "v0=" + HMAC_SHA256(signingSecret, `v0:${timestamp}:${rawBody}`) + * Requests older than 5 minutes are rejected (replay protection). + */ +export function verifySlackSignature(params: { + readonly rawBody: string; + readonly signature: string | undefined; + readonly timestamp: string | undefined; + readonly signingSecret: string; + readonly nowMs: number; +}): boolean { + const { rawBody, signature, timestamp, signingSecret, nowMs } = params; + if (!signature || !timestamp) return false; + + const ts = Number(timestamp); + if (!Number.isFinite(ts)) return false; + if (Math.abs(nowMs / 1000 - ts) > 300) return false; + + const expected = + "v0=" + + createHmac("sha256", signingSecret) + .update(`v0:${timestamp}:${rawBody}`) + .digest("hex"); + + const a = Buffer.from(expected); + const b = Buffer.from(signature); + return a.length === b.length && timingSafeEqual(a, b); +} + +export interface SlackCommand { + readonly command: string; + readonly text: string; + readonly userId: string; +} + +/** Parse Slack's application/x-www-form-urlencoded slash-command body. */ +export function parseSlackCommand(rawBody: string): SlackCommand { + const params = new URLSearchParams(rawBody); + return { + command: params.get("command") ?? "", + text: params.get("text") ?? "", + userId: params.get("user_id") ?? "", + }; +} + +/** + * Self-hosted personal mode maps every request to a single configured user; + * otherwise the Slack user id is used directly as the app user id. + */ +export function resolveUserId( + defaultUserId: string | undefined, + platformUserId: string, +): string { + return defaultUserId ?? platformUserId; +} diff --git a/apps/bot/test/commands.test.ts b/apps/bot/test/commands.test.ts new file mode 100644 index 0000000..d125d17 --- /dev/null +++ b/apps/bot/test/commands.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + LoyaltyAccountReadModel, + ValueAdviceReadModel, +} from "@pointup/core"; + +import { handleCommand, type BotUseCases } from "../src/commands"; + +function account( + over: Partial & { + displayName: string; + kind?: LoyaltyAccountReadModel["provider"]["kind"]; + points?: number | null; + valueCents?: number; + daysUntilExpiry?: number | null; + }, +): LoyaltyAccountReadModel { + const { displayName, kind, points, valueCents, daysUntilExpiry, ...rest } = over; + return { + id: displayName.toLowerCase(), + provider: { + id: displayName.toLowerCase(), + kind: kind ?? "airline", + displayName, + pointsCurrency: "points", + estimatedCentsPerPoint: 1.3, + inactivityExpiryMonths: 18, + }, + membershipNumber: "X", + hasStoredCredential: false, + latestBalance: + points == null + ? null + : { points, source: "manual", capturedAt: new Date("2026-01-01") }, + estimatedValueCents: valueCents ?? 0, + trend: {} as LoyaltyAccountReadModel["trend"], + expiresAt: null, + daysUntilExpiry: daysUntilExpiry ?? null, + notes: null, + tags: [], + pinnedAt: null, + createdAt: new Date("2026-01-01"), + ...rest, + }; +} + +function useCases(over: Partial = {}): BotUseCases { + return { + listAccounts: { execute: vi.fn(async () => []) }, + getValueAdvice: { + execute: vi.fn( + async (): Promise => ({ transfers: [], deals: [] }), + ), + }, + chatWithAssistant: { execute: vi.fn(async () => ({ reply: "assistant reply" })) }, + ...over, + }; +} + +describe("handleCommand", () => { + it("help (and empty input) lists commands", async () => { + expect(await handleCommand({ userId: "u", text: "" }, useCases())).toContain( + "PointBot commands", + ); + expect(await handleCommand({ userId: "u", text: "help" }, useCases())).toContain( + "`ask `", + ); + }); + + it("portfolio summarizes balances", async () => { + const uc = useCases({ + listAccounts: { + execute: vi.fn(async () => [ + account({ displayName: "Hyatt", kind: "hotel", points: 100_000, valueCents: 170_000 }), + account({ displayName: "United", points: 50_000, valueCents: 60_000 }), + ]), + }, + }); + const out = await handleCommand({ userId: "u", text: "portfolio" }, uc); + expect(out).toContain("2 programs"); + expect(out).toContain("Hyatt"); + // Most valuable first. + expect(out.indexOf("Hyatt")).toBeLessThan(out.indexOf("United")); + }); + + it("expiring lists at-risk programs soonest first", async () => { + const uc = useCases({ + listAccounts: { + execute: vi.fn(async () => [ + account({ displayName: "Delta", points: 1000, daysUntilExpiry: 40 }), + account({ displayName: "Marriott", kind: "hotel", points: 1000, daysUntilExpiry: 5 }), + account({ displayName: "Amex", kind: "credit_card", points: 1000, daysUntilExpiry: null }), + ]), + }, + }); + const out = await handleCommand({ userId: "u", text: "expiring" }, uc); + expect(out).toContain("Marriott"); + expect(out.indexOf("Marriott")).toBeLessThan(out.indexOf("Delta")); + expect(out).not.toContain("Amex"); // no expiry -> not at risk + }); + + it("value shows advice and the transfer disclaimer", async () => { + const uc = useCases({ + getValueAdvice: { + execute: vi.fn( + async (): Promise => ({ + transfers: [ + { + from: { displayName: "Chase UR" }, + to: { displayName: "Hyatt" }, + sourcePoints: 100_000, + destinationPoints: 100_000, + effectiveCentsPerPoint: 2.1, + bonusLabel: null, + }, + ] as ValueAdviceReadModel["transfers"], + deals: [], + }), + ), + }, + }); + const out = await handleCommand({ userId: "u", text: "value" }, uc); + expect(out).toContain("Chase UR"); + expect(out).toContain("irreversible"); + }); + + it("ask routes to the assistant with the question", async () => { + const execute = vi.fn(async () => ({ reply: "Transfer to Hyatt." })); + const uc = useCases({ chatWithAssistant: { execute } }); + const out = await handleCommand( + { userId: "u", text: "ask should I move UR to Hyatt?" }, + uc, + ); + expect(out).toBe("Transfer to Hyatt."); + expect(execute).toHaveBeenCalledWith({ + userId: "u", + message: "should I move UR to Hyatt?", + }); + }); + + it("unrecognized text is treated as a free-form assistant question", async () => { + const execute = vi.fn(async () => ({ reply: "answer" })); + const uc = useCases({ chatWithAssistant: { execute } }); + await handleCommand({ userId: "u", text: "what's my best redemption" }, uc); + expect(execute).toHaveBeenCalledWith({ + userId: "u", + message: "what's my best redemption", + }); + }); +}); diff --git a/apps/bot/test/slack.test.ts b/apps/bot/test/slack.test.ts new file mode 100644 index 0000000..c864c59 --- /dev/null +++ b/apps/bot/test/slack.test.ts @@ -0,0 +1,90 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; + +import { + parseSlackCommand, + resolveUserId, + verifySlackSignature, +} from "../src/slack"; + +const SECRET = "shhh"; + +function sign(rawBody: string, timestamp: string): string { + return ( + "v0=" + + createHmac("sha256", SECRET).update(`v0:${timestamp}:${rawBody}`).digest("hex") + ); +} + +describe("verifySlackSignature", () => { + const rawBody = "command=%2Fpointbot&text=portfolio&user_id=U123"; + const nowMs = 1_760_000_000_000; + const timestamp = String(Math.floor(nowMs / 1000)); + + it("accepts a correctly signed, fresh request", () => { + expect( + verifySlackSignature({ + rawBody, + signature: sign(rawBody, timestamp), + timestamp, + signingSecret: SECRET, + nowMs, + }), + ).toBe(true); + }); + + it("rejects a bad signature", () => { + expect( + verifySlackSignature({ + rawBody, + signature: "v0=deadbeef", + timestamp, + signingSecret: SECRET, + nowMs, + }), + ).toBe(false); + }); + + it("rejects a stale timestamp (replay)", () => { + const staleTs = String(Math.floor(nowMs / 1000) - 600); + expect( + verifySlackSignature({ + rawBody, + signature: sign(rawBody, staleTs), + timestamp: staleTs, + signingSecret: SECRET, + nowMs, + }), + ).toBe(false); + }); + + it("rejects missing signature/timestamp", () => { + expect( + verifySlackSignature({ + rawBody, + signature: undefined, + timestamp, + signingSecret: SECRET, + nowMs, + }), + ).toBe(false); + }); +}); + +describe("parseSlackCommand / resolveUserId", () => { + it("parses the urlencoded slash-command body", () => { + const cmd = parseSlackCommand( + "command=%2Fpointbot&text=ask+best+transfer&user_id=U42", + ); + expect(cmd).toEqual({ + command: "/pointbot", + text: "ask best transfer", + userId: "U42", + }); + }); + + it("prefers the configured default user id (self-hosted mode)", () => { + expect(resolveUserId("owner", "U42")).toBe("owner"); + expect(resolveUserId(undefined, "U42")).toBe("U42"); + }); +}); diff --git a/apps/bot/tsconfig.json b/apps/bot/tsconfig.json new file mode 100644 index 0000000..2cbda19 --- /dev/null +++ b/apps/bot/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/apps/worker/src/digest-chat.ts b/apps/worker/src/digest-chat.ts new file mode 100644 index 0000000..4b117f8 --- /dev/null +++ b/apps/worker/src/digest-chat.ts @@ -0,0 +1,48 @@ +import type { OutboundNotification, PortfolioDigestReadModel } from "@pointup/core"; + +const num = new Intl.NumberFormat("en-US"); +const usd = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, +}); + +/** + * Renders the periodic portfolio digest as a compact chat notification + * (Slack/Discord). Kept parallel to `renderDigestEmail` — same data, a shorter + * shape suited to a chat message. + */ +export function renderDigestChat( + digest: PortfolioDigestReadModel, +): OutboundNotification { + const { summary, accounts, expiring } = digest; + const total = usd.format(summary.totalValueCents / 100); + + const lines = [ + `*PointBot weekly digest* — ${num.format(summary.totalPoints)} points across ${summary.accountCount} program${summary.accountCount === 1 ? "" : "s"}, ~${total}.`, + ]; + + for (const account of accounts.slice(0, 6)) { + const points = account.latestBalance + ? num.format(account.latestBalance.points) + : "—"; + lines.push( + `• ${account.provider.displayName}: ${points} ${account.provider.pointsCurrency} (~${usd.format(account.estimatedValueCents / 100)})`, + ); + } + + if (expiring.length > 0) { + const soonest = expiring + .map((a) => a.provider.displayName) + .slice(0, 3) + .join(", "); + lines.push( + `⚠️ ${expiring.length} program${expiring.length === 1 ? "" : "s"} expiring soon: ${soonest}.`, + ); + } + + const markdown = lines.join("\n"); + // Plain-text fallback strips Slack emphasis markers. + const text = markdown.replace(/\*/g, ""); + return { text, markdown }; +} diff --git a/apps/worker/src/env.ts b/apps/worker/src/env.ts index 59d057b..b39c812 100644 --- a/apps/worker/src/env.ts +++ b/apps/worker/src/env.ts @@ -46,6 +46,10 @@ const envSchema = z.object({ /** Optional server-side credential vault (1Password Connect). */ OP_CONNECT_HOST: z.url().optional(), OP_CONNECT_TOKEN: z.string().min(1).optional(), + + /** Optional chat digest delivery — Slack / Discord incoming webhooks. */ + SLACK_WEBHOOK_URL: z.url().optional(), + DISCORD_WEBHOOK_URL: z.url().optional(), }); export type WorkerEnv = z.infer; diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index aa27260..5108b8d 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -4,6 +4,7 @@ import { runMigrations } from "./jobs/migrate"; import { sendDigests } from "./jobs/send-digests"; import { syncAllUsers } from "./jobs/sync-all-users"; import { createMailer } from "./mailers"; +import { createNotifier } from "./notifiers"; import { createUserDirectory } from "./user-directory"; const JOBS = ["sync", "digest", "migrate"] as const; @@ -28,7 +29,12 @@ async function main(): Promise { if (job === "sync") { await syncAllUsers(container); } else { - await sendDigests(container, createUserDirectory(env), createMailer(env)); + await sendDigests( + container, + createUserDirectory(env), + createMailer(env), + createNotifier(env), + ); } } diff --git a/apps/worker/src/jobs/send-digests.ts b/apps/worker/src/jobs/send-digests.ts index 3e4838f..14617ee 100644 --- a/apps/worker/src/jobs/send-digests.ts +++ b/apps/worker/src/jobs/send-digests.ts @@ -1,23 +1,47 @@ -import type { Mailer, UserDirectory } from "@pointup/core"; +import type { Mailer, Notifier, UserDirectory } from "@pointup/core"; import type { WorkerContainer } from "../container"; +import { renderDigestChat } from "../digest-chat"; import { renderDigestEmail } from "../digest-email"; /** - * Scheduled job: email each user a summary of their portfolio. Users without - * a resolvable email or without linked accounts are skipped. + * Scheduled job: email each user a summary of their portfolio, and optionally + * post it to configured chat channels (Slack/Discord). Users without a + * resolvable email or without linked accounts are skipped for email; chat + * delivery still fires for any user with accounts. */ export async function sendDigests( container: WorkerContainer, directory: UserDirectory, mailer: Mailer, + notifier: Notifier | null = null, ): Promise { const userIds = await container.accounts.listUserIds(); console.info(`[digest] starting for ${userIds.length} user(s)`); let sent = 0; let skipped = 0; + let notified = 0; for (const userId of userIds) { + const digest = + await container.useCases.buildPortfolioDigest.execute(userId); + if (digest.accounts.length === 0) { + skipped += 1; + continue; + } + + // Chat delivery (Slack/Discord) does not depend on an email address. + if (notifier) { + await notifier + .notify(renderDigestChat(digest)) + .then(() => { + notified += 1; + }) + .catch((error: unknown) => { + console.warn(`[digest] user=${userId} chat notify failed`, error); + }); + } + const email = await directory.getEmail(userId).catch((error: unknown) => { console.warn(`[digest] user=${userId} directory lookup failed`, error); return null; @@ -27,16 +51,11 @@ export async function sendDigests( continue; } - const digest = - await container.useCases.buildPortfolioDigest.execute(userId); - if (digest.accounts.length === 0) { - skipped += 1; - continue; - } - await mailer.send(renderDigestEmail(digest, email)); sent += 1; } - console.info(`[digest] done: ${sent} sent, ${skipped} skipped`); + console.info( + `[digest] done: ${sent} emailed, ${notified} chat-notified, ${skipped} skipped`, + ); } diff --git a/apps/worker/src/notifiers.ts b/apps/worker/src/notifiers.ts new file mode 100644 index 0000000..d6e4b2f --- /dev/null +++ b/apps/worker/src/notifiers.ts @@ -0,0 +1,23 @@ +import { + CompositeNotifier, + DiscordWebhookNotifier, + SlackWebhookNotifier, + type Notifier, +} from "@pointup/core"; + +import type { WorkerEnv } from "./env"; + +/** + * Builds a fan-out notifier from whatever chat webhooks are configured, or + * returns null when none are — in which case digests are email-only. + */ +export function createNotifier(env: WorkerEnv): Notifier | null { + const notifiers: Notifier[] = []; + if (env.SLACK_WEBHOOK_URL) { + notifiers.push(new SlackWebhookNotifier(env.SLACK_WEBHOOK_URL)); + } + if (env.DISCORD_WEBHOOK_URL) { + notifiers.push(new DiscordWebhookNotifier(env.DISCORD_WEBHOOK_URL)); + } + return notifiers.length > 0 ? new CompositeNotifier(notifiers) : null; +} diff --git a/docker-compose.yml b/docker-compose.yml index 5747a17..5ba82ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,6 +71,30 @@ services: # Without a Clerk key, digests go to this address via Mailpit. DIGEST_RECIPIENT_OVERRIDE: dev@pointup.local CLERK_SECRET_KEY: ${CLERK_SECRET_KEY:-} + # Optional: also post the digest to chat. + SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:-} + DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL:-} + + # PointBot chat surface (Slack slash commands): http://localhost:8080 + # docker compose --profile bot up bot + bot: + build: + context: . + dockerfile: Dockerfile.bot + profiles: ["bot"] + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql://postgres:password@db:5432/app + SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET:-} + BOT_DEFAULT_USER_ID: ${BOT_DEFAULT_USER_ID:-} + # Assistant provider (same vars as the web app); omit for heuristic replies. + LLM_PROVIDER: ${LLM_PROVIDER:-} + LLM_API_KEY: ${LLM_API_KEY:-} + BEDROCK_MODEL_ID: ${BEDROCK_MODEL_ID:-} + ports: + - "8080:8080" volumes: db-data: diff --git a/docs/bot.md b/docs/bot.md new file mode 100644 index 0000000..237716a --- /dev/null +++ b/docs/bot.md @@ -0,0 +1,105 @@ +# PointBot chat surface + +`point_bot` began as a bot, and this is its chat surface reborn on the modern +core. Two independent capabilities: + +1. **Inbound commands** (`apps/bot`) — a Slack slash-command server that answers + portfolio questions by calling `@pointup/core` use cases. +2. **Outbound digests** — the background worker (`apps/worker`) can post the + weekly portfolio digest to Slack/Discord via the `Notifier` port, in + addition to email. + +Both consume the same domain core as the web app — no duplicated business +logic (hexagonal boundary: chat adapter → use cases → ports). + +## Commands + +Wire a Slack slash command (e.g. `/pointbot`) to the bot; the text after it +selects the action: + +| Command | What it returns | +| --- | --- | +| `portfolio` (`balances`, `points`) | Totals + most valuable programs | +| `expiring` | Programs at risk of expiring, soonest first | +| `value` (`deals`, `transfers`) | Best transfer moves + affordable deals | +| `ask ` | Grounded answer from the assistant (`ChatWithAssistant`) | +| `help` | Command list | + +Any unrecognized text is treated as a free-form assistant question. + +The assistant uses the same provider selection as the web app +(`LLM_PROVIDER=bedrock` + `BEDROCK_MODEL_ID` → OpenAI-compatible via +`LLM_API_KEY` → heuristic fallback). See [docs/migration-from-pointup.md](./migration-from-pointup.md). + +## Architecture + +``` +Slack slash command ──HTTP──▶ apps/bot + │ verify signature (HMAC), parse, resolve user + ▼ + handleCommand() ── pure router (unit-tested) + │ + ▼ + @pointup/core use cases (ListLoyaltyAccounts, + GetValueAdvice, ChatWithAssistant) → ports → Drizzle/Postgres +``` + +- `apps/bot/src/format.ts` and `commands.ts` are pure and unit-tested (no DB, + no LLM) — the transport is a thin shell. +- `apps/bot/src/slack.ts` verifies Slack's request signature (`v0=` HMAC over + `v0:timestamp:body`, 5-minute replay window) and parses the slash-command + body — also unit-tested. +- Slow paths (a real LLM call) reply asynchronously via Slack's `response_url` + so the initial request acks within Slack's 3-second window. + +### User mapping + +Slack identities map to app user ids one of two ways: + +- **Self-hosted / personal** — set `BOT_DEFAULT_USER_ID` and every command runs + as that single app user (the original PointBot model: a few named users). +- **Multi-user** — leave it unset and the Slack `user_id` is used as the app + user id. + +## Local development + +```bash +# Assistant works on the heuristic fallback with no keys. +docker compose up -d db +npm run db:migrate +SLACK_SIGNING_SECRET=... BOT_DEFAULT_USER_ID=demo npm run dev --workspace @pointup/bot +# or: docker compose --profile bot up bot (reads SLACK_SIGNING_SECRET, etc. from env) +``` + +`GET /health` returns `{"status":"ok"}`; Slack posts to `POST /slack/commands`. +For local Slack testing, expose the port with a tunnel (e.g. `cloudflared`, +`ngrok`) and set the slash-command Request URL to `/slack/commands`. + +## Outbound digests to chat + +Set either/both incoming-webhook URLs and the worker's `digest` job posts a +compact digest to them alongside email: + +```bash +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... \ +DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... \ + npm run dev --workspace @pointup/worker # then: digest +``` + +In AWS, pass the webhook URLs to the digest task via CDK context: + +```bash +npx cdk deploy -c slackWebhookUrl=https://hooks.slack.com/services/... \ + -c discordWebhookUrl=https://discord.com/api/webhooks/... +``` + +## Deploying the bot + +`Dockerfile.bot` builds a self-contained bundle (`node index.cjs`, port 8080, +`/health` for load-balancer checks). It is **not** yet provisioned as a Fargate +service in `infra/` — deploying it as a public HTTPS service (ALB + ACM cert so +Slack can reach it, with `SLACK_SIGNING_SECRET` from Secrets Manager) is the +natural follow-up. The application, image, and local compose service are ready. + +Discord **inbound** interactions (ed25519-verified) are a future addition; +Discord is currently supported as an **outbound** digest channel. diff --git a/infra/lib/app-stack.ts b/infra/lib/app-stack.ts index b75d506..ca4db82 100644 --- a/infra/lib/app-stack.ts +++ b/infra/lib/app-stack.ts @@ -98,6 +98,56 @@ export class AppStack extends cdk.Stack { ); } + // ─── Optional assistant / scraping config ────────────────────────────── + // Non-secret config comes from CDK context; API keys become Secrets + // Manager placeholders (set the real value after deploy), created only when + // opted in so we never provision empty secrets: + // -c enableOpenAiLlm=true -c llmModel=gpt-4o-mini -c llmBaseUrl=... + // -c enableFirecrawl=true -c firecrawlBaseUrl=https://api.firecrawl.dev + const ctx = (key: string): string | undefined => + (this.node.tryGetContext(key) as string | undefined) || undefined; + const placeholderSecret = (id: string, description: string) => + new secretsmanager.Secret(this, id, { + description, + generateSecretString: { passwordLength: 40, excludePunctuation: true }, + }); + + const openAiLlmSecret = this.node.tryGetContext("enableOpenAiLlm") + ? placeholderSecret( + "OpenAiLlmApiKey", + "OpenAI-compatible LLM API key (set the real value after deploy)", + ) + : undefined; + const firecrawlSecret = this.node.tryGetContext("enableFirecrawl") + ? placeholderSecret( + "FirecrawlApiKey", + "Firecrawl API key (set the real fc-... value after deploy)", + ) + : undefined; + + // Bedrock wins when configured; otherwise fall back to the OpenAI provider + // when its secret is present. Non-secret knobs are plain env. + const assistantEnvironment: Record = { + ...(bedrockModelId + ? { LLM_PROVIDER: "bedrock", BEDROCK_MODEL_ID: bedrockModelId } + : openAiLlmSecret + ? { LLM_PROVIDER: "openai" } + : {}), + ...(ctx("llmModel") ? { LLM_MODEL: ctx("llmModel")! } : {}), + ...(ctx("llmBaseUrl") ? { LLM_BASE_URL: ctx("llmBaseUrl")! } : {}), + ...(ctx("firecrawlBaseUrl") + ? { FIRECRAWL_BASE_URL: ctx("firecrawlBaseUrl")! } + : {}), + }; + const assistantSecrets: Record = { + ...(openAiLlmSecret + ? { LLM_API_KEY: ecs.Secret.fromSecretsManager(openAiLlmSecret) } + : {}), + ...(firecrawlSecret + ? { FIRECRAWL_API_KEY: ecs.Secret.fromSecretsManager(firecrawlSecret) } + : {}), + }; + const image = new ecrAssets.DockerImageAsset(this, "AppImage", { directory: path.join(__dirname, "..", ".."), platform: ecrAssets.Platform.LINUX_AMD64, @@ -133,10 +183,8 @@ export class AppStack extends cdk.Stack { environment: { NODE_ENV: "production", // src/env.ts composes DATABASE_URL from the DB_* variables below. - // Enable the Bedrock-backed assistant when a model id is supplied. - ...(bedrockModelId - ? { LLM_PROVIDER: "bedrock", BEDROCK_MODEL_ID: bedrockModelId } - : {}), + // Assistant (Bedrock/OpenAI) + Firecrawl config, when configured. + ...assistantEnvironment, }, secrets: { DB_HOST: ecs.Secret.fromSecretsManager(dbSecret, "host"), @@ -145,6 +193,7 @@ export class AppStack extends cdk.Stack { DB_PASSWORD: ecs.Secret.fromSecretsManager(dbSecret, "password"), DB_NAME: ecs.Secret.fromSecretsManager(dbSecret, "dbname"), CLERK_SECRET_KEY: ecs.Secret.fromSecretsManager(clerkSecret), + ...assistantSecrets, }, logDriver: ecs.LogDrivers.awsLogs({ streamPrefix: "app", @@ -259,6 +308,16 @@ export class AppStack extends cdk.Stack { NODE_ENV: "production", MAILER: digestFromEmail ? "ses" : "console", ...(digestFromEmail ? { DIGEST_FROM_EMAIL: digestFromEmail } : {}), + // Optional chat digests. Webhook URLs carry a token — pass via + // context, or move to Secrets Manager for stricter setups: + // -c slackWebhookUrl=https://hooks.slack.com/services/... + // -c discordWebhookUrl=https://discord.com/api/webhooks/... + ...(ctx("slackWebhookUrl") + ? { SLACK_WEBHOOK_URL: ctx("slackWebhookUrl")! } + : {}), + ...(ctx("discordWebhookUrl") + ? { DISCORD_WEBHOOK_URL: ctx("discordWebhookUrl")! } + : {}), }, secrets: workerSecrets, logDriver: ecs.LogDrivers.awsLogs({ @@ -348,6 +407,18 @@ export class AppStack extends cdk.Stack { value: clerkSecret.secretArn, description: "Set the real Clerk secret key (sk_...) in this secret", }); + if (openAiLlmSecret) { + new cdk.CfnOutput(this, "OpenAiLlmSecretArn", { + value: openAiLlmSecret.secretArn, + description: "Set the real OpenAI-compatible LLM API key in this secret", + }); + } + if (firecrawlSecret) { + new cdk.CfnOutput(this, "FirecrawlSecretArn", { + value: firecrawlSecret.secretArn, + description: "Set the real Firecrawl API key (fc-...) in this secret", + }); + } // Consumed by .github/workflows/deploy.yml to run migrations post-deploy. new cdk.CfnOutput(this, "ClusterArn", { value: cluster.clusterArn }); diff --git a/package-lock.json b/package-lock.json index 9f66ae4..d032acb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,21 @@ "node": ">=20" } }, + "apps/bot": { + "name": "@pointup/bot", + "version": "1.0.0", + "dependencies": { + "@pointup/core": "*", + "zod": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "esbuild": "^0.25.0", + "tsx": "^4.19.0", + "typescript": "^5.9.0", + "vitest": "^3.0.0" + } + }, "apps/web": { "name": "@pointup/web", "version": "1.0.0", @@ -2268,6 +2283,10 @@ "resolved": "packages/api-client", "link": true }, + "node_modules/@pointup/bot": { + "resolved": "apps/bot", + "link": true + }, "node_modules/@pointup/core": { "resolved": "packages/core", "link": true diff --git a/packages/core/src/application/ports.ts b/packages/core/src/application/ports.ts index 6db3c9f..54876aa 100644 --- a/packages/core/src/application/ports.ts +++ b/packages/core/src/application/ports.ts @@ -82,6 +82,24 @@ export interface UserDirectory { getEmail(userId: string): Promise; } +/** A short chat/notification message ready to deliver to a channel. */ +export interface OutboundNotification { + readonly text: string; + /** + * Optional richer body for surfaces that render markdown (Slack mrkdwn, + * Discord). Adapters that only support plain text fall back to `text`. + */ + readonly markdown?: string; +} + +/** + * Chat / notification delivery port (Slack, Discord, console). Sits alongside + * `Mailer` so digests and alerts can fan out to chat surfaces, not just email. + */ +export interface Notifier { + notify(notification: OutboundNotification): Promise; +} + // ─── AI assistant ────────────────────────────────────────────────────────── export type AssistantRole = "system" | "user" | "assistant"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2684e27..2ceafd3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -53,4 +53,5 @@ export * from "./infrastructure/vault/one-password-connect-vault"; export * from "./infrastructure/vault/null-credential-vault"; export * from "./infrastructure/llm/openai-compatible-assistant"; export * from "./infrastructure/llm/bedrock-assistant"; +export * from "./infrastructure/notify/webhook-notifiers"; export * from "./infrastructure/scraper/firecrawl-page-scraper"; diff --git a/packages/core/src/infrastructure/notify/webhook-notifiers.ts b/packages/core/src/infrastructure/notify/webhook-notifiers.ts new file mode 100644 index 0000000..8a0e1ce --- /dev/null +++ b/packages/core/src/infrastructure/notify/webhook-notifiers.ts @@ -0,0 +1,96 @@ +import type { Notifier, OutboundNotification } from "../../application/ports"; + +/** Minimal fetch signature so adapters can be unit-tested without a network. */ +export type FetchLike = ( + url: string, + init: { + method: string; + headers: Record; + body: string; + signal?: AbortSignal; + }, +) => Promise<{ ok: boolean; status: number; text(): Promise }>; + +const defaultFetch: FetchLike = (url, init) => fetch(url, init as RequestInit); + +async function postJson( + fetchImpl: FetchLike, + url: string, + payload: unknown, + channel: string, +): Promise { + const response = await fetchImpl(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `${channel} webhook failed (${response.status}): ${body.slice(0, 200)}`, + ); + } +} + +/** + * Posts to a Slack Incoming Webhook. Uses Slack `mrkdwn` when a markdown body + * is supplied, otherwise the plain text. + */ +export class SlackWebhookNotifier implements Notifier { + constructor( + private readonly webhookUrl: string, + private readonly fetchImpl: FetchLike = defaultFetch, + ) {} + + async notify(notification: OutboundNotification): Promise { + await postJson( + this.fetchImpl, + this.webhookUrl, + { text: notification.markdown ?? notification.text, mrkdwn: true }, + "Slack", + ); + } +} + +/** + * Posts to a Discord webhook. Discord renders standard markdown in `content` + * and caps messages at 2000 characters. + */ +export class DiscordWebhookNotifier implements Notifier { + constructor( + private readonly webhookUrl: string, + private readonly fetchImpl: FetchLike = defaultFetch, + ) {} + + async notify(notification: OutboundNotification): Promise { + const content = (notification.markdown ?? notification.text).slice(0, 2000); + await postJson(this.fetchImpl, this.webhookUrl, { content }, "Discord"); + } +} + +/** Logs instead of sending; useful for local development and tests. */ +export class ConsoleNotifier implements Notifier { + async notify(notification: OutboundNotification): Promise { + console.info(`[console-notifier]\n${notification.text}`); + } +} + +/** + * Fans a notification out to several channels, isolating failures: one + * channel erroring does not prevent delivery to the others (the first error + * is rethrown after all have been attempted). + */ +export class CompositeNotifier implements Notifier { + constructor(private readonly notifiers: readonly Notifier[]) {} + + async notify(notification: OutboundNotification): Promise { + const results = await Promise.allSettled( + this.notifiers.map((n) => n.notify(notification)), + ); + const firstError = results.find( + (r): r is PromiseRejectedResult => r.status === "rejected", + ); + if (firstError) throw firstError.reason; + } +} diff --git a/packages/core/test/notifier.test.ts b/packages/core/test/notifier.test.ts new file mode 100644 index 0000000..2860518 --- /dev/null +++ b/packages/core/test/notifier.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + CompositeNotifier, + ConsoleNotifier, + DiscordWebhookNotifier, + SlackWebhookNotifier, + type FetchLike, +} from "../src/infrastructure/notify/webhook-notifiers"; +import type { Notifier } from "../src/application/ports"; + +function okFetch(): { fetchImpl: FetchLike; calls: Array<{ url: string; body: unknown }> } { + const calls: Array<{ url: string; body: unknown }> = []; + const fetchImpl: FetchLike = async (url, init) => { + calls.push({ url, body: JSON.parse(init.body) }); + return { ok: true, status: 200, text: async () => "" }; + }; + return { fetchImpl, calls }; +} + +describe("webhook notifiers", () => { + it("Slack posts mrkdwn with the markdown body when present", async () => { + const { fetchImpl, calls } = okFetch(); + await new SlackWebhookNotifier("https://hooks.slack/x", fetchImpl).notify({ + text: "plain", + markdown: "*rich*", + }); + expect(calls[0]?.url).toBe("https://hooks.slack/x"); + expect(calls[0]?.body).toEqual({ text: "*rich*", mrkdwn: true }); + }); + + it("Slack falls back to plain text when no markdown", async () => { + const { fetchImpl, calls } = okFetch(); + await new SlackWebhookNotifier("https://h", fetchImpl).notify({ text: "hello" }); + expect(calls[0]?.body).toEqual({ text: "hello", mrkdwn: true }); + }); + + it("Discord posts content and truncates to 2000 chars", async () => { + const { fetchImpl, calls } = okFetch(); + const long = "x".repeat(2500); + await new DiscordWebhookNotifier("https://d", fetchImpl).notify({ text: long }); + const body = calls[0]?.body as { content: string }; + expect(body.content).toHaveLength(2000); + }); + + it("throws on a non-2xx webhook response", async () => { + const failing: FetchLike = async () => ({ + ok: false, + status: 500, + text: async () => "boom", + }); + await expect( + new SlackWebhookNotifier("https://h", failing).notify({ text: "x" }), + ).rejects.toThrow(/Slack webhook failed \(500\)/); + }); + + it("Composite fans out to every notifier and rethrows the first failure", async () => { + const good1 = { notify: vi.fn(async () => undefined) } satisfies Notifier; + const bad = { + notify: vi.fn(async () => { + throw new Error("down"); + }), + } satisfies Notifier; + const good2 = { notify: vi.fn(async () => undefined) } satisfies Notifier; + + await expect( + new CompositeNotifier([good1, bad, good2]).notify({ text: "hi" }), + ).rejects.toThrow("down"); + // Every channel was still attempted despite the middle failure. + expect(good1.notify).toHaveBeenCalledOnce(); + expect(good2.notify).toHaveBeenCalledOnce(); + }); + + it("ConsoleNotifier does not throw", async () => { + await expect(new ConsoleNotifier().notify({ text: "hi" })).resolves.toBeUndefined(); + }); +});