Align IM connections with local channels

This commit is contained in:
taohe
2026-06-10 22:16:47 +08:00
parent 92c185b90d
commit d06643d8a2
33 changed files with 588 additions and 1536 deletions
+59
View File
@@ -18,6 +18,16 @@ logger = logging.getLogger(__name__)
_DISCORD_MAX_MESSAGE_LEN = 2000
def _extract_connect_code(text: str) -> str | None:
parts = text.strip().split()
if len(parts) < 2:
return None
command = parts[0].lower()
if command in {"/connect", "connect"}:
return parts[1]
return None
class DiscordChannel(Channel):
"""Discord bot channel.
@@ -288,6 +298,10 @@ class DiscordChannel(Channel):
text = text.replace(bot_mention or "", "").replace(alt_mention or "", "").replace(standard_mention or "", "").strip()
# Don't return early if text is empty — still process the mention (e.g., create thread)
connect_code = _extract_connect_code(text)
if connect_code and await self._bind_connection_from_connect_code(message, connect_code):
return
# --- Determine thread/channel routing and typing target ---
thread_id = None
chat_id = None
@@ -464,6 +478,51 @@ class DiscordChannel(Channel):
inbound.workspace_id = connection.get("workspace_id")
return inbound
async def _bind_connection_from_connect_code(self, message, code: str) -> bool:
if self._connection_repo is None or not code:
return False
state = await self._connection_repo.consume_oauth_state(provider="discord", state=code)
if state is None:
await self._send_connection_reply(message, "Discord connection code is invalid or expired.")
return True
guild = getattr(message, "guild", None)
channel = getattr(message, "channel", None)
author = getattr(message, "author", None)
user_id = str(getattr(author, "id", "") or "")
if not user_id:
await self._send_connection_reply(message, "Discord connection could not be completed from this message.")
return True
guild_id = str(getattr(guild, "id", "") or "") or None
await self._connection_repo.upsert_connection(
owner_user_id=state["owner_user_id"],
provider="discord",
external_account_id=user_id,
external_account_name=getattr(author, "display_name", None) or getattr(author, "name", None),
workspace_id=guild_id,
workspace_name=getattr(guild, "name", None) if guild is not None else None,
metadata={
"guild_id": guild_id,
"channel_id": str(getattr(channel, "id", "") or ""),
},
status="connected",
)
await self._send_connection_reply(message, "Discord connected to DeerFlow.")
return True
@staticmethod
async def _send_connection_reply(message, text: str) -> None:
channel = getattr(message, "channel", None)
send = getattr(channel, "send", None)
if send is None:
return
try:
await send(text)
except Exception:
logger.exception("[Discord] failed to send connection reply")
def _run_client(self) -> None:
self._discord_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._discord_loop)
@@ -1 +0,0 @@
"""Provider-specific helpers for user-owned IM channel connections."""
@@ -1,110 +0,0 @@
"""Discord OAuth helpers for user-owned channel connections."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
import httpx
DISCORD_API_BASE_URL = "https://discord.com/api/v10"
DISCORD_TOKEN_URL = f"{DISCORD_API_BASE_URL}/oauth2/token"
DISCORD_CURRENT_USER_URL = f"{DISCORD_API_BASE_URL}/users/@me"
DISCORD_CURRENT_USER_GUILDS_URL = f"{DISCORD_API_BASE_URL}/users/@me/guilds"
class DiscordConnectError(RuntimeError):
"""Raised when Discord OAuth fails."""
@dataclass(frozen=True)
class DiscordIdentity:
user_id: str
display_name: str | None
username: str | None
guilds: list[dict[str, Any]]
access_token: str
refresh_token: str | None
token_type: str | None
scopes: list[str]
expires_at: datetime | None
raw_token: dict[str, Any]
def _split_scopes(value: str | None) -> list[str]:
if not value:
return []
return [scope.strip() for scope in value.replace(",", " ").split() if scope.strip()]
def _display_name(user: dict[str, Any]) -> str | None:
global_name = user.get("global_name")
if isinstance(global_name, str) and global_name:
return global_name
username = user.get("username")
return str(username) if username else None
async def complete_discord_oauth(
*,
client_id: str,
client_secret: str,
code: str,
redirect_uri: str,
http_client: httpx.AsyncClient | None = None,
) -> DiscordIdentity:
async def _complete(client: httpx.AsyncClient) -> DiscordIdentity:
token_response = await client.post(
DISCORD_TOKEN_URL,
data={
"client_id": client_id,
"client_secret": client_secret,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
token_response.raise_for_status()
token = token_response.json()
access_token = token.get("access_token")
if not access_token:
raise DiscordConnectError("Discord OAuth response did not include an access token")
auth_headers = {"Authorization": f"Bearer {access_token}"}
user_response = await client.get(DISCORD_CURRENT_USER_URL, headers=auth_headers, timeout=10)
user_response.raise_for_status()
user = user_response.json()
user_id = user.get("id")
if not user_id:
raise DiscordConnectError("Discord user response did not include a user id")
guilds_response = await client.get(DISCORD_CURRENT_USER_GUILDS_URL, headers=auth_headers, timeout=10)
guilds: list[dict[str, Any]] = []
if guilds_response.status_code == 200:
guilds = guilds_response.json()
expires_at = None
expires_in = token.get("expires_in")
if isinstance(expires_in, int | float):
expires_at = datetime.now(UTC) + timedelta(seconds=float(expires_in))
return DiscordIdentity(
user_id=str(user_id),
display_name=_display_name(user),
username=user.get("username"),
guilds=guilds,
access_token=str(access_token),
refresh_token=token.get("refresh_token"),
token_type=token.get("token_type"),
scopes=_split_scopes(token.get("scope")),
expires_at=expires_at,
raw_token=token,
)
if http_client is None:
async with httpx.AsyncClient() as client:
return await _complete(client)
return await _complete(http_client)
@@ -1,110 +0,0 @@
"""Slack OAuth and Events helpers for user-owned channel connections."""
from __future__ import annotations
import hashlib
import hmac
import time
from dataclasses import dataclass
from typing import Any
import httpx
SLACK_OAUTH_ACCESS_URL = "https://slack.com/api/oauth.v2.access"
SLACK_SIGNATURE_VERSION = "v0"
SLACK_SIGNATURE_TOLERANCE_SECONDS = 60 * 5
class SlackConnectError(RuntimeError):
"""Raised when Slack OAuth or request verification fails."""
@dataclass(frozen=True)
class SlackInstall:
team_id: str
team_name: str | None
authed_user_id: str
bot_user_id: str | None
bot_access_token: str
scopes: list[str]
raw: dict[str, Any]
def verify_slack_signature(
*,
signing_secret: str,
timestamp: str | None,
body: bytes,
signature: str | None,
now: int | None = None,
) -> bool:
if not signing_secret or not timestamp or not signature:
return False
try:
timestamp_int = int(timestamp)
except (TypeError, ValueError):
return False
current_time = int(time.time()) if now is None else now
if abs(current_time - timestamp_int) > SLACK_SIGNATURE_TOLERANCE_SECONDS:
return False
base = f"{SLACK_SIGNATURE_VERSION}:{timestamp}:".encode() + body
digest = hmac.new(signing_secret.encode("utf-8"), base, hashlib.sha256).hexdigest()
expected = f"{SLACK_SIGNATURE_VERSION}={digest}"
return hmac.compare_digest(expected, signature)
def _split_scopes(value: str | None) -> list[str]:
if not value:
return []
return [scope.strip() for scope in value.split(",") if scope.strip()]
async def exchange_slack_oauth_code(
*,
client_id: str,
client_secret: str,
code: str,
redirect_uri: str,
http_client: httpx.AsyncClient | None = None,
) -> SlackInstall:
async def _post(client: httpx.AsyncClient) -> dict[str, Any]:
response = await client.post(
SLACK_OAUTH_ACCESS_URL,
data={
"client_id": client_id,
"client_secret": client_secret,
"code": code,
"redirect_uri": redirect_uri,
},
timeout=10,
)
response.raise_for_status()
return response.json()
if http_client is None:
async with httpx.AsyncClient() as client:
payload = await _post(client)
else:
payload = await _post(http_client)
if not payload.get("ok"):
raise SlackConnectError(str(payload.get("error") or "Slack OAuth exchange failed"))
access_token = payload.get("access_token")
team = payload.get("team") or {}
authed_user = payload.get("authed_user") or {}
if not access_token or not team.get("id") or not authed_user.get("id"):
raise SlackConnectError("Slack OAuth response did not include required installation fields")
return SlackInstall(
team_id=str(team["id"]),
team_name=team.get("name"),
authed_user_id=str(authed_user["id"]),
bot_user_id=payload.get("bot_user_id"),
bot_access_token=str(access_token),
scopes=_split_scopes(payload.get("scope")),
raw=payload,
)
+2 -26
View File
@@ -57,37 +57,14 @@ def _merge_channel_connection_runtime_config(channels_config: dict[str, Any], ap
if connection_config is None or not getattr(connection_config, "enabled", False):
return
telegram = getattr(connection_config, "telegram", None)
if telegram is not None and getattr(telegram, "enabled", False) and getattr(telegram, "configured", False):
telegram_config = dict(channels_config.get("telegram", {})) if isinstance(channels_config.get("telegram"), dict) else {}
telegram_config.setdefault("enabled", True)
telegram_config.setdefault("bot_token", telegram.bot_token)
channels_config["telegram"] = telegram_config
slack = getattr(connection_config, "slack", None)
if slack is not None and getattr(slack, "enabled", False) and getattr(slack, "configured", False):
slack_config = dict(channels_config.get("slack", {})) if isinstance(channels_config.get("slack"), dict) else {}
slack_config.setdefault("enabled", True)
slack_config.setdefault("event_delivery", slack.event_delivery)
slack_config.setdefault("signing_secret", slack.signing_secret)
channels_config["slack"] = slack_config
discord = getattr(connection_config, "discord", None)
if discord is not None and getattr(discord, "enabled", False) and getattr(discord, "configured", False):
discord_config = dict(channels_config.get("discord", {})) if isinstance(channels_config.get("discord"), dict) else {}
discord_config.setdefault("enabled", True)
discord_config.setdefault("bot_token", discord.bot_token)
channels_config["discord"] = discord_config
def _make_connection_repo(app_config: AppConfig):
connection_config = getattr(app_config, "channel_connections", None)
if connection_config is None or not getattr(connection_config, "enabled", False):
return None
encryption_key = getattr(connection_config, "encryption_key", "")
try:
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import get_session_factory
except Exception:
logger.exception("Failed to import channel connection repository")
@@ -97,8 +74,7 @@ def _make_connection_repo(app_config: AppConfig):
if session_factory is None:
logger.warning("Channel connections are enabled but database persistence is not available")
return None
cipher = ChannelCredentialCipher.from_key(encryption_key) if encryption_key else None
return ChannelConnectionRepository(session_factory, cipher=cipher)
return ChannelConnectionRepository(session_factory)
class ChannelService:
+99 -5
View File
@@ -47,6 +47,16 @@ def _strip_leading_slack_bot_mention(text: str, bot_user_id: str | None) -> str:
return text[end + 1 :].lstrip()
def _extract_connect_code(text: str) -> str | None:
parts = text.strip().split()
if len(parts) < 2:
return None
command = parts[0].lower()
if command in {"/connect", "connect"}:
return parts[1]
return None
class SlackChannel(Channel):
"""Slack IM channel using Socket Mode (WebSocket, no public IP).
@@ -219,8 +229,7 @@ class SlackChannel(Channel):
credentials = await self._connection_repo.get_credentials(msg.connection_id)
access_token = credentials.get("access_token") if credentials else None
if not access_token:
logger.warning("[Slack] no bot token found for connection=%s", msg.connection_id)
return None
return self._web_client
if self._web_client_factory is None:
from slack_sdk import WebClient
@@ -282,12 +291,15 @@ class SlackChannel(Channel):
# Handle message events (DM or @mention)
if etype in ("message", "app_mention"):
self._handle_message_event(event)
self._handle_message_event(
event,
team_id=req.payload.get("team_id") or req.payload.get("team") or event.get("team"),
)
except Exception:
logger.exception("Error processing Slack event")
def _handle_message_event(self, event: dict) -> None:
def _handle_message_event(self, event: dict, *, team_id: str | None = None) -> None:
# Ignore bot messages
if event.get("bot_id") or event.get("subtype"):
return
@@ -305,6 +317,19 @@ class SlackChannel(Channel):
if not text:
return
connect_code = _extract_connect_code(text)
if connect_code:
if self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(
self._bind_connection_from_connect_code(
event=event,
team_id=str(team_id or event.get("team") or ""),
code=connect_code,
),
self._loop,
)
return
channel_id = event.get("channel", "")
thread_ts = event.get("thread_ts") or event.get("ts", "")
@@ -330,4 +355,73 @@ class SlackChannel(Channel):
self._add_reaction(channel_id, event.get("ts", thread_ts), "eyes")
# Send "running" reply first (fire-and-forget from SDK thread)
self._send_running_reply(channel_id, thread_ts)
asyncio.run_coroutine_threadsafe(self.bus.publish_inbound(inbound), self._loop)
if self._connection_repo is None:
asyncio.run_coroutine_threadsafe(self.bus.publish_inbound(inbound), self._loop)
else:
asyncio.run_coroutine_threadsafe(self._publish_inbound_with_connection(inbound, team_id=team_id), self._loop)
async def _publish_inbound_with_connection(self, inbound, *, team_id: str | None = None) -> None:
inbound = await self._attach_connection_identity(inbound, team_id=team_id)
await self.bus.publish_inbound(inbound)
async def _attach_connection_identity(self, inbound, *, team_id: str | None = None):
if self._connection_repo is None:
return inbound
workspace_id = str(team_id or inbound.metadata.get("team_id") or "")
if not workspace_id:
return inbound
connection = await self._connection_repo.find_connection_by_external_identity(
provider="slack",
external_account_id=inbound.user_id,
workspace_id=workspace_id,
)
if connection is None:
return inbound
inbound.connection_id = connection["id"]
inbound.owner_user_id = connection["owner_user_id"]
inbound.workspace_id = connection.get("workspace_id")
return inbound
async def _bind_connection_from_connect_code(self, *, event: dict, team_id: str, code: str) -> bool:
if self._connection_repo is None or not code:
return False
channel_id = str(event.get("channel") or "")
thread_ts = str(event.get("thread_ts") or event.get("ts") or "")
state = await self._connection_repo.consume_oauth_state(provider="slack", state=code)
if state is None:
self._post_connection_reply(channel_id, "Slack connection code is invalid or expired.", thread_ts)
return True
user_id = str(event.get("user") or "")
if not user_id or not team_id:
self._post_connection_reply(channel_id, "Slack connection could not be completed from this message.", thread_ts)
return True
await self._connection_repo.upsert_connection(
owner_user_id=state["owner_user_id"],
provider="slack",
external_account_id=user_id,
workspace_id=team_id,
metadata={
"team_id": team_id,
"channel_id": channel_id,
},
status="connected",
)
self._post_connection_reply(channel_id, "Slack connected to DeerFlow.", thread_ts)
return True
def _post_connection_reply(self, channel_id: str, text: str, thread_ts: str | None = None) -> None:
if not self._web_client or not channel_id:
return
kwargs: dict[str, Any] = {"channel": channel_id, "text": text}
if thread_ts:
kwargs["thread_ts"] = thread_ts
try:
self._web_client.chat_postMessage(**kwargs)
except Exception:
logger.exception("[Slack] failed to send connection reply in channel=%s", channel_id)
-3
View File
@@ -34,7 +34,6 @@ _PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
"/docs",
"/redoc",
"/openapi.json",
"/api/channels/webhooks/",
)
# Exact auth paths that are public (login/register/status check).
@@ -46,8 +45,6 @@ _PUBLIC_EXACT_PATHS: frozenset[str] = frozenset(
"/api/v1/auth/logout",
"/api/v1/auth/setup-status",
"/api/v1/auth/initialize",
"/api/channels/slack/callback",
"/api/channels/discord/callback",
}
)
-2
View File
@@ -44,8 +44,6 @@ def should_check_csrf(request: Request) -> bool:
return False
path = request.url.path.rstrip("/")
if path.startswith("/api/channels/webhooks/"):
return False
# Exempt /api/v1/auth/me endpoint
if path == "/api/v1/auth/me":
return False
@@ -1,22 +1,16 @@
"""Browser-facing APIs for user-owned IM channel connections."""
"""Browser-facing APIs for user-owned IM channel bindings."""
from __future__ import annotations
import hashlib
import json
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
from urllib.parse import urlencode
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel, Field
from starlette.responses import PlainTextResponse, RedirectResponse
from app.channels.message_bus import InboundMessage, InboundMessageType
from app.channels.providers import discord_connect, slack_connect
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import get_session_factory
router = APIRouter(prefix="/api/channels", tags=["channel-connections"])
@@ -59,14 +53,22 @@ class ChannelConnectionsResponse(BaseModel):
class ChannelConnectResponse(BaseModel):
provider: str
mode: str
url: str
url: str | None = None
code: str
instruction: str
expires_in: int
_PROVIDER_META: dict[str, dict[str, str]] = {
"telegram": {"display_name": "Telegram", "auth_mode": "deep_link"},
"slack": {"display_name": "Slack", "auth_mode": "oauth"},
"discord": {"display_name": "Discord", "auth_mode": "oauth_and_bot_install"},
"slack": {"display_name": "Slack", "auth_mode": "binding_code"},
"discord": {"display_name": "Discord", "auth_mode": "binding_code"},
}
_RUNTIME_REQUIREMENTS: dict[str, tuple[str, ...]] = {
"telegram": ("bot_token",),
"slack": ("bot_token", "app_token"),
"discord": ("bot_token",),
}
@@ -77,14 +79,28 @@ def _get_user_id(request: Request) -> str:
return str(user.id)
def _get_app_config():
from deerflow.config.app_config import get_app_config
return get_app_config()
def _get_channel_connections_config(request: Request) -> ChannelConnectionsConfig:
config = getattr(request.app.state, "channel_connections_config", None)
if isinstance(config, ChannelConnectionsConfig):
return config
return _get_app_config().channel_connections
from deerflow.config.app_config import get_app_config
return get_app_config().channel_connections
def _get_channels_config(request: Request) -> dict[str, Any]:
state_config = getattr(request.app.state, "channels_config", None)
if isinstance(state_config, dict):
return state_config
app_config = _get_app_config()
extra = app_config.model_extra or {}
channels_config = extra.get("channels")
return dict(channels_config) if isinstance(channels_config, dict) else {}
def _get_repository(request: Request, config: ChannelConnectionsConfig) -> ChannelConnectionRepository:
@@ -96,8 +112,7 @@ def _get_repository(request: Request, config: ChannelConnectionsConfig) -> Chann
if sf is None:
raise HTTPException(status_code=503, detail="Channel connection persistence is not available")
cipher = ChannelCredentialCipher.from_key(config.encryption_key) if config.encryption_key else None
repo = ChannelConnectionRepository(sf, cipher=cipher)
repo = ChannelConnectionRepository(sf)
request.app.state.channel_connection_repo = repo
return repo
@@ -109,41 +124,48 @@ def _provider_config(config: ChannelConnectionsConfig, provider: str):
return provider_config
def _provider_unavailable_reason(config: ChannelConnectionsConfig, provider: str) -> str | None:
def _runtime_channel_configured(provider: str, channels_config: dict[str, Any]) -> bool:
runtime_config = channels_config.get(provider)
if not isinstance(runtime_config, dict) or not runtime_config.get("enabled", False):
return False
return all(str(runtime_config.get(key) or "").strip() for key in _RUNTIME_REQUIREMENTS[provider])
def _runtime_unavailable_reason(provider: str) -> str:
keys = " and ".join(f"channels.{provider}.{key}" for key in _RUNTIME_REQUIREMENTS[provider])
return f"Enable and configure channels.{provider} with {keys}."
def _provider_unavailable_reason(
config: ChannelConnectionsConfig,
channels_config: dict[str, Any],
provider: str,
) -> str | None:
provider_config = _provider_config(config, provider)
if not provider_config.enabled or not provider_config.configured:
if not provider_config.enabled:
return None
if provider == "telegram" and getattr(provider_config, "delivery", "polling") == "webhook":
if not provider_config.webhook_secret:
return "Telegram webhook delivery requires channel_connections.telegram.webhook_secret"
if not config.public_base_url:
return "Telegram webhook delivery requires channel_connections.public_base_url; use polling for local/private deployments"
if provider == "slack" and getattr(provider_config, "event_delivery", "http") == "http" and not config.public_base_url:
return "Slack HTTP Events require channel_connections.public_base_url; use a public URL/tunnel or Slack Socket Mode for private deployments"
if provider in {"slack", "discord"} and not config.encryption_key:
display_name = _PROVIDER_META[provider]["display_name"]
return f"{display_name} connections require channel_connections.encryption_key to store OAuth credentials"
if not provider_config.configured:
if provider == "telegram":
return "Configure channel_connections.telegram.bot_username for Telegram deep links."
return f"Configure channel_connections.{provider}."
if not _runtime_channel_configured(provider, channels_config):
return _runtime_unavailable_reason(provider)
return None
def _require_provider_connectable(config: ChannelConnectionsConfig, provider: str) -> None:
reason = _provider_unavailable_reason(config, provider)
if reason:
raise HTTPException(status_code=400, detail=reason)
def _provider_status(
config: ChannelConnectionsConfig,
channels_config: dict[str, Any],
provider: str,
) -> tuple[dict[str, bool], str | None]:
declared = config.provider_status(provider)
unavailable_reason = _provider_unavailable_reason(config, channels_config, provider)
configured = declared["configured"] and _runtime_channel_configured(provider, channels_config)
return {"enabled": declared["enabled"], "configured": configured}, unavailable_reason
def _callback_base_url(config: ChannelConnectionsConfig, request: Request) -> str:
if config.public_base_url:
return config.public_base_url.rstrip("/")
return str(request.base_url).rstrip("/")
def _callback_redirect_uri(config: ChannelConnectionsConfig, request: Request, provider: str) -> str:
return f"{_callback_base_url(config, request)}/api/channels/{provider}/callback"
def _new_binding_code() -> str:
return secrets.token_hex(4)
async def _create_state(
@@ -151,135 +173,40 @@ async def _create_state(
*,
owner_user_id: str,
provider: str,
requested_scopes: list[str] | None = None,
metadata: dict[str, Any] | None = None,
) -> str:
state = secrets.token_urlsafe(32)
state = _new_binding_code()
await repo.create_oauth_state(
owner_user_id=owner_user_id,
provider=provider,
state=state,
requested_scopes=requested_scopes,
metadata=metadata,
expires_at=datetime.now(UTC) + timedelta(seconds=_STATE_TTL_SECONDS),
)
return state
def _build_connect_url(config: ChannelConnectionsConfig, request: Request, provider: str, state: str) -> str:
provider_config = _provider_config(config, provider)
def _connect_instruction(provider: str, code: str) -> str:
if provider == "telegram":
return f"https://t.me/{provider_config.bot_username}?start={state}"
redirect_uri = _callback_redirect_uri(config, request, provider)
return f"Send /start {code} to the DeerFlow Telegram bot."
if provider == "slack":
query = urlencode(
{
"client_id": provider_config.client_id,
"scope": ",".join(provider_config.scopes),
"redirect_uri": redirect_uri,
"state": state,
}
)
return f"https://slack.com/oauth/v2/authorize?{query}"
return f"Send /connect {code} to the DeerFlow Slack bot."
if provider == "discord":
scopes = "identify guilds bot applications.commands"
query = urlencode(
{
"client_id": provider_config.client_id,
"response_type": "code",
"redirect_uri": redirect_uri,
"scope": scopes,
"state": state,
"permissions": provider_config.permissions,
}
)
return f"https://discord.com/oauth2/authorize?{query}"
return f"Send /connect {code} to the DeerFlow Discord bot."
raise HTTPException(status_code=404, detail="Unknown channel provider")
def _callback_redirect(provider: str, state_data: dict[str, Any]) -> RedirectResponse:
redirect_after = state_data.get("redirect_after")
if isinstance(redirect_after, str) and redirect_after:
return RedirectResponse(redirect_after)
return RedirectResponse(f"/workspace?channel_connected={provider}")
def _get_message_bus(request: Request):
bus = getattr(request.app.state, "channel_message_bus", None)
if bus is not None:
return bus
try:
from app.channels.service import get_channel_service
except Exception:
def _connect_url(config: ChannelConnectionsConfig, provider: str, code: str) -> str | None:
if provider == "telegram":
provider_config = _provider_config(config, provider)
return f"https://t.me/{provider_config.bot_username}?start={code}"
if provider in {"slack", "discord"}:
return None
service = get_channel_service()
return service.bus if service is not None else None
def _get_channel_instance(request: Request, name: str):
channel_instances = getattr(request.app.state, "channel_instances", None)
if isinstance(channel_instances, dict) and name in channel_instances:
return channel_instances[name]
try:
from app.channels.service import get_channel_service
except Exception:
return None
service = get_channel_service()
return service.get_channel(name) if service is not None else None
async def _publish_slack_event(
*,
repo: ChannelConnectionRepository,
bus: Any,
payload: dict[str, Any],
) -> bool:
event = payload.get("event") or {}
event_type = event.get("type")
if event_type not in {"message", "app_mention"}:
return False
if event.get("bot_id") or event.get("subtype"):
return False
text = str(event.get("text") or "").strip()
user_id = str(event.get("user") or "")
channel_id = str(event.get("channel") or "")
team_id = str(payload.get("team_id") or event.get("team") or event.get("team_id") or "")
if not text or not user_id or not channel_id or not team_id:
return False
connection = await repo.find_connection_by_external_identity(
provider="slack",
external_account_id=user_id,
workspace_id=team_id,
)
if connection is None:
return False
thread_ts = str(event.get("thread_ts") or event.get("ts") or "")
inbound = InboundMessage(
channel_name="slack",
chat_id=channel_id,
user_id=user_id,
text=text,
msg_type=InboundMessageType.COMMAND if text.startswith("/") else InboundMessageType.CHAT,
thread_ts=thread_ts,
metadata={"team_id": team_id, "event_id": payload.get("event_id")},
connection_id=connection["id"],
owner_user_id=connection["owner_user_id"],
workspace_id=team_id,
)
inbound.topic_id = thread_ts or None
await bus.publish_inbound(inbound)
return True
raise HTTPException(status_code=404, detail="Unknown channel provider")
@router.get("/providers", response_model=ChannelProvidersResponse)
async def get_channel_providers(request: Request) -> ChannelProvidersResponse:
config = _get_channel_connections_config(request)
channels_config = _get_channels_config(request)
repo = None
if config.enabled:
try:
@@ -293,9 +220,8 @@ async def get_channel_providers(request: Request) -> ChannelProvidersResponse:
providers: list[ChannelProviderResponse] = []
for provider, meta in _PROVIDER_META.items():
status = config.provider_status(provider)
status, unavailable_reason = _provider_status(config, channels_config, provider)
connection = by_provider.get(provider)
unavailable_reason = _provider_unavailable_reason(config, provider)
providers.append(
ChannelProviderResponse(
provider=provider,
@@ -337,199 +263,32 @@ async def disconnect_channel_connection(connection_id: str, request: Request) ->
return Response(status_code=204)
@router.get("/slack/callback")
async def slack_oauth_callback(request: Request, code: str | None = None, state: str | None = None, error: str | None = None):
if error:
raise HTTPException(status_code=400, detail=f"Slack OAuth failed: {error}")
if not code or not state:
raise HTTPException(status_code=400, detail="Slack OAuth callback is missing code or state")
config = _get_channel_connections_config(request)
provider_config = _provider_config(config, "slack")
if not config.enabled or not provider_config.enabled or not provider_config.configured:
raise HTTPException(status_code=400, detail="Channel provider is not configured")
repo = _get_repository(request, config)
state_data = await repo.consume_oauth_state(provider="slack", state=state)
if state_data is None:
raise HTTPException(status_code=400, detail="Invalid or expired OAuth state")
redirect_uri = _callback_redirect_uri(config, request, "slack")
install = await slack_connect.exchange_slack_oauth_code(
client_id=provider_config.client_id,
client_secret=provider_config.client_secret,
code=code,
redirect_uri=redirect_uri,
)
connection = await repo.upsert_connection(
owner_user_id=state_data["owner_user_id"],
provider="slack",
external_account_id=install.authed_user_id,
workspace_id=install.team_id,
workspace_name=install.team_name,
bot_user_id=install.bot_user_id,
scopes=install.scopes or state_data.get("requested_scopes", []),
metadata={"team_id": install.team_id, "team_name": install.team_name},
status="connected",
)
await repo.store_credentials(
connection["id"],
access_token=install.bot_access_token,
token_type="Bearer",
extra={"bot_user_id": install.bot_user_id, "team_id": install.team_id},
)
return _callback_redirect("slack", state_data)
@router.get("/discord/callback")
async def discord_oauth_callback(request: Request, code: str | None = None, state: str | None = None, error: str | None = None):
if error:
raise HTTPException(status_code=400, detail=f"Discord OAuth failed: {error}")
if not code or not state:
raise HTTPException(status_code=400, detail="Discord OAuth callback is missing code or state")
config = _get_channel_connections_config(request)
provider_config = _provider_config(config, "discord")
if not config.enabled or not provider_config.enabled or not provider_config.configured:
raise HTTPException(status_code=400, detail="Channel provider is not configured")
repo = _get_repository(request, config)
state_data = await repo.consume_oauth_state(provider="discord", state=state)
if state_data is None:
raise HTTPException(status_code=400, detail="Invalid or expired OAuth state")
redirect_uri = _callback_redirect_uri(config, request, "discord")
identity = await discord_connect.complete_discord_oauth(
client_id=provider_config.client_id,
client_secret=provider_config.client_secret,
code=code,
redirect_uri=redirect_uri,
)
connection = await repo.upsert_connection(
owner_user_id=state_data["owner_user_id"],
provider="discord",
external_account_id=identity.user_id,
external_account_name=identity.display_name or identity.username,
scopes=identity.scopes or state_data.get("requested_scopes", []),
capabilities={"message_content_intent_required": provider_config.require_message_content_intent},
metadata={"username": identity.username, "guilds": identity.guilds},
status="connected",
)
await repo.store_credentials(
connection["id"],
access_token=identity.access_token,
refresh_token=identity.refresh_token,
token_type=identity.token_type,
expires_at=identity.expires_at,
extra={"guilds": identity.guilds},
)
return _callback_redirect("discord", state_data)
@router.post("/webhooks/slack/events")
async def slack_events_webhook(request: Request):
config = _get_channel_connections_config(request)
provider_config = _provider_config(config, "slack")
if not config.enabled or not provider_config.enabled or not provider_config.configured:
raise HTTPException(status_code=400, detail="Channel provider is not configured")
body = await request.body()
if not slack_connect.verify_slack_signature(
signing_secret=provider_config.signing_secret,
timestamp=request.headers.get("X-Slack-Request-Timestamp"),
body=body,
signature=request.headers.get("X-Slack-Signature"),
):
raise HTTPException(status_code=401, detail="Invalid Slack signature")
try:
payload = json.loads(body.decode("utf-8"))
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="Invalid Slack payload") from exc
if payload.get("type") == "url_verification":
challenge = payload.get("challenge")
if not isinstance(challenge, str):
raise HTTPException(status_code=400, detail="Slack challenge is missing")
return PlainTextResponse(challenge)
repo = _get_repository(request, config)
delivery_id = str(payload.get("event_id") or hashlib.sha256(body).hexdigest())
payload_hash = hashlib.sha256(body).hexdigest()
event = payload.get("event") or {}
is_new = await repo.record_webhook_delivery(
provider="slack",
delivery_id=delivery_id,
payload_sha256=payload_hash,
event_type=event.get("type"),
)
if not is_new:
return {"ok": True, "duplicate": True, "processed": False}
bus = _get_message_bus(request)
processed = False
if bus is not None:
processed = await _publish_slack_event(repo=repo, bus=bus, payload=payload)
return {"ok": True, "processed": processed}
@router.post("/webhooks/telegram")
async def telegram_webhook(request: Request):
config = _get_channel_connections_config(request)
provider_config = _provider_config(config, "telegram")
if not config.enabled or not provider_config.enabled or not provider_config.configured:
raise HTTPException(status_code=400, detail="Channel provider is not configured")
secret_header = request.headers.get("X-Telegram-Bot-Api-Secret-Token")
if not secret_header or not secrets.compare_digest(secret_header, provider_config.webhook_secret):
raise HTTPException(status_code=401, detail="Invalid Telegram webhook secret")
body = await request.body()
try:
payload = json.loads(body.decode("utf-8"))
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail="Invalid Telegram payload") from exc
repo = _get_repository(request, config)
delivery_id = str(payload.get("update_id") or hashlib.sha256(body).hexdigest())
is_new = await repo.record_webhook_delivery(
provider="telegram",
delivery_id=delivery_id,
payload_sha256=hashlib.sha256(body).hexdigest(),
event_type="update",
)
if not is_new:
return {"ok": True, "duplicate": True, "processed": False}
processed = False
channel = _get_channel_instance(request, "telegram")
process_update = getattr(channel, "process_webhook_update", None)
if process_update is not None:
processed = bool(await process_update(payload))
return {"ok": True, "processed": processed}
@router.post("/{provider}/connect", response_model=ChannelConnectResponse)
async def connect_channel_provider(provider: str, request: Request) -> ChannelConnectResponse:
config = _get_channel_connections_config(request)
channels_config = _get_channels_config(request)
if not config.enabled:
raise HTTPException(status_code=400, detail="Channel connections are disabled")
provider_config = _provider_config(config, provider)
if not provider_config.enabled or not provider_config.configured:
status, unavailable_reason = _provider_status(config, channels_config, provider)
if not status["enabled"]:
raise HTTPException(status_code=400, detail="Channel provider is not enabled")
if unavailable_reason:
raise HTTPException(status_code=400, detail=unavailable_reason)
if not status["configured"]:
raise HTTPException(status_code=400, detail="Channel provider is not configured")
_require_provider_connectable(config, provider)
repo = _get_repository(request, config)
state = await _create_state(
code = await _create_state(
repo,
owner_user_id=_get_user_id(request),
provider=provider,
requested_scopes=getattr(provider_config, "scopes", []),
)
return ChannelConnectResponse(
provider=provider,
mode=_PROVIDER_META[provider]["auth_mode"],
url=_build_connect_url(config, request, provider, state),
url=_connect_url(config, provider, code),
code=code,
instruction=_connect_instruction(provider, code),
expires_in=_STATE_TTL_SECONDS,
)