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
+1 -1
View File
@@ -343,7 +343,7 @@ See the [MCP Server Guide](backend/docs/MCP_SERVER.md) for detailed instructions
DeerFlow supports receiving tasks from messaging apps. Channels auto-start when configured — no public IP required for any of them.
DeerFlow can also expose user-owned IM channel connections in the workspace UI. When `channel_connections` is enabled, logged-in users can connect Telegram, Slack, or Discord from the sidebar / Settings > Channels. Stored provider OAuth credentials are encrypted at rest when a provider needs them, and incoming IM messages run under the connected DeerFlow user account. See [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md) for local/private mode, OAuth callback URLs, webhook setup, and security notes.
DeerFlow can also expose user-owned IM channel connections in the workspace UI. When `channel_connections` is enabled, logged-in users can bind Telegram, Slack, or Discord from the sidebar / Settings > Channels. It reuses the existing outbound `channels.*` transports, so no public IP or provider callback URL is required. Incoming IM messages then run under the connected DeerFlow user account. See [IM Channel Connections](backend/docs/IM_CHANNEL_CONNECTIONS.md) for setup and security notes.
| Channel | Transport | Difficulty |
|---------|-----------|------------|
+8 -9
View File
@@ -380,8 +380,8 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk
- `base.py` - Abstract `Channel` base class (start/stop/send lifecycle)
- `service.py` - Manages lifecycle of all configured channels from `config.yaml`
- `slack.py` / `feishu.py` / `telegram.py` / `discord.py` / `dingtalk.py` - Platform-specific implementations (`feishu.py` tracks the running card `message_id` in memory and patches the same card in place; `dingtalk.py` optionally uses AI Card streaming for in-place updates when `card_template_id` is configured)
- `app/gateway/routers/channel_connections.py` - Browser-facing user connection APIs plus provider callbacks/webhooks
- `deerflow.persistence.channel_connections` - SQL-backed user-owned connection, credential, OAuth state, conversation, and webhook delivery store
- `app/gateway/routers/channel_connections.py` - Browser-facing user connection and disconnect APIs
- `deerflow.persistence.channel_connections` - SQL-backed user-owned connection, optional credential, connect state, and conversation store
**Message Flow**:
1. External platform -> Channel impl -> `MessageBus.publish_inbound()`
@@ -402,14 +402,13 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk
- Per-channel configs: `feishu` (app_id, app_secret), `slack` (bot_token, app_token), `telegram` (bot_token), `dingtalk` (client_id, client_secret, optional `card_template_id` for AI Card streaming)
**User-owned channel connections** (`config.yaml` -> `channel_connections`):
- Disabled by default. `mode: local` is the default for local/private deployments; `mode: public` requires `public_base_url`.
- `public_base_url` is only required for provider-to-server public callbacks/webhooks such as Slack HTTP Events and Telegram webhooks. If it is omitted, OAuth redirect URLs fall back to the current request origin for localhost/private testing.
- `encryption_key` is required before storing provider OAuth credentials for Slack and Discord. Telegram deep-link binding can run without it because it stores only connection identity/state, not per-user provider tokens.
- Disabled by default. It is a user-binding layer on top of the existing `channels.*` runtime config, not a replacement for provider bot credentials.
- No public IP, OAuth callback URL, or provider webhook route is required by the current implementation.
- Telegram uses a deep-link `/start <code>` flow over the existing long-polling worker. Slack uses `/connect <code>` over the existing Socket Mode worker. Discord uses `/connect <code>` over the existing Gateway worker.
- Frontend APIs: `GET /api/channels/providers`, `GET /api/channels/connections`, `POST /api/channels/{provider}/connect`, and `DELETE /api/channels/connections/{connection_id}`.
- Public provider routes: Slack/Discord OAuth callbacks and Slack/Telegram webhooks are explicitly allowed through AuthMiddleware; webhooks are exempt from CSRF because they are provider-to-server calls and validate Slack signatures or Telegram secret tokens.
- Slack HTTP Events mode uses per-connection encrypted bot tokens for replies. Legacy Slack Socket Mode remains available through the `channels.slack` config.
- Telegram supports frontend deep-link binding and can process signed webhook updates; long polling remains the local/self-host fallback.
- Discord OAuth stores the user identity and guild metadata; Gateway messages from `discord.py` resolve to connection identity before reaching `ChannelManager`.
- Browser APIs remain protected by normal Gateway auth/CSRF. Provider messages arrive through the already-configured channel workers.
- Slack replies use the configured operator bot token from `channels.slack` unless a future provider-token flow stores per-connection credentials.
- Telegram, Slack, and Discord workers resolve incoming platform identities to connection records before reaching `ChannelManager`.
- See `backend/docs/IM_CHANNEL_CONNECTIONS.md` for provider setup and operational notes.
+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,
)
+41 -64
View File
@@ -1,107 +1,84 @@
# IM Channel Connections
DeerFlow supports user-owned IM channel connections for Telegram, Slack, and Discord. A logged-in user connects a provider from the frontend, and incoming IM messages run under that DeerFlow user account instead of the raw platform user id.
DeerFlow supports user-owned IM channel bindings for Telegram, Slack, and Discord. The feature reuses the existing `channels.*` runtime configuration, so it works in local and private deployments with the same outbound transports already supported by DeerFlow.
No public IP, OAuth callback URL, or provider webhook is required in this implementation.
## Configuration
Enable the top-level `channel_connections` block in `config.yaml`:
Local/private deployment:
Configure the actual IM bots under the existing `channels` block:
```yaml
channel_connections:
enabled: true
mode: local
channels:
telegram:
enabled: true
bot_token: $TELEGRAM_BOT_TOKEN
bot_username: $TELEGRAM_BOT_USERNAME
```
This mode is intended for a DeerFlow instance running on a developer machine or a private network. Telegram uses the existing long-polling worker, so it does not need a public URL. The frontend `Connect` button returns a Telegram deep link and stores a one-time state locally so the `/start` message can bind the Telegram chat to the current DeerFlow user.
Public deployment:
```yaml
channel_connections:
enabled: true
mode: public
public_base_url: https://deerflow.example.com
encryption_key: $DEER_FLOW_CHANNEL_CONNECTIONS_KEY
telegram:
enabled: true
bot_token: $TELEGRAM_BOT_TOKEN
bot_username: $TELEGRAM_BOT_USERNAME
webhook_secret: $TELEGRAM_WEBHOOK_SECRET
slack:
enabled: true
client_id: $SLACK_CLIENT_ID
client_secret: $SLACK_CLIENT_SECRET
signing_secret: $SLACK_SIGNING_SECRET
event_delivery: http
bot_token: $SLACK_BOT_TOKEN
app_token: $SLACK_APP_TOKEN
discord:
enabled: true
client_id: $DISCORD_CLIENT_ID
client_secret: $DISCORD_CLIENT_SECRET
bot_token: $DISCORD_BOT_TOKEN
permissions: "274877975552"
```
`public_base_url` is only required for public callback/webhook deployments. If it is omitted, OAuth redirect URLs are built from the current request origin, which is suitable for localhost development when the provider allows an exact localhost redirect URI. Provider-to-server webhooks such as Slack HTTP Events and Telegram webhooks still need a reachable public URL or a tunnel.
Then enable user bindings in `channel_connections`:
`encryption_key` encrypts provider tokens at rest with Fernet. Telegram deep-link binding does not store user provider tokens, so it can run locally without this key. Slack and Discord connections store OAuth credentials and require a stable key; v1 does not support transparent key rotation, so changing it requires users to reconnect.
```yaml
channel_connections:
enabled: true
## Frontend Flow
telegram:
enabled: true
bot_username: $TELEGRAM_BOT_USERNAME
The workspace sidebar shows a Channels group with Telegram, Slack, and Discord. Settings > Channels exposes the management surface for connect, disconnect, and reconnect. Browser state-changing calls use the existing CSRF-aware frontend fetch wrapper.
slack:
enabled: true
## Provider Setup
discord:
enabled: true
```
`channel_connections` does not duplicate provider secrets. It only controls the browser-facing connect UI and stores per-user binding records. Telegram needs `bot_username` only so the frontend can open a deep link.
## Connect Flow
Telegram:
- Register a bot with BotFather.
- Configure the bot username and bot token.
- Users connect with a deep link: `https://t.me/<bot_username>?start=<state>`.
- Local/private delivery uses the existing long-polling channel worker and does not require `public_base_url`.
- Production webhook path: `POST /api/channels/webhooks/telegram`, protected by `X-Telegram-Bot-Api-Secret-Token`; webhook delivery requires `webhook_secret` and a public `public_base_url`.
- The frontend creates a short one-time code.
- The Connect button opens `https://t.me/<bot_username>?start=<code>`.
- The existing Telegram long-polling worker receives `/start <code>` and binds that Telegram chat/user to the current DeerFlow user.
Slack:
- Create a Slack app with OAuth V2.
- Redirect URL: `https://<public_base_url>/api/channels/slack/callback`.
- Event request URL: `https://<public_base_url>/api/channels/webhooks/slack/events`.
- Required signing secret: Slack's request signing secret, not the deprecated verification token.
- Suggested MVP bot scopes: `app_mentions:read`, `chat:write`, `channels:history`, `channels:read`.
- Slack events are signature-verified, deduplicated by `event_id`, and then routed to a matching user connection.
- In local/private mode, Slack HTTP Events are reported as unavailable unless `public_base_url` is set to a tunnel or public HTTPS URL.
- The frontend creates a short one-time code.
- The UI shows `Send /connect <code> to the DeerFlow Slack bot.`
- The existing Slack Socket Mode worker receives the message and binds the Slack user/team to the current DeerFlow user.
Discord:
- Create a Discord application and bot.
- Redirect URL: `https://<public_base_url>/api/channels/discord/callback` in public mode, or the matching localhost callback URL in local development if the Discord application is configured to allow it.
- DeerFlow starts OAuth with `identify guilds bot applications.commands` and the configured bot permissions.
- The Discord Gateway is still handled by `discord.py`; message content may require the privileged Message Content Intent depending on your bot setup.
- The frontend creates a short one-time code.
- The UI shows `Send /connect <code> to the DeerFlow Discord bot.`
- The existing Discord Gateway worker receives the message and binds the Discord user/guild to the current DeerFlow user.
Codes expire after 10 minutes and are single-use.
## Runtime Model
Connection records live in SQL tables under `deerflow.persistence.channel_connections`:
- `channel_connections`: owner user, provider identity, workspace/guild/team, status, metadata.
- `channel_credentials`: encrypted access/refresh/bot tokens.
- `channel_oauth_states`: one-time OAuth/deep-link states.
- `channel_oauth_states`: one-time connect codes and Telegram deep-link state.
- `channel_conversations`: connection-scoped IM conversation to DeerFlow thread mapping.
- `channel_webhook_deliveries`: provider webhook dedupe records.
- `channel_credentials`: reserved for future provider-token flows, not used by the local/private binding flow.
Incoming messages that resolve to a connection carry `connection_id`, `owner_user_id`, and `workspace_id`. `ChannelManager` uses `owner_user_id` as the DeerFlow run user id and preserves the platform user id as `channel_user_id`. Legacy operator-owned channels keep the existing JSON `ChannelStore` behavior when no `connection_id` is present.
Incoming messages that resolve to a connection carry `connection_id`, `owner_user_id`, and `workspace_id`. `ChannelManager` uses `owner_user_id` as the DeerFlow run user id and preserves the raw platform user id as `channel_user_id`.
## Security Notes
- OAuth state tokens are one-time and short-lived.
- Provider tokens are never returned from browser APIs.
- Public callback/webhook routes bypass cookie auth only because they validate provider state/signatures/secrets themselves.
- Slack and Telegram webhooks skip CSRF because they are called by providers, not browsers.
- Logs should never include access tokens, refresh tokens, bot tokens, OAuth codes, or raw signed webhook bodies.
- Browser APIs remain authenticated and CSRF-protected.
- Connect codes are random, short-lived, and single-use.
- Provider bot tokens remain in `channels.*` and are never returned to the browser.
- This implementation does not add public provider callback or webhook routes.
@@ -2,88 +2,48 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field, model_validator
ChannelConnectionMode = Literal["local", "private", "public"]
TelegramDeliveryMode = Literal["polling", "webhook"]
from pydantic import BaseModel, Field
class SlackChannelConnectionConfig(BaseModel):
enabled: bool = False
client_id: str = ""
client_secret: str = ""
signing_secret: str = ""
scopes: list[str] = Field(
default_factory=lambda: [
"app_mentions:read",
"chat:write",
"channels:history",
"channels:read",
]
)
event_delivery: str = "http"
@property
def configured(self) -> bool:
return bool(self.client_id and self.client_secret and self.signing_secret)
return True
class TelegramChannelConnectionConfig(BaseModel):
enabled: bool = False
bot_token: str = ""
bot_username: str = ""
delivery: TelegramDeliveryMode = "polling"
webhook_secret: str = ""
oidc_client_id: str = ""
oidc_client_secret: str = ""
@property
def configured(self) -> bool:
if self.delivery == "webhook":
return bool(self.bot_token and self.bot_username and self.webhook_secret)
return bool(self.bot_token and self.bot_username)
return bool(self.bot_username)
class DiscordChannelConnectionConfig(BaseModel):
enabled: bool = False
client_id: str = ""
client_secret: str = ""
bot_token: str = ""
permissions: str = ""
require_message_content_intent: bool = True
@property
def configured(self) -> bool:
return bool(self.client_id and self.client_secret and self.bot_token)
return True
class ChannelConnectionsConfig(BaseModel):
"""Top-level config for browser-connectable IM channels."""
enabled: bool = False
mode: ChannelConnectionMode = "local"
public_base_url: str = ""
encryption_key: str = ""
slack: SlackChannelConnectionConfig = Field(default_factory=SlackChannelConnectionConfig)
telegram: TelegramChannelConnectionConfig = Field(default_factory=TelegramChannelConnectionConfig)
discord: DiscordChannelConnectionConfig = Field(default_factory=DiscordChannelConnectionConfig)
@model_validator(mode="after")
def _require_shared_config_when_enabled(self) -> ChannelConnectionsConfig:
missing: list[str] = []
if self.enabled and self.mode == "public" and not self.public_base_url:
missing.append("public_base_url is required when channel_connections.mode is public")
if missing:
raise ValueError("; ".join(missing))
return self
def provider_status(self, provider: str) -> dict[str, bool]:
config = getattr(self, provider, None)
if config is None:
return {"enabled": False, "configured": False}
enabled = bool(config.enabled)
return {
"enabled": bool(config.enabled),
"configured": bool(config.configured),
"enabled": enabled,
"configured": enabled and bool(config.configured),
}
@@ -5,7 +5,6 @@ from deerflow.persistence.channel_connections.model import (
ChannelConversationRow,
ChannelCredentialRow,
ChannelOAuthStateRow,
ChannelWebhookDeliveryRow,
)
from deerflow.persistence.channel_connections.sql import (
ChannelConnectionRepository,
@@ -19,5 +18,4 @@ __all__ = [
"ChannelCredentialCipher",
"ChannelCredentialRow",
"ChannelOAuthStateRow",
"ChannelWebhookDeliveryRow",
]
@@ -109,13 +109,3 @@ class ChannelConversationRow(Base):
name="uq_channel_conversation_connection_external",
),
)
class ChannelWebhookDeliveryRow(Base):
__tablename__ = "channel_webhook_deliveries"
provider: Mapped[str] = mapped_column(String(32), primary_key=True)
delivery_id: Mapped[str] = mapped_column(String(128), primary_key=True)
payload_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
event_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
processed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utc_now)
@@ -18,7 +18,6 @@ from deerflow.persistence.channel_connections.model import (
ChannelConversationRow,
ChannelCredentialRow,
ChannelOAuthStateRow,
ChannelWebhookDeliveryRow,
)
from deerflow.utils.time import coerce_iso
@@ -345,30 +344,3 @@ class ChannelConnectionRepository:
ChannelConversationRow.external_topic_id == (external_topic_id or ""),
)
return (await session.execute(stmt)).scalar_one_or_none()
async def record_webhook_delivery(
self,
*,
provider: str,
delivery_id: str,
payload_sha256: str,
event_type: str | None = None,
) -> bool:
async with self.session_factory() as session:
existing = await session.get(
ChannelWebhookDeliveryRow,
{"provider": provider, "delivery_id": delivery_id},
)
if existing is not None:
return False
session.add(
ChannelWebhookDeliveryRow(
provider=provider,
delivery_id=delivery_id,
payload_sha256=payload_sha256,
event_type=event_type,
)
)
await session.commit()
return True
@@ -19,7 +19,6 @@ from deerflow.persistence.channel_connections.model import (
ChannelConversationRow,
ChannelCredentialRow,
ChannelOAuthStateRow,
ChannelWebhookDeliveryRow,
)
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.models.run_event import RunEventRow
@@ -32,7 +31,6 @@ __all__ = [
"ChannelConversationRow",
"ChannelCredentialRow",
"ChannelOAuthStateRow",
"ChannelWebhookDeliveryRow",
"FeedbackRow",
"RunEventRow",
"RunRow",
+2 -4
View File
@@ -22,10 +22,6 @@ from app.gateway.csrf_middleware import CSRFMiddleware
"/api/v1/auth/register",
"/api/v1/auth/logout",
"/api/v1/auth/setup-status",
"/api/channels/slack/callback",
"/api/channels/discord/callback",
"/api/channels/webhooks/slack/events",
"/api/channels/webhooks/telegram",
],
)
def test_public_paths(path: str):
@@ -43,6 +39,8 @@ def test_public_paths(path: str):
"/api/threads/123/uploads",
"/api/agents",
"/api/channels",
"/api/channels/providers",
"/api/channels/slack/connect",
"/api/runs/stream",
"/api/threads/123/runs",
"/api/v1/auth/me",
@@ -1,8 +1,5 @@
"""Tests for user-facing IM channel connection configuration."""
import pytest
from pydantic import ValidationError
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
@@ -10,67 +7,34 @@ def test_channel_connections_disabled_by_default():
config = ChannelConnectionsConfig()
assert config.enabled is False
assert config.public_base_url == ""
assert config.encryption_key == ""
assert config.slack.enabled is False
assert config.telegram.enabled is False
assert config.discord.enabled is False
def test_enabled_channel_connections_can_run_in_local_mode_without_public_url_or_encryption_key():
def test_enabled_channel_connections_do_not_require_public_url_or_encryption_key():
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "local",
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
},
"slack": {"enabled": True},
"discord": {"enabled": True},
}
)
assert config.public_base_url == ""
assert config.encryption_key == ""
assert config.enabled is True
assert config.provider_status("telegram") == {"enabled": True, "configured": True}
def test_public_mode_requires_public_url():
with pytest.raises(ValidationError) as excinfo:
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "public",
"encryption_key": "test-secret",
}
)
assert "public_base_url is required when channel_connections.mode is public" in str(excinfo.value)
def test_provider_config_completeness_is_reported_without_crashing():
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "test-secret",
"slack": {
"enabled": True,
"client_id": "slack-client",
"client_secret": "slack-secret",
"signing_secret": "slack-signing",
},
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
"webhook_secret": "telegram-webhook",
},
"discord": {"enabled": True, "client_id": "discord-client"},
}
)
assert config.provider_status("slack") == {"enabled": True, "configured": True}
assert config.provider_status("telegram") == {"enabled": True, "configured": True}
assert config.provider_status("discord") == {"enabled": True, "configured": False}
assert config.provider_status("discord") == {"enabled": True, "configured": True}
def test_provider_status_reports_disabled_and_unknown_providers():
config = ChannelConnectionsConfig.model_validate({"enabled": True})
assert config.provider_status("slack") == {"enabled": False, "configured": False}
assert config.provider_status("telegram") == {"enabled": False, "configured": False}
assert config.provider_status("discord") == {"enabled": False, "configured": False}
assert config.provider_status("unknown") == {"enabled": False, "configured": False}
@@ -12,7 +12,6 @@ from deerflow.persistence.channel_connections import (
ChannelConnectionRow,
ChannelCredentialCipher,
ChannelCredentialRow,
ChannelWebhookDeliveryRow,
)
@@ -201,25 +200,3 @@ class TestChannelConnectionRepository:
assert disconnected is False
assert (await repo.list_connections("alice"))[0]["status"] == "connected"
@pytest.mark.anyio
async def test_record_webhook_delivery_returns_false_for_duplicate_delivery_id(self, repo):
first = await repo.record_webhook_delivery(
provider="slack",
delivery_id="Ev123",
payload_sha256="abc",
event_type="app_mention",
)
second = await repo.record_webhook_delivery(
provider="slack",
delivery_id="Ev123",
payload_sha256="abc",
event_type="app_mention",
)
assert first is True
assert second is False
async with repo.session_factory() as session:
rows = (await session.execute(select(ChannelWebhookDeliveryRow))).scalars().all()
assert len(rows) == 1
assert rows[0].event_type == "app_mention"
+87 -415
View File
@@ -2,15 +2,11 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from urllib.parse import parse_qs, urlparse
from uuid import UUID
from _router_auth_helpers import make_authed_test_app
from fastapi.testclient import TestClient
from app.channels.providers.discord_connect import DiscordIdentity
from app.channels.providers.slack_connect import SlackInstall
from app.gateway.auth.models import User
from app.gateway.routers import channel_connections
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
@@ -25,42 +21,47 @@ def _user() -> User:
)
async def _make_repo(tmp_path, encryption_key: str | None = "router-secret"):
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
async def _make_repo(tmp_path):
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import get_session_factory, init_engine
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'router.db'}", sqlite_dir=str(tmp_path))
cipher = ChannelCredentialCipher.from_key(encryption_key) if encryption_key else None
return ChannelConnectionRepository(get_session_factory(), cipher=cipher)
return ChannelConnectionRepository(get_session_factory())
def _make_app(config: ChannelConnectionsConfig, repo):
def _make_app(config: ChannelConnectionsConfig, repo, channels_config: dict | None = None):
app = make_authed_test_app(user_factory=_user)
app.state.channel_connections_config = config
app.state.channel_connection_repo = repo
app.state.channels_config = channels_config or {}
app.include_router(channel_connections.router)
return app
def test_get_providers_returns_catalog_and_current_status(tmp_path):
def _enabled_connections_config() -> ChannelConnectionsConfig:
return ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"telegram": {"enabled": True, "bot_username": "deerflow_bot"},
"slack": {"enabled": True},
"discord": {"enabled": True},
}
)
def _channels_config() -> dict:
return {
"telegram": {"enabled": True, "bot_token": "telegram-token"},
"slack": {"enabled": True, "bot_token": "xoxb-operator", "app_token": "xapp-operator"},
"discord": {"enabled": True, "bot_token": "discord-bot"},
}
def test_get_providers_uses_existing_channels_config(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
"webhook_secret": "telegram-secret",
},
"slack": {"enabled": True, "client_id": "slack-client"},
}
)
app = _make_app(config, repo)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.get("/api/channels/providers")
@@ -68,13 +69,34 @@ def test_get_providers_returns_catalog_and_current_status(tmp_path):
assert response.status_code == 200
body = response.json()
assert body["enabled"] is True
telegram = next(item for item in body["providers"] if item["provider"] == "telegram")
slack = next(item for item in body["providers"] if item["provider"] == "slack")
assert telegram["enabled"] is True
assert telegram["configured"] is True
assert telegram["connection_status"] == "not_connected"
assert slack["enabled"] is True
assert slack["configured"] is False
by_provider = {item["provider"]: item for item in body["providers"]}
assert by_provider["telegram"]["configured"] is True
assert by_provider["telegram"]["auth_mode"] == "deep_link"
assert by_provider["slack"]["configured"] is True
assert by_provider["slack"]["auth_mode"] == "binding_code"
assert by_provider["discord"]["configured"] is True
assert by_provider["discord"]["auth_mode"] == "binding_code"
anyio.run(repo.close)
def test_get_providers_reports_unconfigured_when_runtime_channel_is_missing(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, {"telegram": {"enabled": True, "bot_token": "telegram-token"}})
with TestClient(app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
by_provider = {item["provider"]: item for item in response.json()["providers"]}
assert by_provider["telegram"]["configured"] is True
assert by_provider["slack"]["configured"] is False
assert by_provider["slack"]["connectable"] is False
assert "channels.slack" in by_provider["slack"]["unavailable_reason"]
assert by_provider["discord"]["configured"] is False
assert "channels.discord" in by_provider["discord"]["unavailable_reason"]
anyio.run(repo.close)
@@ -101,16 +123,7 @@ def test_get_connections_returns_current_user_connections_only(tmp_path):
)
anyio.run(seed_connections)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
}
),
repo,
)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.get("/api/channels/connections")
@@ -128,22 +141,7 @@ def test_connect_telegram_returns_deep_link_and_persists_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
"webhook_secret": "telegram-secret",
},
}
),
repo,
)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.post("/api/channels/telegram/connect")
@@ -153,6 +151,8 @@ def test_connect_telegram_returns_deep_link_and_persists_state(tmp_path):
assert body["provider"] == "telegram"
assert body["mode"] == "deep_link"
assert body["url"].startswith("https://t.me/deerflow_bot?start=")
assert body["code"]
assert "/start" in body["instruction"]
async def count_states():
return await repo.count_oauth_states(owner_user_id=str(_user().id), provider="telegram")
@@ -162,375 +162,67 @@ def test_connect_telegram_returns_deep_link_and_persists_state(tmp_path):
anyio.run(repo.close)
def test_connect_telegram_local_mode_without_public_url_or_encryption_key(tmp_path):
def test_connect_slack_returns_binding_command_and_persists_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path, None)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "local",
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
},
}
),
repo,
)
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.post("/api/channels/telegram/connect")
response = client.post("/api/channels/slack/connect")
assert response.status_code == 200
body = response.json()
assert body["provider"] == "telegram"
assert body["url"].startswith("https://t.me/deerflow_bot?start=")
assert body["provider"] == "slack"
assert body["mode"] == "binding_code"
assert body["url"] is None
assert body["code"]
assert body["instruction"] == f"Send /connect {body['code']} to the DeerFlow Slack bot."
async def count_states():
return await repo.count_oauth_states(owner_user_id=str(_user().id), provider="telegram")
return await repo.count_oauth_states(owner_user_id=str(_user().id), provider="slack")
assert anyio.run(count_states) == 1
anyio.run(repo.close)
def test_get_providers_reports_slack_http_unavailable_without_public_url(tmp_path):
def test_connect_discord_returns_binding_command_and_persists_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "local",
"encryption_key": "router-secret",
"slack": {
"enabled": True,
"client_id": "slack-client",
"client_secret": "slack-secret",
"signing_secret": "slack-signing-secret",
"event_delivery": "http",
},
}
)
app = _make_app(config, repo)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.get("/api/channels/providers")
response = client.post("/api/channels/discord/connect")
assert response.status_code == 200
slack = next(item for item in response.json()["providers"] if item["provider"] == "slack")
assert slack["enabled"] is True
assert slack["configured"] is True
assert slack["connectable"] is False
assert "public_base_url" in slack["unavailable_reason"]
body = response.json()
assert body["provider"] == "discord"
assert body["mode"] == "binding_code"
assert body["url"] is None
assert body["code"]
assert body["instruction"] == f"Send /connect {body['code']} to the DeerFlow Discord bot."
async def count_states():
return await repo.count_oauth_states(owner_user_id=str(_user().id), provider="discord")
assert anyio.run(count_states) == 1
anyio.run(repo.close)
def test_connect_unconfigured_provider_returns_400(tmp_path):
def test_connect_unconfigured_runtime_channel_returns_400(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
"slack": {"enabled": True, "client_id": "slack-client"},
}
),
repo,
)
app = _make_app(_enabled_connections_config(), repo, {})
with TestClient(app) as client:
response = client.post("/api/channels/slack/connect")
assert response.status_code == 400
assert response.json()["detail"] == "Channel provider is not configured"
anyio.run(repo.close)
def test_connect_slack_http_without_public_url_returns_400(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "local",
"encryption_key": "router-secret",
"slack": {
"enabled": True,
"client_id": "slack-client",
"client_secret": "slack-secret",
"signing_secret": "slack-signing-secret",
"event_delivery": "http",
},
}
),
repo,
)
with TestClient(app) as client:
response = client.post("/api/channels/slack/connect")
assert response.status_code == 400
assert "public_base_url" in response.json()["detail"]
anyio.run(repo.close)
def test_connect_discord_uses_request_base_url_without_public_base_url(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "local",
"encryption_key": "router-secret",
"discord": {
"enabled": True,
"client_id": "discord-client",
"client_secret": "discord-secret",
"bot_token": "discord-bot",
"permissions": "274877975552",
},
}
),
repo,
)
with TestClient(app, base_url="http://localhost:2026") as client:
response = client.post("/api/channels/discord/connect")
assert response.status_code == 200
parsed = urlparse(response.json()["url"])
query = parse_qs(parsed.query)
assert query["redirect_uri"] == ["http://localhost:2026/api/channels/discord/callback"]
anyio.run(repo.close)
def test_connect_discord_without_encryption_key_returns_400(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path, None)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "local",
"discord": {
"enabled": True,
"client_id": "discord-client",
"client_secret": "discord-secret",
"bot_token": "discord-bot",
},
}
),
repo,
)
with TestClient(app) as client:
response = client.post("/api/channels/discord/connect")
assert response.status_code == 400
assert "encryption_key" in response.json()["detail"]
anyio.run(repo.close)
def test_connect_discord_includes_bot_install_scope_and_permissions(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
"discord": {
"enabled": True,
"client_id": "discord-client",
"client_secret": "discord-secret",
"bot_token": "discord-bot",
"permissions": "274877975552",
},
}
),
repo,
)
with TestClient(app) as client:
response = client.post("/api/channels/discord/connect")
assert response.status_code == 200
url = response.json()["url"]
parsed = urlparse(url)
query = parse_qs(parsed.query)
scopes = set(query["scope"][0].split())
assert {"identify", "guilds", "bot", "applications.commands"}.issubset(scopes)
assert query["permissions"] == ["274877975552"]
anyio.run(repo.close)
def test_slack_callback_exchanges_code_and_stores_connection(tmp_path, monkeypatch):
import anyio
from app.channels.providers import slack_connect
repo = anyio.run(_make_repo, tmp_path)
state_token = "slack-state-token"
async def seed_state():
await repo.create_oauth_state(
owner_user_id=str(_user().id),
provider="slack",
state=state_token,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
requested_scopes=["chat:write"],
)
async def fake_exchange_slack_oauth_code(**kwargs):
assert kwargs["code"] == "slack-code"
assert kwargs["redirect_uri"] == "https://deerflow.example.com/api/channels/slack/callback"
return SlackInstall(
team_id="T123",
team_name="Deer Team",
authed_user_id="U123",
bot_user_id="B123",
bot_access_token="xoxb-secret",
scopes=["chat:write"],
raw={"ok": True},
)
anyio.run(seed_state)
monkeypatch.setattr(slack_connect, "exchange_slack_oauth_code", fake_exchange_slack_oauth_code)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
"slack": {
"enabled": True,
"client_id": "slack-client",
"client_secret": "slack-secret",
"signing_secret": "slack-signing-secret",
},
}
),
repo,
)
with TestClient(app) as client:
response = client.get(
f"/api/channels/slack/callback?code=slack-code&state={state_token}",
follow_redirects=False,
)
assert response.status_code in {302, 307}
assert response.headers["location"] == "/workspace?channel_connected=slack"
async def get_connection_and_credentials():
connections = await repo.list_connections(str(_user().id))
credentials = await repo.get_credentials(connections[0]["id"])
return connections[0], credentials
connection, credentials = anyio.run(get_connection_and_credentials)
assert connection["provider"] == "slack"
assert connection["external_account_id"] == "U123"
assert connection["workspace_id"] == "T123"
assert connection["bot_user_id"] == "B123"
assert connection["scopes"] == ["chat:write"]
assert credentials["access_token"] == "xoxb-secret"
anyio.run(repo.close)
def test_discord_callback_exchanges_code_and_stores_identity(tmp_path, monkeypatch):
import anyio
from app.channels.providers import discord_connect
repo = anyio.run(_make_repo, tmp_path)
state_token = "discord-state-token"
async def seed_state():
await repo.create_oauth_state(
owner_user_id=str(_user().id),
provider="discord",
state=state_token,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
requested_scopes=["identify", "guilds"],
)
async def fake_complete_discord_oauth(**kwargs):
assert kwargs["code"] == "discord-code"
assert kwargs["redirect_uri"] == "https://deerflow.example.com/api/channels/discord/callback"
return DiscordIdentity(
user_id="987",
display_name="Alice",
username="alice",
guilds=[{"id": "G1", "name": "Guild One"}],
access_token="discord-access-token",
refresh_token="discord-refresh-token",
token_type="Bearer",
scopes=["identify", "guilds"],
expires_at=datetime.now(UTC) + timedelta(hours=1),
raw_token={"scope": "identify guilds"},
)
anyio.run(seed_state)
monkeypatch.setattr(discord_connect, "complete_discord_oauth", fake_complete_discord_oauth)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
"discord": {
"enabled": True,
"client_id": "discord-client",
"client_secret": "discord-secret",
"bot_token": "discord-bot",
},
}
),
repo,
)
with TestClient(app) as client:
response = client.get(
f"/api/channels/discord/callback?code=discord-code&state={state_token}",
follow_redirects=False,
)
assert response.status_code in {302, 307}
assert response.headers["location"] == "/workspace?channel_connected=discord"
async def get_connection_and_credentials():
connections = await repo.list_connections(str(_user().id))
credentials = await repo.get_credentials(connections[0]["id"])
return connections[0], credentials
connection, credentials = anyio.run(get_connection_and_credentials)
assert connection["provider"] == "discord"
assert connection["external_account_id"] == "987"
assert connection["external_account_name"] == "Alice"
assert connection["metadata"]["guilds"] == [{"id": "G1", "name": "Guild One"}]
assert credentials["access_token"] == "discord-access-token"
assert credentials["refresh_token"] == "discord-refresh-token"
assert "channels.slack" in response.json()["detail"]
anyio.run(repo.close)
@@ -547,20 +239,10 @@ def test_disconnect_connection_revokes_current_user_connection(tmp_path):
external_account_id="42",
status="connected",
)
await repo.store_credentials(connection["id"], access_token="secret-token")
return connection["id"]
connection_id = anyio.run(seed_connection)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
}
),
repo,
)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.delete(f"/api/channels/connections/{connection_id}")
@@ -571,7 +253,6 @@ def test_disconnect_connection_revokes_current_user_connection(tmp_path):
return (await repo.list_connections(str(_user().id)))[0]["status"]
assert anyio.run(get_connection_status) == "revoked"
assert anyio.run(repo.get_credentials, connection_id) is None
anyio.run(repo.close)
@@ -591,16 +272,7 @@ def test_disconnect_connection_is_current_user_scoped(tmp_path):
return connection["id"]
connection_id = anyio.run(seed_connection)
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
}
),
repo,
)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.delete(f"/api/channels/connections/{connection_id}")
+31 -62
View File
@@ -3276,7 +3276,7 @@ class TestChannelService:
assert service._config == {"telegram": {"enabled": False}}
def test_from_app_config_merges_telegram_channel_connections_config(self):
def test_from_app_config_does_not_create_runtime_channels_from_channel_connections(self):
from app.channels.service import ChannelService
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
@@ -3285,74 +3285,43 @@ class TestChannelService:
channel_connections=ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "secret",
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
"webhook_secret": "webhook-secret",
},
"telegram": {"enabled": True, "bot_username": "deerflow_bot"},
"slack": {"enabled": True},
"discord": {"enabled": True},
}
),
)
service = ChannelService.from_app_config(app_config)
assert service._config == {}
def test_from_app_config_preserves_existing_runtime_channels_with_channel_connections_enabled(self):
from app.channels.service import ChannelService
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
app_config = SimpleNamespace(
model_extra={
"channels": {
"telegram": {"enabled": True, "bot_token": "telegram-token"},
"slack": {"enabled": True, "bot_token": "xoxb", "app_token": "xapp"},
"discord": {"enabled": True, "bot_token": "discord-bot-token"},
}
},
channel_connections=ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"telegram": {"enabled": True, "bot_username": "deerflow_bot"},
"slack": {"enabled": True},
"discord": {"enabled": True},
}
),
)
service = ChannelService.from_app_config(app_config)
assert service._config["telegram"]["enabled"] is True
assert service._config["telegram"]["bot_token"] == "telegram-token"
def test_from_app_config_merges_slack_http_channel_connections_config(self):
from app.channels.service import ChannelService
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
app_config = SimpleNamespace(
model_extra={},
channel_connections=ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "secret",
"slack": {
"enabled": True,
"client_id": "slack-client",
"client_secret": "slack-secret",
"signing_secret": "signing-secret",
"event_delivery": "http",
},
}
),
)
service = ChannelService.from_app_config(app_config)
assert service._config["slack"]["enabled"] is True
assert service._config["slack"]["event_delivery"] == "http"
def test_from_app_config_merges_discord_channel_connections_config(self):
from app.channels.service import ChannelService
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
app_config = SimpleNamespace(
model_extra={},
channel_connections=ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "secret",
"discord": {
"enabled": True,
"client_id": "discord-client",
"client_secret": "discord-secret",
"bot_token": "discord-bot-token",
},
}
),
)
service = ChannelService.from_app_config(app_config)
assert service._config["discord"]["enabled"] is True
assert service._config["slack"]["app_token"] == "xapp"
assert service._config["discord"]["bot_token"] == "discord-bot-token"
def test_connection_repo_is_forwarded_to_manager(self):
+5 -8
View File
@@ -22,10 +22,6 @@ def _make_app() -> FastAPI:
async def protected_mutation():
return {"ok": True}
@app.post("/api/channels/webhooks/slack/events")
async def slack_events_webhook():
return {"ok": True}
return app
@@ -239,12 +235,13 @@ def test_non_auth_mutation_rejects_mismatched_double_submit_token():
assert response.json()["detail"] == "CSRF token mismatch."
def test_channel_webhook_post_skips_double_submit_csrf():
def test_channel_posts_require_double_submit_csrf():
client = TestClient(_make_app(), base_url="https://deerflow.example")
response = client.post(
"/api/channels/webhooks/slack/events",
headers={"Origin": "https://slack.com"},
"/api/channels/slack/connect",
headers={"Origin": "https://deerflow.example"},
)
assert response.status_code == 200
assert response.status_code == 403
assert response.json()["detail"] == "CSRF token missing. Include X-CSRF-Token header."
@@ -2,6 +2,9 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.channels.discord import DiscordChannel
@@ -48,3 +51,38 @@ async def test_discord_inbound_attaches_owner_identity_from_user_level_connectio
assert attached.connection_id == connection["id"]
assert attached.owner_user_id == "alice"
assert attached.workspace_id is None
@pytest.mark.anyio
async def test_discord_connect_command_binds_gateway_identity(repo):
state = "discord-bind-code"
await repo.create_oauth_state(
owner_user_id="deerflow-user-1",
provider="discord",
state=state,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
)
channel = DiscordChannel(
bus=MessageBus(),
config={"bot_token": "discord-bot", "connection_repo": repo},
)
message = MagicMock()
message.author.id = 987
message.author.display_name = "Alice"
message.guild.id = 123
message.guild.name = "Deer Guild"
message.channel.id = 456
message.channel.send = AsyncMock()
handled = await channel._bind_connection_from_connect_code(message, state)
connections = await repo.list_connections("deerflow-user-1")
assert handled is True
assert len(connections) == 1
assert connections[0]["provider"] == "discord"
assert connections[0]["external_account_id"] == "987"
assert connections[0]["external_account_name"] == "Alice"
assert connections[0]["workspace_id"] == "123"
assert connections[0]["workspace_name"] == "Deer Guild"
assert connections[0]["metadata"]["channel_id"] == "456"
message.channel.send.assert_awaited_once()
+36 -130
View File
@@ -1,31 +1,11 @@
"""Slack OAuth Events tests for user-owned channel connections."""
"""Slack connection tests for user-owned channel bindings."""
from __future__ import annotations
import hashlib
import hmac
import json
import time
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID
from _router_auth_helpers import make_authed_test_app
from fastapi.testclient import TestClient
from app.channels.message_bus import MessageBus, OutboundMessage
from app.channels.providers.slack_connect import verify_slack_signature
from app.gateway.auth.models import User
from app.gateway.routers import channel_connections
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
def _user() -> User:
return User(
id=UUID("11111111-2222-3333-4444-555555555555"),
email="alice@example.com",
password_hash="x",
system_role="user",
)
async def _make_repo(tmp_path):
@@ -39,121 +19,47 @@ async def _make_repo(tmp_path):
)
def _make_app(config: ChannelConnectionsConfig, repo, bus):
app = make_authed_test_app(user_factory=_user)
app.state.channel_connections_config = config
app.state.channel_connection_repo = repo
app.state.channel_message_bus = bus
app.include_router(channel_connections.router)
return app
def _slack_signature(signing_secret: str, timestamp: str, body: bytes) -> str:
base = f"v0:{timestamp}:".encode() + body
digest = hmac.new(signing_secret.encode("utf-8"), base, hashlib.sha256).hexdigest()
return f"v0={digest}"
def test_verify_slack_signature_accepts_valid_signature():
body = b'{"type":"event_callback"}'
timestamp = "1710000000"
signature = _slack_signature("secret", timestamp, body)
assert verify_slack_signature(
signing_secret="secret",
timestamp=timestamp,
body=body,
signature=signature,
now=1710000001,
)
def test_verify_slack_signature_rejects_stale_timestamp():
body = b'{"type":"event_callback"}'
timestamp = "1710000000"
signature = _slack_signature("secret", timestamp, body)
assert not verify_slack_signature(
signing_secret="secret",
timestamp=timestamp,
body=body,
signature=signature,
now=1710001000,
)
def test_slack_events_webhook_publishes_connection_scoped_inbound(tmp_path):
def test_slack_connect_command_binds_socket_mode_identity(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
from app.channels.slack import SlackChannel
async def seed_connection():
return await repo.upsert_connection(
owner_user_id=str(_user().id),
async def go():
repo = await _make_repo(tmp_path)
state = "slack-bind-code"
await repo.create_oauth_state(
owner_user_id="deerflow-user-1",
provider="slack",
external_account_id="U123",
workspace_id="T123",
workspace_name="Deer Team",
status="connected",
state=state,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
)
channel = SlackChannel(
bus=MessageBus(),
config={"bot_token": "xoxb-operator", "app_token": "xapp-operator", "connection_repo": repo},
)
channel._web_client = MagicMock()
handled = await channel._bind_connection_from_connect_code(
event={
"user": "U123",
"channel": "C123",
"ts": "1710000000.000100",
},
team_id="T123",
code=state,
)
connection = anyio.run(seed_connection)
bus = AsyncMock()
app = _make_app(
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "slack-secret",
"slack": {
"enabled": True,
"client_id": "slack-client",
"client_secret": "slack-secret",
"signing_secret": "slack-signing-secret",
},
}
),
repo,
bus,
)
payload = {
"type": "event_callback",
"event_id": "Ev123",
"team_id": "T123",
"event": {
"type": "app_mention",
"user": "U123",
"channel": "C123",
"text": "hello deerflow",
"ts": "1710000000.000100",
},
}
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
timestamp = str(int(time.time()))
headers = {
"X-Slack-Request-Timestamp": timestamp,
"X-Slack-Signature": _slack_signature("slack-signing-secret", timestamp, body),
}
connections = await repo.list_connections("deerflow-user-1")
assert handled is True
assert len(connections) == 1
assert connections[0]["provider"] == "slack"
assert connections[0]["external_account_id"] == "U123"
assert connections[0]["workspace_id"] == "T123"
assert connections[0]["metadata"]["channel_id"] == "C123"
channel._web_client.chat_postMessage.assert_called_once()
await repo.close()
with TestClient(app) as client:
response = client.post("/api/channels/webhooks/slack/events", content=body, headers=headers)
duplicate = client.post("/api/channels/webhooks/slack/events", content=body, headers=headers)
assert response.status_code == 200
assert response.json() == {"ok": True, "processed": True}
assert duplicate.status_code == 200
assert duplicate.json() == {"ok": True, "duplicate": True, "processed": False}
bus.publish_inbound.assert_awaited_once()
inbound = bus.publish_inbound.call_args.args[0]
assert inbound.connection_id == connection["id"]
assert inbound.owner_user_id == str(_user().id)
assert inbound.workspace_id == "T123"
assert inbound.chat_id == "C123"
assert inbound.user_id == "U123"
assert inbound.text == "hello deerflow"
assert inbound.topic_id == "1710000000.000100"
anyio.run(repo.close)
anyio.run(go)
def test_slack_send_uses_connection_bot_token_when_connection_id_is_present():
@@ -7,13 +7,9 @@ from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.channels.message_bus import MessageBus
from app.channels.telegram import TelegramChannel
from app.gateway.routers import channel_connections
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
@pytest.fixture
@@ -102,44 +98,3 @@ async def test_bound_telegram_message_publishes_connection_identity(repo):
assert inbound.user_id == "42"
assert inbound.chat_id == "100"
assert inbound.text == "hello"
@pytest.mark.anyio
async def test_telegram_webhook_verifies_secret_and_deduplicates_updates(repo):
channel = MagicMock()
channel.process_webhook_update = AsyncMock(return_value=True)
app = FastAPI()
app.state.channel_connections_config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "telegram-secret",
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
"webhook_secret": "webhook-secret",
},
}
)
app.state.channel_connection_repo = repo
app.state.channel_instances = {"telegram": channel}
app.include_router(channel_connections.router)
with TestClient(app) as client:
response = client.post(
"/api/channels/webhooks/telegram",
json={"update_id": 123, "message": {"text": "hello"}},
headers={"X-Telegram-Bot-Api-Secret-Token": "webhook-secret"},
)
duplicate = client.post(
"/api/channels/webhooks/telegram",
json={"update_id": 123, "message": {"text": "hello"}},
headers={"X-Telegram-Bot-Api-Secret-Token": "webhook-secret"},
)
assert response.status_code == 200
assert response.json() == {"ok": True, "processed": True}
assert duplicate.status_code == 200
assert duplicate.json() == {"ok": True, "duplicate": True, "processed": False}
channel.process_webhook_update.assert_awaited_once_with({"update_id": 123, "message": {"text": "hello"}})
+7 -33
View File
@@ -1135,54 +1135,28 @@ run_events:
# User-Owned IM Channel Connections
# ============================================================================
# Lets logged-in users connect their own Telegram, Slack, and Discord accounts
# from the DeerFlow frontend. This is separate from the legacy operator-owned
# `channels` block below:
# - `channel_connections` stores per-user connection records and encrypted
# provider credentials.
# - `channels` still configures legacy operator-owned bots and local polling /
# socket-mode workers.
# from the DeerFlow frontend while reusing the existing `channels` runtime
# configuration below.
#
# Security notes:
# - `mode: local` supports local/private deployments. Telegram deep-link
# binding works with long polling and does not require a public URL.
# - `mode: public` requires `public_base_url`, an externally reachable HTTPS
# origin for provider callbacks and webhooks. Slack HTTP Events and Telegram
# webhooks need this, or a tunnel, even when DeerFlow itself runs locally.
# - `encryption_key` is used to encrypt provider tokens at rest. Generate a
# long random value and keep it stable. Telegram deep-link binding can run
# without it because it does not store per-user provider tokens. Slack and
# Discord connections require it.
# - OAuth callbacks and provider webhooks are public routes, but they are
# protected by one-time state tokens or provider signatures/secrets.
# - No public IP, OAuth callback URL, or provider webhook is required.
# - Provider bot/app credentials stay under `channels.*`.
# - `channel_connections` stores per-user bindings and one-time connect codes.
# - Telegram uses a deep link when `bot_username` is configured.
# - Slack and Discord use `/connect <code>` through the already-running bot.
#
# channel_connections:
# enabled: false
# mode: local
# # public_base_url: https://deerflow.example.com
# # encryption_key: $DEER_FLOW_CHANNEL_CONNECTIONS_KEY
#
# telegram:
# enabled: false
# bot_token: $TELEGRAM_BOT_TOKEN
# bot_username: $TELEGRAM_BOT_USERNAME
# delivery: polling
# # webhook_secret: $TELEGRAM_WEBHOOK_SECRET
#
# slack:
# enabled: false
# client_id: $SLACK_CLIENT_ID
# client_secret: $SLACK_CLIENT_SECRET
# signing_secret: $SLACK_SIGNING_SECRET
# scopes: ["app_mentions:read", "chat:write", "channels:history", "channels:read"]
# event_delivery: http
#
# discord:
# enabled: false
# client_id: $DISCORD_CLIENT_ID
# client_secret: $DISCORD_CLIENT_SECRET
# bot_token: $DISCORD_BOT_TOKEN
# permissions: "274877975552"
# require_message_content_intent: true
# ============================================================================
# IM Channels Configuration
@@ -106,12 +106,20 @@ export function WorkspaceChannelsList() {
disabled={!canConnect || isPending}
title={getProviderDisabledReason(provider, t)}
onClick={() => {
const connectWindow = prepareConnectWindow();
const connectWindow =
provider.auth_mode === "deep_link"
? prepareConnectWindow()
: null;
void connectMutation
.mutateAsync(provider.provider)
.then((result) =>
openConnectUrl(result.url, connectWindow),
)
.then((result) => {
if (result.url) {
openConnectUrl(result.url, connectWindow);
return;
}
closeConnectWindow(connectWindow);
toast.success(result.instruction);
})
.catch((error) => {
closeConnectWindow(connectWindow);
toast.error(
@@ -165,10 +165,20 @@ function ChannelProviderItem({
disabled={!canConnect || isConnecting}
title={unavailableReason}
onClick={() => {
const connectWindow = prepareConnectWindow();
const connectWindow =
provider.auth_mode === "deep_link"
? prepareConnectWindow()
: null;
void connectMutation
.mutateAsync(provider.provider)
.then((result) => openConnectUrl(result.url, connectWindow))
.then((result) => {
if (result.url) {
openConnectUrl(result.url, connectWindow);
return;
}
closeConnectWindow(connectWindow);
toast.success(result.instruction);
})
.catch((error) => {
closeConnectWindow(connectWindow);
toast.error(
+3 -1
View File
@@ -35,6 +35,8 @@ export interface ChannelConnectionsResponse {
export interface ChannelConnectResponse {
provider: ChannelProviderId;
mode: string;
url: string;
url?: string | null;
code: string;
instruction: string;
expires_in: number;
}
+9 -8
View File
@@ -23,7 +23,7 @@ function mockChannelsAPI(page: Page) {
display_name: "Slack",
enabled: true,
configured: true,
auth_mode: "oauth",
auth_mode: "binding_code",
connection_status: "not_connected",
},
{
@@ -31,7 +31,7 @@ function mockChannelsAPI(page: Page) {
display_name: "Discord",
enabled: true,
configured: true,
auth_mode: "oauth_and_bot_install",
auth_mode: "binding_code",
connection_status: "not_connected",
},
],
@@ -53,8 +53,10 @@ function mockChannelsAPI(page: Page) {
contentType: "application/json",
body: JSON.stringify({
provider: "slack",
mode: "oauth",
url: "http://localhost:3000/mock-slack-oauth?client_id=dev&state=test",
mode: "binding_code",
url: null,
code: "abc123",
instruction: "Send /connect abc123 to the DeerFlow Slack bot.",
expires_in: 600,
}),
});
@@ -91,11 +93,10 @@ test.describe("IM channels", () => {
const connectButtons = dialog.getByRole("button", { name: "Connect" });
await expect(connectButtons).toHaveCount(3);
const popupPromise = page.waitForEvent("popup");
await connectButtons.nth(1).click();
const popup = await popupPromise;
await expect(page).toHaveURL(/\/workspace\/chats\/new/);
await expect(popup).toHaveURL(/\/mock-slack-oauth/);
await popup.close();
await expect(
page.getByText("Send /connect abc123 to the DeerFlow Slack bot."),
).toBeVisible();
});
});
@@ -85,6 +85,8 @@ describe("channels api", () => {
provider: "telegram",
mode: "deep_link",
url: "https://t.me/deerflow_bot?start=state",
code: "state",
instruction: "Send /start state to the DeerFlow Telegram bot.",
expires_in: 600,
}),
);
@@ -92,6 +94,7 @@ describe("channels api", () => {
await expect(connectChannelProvider("telegram")).resolves.toMatchObject({
provider: "telegram",
url: "https://t.me/deerflow_bot?start=state",
instruction: "Send /start state to the DeerFlow Telegram bot.",
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/telegram/connect",
@@ -99,6 +102,26 @@ describe("channels api", () => {
);
});
test("starts a binding-code connection flow", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
provider: "slack",
mode: "binding_code",
url: null,
code: "abc123",
instruction: "Send /connect abc123 to the DeerFlow Slack bot.",
expires_in: 600,
}),
);
await expect(connectChannelProvider("slack")).resolves.toMatchObject({
provider: "slack",
url: null,
code: "abc123",
instruction: "Send /connect abc123 to the DeerFlow Slack bot.",
});
});
test("disconnects a channel connection", async () => {
mockedFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
@@ -69,9 +69,11 @@ describe("channel connect window helpers", () => {
test("falls back to current-window navigation when no popup is available", () => {
const { assign } = stubWindow(null);
openConnectUrl("https://slack.com/oauth/v2/authorize");
openConnectUrl("https://t.me/deerflow_bot?start=state");
expect(assign).toHaveBeenCalledWith("https://slack.com/oauth/v2/authorize");
expect(assign).toHaveBeenCalledWith(
"https://t.me/deerflow_bot?start=state",
);
});
test("closes a prepared popup on connect failure", () => {