Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .env-example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ yarn-error.log*

# cdk
infra/cdk.out/
infra/cdk.context.json
**/dist/
40 changes: 40 additions & 0 deletions Dockerfile.bot
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions apps/bot/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
108 changes: 108 additions & 0 deletions apps/bot/src/commands.ts
Original file line number Diff line number Diff line change
@@ -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<LoyaltyAccountReadModel[]>;
};
getValueAdvice: {
execute(userId: string): Promise<ValueAdviceReadModel>;
};
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 <question>` β€” 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<string> {
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<string> {
const { reply } = await useCases.chatWithAssistant.execute({ userId, message });
return reply;
}
56 changes: 56 additions & 0 deletions apps/bot/src/container.ts
Original file line number Diff line number Diff line change
@@ -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),
),
};
}
47 changes: 47 additions & 0 deletions apps/bot/src/env.ts
Original file line number Diff line number Diff line change
@@ -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<typeof envSchema>;

export function loadEnv(): BotEnv {
return envSchema.parse({ ...process.env, DATABASE_URL: getDatabaseUrl() });
}
Loading
Loading