mirror of
https://github.com/Sea-Haven-Industries/expense-approval-bot.git
synced 2026-05-18 20:20:14 +00:00
Initial commit: expense approval bot SAM stack
This commit is contained in:
commit
bede364f16
8 changed files with 322 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.aws-sam/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
samconfig.toml
|
||||||
|
*.log
|
||||||
59
README.md
Normal file
59
README.md
Normal 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
16
samconfig.toml.example
Normal 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
113
src/processor/app.py
Normal 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
|
||||||
0
src/processor/requirements.txt
Normal file
0
src/processor/requirements.txt
Normal file
58
src/receiver/app.py
Normal file
58
src/receiver/app.py
Normal 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)
|
||||||
0
src/receiver/requirements.txt
Normal file
0
src/receiver/requirements.txt
Normal file
70
template.yaml
Normal file
70
template.yaml
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue