mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-10 17:35:57 +00:00
Align IM connections with local channels
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user