Slack bot for expense report approvals via Stampli
Find a file
2026-05-08 16:56:14 -04:00
.github Add permissions block for OIDC token exchange 2026-05-08 16:56:14 -04:00
src Add CI workflow and apply ruff formatting (#6) 2026-05-08 15:48:06 -04:00
.gitignore Initial commit: expense approval bot SAM stack 2026-04-20 17:56:00 -04:00
README.md Update README for delete-on-advance behavior and SUBMITTED_CHANNEL constant 2026-04-20 19:07:30 -04:00
samconfig.toml.example Move Slack signing secret to Secrets Manager 2026-04-20 18:01:48 -04:00
template.yaml Move Slack signing secret to Secrets Manager 2026-04-20 18:01:48 -04:00

Expense Approval Bot

Slack reaction-driven expense approval router. Replaces the Pipedream expenses_pipeline workflow.

Flow

A user reacts ✅ to a message in an expense channel. The bot:

  1. Maps the source channel to the next stage's channel.
  2. Copies the message text (stripped of any prior "react to advance" hint) into the next channel. When advancing out of Submitted, the copy also gets a permalink back to the user's original message.
  3. Depending on where we're advancing from:
    • From Submitted: keeps the user's original message in place and posts a threaded ➡️ Advanced to {Stage} reply. The Submitted channel is the permanent audit trail.
    • From Processed or Authorized: deletes the old copy so each intermediate channel stays a clean "current work" queue.

Channel chain: Submitted → Processed → Authorized → Matched. The bot can only delete its own posts, which is why the Submitted original (a real user message) is preserved by design.

Architecture

  • Receiver Lambda (src/receiver/) — fronted by API Gateway HTTP API. Verifies the Slack request signature, handles the URL-verification handshake, and async-invokes the processor. Returns 200 within Slack's 3-second window to prevent retries.
  • Processor Lambda (src/processor/) — does the 5-step sequence of Slack API calls. No inbound HTTP; invoked only by the receiver.
  • Secrets — Slack bot token and signing secret each stored as separate AWS Secrets Manager secrets. Each Lambda only has IAM access to the secret it needs.

Runtime: Python 3.12 on arm64, no third-party dependencies (stdlib urllib for HTTP, boto3 from the Lambda runtime).

Setup

  1. Store the two Slack credentials in Secrets Manager:

    aws secretsmanager create-secret \
      --name expense-bot-slack-token \
      --secret-string "xoxb-..."
    
    aws secretsmanager create-secret \
      --name expense-bot-slack-signing-secret \
      --secret-string "..."
    
  2. Copy the sample SAM config and fill in real values:

    cp samconfig.toml.example samconfig.toml
    
    • SlackBotTokenSecretArn — ARN of the bot-token secret
    • SlackSigningSecretArn — ARN of the signing-secret secret
  3. Build and deploy:

    sam build && sam deploy
    
  4. After the first deploy, take the SlackEventsUrl output and paste it into the Slack app's Event Subscriptions → Request URL. Subscribe the bot to the reaction_added event.

Configuration

Channel IDs live in src/processor/app.py:

  • SUBMITTED_CHANNEL — the user-authored origin channel. Controls both the "include a permalink" behavior and the "preserve vs delete source" branch.
  • STAGES — the full chain mapping each source channel to its next channel and human-readable label.

If the Submitted channel changes, update SUBMITTED_CHANNEL and the first key of STAGES. For any other stage change, update STAGES only.

Manual testing

Invoke the processor directly with a synthetic Slack reaction event:

aws lambda invoke \
  --function-name expense-approval-processor \
  --payload '{"reaction":"white_check_mark","item":{"channel":"C0AQ2AWLNEN","ts":"1700000000.000000"}}' \
  /dev/stdout