repro-papers / slack_bot.py
EmmaScharfmann's picture
EmmaScharfmann HF Staff
add dockerfile
ca60ce9
Raw
History Blame Contribute Delete
6.18 kB
"""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,
)