"""Slack integration for daily-scan candidates. Posts a Block Kit message per candidate paper with Approve/Reject buttons via an Incoming Webhook, verifies Slack's request signature on the interactivity callback, and edits the message in place (via the interaction's response_url) once a decision is made. No bot token needed โ€” posting goes through the webhook, and updates go through response_url, which Slack hands back on every interaction regardless of how the original message was posted. Two callers: - daily_scan.py calls post_candidate() after creating a job in the store. - app.py's /slack/interactions route calls verify_signature() then handle_interaction() when a button is clicked. """ from __future__ import annotations import hashlib import hmac import os import time import requests try: from dotenv import load_dotenv load_dotenv() except ImportError: pass try: # Works when this file lives inside the deployed Space (flat layout, # store.py a sibling module). import store except ImportError: # Works when imported as repropapers.slack_bot from the outer monorepo # (e.g. daily_scan.py at the repo root). from repropapers import store APPROVE_ACTION = "approve_candidate" REJECT_ACTION = "reject_candidate" # Slack rejects signatures on requests older than this; we mirror the check. MAX_REQUEST_AGE_SECONDS = 60 * 5 def _signing_secret() -> str: secret = os.environ.get("SLACK_SIGNING_SECRET") if not secret: raise RuntimeError("SLACK_SIGNING_SECRET is not set.") return secret def _webhook_url() -> str: url = os.environ.get("SLACK_WEBHOOK_URL") if not url: raise RuntimeError("SLACK_WEBHOOK_URL is not set.") return url def verify_signature(timestamp: str, body: str, signature: str) -> bool: """Verify Slack's HMAC request signature. See Slack's "Verifying requests" docs.""" if not timestamp or not signature: return False try: if abs(time.time() - int(timestamp)) > MAX_REQUEST_AGE_SECONDS: return False except ValueError: return False basestring = f"v0:{timestamp}:{body}".encode() digest = hmac.new(_signing_secret().encode(), basestring, hashlib.sha256).hexdigest() expected = f"v0={digest}" return hmac.compare_digest(expected, signature) def _links_line(job: dict) -> str: parts = [f"<{job['paper_url']}|paper>", f"<{job['code_url']}|code>"] if job.get("data_url"): parts.append(f"<{job['data_url']}|data>") return " ยท ".join(parts) def _candidate_blocks(job: dict, decision: dict | None = None) -> list[dict]: blocks = [ { "type": "section", "text": { "type": "mrkdwn", "text": f"๐Ÿ”ฌ *New reproduction candidate*\n*{job['title']}*", }, }, { "type": "section", "text": {"type": "mrkdwn", "text": f"Why: {job.get('repro_summary') or '_no summary_'}"}, }, { "type": "context", "elements": [ { "type": "mrkdwn", "text": ( f"score {job.get('repro_score', '?')}/100 ยท " f"{job.get('upvotes', 0)} upvotes ยท {_links_line(job)}" ), } ], }, ] if decision: icon = "โœ…" if decision["status"] == "approved" else "โœ•" blocks.append( { "type": "context", "elements": [ {"type": "mrkdwn", "text": f"{icon} *{decision['status'].capitalize()}* by <@{decision['user_id']}>"} ], } ) else: blocks.append( { "type": "actions", "elements": [ { "type": "button", "text": {"type": "plain_text", "text": "โœ“ Approve"}, "style": "primary", "action_id": APPROVE_ACTION, "value": job["id"], }, { "type": "button", "text": {"type": "plain_text", "text": "โœ• Reject"}, "style": "danger", "action_id": REJECT_ACTION, "value": job["id"], }, ], } ) return blocks def post_candidate(job: dict) -> None: """Post a candidate to Slack via the Incoming Webhook.""" resp = requests.post( _webhook_url(), json={ "text": f"New reproduction candidate: {job['title']}", # fallback for notifications "blocks": _candidate_blocks(job), }, timeout=15, ) resp.raise_for_status() def handle_interaction(payload: dict) -> None: """Handle a Slack block_actions payload: flip the job's status and edit the message.""" if payload.get("type") != "block_actions": return actions = payload.get("actions") or [] if not actions: return action = actions[0] job_id = action.get("value") action_id = action.get("action_id") if not job_id or action_id not in (APPROVE_ACTION, REJECT_ACTION): return job = store.get_job(job_id) if job is None: return status = "approved" if action_id == APPROVE_ACTION else "rejected" user_id = (payload.get("user") or {}).get("id", "") fields = {"decided_by": user_id} if status == "approved": from datetime import datetime, timezone fields["approved_at"] = datetime.now(timezone.utc).isoformat() job = store.set_status(job_id, status, **fields) response_url = payload.get("response_url") if response_url: requests.post( response_url, json={ "replace_original": True, "text": f"{job['title']} โ€” {status}", "blocks": _candidate_blocks(job, decision={"status": status, "user_id": user_id}), }, timeout=15, )