apm-wo-analysis/lambdas/slack_post/slackio.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

70 lines
2.3 KiB
Python

"""Shared Slack + AWS I/O for the post and interactions Lambdas.
Keeps the Block Kit builders (blockkit.py) pure: everything that touches the
network or AWS lives here. Slack credentials are a single Secrets Manager secret
``{ botToken, signingSecret, channelId }``; the Grafana dashboard URL is
operational config in SSM (editable without a redeploy). Both are cached for the
life of the execution environment.
"""
from __future__ import annotations
import json
import os
import boto3
from slack_sdk import WebClient
from slack_sdk.signature import SignatureVerifier
_secrets = boto3.client("secretsmanager")
_ssm = boto3.client("ssm")
_s3 = boto3.client("s3")
_SECRET_NAME = os.environ["SLACK_SECRET_NAME"]
_DASHBOARD_PARAM = os.environ["DASHBOARD_URL_PARAM"]
_BUCKET = os.environ["ANALYTICS_BUCKET"]
# Lazily-populated caches (warm across invocations in the same container).
_creds: dict | None = None
_dashboard_url: str | None = None
def get_credentials() -> dict:
"""Return the Slack creds dict: ``botToken``, ``signingSecret``, ``channelId``."""
global _creds
if _creds is None:
raw = _secrets.get_secret_value(SecretId=_SECRET_NAME)["SecretString"]
_creds = json.loads(raw)
return _creds
def get_dashboard_url() -> str:
"""Grafana dashboard URL for the 📊 button / modal overflow links (SSM)."""
global _dashboard_url
if _dashboard_url is None:
_dashboard_url = _ssm.get_parameter(Name=_DASHBOARD_PARAM)["Parameter"]["Value"]
return _dashboard_url
def web_client() -> WebClient:
return WebClient(token=get_credentials()["botToken"])
def channel_id() -> str:
return get_credentials()["channelId"]
def verify_signature(body: str, timestamp: str, signature: str) -> bool:
"""Validate a Slack request signature (HMAC + 5-minute replay window)."""
verifier = SignatureVerifier(signing_secret=get_credentials()["signingSecret"])
return verifier.is_valid(body=body, timestamp=timestamp, signature=signature)
def read_analytics_json(dt: str, name: str):
"""Read ``analytics/dt=<dt>/<name>`` as JSON, or None if absent."""
key = f"analytics/dt={dt}/{name}"
try:
obj = _s3.get_object(Bucket=_BUCKET, Key=key)
except _s3.exceptions.NoSuchKey:
return None
return json.loads(obj["Body"].read())