Initial commit: expense approval bot SAM stack

This commit is contained in:
Adam Moussa 2026-04-20 17:56:00 -04:00
commit bede364f16
8 changed files with 322 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.aws-sam/
__pycache__/
*.pyc
.env
samconfig.toml
*.log

59
README.md Normal file
View file

@ -0,0 +1,59 @@
# 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, with a permalink back to the original on the first advance.
3. Replies in-thread on the original message with `➡️ Advanced to {Stage}`.
Channel chain: Submitted → Processed → Authorized → Matched.
## 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 in AWS Secrets Manager. Signing secret passed as a SAM template parameter (`NoEcho`).
Runtime: Python 3.12 on arm64, no third-party dependencies (stdlib `urllib` for HTTP, `boto3` from the Lambda runtime).
## Setup
1. Store the Slack bot token:
```bash
aws secretsmanager create-secret \
--name expense-bot-slack-token \
--secret-string "xoxb-..."
```
2. Copy the sample SAM config and fill in real values:
```bash
cp samconfig.toml.example samconfig.toml
```
- `SlackBotTokenSecretArn` — ARN from step 1
- `SlackSigningSecret` — Slack app → Basic Information → Signing Secret
3. Build and deploy:
```bash
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
The channel → stage mapping lives in `src/processor/app.py` (`STAGES`). Update there if channels change. The "on first advance, include a permalink" check is keyed on the Processed-stage channel ID — update that too if the first-stage channel changes.
## Manual testing
Invoke the processor directly with a synthetic Slack reaction event:
```bash
aws lambda invoke \
--function-name expense-approval-processor \
--payload '{"reaction":"white_check_mark","item":{"channel":"C0AQ2AWLNEN","ts":"1700000000.000000"}}' \
/dev/stdout
```

16
samconfig.toml.example Normal file
View file

@ -0,0 +1,16 @@
version = 0.1
[default.deploy.parameters]
stack_name = "expense-approval-bot"
resolve_s3 = true
s3_prefix = "expense-approval-bot"
region = "us-east-1"
capabilities = "CAPABILITY_IAM"
confirm_changeset = true
parameter_overrides = [
"SlackBotTokenSecretArn=arn:aws:secretsmanager:us-east-1:ACCOUNT_ID:secret:expense-bot-slack-token-XXXXXX",
"SlackSigningSecret=REPLACE_WITH_SLACK_APP_SIGNING_SECRET",
]
[default.build.parameters]
use_container = false

113
src/processor/app.py Normal file
View file

@ -0,0 +1,113 @@
import json
import os
import re
import urllib.parse
import urllib.request
import boto3
secrets_client = boto3.client("secretsmanager")
_cached_token: str | None = None
SLACK_BOT_TOKEN_SECRET_ARN = os.environ["SLACK_BOT_TOKEN_SECRET_ARN"]
STAGES = {
"C0AQ2AWLNEN": {"next": "C0APLSGABAB", "label": "Processed"},
"C0APLSGABAB": {"next": "C0AQ09CDJH4", "label": "Authorized"},
"C0AQ09CDJH4": {"next": "C0APYUM1JFP", "label": "Matched"},
}
REACT_HINT_RE = re.compile(r"_React_ :white_check_mark: _to advance to \w+_")
def handler(event, context):
if event.get("reaction") != "white_check_mark":
print("Not white_check_mark reaction, skipping")
return
from_channel = event["item"]["channel"]
message_ts = event["item"]["ts"]
route = STAGES.get(from_channel)
if not route:
print(f"Channel {from_channel} not in STAGES, skipping")
return
to_channel = route["next"]
label = route["label"]
token = get_bot_token()
history = slack_get("conversations.history", token, {
"channel": from_channel,
"latest": message_ts,
"limit": 1,
"inclusive": "true",
})
original_text = history["messages"][0]["text"]
permalink_resp = slack_get("chat.getPermalink", token, {
"channel": from_channel,
"message_ts": message_ts,
})
permalink = permalink_resp["permalink"]
text = REACT_HINT_RE.sub("", original_text).strip()
next_stage = STAGES.get(to_channel)
next_label = next_stage["label"] if next_stage else None
react_line = f"\n\n_React_ :white_check_mark: _to advance to {next_label}_" if next_label else ""
permalink_line = (
f"\n\n📎 *Original Submission:* <{permalink}|View Original Message>"
if to_channel == "C0APLSGABAB"
else ""
)
full_text = f"{text}{permalink_line}{react_line}"
slack_post("chat.postMessage", token, {
"channel": to_channel,
"text": full_text,
"mrkdwn": True,
"unfurl_links": False,
"unfurl_media": False,
})
slack_post("chat.postMessage", token, {
"channel": from_channel,
"thread_ts": message_ts,
"text": f"➡️ Advanced to {label}",
})
def get_bot_token() -> str:
global _cached_token
if _cached_token:
return _cached_token
result = secrets_client.get_secret_value(SecretId=SLACK_BOT_TOKEN_SECRET_ARN)
_cached_token = result["SecretString"]
return _cached_token
def slack_get(method: str, token: str, params: dict) -> dict:
url = f"https://slack.com/api/{method}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
if not data.get("ok"):
raise RuntimeError(f"{method} failed: {data.get('error')}")
return data
def slack_post(method: str, token: str, body: dict) -> dict:
req = urllib.request.Request(
f"https://slack.com/api/{method}",
data=json.dumps(body).encode("utf-8"),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json; charset=utf-8",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
if not data.get("ok"):
raise RuntimeError(f"{method} failed: {data.get('error')}")
return data

View file

58
src/receiver/app.py Normal file
View file

@ -0,0 +1,58 @@
import hashlib
import hmac
import json
import os
import time
import boto3
lambda_client = boto3.client("lambda")
SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"]
PROCESSOR_FUNCTION_NAME = os.environ["PROCESSOR_FUNCTION_NAME"]
def handler(event, context):
body = event.get("body") or ""
if event.get("isBase64Encoded"):
import base64
body = base64.b64decode(body).decode("utf-8")
headers = {k.lower(): v for k, v in (event.get("headers") or {}).items()}
timestamp = headers.get("x-slack-request-timestamp", "")
signature = headers.get("x-slack-signature", "")
if not verify_signature(body, timestamp, signature):
return {"statusCode": 401, "body": "unauthorized"}
payload = json.loads(body)
if payload.get("type") == "url_verification":
return {
"statusCode": 200,
"headers": {"Content-Type": "text/plain"},
"body": payload.get("challenge", ""),
}
if payload.get("type") == "event_callback":
lambda_client.invoke(
FunctionName=PROCESSOR_FUNCTION_NAME,
InvocationType="Event",
Payload=json.dumps(payload["event"]).encode("utf-8"),
)
return {"statusCode": 200, "body": ""}
def verify_signature(body: str, timestamp: str, signature: str) -> bool:
if not timestamp or not signature:
return False
try:
ts = int(timestamp)
except ValueError:
return False
if abs(time.time() - ts) > 300:
return False
base = f"v0:{timestamp}:{body}".encode("utf-8")
expected = "v0=" + hmac.new(SIGNING_SECRET.encode("utf-8"), base, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)

View file

70
template.yaml Normal file
View file

@ -0,0 +1,70 @@
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Expense Approval Bot - Slack reaction-driven multi-stage approval router
Parameters:
SlackBotTokenSecretArn:
Type: String
Description: ARN of the Secrets Manager secret containing the Slack bot token (xoxb-...)
SlackSigningSecret:
Type: String
Description: Slack app signing secret (for verifying Event API requests)
NoEcho: true
Globals:
Function:
Runtime: python3.12
Timeout: 15
MemorySize: 256
Architectures:
- arm64
Resources:
ProcessorFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: expense-approval-processor
Handler: app.handler
CodeUri: src/processor/
Environment:
Variables:
SLACK_BOT_TOKEN_SECRET_ARN: !Ref SlackBotTokenSecretArn
Policies:
- Version: '2012-10-17'
Statement:
- Effect: Allow
Action: secretsmanager:GetSecretValue
Resource: !Ref SlackBotTokenSecretArn
ReceiverFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: expense-approval-receiver
Handler: app.handler
CodeUri: src/receiver/
Timeout: 5
Environment:
Variables:
PROCESSOR_FUNCTION_NAME: !Ref ProcessorFunction
SLACK_SIGNING_SECRET: !Ref SlackSigningSecret
Policies:
- Version: '2012-10-17'
Statement:
- Effect: Allow
Action: lambda:InvokeFunction
Resource: !GetAtt ProcessorFunction.Arn
Events:
SlackEvents:
Type: HttpApi
Properties:
Path: /slack/events
Method: POST
Outputs:
SlackEventsUrl:
Description: Paste this into the Slack app's Event Subscriptions -> Request URL
Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/slack/events"
ProcessorFunctionArn:
Value: !GetAtt ProcessorFunction.Arn
ReceiverFunctionArn:
Value: !GetAtt ReceiverFunction.Arn