open-swe/agent/webhooks/slack_routes.py
Adam Moussa b3fc62da80
refactor: split webapp.py into api/ + per-source webhook routes
Plan step C4 (docs/upstream-sync/domain-reorg/reorg-build-plan.md, approved
decisions 1-2): split the 2,590-line agent/webapp.py monolith into
agent/webhooks/common.py (shared verify/dispatch helpers), agent/api/app.py
(composition), agent/api/health.py (/health + /webhooks/run-complete), and
per-source {github,linear,slack,jira,confluence}_routes.py. Atlassian
Connect lifecycle + descriptor routes (/connect/*) fold into
confluence_routes.py; webapp.py becomes the upstream-shaped compatibility
shim (from .api.app import app). langgraph.json http.app stays
agent.webapp:app via the shim.

Fork content, upstream layout: linear/slack route files verified
content-identical to upstream 8356eb34 and taken verbatim; github_routes is
upstream + the fork's CI auto-fix trigger wiring; jira/confluence routes are
fork-only, transformed to the same common.X / service.X module-attribute
style. All signature verification (GitHub HMAC, Slack, Linear
timestamp-freshness, verify_jira_secret + opt-in HMAC/timestamp/IP
allowlist, Connect JWT/qsh), token-attribution gating, TID-COLLIDE-01 repo
binding, _is_repo_auto_review_enabled gates, and public-repo org gate move
unchanged.

Handlers rewired from webapp.X to common.X; test monkeypatch sites across
26 files + conftest.py + e2e/harness.py retargeted to
webhook_common/handler/route modules per upstream's pattern. Residual
agent.webapp importers: only the shim, langgraph.json http.app, Makefile
uvicorn target, and docs (doc-path updates land in C7).

Gates: ruff check + format, pytest --co, full unit (1637 passed), full
Playwright E2E vs real langgraph dev (9/9), residual-importer sweep.
2026-07-17 14:30:05 -04:00

347 lines
15 KiB
Python

"""Slack webhook HTTP routes."""
from fastapi import APIRouter
from . import common
from . import slack as service
router = APIRouter()
@router.post("/webhooks/slack")
async def slack_webhook(
request: common.Request, background_tasks: common.BackgroundTasks
) -> dict[str, str]:
"""Handle Slack Event API webhooks for app mentions."""
body = await request.body()
signature = request.headers.get("X-Slack-Signature", "")
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
if not common.verify_slack_signature(
body=body,
timestamp=timestamp,
signature=signature,
secret=common.SLACK_SIGNING_SECRET,
):
common.logger.warning("Invalid Slack signature")
raise common.HTTPException(status_code=401, detail="Invalid signature")
try:
payload = common.json.loads(body)
except common.json.JSONDecodeError:
common.logger.exception("Failed to parse Slack webhook JSON")
return {"status": "error", "message": "Invalid JSON"}
if payload.get("type") == "url_verification":
challenge = payload.get("challenge", "")
return {"challenge": challenge}
if payload.get("type") != "event_callback":
return {"status": "ignored", "reason": "Not an event callback"}
event = payload.get("event", {})
if event.get("type") == "reaction_added":
reaction = event.get("reaction")
if reaction in common.FEEDBACK_REACTIONS:
background_tasks.add_task(
common.process_slack_reaction_added, event, payload.get("event_id", "")
)
return {"status": "accepted", "message": "Reaction feedback queued"}
return {"status": "ignored", "reason": "Reaction not tracked for feedback"}
if event.get("type") == "reaction_removed":
reaction = event.get("reaction")
if reaction in common.FEEDBACK_REACTIONS:
background_tasks.add_task(
common.process_slack_reaction_removed, event, payload.get("event_id", "")
)
return {"status": "accepted", "message": "Reaction removal queued"}
return {"status": "ignored", "reason": "Reaction not tracked for feedback"}
if event.get("type") != "app_mention":
message_text = event.get("text", "")
has_username_mention = bool(
event.get("type") == "message"
and common.SLACK_BOT_USERNAME
and f"@{common.SLACK_BOT_USERNAME}" in message_text
)
has_id_mention = bool(
event.get("type") == "message"
and common.SLACK_BOT_USER_ID
and f"<@{common.SLACK_BOT_USER_ID}>" in message_text
)
if not (has_username_mention or has_id_mention):
return {"status": "ignored", "reason": "Not an app_mention event"}
if event.get("subtype") == "bot_message" or event.get("bot_id"):
return {"status": "ignored", "reason": "Event from a bot"}
channel_id = event.get("channel", "")
event_ts = event.get("ts", "")
thread_ts = event.get("thread_ts") or event_ts
user_id = event.get("user", "")
text = event.get("text", "")
if not channel_id or not event_ts or not thread_ts:
return {"status": "ignored", "reason": "Missing channel/thread timestamp"}
bot_user_id = common.SLACK_BOT_USER_ID
if not bot_user_id:
authorizations = payload.get("authorizations", [])
if isinstance(authorizations, list) and authorizations:
auth_user_id = authorizations[0].get("user_id")
if isinstance(auth_user_id, str):
bot_user_id = auth_user_id
if not bot_user_id:
authed_users = payload.get("authed_users", [])
if isinstance(authed_users, list) and authed_users:
first_user = authed_users[0]
if isinstance(first_user, str):
bot_user_id = first_user
if bot_user_id and user_id == bot_user_id:
return {"status": "ignored", "reason": "Event from this bot user"}
channel_context = await common._get_slack_channel_context(channel_id)
if await common._is_docs_plz_slack_channel(channel_id, channel_context):
background_tasks.add_task(
common.post_slack_thread_reply,
channel_id,
thread_ts,
common.DOCS_PLZ_SLACK_GATE_REPLY,
)
return {"status": "accepted", "message": "Slack mention gated for docs-plz"}
event_data = {
"channel_id": channel_id,
"channel_context": channel_context,
"thread_ts": thread_ts,
"event_ts": event_ts,
"user_id": user_id,
"text": text,
"bot_user_id": bot_user_id,
}
repo_config = await common.get_slack_repo_config(
channel_id, thread_ts, slack_user_id=user_id, channel_context=channel_context
)
background_tasks.add_task(service.process_slack_mention, event_data, repo_config)
return {"status": "accepted", "message": "Slack mention queued"}
@router.post("/webhooks/slack/interactivity")
async def slack_interactivity(
request: common.Request, background_tasks: common.BackgroundTasks
) -> dict[str, str]:
"""Handle Slack Block Kit interactions."""
body = await request.body()
signature = request.headers.get("X-Slack-Signature", "")
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
if not common.verify_slack_signature(
body=body,
timestamp=timestamp,
signature=signature,
secret=common.SLACK_SIGNING_SECRET,
):
common.logger.warning("Invalid Slack interactivity signature")
raise common.HTTPException(status_code=401, detail="Invalid signature")
form = common.parse_qs(body.decode("utf-8"))
payload_raw = (form.get("payload") or [""])[0]
try:
payload = common.json.loads(payload_raw)
except common.json.JSONDecodeError:
common.logger.exception("Failed to parse Slack interactivity payload")
return {"status": "error", "message": "Invalid payload"}
action = _first_open_swe_option_action(payload.get("actions"))
if action is None:
return {"status": "ignored", "reason": "No Open SWE action"}
try:
action_value = common.json.loads(str(action.get("value") or "{}"))
except common.json.JSONDecodeError:
return {"status": "ignored", "reason": "Invalid action value"}
if action_value.get("type") == "workflow_push_approval":
workflow_action = str(action_value.get("action") or "").strip()
fingerprint = str(action_value.get("fingerprint") or "").strip()
channel = payload.get("channel") if isinstance(payload.get("channel"), dict) else {}
message = payload.get("message") if isinstance(payload.get("message"), dict) else {}
container = payload.get("container") if isinstance(payload.get("container"), dict) else {}
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
channel_id = str(channel.get("id") or container.get("channel_id") or "")
thread_ts = str(
message.get("thread_ts") or message.get("ts") or container.get("thread_ts") or ""
)
user_id = str(user.get("id") or "")
if not channel_id or not thread_ts or not fingerprint:
return {"status": "ignored", "reason": "Missing workflow approval context"}
thread_id = common.generate_thread_id_from_slack_thread(channel_id, thread_ts)
if not await common._slack_user_is_thread_owner(thread_id, user_id):
await common.post_slack_thread_reply(
channel_id=channel_id,
thread_ts=thread_ts,
text="Only the person who requested this run can approve workflow file pushes.",
)
return {"status": "ignored", "reason": "approver is not the thread owner"}
if workflow_action not in {"approve", "reject"}:
return {"status": "ignored", "reason": "Unknown workflow approval action"}
approved = workflow_action == "approve"
record = await common.decide_workflow_push_approval(
thread_id, fingerprint, approved=approved, actor=user_id
)
if record is None:
await common.post_slack_thread_reply(
channel_id=channel_id,
thread_ts=thread_ts,
text="I couldn't find that workflow approval request. Trigger the push again to create a fresh approval.",
)
return {"status": "ignored", "reason": "workflow approval not found"}
if not approved:
await common.post_slack_thread_reply(
channel_id=channel_id,
thread_ts=thread_ts,
text=f"Workflow push rejected for fingerprint `{fingerprint}`. No workflow files will be pushed.",
)
return {"status": "accepted", "message": "Workflow push rejected"}
await common.post_slack_thread_reply(
channel_id=channel_id,
thread_ts=thread_ts,
text=f"Workflow push approved for fingerprint `{fingerprint}`. Open SWE will retry the blocked push.",
)
channel_context = await common._get_slack_channel_context(channel_id)
repo_config = await common.get_slack_repo_config(
channel_id, thread_ts, slack_user_id=user_id, channel_context=channel_context
)
background_tasks.add_task(
service.process_slack_mention,
{
"channel_id": channel_id,
"channel_context": channel_context,
"thread_ts": thread_ts,
"event_ts": str(message.get("ts") or ""),
"user_id": user_id,
"text": (
"The workflow-file push approval was approved. Retry the blocked "
"git push now; do not alter workflow files before pushing."
),
"bot_user_id": common.SLACK_BOT_USER_ID,
},
repo_config,
)
return {"status": "accepted", "message": "Workflow push approved, retry queued"}
if action_value.get("type") == "plan_approval":
plan_action = str(action_value.get("action") or "").strip()
channel = payload.get("channel") if isinstance(payload.get("channel"), dict) else {}
message = payload.get("message") if isinstance(payload.get("message"), dict) else {}
container = payload.get("container") if isinstance(payload.get("container"), dict) else {}
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
channel_id = str(channel.get("id") or container.get("channel_id") or "")
thread_ts = str(
message.get("thread_ts") or message.get("ts") or container.get("thread_ts") or ""
)
user_id = str(user.get("id") or "")
if not channel_id or not thread_ts:
return {"status": "ignored", "reason": "Missing Slack action context"}
thread_id = common.generate_thread_id_from_slack_thread(channel_id, thread_ts)
if plan_action == "cancel":
await common.post_slack_thread_reply(
channel_id=channel_id,
thread_ts=thread_ts,
text="Plan cancelled. No changes will be made.",
)
return {"status": "accepted", "message": "Plan cancelled"}
if plan_action == "approve":
if not await common._slack_user_is_thread_owner(thread_id, user_id):
await common.post_slack_thread_reply(
channel_id=channel_id,
thread_ts=thread_ts,
text="Only the person who requested this plan can approve it. Anyone can reply with feedback or use *Revise Plan*.",
)
return {"status": "ignored", "reason": "approver is not the thread owner"}
await common._set_thread_plan_mode(thread_id, False)
channel_context = await common._get_slack_channel_context(channel_id)
repo_config = await common.get_slack_repo_config(
channel_id, thread_ts, slack_user_id=user_id, channel_context=channel_context
)
background_tasks.add_task(
service.process_slack_mention,
{
"channel_id": channel_id,
"channel_context": channel_context,
"thread_ts": thread_ts,
"event_ts": str(message.get("ts") or ""),
"user_id": user_id,
"text": "Proceed with the approved plan. Implement the changes as described in the plan.",
"bot_user_id": common.SLACK_BOT_USER_ID,
},
repo_config,
)
return {"status": "accepted", "message": "Plan approved, starting implementation"}
return {"status": "accepted", "message": "Reply to revise the plan"}
if action_value.get("type") != "open_swe_option":
return {"status": "ignored", "reason": "Unknown action type"}
response = str(action_value.get("response") or "").strip()
if not response:
return {"status": "ignored", "reason": "Empty response"}
channel = payload.get("channel") if isinstance(payload.get("channel"), dict) else {}
message = payload.get("message") if isinstance(payload.get("message"), dict) else {}
container = payload.get("container") if isinstance(payload.get("container"), dict) else {}
user = payload.get("user") if isinstance(payload.get("user"), dict) else {}
channel_id = str(channel.get("id") or container.get("channel_id") or "")
event_ts = str(
action.get("action_ts") or message.get("ts") or container.get("message_ts") or ""
)
thread_ts = str(
message.get("thread_ts") or message.get("ts") or container.get("thread_ts") or event_ts
)
user_id = str(user.get("id") or "")
if not channel_id or not thread_ts or not event_ts or not user_id:
return {"status": "ignored", "reason": "Missing Slack action context"}
channel_context = await common._get_slack_channel_context(channel_id)
repo_config = await common.get_slack_repo_config(
channel_id, thread_ts, slack_user_id=user_id, channel_context=channel_context
)
background_tasks.add_task(
service.process_slack_mention,
{
"channel_id": channel_id,
"channel_context": channel_context,
"thread_ts": thread_ts,
"event_ts": event_ts,
"user_id": user_id,
"text": response,
"bot_user_id": common.SLACK_BOT_USER_ID,
},
repo_config,
)
return {"status": "accepted", "message": "Slack option queued"}
def _first_open_swe_option_action(actions: common.Any) -> dict[str, common.Any] | None:
if not isinstance(actions, list):
return None
for action in actions:
if isinstance(action, dict) and action.get("action_id") == "open_swe_option_select":
return action
return None
@router.get("/webhooks/slack")
async def slack_webhook_verify() -> dict[str, str]:
"""Verify endpoint for Slack webhook setup."""
return {"status": "ok", "message": "Slack webhook endpoint is active"}