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 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 | | 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) - `base.py` - Abstract `Channel` base class (start/stop/send lifecycle)
- `service.py` - Manages lifecycle of all configured channels from `config.yaml` - `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) - `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 - `app/gateway/routers/channel_connections.py` - Browser-facing user connection and disconnect APIs
- `deerflow.persistence.channel_connections` - SQL-backed user-owned connection, credential, OAuth state, conversation, and webhook delivery store - `deerflow.persistence.channel_connections` - SQL-backed user-owned connection, optional credential, connect state, and conversation store
**Message Flow**: **Message Flow**:
1. External platform -> Channel impl -> `MessageBus.publish_inbound()` 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) - 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`): **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`. - Disabled by default. It is a user-binding layer on top of the existing `channels.*` runtime config, not a replacement for provider bot credentials.
- `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. - No public IP, OAuth callback URL, or provider webhook route is required by the current implementation.
- `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. - 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}`. - 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. - Browser APIs remain protected by normal Gateway auth/CSRF. Provider messages arrive through the already-configured channel workers.
- Slack HTTP Events mode uses per-connection encrypted bot tokens for replies. Legacy Slack Socket Mode remains available through the `channels.slack` config. - Slack replies use the configured operator bot token from `channels.slack` unless a future provider-token flow stores per-connection credentials.
- Telegram supports frontend deep-link binding and can process signed webhook updates; long polling remains the local/self-host fallback. - Telegram, Slack, and Discord workers resolve incoming platform identities to connection records before reaching `ChannelManager`.
- Discord OAuth stores the user identity and guild metadata; Gateway messages from `discord.py` resolve to connection identity before reaching `ChannelManager`.
- See `backend/docs/IM_CHANNEL_CONNECTIONS.md` for provider setup and operational notes. - 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 _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): class DiscordChannel(Channel):
"""Discord bot 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() 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) # 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 --- # --- Determine thread/channel routing and typing target ---
thread_id = None thread_id = None
chat_id = None chat_id = None
@@ -464,6 +478,51 @@ class DiscordChannel(Channel):
inbound.workspace_id = connection.get("workspace_id") inbound.workspace_id = connection.get("workspace_id")
return inbound 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: def _run_client(self) -> None:
self._discord_loop = asyncio.new_event_loop() self._discord_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._discord_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): if connection_config is None or not getattr(connection_config, "enabled", False):
return 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): def _make_connection_repo(app_config: AppConfig):
connection_config = getattr(app_config, "channel_connections", None) connection_config = getattr(app_config, "channel_connections", None)
if connection_config is None or not getattr(connection_config, "enabled", False): if connection_config is None or not getattr(connection_config, "enabled", False):
return None return None
encryption_key = getattr(connection_config, "encryption_key", "")
try: try:
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import get_session_factory from deerflow.persistence.engine import get_session_factory
except Exception: except Exception:
logger.exception("Failed to import channel connection repository") logger.exception("Failed to import channel connection repository")
@@ -97,8 +74,7 @@ def _make_connection_repo(app_config: AppConfig):
if session_factory is None: if session_factory is None:
logger.warning("Channel connections are enabled but database persistence is not available") logger.warning("Channel connections are enabled but database persistence is not available")
return None return None
cipher = ChannelCredentialCipher.from_key(encryption_key) if encryption_key else None return ChannelConnectionRepository(session_factory)
return ChannelConnectionRepository(session_factory, cipher=cipher)
class ChannelService: class ChannelService:
+98 -4
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() 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): class SlackChannel(Channel):
"""Slack IM channel using Socket Mode (WebSocket, no public IP). """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) credentials = await self._connection_repo.get_credentials(msg.connection_id)
access_token = credentials.get("access_token") if credentials else None access_token = credentials.get("access_token") if credentials else None
if not access_token: if not access_token:
logger.warning("[Slack] no bot token found for connection=%s", msg.connection_id) return self._web_client
return None
if self._web_client_factory is None: if self._web_client_factory is None:
from slack_sdk import WebClient from slack_sdk import WebClient
@@ -282,12 +291,15 @@ class SlackChannel(Channel):
# Handle message events (DM or @mention) # Handle message events (DM or @mention)
if etype in ("message", "app_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: except Exception:
logger.exception("Error processing Slack event") 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 # Ignore bot messages
if event.get("bot_id") or event.get("subtype"): if event.get("bot_id") or event.get("subtype"):
return return
@@ -305,6 +317,19 @@ class SlackChannel(Channel):
if not text: if not text:
return 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", "") channel_id = event.get("channel", "")
thread_ts = event.get("thread_ts") or event.get("ts", "") 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") self._add_reaction(channel_id, event.get("ts", thread_ts), "eyes")
# Send "running" reply first (fire-and-forget from SDK thread) # Send "running" reply first (fire-and-forget from SDK thread)
self._send_running_reply(channel_id, thread_ts) self._send_running_reply(channel_id, thread_ts)
if self._connection_repo is None:
asyncio.run_coroutine_threadsafe(self.bus.publish_inbound(inbound), self._loop) 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", "/docs",
"/redoc", "/redoc",
"/openapi.json", "/openapi.json",
"/api/channels/webhooks/",
) )
# Exact auth paths that are public (login/register/status check). # 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/logout",
"/api/v1/auth/setup-status", "/api/v1/auth/setup-status",
"/api/v1/auth/initialize", "/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 return False
path = request.url.path.rstrip("/") path = request.url.path.rstrip("/")
if path.startswith("/api/channels/webhooks/"):
return False
# Exempt /api/v1/auth/me endpoint # Exempt /api/v1/auth/me endpoint
if path == "/api/v1/auth/me": if path == "/api/v1/auth/me":
return False 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 from __future__ import annotations
import hashlib
import json
import secrets import secrets
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from urllib.parse import urlencode
from fastapi import APIRouter, HTTPException, Request, Response from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel, Field 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.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 from deerflow.persistence.engine import get_session_factory
router = APIRouter(prefix="/api/channels", tags=["channel-connections"]) router = APIRouter(prefix="/api/channels", tags=["channel-connections"])
@@ -59,14 +53,22 @@ class ChannelConnectionsResponse(BaseModel):
class ChannelConnectResponse(BaseModel): class ChannelConnectResponse(BaseModel):
provider: str provider: str
mode: str mode: str
url: str url: str | None = None
code: str
instruction: str
expires_in: int expires_in: int
_PROVIDER_META: dict[str, dict[str, str]] = { _PROVIDER_META: dict[str, dict[str, str]] = {
"telegram": {"display_name": "Telegram", "auth_mode": "deep_link"}, "telegram": {"display_name": "Telegram", "auth_mode": "deep_link"},
"slack": {"display_name": "Slack", "auth_mode": "oauth"}, "slack": {"display_name": "Slack", "auth_mode": "binding_code"},
"discord": {"display_name": "Discord", "auth_mode": "oauth_and_bot_install"}, "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) 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: def _get_channel_connections_config(request: Request) -> ChannelConnectionsConfig:
config = getattr(request.app.state, "channel_connections_config", None) config = getattr(request.app.state, "channel_connections_config", None)
if isinstance(config, ChannelConnectionsConfig): if isinstance(config, ChannelConnectionsConfig):
return config 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: def _get_repository(request: Request, config: ChannelConnectionsConfig) -> ChannelConnectionRepository:
@@ -96,8 +112,7 @@ def _get_repository(request: Request, config: ChannelConnectionsConfig) -> Chann
if sf is None: if sf is None:
raise HTTPException(status_code=503, detail="Channel connection persistence is not available") 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)
repo = ChannelConnectionRepository(sf, cipher=cipher)
request.app.state.channel_connection_repo = repo request.app.state.channel_connection_repo = repo
return repo return repo
@@ -109,41 +124,48 @@ def _provider_config(config: ChannelConnectionsConfig, provider: str):
return provider_config 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) provider_config = _provider_config(config, provider)
if not provider_config.enabled or not provider_config.configured: if not provider_config.enabled:
return None return None
if not provider_config.configured:
if provider == "telegram" and getattr(provider_config, "delivery", "polling") == "webhook": if provider == "telegram":
if not provider_config.webhook_secret: return "Configure channel_connections.telegram.bot_username for Telegram deep links."
return "Telegram webhook delivery requires channel_connections.telegram.webhook_secret" return f"Configure channel_connections.{provider}."
if not config.public_base_url: if not _runtime_channel_configured(provider, channels_config):
return "Telegram webhook delivery requires channel_connections.public_base_url; use polling for local/private deployments" return _runtime_unavailable_reason(provider)
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"
return None return None
def _require_provider_connectable(config: ChannelConnectionsConfig, provider: str) -> None: def _provider_status(
reason = _provider_unavailable_reason(config, provider) config: ChannelConnectionsConfig,
if reason: channels_config: dict[str, Any],
raise HTTPException(status_code=400, detail=reason) 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: def _new_binding_code() -> str:
if config.public_base_url: return secrets.token_hex(4)
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"
async def _create_state( async def _create_state(
@@ -151,135 +173,40 @@ async def _create_state(
*, *,
owner_user_id: str, owner_user_id: str,
provider: str, provider: str,
requested_scopes: list[str] | None = None,
metadata: dict[str, Any] | None = None,
) -> str: ) -> str:
state = secrets.token_urlsafe(32) state = _new_binding_code()
await repo.create_oauth_state( await repo.create_oauth_state(
owner_user_id=owner_user_id, owner_user_id=owner_user_id,
provider=provider, provider=provider,
state=state, state=state,
requested_scopes=requested_scopes,
metadata=metadata,
expires_at=datetime.now(UTC) + timedelta(seconds=_STATE_TTL_SECONDS), expires_at=datetime.now(UTC) + timedelta(seconds=_STATE_TTL_SECONDS),
) )
return state return state
def _build_connect_url(config: ChannelConnectionsConfig, request: Request, provider: str, state: str) -> str: def _connect_instruction(provider: str, code: str) -> str:
provider_config = _provider_config(config, provider)
if provider == "telegram": if provider == "telegram":
return f"https://t.me/{provider_config.bot_username}?start={state}" return f"Send /start {code} to the DeerFlow Telegram bot."
redirect_uri = _callback_redirect_uri(config, request, provider)
if provider == "slack": if provider == "slack":
query = urlencode( return f"Send /connect {code} to the DeerFlow Slack bot."
{
"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}"
if provider == "discord": if provider == "discord":
scopes = "identify guilds bot applications.commands" return f"Send /connect {code} to the DeerFlow Discord bot."
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}"
raise HTTPException(status_code=404, detail="Unknown channel provider") raise HTTPException(status_code=404, detail="Unknown channel provider")
def _callback_redirect(provider: str, state_data: dict[str, Any]) -> RedirectResponse: def _connect_url(config: ChannelConnectionsConfig, provider: str, code: str) -> str | None:
redirect_after = state_data.get("redirect_after") if provider == "telegram":
if isinstance(redirect_after, str) and redirect_after: provider_config = _provider_config(config, provider)
return RedirectResponse(redirect_after) return f"https://t.me/{provider_config.bot_username}?start={code}"
return RedirectResponse(f"/workspace?channel_connected={provider}") if provider in {"slack", "discord"}:
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:
return None return None
service = get_channel_service() raise HTTPException(status_code=404, detail="Unknown channel provider")
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
@router.get("/providers", response_model=ChannelProvidersResponse) @router.get("/providers", response_model=ChannelProvidersResponse)
async def get_channel_providers(request: Request) -> ChannelProvidersResponse: async def get_channel_providers(request: Request) -> ChannelProvidersResponse:
config = _get_channel_connections_config(request) config = _get_channel_connections_config(request)
channels_config = _get_channels_config(request)
repo = None repo = None
if config.enabled: if config.enabled:
try: try:
@@ -293,9 +220,8 @@ async def get_channel_providers(request: Request) -> ChannelProvidersResponse:
providers: list[ChannelProviderResponse] = [] providers: list[ChannelProviderResponse] = []
for provider, meta in _PROVIDER_META.items(): 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) connection = by_provider.get(provider)
unavailable_reason = _provider_unavailable_reason(config, provider)
providers.append( providers.append(
ChannelProviderResponse( ChannelProviderResponse(
provider=provider, provider=provider,
@@ -337,199 +263,32 @@ async def disconnect_channel_connection(connection_id: str, request: Request) ->
return Response(status_code=204) 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) @router.post("/{provider}/connect", response_model=ChannelConnectResponse)
async def connect_channel_provider(provider: str, request: Request) -> ChannelConnectResponse: async def connect_channel_provider(provider: str, request: Request) -> ChannelConnectResponse:
config = _get_channel_connections_config(request) config = _get_channel_connections_config(request)
channels_config = _get_channels_config(request)
if not config.enabled: if not config.enabled:
raise HTTPException(status_code=400, detail="Channel connections are disabled") raise HTTPException(status_code=400, detail="Channel connections are disabled")
provider_config = _provider_config(config, provider) status, unavailable_reason = _provider_status(config, channels_config, provider)
if not provider_config.enabled or not provider_config.configured: 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") raise HTTPException(status_code=400, detail="Channel provider is not configured")
_require_provider_connectable(config, provider)
repo = _get_repository(request, config) repo = _get_repository(request, config)
state = await _create_state( code = await _create_state(
repo, repo,
owner_user_id=_get_user_id(request), owner_user_id=_get_user_id(request),
provider=provider, provider=provider,
requested_scopes=getattr(provider_config, "scopes", []),
) )
return ChannelConnectResponse( return ChannelConnectResponse(
provider=provider, provider=provider,
mode=_PROVIDER_META[provider]["auth_mode"], 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, expires_in=_STATE_TTL_SECONDS,
) )
+41 -64
View File
@@ -1,107 +1,84 @@
# IM Channel Connections # 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 ## Configuration
Enable the top-level `channel_connections` block in `config.yaml`: Configure the actual IM bots under the existing `channels` block:
Local/private deployment:
```yaml ```yaml
channel_connections: channels:
enabled: true
mode: local
telegram: telegram:
enabled: true enabled: true
bot_token: $TELEGRAM_BOT_TOKEN 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: slack:
enabled: true enabled: true
client_id: $SLACK_CLIENT_ID bot_token: $SLACK_BOT_TOKEN
client_secret: $SLACK_CLIENT_SECRET app_token: $SLACK_APP_TOKEN
signing_secret: $SLACK_SIGNING_SECRET
event_delivery: http
discord: discord:
enabled: true enabled: true
client_id: $DISCORD_CLIENT_ID
client_secret: $DISCORD_CLIENT_SECRET
bot_token: $DISCORD_BOT_TOKEN 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: Telegram:
- Register a bot with BotFather. - The frontend creates a short one-time code.
- Configure the bot username and bot token. - The Connect button opens `https://t.me/<bot_username>?start=<code>`.
- Users connect with a deep link: `https://t.me/<bot_username>?start=<state>`. - The existing Telegram long-polling worker receives `/start <code>` and binds that Telegram chat/user to the current DeerFlow user.
- 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`.
Slack: Slack:
- Create a Slack app with OAuth V2. - The frontend creates a short one-time code.
- Redirect URL: `https://<public_base_url>/api/channels/slack/callback`. - The UI shows `Send /connect <code> to the DeerFlow Slack bot.`
- Event request URL: `https://<public_base_url>/api/channels/webhooks/slack/events`. - The existing Slack Socket Mode worker receives the message and binds the Slack user/team to the current DeerFlow user.
- 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.
Discord: Discord:
- Create a Discord application and bot. - The frontend creates a short one-time code.
- 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. - The UI shows `Send /connect <code> to the DeerFlow Discord bot.`
- DeerFlow starts OAuth with `identify guilds bot applications.commands` and the configured bot permissions. - The existing Discord Gateway worker receives the message and binds the Discord user/guild to the current DeerFlow user.
- The Discord Gateway is still handled by `discord.py`; message content may require the privileged Message Content Intent depending on your bot setup.
Codes expire after 10 minutes and are single-use.
## Runtime Model ## Runtime Model
Connection records live in SQL tables under `deerflow.persistence.channel_connections`: Connection records live in SQL tables under `deerflow.persistence.channel_connections`:
- `channel_connections`: owner user, provider identity, workspace/guild/team, status, metadata. - `channel_connections`: owner user, provider identity, workspace/guild/team, status, metadata.
- `channel_credentials`: encrypted access/refresh/bot tokens. - `channel_oauth_states`: one-time connect codes and Telegram deep-link state.
- `channel_oauth_states`: one-time OAuth/deep-link states.
- `channel_conversations`: connection-scoped IM conversation to DeerFlow thread mapping. - `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 ## Security Notes
- OAuth state tokens are one-time and short-lived. - Browser APIs remain authenticated and CSRF-protected.
- Provider tokens are never returned from browser APIs. - Connect codes are random, short-lived, and single-use.
- Public callback/webhook routes bypass cookie auth only because they validate provider state/signatures/secrets themselves. - Provider bot tokens remain in `channels.*` and are never returned to the browser.
- Slack and Telegram webhooks skip CSRF because they are called by providers, not browsers. - This implementation does not add public provider callback or webhook routes.
- Logs should never include access tokens, refresh tokens, bot tokens, OAuth codes, or raw signed webhook bodies.
@@ -2,88 +2,48 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
ChannelConnectionMode = Literal["local", "private", "public"]
TelegramDeliveryMode = Literal["polling", "webhook"]
class SlackChannelConnectionConfig(BaseModel): class SlackChannelConnectionConfig(BaseModel):
enabled: bool = False 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 @property
def configured(self) -> bool: def configured(self) -> bool:
return bool(self.client_id and self.client_secret and self.signing_secret) return True
class TelegramChannelConnectionConfig(BaseModel): class TelegramChannelConnectionConfig(BaseModel):
enabled: bool = False enabled: bool = False
bot_token: str = ""
bot_username: str = "" bot_username: str = ""
delivery: TelegramDeliveryMode = "polling"
webhook_secret: str = ""
oidc_client_id: str = ""
oidc_client_secret: str = ""
@property @property
def configured(self) -> bool: def configured(self) -> bool:
if self.delivery == "webhook": return bool(self.bot_username)
return bool(self.bot_token and self.bot_username and self.webhook_secret)
return bool(self.bot_token and self.bot_username)
class DiscordChannelConnectionConfig(BaseModel): class DiscordChannelConnectionConfig(BaseModel):
enabled: bool = False enabled: bool = False
client_id: str = ""
client_secret: str = ""
bot_token: str = ""
permissions: str = ""
require_message_content_intent: bool = True
@property @property
def configured(self) -> bool: def configured(self) -> bool:
return bool(self.client_id and self.client_secret and self.bot_token) return True
class ChannelConnectionsConfig(BaseModel): class ChannelConnectionsConfig(BaseModel):
"""Top-level config for browser-connectable IM channels.""" """Top-level config for browser-connectable IM channels."""
enabled: bool = False enabled: bool = False
mode: ChannelConnectionMode = "local"
public_base_url: str = ""
encryption_key: str = ""
slack: SlackChannelConnectionConfig = Field(default_factory=SlackChannelConnectionConfig) slack: SlackChannelConnectionConfig = Field(default_factory=SlackChannelConnectionConfig)
telegram: TelegramChannelConnectionConfig = Field(default_factory=TelegramChannelConnectionConfig) telegram: TelegramChannelConnectionConfig = Field(default_factory=TelegramChannelConnectionConfig)
discord: DiscordChannelConnectionConfig = Field(default_factory=DiscordChannelConnectionConfig) 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]: def provider_status(self, provider: str) -> dict[str, bool]:
config = getattr(self, provider, None) config = getattr(self, provider, None)
if config is None: if config is None:
return {"enabled": False, "configured": False} return {"enabled": False, "configured": False}
enabled = bool(config.enabled)
return { return {
"enabled": bool(config.enabled), "enabled": enabled,
"configured": bool(config.configured), "configured": enabled and bool(config.configured),
} }
@@ -5,7 +5,6 @@ from deerflow.persistence.channel_connections.model import (
ChannelConversationRow, ChannelConversationRow,
ChannelCredentialRow, ChannelCredentialRow,
ChannelOAuthStateRow, ChannelOAuthStateRow,
ChannelWebhookDeliveryRow,
) )
from deerflow.persistence.channel_connections.sql import ( from deerflow.persistence.channel_connections.sql import (
ChannelConnectionRepository, ChannelConnectionRepository,
@@ -19,5 +18,4 @@ __all__ = [
"ChannelCredentialCipher", "ChannelCredentialCipher",
"ChannelCredentialRow", "ChannelCredentialRow",
"ChannelOAuthStateRow", "ChannelOAuthStateRow",
"ChannelWebhookDeliveryRow",
] ]
@@ -109,13 +109,3 @@ class ChannelConversationRow(Base):
name="uq_channel_conversation_connection_external", 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, ChannelConversationRow,
ChannelCredentialRow, ChannelCredentialRow,
ChannelOAuthStateRow, ChannelOAuthStateRow,
ChannelWebhookDeliveryRow,
) )
from deerflow.utils.time import coerce_iso from deerflow.utils.time import coerce_iso
@@ -345,30 +344,3 @@ class ChannelConnectionRepository:
ChannelConversationRow.external_topic_id == (external_topic_id or ""), ChannelConversationRow.external_topic_id == (external_topic_id or ""),
) )
return (await session.execute(stmt)).scalar_one_or_none() 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, ChannelConversationRow,
ChannelCredentialRow, ChannelCredentialRow,
ChannelOAuthStateRow, ChannelOAuthStateRow,
ChannelWebhookDeliveryRow,
) )
from deerflow.persistence.feedback.model import FeedbackRow from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.models.run_event import RunEventRow from deerflow.persistence.models.run_event import RunEventRow
@@ -32,7 +31,6 @@ __all__ = [
"ChannelConversationRow", "ChannelConversationRow",
"ChannelCredentialRow", "ChannelCredentialRow",
"ChannelOAuthStateRow", "ChannelOAuthStateRow",
"ChannelWebhookDeliveryRow",
"FeedbackRow", "FeedbackRow",
"RunEventRow", "RunEventRow",
"RunRow", "RunRow",
+2 -4
View File
@@ -22,10 +22,6 @@ from app.gateway.csrf_middleware import CSRFMiddleware
"/api/v1/auth/register", "/api/v1/auth/register",
"/api/v1/auth/logout", "/api/v1/auth/logout",
"/api/v1/auth/setup-status", "/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): def test_public_paths(path: str):
@@ -43,6 +39,8 @@ def test_public_paths(path: str):
"/api/threads/123/uploads", "/api/threads/123/uploads",
"/api/agents", "/api/agents",
"/api/channels", "/api/channels",
"/api/channels/providers",
"/api/channels/slack/connect",
"/api/runs/stream", "/api/runs/stream",
"/api/threads/123/runs", "/api/threads/123/runs",
"/api/v1/auth/me", "/api/v1/auth/me",
@@ -1,8 +1,5 @@
"""Tests for user-facing IM channel connection configuration.""" """Tests for user-facing IM channel connection configuration."""
import pytest
from pydantic import ValidationError
from deerflow.config.channel_connections_config import ChannelConnectionsConfig from deerflow.config.channel_connections_config import ChannelConnectionsConfig
@@ -10,67 +7,34 @@ def test_channel_connections_disabled_by_default():
config = ChannelConnectionsConfig() config = ChannelConnectionsConfig()
assert config.enabled is False assert config.enabled is False
assert config.public_base_url == ""
assert config.encryption_key == ""
assert config.slack.enabled is False assert config.slack.enabled is False
assert config.telegram.enabled is False assert config.telegram.enabled is False
assert config.discord.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( config = ChannelConnectionsConfig.model_validate(
{ {
"enabled": True, "enabled": True,
"mode": "local",
"telegram": { "telegram": {
"enabled": True, "enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot", "bot_username": "deerflow_bot",
}, },
"slack": {"enabled": True},
"discord": {"enabled": True},
} }
) )
assert config.public_base_url == "" assert config.enabled is True
assert config.encryption_key == ""
assert config.provider_status("telegram") == {"enabled": True, "configured": 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("slack") == {"enabled": True, "configured": True}
assert config.provider_status("telegram") == {"enabled": True, "configured": True} assert config.provider_status("discord") == {"enabled": True, "configured": True}
assert config.provider_status("discord") == {"enabled": True, "configured": False}
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} assert config.provider_status("unknown") == {"enabled": False, "configured": False}
@@ -12,7 +12,6 @@ from deerflow.persistence.channel_connections import (
ChannelConnectionRow, ChannelConnectionRow,
ChannelCredentialCipher, ChannelCredentialCipher,
ChannelCredentialRow, ChannelCredentialRow,
ChannelWebhookDeliveryRow,
) )
@@ -201,25 +200,3 @@ class TestChannelConnectionRepository:
assert disconnected is False assert disconnected is False
assert (await repo.list_connections("alice"))[0]["status"] == "connected" 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 __future__ import annotations
from datetime import UTC, datetime, timedelta
from urllib.parse import parse_qs, urlparse
from uuid import UUID from uuid import UUID
from _router_auth_helpers import make_authed_test_app from _router_auth_helpers import make_authed_test_app
from fastapi.testclient import TestClient 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.auth.models import User
from app.gateway.routers import channel_connections from app.gateway.routers import channel_connections
from deerflow.config.channel_connections_config import ChannelConnectionsConfig 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"): async def _make_repo(tmp_path):
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import get_session_factory, init_engine 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)) 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())
return ChannelConnectionRepository(get_session_factory(), cipher=cipher)
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 = make_authed_test_app(user_factory=_user)
app.state.channel_connections_config = config app.state.channel_connections_config = config
app.state.channel_connection_repo = repo app.state.channel_connection_repo = repo
app.state.channels_config = channels_config or {}
app.include_router(channel_connections.router) app.include_router(channel_connections.router)
return app 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 import anyio
repo = anyio.run(_make_repo, tmp_path) repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate( app = _make_app(_enabled_connections_config(), repo, _channels_config())
{
"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)
with TestClient(app) as client: with TestClient(app) as client:
response = client.get("/api/channels/providers") 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 assert response.status_code == 200
body = response.json() body = response.json()
assert body["enabled"] is True assert body["enabled"] is True
telegram = next(item for item in body["providers"] if item["provider"] == "telegram") by_provider = {item["provider"]: item for item in body["providers"]}
slack = next(item for item in body["providers"] if item["provider"] == "slack") assert by_provider["telegram"]["configured"] is True
assert telegram["enabled"] is True assert by_provider["telegram"]["auth_mode"] == "deep_link"
assert telegram["configured"] is True assert by_provider["slack"]["configured"] is True
assert telegram["connection_status"] == "not_connected" assert by_provider["slack"]["auth_mode"] == "binding_code"
assert slack["enabled"] is True assert by_provider["discord"]["configured"] is True
assert slack["configured"] is False 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) anyio.run(repo.close)
@@ -101,16 +123,7 @@ def test_get_connections_returns_current_user_connections_only(tmp_path):
) )
anyio.run(seed_connections) anyio.run(seed_connections)
app = _make_app( app = _make_app(_enabled_connections_config(), repo, _channels_config())
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
}
),
repo,
)
with TestClient(app) as client: with TestClient(app) as client:
response = client.get("/api/channels/connections") response = client.get("/api/channels/connections")
@@ -128,22 +141,7 @@ def test_connect_telegram_returns_deep_link_and_persists_state(tmp_path):
import anyio import anyio
repo = anyio.run(_make_repo, tmp_path) repo = anyio.run(_make_repo, tmp_path)
app = _make_app( app = _make_app(_enabled_connections_config(), repo, _channels_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",
},
}
),
repo,
)
with TestClient(app) as client: with TestClient(app) as client:
response = client.post("/api/channels/telegram/connect") 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["provider"] == "telegram"
assert body["mode"] == "deep_link" assert body["mode"] == "deep_link"
assert body["url"].startswith("https://t.me/deerflow_bot?start=") assert body["url"].startswith("https://t.me/deerflow_bot?start=")
assert body["code"]
assert "/start" in body["instruction"]
async def count_states(): 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="telegram")
@@ -162,375 +162,67 @@ def test_connect_telegram_returns_deep_link_and_persists_state(tmp_path):
anyio.run(repo.close) 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 import anyio
repo = anyio.run(_make_repo, tmp_path, None) repo = anyio.run(_make_repo, tmp_path)
app = _make_app( app = _make_app(_enabled_connections_config(), repo, _channels_config())
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"mode": "local",
"telegram": {
"enabled": True,
"bot_token": "telegram-token",
"bot_username": "deerflow_bot",
},
}
),
repo,
)
with TestClient(app) as client: with TestClient(app) as client:
response = client.post("/api/channels/telegram/connect") response = client.post("/api/channels/slack/connect")
assert response.status_code == 200 assert response.status_code == 200
body = response.json() body = response.json()
assert body["provider"] == "telegram" assert body["provider"] == "slack"
assert body["url"].startswith("https://t.me/deerflow_bot?start=") 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(): 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 assert anyio.run(count_states) == 1
anyio.run(repo.close) 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 import anyio
repo = anyio.run(_make_repo, tmp_path) repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate( app = _make_app(_enabled_connections_config(), repo, _channels_config())
{
"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)
with TestClient(app) as client: with TestClient(app) as client:
response = client.get("/api/channels/providers") response = client.post("/api/channels/discord/connect")
assert response.status_code == 200 assert response.status_code == 200
slack = next(item for item in response.json()["providers"] if item["provider"] == "slack") body = response.json()
assert slack["enabled"] is True assert body["provider"] == "discord"
assert slack["configured"] is True assert body["mode"] == "binding_code"
assert slack["connectable"] is False assert body["url"] is None
assert "public_base_url" in slack["unavailable_reason"] 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) 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 import anyio
repo = anyio.run(_make_repo, tmp_path) repo = anyio.run(_make_repo, tmp_path)
app = _make_app( app = _make_app(_enabled_connections_config(), repo, {})
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
"slack": {"enabled": True, "client_id": "slack-client"},
}
),
repo,
)
with TestClient(app) as client: with TestClient(app) as client:
response = client.post("/api/channels/slack/connect") response = client.post("/api/channels/slack/connect")
assert response.status_code == 400 assert response.status_code == 400
assert response.json()["detail"] == "Channel provider is not configured" assert "channels.slack" in response.json()["detail"]
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"
anyio.run(repo.close) anyio.run(repo.close)
@@ -547,20 +239,10 @@ def test_disconnect_connection_revokes_current_user_connection(tmp_path):
external_account_id="42", external_account_id="42",
status="connected", status="connected",
) )
await repo.store_credentials(connection["id"], access_token="secret-token")
return connection["id"] return connection["id"]
connection_id = anyio.run(seed_connection) connection_id = anyio.run(seed_connection)
app = _make_app( app = _make_app(_enabled_connections_config(), repo, _channels_config())
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
}
),
repo,
)
with TestClient(app) as client: with TestClient(app) as client:
response = client.delete(f"/api/channels/connections/{connection_id}") 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"] return (await repo.list_connections(str(_user().id)))[0]["status"]
assert anyio.run(get_connection_status) == "revoked" assert anyio.run(get_connection_status) == "revoked"
assert anyio.run(repo.get_credentials, connection_id) is None
anyio.run(repo.close) anyio.run(repo.close)
@@ -591,16 +272,7 @@ def test_disconnect_connection_is_current_user_scoped(tmp_path):
return connection["id"] return connection["id"]
connection_id = anyio.run(seed_connection) connection_id = anyio.run(seed_connection)
app = _make_app( app = _make_app(_enabled_connections_config(), repo, _channels_config())
ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"public_base_url": "https://deerflow.example.com",
"encryption_key": "router-secret",
}
),
repo,
)
with TestClient(app) as client: with TestClient(app) as client:
response = client.delete(f"/api/channels/connections/{connection_id}") 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}} 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 app.channels.service import ChannelService
from deerflow.config.channel_connections_config import ChannelConnectionsConfig from deerflow.config.channel_connections_config import ChannelConnectionsConfig
@@ -3285,74 +3285,43 @@ class TestChannelService:
channel_connections=ChannelConnectionsConfig.model_validate( channel_connections=ChannelConnectionsConfig.model_validate(
{ {
"enabled": True, "enabled": True,
"public_base_url": "https://deerflow.example.com", "telegram": {"enabled": True, "bot_username": "deerflow_bot"},
"encryption_key": "secret", "slack": {"enabled": True},
"telegram": { "discord": {"enabled": True},
"enabled": True, }
"bot_token": "telegram-token", ),
"bot_username": "deerflow_bot", )
"webhook_secret": "webhook-secret",
}, 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) service = ChannelService.from_app_config(app_config)
assert service._config["telegram"]["enabled"] is True
assert service._config["telegram"]["bot_token"] == "telegram-token" assert service._config["telegram"]["bot_token"] == "telegram-token"
assert service._config["slack"]["app_token"] == "xapp"
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["discord"]["bot_token"] == "discord-bot-token" assert service._config["discord"]["bot_token"] == "discord-bot-token"
def test_connection_repo_is_forwarded_to_manager(self): 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(): async def protected_mutation():
return {"ok": True} return {"ok": True}
@app.post("/api/channels/webhooks/slack/events")
async def slack_events_webhook():
return {"ok": True}
return app return app
@@ -239,12 +235,13 @@ def test_non_auth_mutation_rejects_mismatched_double_submit_token():
assert response.json()["detail"] == "CSRF token mismatch." 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") client = TestClient(_make_app(), base_url="https://deerflow.example")
response = client.post( response = client.post(
"/api/channels/webhooks/slack/events", "/api/channels/slack/connect",
headers={"Origin": "https://slack.com"}, 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 __future__ import annotations
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from app.channels.discord import DiscordChannel 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.connection_id == connection["id"]
assert attached.owner_user_id == "alice" assert attached.owner_user_id == "alice"
assert attached.workspace_id is None 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()
+31 -125
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 from __future__ import annotations
import hashlib from datetime import UTC, datetime, timedelta
import hmac
import json
import time
from unittest.mock import AsyncMock, MagicMock 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.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): async def _make_repo(tmp_path):
@@ -39,121 +19,47 @@ async def _make_repo(tmp_path):
) )
def _make_app(config: ChannelConnectionsConfig, repo, bus): def test_slack_connect_command_binds_socket_mode_identity(tmp_path):
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):
import anyio import anyio
repo = anyio.run(_make_repo, tmp_path) from app.channels.slack import SlackChannel
async def seed_connection(): async def go():
return await repo.upsert_connection( repo = await _make_repo(tmp_path)
owner_user_id=str(_user().id), state = "slack-bind-code"
await repo.create_oauth_state(
owner_user_id="deerflow-user-1",
provider="slack", provider="slack",
external_account_id="U123", state=state,
workspace_id="T123", expires_at=datetime.now(UTC) + timedelta(minutes=5),
workspace_name="Deer Team",
status="connected",
) )
channel = SlackChannel(
bus=MessageBus(),
config={"bot_token": "xoxb-operator", "app_token": "xapp-operator", "connection_repo": repo},
)
channel._web_client = MagicMock()
connection = anyio.run(seed_connection) handled = await channel._bind_connection_from_connect_code(
bus = AsyncMock() event={
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", "user": "U123",
"channel": "C123", "channel": "C123",
"text": "hello deerflow",
"ts": "1710000000.000100", "ts": "1710000000.000100",
}, },
} team_id="T123",
body = json.dumps(payload, separators=(",", ":")).encode("utf-8") code=state,
timestamp = str(int(time.time())) )
headers = {
"X-Slack-Request-Timestamp": timestamp,
"X-Slack-Signature": _slack_signature("slack-signing-secret", timestamp, body),
}
with TestClient(app) as client: connections = await repo.list_connections("deerflow-user-1")
response = client.post("/api/channels/webhooks/slack/events", content=body, headers=headers) assert handled is True
duplicate = client.post("/api/channels/webhooks/slack/events", content=body, headers=headers) 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()
assert response.status_code == 200 anyio.run(go)
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)
def test_slack_send_uses_connection_bot_token_when_connection_id_is_present(): 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 from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.channels.message_bus import MessageBus from app.channels.message_bus import MessageBus
from app.channels.telegram import TelegramChannel from app.channels.telegram import TelegramChannel
from app.gateway.routers import channel_connections
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
@pytest.fixture @pytest.fixture
@@ -102,44 +98,3 @@ async def test_bound_telegram_message_publishes_connection_identity(repo):
assert inbound.user_id == "42" assert inbound.user_id == "42"
assert inbound.chat_id == "100" assert inbound.chat_id == "100"
assert inbound.text == "hello" 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 # User-Owned IM Channel Connections
# ============================================================================ # ============================================================================
# Lets logged-in users connect their own Telegram, Slack, and Discord accounts # Lets logged-in users connect their own Telegram, Slack, and Discord accounts
# from the DeerFlow frontend. This is separate from the legacy operator-owned # from the DeerFlow frontend while reusing the existing `channels` runtime
# `channels` block below: # configuration 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.
# #
# Security notes: # Security notes:
# - `mode: local` supports local/private deployments. Telegram deep-link # - No public IP, OAuth callback URL, or provider webhook is required.
# binding works with long polling and does not require a public URL. # - Provider bot/app credentials stay under `channels.*`.
# - `mode: public` requires `public_base_url`, an externally reachable HTTPS # - `channel_connections` stores per-user bindings and one-time connect codes.
# origin for provider callbacks and webhooks. Slack HTTP Events and Telegram # - Telegram uses a deep link when `bot_username` is configured.
# webhooks need this, or a tunnel, even when DeerFlow itself runs locally. # - Slack and Discord use `/connect <code>` through the already-running bot.
# - `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.
# #
# channel_connections: # channel_connections:
# enabled: false # enabled: false
# mode: local
# # public_base_url: https://deerflow.example.com
# # encryption_key: $DEER_FLOW_CHANNEL_CONNECTIONS_KEY
# #
# telegram: # telegram:
# enabled: false # enabled: false
# bot_token: $TELEGRAM_BOT_TOKEN
# bot_username: $TELEGRAM_BOT_USERNAME # bot_username: $TELEGRAM_BOT_USERNAME
# delivery: polling
# # webhook_secret: $TELEGRAM_WEBHOOK_SECRET
# #
# slack: # slack:
# enabled: false # 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: # discord:
# enabled: false # 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 # IM Channels Configuration
@@ -106,12 +106,20 @@ export function WorkspaceChannelsList() {
disabled={!canConnect || isPending} disabled={!canConnect || isPending}
title={getProviderDisabledReason(provider, t)} title={getProviderDisabledReason(provider, t)}
onClick={() => { onClick={() => {
const connectWindow = prepareConnectWindow(); const connectWindow =
provider.auth_mode === "deep_link"
? prepareConnectWindow()
: null;
void connectMutation void connectMutation
.mutateAsync(provider.provider) .mutateAsync(provider.provider)
.then((result) => .then((result) => {
openConnectUrl(result.url, connectWindow), if (result.url) {
) openConnectUrl(result.url, connectWindow);
return;
}
closeConnectWindow(connectWindow);
toast.success(result.instruction);
})
.catch((error) => { .catch((error) => {
closeConnectWindow(connectWindow); closeConnectWindow(connectWindow);
toast.error( toast.error(
@@ -165,10 +165,20 @@ function ChannelProviderItem({
disabled={!canConnect || isConnecting} disabled={!canConnect || isConnecting}
title={unavailableReason} title={unavailableReason}
onClick={() => { onClick={() => {
const connectWindow = prepareConnectWindow(); const connectWindow =
provider.auth_mode === "deep_link"
? prepareConnectWindow()
: null;
void connectMutation void connectMutation
.mutateAsync(provider.provider) .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) => { .catch((error) => {
closeConnectWindow(connectWindow); closeConnectWindow(connectWindow);
toast.error( toast.error(
+3 -1
View File
@@ -35,6 +35,8 @@ export interface ChannelConnectionsResponse {
export interface ChannelConnectResponse { export interface ChannelConnectResponse {
provider: ChannelProviderId; provider: ChannelProviderId;
mode: string; mode: string;
url: string; url?: string | null;
code: string;
instruction: string;
expires_in: number; expires_in: number;
} }
+9 -8
View File
@@ -23,7 +23,7 @@ function mockChannelsAPI(page: Page) {
display_name: "Slack", display_name: "Slack",
enabled: true, enabled: true,
configured: true, configured: true,
auth_mode: "oauth", auth_mode: "binding_code",
connection_status: "not_connected", connection_status: "not_connected",
}, },
{ {
@@ -31,7 +31,7 @@ function mockChannelsAPI(page: Page) {
display_name: "Discord", display_name: "Discord",
enabled: true, enabled: true,
configured: true, configured: true,
auth_mode: "oauth_and_bot_install", auth_mode: "binding_code",
connection_status: "not_connected", connection_status: "not_connected",
}, },
], ],
@@ -53,8 +53,10 @@ function mockChannelsAPI(page: Page) {
contentType: "application/json", contentType: "application/json",
body: JSON.stringify({ body: JSON.stringify({
provider: "slack", provider: "slack",
mode: "oauth", mode: "binding_code",
url: "http://localhost:3000/mock-slack-oauth?client_id=dev&state=test", url: null,
code: "abc123",
instruction: "Send /connect abc123 to the DeerFlow Slack bot.",
expires_in: 600, expires_in: 600,
}), }),
}); });
@@ -91,11 +93,10 @@ test.describe("IM channels", () => {
const connectButtons = dialog.getByRole("button", { name: "Connect" }); const connectButtons = dialog.getByRole("button", { name: "Connect" });
await expect(connectButtons).toHaveCount(3); await expect(connectButtons).toHaveCount(3);
const popupPromise = page.waitForEvent("popup");
await connectButtons.nth(1).click(); await connectButtons.nth(1).click();
const popup = await popupPromise;
await expect(page).toHaveURL(/\/workspace\/chats\/new/); await expect(page).toHaveURL(/\/workspace\/chats\/new/);
await expect(popup).toHaveURL(/\/mock-slack-oauth/); await expect(
await popup.close(); page.getByText("Send /connect abc123 to the DeerFlow Slack bot."),
).toBeVisible();
}); });
}); });
@@ -85,6 +85,8 @@ describe("channels api", () => {
provider: "telegram", provider: "telegram",
mode: "deep_link", mode: "deep_link",
url: "https://t.me/deerflow_bot?start=state", url: "https://t.me/deerflow_bot?start=state",
code: "state",
instruction: "Send /start state to the DeerFlow Telegram bot.",
expires_in: 600, expires_in: 600,
}), }),
); );
@@ -92,6 +94,7 @@ describe("channels api", () => {
await expect(connectChannelProvider("telegram")).resolves.toMatchObject({ await expect(connectChannelProvider("telegram")).resolves.toMatchObject({
provider: "telegram", provider: "telegram",
url: "https://t.me/deerflow_bot?start=state", url: "https://t.me/deerflow_bot?start=state",
instruction: "Send /start state to the DeerFlow Telegram bot.",
}); });
expect(mockedFetch).toHaveBeenCalledWith( expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/telegram/connect", "/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 () => { test("disconnects a channel connection", async () => {
mockedFetch.mockResolvedValueOnce(new Response(null, { status: 204 })); 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", () => { test("falls back to current-window navigation when no popup is available", () => {
const { assign } = stubWindow(null); 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", () => { test("closes a prepared popup on connect failure", () => {