point_bot began as a bot, and this is its chat surface reborn on the modern
core. Two independent capabilities:
- Inbound commands (
apps/bot) — a Slack slash-command server that answers portfolio questions by calling@pointup/coreuse cases. - Outbound digests — the background worker (
apps/worker) can post the weekly portfolio digest to Slack/Discord via theNotifierport, 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).
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 <question> |
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.
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.tsandcommands.tsare pure and unit-tested (no DB, no LLM) — the transport is a thin shell.apps/bot/src/slack.tsverifies Slack's request signature (v0=HMAC overv0: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_urlso the initial request acks within Slack's 3-second window.
Slack identities map to app user ids one of two ways:
- Self-hosted / personal — set
BOT_DEFAULT_USER_IDand 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_idis used as the app user id.
# 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 <tunnel>/slack/commands.
Set either/both incoming-webhook URLs and the worker's digest job posts a
compact digest to them alongside email:
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... \
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/... \
npm run dev --workspace @pointup/worker # then: digestIn AWS, pass the webhook URLs to the digest task via CDK context:
npx cdk deploy -c slackWebhookUrl=https://hooks.slack.com/services/... \
-c discordWebhookUrl=https://discord.com/api/webhooks/...Separate from the weekly digest, the worker's alerts job pushes urgent,
actionable items — and only when there's something to say (nothing fires for a
calm portfolio). Alerts are derived statelessly by deriveAlerts in the core
from the same digest read model:
- Expiring / expired points (within the warning window)
- Balance drops / jumps past a threshold (from each account's
trend) - Goal reached — enough points to book a trip goal
Delivery reuses the Notifier (Slack/Discord) and Mailer ports. Run it daily:
npm run dev --workspace @pointup/worker # then: alerts
# or: docker compose run --rm worker alertsThresholds are tunable via ALERT_EXPIRY_WARNING_DAYS and
ALERT_BIG_CHANGE_PERCENT. In AWS the AlertsTask runs daily at 12:00 UTC.
Related: the daily watch job re-scrapes award watchlist pages
(/api/v1/watches) and notifies when a watched page's best realized ¢/pt
improves past your threshold (WatchTask, 11:00 UTC).
Dockerfile.bot builds a self-contained bundle (node index.cjs, port 8080,
/health for load-balancer checks).
The CDK stack can provision it as a public Fargate service behind an ALB — opt
in with -c enableBot=true. Slack and Discord require HTTPS, so supply an
ACM certificate + domain for a production-usable endpoint:
npx cdk deploy -c enableBot=true \
-c botCertificateArn=arn:aws:acm:REGION:ACCT:certificate/... \
-c botDomainName=bot.example.com \
-c botDefaultUserId=user_123 \ # self-hosted single-user mode
-c discordPublicKey=<hex> -c discordAppId=<id>The Slack signing secret is created as a Secrets Manager placeholder
(BotSlackSigningSecretArn output) — set the real value after deploy. The bot
task gets DB access and, when bedrockModelId is configured, Bedrock invoke
permissions. Without a cert/domain the service comes up on plain HTTP (fine for
testing; Slack/Discord won't call it until it's HTTPS). Endpoints:
<url>/slack/commands and <url>/discord/interactions (BotUrl output).
Both directions are supported:
- Inbound —
POST /discord/interactionsverifies the Ed25519 signature (DISCORD_PUBLIC_KEY), answers thePINGhandshake, and routes application commands through the samehandleCommandrouter as Slack. Command text comes from the first string option (register a/pointbot query:<text>command) or the command name (register per-action commands like/portfolio). WithDISCORD_APP_IDset, slow commands are deferred (ack now, edit the reply) to stay within Discord's 3-second window. Point the Interactions Endpoint URL in the Discord Developer Portal at<public-url>/discord/interactions. - Outbound — digests and alerts via a Discord webhook (
DISCORD_WEBHOOK_URL).