Add SAM stack, Fargate listener, and shared modules
- SAM template with DynamoDB, two Lambdas (fetch-classify, daily-digest), ECS/Fargate for Socket Mode listener, EventBridge Scheduler - Gmail API integration with OAuth refresh and History API sync - Bedrock Haiku classification (HIGH/NORMAL/LOW) - Slack DM delivery with Block Kit formatting and threading - Socket Mode listener for two-way conversation (placeholder) - Setup script for Gmail OAuth token exchange - Slack app manifest for app creation
This commit is contained in:
parent
2edac81cce
commit
e7da56270a
19 changed files with 1508 additions and 0 deletions
92
README.md
Normal file
92
README.md
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
# Exec Aide
|
||||||
|
|
||||||
|
Personal AI agent that monitors Adam Moussa's Gmail inbox, classifies emails by urgency using Claude Haiku (Bedrock), and delivers Slack alerts and daily digests.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
EventBridge (15 min) → fetch-classify Lambda → Gmail API → Bedrock Haiku → DynamoDB + Slack DM
|
||||||
|
EventBridge Scheduler (5 PM ET M-F) → daily-digest Lambda → DynamoDB + Gmail API → Slack DM
|
||||||
|
```
|
||||||
|
|
||||||
|
- **exec-aide-fetch-classify**: Polls Gmail via History API, classifies each new message, sends immediate Slack DM for HIGH priority items, tracks thread state for unanswered detection.
|
||||||
|
- **exec-aide-daily-digest**: Summarizes the day's high-priority items, bypassed work orders (emails not CC'ing work-orders@), and unanswered threads older than 24h.
|
||||||
|
- **DynamoDB table `exec-aide`**: Single-table design with message records, thread tracking, and sync metadata. 90-day TTL.
|
||||||
|
|
||||||
|
## Classification Rules
|
||||||
|
|
||||||
|
| Category | Trigger |
|
||||||
|
|---|---|
|
||||||
|
| **HIGH** | VIP sender, urgency keywords, escalation language, financial/legal matters |
|
||||||
|
| **NORMAL** | Standard business correspondence |
|
||||||
|
| **LOW** | Newsletters, automated notifications, marketing |
|
||||||
|
| **Bypassed WO** | Email to Adam without work-orders@ in TO/CC |
|
||||||
|
| **Unanswered** | Thread where Adam hasn't replied in 24h+ |
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- AWS CLI + SAM CLI
|
||||||
|
- Python 3.12
|
||||||
|
- Gmail API enabled in Google Cloud Console
|
||||||
|
- Slack app with `chat:write` + `im:write` scopes
|
||||||
|
|
||||||
|
## One-Time Setup
|
||||||
|
|
||||||
|
### 1. Gmail OAuth
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install the setup dependency (not needed in Lambda)
|
||||||
|
pip install google-auth-oauthlib
|
||||||
|
|
||||||
|
# Download OAuth client JSON from Google Cloud Console, then:
|
||||||
|
python scripts/get_gmail_token.py --client-secrets-file path/to/client_secret.json
|
||||||
|
# Authorize as adam@seahavenind.com in the browser
|
||||||
|
# Copy the output into Secrets Manager
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Secrets Manager
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Gmail OAuth (output from step 1)
|
||||||
|
aws secretsmanager create-secret --name exec-aide/gmail-oauth --secret-string '...'
|
||||||
|
|
||||||
|
# Slack bot token
|
||||||
|
aws secretsmanager create-secret --name exec-aide/slack-bot-token --secret-string 'xoxb-...'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. SSM Parameters
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws ssm put-parameter --name /exec-aide/adam-email --value adam@seahavenind.com --type String
|
||||||
|
aws ssm put-parameter --name /exec-aide/adam-slack-user-id --value U01XXXXXXXX --type String
|
||||||
|
aws ssm put-parameter --name /exec-aide/vip-senders --value '["ceo@example.com"]' --type String
|
||||||
|
aws ssm put-parameter --name /exec-aide/vip-domains --value '["example.com"]' --type String
|
||||||
|
aws ssm put-parameter --name /exec-aide/work-order-addresses --value '["work-orders@seahavenind.com","work-orders@seahaven.com"]' --type String
|
||||||
|
aws ssm put-parameter --name /exec-aide/unanswered-threshold-hours --value 24 --type String
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp samconfig.toml.example samconfig.toml # edit if needed
|
||||||
|
sam build
|
||||||
|
sam deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Trigger a poll
|
||||||
|
aws lambda invoke --function-name exec-aide-fetch-classify /dev/stdout
|
||||||
|
|
||||||
|
# Trigger the daily digest
|
||||||
|
aws lambda invoke --function-name exec-aide-daily-digest /dev/stdout
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating VIP List or Keywords
|
||||||
|
|
||||||
|
Edit the SSM parameters directly — changes take effect on the next Lambda cold start (or within ~15 minutes):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
aws ssm put-parameter --name /exec-aide/vip-senders --value '["new@example.com"]' --type String --overwrite
|
||||||
|
```
|
||||||
8
listener/Dockerfile
Normal file
8
listener/Dockerfile
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY app.py .
|
||||||
|
|
||||||
|
CMD ["python", "app.py"]
|
||||||
76
listener/app.py
Normal file
76
listener/app.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from slack_bolt import App
|
||||||
|
from slack_bolt.adapter.socket_mode import SocketModeHandler
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("exec-aide")
|
||||||
|
|
||||||
|
secrets_client = boto3.client("secretsmanager")
|
||||||
|
ssm_client = boto3.client("ssm")
|
||||||
|
|
||||||
|
_config = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_slack_credentials():
|
||||||
|
resp = secrets_client.get_secret_value(SecretId=os.environ["SECRET_SLACK"])
|
||||||
|
return json.loads(resp["SecretString"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_config():
|
||||||
|
global _config
|
||||||
|
if _config is not None:
|
||||||
|
return _config
|
||||||
|
|
||||||
|
prefix = os.environ["SSM_PREFIX"]
|
||||||
|
params = {}
|
||||||
|
paginator = ssm_client.get_paginator("get_parameters_by_path")
|
||||||
|
for page in paginator.paginate(Path=prefix, Recursive=True):
|
||||||
|
for p in page["Parameters"]:
|
||||||
|
key = p["Name"].split("/")[-1]
|
||||||
|
params[key] = p["Value"]
|
||||||
|
|
||||||
|
_config = {
|
||||||
|
"adam_email": params.get("adam-email", ""),
|
||||||
|
"adam_slack_user_id": params.get("adam-slack-user-id", ""),
|
||||||
|
}
|
||||||
|
return _config
|
||||||
|
|
||||||
|
|
||||||
|
creds = get_slack_credentials()
|
||||||
|
app = App(token=creds["botToken"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.event("message")
|
||||||
|
def handle_message(event, say):
|
||||||
|
if event.get("bot_id") or event.get("subtype"):
|
||||||
|
return
|
||||||
|
|
||||||
|
config = get_config()
|
||||||
|
if event.get("user") != config["adam_slack_user_id"]:
|
||||||
|
return
|
||||||
|
|
||||||
|
text = event.get("text", "").strip()
|
||||||
|
thread_ts = event.get("thread_ts") or event["ts"]
|
||||||
|
|
||||||
|
response = (
|
||||||
|
"I received your message. Right now I'm focused on monitoring your inbox — "
|
||||||
|
"I'll send you real-time alerts for high-priority emails and a daily digest at 5 PM.\n\n"
|
||||||
|
"More conversational features are coming soon."
|
||||||
|
)
|
||||||
|
|
||||||
|
say(text=response, thread_ts=thread_ts)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
handler = SocketModeHandler(app, creds["appToken"])
|
||||||
|
logger.info("Exec Aide listener starting (Socket Mode)")
|
||||||
|
handler.start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
3
listener/requirements.txt
Normal file
3
listener/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
slack-bolt>=1.18
|
||||||
|
slack-sdk>=3.27
|
||||||
|
boto3>=1.34
|
||||||
9
samconfig.toml.example
Normal file
9
samconfig.toml.example
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
version = 0.1
|
||||||
|
|
||||||
|
[default.deploy.parameters]
|
||||||
|
stack_name = "exec-aide"
|
||||||
|
resolve_s3 = true
|
||||||
|
s3_prefix = "exec-aide"
|
||||||
|
region = "us-east-1"
|
||||||
|
confirm_changeset = true
|
||||||
|
capabilities = "CAPABILITY_NAMED_IAM"
|
||||||
49
scripts/get_gmail_token.py
Normal file
49
scripts/get_gmail_token.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""One-time OAuth token exchange for Gmail API access.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
1. Download OAuth client JSON from Google Cloud Console
|
||||||
|
2. Run: python scripts/get_gmail_token.py --client-secrets-file path/to/client_secret.json
|
||||||
|
3. Authorize as adam@seahavenind.com in the browser
|
||||||
|
4. Copy the output JSON into Secrets Manager:
|
||||||
|
aws secretsmanager create-secret --name exec-aide/gmail-oauth --secret-string '<json>'
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
|
||||||
|
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||||
|
|
||||||
|
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Get Gmail OAuth refresh token")
|
||||||
|
parser.add_argument(
|
||||||
|
"--client-secrets-file",
|
||||||
|
required=True,
|
||||||
|
help="Path to the OAuth client secrets JSON downloaded from Google Cloud Console",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
flow = InstalledAppFlow.from_client_secrets_file(args.client_secrets_file, SCOPES)
|
||||||
|
creds = flow.run_local_server(port=8080)
|
||||||
|
|
||||||
|
secret = {
|
||||||
|
"client_id": creds.client_id,
|
||||||
|
"client_secret": creds.client_secret,
|
||||||
|
"refresh_token": creds.refresh_token,
|
||||||
|
"access_token": creds.token,
|
||||||
|
"token_expiry": creds.expiry.isoformat() if creds.expiry else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\nStore this in Secrets Manager as exec-aide/gmail-oauth:\n")
|
||||||
|
print(json.dumps(secret, indent=2))
|
||||||
|
print(
|
||||||
|
"\nCommand:\n"
|
||||||
|
f"aws secretsmanager create-secret --name exec-aide/gmail-oauth "
|
||||||
|
f"--secret-string '{json.dumps(secret)}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
31
slack-app-manifest.yaml
Normal file
31
slack-app-manifest.yaml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
display_information:
|
||||||
|
name: Exec Aide
|
||||||
|
description: Adam's personal executive assistant — inbox monitoring, triage, and more.
|
||||||
|
background_color: "#1a1a2e"
|
||||||
|
|
||||||
|
features:
|
||||||
|
bot_user:
|
||||||
|
display_name: Exec Aide
|
||||||
|
always_online: true
|
||||||
|
app_home:
|
||||||
|
home_tab_enabled: false
|
||||||
|
messages_tab_enabled: true
|
||||||
|
messages_tab_read_only_enabled: false
|
||||||
|
|
||||||
|
oauth_config:
|
||||||
|
scopes:
|
||||||
|
bot:
|
||||||
|
- chat:write
|
||||||
|
- im:history
|
||||||
|
- im:read
|
||||||
|
- im:write
|
||||||
|
|
||||||
|
settings:
|
||||||
|
event_subscriptions:
|
||||||
|
bot_events:
|
||||||
|
- message.im
|
||||||
|
org_deploy_enabled: false
|
||||||
|
socket_mode_enabled: true
|
||||||
|
token_rotation_enabled: false
|
||||||
|
interactivity:
|
||||||
|
is_enabled: false
|
||||||
0
src/daily_digest/__init__.py
Normal file
0
src/daily_digest/__init__.py
Normal file
72
src/daily_digest/app.py
Normal file
72
src/daily_digest/app.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from shared.secrets import get_config, get_slack_token
|
||||||
|
from shared.gmail import get_authenticated_service, fetch_thread
|
||||||
|
from shared.dynamo import get_todays_messages, get_unanswered_threads, save_thread_state
|
||||||
|
from shared.slack import send_dm, build_daily_digest
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def lambda_handler(event, context):
|
||||||
|
config = get_config()
|
||||||
|
service = get_authenticated_service()
|
||||||
|
|
||||||
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
messages = get_todays_messages(today)
|
||||||
|
|
||||||
|
high_items = [m for m in messages if m.get("classification") == "HIGH"]
|
||||||
|
bypassed = [m for m in messages if m.get("bypassed_wo")]
|
||||||
|
|
||||||
|
unanswered_raw = get_unanswered_threads(config["unanswered_threshold_hours"])
|
||||||
|
unanswered = []
|
||||||
|
for thread in unanswered_raw:
|
||||||
|
thread_id = thread["pk"].replace("THD#", "")
|
||||||
|
try:
|
||||||
|
thread_msgs = fetch_thread(service, thread_id)
|
||||||
|
adam_replied_after = any(
|
||||||
|
m["from_email"] == config["adam_email"]
|
||||||
|
and m["internal_date"] > _iso_to_epoch_ms(thread.get("unanswered_since", ""))
|
||||||
|
for m in thread_msgs
|
||||||
|
)
|
||||||
|
if adam_replied_after:
|
||||||
|
save_thread_state(thread_id, thread_msgs[-1], config["adam_email"])
|
||||||
|
else:
|
||||||
|
unanswered.append(thread)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to verify thread %s", thread_id)
|
||||||
|
unanswered.append(thread)
|
||||||
|
|
||||||
|
normal_count = sum(1 for m in messages if m.get("classification") == "NORMAL")
|
||||||
|
low_count = sum(1 for m in messages if m.get("classification") == "LOW")
|
||||||
|
|
||||||
|
blocks = build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count)
|
||||||
|
send_dm(
|
||||||
|
config["adam_slack_user_id"],
|
||||||
|
blocks,
|
||||||
|
"Daily Email Digest",
|
||||||
|
get_slack_token(),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"sent": True,
|
||||||
|
"high": len(high_items),
|
||||||
|
"bypassed": len(bypassed),
|
||||||
|
"unanswered": len(unanswered),
|
||||||
|
"normal": normal_count,
|
||||||
|
"low": low_count,
|
||||||
|
}
|
||||||
|
logger.info("Digest sent: %s", result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_to_epoch_ms(iso_str):
|
||||||
|
if not iso_str:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(iso_str)
|
||||||
|
return int(dt.timestamp() * 1000)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0
|
||||||
0
src/fetch_classify/__init__.py
Normal file
0
src/fetch_classify/__init__.py
Normal file
76
src/fetch_classify/app.py
Normal file
76
src/fetch_classify/app.py
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from shared.secrets import get_config, get_slack_token
|
||||||
|
from shared.gmail import get_authenticated_service, fetch_history, fetch_message, initial_sync
|
||||||
|
from shared.dynamo import message_exists, save_message, save_thread_state, get_sync_metadata, save_sync_metadata
|
||||||
|
from shared.classify import classify_email, check_bypassed_workorder
|
||||||
|
from shared.slack import send_dm, build_high_priority_alert
|
||||||
|
|
||||||
|
logger = logging.getLogger()
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def lambda_handler(event, context):
|
||||||
|
config = get_config()
|
||||||
|
service = get_authenticated_service()
|
||||||
|
|
||||||
|
sync_meta = get_sync_metadata()
|
||||||
|
|
||||||
|
if sync_meta is None:
|
||||||
|
logger.info("No sync metadata found, running initial sync")
|
||||||
|
msg_ids, history_id = initial_sync(service)
|
||||||
|
else:
|
||||||
|
msg_ids, history_id = fetch_history(service, sync_meta["history_id"])
|
||||||
|
if msg_ids is None:
|
||||||
|
logger.warning("History expired, falling back to initial sync")
|
||||||
|
msg_ids, history_id = initial_sync(service)
|
||||||
|
|
||||||
|
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
results = {"processed": 0, "high": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
for msg_id in msg_ids:
|
||||||
|
try:
|
||||||
|
if message_exists(msg_id):
|
||||||
|
results["skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg = fetch_message(service, msg_id)
|
||||||
|
classification = classify_email(
|
||||||
|
msg, config["vip_senders"], config["vip_domains"]
|
||||||
|
)
|
||||||
|
bypassed = check_bypassed_workorder(msg, config["work_order_addresses"])
|
||||||
|
|
||||||
|
slack_ts = ""
|
||||||
|
if classification["classification"] == "HIGH":
|
||||||
|
blocks = build_high_priority_alert({
|
||||||
|
**msg,
|
||||||
|
"classification_reason": classification["reason"],
|
||||||
|
})
|
||||||
|
slack_ts = send_dm(
|
||||||
|
config["adam_slack_user_id"],
|
||||||
|
blocks,
|
||||||
|
f"HIGH: {msg['subject']}",
|
||||||
|
get_slack_token(),
|
||||||
|
) or ""
|
||||||
|
results["high"] += 1
|
||||||
|
|
||||||
|
save_message({
|
||||||
|
**msg,
|
||||||
|
"classification": classification["classification"],
|
||||||
|
"classification_reason": classification["reason"],
|
||||||
|
"bypassed_wo": bypassed,
|
||||||
|
"classified_date": today,
|
||||||
|
"notified": bool(slack_ts),
|
||||||
|
"slack_ts": slack_ts,
|
||||||
|
})
|
||||||
|
save_thread_state(msg["thread_id"], msg, config["adam_email"])
|
||||||
|
|
||||||
|
results["processed"] += 1
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to process message %s", msg_id)
|
||||||
|
results["errors"] += 1
|
||||||
|
|
||||||
|
save_sync_metadata(history_id)
|
||||||
|
logger.info("Poll complete: %s", results)
|
||||||
|
return results
|
||||||
3
src/requirements.txt
Normal file
3
src/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
google-api-python-client>=2.0
|
||||||
|
google-auth>=2.0
|
||||||
|
requests>=2.31
|
||||||
0
src/shared/__init__.py
Normal file
0
src/shared/__init__.py
Normal file
72
src/shared/classify.py
Normal file
72
src/shared/classify.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")
|
||||||
|
|
||||||
|
MODEL_ID = "us.anthropic.claude-haiku-4-5-20251001"
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """\
|
||||||
|
You are an email triage assistant for Adam Moussa, President of Sea Haven Industries \
|
||||||
|
(a facilities maintenance company).
|
||||||
|
|
||||||
|
Classify this email's urgency. Return ONLY valid JSON — no markdown, no explanation:
|
||||||
|
|
||||||
|
{"classification": "HIGH" or "NORMAL" or "LOW", "reason": "one sentence"}
|
||||||
|
|
||||||
|
HIGH:
|
||||||
|
- Sender is on the VIP list
|
||||||
|
- Urgency keywords: urgent, asap, emergency, critical, immediate, time-sensitive, deadline today, EOD
|
||||||
|
- Escalation language: threats, legal mentions, contract termination, safety incidents, regulatory compliance
|
||||||
|
- Direct asks from C-suite, major clients, or key vendors requiring immediate response
|
||||||
|
- Financial approvals over $10,000
|
||||||
|
|
||||||
|
NORMAL:
|
||||||
|
- Standard business correspondence requiring response but not time-critical
|
||||||
|
- Meeting invites, project updates, routine vendor communications
|
||||||
|
|
||||||
|
LOW:
|
||||||
|
- Newsletters, marketing, automated notifications, bulk mailings
|
||||||
|
- FYI-only messages, calendar confirmations, read receipts\
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def classify_email(message, vip_senders, vip_domains):
|
||||||
|
sender_email = message["from_email"]
|
||||||
|
sender_domain = sender_email.split("@")[-1] if "@" in sender_email else ""
|
||||||
|
is_vip = sender_email in vip_senders or sender_domain in vip_domains
|
||||||
|
|
||||||
|
user_msg = (
|
||||||
|
f"From: {message['from_name']} <{sender_email}>"
|
||||||
|
f"{' [VIP SENDER]' if is_vip else ''}\n"
|
||||||
|
f"Subject: {message['subject']}\n"
|
||||||
|
f"Preview: {message['snippet']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = _bedrock.converse(
|
||||||
|
modelId=MODEL_ID,
|
||||||
|
system=[{"text": SYSTEM_PROMPT}],
|
||||||
|
messages=[{"role": "user", "content": [{"text": user_msg}]}],
|
||||||
|
inferenceConfig={"temperature": 0.0, "maxTokens": 256},
|
||||||
|
)
|
||||||
|
raw = resp["output"]["message"]["content"][0]["text"]
|
||||||
|
result = json.loads(raw)
|
||||||
|
return {
|
||||||
|
"classification": result.get("classification", "NORMAL"),
|
||||||
|
"reason": result.get("reason", ""),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Classification failed, defaulting to NORMAL")
|
||||||
|
return {"classification": "NORMAL", "reason": "Classification unavailable"}
|
||||||
|
|
||||||
|
|
||||||
|
def check_bypassed_workorder(message, work_order_addresses):
|
||||||
|
all_recipients = [
|
||||||
|
addr.lower()
|
||||||
|
for addr in message["to_emails"] + message["cc_emails"]
|
||||||
|
]
|
||||||
|
return not any(wo in all_recipients for wo in work_order_addresses)
|
||||||
157
src/shared/dynamo.py
Normal file
157
src/shared/dynamo.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
_table = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_table():
|
||||||
|
global _table
|
||||||
|
if _table is None:
|
||||||
|
_table = boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"])
|
||||||
|
return _table
|
||||||
|
|
||||||
|
|
||||||
|
def _ttl_90_days():
|
||||||
|
return int(time.time()) + (90 * 24 * 60 * 60)
|
||||||
|
|
||||||
|
|
||||||
|
def message_exists(message_id):
|
||||||
|
resp = _get_table().get_item(
|
||||||
|
Key={"pk": f"MSG#{message_id}", "sk": "MSG"},
|
||||||
|
ProjectionExpression="pk",
|
||||||
|
)
|
||||||
|
return "Item" in resp
|
||||||
|
|
||||||
|
|
||||||
|
def save_message(msg_data):
|
||||||
|
item = {
|
||||||
|
"pk": f"MSG#{msg_data['message_id']}",
|
||||||
|
"sk": "MSG",
|
||||||
|
"thread_id": msg_data["thread_id"],
|
||||||
|
"from_email": msg_data["from_email"],
|
||||||
|
"from_name": msg_data["from_name"],
|
||||||
|
"to_emails": msg_data["to_emails"],
|
||||||
|
"cc_emails": msg_data["cc_emails"],
|
||||||
|
"subject": msg_data["subject"],
|
||||||
|
"snippet": msg_data["snippet"],
|
||||||
|
"internal_date": msg_data["internal_date"],
|
||||||
|
"classification": msg_data["classification"],
|
||||||
|
"classification_reason": msg_data["classification_reason"],
|
||||||
|
"bypassed_wo": msg_data["bypassed_wo"],
|
||||||
|
"notified": msg_data.get("notified", False),
|
||||||
|
"classified_date": msg_data["classified_date"],
|
||||||
|
"slack_ts": msg_data.get("slack_ts", ""),
|
||||||
|
"ttl": _ttl_90_days(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
_get_table().put_item(
|
||||||
|
Item=item,
|
||||||
|
ConditionExpression="attribute_not_exists(pk)",
|
||||||
|
)
|
||||||
|
except ClientError as e:
|
||||||
|
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def save_thread_state(thread_id, message, adam_email):
|
||||||
|
now_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
is_adam = message["from_email"] == adam_email
|
||||||
|
|
||||||
|
if is_adam:
|
||||||
|
_get_table().update_item(
|
||||||
|
Key={"pk": f"THD#{thread_id}", "sk": "THD"},
|
||||||
|
UpdateExpression=(
|
||||||
|
"SET last_message_from = :from_email, "
|
||||||
|
"last_message_date = :now, "
|
||||||
|
"subject = :subject, "
|
||||||
|
"adam_last_reply_date = :now, "
|
||||||
|
"ttl = :ttl "
|
||||||
|
"REMOVE unanswered_since"
|
||||||
|
),
|
||||||
|
ExpressionAttributeValues={
|
||||||
|
":from_email": message["from_email"],
|
||||||
|
":now": now_iso,
|
||||||
|
":subject": message["subject"],
|
||||||
|
":ttl": _ttl_90_days(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
_get_table().update_item(
|
||||||
|
Key={"pk": f"THD#{thread_id}", "sk": "THD"},
|
||||||
|
UpdateExpression=(
|
||||||
|
"SET last_message_from = :from_email, "
|
||||||
|
"last_message_date = :now, "
|
||||||
|
"subject = :subject, "
|
||||||
|
"unanswered_since = if_not_exists(unanswered_since, :now), "
|
||||||
|
"ttl = :ttl"
|
||||||
|
),
|
||||||
|
ExpressionAttributeValues={
|
||||||
|
":from_email": message["from_email"],
|
||||||
|
":now": now_iso,
|
||||||
|
":subject": message["subject"],
|
||||||
|
":ttl": _ttl_90_days(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sync_metadata():
|
||||||
|
resp = _get_table().get_item(
|
||||||
|
Key={"pk": "META#sync", "sk": "META"}
|
||||||
|
)
|
||||||
|
return resp.get("Item")
|
||||||
|
|
||||||
|
|
||||||
|
def save_sync_metadata(history_id):
|
||||||
|
_get_table().put_item(
|
||||||
|
Item={
|
||||||
|
"pk": "META#sync",
|
||||||
|
"sk": "META",
|
||||||
|
"history_id": str(history_id),
|
||||||
|
"last_sync_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
"initial_sync_done": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_todays_messages(date_str):
|
||||||
|
items = []
|
||||||
|
resp = _get_table().query(
|
||||||
|
IndexName="by-date",
|
||||||
|
KeyConditionExpression="classified_date = :d AND sk = :sk",
|
||||||
|
ExpressionAttributeValues={":d": date_str, ":sk": "MSG"},
|
||||||
|
)
|
||||||
|
items.extend(resp.get("Items", []))
|
||||||
|
while "LastEvaluatedKey" in resp:
|
||||||
|
resp = _get_table().query(
|
||||||
|
IndexName="by-date",
|
||||||
|
KeyConditionExpression="classified_date = :d AND sk = :sk",
|
||||||
|
ExpressionAttributeValues={":d": date_str, ":sk": "MSG"},
|
||||||
|
ExclusiveStartKey=resp["LastEvaluatedKey"],
|
||||||
|
)
|
||||||
|
items.extend(resp.get("Items", []))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def get_unanswered_threads(threshold_hours):
|
||||||
|
cutoff = datetime.now(timezone.utc).timestamp() - (threshold_hours * 3600)
|
||||||
|
items = []
|
||||||
|
scan_kwargs = {
|
||||||
|
"FilterExpression": "begins_with(pk, :prefix) AND attribute_exists(unanswered_since)",
|
||||||
|
"ExpressionAttributeValues": {":prefix": "THD#"},
|
||||||
|
}
|
||||||
|
resp = _get_table().scan(**scan_kwargs)
|
||||||
|
items.extend(resp.get("Items", []))
|
||||||
|
while "LastEvaluatedKey" in resp:
|
||||||
|
scan_kwargs["ExclusiveStartKey"] = resp["LastEvaluatedKey"]
|
||||||
|
resp = _get_table().scan(**scan_kwargs)
|
||||||
|
items.extend(resp.get("Items", []))
|
||||||
|
|
||||||
|
return [
|
||||||
|
item for item in items
|
||||||
|
if datetime.fromisoformat(item["unanswered_since"]).timestamp() < cutoff
|
||||||
|
]
|
||||||
167
src/shared/gmail.py
Normal file
167
src/shared/gmail.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import google.auth.transport.requests
|
||||||
|
from google.oauth2.credentials import Credentials
|
||||||
|
from googleapiclient.discovery import build
|
||||||
|
from googleapiclient.errors import HttpError
|
||||||
|
|
||||||
|
from shared.secrets import get_gmail_oauth, save_gmail_tokens
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_service = None
|
||||||
|
|
||||||
|
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_authenticated_service():
|
||||||
|
global _service
|
||||||
|
oauth = get_gmail_oauth()
|
||||||
|
creds = Credentials(
|
||||||
|
token=oauth.get("access_token"),
|
||||||
|
refresh_token=oauth["refresh_token"],
|
||||||
|
token_uri="https://oauth2.googleapis.com/token",
|
||||||
|
client_id=oauth["client_id"],
|
||||||
|
client_secret=oauth["client_secret"],
|
||||||
|
scopes=SCOPES,
|
||||||
|
)
|
||||||
|
if not creds.valid:
|
||||||
|
creds.refresh(google.auth.transport.requests.Request())
|
||||||
|
save_gmail_tokens(
|
||||||
|
creds.token,
|
||||||
|
creds.expiry.isoformat() if creds.expiry else "",
|
||||||
|
)
|
||||||
|
_service = build("gmail", "v1", credentials=creds, cache_discovery=False)
|
||||||
|
return _service
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_history(service, history_id):
|
||||||
|
message_ids = []
|
||||||
|
new_history_id = history_id
|
||||||
|
try:
|
||||||
|
page_token = None
|
||||||
|
while True:
|
||||||
|
resp = (
|
||||||
|
service.users()
|
||||||
|
.history()
|
||||||
|
.list(
|
||||||
|
userId="me",
|
||||||
|
startHistoryId=history_id,
|
||||||
|
historyTypes=["messageAdded"],
|
||||||
|
labelIds=["INBOX"],
|
||||||
|
pageToken=page_token,
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
new_history_id = resp.get("historyId", new_history_id)
|
||||||
|
for record in resp.get("history", []):
|
||||||
|
for added in record.get("messagesAdded", []):
|
||||||
|
message_ids.append(added["message"]["id"])
|
||||||
|
page_token = resp.get("nextPageToken")
|
||||||
|
if not page_token:
|
||||||
|
break
|
||||||
|
except HttpError as e:
|
||||||
|
if e.resp.status == 404:
|
||||||
|
logger.warning("History ID expired, signaling re-sync")
|
||||||
|
return None, None
|
||||||
|
raise
|
||||||
|
return message_ids, new_history_id
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_message(service, message_id):
|
||||||
|
msg = (
|
||||||
|
service.users()
|
||||||
|
.messages()
|
||||||
|
.get(
|
||||||
|
userId="me",
|
||||||
|
id=message_id,
|
||||||
|
format="metadata",
|
||||||
|
metadataHeaders=["From", "To", "Cc", "Subject", "Date"],
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
return _parse_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_thread(service, thread_id):
|
||||||
|
thread = (
|
||||||
|
service.users()
|
||||||
|
.threads()
|
||||||
|
.get(userId="me", id=thread_id, format="metadata")
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
return [_parse_message(m) for m in thread.get("messages", [])]
|
||||||
|
|
||||||
|
|
||||||
|
def initial_sync(service):
|
||||||
|
message_ids = []
|
||||||
|
page_token = None
|
||||||
|
while True:
|
||||||
|
resp = (
|
||||||
|
service.users()
|
||||||
|
.messages()
|
||||||
|
.list(
|
||||||
|
userId="me",
|
||||||
|
q="newer_than:2d",
|
||||||
|
labelIds=["INBOX"],
|
||||||
|
pageToken=page_token,
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
for m in resp.get("messages", []):
|
||||||
|
message_ids.append(m["id"])
|
||||||
|
page_token = resp.get("nextPageToken")
|
||||||
|
if not page_token:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not message_ids:
|
||||||
|
profile = service.users().getProfile(userId="me").execute()
|
||||||
|
return [], str(profile["historyId"])
|
||||||
|
|
||||||
|
first_msg = (
|
||||||
|
service.users()
|
||||||
|
.messages()
|
||||||
|
.get(userId="me", id=message_ids[0], format="minimal")
|
||||||
|
.execute()
|
||||||
|
)
|
||||||
|
return message_ids, str(first_msg["historyId"])
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_message(msg):
|
||||||
|
headers = {h["name"]: h["value"] for h in msg.get("payload", {}).get("headers", [])}
|
||||||
|
from_raw = headers.get("From", "")
|
||||||
|
from_name, from_email = _parse_from(from_raw)
|
||||||
|
return {
|
||||||
|
"message_id": msg["id"],
|
||||||
|
"thread_id": msg["threadId"],
|
||||||
|
"from_email": from_email,
|
||||||
|
"from_name": from_name,
|
||||||
|
"to_emails": _parse_address_list(headers.get("To", "")),
|
||||||
|
"cc_emails": _parse_address_list(headers.get("Cc", "")),
|
||||||
|
"subject": headers.get("Subject", "(no subject)"),
|
||||||
|
"snippet": msg.get("snippet", ""),
|
||||||
|
"internal_date": int(msg.get("internalDate", 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_from(raw):
|
||||||
|
match = re.match(r"^(.+?)\s*<(.+?)>$", raw)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip().strip('"'), match.group(2).lower()
|
||||||
|
return "", raw.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_address_list(raw):
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
addresses = []
|
||||||
|
for part in raw.split(","):
|
||||||
|
part = part.strip()
|
||||||
|
match = re.match(r".*<(.+?)>", part)
|
||||||
|
if match:
|
||||||
|
addresses.append(match.group(1).lower())
|
||||||
|
elif "@" in part:
|
||||||
|
addresses.append(part.lower())
|
||||||
|
return addresses
|
||||||
72
src/shared/secrets.py
Normal file
72
src/shared/secrets.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
_secrets_client = boto3.client("secretsmanager")
|
||||||
|
_ssm_client = boto3.client("ssm")
|
||||||
|
|
||||||
|
_gmail_oauth = None
|
||||||
|
_slack_creds = None
|
||||||
|
_config = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_gmail_oauth():
|
||||||
|
global _gmail_oauth
|
||||||
|
if _gmail_oauth is None:
|
||||||
|
resp = _secrets_client.get_secret_value(
|
||||||
|
SecretId=os.environ["SECRET_GMAIL"]
|
||||||
|
)
|
||||||
|
_gmail_oauth = json.loads(resp["SecretString"])
|
||||||
|
return _gmail_oauth
|
||||||
|
|
||||||
|
|
||||||
|
def save_gmail_tokens(access_token, token_expiry):
|
||||||
|
global _gmail_oauth
|
||||||
|
oauth = get_gmail_oauth()
|
||||||
|
oauth["access_token"] = access_token
|
||||||
|
oauth["token_expiry"] = token_expiry
|
||||||
|
_secrets_client.put_secret_value(
|
||||||
|
SecretId=os.environ["SECRET_GMAIL"],
|
||||||
|
SecretString=json.dumps(oauth),
|
||||||
|
)
|
||||||
|
_gmail_oauth = oauth
|
||||||
|
|
||||||
|
|
||||||
|
def get_slack_credentials():
|
||||||
|
"""Returns { botToken, signingSecret, appToken }."""
|
||||||
|
global _slack_creds
|
||||||
|
if _slack_creds is None:
|
||||||
|
resp = _secrets_client.get_secret_value(
|
||||||
|
SecretId=os.environ["SECRET_SLACK"]
|
||||||
|
)
|
||||||
|
_slack_creds = json.loads(resp["SecretString"])
|
||||||
|
return _slack_creds
|
||||||
|
|
||||||
|
|
||||||
|
def get_slack_token():
|
||||||
|
return get_slack_credentials()["botToken"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_config():
|
||||||
|
global _config
|
||||||
|
if _config is not None:
|
||||||
|
return _config
|
||||||
|
|
||||||
|
prefix = os.environ["SSM_PREFIX"]
|
||||||
|
params = {}
|
||||||
|
paginator = _ssm_client.get_paginator("get_parameters_by_path")
|
||||||
|
for page in paginator.paginate(Path=prefix, Recursive=True):
|
||||||
|
for p in page["Parameters"]:
|
||||||
|
key = p["Name"].split("/")[-1]
|
||||||
|
params[key] = p["Value"]
|
||||||
|
|
||||||
|
_config = {
|
||||||
|
"adam_email": params.get("adam-email", ""),
|
||||||
|
"adam_slack_user_id": params.get("adam-slack-user-id", ""),
|
||||||
|
"vip_senders": json.loads(params.get("vip-senders", "[]")),
|
||||||
|
"vip_domains": json.loads(params.get("vip-domains", "[]")),
|
||||||
|
"work_order_addresses": json.loads(params.get("work-order-addresses", "[]")),
|
||||||
|
"unanswered_threshold_hours": int(params.get("unanswered-threshold-hours", "24")),
|
||||||
|
}
|
||||||
|
return _config
|
||||||
224
src/shared/slack.py
Normal file
224
src/shared/slack.py
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_dm_channel_id = None
|
||||||
|
|
||||||
|
|
||||||
|
def send_dm(user_id, blocks, text, token):
|
||||||
|
global _dm_channel_id
|
||||||
|
if _dm_channel_id is None:
|
||||||
|
resp = requests.post(
|
||||||
|
"https://slack.com/api/conversations.open",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"users": user_id},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if not data.get("ok"):
|
||||||
|
raise RuntimeError(f"conversations.open failed: {data.get('error')}")
|
||||||
|
_dm_channel_id = data["channel"]["id"]
|
||||||
|
|
||||||
|
resp = requests.post(
|
||||||
|
"https://slack.com/api/chat.postMessage",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={
|
||||||
|
"channel": _dm_channel_id,
|
||||||
|
"blocks": blocks,
|
||||||
|
"text": text,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if not data.get("ok"):
|
||||||
|
raise RuntimeError(f"chat.postMessage failed: {data.get('error')}")
|
||||||
|
return data.get("ts")
|
||||||
|
|
||||||
|
|
||||||
|
def build_high_priority_alert(message):
|
||||||
|
time_ago = _format_time_ago(message["internal_date"])
|
||||||
|
gmail_url = f"https://mail.google.com/mail/u/0/#inbox/{message['message_id']}"
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"text": {"type": "plain_text", "text": ":rotating_light: High-Priority Email"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"fields": [
|
||||||
|
{"type": "mrkdwn", "text": f"*From*\n{message['from_name'] or message['from_email']}"},
|
||||||
|
{"type": "mrkdwn", "text": f"*Received*\n{time_ago}"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {"type": "mrkdwn", "text": f"*{message['subject']}*\n{message['snippet'][:300]}"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "context",
|
||||||
|
"elements": [
|
||||||
|
{"type": "mrkdwn", "text": f":brain: _{message.get('classification_reason', '')}_"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "actions",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"text": {"type": "plain_text", "text": "Open in Gmail"},
|
||||||
|
"url": gmail_url,
|
||||||
|
"action_id": "open_gmail",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count):
|
||||||
|
today_str = datetime.now(timezone.utc).strftime("%A, %B %d")
|
||||||
|
total = len(high_items) + normal_count + low_count
|
||||||
|
blocks = [
|
||||||
|
{
|
||||||
|
"type": "header",
|
||||||
|
"text": {"type": "plain_text", "text": ":newspaper: Daily Inbox Digest"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "context",
|
||||||
|
"elements": [
|
||||||
|
{"type": "mrkdwn", "text": f"{today_str} · {total} emails processed today"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"type": "divider"},
|
||||||
|
]
|
||||||
|
|
||||||
|
if high_items:
|
||||||
|
blocks.append({
|
||||||
|
"type": "section",
|
||||||
|
"text": {"type": "mrkdwn", "text": f":rotating_light: *High Priority ({len(high_items)})*"},
|
||||||
|
})
|
||||||
|
for item in high_items:
|
||||||
|
time_ago = _format_time_ago(item["internal_date"])
|
||||||
|
gmail_url = f"https://mail.google.com/mail/u/0/#inbox/{item['pk'].replace('MSG#', '')}"
|
||||||
|
blocks.append({
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": (
|
||||||
|
f"*{item['subject']}*\n"
|
||||||
|
f"From: {item.get('from_name') or item['from_email']} · {time_ago}\n"
|
||||||
|
f"_{item.get('classification_reason', '')}_"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"accessory": {
|
||||||
|
"type": "button",
|
||||||
|
"text": {"type": "plain_text", "text": "Open"},
|
||||||
|
"url": gmail_url,
|
||||||
|
"action_id": f"open_{item['pk']}",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
blocks.append({"type": "divider"})
|
||||||
|
|
||||||
|
if bypassed:
|
||||||
|
blocks.append({
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": (
|
||||||
|
f":warning: *Bypassed Work Orders ({len(bypassed)})*\n"
|
||||||
|
"_Sent directly to you without CC'ing work-orders@_"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for item in bypassed:
|
||||||
|
blocks.append({
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": f" · *{item['subject']}* from {item.get('from_name') or item['from_email']}",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
blocks.append({"type": "divider"})
|
||||||
|
|
||||||
|
if unanswered:
|
||||||
|
blocks.append({
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": (
|
||||||
|
f":hourglass: *Unanswered Threads ({len(unanswered)})*\n"
|
||||||
|
"_Awaiting your reply for 24h+_"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
for thread in unanswered:
|
||||||
|
hours = _hours_since_iso(thread.get("unanswered_since", ""))
|
||||||
|
blocks.append({
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": (
|
||||||
|
f" · *{thread.get('subject', '(no subject)')}*"
|
||||||
|
f" — last from {thread.get('last_message_from', 'unknown')}"
|
||||||
|
f" · {hours}h waiting"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
blocks.append({"type": "divider"})
|
||||||
|
|
||||||
|
blocks.append({
|
||||||
|
"type": "context",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": (
|
||||||
|
f":bar_chart: Today: {len(high_items)} high · "
|
||||||
|
f"{normal_count} normal · {low_count} low · "
|
||||||
|
f"{len(bypassed)} bypassed WO · {len(unanswered)} unanswered"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def send_message_to_channel(channel, text, token):
|
||||||
|
resp = requests.post(
|
||||||
|
"https://slack.com/api/chat.postMessage",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
json={"channel": channel, "text": text},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if not data.get("ok"):
|
||||||
|
logger.error("chat.postMessage failed: %s", data.get("error"))
|
||||||
|
|
||||||
|
|
||||||
|
def _format_time_ago(internal_date):
|
||||||
|
if not internal_date:
|
||||||
|
return "unknown"
|
||||||
|
msg_time = datetime.fromtimestamp(int(internal_date) / 1000, tz=timezone.utc)
|
||||||
|
delta = datetime.now(timezone.utc) - msg_time
|
||||||
|
hours = int(delta.total_seconds() / 3600)
|
||||||
|
if hours < 1:
|
||||||
|
mins = int(delta.total_seconds() / 60)
|
||||||
|
return f"{mins}m ago"
|
||||||
|
if hours < 24:
|
||||||
|
return f"{hours}h ago"
|
||||||
|
return f"{hours // 24}d ago"
|
||||||
|
|
||||||
|
|
||||||
|
def _hours_since_iso(iso_str):
|
||||||
|
if not iso_str:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(iso_str)
|
||||||
|
delta = datetime.now(timezone.utc) - dt
|
||||||
|
return int(delta.total_seconds() / 3600)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0
|
||||||
397
template.yaml
Normal file
397
template.yaml
Normal file
|
|
@ -0,0 +1,397 @@
|
||||||
|
AWSTemplateFormatVersion: '2010-09-09'
|
||||||
|
Transform: AWS::Serverless-2016-10-31
|
||||||
|
Description: >
|
||||||
|
exec-aide — Gmail inbox monitor with AI classification and Slack alerts
|
||||||
|
|
||||||
|
Globals:
|
||||||
|
Function:
|
||||||
|
Runtime: python3.12
|
||||||
|
Architectures:
|
||||||
|
- arm64
|
||||||
|
MemorySize: 256
|
||||||
|
Timeout: 120
|
||||||
|
Environment:
|
||||||
|
Variables:
|
||||||
|
TABLE_NAME: !Ref ExecAideTable
|
||||||
|
SECRET_GMAIL: exec-aide/gmail-oauth
|
||||||
|
SECRET_SLACK: exec-aide/slack-credentials
|
||||||
|
SSM_PREFIX: /exec-aide
|
||||||
|
|
||||||
|
Resources:
|
||||||
|
|
||||||
|
# ── DynamoDB ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
ExecAideTable:
|
||||||
|
Type: AWS::DynamoDB::Table
|
||||||
|
Properties:
|
||||||
|
TableName: exec-aide
|
||||||
|
BillingMode: PAY_PER_REQUEST
|
||||||
|
AttributeDefinitions:
|
||||||
|
- AttributeName: pk
|
||||||
|
AttributeType: S
|
||||||
|
- AttributeName: sk
|
||||||
|
AttributeType: S
|
||||||
|
- AttributeName: classified_date
|
||||||
|
AttributeType: S
|
||||||
|
KeySchema:
|
||||||
|
- AttributeName: pk
|
||||||
|
KeyType: HASH
|
||||||
|
- AttributeName: sk
|
||||||
|
KeyType: RANGE
|
||||||
|
GlobalSecondaryIndexes:
|
||||||
|
- IndexName: by-date
|
||||||
|
KeySchema:
|
||||||
|
- AttributeName: classified_date
|
||||||
|
KeyType: HASH
|
||||||
|
- AttributeName: sk
|
||||||
|
KeyType: RANGE
|
||||||
|
Projection:
|
||||||
|
ProjectionType: ALL
|
||||||
|
TimeToLiveSpecification:
|
||||||
|
AttributeName: ttl
|
||||||
|
Enabled: true
|
||||||
|
|
||||||
|
# ── CloudWatch Log Groups ────────────────────────────────
|
||||||
|
|
||||||
|
FetchClassifyLogGroup:
|
||||||
|
Type: AWS::Logs::LogGroup
|
||||||
|
Properties:
|
||||||
|
LogGroupName: /aws/lambda/exec-aide-fetch-classify
|
||||||
|
RetentionInDays: 60
|
||||||
|
|
||||||
|
DailyDigestLogGroup:
|
||||||
|
Type: AWS::Logs::LogGroup
|
||||||
|
Properties:
|
||||||
|
LogGroupName: /aws/lambda/exec-aide-daily-digest
|
||||||
|
RetentionInDays: 60
|
||||||
|
|
||||||
|
# ── Lambda Functions ─────────────────────────────────────
|
||||||
|
|
||||||
|
FetchClassifyFunction:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
DependsOn: FetchClassifyLogGroup
|
||||||
|
Properties:
|
||||||
|
FunctionName: exec-aide-fetch-classify
|
||||||
|
Handler: fetch_classify.app.lambda_handler
|
||||||
|
CodeUri: src/
|
||||||
|
Events:
|
||||||
|
PollSchedule:
|
||||||
|
Type: Schedule
|
||||||
|
Properties:
|
||||||
|
Schedule: rate(15 minutes)
|
||||||
|
Description: Poll Gmail for new messages
|
||||||
|
Enabled: true
|
||||||
|
Policies:
|
||||||
|
- DynamoDBCrudPolicy:
|
||||||
|
TableName: !Ref ExecAideTable
|
||||||
|
- Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- secretsmanager:GetSecretValue
|
||||||
|
Resource:
|
||||||
|
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:exec-aide/gmail-oauth-*
|
||||||
|
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:exec-aide/slack-credentials-*
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- secretsmanager:PutSecretValue
|
||||||
|
Resource:
|
||||||
|
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:exec-aide/gmail-oauth-*
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- ssm:GetParametersByPath
|
||||||
|
- ssm:GetParameter
|
||||||
|
Resource:
|
||||||
|
- !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/exec-aide/*
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- bedrock:InvokeModel
|
||||||
|
Resource:
|
||||||
|
- arn:aws:bedrock:*::foundation-model/anthropic.*
|
||||||
|
- !Sub arn:aws:bedrock:${AWS::Region}:${AWS::AccountId}:inference-profile/us.anthropic.*
|
||||||
|
|
||||||
|
DailyDigestFunction:
|
||||||
|
Type: AWS::Serverless::Function
|
||||||
|
DependsOn: DailyDigestLogGroup
|
||||||
|
Properties:
|
||||||
|
FunctionName: exec-aide-daily-digest
|
||||||
|
Handler: daily_digest.app.lambda_handler
|
||||||
|
CodeUri: src/
|
||||||
|
Policies:
|
||||||
|
- DynamoDBCrudPolicy:
|
||||||
|
TableName: !Ref ExecAideTable
|
||||||
|
- Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- secretsmanager:GetSecretValue
|
||||||
|
Resource:
|
||||||
|
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:exec-aide/gmail-oauth-*
|
||||||
|
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:exec-aide/slack-credentials-*
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- secretsmanager:PutSecretValue
|
||||||
|
Resource:
|
||||||
|
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:exec-aide/gmail-oauth-*
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- ssm:GetParametersByPath
|
||||||
|
- ssm:GetParameter
|
||||||
|
Resource:
|
||||||
|
- !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/exec-aide/*
|
||||||
|
|
||||||
|
# ── ECS / Fargate (Socket Mode listener) ──────────────────
|
||||||
|
|
||||||
|
ListenerLogGroup:
|
||||||
|
Type: AWS::Logs::LogGroup
|
||||||
|
Properties:
|
||||||
|
LogGroupName: /ecs/exec-aide-listener
|
||||||
|
RetentionInDays: 60
|
||||||
|
|
||||||
|
EcsCluster:
|
||||||
|
Type: AWS::ECS::Cluster
|
||||||
|
Properties:
|
||||||
|
ClusterName: exec-aide
|
||||||
|
|
||||||
|
ListenerTaskDefinition:
|
||||||
|
Type: AWS::ECS::TaskDefinition
|
||||||
|
Properties:
|
||||||
|
Family: exec-aide-listener
|
||||||
|
Cpu: "256"
|
||||||
|
Memory: "512"
|
||||||
|
NetworkMode: awsvpc
|
||||||
|
RequiresCompatibilities:
|
||||||
|
- FARGATE
|
||||||
|
RuntimePlatform:
|
||||||
|
CpuArchitecture: ARM64
|
||||||
|
OperatingSystemFamily: LINUX
|
||||||
|
ExecutionRoleArn: !GetAtt ListenerExecutionRole.Arn
|
||||||
|
TaskRoleArn: !GetAtt ListenerTaskRole.Arn
|
||||||
|
ContainerDefinitions:
|
||||||
|
- Name: listener
|
||||||
|
Image: !Sub ${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/exec-aide-listener:latest
|
||||||
|
Essential: true
|
||||||
|
Environment:
|
||||||
|
- Name: TABLE_NAME
|
||||||
|
Value: !Ref ExecAideTable
|
||||||
|
- Name: SECRET_SLACK
|
||||||
|
Value: exec-aide/slack-credentials
|
||||||
|
- Name: SSM_PREFIX
|
||||||
|
Value: /exec-aide
|
||||||
|
LogConfiguration:
|
||||||
|
LogDriver: awslogs
|
||||||
|
Options:
|
||||||
|
awslogs-group: /ecs/exec-aide-listener
|
||||||
|
awslogs-region: !Ref AWS::Region
|
||||||
|
awslogs-stream-prefix: listener
|
||||||
|
|
||||||
|
ListenerService:
|
||||||
|
Type: AWS::ECS::Service
|
||||||
|
Properties:
|
||||||
|
ServiceName: exec-aide-listener
|
||||||
|
Cluster: !Ref EcsCluster
|
||||||
|
TaskDefinition: !Ref ListenerTaskDefinition
|
||||||
|
DesiredCount: 1
|
||||||
|
LaunchType: FARGATE
|
||||||
|
NetworkConfiguration:
|
||||||
|
AwsvpcConfiguration:
|
||||||
|
AssignPublicIp: ENABLED
|
||||||
|
Subnets:
|
||||||
|
- !Ref ListenerSubnet
|
||||||
|
SecurityGroups:
|
||||||
|
- !Ref ListenerSecurityGroup
|
||||||
|
|
||||||
|
ListenerSubnet:
|
||||||
|
Type: AWS::EC2::Subnet
|
||||||
|
Properties:
|
||||||
|
VpcId: !Ref ListenerVpc
|
||||||
|
CidrBlock: 10.30.0.0/24
|
||||||
|
MapPublicIpOnLaunch: true
|
||||||
|
AvailabilityZone: !Select [0, !GetAZs ""]
|
||||||
|
Tags:
|
||||||
|
- Key: Name
|
||||||
|
Value: exec-aide-listener
|
||||||
|
|
||||||
|
ListenerVpc:
|
||||||
|
Type: AWS::EC2::VPC
|
||||||
|
Properties:
|
||||||
|
CidrBlock: 10.30.0.0/16
|
||||||
|
EnableDnsHostnames: true
|
||||||
|
EnableDnsSupport: true
|
||||||
|
Tags:
|
||||||
|
- Key: Name
|
||||||
|
Value: exec-aide
|
||||||
|
|
||||||
|
ListenerIgw:
|
||||||
|
Type: AWS::EC2::InternetGateway
|
||||||
|
Properties:
|
||||||
|
Tags:
|
||||||
|
- Key: Name
|
||||||
|
Value: exec-aide
|
||||||
|
|
||||||
|
ListenerIgwAttachment:
|
||||||
|
Type: AWS::EC2::VPCGatewayAttachment
|
||||||
|
Properties:
|
||||||
|
VpcId: !Ref ListenerVpc
|
||||||
|
InternetGatewayId: !Ref ListenerIgw
|
||||||
|
|
||||||
|
ListenerRouteTable:
|
||||||
|
Type: AWS::EC2::RouteTable
|
||||||
|
Properties:
|
||||||
|
VpcId: !Ref ListenerVpc
|
||||||
|
|
||||||
|
ListenerRoute:
|
||||||
|
Type: AWS::EC2::Route
|
||||||
|
DependsOn: ListenerIgwAttachment
|
||||||
|
Properties:
|
||||||
|
RouteTableId: !Ref ListenerRouteTable
|
||||||
|
DestinationCidrBlock: 0.0.0.0/0
|
||||||
|
GatewayId: !Ref ListenerIgw
|
||||||
|
|
||||||
|
ListenerSubnetRouteTableAssoc:
|
||||||
|
Type: AWS::EC2::SubnetRouteTableAssociation
|
||||||
|
Properties:
|
||||||
|
SubnetId: !Ref ListenerSubnet
|
||||||
|
RouteTableId: !Ref ListenerRouteTable
|
||||||
|
|
||||||
|
ListenerSecurityGroup:
|
||||||
|
Type: AWS::EC2::SecurityGroup
|
||||||
|
Properties:
|
||||||
|
GroupDescription: exec-aide listener — outbound only
|
||||||
|
VpcId: !Ref ListenerVpc
|
||||||
|
SecurityGroupEgress:
|
||||||
|
- IpProtocol: "-1"
|
||||||
|
CidrIp: 0.0.0.0/0
|
||||||
|
|
||||||
|
ListenerExecutionRole:
|
||||||
|
Type: AWS::IAM::Role
|
||||||
|
Properties:
|
||||||
|
RoleName: exec-aide-listener-execution
|
||||||
|
AssumeRolePolicyDocument:
|
||||||
|
Version: "2012-10-17"
|
||||||
|
Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Principal:
|
||||||
|
Service: ecs-tasks.amazonaws.com
|
||||||
|
Action: sts:AssumeRole
|
||||||
|
ManagedPolicyArns:
|
||||||
|
- arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
|
||||||
|
|
||||||
|
ListenerTaskRole:
|
||||||
|
Type: AWS::IAM::Role
|
||||||
|
Properties:
|
||||||
|
RoleName: exec-aide-listener-task
|
||||||
|
AssumeRolePolicyDocument:
|
||||||
|
Version: "2012-10-17"
|
||||||
|
Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Principal:
|
||||||
|
Service: ecs-tasks.amazonaws.com
|
||||||
|
Action: sts:AssumeRole
|
||||||
|
Policies:
|
||||||
|
- PolicyName: ExecAideListenerAccess
|
||||||
|
PolicyDocument:
|
||||||
|
Version: "2012-10-17"
|
||||||
|
Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- secretsmanager:GetSecretValue
|
||||||
|
Resource:
|
||||||
|
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:exec-aide/slack-credentials-*
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- ssm:GetParametersByPath
|
||||||
|
- ssm:GetParameter
|
||||||
|
Resource:
|
||||||
|
- !Sub arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/exec-aide/*
|
||||||
|
- Effect: Allow
|
||||||
|
Action:
|
||||||
|
- dynamodb:GetItem
|
||||||
|
- dynamodb:PutItem
|
||||||
|
- dynamodb:UpdateItem
|
||||||
|
- dynamodb:Query
|
||||||
|
Resource:
|
||||||
|
- !GetAtt ExecAideTable.Arn
|
||||||
|
- !Sub ${ExecAideTable.Arn}/index/*
|
||||||
|
|
||||||
|
ListenerEcrRepo:
|
||||||
|
Type: AWS::ECR::Repository
|
||||||
|
Properties:
|
||||||
|
RepositoryName: exec-aide-listener
|
||||||
|
ImageScanningConfiguration:
|
||||||
|
ScanOnPush: true
|
||||||
|
LifecyclePolicy:
|
||||||
|
LifecyclePolicyText: |
|
||||||
|
{
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"rulePriority": 1,
|
||||||
|
"description": "Keep last 5 images",
|
||||||
|
"selection": {
|
||||||
|
"tagStatus": "any",
|
||||||
|
"countType": "imageCountMoreThan",
|
||||||
|
"countNumber": 5
|
||||||
|
},
|
||||||
|
"action": { "type": "expire" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── EventBridge Scheduler (daily digest, DST-aware) ──────
|
||||||
|
|
||||||
|
DailyDigestSchedule:
|
||||||
|
Type: AWS::Scheduler::Schedule
|
||||||
|
Properties:
|
||||||
|
Name: exec-aide-daily-digest
|
||||||
|
Description: Daily 5 PM ET inbox digest
|
||||||
|
ScheduleExpression: cron(0 17 ? * MON-FRI *)
|
||||||
|
ScheduleExpressionTimezone: America/New_York
|
||||||
|
FlexibleTimeWindow:
|
||||||
|
Mode: "OFF"
|
||||||
|
State: ENABLED
|
||||||
|
Target:
|
||||||
|
Arn: !GetAtt DailyDigestFunction.Arn
|
||||||
|
RoleArn: !GetAtt DailyDigestSchedulerRole.Arn
|
||||||
|
|
||||||
|
DailyDigestSchedulerRole:
|
||||||
|
Type: AWS::IAM::Role
|
||||||
|
Properties:
|
||||||
|
RoleName: exec-aide-digest-scheduler
|
||||||
|
AssumeRolePolicyDocument:
|
||||||
|
Version: "2012-10-17"
|
||||||
|
Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Principal:
|
||||||
|
Service: scheduler.amazonaws.com
|
||||||
|
Action: sts:AssumeRole
|
||||||
|
Policies:
|
||||||
|
- PolicyName: InvokeLambda
|
||||||
|
PolicyDocument:
|
||||||
|
Version: "2012-10-17"
|
||||||
|
Statement:
|
||||||
|
- Effect: Allow
|
||||||
|
Action: lambda:InvokeFunction
|
||||||
|
Resource: !GetAtt DailyDigestFunction.Arn
|
||||||
|
|
||||||
|
DailyDigestSchedulePermission:
|
||||||
|
Type: AWS::Lambda::Permission
|
||||||
|
Properties:
|
||||||
|
FunctionName: !Ref DailyDigestFunction
|
||||||
|
Action: lambda:InvokeFunction
|
||||||
|
Principal: scheduler.amazonaws.com
|
||||||
|
SourceArn: !GetAtt DailyDigestSchedule.Arn
|
||||||
|
|
||||||
|
Outputs:
|
||||||
|
FetchClassifyFunctionArn:
|
||||||
|
Description: Fetch & classify Lambda ARN
|
||||||
|
Value: !GetAtt FetchClassifyFunction.Arn
|
||||||
|
DailyDigestFunctionArn:
|
||||||
|
Description: Daily digest Lambda ARN
|
||||||
|
Value: !GetAtt DailyDigestFunction.Arn
|
||||||
|
ExecAideTableName:
|
||||||
|
Description: DynamoDB table name
|
||||||
|
Value: !Ref ExecAideTable
|
||||||
|
ListenerEcrRepoUri:
|
||||||
|
Description: ECR repo for the Socket Mode listener
|
||||||
|
Value: !GetAtt ListenerEcrRepo.RepositoryUri
|
||||||
|
EcsClusterName:
|
||||||
|
Description: ECS cluster name
|
||||||
|
Value: !Ref EcsCluster
|
||||||
Reference in a new issue