mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 15:03:16 +00:00
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.
347 lines
15 KiB
Python
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"}
|