From c3a2c936d1c367af1e4b30fb657f63f813a3a0c3 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Thu, 28 May 2026 17:48:51 -0400 Subject: [PATCH] Add Slack post + interactions Lambdas with drill-down modals (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cdk/cdk.json | 6 +- cdk/stacks/pipeline_stack.py | 149 +++++++++- lambdas/classifier/handler.py | 50 +++- lambdas/slack_post/blockkit.py | 424 +++++++++++++++++++++++++++- lambdas/slack_post/handler.py | 59 +++- lambdas/slack_post/interactions.py | 69 +++++ lambdas/slack_post/slackio.py | 70 +++++ slack/manifest.yaml | 41 +++ tests/test_blockkit.py | 439 +++++++++++++++++++++++++++++ tests/test_pipeline_synth.py | 81 +++++- 10 files changed, 1361 insertions(+), 27 deletions(-) create mode 100644 lambdas/slack_post/interactions.py create mode 100644 lambdas/slack_post/slackio.py create mode 100644 slack/manifest.yaml create mode 100644 tests/test_blockkit.py diff --git a/cdk/cdk.json b/cdk/cdk.json index f28e013..164a462 100644 --- a/cdk/cdk.json +++ b/cdk/cdk.json @@ -1,6 +1,10 @@ { "app": "python3 app.py", "context": { - "@aws-cdk/core:bootstrapQualifier": "hnb659fds" + "@aws-cdk/core:bootstrapQualifier": "hnb659fds", + "wildcardCertArn": "arn:aws:acm:us-east-1:328440206208:certificate/a66c0994-90d4-410a-a1d9-5595c2a3fae3", + "hostedZoneId": "Z06652411XKH89KTZD3XA", + "hostedZoneName": "seahaven.com", + "slackInteractionsDomain": "apm-wo.seahaven.com" } } diff --git a/cdk/stacks/pipeline_stack.py b/cdk/stacks/pipeline_stack.py index a258317..8b3a36e 100644 --- a/cdk/stacks/pipeline_stack.py +++ b/cdk/stacks/pipeline_stack.py @@ -2,8 +2,8 @@ Phase 1 — exports bucket + drop-folder uploader IAM user. Phase 2 — Glue database + S3-triggered classifier Lambda. -Phase 3 — partition-projection Glue table + Athena workgroup (this file). -Phase 4 — slack-post Lambda + scoped IAM (TODO). +Phase 3 — partition-projection Glue table + Athena workgroup. +Phase 4 — slack-post + interactions Lambdas, API Gateway, scoped IAM (this file). Lambdas: Python 3.12, ARM64, explicit LogGroup with 60-day retention. No DynamoDB — this is an S3 + Athena analytics workload (see CLAUDE.md). @@ -17,9 +17,18 @@ from aws_cdk import ( RemovalPolicy, Stack, ) +from aws_cdk import ( + aws_apigatewayv2 as apigwv2, +) +from aws_cdk import ( + aws_apigatewayv2_integrations as apigwv2_integrations, +) from aws_cdk import ( aws_athena as athena, ) +from aws_cdk import ( + aws_certificatemanager as acm, +) from aws_cdk import ( aws_glue as glue, ) @@ -32,6 +41,12 @@ from aws_cdk import ( from aws_cdk import ( aws_logs as logs, ) +from aws_cdk import ( + aws_route53 as route53, +) +from aws_cdk import ( + aws_route53_targets as route53_targets, +) from aws_cdk import ( aws_s3 as s3, ) @@ -41,12 +56,20 @@ from aws_cdk import ( from aws_cdk import ( aws_secretsmanager as secretsmanager, ) +from aws_cdk import ( + aws_ssm as ssm, +) from constructs import Construct GLUE_DATABASE = "apm_wo_analysis" GLUE_TABLE = "apm_wo_snapshots" ATHENA_WORKGROUP = "apm-wo-analysis" ANTHROPIC_SECRET = "apm-wo-analysis/anthropic-api-key" +SLACK_SECRET = "apm-wo-analysis/slack-credentials" +GRAFANA_URL_PARAM = "/apm-wo-analysis/grafana-base-url" +GRAFANA_DASHBOARD_URL = ( + "https://grafana.seahaven.com/d/apm-wo/apm-work-orders?from=now-30d&to=now" +) LAMBDAS_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "lambdas") # Snapshot schema — mirrors the per-WO record written by the classifier @@ -241,4 +264,124 @@ class PipelineStack(Stack): self, "AnthropicKey", ANTHROPIC_SECRET ).grant_read(self.classifier_fn) - # Phase 4 — slack-post Lambda + scoped IAM; classifier async-invokes it. TODO + # ----- Phase 4 — Slack post + interactions Lambdas ----- + # Reused Slack app credentials (botToken/signingSecret/channelId) live in + # one Secrets Manager secret, created out-of-band like the Anthropic key. + slack_secret = secretsmanager.Secret.from_secret_name_v2( + self, "SlackCreds", SLACK_SECRET + ) + # Grafana dashboard URL is operational config — SSM so ops can repoint the + # 📊 button / modal links without a redeploy. The d/apm-wo slug is the + # forward contract Phase 5's dashboard must honor. + dashboard_param = ssm.StringParameter( + self, + "GrafanaUrlParam", + parameter_name=GRAFANA_URL_PARAM, + string_value=GRAFANA_DASHBOARD_URL, + ) + + slack_env = { + "SLACK_SECRET_NAME": SLACK_SECRET, + "DASHBOARD_URL_PARAM": GRAFANA_URL_PARAM, + "ANALYTICS_BUCKET": self.exports_bucket.bucket_name, + } + + def _slack_lambda(construct_id: str, fn_name: str, handler_path: str): + """A slack_post-package Lambda: read analytics/, the Slack secret, and + the dashboard param. slack_sdk is Docker-bundled for ARM64.""" + log_group = logs.LogGroup( + self, + f"{construct_id}Logs", + log_group_name=f"/aws/lambda/{fn_name}", + retention=logs.RetentionDays.TWO_MONTHS, + removal_policy=RemovalPolicy.DESTROY, + ) + fn = lambda_.Function( + self, + construct_id, + function_name=fn_name, + runtime=lambda_.Runtime.PYTHON_3_12, + architecture=lambda_.Architecture.ARM_64, + handler=handler_path, + memory_size=256, + timeout=Duration.seconds(30), + log_group=log_group, + environment=slack_env, + code=lambda_.Code.from_asset( + os.path.join(LAMBDAS_DIR, "slack_post"), + bundling=BundlingOptions( + image=lambda_.Runtime.PYTHON_3_12.bundling_image, + platform="linux/arm64", + command=[ + "bash", + "-c", + "pip install -r requirements.txt -t /asset-output " + "&& cp -au . /asset-output", + ], + ), + ), + ) + self.exports_bucket.grant_read(fn, "analytics/*") + slack_secret.grant_read(fn) + dashboard_param.grant_read(fn) + return fn + + slack_post_fn = _slack_lambda( + "SlackPost", "apm-wo-analysis-slack-post", "handler.handler" + ) + interactions_fn = _slack_lambda( + "SlackInteractions", + "apm-wo-analysis-slack-interactions", + "interactions.handler", + ) + + # Classifier async-invokes slack-post after writing the snapshot. + self.classifier_fn.add_environment( + "SLACK_POST_FUNCTION_NAME", slack_post_fn.function_name + ) + slack_post_fn.grant_invoke(self.classifier_fn) + + # HTTP API for Slack interactivity, on apm-wo.seahaven.com (the request URL + # registered in the Slack app manifest). The interactions Lambda verifies + # the Slack signature itself; the route is intentionally unauthenticated. + cert = acm.Certificate.from_certificate_arn( + self, "WildcardCert", self.node.try_get_context("wildcardCertArn") + ) + slack_domain_name = self.node.try_get_context("slackInteractionsDomain") + slack_domain = apigwv2.DomainName( + self, "SlackDomain", domain_name=slack_domain_name, certificate=cert + ) + slack_api = apigwv2.HttpApi( + self, + "SlackInteractionsApi", + default_domain_mapping=apigwv2.DomainMappingOptions( + domain_name=slack_domain + ), + ) + slack_api.add_routes( + path="/slack/interactions", + methods=[apigwv2.HttpMethod.POST], + integration=apigwv2_integrations.HttpLambdaIntegration( + "InteractionsIntegration", interactions_fn + ), + ) + + # Route53 alias apm-wo.seahaven.com → the API Gateway custom domain. + zone = route53.HostedZone.from_hosted_zone_attributes( + self, + "SeahavenZone", + hosted_zone_id=self.node.try_get_context("hostedZoneId"), + zone_name=self.node.try_get_context("hostedZoneName"), + ) + route53.ARecord( + self, + "SlackDomainAlias", + zone=zone, + record_name=slack_domain_name.split(".")[0], + target=route53.RecordTarget.from_alias( + route53_targets.ApiGatewayv2DomainProperties( + slack_domain.regional_domain_name, + slack_domain.regional_hosted_zone_id, + ) + ), + ) diff --git a/lambdas/classifier/handler.py b/lambdas/classifier/handler.py index ee8f32c..f356cac 100644 --- a/lambdas/classifier/handler.py +++ b/lambdas/classifier/handler.py @@ -51,6 +51,7 @@ COLUMN_MATCHERS = { } _s3 = boto3.client("s3") +_lambda = boto3.client("lambda") def _haiku_enabled() -> bool: @@ -177,6 +178,29 @@ def _build_summary(df: pd.DataFrame, dt: str, key: str, blank: int) -> dict: } +# Comment snippet length for the modal rows — Slack section text caps at 3000 +# chars; keep rows short so a full modal stays well under the block limit. +_SNIPPET_LEN = 280 + + +def _build_details(df: pd.DataFrame) -> list[dict]: + """Per-WO index for the Slack drill-down modals (read from S3 on demand).""" + return [ + { + "wo_number": r.wo_number, + "wo_description": r.wo_description, + "site": r.site, + "department": r.department, + "category": r.category, + "last_comment": r.last_comment[:_SNIPPET_LEN], + "is_escalation": bool(r.is_escalation), + "is_action": bool(r.is_action), + "mismatch": r.mismatch, + } + for r in df.itertuples() + ] + + def handler(event, context): """Classify each export dropped under raw/ into a daily Parquet snapshot.""" record = event["Records"][0] @@ -220,14 +244,38 @@ def handler(event, context): Body=json.dumps(summary, indent=2).encode("utf-8"), ContentType="application/json", ) + # details.json — per-WO index the slack-post Lambda reads for drill-down modals. + _s3.put_object( + Bucket=bucket, + Key=f"{ANALYTICS_PREFIX}/dt={dt}/details.json", + Body=json.dumps(_build_details(df)).encode("utf-8"), + ContentType="application/json", + ) - # Phase 4: async-invoke the slack-post Lambda here once it exists. print( f"Wrote {len(df)} rows, {summary['escalation_total']} escalations " f"({summary['third_escalation_count']} 3rd), {len(summary['mismatches'])} mismatches." ) + _invoke_slack_post(dt) return { "classified": int(len(df)), "dt": dt, "summary_key": f"dt={dt}/summary.json", } + + +def _invoke_slack_post(dt: str) -> None: + """Async-invoke the slack-post Lambda (name from env), if wired. Best-effort: + a Slack failure must not fail the classification that already landed in S3.""" + fn = os.environ.get("SLACK_POST_FUNCTION_NAME") + if not fn: + return + try: + _lambda.invoke( + FunctionName=fn, + InvocationType="Event", + Payload=json.dumps({"dt": dt}).encode("utf-8"), + ) + print(f"Invoked slack-post {fn} for dt={dt}") + except Exception as exc: # noqa: BLE001 — never let Slack break the pipeline + print(f"slack-post invoke failed (non-fatal): {exc}") diff --git a/lambdas/slack_post/blockkit.py b/lambdas/slack_post/blockkit.py index 84f7724..2823976 100644 --- a/lambdas/slack_post/blockkit.py +++ b/lambdas/slack_post/blockkit.py @@ -2,22 +2,432 @@ Two push surfaces, no App Home (see CLAUDE.md "Slack surfaces"): - daily summary: header, vs-yesterday deltas, escalation + action/routine - fields, top sites, mismatch callout, and a 📊 Open dashboard link button to + fields, top sites, mismatch callout, and a Open dashboard link button to Grafana. Long WO lists go in modals, never the channel (<100-block limit). - 3rd-escalation alert: standalone, batched, one @here — suppressed entirely on zero-3rd days (return None). Owned by the slack-blockkit-designer agent; built in Phase 4 (docs/BUILD.md). + +Slack constraints enforced here: + - Message / modal max 100 blocks. + - Actions block max 25 elements (kept to 5 per block for readability). + - section text max 3000 chars. + - Header text max 150 chars. + - No APM deep-links anywhere — Grafana only. """ from __future__ import annotations +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- -def build_daily_summary(today: dict, yesterday: dict | None) -> list[dict]: - """Build the daily summary blocks (with vs-yesterday deltas). (Phase 4)""" - raise NotImplementedError +ESCALATION_CATEGORIES: tuple[str, ...] = ( + "1st Escalation", + "2nd Escalation", + "3rd Escalation", + "SIM Ticket", + "Other Escalation", +) + +# Categories that drive the "drill" buttons — non-routine and worth calling out. +# Order determines button priority when we cap at 5 per actions block. +DRILL_CATEGORY_PRIORITY: tuple[str, ...] = ( + "3rd Escalation", + "2nd Escalation", + "1st Escalation", + "SIM Ticket", + "Vendor No-Show", + "Awaiting Scheduling", + "Report / Docs Needed", + "Awaiting Report / Invoice", + "Awaiting Vendor / Parts", + "Status Inquiry", + "Other Escalation", +) + +# Max WOs to render inline before the "+M more" overflow note in the alert. +ALERT_MAX_WOS = 20 + +# Max blocks reserved for WO rows in the modal (leaves headroom for +# title/footer blocks). Each WO gets 1 section block. +MODAL_HEADER_BLOCKS = 1 # title is not a block; we add 1 context block as preamble +MODAL_FOOTER_BLOCKS = 1 # overflow context block (may not be emitted) +MODAL_MAX_WO_BLOCKS = 100 - MODAL_HEADER_BLOCKS - MODAL_FOOTER_BLOCKS # = 98 -def build_escalation_alert(today: dict) -> list[dict] | None: - """Build the batched 3rd-escalation alert, or None when count is 0. (Phase 4)""" - raise NotImplementedError +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _divider() -> dict: + return {"type": "divider"} + + +def _header(text: str) -> dict: + # Header block text is plain_text, max 150 chars. + return { + "type": "header", + "text": {"type": "plain_text", "text": text[:150], "emoji": True}, + } + + +def _section(text: str) -> dict: + # Section text is mrkdwn, max 3000 chars. + return { + "type": "section", + "text": {"type": "mrkdwn", "text": text[:3000]}, + } + + +def _context(text: str) -> dict: + return { + "type": "context", + "elements": [{"type": "mrkdwn", "text": text[:3000]}], + } + + +def _fields_section(fields: list[str]) -> dict: + """Section with up to 10 fields (Slack limit), each <=2000 chars.""" + return { + "type": "section", + "fields": [{"type": "mrkdwn", "text": f[:2000]} for f in fields[:10]], + } + + +def _button(text: str, action_id: str, value: str) -> dict: + """Interactive button (opens modal via interactivity handler).""" + return { + "type": "button", + "text": {"type": "plain_text", "text": text, "emoji": False}, + "action_id": action_id, + "value": value, + } + + +def _link_button(text: str, url: str) -> dict: + """Link button — opens URL directly, no action_id.""" + return { + "type": "button", + "text": {"type": "plain_text", "text": text, "emoji": True}, + "url": url, + } + + +def _actions(elements: list[dict]) -> dict: + """Actions block; Slack limit 25 elements, we keep <=5 per block.""" + return {"type": "actions", "elements": elements[:25]} + + +def _delta_str(now: int, prev: int) -> str: + """Format a vs-yesterday delta as ▲+N or ▼-N or ~0.""" + diff = now - prev + if diff > 0: + return f"▲+{diff}" + if diff < 0: + return f"▼{diff}" + return "~0" + + +def _fmt_date(dt: str) -> str: + """Format 'YYYY-MM-DD' as 'May 28, 2026' for display.""" + try: + from datetime import date + + d = date.fromisoformat(dt) + # %-d is Linux-specific; strip the leading zero portably instead. + return f"{d.strftime('%b')} {d.day}, {d.year}" + except Exception: + return dt + + +# --------------------------------------------------------------------------- +# Public surface builders +# --------------------------------------------------------------------------- + + +def build_daily_summary( + today: dict, + yesterday: dict | None, + dashboard_url: str, +) -> list[dict]: + """Build the daily summary post blocks. + + Returns a list of Block Kit block dicts ready to pass to ``blocks=`` in a + ``chat.postMessage`` call. Always stays well under 100 blocks. + + Args: + today: The contents of today's ``summary.json``. + yesterday: The contents of yesterday's ``summary.json``, or None when + there is no previous snapshot (first run, etc.). + dashboard_url: The Grafana dashboard URL for the link button. + """ + blocks: list[dict] = [] + + dt = today.get("dt", "") + date_label = _fmt_date(dt) if dt else "Today" + + # ------------------------------------------------------------------ + # 1. Header + # ------------------------------------------------------------------ + blocks.append(_header(f"APM Work Orders — {date_label}")) + + # ------------------------------------------------------------------ + # 2. Context line: classified total + vs-yesterday deltas + # ------------------------------------------------------------------ + classified = today.get("classified_total", 0) + blank = today.get("blank_comment_rows", 0) + escalation = today.get("escalation_total", 0) + action = today.get("action_needed", 0) + routine = today.get("routine", 0) + + if yesterday is not None: + delta_classified = _delta_str(classified, yesterday.get("classified_total", 0)) + delta_escalation = _delta_str(escalation, yesterday.get("escalation_total", 0)) + delta_action = _delta_str(action, yesterday.get("action_needed", 0)) + context_text = ( + f"*{classified}* WOs classified ({blank} blank-comment rows excluded) " + f"| Escalations {delta_escalation} | Action-needed {delta_action} " + f"| vs yesterday: total {delta_classified}" + ) + else: + context_text = ( + f"*{classified}* WOs classified ({blank} blank-comment rows excluded) " + f"| No prior day for delta comparison" + ) + + blocks.append(_section(context_text)) + blocks.append(_divider()) + + # ------------------------------------------------------------------ + # 3. Escalation breakdown + # ------------------------------------------------------------------ + category_counts: dict[str, int] = today.get("category_counts", {}) + third_count = today.get( + "third_escalation_count", category_counts.get("3rd Escalation", 0) + ) + + esc_lines: list[str] = [] + for cat in ESCALATION_CATEGORIES: + count = category_counts.get(cat, 0) + if count == 0: + continue + prefix = "*" if cat == "3rd Escalation" else "" + suffix = "*" if cat == "3rd Escalation" else "" + esc_lines.append(f"{prefix}{cat}: {count}{suffix}") + + if esc_lines: + blocks.append(_section("*Escalations*\n" + "\n".join(esc_lines))) + else: + blocks.append(_section("*Escalations*\nNone today")) + + # ------------------------------------------------------------------ + # 4. Action-needed vs routine fields + # ------------------------------------------------------------------ + blocks.append( + _fields_section( + [ + f"*Action-needed*\n{action}", + f"*Routine*\n{routine}", + f"*3rd Escalations*\n{third_count}", + f"*Total classified*\n{classified}", + ] + ) + ) + blocks.append(_divider()) + + # ------------------------------------------------------------------ + # 5. Top 5 sites + # ------------------------------------------------------------------ + top_sites: list[dict] = today.get("top_sites", [])[:5] + if top_sites: + site_lines = "\n".join( + f"{i + 1}. *{s['site']}* — {s['count']} WOs" + for i, s in enumerate(top_sites) + ) + blocks.append(_section(f"*Top sites*\n{site_lines}")) + blocks.append(_divider()) + + # ------------------------------------------------------------------ + # 6. Mismatch callout (only when mismatches present) + # ------------------------------------------------------------------ + mismatches: list[dict] = today.get("mismatches", []) + if mismatches: + mm_count = len(mismatches) + blocks.append( + _section( + f":warning: *{mm_count} mismatch{'es' if mm_count != 1 else ''} " + f"flagged* — comment intent contradicts structured state. " + f"See the Grafana mismatch panel for details." + ) + ) + blocks.append(_divider()) + + # ------------------------------------------------------------------ + # 7. Drill buttons (action_id="drill_category") + dashboard link button + # ------------------------------------------------------------------ + # Pick the most-common non-routine categories up to 4 slots; the 5th slot + # is always the dashboard link button, keeping each actions block <=5. + drill_cats: list[tuple[str, int]] = [] + for cat in DRILL_CATEGORY_PRIORITY: + count = category_counts.get(cat, 0) + if count > 0: + drill_cats.append((cat, count)) + if len(drill_cats) >= 4: + break + + button_elements: list[dict] = [] + for cat, count in drill_cats: + short_label = cat.replace(" Escalation", " Esc.").replace("Awaiting ", "") + short_label = short_label[:20] # button text kept concise + button_elements.append( + _button(f"{short_label} ({count})", "drill_category", cat) + ) + + # Dashboard link button always present (no action_id — url button). + button_elements.append(_link_button("Open dashboard", dashboard_url)) + + blocks.append(_actions(button_elements)) + + # ------------------------------------------------------------------ + # 8. Footer context + # ------------------------------------------------------------------ + generated_at = today.get("generated_at", "") + footer_parts = [f"Generated {generated_at}" if generated_at else ""] + footer_parts.append(f"dt={dt}") + blocks.append(_context(" | ".join(p for p in footer_parts if p))) + + # Safety check — should never fire in practice given the bounded sections + # above, but surface a warning in the footer rather than silently truncate. + if len(blocks) > 95: + # Trim from the middle, preserve header and footer. + over = len(blocks) - 95 + blocks = blocks[:3] + blocks[3 + over :] + blocks.append( + _context(f"+{over} block(s) trimmed — view full breakdown in Grafana.") + ) + + return blocks + + +def build_escalation_alert(escalations: list[dict]) -> list[dict] | None: + """Build the batched 3rd-escalation alert blocks. + + Zero-3rd suppression: returns None when the list is empty. The caller must + check for None before posting — do NOT post a message with no content. + + Args: + escalations: Rows from ``details.json`` pre-filtered to + ``category == "3rd Escalation"``. + + Returns: + A list of Block Kit block dicts, or None. + """ + if not escalations: + return None + + blocks: list[dict] = [] + + # ------------------------------------------------------------------ + # 1. Header + # ------------------------------------------------------------------ + count = len(escalations) + blocks.append( + _header(f"3rd Escalation Alert — {count} WO{'s' if count != 1 else ''}") + ) + + # ------------------------------------------------------------------ + # 2. ONE @here mention (never one ping per WO) + # ------------------------------------------------------------------ + blocks.append( + _section( + f" *{count} work order{'s' if count != 1 else ''} " + f"at 3rd escalation* require immediate attention." + ) + ) + blocks.append(_divider()) + + # ------------------------------------------------------------------ + # 3. Compact WO list, capped at ALERT_MAX_WOS + # ------------------------------------------------------------------ + visible = escalations[:ALERT_MAX_WOS] + overflow = len(escalations) - len(visible) + + for wo in visible: + wo_num = wo.get("wo_number", "???") + site = wo.get("site", "—") + desc = wo.get("wo_description", "") + # Truncate description to keep the block tidy. + if len(desc) > 80: + desc = desc[:77] + "..." + blocks.append(_section(f"*{wo_num}* — {site} — {desc}")) + + if overflow > 0: + blocks.append( + _context(f"+{overflow} more — see the Grafana dashboard for the full list.") + ) + + return blocks + + +def build_wo_modal(title: str, wos: list[dict], dashboard_url: str) -> dict: + """Build a ``views.open`` payload (modal) listing work orders. + + Caps to Slack's 100-block modal limit by rendering the first chunk of WOs + and appending a "+M more" overflow context block when the list is too long. + No APM deep-links — Grafana only. + + Args: + title: Modal title (max 24 chars enforced by Slack; truncated here). + wos: List of per-WO dicts from ``details.json``. + dashboard_url: The Grafana dashboard URL for the overflow note link. + + Returns: + A dict suitable for passing to ``views.open`` as the ``view=`` arg. + """ + # Slack enforces a 24-char modal title limit. + title_text = title[:24] + + modal_blocks: list[dict] = [] + + # Each WO is one section block. Reserve space for the optional overflow + # context block. + max_wo = MODAL_MAX_WO_BLOCKS - 1 # one slot reserved for potential overflow + visible = wos[:max_wo] + overflow_count = len(wos) - len(visible) + + for wo in visible: + wo_num = wo.get("wo_number", "???") + site = wo.get("site", "—") + dept = wo.get("department", "—") + category = wo.get("category", "—") + snippet = wo.get("last_comment", "") + if len(snippet) > 150: + snippet = snippet[:147] + "..." + + text_parts = [ + f"*{wo_num}*", + f"_{site}_ | {dept} | {category}", + ] + if snippet: + text_parts.append(snippet) + + modal_blocks.append(_section("\n".join(text_parts))) + + if overflow_count > 0: + modal_blocks.append( + _context( + f"+{overflow_count} more — <{dashboard_url}|view the full list in Grafana>." + ) + ) + elif not modal_blocks: + modal_blocks.append(_section("No work orders to display.")) + + return { + "type": "modal", + "title": {"type": "plain_text", "text": title_text, "emoji": False}, + "close": {"type": "plain_text", "text": "Close", "emoji": False}, + "blocks": modal_blocks, + } diff --git a/lambdas/slack_post/handler.py b/lambdas/slack_post/handler.py index 9cba151..128b851 100644 --- a/lambdas/slack_post/handler.py +++ b/lambdas/slack_post/handler.py @@ -1,16 +1,59 @@ -"""Slack post + alert Lambda: daily summary and batched 3rd-escalation alert. +"""Slack post Lambda: daily summary + batched 3rd-escalation alert. -Invoked after the classifier finishes. Reads ``analytics/dt=today/summary.json`` -and the yesterday partition (for deltas), posts the daily summary to the WO -channel via the reused bot token (SSM, same pattern as payments-dashboard), and -— only if the 3rd-escalation count > 0 — posts the standalone batched alert. +Async-invoked by the classifier with ``{"dt": "YYYY-MM-DD"}`` once the day's +snapshot is written. Reads today's (and yesterday's, for deltas) ``summary.json`` +from ``analytics/``, posts the daily summary to the WO channel via the reused bot +token (Secrets Manager), and — only when the 3rd-escalation count > 0 — posts the +standalone batched alert (reading ``details.json`` for the 3rd-escalation rows). -Implemented in Phase 4 (docs/BUILD.md). +Runtime: Python 3.12, ARM64. No App Home — two push surfaces only (see CLAUDE.md). """ from __future__ import annotations +from datetime import datetime, timedelta, timezone + +import blockkit +import slackio + + +def _yesterday(dt: str) -> str: + return (datetime.strptime(dt, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d") + def handler(event, context): - """Lambda entry point. (Phase 4)""" - raise NotImplementedError + """Post the daily summary and (conditionally) the 3rd-escalation alert.""" + dt = (event or {}).get("dt") or datetime.now(timezone.utc).strftime("%Y-%m-%d") + + today = slackio.read_analytics_json(dt, "summary.json") + if today is None: + print(f"No summary.json for dt={dt}; nothing to post.") + return {"posted": False, "reason": "no summary", "dt": dt} + yesterday = slackio.read_analytics_json(_yesterday(dt), "summary.json") + + client = slackio.web_client() + channel = slackio.channel_id() + dashboard_url = slackio.get_dashboard_url() + + summary_blocks = blockkit.build_daily_summary(today, yesterday, dashboard_url) + client.chat_postMessage( + channel=channel, blocks=summary_blocks, text=f"APM Work Orders — {dt}" + ) + posted = {"summary": True, "alert": False} + + # Standalone batched alert — only when there are 3rd escalations. Pull the + # rows from details.json so the alert can name the WOs. + if today.get("third_escalation_count", 0) > 0: + details = slackio.read_analytics_json(dt, "details.json") or [] + thirds = [d for d in details if d.get("category") == "3rd Escalation"] + alert_blocks = blockkit.build_escalation_alert(thirds) + if alert_blocks: + client.chat_postMessage( + channel=channel, + blocks=alert_blocks, + text=f"🚨 {len(thirds)} 3rd-escalation work orders", + ) + posted["alert"] = True + + print(f"Posted dt={dt}: {posted}") + return {"posted": True, "dt": dt, **posted} diff --git a/lambdas/slack_post/interactions.py b/lambdas/slack_post/interactions.py new file mode 100644 index 0000000..647f3fe --- /dev/null +++ b/lambdas/slack_post/interactions.py @@ -0,0 +1,69 @@ +"""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": ""} diff --git a/lambdas/slack_post/slackio.py b/lambdas/slack_post/slackio.py new file mode 100644 index 0000000..6b4b670 --- /dev/null +++ b/lambdas/slack_post/slackio.py @@ -0,0 +1,70 @@ +"""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=