From bede364f16fbf1fefa30fe6ab247545803ebe7b9 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:56:00 -0400 Subject: [PATCH] Initial commit: expense approval bot SAM stack --- .gitignore | 6 ++ README.md | 59 +++++++++++++++++ samconfig.toml.example | 16 +++++ src/processor/app.py | 113 +++++++++++++++++++++++++++++++++ src/processor/requirements.txt | 0 src/receiver/app.py | 58 +++++++++++++++++ src/receiver/requirements.txt | 0 template.yaml | 70 ++++++++++++++++++++ 8 files changed, 322 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 samconfig.toml.example create mode 100644 src/processor/app.py create mode 100644 src/processor/requirements.txt create mode 100644 src/receiver/app.py create mode 100644 src/receiver/requirements.txt create mode 100644 template.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..883370e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.aws-sam/ +__pycache__/ +*.pyc +.env +samconfig.toml +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..1fd28bb --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/samconfig.toml.example b/samconfig.toml.example new file mode 100644 index 0000000..7066907 --- /dev/null +++ b/samconfig.toml.example @@ -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 diff --git a/src/processor/app.py b/src/processor/app.py new file mode 100644 index 0000000..6aa152c --- /dev/null +++ b/src/processor/app.py @@ -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 diff --git a/src/processor/requirements.txt b/src/processor/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/receiver/app.py b/src/receiver/app.py new file mode 100644 index 0000000..7d2f7f2 --- /dev/null +++ b/src/receiver/app.py @@ -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) diff --git a/src/receiver/requirements.txt b/src/receiver/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/template.yaml b/template.yaml new file mode 100644 index 0000000..3a6aa60 --- /dev/null +++ b/template.yaml @@ -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