apm-wo-analysis/lambdas/slack_post/interactions.py
Adam Moussa c3a2c936d1 Add Slack post + interactions Lambdas with drill-down modals (Phase 4)
Two push surfaces (no App Home) + interactive drill-down, per CLAUDE.md.

Block Kit (blockkit.py, pure/offline): build_daily_summary (header, vs-yesterday
deltas, escalation breakdown with 3rd highlighted, action/routine, top sites,
mismatch callout, category drill buttons + 📊 Open dashboard link, footer),
build_escalation_alert (one @here, returns None on zero-3rd — suppression), and
build_wo_modal (views.open payload, capped under Slack's 100-block limit).

Lambdas: slack_post/handler.py (classifier-invoked: read today/yesterday
summary.json, post daily summary, conditionally post the batched alert from
details.json) and slack_post/interactions.py (API Gateway: verify Slack
signature, filter details.json, views.open the WO modal within the 3s trigger_id
window). slackio.py centralizes Secrets Manager creds, the SSM dashboard URL,
signature verification, and analytics/ reads — keeping blockkit pure.

Classifier: emit analytics/dt=*/details.json (per-WO index for the modals) and
async-invoke slack-post after the snapshot write (best-effort; a Slack failure
never fails classification).

CDK: slack-post + interactions Lambdas (Docker-bundled slack_sdk), HTTP API on
apm-wo.seahaven.com (wildcard ACM cert + Route53 alias; signature-verified, so
the route is unauthenticated by design), SSM /apm-wo-analysis/grafana-base-url,
and scoped IAM (read analytics/, read the Slack secret + dashboard param;
classifier granted lambda:InvokeFunction on slack-post). Slack creds live in one
Secrets Manager secret apm-wo-analysis/slack-credentials {botToken, signingSecret,
channelId}; cdk.json gains cert/zone/domain context.

WO drill-downs link to Grafana only — no APM deep-links (per decision).

Deliverables for test time: slack/manifest.yaml (app manifest, interactivity
request_url = apm-wo.seahaven.com).

Tests: tests/test_blockkit.py (30 offline cases — deltas, zero-3rd None, <100
blocks under large inputs, modal truncation/overflow, dashboard URL) and Phase 4
assertions in test_pipeline_synth.py (both Lambdas, the API route/domain/alias,
and no broad/write IAM on the Slack roles). 49/49 tests pass; cdk synth green.
2026-05-28 17:48:51 -04:00

69 lines
2.4 KiB
Python

"""Slack interactivity Lambda: drill-down modals behind API Gateway.
Fronted by the ``apm-wo.seahaven.com`` HTTP API (Slack interactivity request URL).
Verifies the Slack request signature, then for a category/site drill button reads
the day's ``details.json``, filters it, and opens a WO-list modal via ``views.open``
within Slack's 3-second ``trigger_id`` window. WO rows link to Grafana only — no
APM deep-links (see Phase 4 decisions).
"""
from __future__ import annotations
import base64
import json
from datetime import datetime, timezone
from urllib.parse import parse_qs
import blockkit
import slackio
_FILTER_FIELD = {"drill_category": "category", "drill_site": "site"}
def _raw_body(event: dict) -> str:
body = event.get("body") or ""
if event.get("isBase64Encoded"):
body = base64.b64decode(body).decode("utf-8")
return body
def _headers(event: dict) -> dict:
# API Gateway HTTP API lowercases header names; be defensive either way.
return {k.lower(): v for k, v in (event.get("headers") or {}).items()}
def handler(event, context):
"""Verify the Slack signature and open the requested drill-down modal."""
body = _raw_body(event)
headers = _headers(event)
timestamp = headers.get("x-slack-request-timestamp", "")
signature = headers.get("x-slack-signature", "")
if not slackio.verify_signature(body, timestamp, signature):
print("Rejected: invalid Slack signature.")
return {"statusCode": 401, "body": "invalid signature"}
parsed = parse_qs(body)
payload = json.loads(parsed.get("payload", ["{}"])[0])
if payload.get("type") != "block_actions":
return {"statusCode": 200, "body": ""}
action = (payload.get("actions") or [{}])[0]
field = _FILTER_FIELD.get(action.get("action_id"))
value = action.get("value")
trigger_id = payload.get("trigger_id")
if not field or not value or not trigger_id:
return {"statusCode": 200, "body": ""}
# The daily post is same-day; default the drill to today's snapshot.
dt = datetime.now(timezone.utc).strftime("%Y-%m-%d")
details = slackio.read_analytics_json(dt, "details.json") or []
wos = [d for d in details if d.get(field) == value]
view = blockkit.build_wo_modal(
title=f"{value} ({len(wos)})",
wos=wos,
dashboard_url=slackio.get_dashboard_url(),
)
slackio.web_client().views_open(trigger_id=trigger_id, view=view)
return {"statusCode": 200, "body": ""}