From 9cb5f416c15e0e8471c8a03717be867dd5b1d91a Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 8 May 2026 15:41:27 -0400 Subject: [PATCH 1/2] Add CI workflow and apply ruff formatting --- .github/workflows/ci.yaml | 13 +++++++ src/conversation/app.py | 47 +++++++++++++++++--------- src/conversation/tools.py | 59 +++++++++++++++++++++----------- src/daily_digest/app.py | 7 ++-- src/fetch_classify/app.py | 61 +++++++++++++++++++++------------ src/reminder/app.py | 1 + src/shared/calendar.py | 20 ++++++----- src/shared/classify.py | 5 ++- src/shared/dynamo.py | 71 ++++++++++++++++++++------------------- src/shared/gmail.py | 1 - src/shared/secrets.py | 12 +++---- src/shared/slack.py | 70 +++++++++++++++++++++++++------------- 12 files changed, 233 insertions(+), 134 deletions(-) create mode 100644 .github/workflows/ci.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..820157a --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,13 @@ +name: CI +on: + pull_request: + branches: [main] + +jobs: + python: + uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main + with: + source-dirs: "src" + run-sam-validate: false + typescript: + uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main diff --git a/src/conversation/app.py b/src/conversation/app.py index bc43fce..67ffad1 100644 --- a/src/conversation/app.py +++ b/src/conversation/app.py @@ -4,6 +4,11 @@ from decimal import Decimal import boto3 +from conversation.tools import TOOLS, dispatch_tool, set_tool_context +from shared.dynamo import get_conversation, save_conversation +from shared.secrets import get_slack_token +from shared.slack import update_message, post_message + class _DecimalEncoder(json.JSONEncoder): def default(self, o): @@ -11,10 +16,6 @@ class _DecimalEncoder(json.JSONEncoder): return int(o) if o == int(o) else float(o) return super().default(o) -from shared.dynamo import get_conversation, save_conversation -from shared.secrets import get_slack_token -from shared.slack import update_message, post_message -from conversation.tools import TOOLS, dispatch_tool, set_tool_context logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -99,15 +100,21 @@ def lambda_handler(event, context): if placeholder_ts: update_message(channel, placeholder_ts, final_text, token) elif coordination: - post_message(channel, final_text, token, - thread_ts=coordination["coord_thread_ts"]) + post_message( + channel, final_text, token, thread_ts=coordination["coord_thread_ts"] + ) elif thread_ts: post_message(channel, final_text, token, thread_ts=thread_ts) else: post_message(channel, final_text, token) - logger.info("Conversation complete: channel=%s, thread=%s, turns=%d, is_dm=%s", - channel, thread_ts, len(messages), is_dm) + logger.info( + "Conversation complete: channel=%s, thread=%s, turns=%d, is_dm=%s", + channel, + thread_ts, + len(messages), + is_dm, + ) except Exception: logger.exception("Conversation failed for thread %s", thread_ts) try: @@ -143,14 +150,20 @@ def _run_conversation(messages, system): for block in assistant_message["content"]: tool_use = block.get("toolUse") if tool_use: - logger.info("Tool call: %s(%s)", tool_use["name"], json.dumps(tool_use["input"])) + logger.info( + "Tool call: %s(%s)", tool_use["name"], json.dumps(tool_use["input"]) + ) result = dispatch_tool(tool_use["name"], tool_use["input"]) - tool_results.append({ - "toolResult": { - "toolUseId": tool_use["toolUseId"], - "content": [{"text": json.dumps(result, cls=_DecimalEncoder)}], + tool_results.append( + { + "toolResult": { + "toolUseId": tool_use["toolUseId"], + "content": [ + {"text": json.dumps(result, cls=_DecimalEncoder)} + ], + } } - }) + ) messages.append({"role": "user", "content": tool_results}) return assistant_message @@ -161,4 +174,8 @@ def _extract_text(message): for block in message.get("content", []): if "text" in block: parts.append(block["text"]) - return "\n".join(parts) if parts else "I processed your request but have nothing to report." + return ( + "\n".join(parts) + if parts + else "I processed your request but have nothing to report." + ) diff --git a/src/conversation/tools.py b/src/conversation/tools.py index b3cdf85..0cf04a7 100644 --- a/src/conversation/tools.py +++ b/src/conversation/tools.py @@ -6,8 +6,13 @@ from datetime import datetime, timezone import boto3 from shared.dynamo import ( - get_todays_messages, get_unanswered_threads, save_coordination, - create_task, get_tasks, update_task_status, delete_task, + get_todays_messages, + get_unanswered_threads, + save_coordination, + create_task, + get_tasks, + update_task_status, + delete_task, ) from shared.gmail import get_authenticated_service, fetch_thread from shared.secrets import get_config, get_slack_token @@ -536,26 +541,38 @@ def _handle_search_inbox(inputs): query = inputs["query"] max_results = inputs.get("max_results", 10) - resp = service.users().messages().list( - userId="me", q=query, maxResults=max_results - ).execute() + resp = ( + service.users() + .messages() + .list(userId="me", q=query, maxResults=max_results) + .execute() + ) messages = [] for msg_meta in resp.get("messages", []): - msg = service.users().messages().get( - userId="me", id=msg_meta["id"], format="metadata", - metadataHeaders=["From", "To", "Subject", "Date"], - ).execute() + msg = ( + service.users() + .messages() + .get( + userId="me", + id=msg_meta["id"], + format="metadata", + metadataHeaders=["From", "To", "Subject", "Date"], + ) + .execute() + ) headers = {h["name"]: h["value"] for h in msg["payload"]["headers"]} - messages.append({ - "id": msg["id"], - "thread_id": msg["threadId"], - "from": headers.get("From", ""), - "to": headers.get("To", ""), - "subject": headers.get("Subject", ""), - "date": headers.get("Date", ""), - "snippet": msg.get("snippet", ""), - }) + messages.append( + { + "id": msg["id"], + "thread_id": msg["threadId"], + "from": headers.get("From", ""), + "to": headers.get("To", ""), + "subject": headers.get("Subject", ""), + "date": headers.get("Date", ""), + "snippet": msg.get("snippet", ""), + } + ) return {"query": query, "count": len(messages), "messages": messages} @@ -668,8 +685,10 @@ def _handle_get_calendar_events(inputs): { "id": e.get("id", ""), "summary": e.get("summary", "(no title)"), - "start": e.get("start", {}).get("dateTime") or e.get("start", {}).get("date", ""), - "end": e.get("end", {}).get("dateTime") or e.get("end", {}).get("date", ""), + "start": e.get("start", {}).get("dateTime") + or e.get("start", {}).get("date", ""), + "end": e.get("end", {}).get("dateTime") + or e.get("end", {}).get("date", ""), "attendees": [a.get("email", "") for a in e.get("attendees", [])], "location": e.get("location", ""), "status": e.get("status", ""), diff --git a/src/daily_digest/app.py b/src/daily_digest/app.py index 3c154fd..653f0c1 100644 --- a/src/daily_digest/app.py +++ b/src/daily_digest/app.py @@ -28,7 +28,8 @@ def lambda_handler(event, context): 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", "")) + and m["internal_date"] + > _iso_to_epoch_ms(thread.get("unanswered_since", "")) for m in thread_msgs ) if adam_replied_after: @@ -42,7 +43,9 @@ def lambda_handler(event, context): 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) + blocks = build_daily_digest( + high_items, bypassed, unanswered, normal_count, low_count + ) send_dm( config["adam_slack_user_id"], blocks, diff --git a/src/fetch_classify/app.py b/src/fetch_classify/app.py index 53d4a82..b7d65a4 100644 --- a/src/fetch_classify/app.py +++ b/src/fetch_classify/app.py @@ -2,14 +2,26 @@ 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.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() @@ -47,27 +59,34 @@ def lambda_handler(event, context): 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 "" + 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_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 diff --git a/src/reminder/app.py b/src/reminder/app.py index 974d5e9..00d0c62 100644 --- a/src/reminder/app.py +++ b/src/reminder/app.py @@ -13,6 +13,7 @@ def lambda_handler(event, context): token = get_slack_token() import requests + resp = requests.post( "https://slack.com/api/conversations.open", headers={"Authorization": f"Bearer {token}"}, diff --git a/src/shared/calendar.py b/src/shared/calendar.py index 029b78a..c4fb3cf 100644 --- a/src/shared/calendar.py +++ b/src/shared/calendar.py @@ -38,14 +38,18 @@ def get_calendar_service(): def list_events(service, time_min, time_max, max_results=10): - result = service.events().list( - calendarId="primary", - timeMin=time_min, - timeMax=time_max, - maxResults=max_results, - singleEvents=True, - orderBy="startTime", - ).execute() + result = ( + service.events() + .list( + calendarId="primary", + timeMin=time_min, + timeMax=time_max, + maxResults=max_results, + singleEvents=True, + orderBy="startTime", + ) + .execute() + ) return result.get("items", []) diff --git a/src/shared/classify.py b/src/shared/classify.py index 45dc723..ff1db82 100644 --- a/src/shared/classify.py +++ b/src/shared/classify.py @@ -62,7 +62,7 @@ def classify_email(message, vip_senders, vip_domains): inferenceConfig={"temperature": 0.0, "maxTokens": 256}, ) raw = resp["output"]["message"]["content"][0]["text"] - match = re.search(r'\{[^{}]*\}', raw) + match = re.search(r"\{[^{}]*\}", raw) result = json.loads(match.group()) if match else json.loads(raw) return { "classification": result.get("classification", "NORMAL"), @@ -75,7 +75,6 @@ def classify_email(message, vip_senders, vip_domains): def check_bypassed_workorder(message, work_order_addresses): all_recipients = [ - addr.lower() - for addr in message["to_emails"] + message["cc_emails"] + addr.lower() for addr in message["to_emails"] + message["cc_emails"] ] return not any(wo in all_recipients for wo in work_order_addresses) diff --git a/src/shared/dynamo.py b/src/shared/dynamo.py index 8af3b20..e5960e5 100644 --- a/src/shared/dynamo.py +++ b/src/shared/dynamo.py @@ -101,9 +101,7 @@ def save_thread_state(thread_id, message, adam_email): def get_sync_metadata(): - resp = _get_table().get_item( - Key={"pk": "META#sync", "sk": "META"} - ) + resp = _get_table().get_item(Key={"pk": "META#sync", "sk": "META"}) return resp.get("Item") @@ -139,34 +137,36 @@ def get_todays_messages(date_str): def get_conversation(thread_ts): - resp = _get_table().get_item( - Key={"pk": f"CONV#{thread_ts}", "sk": "CONV"} - ) + resp = _get_table().get_item(Key={"pk": f"CONV#{thread_ts}", "sk": "CONV"}) item = resp.get("Item") return item["messages"] if item else [] def save_conversation(thread_ts, messages): - _get_table().put_item(Item={ - "pk": f"CONV#{thread_ts}", - "sk": "CONV", - "messages": messages, - "updated_at": datetime.now(timezone.utc).isoformat(), - "ttl": _ttl_90_days(), - }) + _get_table().put_item( + Item={ + "pk": f"CONV#{thread_ts}", + "sk": "CONV", + "messages": messages, + "updated_at": datetime.now(timezone.utc).isoformat(), + "ttl": _ttl_90_days(), + } + ) def save_coordination(channel, coord_thread_ts, conversation_thread_ts, started_by): - _get_table().put_item(Item={ - "pk": f"COORD#{channel}#{coord_thread_ts}", - "sk": "COORD", - "conversation_thread_ts": conversation_thread_ts, - "channel": channel, - "started_by": started_by, - "status": "active", - "created_at": datetime.now(timezone.utc).isoformat(), - "ttl": int(time.time()) + (7 * 24 * 60 * 60), - }) + _get_table().put_item( + Item={ + "pk": f"COORD#{channel}#{coord_thread_ts}", + "sk": "COORD", + "conversation_thread_ts": conversation_thread_ts, + "channel": channel, + "started_by": started_by, + "status": "active", + "created_at": datetime.now(timezone.utc).isoformat(), + "ttl": int(time.time()) + (7 * 24 * 60 * 60), + } + ) def get_coordination(channel, coord_thread_ts): @@ -203,7 +203,8 @@ def get_unanswered_threads(threshold_hours): items.extend(resp.get("Items", [])) return [ - item for item in items + item + for item in items if datetime.fromisoformat(item["unanswered_since"]).timestamp() < cutoff ] @@ -213,16 +214,18 @@ def get_unanswered_threads(threshold_hours): def create_task(title, due_date=None): task_id = str(ulid.new()) - _get_table().put_item(Item={ - "pk": f"TASK#{task_id}", - "sk": "TASK", - "title": title, - "due_date": due_date or "", - "status": "open", - "created_at": datetime.now(timezone.utc).isoformat(), - "completed_at": "", - "ttl": _ttl_90_days(), - }) + _get_table().put_item( + Item={ + "pk": f"TASK#{task_id}", + "sk": "TASK", + "title": title, + "due_date": due_date or "", + "status": "open", + "created_at": datetime.now(timezone.utc).isoformat(), + "completed_at": "", + "ttl": _ttl_90_days(), + } + ) return task_id diff --git a/src/shared/gmail.py b/src/shared/gmail.py index 39f8c1b..c08c899 100644 --- a/src/shared/gmail.py +++ b/src/shared/gmail.py @@ -1,6 +1,5 @@ import logging import re -from datetime import datetime, timezone import google.auth.transport.requests from google.oauth2.credentials import Credentials diff --git a/src/shared/secrets.py b/src/shared/secrets.py index 4e4e6f1..488c853 100644 --- a/src/shared/secrets.py +++ b/src/shared/secrets.py @@ -14,9 +14,7 @@ _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"] - ) + resp = _secrets_client.get_secret_value(SecretId=os.environ["SECRET_GMAIL"]) _gmail_oauth = json.loads(resp["SecretString"]) return _gmail_oauth @@ -37,9 +35,7 @@ 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"] - ) + resp = _secrets_client.get_secret_value(SecretId=os.environ["SECRET_SLACK"]) _slack_creds = json.loads(resp["SecretString"]) return _slack_creds @@ -67,6 +63,8 @@ def get_config(): "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")), + "unanswered_threshold_hours": int( + params.get("unanswered-threshold-hours", "24") + ), } return _config diff --git a/src/shared/slack.py b/src/shared/slack.py index 1a72649..69f8a38 100644 --- a/src/shared/slack.py +++ b/src/shared/slack.py @@ -1,4 +1,3 @@ -import json import logging from datetime import datetime, timezone @@ -46,23 +45,35 @@ def build_high_priority_alert(message): return [ { "type": "header", - "text": {"type": "plain_text", "text": ":rotating_light: High-Priority Email"}, + "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"*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]}"}, + "text": { + "type": "mrkdwn", + "text": f"*{message['subject']}*\n{message['snippet'][:300]}", + }, }, { "type": "context", "elements": [ - {"type": "mrkdwn", "text": f":brain: _{message.get('classification_reason', '')}_"}, + { + "type": "mrkdwn", + "text": f":brain: _{message.get('classification_reason', '')}_", + }, ], }, { @@ -90,7 +101,10 @@ def build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count { "type": "context", "elements": [ - {"type": "mrkdwn", "text": f"{today_str} · {total} emails processed today"}, + { + "type": "mrkdwn", + "text": f"{today_str} · {total} emails processed today", + }, ], }, {"type": "divider"}, @@ -108,7 +122,9 @@ def build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count ) if len(high_items) > max_items: lines.append(f" _...and {len(high_items) - max_items} more_") - blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}) + blocks.append( + {"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}} + ) blocks.append({"type": "divider"}) if bypassed: @@ -117,10 +133,14 @@ def build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count "_Sent directly to you without CC'ing work-orders@_", ] for item in bypassed[:max_items]: - lines.append(f" · *{item['subject']}* from {item.get('from_name') or item['from_email']}") + lines.append( + f" · *{item['subject']}* from {item.get('from_name') or item['from_email']}" + ) if len(bypassed) > max_items: lines.append(f" _...and {len(bypassed) - max_items} more_") - blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}) + blocks.append( + {"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}} + ) blocks.append({"type": "divider"}) if unanswered: @@ -137,22 +157,26 @@ def build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count ) if len(unanswered) > max_items: lines.append(f" _...and {len(unanswered) - max_items} more_") - blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}) + blocks.append( + {"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}} + ) 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" - ), - } - ], - }) + 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 -- 2.50.1 From ff9aec3c355fca9ee55d09ff39eb7363ea0c9326 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 8 May 2026 16:00:07 -0400 Subject: [PATCH 2/2] =?UTF-8?q?Disable=20cdk=20synth=20=E2=80=94=20PythonF?= =?UTF-8?q?unction=20requires=20Docker=20arm64=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PythonFunction construct bundles via Docker with --platform linux/arm64, which fails on GitHub Actions x86 runners. tsc --noEmit still runs and catches TypeScript errors. --- .github/workflows/ci.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 820157a..f5d488d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,3 +11,5 @@ jobs: run-sam-validate: false typescript: uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main + with: + run-cdk-synth: false -- 2.50.1