From 78315eb55b09cea5e28323069d0bdd1611821ec7 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 1 Jun 2026 12:36:27 +0530 Subject: [PATCH] feat(gateway): add Slack platform adapter --- .../app/gateway/slack/__init__.py | 1 + .../app/gateway/slack/adapter.py | 120 ++++++++++++++++++ surfsense_backend/app/gateway/slack/client.py | 72 +++++++++++ .../tests/unit/gateway/test_slack_adapter.py | 47 +++++++ 4 files changed, 240 insertions(+) create mode 100644 surfsense_backend/app/gateway/slack/__init__.py create mode 100644 surfsense_backend/app/gateway/slack/adapter.py create mode 100644 surfsense_backend/app/gateway/slack/client.py create mode 100644 surfsense_backend/tests/unit/gateway/test_slack_adapter.py diff --git a/surfsense_backend/app/gateway/slack/__init__.py b/surfsense_backend/app/gateway/slack/__init__.py new file mode 100644 index 000000000..7f7aaf2fc --- /dev/null +++ b/surfsense_backend/app/gateway/slack/__init__.py @@ -0,0 +1 @@ +"""Slack gateway integration.""" diff --git a/surfsense_backend/app/gateway/slack/adapter.py b/surfsense_backend/app/gateway/slack/adapter.py new file mode 100644 index 000000000..e49ca6b9c --- /dev/null +++ b/surfsense_backend/app/gateway/slack/adapter.py @@ -0,0 +1,120 @@ +"""Slack platform adapter for app mentions and threaded replies.""" + +from __future__ import annotations + +import re +from typing import Any + +from app.gateway.base.adapter import ( + BasePlatformAdapter, + ParsedInboundEvent, + PlatformSendResult, +) +from app.gateway.slack.client import SlackGatewayClient + +MENTION_RE = re.compile(r"<@[^>]+>\s*") + + +def slack_user_peer_id(team_id: str, slack_user_id: str) -> str: + return f"slack_user:{team_id}:{slack_user_id}" + + +def slack_thread_peer_id(team_id: str, channel_id: str, thread_ts: str) -> str: + return f"slack_thread:{team_id}:{channel_id}:{thread_ts}" + + +class SlackAdapter(BasePlatformAdapter): + platform = "slack" + + def __init__(self, bot_token: str, *, bot_user_id: str | None = None) -> None: + self.bot_user_id = bot_user_id + self.client = SlackGatewayClient(bot_token) + + def parse_inbound(self, raw_payload: dict[str, Any]) -> ParsedInboundEvent: + event = raw_payload.get("event") or {} + event_type = str(event.get("type") or "other") + team_id = str(raw_payload.get("team_id") or event.get("team") or "") + channel_id = str(event.get("channel") or "") + slack_user_id = str(event.get("user") or "") + message_ts = str(event.get("ts") or "") + thread_ts = str(event.get("thread_ts") or message_ts) + bot_user_id = self.bot_user_id or str(raw_payload.get("authorizations", [{}])[0].get("user_id") or "") + + if not channel_id or not slack_user_id or not message_ts: + return ParsedInboundEvent( + platform=self.platform, + event_kind=event_type, + external_peer_id=None, + external_peer_kind="unknown", + external_message_id=message_ts or None, + external_user_id=slack_user_id or None, + text=None, + raw_payload=raw_payload, + metadata={"team_id": team_id, "bot_user_id": bot_user_id}, + ) + + text = str(event.get("text") or "") + if bot_user_id: + text = text.replace(f"<@{bot_user_id}>", "") + text = MENTION_RE.sub("", text).strip() + + peer_kind = "direct" if str(event.get("channel_type")) == "im" else "channel" + thread_key = slack_thread_peer_id(team_id, channel_id, thread_ts) + user_key = slack_user_peer_id(team_id, slack_user_id) + + return ParsedInboundEvent( + platform=self.platform, + event_kind=event_type, + external_peer_id=thread_key, + external_peer_kind=peer_kind, + external_message_id=message_ts, + external_user_id=slack_user_id, + text=text, + raw_payload=raw_payload, + display_name=None, + username=slack_user_id, + metadata={ + "team_id": team_id, + "channel_id": channel_id, + "slack_user_id": slack_user_id, + "message_ts": message_ts, + "thread_ts": thread_ts, + "bot_user_id": bot_user_id, + "slack_user_peer_id": user_key, + "slack_thread_peer_id": thread_key, + "channel_type": event.get("channel_type"), + }, + ) + + async def send_message( + self, + *, + external_peer_id: str, + text: str, + parse_mode: str | None = None, + reply_to_message_id: str | None = None, + ) -> PlatformSendResult: + del parse_mode + return await self.client.send_message( + channel=external_peer_id, + text=text, + thread_ts=reply_to_message_id, + ) + + async def edit_message( + self, + *, + external_peer_id: str, + external_message_id: str, + text: str, + parse_mode: str | None = None, + ) -> PlatformSendResult: + del parse_mode + return await self.client.update_message( + channel=external_peer_id, + ts=external_message_id, + text=text, + ) + + async def validate_credentials(self) -> dict[str, Any]: + return await self.client.validate() diff --git a/surfsense_backend/app/gateway/slack/client.py b/surfsense_backend/app/gateway/slack/client.py new file mode 100644 index 000000000..37ccda3bd --- /dev/null +++ b/surfsense_backend/app/gateway/slack/client.py @@ -0,0 +1,72 @@ +"""Slack Web API client for gateway bot operations.""" + +from __future__ import annotations + +from typing import Any + +import httpx + +from app.gateway.base.adapter import PlatformSendResult + +SLACK_API = "https://slack.com/api" + + +class SlackGatewayClient: + def __init__(self, bot_token: str) -> None: + self.bot_token = bot_token + + async def api_call(self, method: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + async with httpx.AsyncClient(timeout=20.0) as client: + response = await client.post( + f"{SLACK_API}/{method}", + json=payload or {}, + headers={ + "Authorization": f"Bearer {self.bot_token}", + "Content-Type": "application/json; charset=utf-8", + }, + ) + response.raise_for_status() + data = response.json() + if not data.get("ok", False): + error = data.get("error", "unknown_error") + raise RuntimeError(f"Slack API {method} failed: {error}") + return data + + async def send_message( + self, + *, + channel: str, + text: str, + thread_ts: str | None = None, + ) -> PlatformSendResult: + payload: dict[str, Any] = {"channel": channel, "text": text} + if thread_ts: + payload["thread_ts"] = thread_ts + data = await self.api_call("chat.postMessage", payload) + return PlatformSendResult( + external_message_id=str(data.get("ts", "")), + raw_response=data, + ) + + async def update_message( + self, + *, + channel: str, + ts: str, + text: str, + ) -> PlatformSendResult: + data = await self.api_call("chat.update", {"channel": channel, "ts": ts, "text": text}) + return PlatformSendResult( + external_message_id=str(data.get("ts") or ts), + raw_response=data, + ) + + async def validate(self) -> dict[str, Any]: + data = await self.api_call("auth.test") + return { + "ok": True, + "team_id": data.get("team_id"), + "team": data.get("team"), + "bot_user_id": data.get("user_id"), + "bot_username": data.get("user"), + } diff --git a/surfsense_backend/tests/unit/gateway/test_slack_adapter.py b/surfsense_backend/tests/unit/gateway/test_slack_adapter.py new file mode 100644 index 000000000..8742a6bf4 --- /dev/null +++ b/surfsense_backend/tests/unit/gateway/test_slack_adapter.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from app.gateway.slack.adapter import SlackAdapter + + +def test_slack_adapter_parses_app_mention_and_strips_bot_mention(): + adapter = SlackAdapter("xoxb-test", bot_user_id="U_BOT") + + parsed = adapter.parse_inbound( + { + "team_id": "T123", + "event": { + "type": "app_mention", + "channel": "C123", + "user": "U123", + "text": "<@U_BOT> summarize this thread", + "ts": "1717000000.000100", + }, + } + ) + + assert parsed.platform == "slack" + assert parsed.text == "summarize this thread" + assert parsed.external_peer_id == "slack_thread:T123:C123:1717000000.000100" + assert parsed.metadata["slack_user_peer_id"] == "slack_user:T123:U123" + assert parsed.metadata["thread_ts"] == "1717000000.000100" + + +def test_slack_adapter_uses_existing_thread_ts(): + adapter = SlackAdapter("xoxb-test", bot_user_id="U_BOT") + + parsed = adapter.parse_inbound( + { + "team_id": "T123", + "event": { + "type": "app_mention", + "channel": "C123", + "user": "U123", + "text": "<@U_BOT> continue", + "ts": "1717000001.000200", + "thread_ts": "1717000000.000100", + }, + } + ) + + assert parsed.external_peer_id == "slack_thread:T123:C123:1717000000.000100" + assert parsed.metadata["message_ts"] == "1717000001.000200"