Spaces:
Sleeping
Sleeping
| """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, | |
| ) | |