Compare commits

..

2 Commits

Author SHA1 Message Date
DanielWalnut f401e7baa6 [codex] Fix stale AIO sandbox cache reuse (#3494)
* Fix stale AIO sandbox cache reuse

* Address AIO sandbox review feedback

* Distinguish sandbox health check failures

* Keep local discovery recoverable when the runtime check fails

LocalContainerBackend.discover() shares _is_container_running, which now
raises on transient daemon errors instead of returning False. Discovery has
no exception handling in _discover_or_create_with_lock(_async), so a brief
Docker hiccup turned a recoverable "could not verify, create instead" into a
hard acquire failure. Catch the check failure inside discover() and return
None so an unverifiable container is simply not adopted, restoring the
pre-change fall-through while keeping raise-on-unknown semantics protecting
the destroy path.

Reported by fancy-agent on PR #3494.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Narrow the not-found match in container inspect error handling

A bare "not found" substring also matches transient failures like "command
not found" or "context not found", which would misclassify a check error as
"container definitely gone" and bypass the raise-on-unknown contract. Keep
Docker's specific "No such object"/"No such container" phrases, and only
trust a generic "not found" (Apple Container) when the message names the
inspected container or refers to a container/object.

Reported by WillemJiang on PR #3494.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:53:37 +08:00
Huixin615 919d8bc279 fix(sandbox): persist lazily-acquired sandbox state via Command (#3464)
* fix(sandbox): persist lazily-acquired sandbox state via Command

ensure_sandbox_initialized mutates runtime.state in place, which is local
to the current tool invocation and is not picked up by LangGraph's channel
reducer. Subsequent graph steps and downstream consumers (such as
ToolOutputBudgetMiddleware and the sub-agent task_tool) therefore cannot
observe the sandbox id from state.

Wrap tool calls in SandboxMiddleware (wrap_tool_call / awrap_tool_call) to
detect fresh lazy initialization by diffing runtime.state before and after
the handler, and emit a proper state update via Command(update=...):

- ToolMessage results are wrapped into Command(update={sandbox, messages})
- Command results with a dict update are merged on the sandbox key while
  preserving messages / goto / graph / resume
- Command results with non-dict updates are left untouched to avoid silent
  data loss on unknown update shapes

Tests:
- 7 new unit tests cover lazy-init emit, passthrough, dict-update merge,
  non-dict-update passthrough (sync and async)
- Refresh replay golden write_read_file.ultra.events.json: SSE 'values'
  events now correctly carry the 'sandbox' key in their keys list, which
  is the direct evidence that the fix is effective

Closes #3463

* refactor(sandbox): use dataclasses.replace to preserve Command fields

Address Copilot review on #3464: replace manual field-copy with
dataclasses.replace so any current or future Command fields are
preserved automatically when merging sandbox_update.

Also add a regression test that constructs a Command with non-None
graph/goto/resume to lock this behavior in.
2026-06-11 17:50:36 +08:00
97 changed files with 978 additions and 7323 deletions
-2
View File
@@ -343,8 +343,6 @@ See the [MCP Server Guide](backend/docs/MCP_SERVER.md) for detailed instructions
DeerFlow supports receiving tasks from messaging apps. Channels auto-start when configured — no public IP required for any of them.
DeerFlow can also expose user-owned IM channel connections in the workspace UI. When `channel_connections` is enabled, logged-in users can bind Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, or WeCom 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 |
|---------|-----------|------------|
| Telegram | Bot API (long-polling) | Easy |
+11 -23
View File
@@ -284,7 +284,7 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
**Provider Pattern**: `SandboxProvider` with `acquire`, `acquire_async`, `get`, `release` lifecycle. Async agent/tool paths call async sandbox lifecycle hooks so Docker sandbox creation, discovery, cross-process locking, readiness polling, and release stay off the event loop.
**Implementations**:
- `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-thread `LocalSandbox` (id `local:{thread_id}`) whose `path_mappings` resolve `/mnt/user-data/{workspace,uploads,outputs}` and `/mnt/acp-workspace` to that thread's host directories, so the public `Sandbox` API honours the `/mnt/user-data` contract uniformly with AIO. `acquire()` / `acquire(None)` keeps the legacy generic singleton (id `local`) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by a `threading.Lock`.
- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation
- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation. Active-cache and warm-pool entries are checked with the backend during acquire/reuse; definitively dead containers are dropped from all in-process maps so the thread can discover or create a fresh sandbox instead of reusing a stale client. Backend health-check failures are treated as unknown, not dead; local discovery likewise treats an unverifiable container as not adoptable and falls through to create rather than failing acquire. `get()` remains an in-memory lookup for event-loop-safe tool paths.
**Virtual Path System**:
- Agent sees: `/mnt/user-data/{workspace,uploads,outputs}`, `/mnt/skills`
@@ -369,7 +369,8 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
### IM Channels System (`app/channels/`)
Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk) to the DeerFlow agent via Gateway's LangGraph-compatible API.
Bridges external messaging platforms (Feishu, Slack, Telegram, DingTalk) to the DeerFlow agent via Gateway's LangGraph-compatible API.
**Architecture**: Channels communicate with Gateway through the `langgraph-sdk` HTTP client (same as the frontend), ensuring threads are created and managed server-side. The internal SDK client injects process-local internal auth plus a matching CSRF cookie/header pair so Gateway accepts state-changing thread/run requests from channel workers without relying on browser session cookies.
@@ -379,21 +380,18 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk
- `manager.py` - Core dispatcher: creates threads via `client.threads.create()`, routes commands, keeps Slack/Telegram on `client.runs.wait()`, and uses `client.runs.stream(["messages-tuple", "values"])` for Feishu incremental outbound updates
- `base.py` - Abstract `Channel` base class (start/stop/send lifecycle)
- `service.py` - Manages lifecycle of all configured channels from `config.yaml`
- `slack.py` / `feishu.py` / `telegram.py` / `discord.py` / `dingtalk.py` - Platform-specific implementations (`feishu.py` tracks the running card `message_id` in memory and patches the same card in place; `dingtalk.py` optionally uses AI Card streaming for in-place updates when `card_template_id` is configured)
- `app/gateway/routers/channel_connections.py` - Browser-facing user connection and disconnect APIs
- `deerflow.persistence.channel_connections` - SQL-backed user-owned connection, optional credential, connect state, and conversation store
- `slack.py` / `feishu.py` / `telegram.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)
**Message Flow**:
1. External platform -> Channel impl -> `MessageBus.publish_inbound()`
2. `ChannelManager._dispatch_loop()` consumes from queue
3. For user-owned channel connections, incoming messages carry `connection_id`, `owner_user_id`, and `workspace_id`; `owner_user_id` becomes the DeerFlow run `user_id`, while the raw platform user id remains `channel_user_id`
4. For chat: look up/create thread through Gateway's LangGraph-compatible API
5. Feishu chat: `runs.stream()`accumulate AI text → publish multiple outbound updates (`is_final=False`) → publish final outbound (`is_final=True`)
6. Slack/Telegram chat: `runs.wait()` → extract final response → publish outbound
7. Feishu channel sends one running reply card up front, then patches the same card for each outbound update (card JSON sets `config.update_multi=true` for Feishu's patch API requirement)
8. DingTalk AI Card mode (when `card_template_id` configured): `runs.stream()` → create card with initial text → stream updates via `PUT /v1.0/card/streaming` → finalize on `is_final=True`. Falls back to `sampleMarkdown` if card creation or streaming fails
9. For commands (`/new`, `/status`, `/models`, `/memory`, `/help`): handle locally or query Gateway API
10. Outbound → channel callbacks → platform reply
3. For chat: look up/create thread through Gateway's LangGraph-compatible API
4. Feishu chat: `runs.stream()` → accumulate AI text → publish multiple outbound updates (`is_final=False`) → publish final outbound (`is_final=True`)
5. Slack/Telegram chat: `runs.wait()`extract final response → publish outbound
6. Feishu channel sends one running reply card up front, then patches the same card for each outbound update (card JSON sets `config.update_multi=true` for Feishu's patch API requirement)
7. DingTalk AI Card mode (when `card_template_id` configured): `runs.stream()` → create card with initial text → stream updates via `PUT /v1.0/card/streaming` → finalize on `is_final=True`. Falls back to `sampleMarkdown` if card creation or streaming fails
8. For commands (`/new`, `/status`, `/models`, `/memory`, `/help`): handle locally or query Gateway API
9. Outbound → channel callbacks → platform reply
**Configuration** (`config.yaml` -> `channels`):
- `langgraph_url` - LangGraph-compatible Gateway API base URL (default: `http://localhost:8001/api`)
@@ -401,16 +399,6 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk
- In Docker Compose, IM channels run inside the `gateway` container, so `localhost` points back to that container. Use `http://gateway:8001/api` for `langgraph_url` and `http://gateway:8001` for `gateway_url`, or set `DEER_FLOW_CHANNELS_LANGGRAPH_URL` / `DEER_FLOW_CHANNELS_GATEWAY_URL`.
- Per-channel configs: `feishu` (app_id, app_secret), `slack` (bot_token, app_token), `telegram` (bot_token), `dingtalk` (client_id, client_secret, optional `card_template_id` for AI Card streaming)
**User-owned channel connections** (`config.yaml` -> `channel_connections`):
- Disabled by default. It is a user-binding layer on top of the existing `channels.*` runtime config, not a replacement for provider bot credentials.
- No public IP, OAuth callback URL, or provider webhook route is required by the current implementation.
- Telegram uses a deep-link `/start <code>` flow over the existing long-polling worker. Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom use `/connect <code>` over their existing outbound channel workers.
- Frontend APIs: `GET /api/channels/providers`, `GET /api/channels/connections`, `POST /api/channels/{provider}/connect`, and `DELETE /api/channels/connections/{connection_id}`.
- Browser APIs remain protected by normal Gateway auth/CSRF. Provider messages arrive through the already-configured channel workers.
- Slack replies use the configured operator bot token from `channels.slack` unless a future provider-token flow stores per-connection credentials.
- Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom workers resolve incoming platform identities to connection records before reaching `ChannelManager`.
- See `backend/docs/IM_CHANNEL_CONNECTIONS.md` for provider setup and operational notes.
### Memory System (`packages/harness/deerflow/agents/memory/`)
+1 -1
View File
@@ -69,7 +69,7 @@ Middlewares execute in strict order, each handling a specific concern:
Per-thread isolated execution with virtual path translation:
- **Abstract interface**: `execute_command`, `read_file`, `write_file`, `list_dir`
- **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/). Async runtime paths use async sandbox lifecycle hooks so startup, readiness polling, and release do not block the event loop.
- **Providers**: `LocalSandboxProvider` (filesystem) and `AioSandboxProvider` (Docker, in community/). Async runtime paths use async sandbox lifecycle hooks so startup, readiness polling, and release do not block the event loop. `AioSandboxProvider` validates active-cache and warm-pool containers during acquire/reuse, dropping definitively dead entries so a thread can provision a fresh sandbox after an unexpected container exit while keeping `get()` as an in-memory lookup. Backend health-check failures are treated as unknown, not dead, and a container that cannot be verified during discovery is simply not adopted (acquire falls through to create instead of failing).
- **Virtual paths**: `/mnt/user-data/{workspace,uploads,outputs}` → thread-specific physical directories
- **Skills path**: `/mnt/skills``deer-flow/skills/` directory
- **Skills loading**: Recursively discovers nested `SKILL.md` files under `skills/{public,custom}` and preserves nested container paths
-11
View File
@@ -20,17 +20,6 @@ KNOWN_CHANNEL_COMMANDS: frozenset[str] = frozenset(
)
def extract_connect_code(text: str) -> str | None:
"""Extract the one-time channel binding code from a connect command."""
parts = text.strip().split()
if len(parts) < 2:
return None
command = parts[0].lower()
if command in {"/connect", "connect"}:
return parts[1]
return None
def is_known_channel_command(text: str) -> bool:
"""Return whether text starts with a registered channel control command."""
if not text.startswith("/"):
@@ -1,44 +0,0 @@
"""Helpers for attaching persisted channel connection ownership to inbound messages."""
from __future__ import annotations
from typing import Any
from app.channels.message_bus import InboundMessage
async def attach_connection_identity(
inbound: InboundMessage,
*,
repo: Any,
provider: str,
workspace_id: str | None,
fallback_without_workspace: bool = False,
) -> InboundMessage:
"""Attach connection metadata to an inbound message when a persisted binding exists."""
if repo is None:
return inbound
workspace_candidates: list[str | None] = []
if workspace_id:
workspace_candidates.append(workspace_id)
if fallback_without_workspace:
workspace_candidates.append(None)
if not workspace_candidates:
return inbound
for candidate in workspace_candidates:
connection = await repo.find_connection_by_external_identity(
provider=provider,
external_account_id=inbound.user_id,
workspace_id=candidate,
)
if connection is None:
continue
inbound.connection_id = connection["id"]
inbound.owner_user_id = connection["owner_user_id"]
inbound.workspace_id = connection.get("workspace_id")
return inbound
return inbound
+1 -105
View File
@@ -14,8 +14,7 @@ from typing import Any
import httpx
from app.channels.base import Channel
from app.channels.commands import extract_connect_code, is_known_channel_command
from app.channels.connection_identity import attach_connection_identity
from app.channels.commands import is_known_channel_command
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
logger = logging.getLogger(__name__)
@@ -137,7 +136,6 @@ class DingTalkChannel(Channel):
self._incoming_messages: dict[str, Any] = {}
self._incoming_messages_lock = threading.Lock()
self._card_repliers: dict[str, Any] = {}
self._connection_repo = config.get("connection_repo")
@property
def supports_streaming(self) -> bool:
@@ -397,24 +395,6 @@ class DingTalkChannel(Channel):
text[:100],
)
connect_code = extract_connect_code(text)
if connect_code and self._connection_repo is not None:
if self._main_loop and self._main_loop.is_running():
fut = asyncio.run_coroutine_threadsafe(
self._bind_connection_from_connect_code(
conversation_type=conversation_type,
sender_staff_id=sender_staff_id,
sender_nick=sender_nick,
conversation_id=conversation_id,
code=connect_code,
),
self._main_loop,
)
fut.add_done_callback(lambda f, mid=msg_id: self._log_future_error(f, "bind_connection", mid))
else:
logger.warning("[DingTalk] main loop not running, cannot bind channel connection")
return
if _is_dingtalk_command(text):
msg_type = InboundMessageType.COMMAND
else:
@@ -470,95 +450,11 @@ class DingTalkChannel(Channel):
return ""
async def _prepare_inbound(self, chat_id: str, inbound: InboundMessage) -> None:
inbound = await self._attach_connection_identity(inbound)
# Running reply must finish before publish_inbound so AI card tracks are
# registered before the manager emits streaming outbounds.
await self._send_running_reply(chat_id, inbound)
await self.bus.publish_inbound(inbound)
@staticmethod
def _connection_workspace_id(conversation_type: str, conversation_id: str) -> str | None:
if conversation_type == _CONVERSATION_TYPE_GROUP and conversation_id:
return conversation_id
return None
async def _attach_connection_identity(self, inbound: InboundMessage) -> InboundMessage:
conversation_type = str(inbound.metadata.get("conversation_type") or _CONVERSATION_TYPE_P2P)
conversation_id = str(inbound.metadata.get("conversation_id") or "")
return await attach_connection_identity(
inbound,
repo=self._connection_repo,
provider="dingtalk",
workspace_id=self._connection_workspace_id(conversation_type, conversation_id),
fallback_without_workspace=True,
)
async def _bind_connection_from_connect_code(
self,
*,
conversation_type: str,
sender_staff_id: str,
sender_nick: str,
conversation_id: str,
code: str,
) -> bool:
if self._connection_repo is None or not code:
return False
state = await self._connection_repo.consume_oauth_state(provider="dingtalk", state=code)
if state is None:
await self._send_connection_reply(
conversation_type,
sender_staff_id,
conversation_id,
"DingTalk connection code is invalid or expired.",
)
return True
if not sender_staff_id:
await self._send_connection_reply(
conversation_type,
sender_staff_id,
conversation_id,
"DingTalk connection could not be completed from this message.",
)
return True
await self._connection_repo.upsert_connection(
owner_user_id=state["owner_user_id"],
provider="dingtalk",
external_account_id=sender_staff_id,
external_account_name=sender_nick or None,
workspace_id=self._connection_workspace_id(conversation_type, conversation_id),
metadata={
"conversation_type": conversation_type,
"conversation_id": conversation_id,
},
status="connected",
)
await self._send_connection_reply(
conversation_type,
sender_staff_id,
conversation_id,
"DingTalk connected to DeerFlow.",
)
return True
async def _send_connection_reply(
self,
conversation_type: str,
sender_staff_id: str,
conversation_id: str,
text: str,
) -> None:
robot_code = self._client_id
if conversation_type == _CONVERSATION_TYPE_GROUP:
if conversation_id:
await self._send_text_message_to_group(robot_code, conversation_id, text)
return
if sender_staff_id:
await self._send_text_message_to_user(robot_code, sender_staff_id, text)
async def _send_running_reply(self, chat_id: str, inbound: InboundMessage) -> None:
conversation_type = inbound.metadata.get("conversation_type", _CONVERSATION_TYPE_P2P)
sender_staff_id = inbound.metadata.get("sender_staff_id", "")
+2 -64
View File
@@ -10,9 +10,8 @@ from pathlib import Path
from typing import Any
from app.channels.base import Channel
from app.channels.commands import extract_connect_code, is_known_channel_command
from app.channels.connection_identity import attach_connection_identity
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
from app.channels.commands import is_known_channel_command
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
logger = logging.getLogger(__name__)
@@ -71,7 +70,6 @@ class DiscordChannel(Channel):
self._discord_loop: asyncio.AbstractEventLoop | None = None
self._main_loop: asyncio.AbstractEventLoop | None = None
self._discord_module = None
self._connection_repo = config.get("connection_repo")
async def start(self) -> None:
if self._running:
@@ -289,10 +287,6 @@ class DiscordChannel(Channel):
text = text.replace(bot_mention or "", "").replace(alt_mention or "", "").replace(standard_mention or "", "").strip()
# Don't return early if text is empty — still process the mention (e.g., create thread)
connect_code = extract_connect_code(text)
if connect_code and await self._bind_connection_from_connect_code(message, connect_code):
return
# --- Determine thread/channel routing and typing target ---
thread_id = None
chat_id = None
@@ -321,7 +315,6 @@ class DiscordChannel(Channel):
},
)
inbound.topic_id = thread_id
inbound = await self._attach_connection_identity(inbound, guild_id=str(guild.id) if guild else None)
self._publish(inbound)
# Start typing indicator in the thread
if typing_target:
@@ -429,7 +422,6 @@ class DiscordChannel(Channel):
},
)
inbound.topic_id = thread_id
inbound = await self._attach_connection_identity(inbound, guild_id=str(guild.id) if guild else None)
# Start typing indicator in the correct target (thread or channel)
if typing_target:
@@ -444,60 +436,6 @@ class DiscordChannel(Channel):
future = asyncio.run_coroutine_threadsafe(self.bus.publish_inbound(inbound), self._main_loop)
future.add_done_callback(lambda f: logger.exception("[Discord] publish_inbound failed", exc_info=f.exception()) if f.exception() else None)
async def _attach_connection_identity(self, inbound: InboundMessage, guild_id: str | None = None) -> InboundMessage:
return await attach_connection_identity(
inbound,
repo=self._connection_repo,
provider="discord",
workspace_id=guild_id,
fallback_without_workspace=True,
)
async def _bind_connection_from_connect_code(self, message, code: str) -> bool:
if self._connection_repo is None or not code:
return False
state = await self._connection_repo.consume_oauth_state(provider="discord", state=code)
if state is None:
await self._send_connection_reply(message, "Discord connection code is invalid or expired.")
return True
guild = getattr(message, "guild", None)
channel = getattr(message, "channel", None)
author = getattr(message, "author", None)
user_id = str(getattr(author, "id", "") or "")
if not user_id:
await self._send_connection_reply(message, "Discord connection could not be completed from this message.")
return True
guild_id = str(getattr(guild, "id", "") or "") or None
await self._connection_repo.upsert_connection(
owner_user_id=state["owner_user_id"],
provider="discord",
external_account_id=user_id,
external_account_name=getattr(author, "display_name", None) or getattr(author, "name", None),
workspace_id=guild_id,
workspace_name=getattr(guild, "name", None) if guild is not None else None,
metadata={
"guild_id": guild_id,
"channel_id": str(getattr(channel, "id", "") or ""),
},
status="connected",
)
await self._send_connection_reply(message, "Discord connected to DeerFlow.")
return True
@staticmethod
async def _send_connection_reply(message, text: str) -> None:
channel = getattr(message, "channel", None)
send = getattr(channel, "send", None)
if send is None:
return
try:
await send(text)
except Exception:
logger.exception("[Discord] failed to send connection reply")
def _run_client(self) -> None:
self._discord_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._discord_loop)
+2 -78
View File
@@ -11,8 +11,7 @@ import time
from typing import Any, Literal
from app.channels.base import Channel
from app.channels.commands import extract_connect_code, is_known_channel_command
from app.channels.connection_identity import attach_connection_identity
from app.channels.commands import is_known_channel_command
from app.channels.message_bus import (
PENDING_CLARIFICATION_METADATA_KEY,
RESOLVED_FROM_PENDING_CLARIFICATION_METADATA_KEY,
@@ -72,7 +71,6 @@ class FeishuChannel(Channel):
self._CreateImageRequestBody = None
self._GetMessageResourceRequest = None
self._thread_lock = threading.Lock()
self._connection_repo = config.get("connection_repo")
@staticmethod
def _non_empty_str(value: Any) -> str | None:
@@ -88,23 +86,6 @@ class FeishuChannel(Channel):
def supports_streaming(self) -> bool:
return True
@property
def is_running(self) -> bool:
if not self._running:
return False
return self._thread is not None and self._thread.is_alive()
def _build_event_handler(self, lark):
return (
lark.EventDispatcherHandler.builder("", "")
.register_p2_im_message_receive_v1(self._on_message)
.register_p2_im_message_message_read_v1(self._on_ignored_message_event)
.register_p2_im_message_reaction_created_v1(self._on_ignored_message_event)
.register_p2_im_message_reaction_deleted_v1(self._on_ignored_message_event)
.register_p2_im_message_recalled_v1(self._on_ignored_message_event)
.build()
)
async def start(self) -> None:
if self._running:
return
@@ -198,7 +179,7 @@ class FeishuChannel(Channel):
# thread's uvloop.
_ws_client_mod.loop = loop
event_handler = self._build_event_handler(lark)
event_handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(self._on_message).build()
ws_client = lark.ws.Client(
app_id=app_id,
app_secret=app_secret,
@@ -210,10 +191,6 @@ class FeishuChannel(Channel):
except Exception:
if self._running:
logger.exception("Feishu WebSocket error")
self._running = False
def _on_ignored_message_event(self, event) -> None:
logger.debug("[Feishu] ignoring non-content message event: %s", type(event).__name__)
async def stop(self) -> None:
self._running = False
@@ -749,47 +726,11 @@ class FeishuChannel(Channel):
async def _prepare_inbound(self, msg_id: str, inbound) -> None:
"""Kick off Feishu side effects without delaying inbound dispatch."""
inbound = await self._attach_connection_identity(inbound)
reaction_task = asyncio.create_task(self._add_reaction(msg_id, "OK"))
self._track_background_task(reaction_task, name="add_reaction", msg_id=msg_id)
self._ensure_running_card_started(msg_id)
await self.bus.publish_inbound(inbound)
async def _attach_connection_identity(self, inbound: InboundMessage) -> InboundMessage:
return await attach_connection_identity(
inbound,
repo=self._connection_repo,
provider="feishu",
workspace_id=inbound.chat_id,
)
async def _bind_connection_from_connect_code(self, *, message_id: str, chat_id: str, user_id: str, code: str) -> bool:
if self._connection_repo is None or not code:
return False
state = await self._connection_repo.consume_oauth_state(provider="feishu", state=code)
if state is None:
await self._reply_card(message_id, "Feishu connection code is invalid or expired.")
return True
if not user_id or not chat_id:
await self._reply_card(message_id, "Feishu connection could not be completed from this message.")
return True
await self._connection_repo.upsert_connection(
owner_user_id=state["owner_user_id"],
provider="feishu",
external_account_id=user_id,
workspace_id=chat_id,
metadata={
"chat_id": chat_id,
"message_id": message_id,
},
status="connected",
)
await self._reply_card(message_id, "Feishu connected to DeerFlow.")
return True
def _on_message(self, event) -> None:
"""Called by lark-oapi when a message is received (runs in lark thread)."""
try:
@@ -878,23 +819,6 @@ class FeishuChannel(Channel):
logger.info("[Feishu] empty text, ignoring message")
return
connect_code = extract_connect_code(text)
if connect_code and self._connection_repo is not None:
if self._main_loop and self._main_loop.is_running():
fut = asyncio.run_coroutine_threadsafe(
self._bind_connection_from_connect_code(
message_id=msg_id,
chat_id=chat_id,
user_id=sender_id,
code=connect_code,
),
self._main_loop,
)
fut.add_done_callback(lambda f, mid=msg_id: self._log_future_error(f, "bind_connection", mid))
else:
logger.warning("[Feishu] main loop not running, cannot bind channel connection")
return
# Only treat known slash commands as commands; absolute paths and
# other slash-prefixed text should be handled as normal chat.
if _is_feishu_command(text):
+30 -132
View File
@@ -274,22 +274,6 @@ def _response_metadata(base_metadata: dict[str, Any], *, pending_clarification:
return metadata
def _thread_channel_metadata(msg: InboundMessage) -> dict[str, Any]:
channel_source: dict[str, Any] = {
"type": "im_channel",
"provider": msg.channel_name,
"chat_id": msg.chat_id,
}
if msg.topic_id:
channel_source["topic_id"] = msg.topic_id
if msg.thread_ts:
channel_source["thread_ts"] = msg.thread_ts
if msg.connection_id:
channel_source["connection_id"] = msg.connection_id
return {"channel_source": channel_source}
def _extract_text_content(content: Any) -> str:
"""Extract text from a streaming payload content field."""
if isinstance(content, str):
@@ -456,33 +440,6 @@ def _human_input_message(content: str, *, original_content: str | None = None) -
return message
def _auth_disabled_owner_user_id() -> str | None:
try:
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID, is_auth_disabled
except Exception:
logger.debug("Unable to inspect auth-disabled mode for channel owner fallback", exc_info=True)
return None
return AUTH_DISABLED_USER_ID if is_auth_disabled() else None
def _effective_owner_user_id(msg: InboundMessage) -> str | None:
return _auth_disabled_owner_user_id() or msg.owner_user_id
def _apply_effective_owner(msg: InboundMessage) -> InboundMessage:
owner_user_id = _effective_owner_user_id(msg)
if owner_user_id:
msg.owner_user_id = owner_user_id
return msg
def _owner_headers(msg: InboundMessage) -> dict[str, str] | None:
owner_user_id = _effective_owner_user_id(msg)
if not owner_user_id:
return None
return create_internal_auth_headers(owner_user_id=owner_user_id)
def _resolve_slash_skill_command(
text: str,
available_skills: set[str] | None = None,
@@ -713,7 +670,6 @@ class ChannelManager:
assistant_id: str = DEFAULT_ASSISTANT_ID,
default_session: dict[str, Any] | None = None,
channel_sessions: dict[str, Any] | None = None,
connection_repo: Any | None = None,
) -> None:
self.bus = bus
self.store = store
@@ -723,7 +679,6 @@ class ChannelManager:
self._assistant_id = assistant_id
self._default_session = _as_dict(default_session)
self._channel_sessions = dict(channel_sessions or {})
self._connection_repo = connection_repo
self._client = None # lazy init — langgraph_sdk async client
self._skill_storage: SkillStorage | None = None
self._csrf_token = generate_csrf_token()
@@ -773,17 +728,12 @@ class ChannelManager:
configurable["checkpoint_ns"] = ""
configurable["thread_id"] = thread_id
# ``user_id`` drives DeerFlow-owned memory, files, and thread buckets.
# For browser-connected IM channels, prefer the DeerFlow account that
# owns the connection. Preserve the raw platform user under
# ``channel_user_id`` for platform-facing lookups and audits.
# ``user_id`` drives user-scoped filesystem buckets that only accept
# ``[A-Za-z0-9_-]``, so normalize the channel id and keep the raw value
# under ``channel_user_id`` for platform-facing lookups.
run_context_identity: dict[str, Any] = {"thread_id": thread_id}
owner_user_id = _effective_owner_user_id(msg)
if owner_user_id:
run_context_identity["user_id"] = make_safe_user_id(owner_user_id)
elif msg.user_id:
run_context_identity["user_id"] = make_safe_user_id(msg.user_id)
if msg.user_id:
run_context_identity["user_id"] = make_safe_user_id(msg.user_id)
run_context_identity["channel_user_id"] = msg.user_id
run_context = _merge_dicts(
@@ -895,7 +845,6 @@ class ChannelManager:
logger.error("[Manager] unhandled error in message task: %s", exc, exc_info=exc)
async def _handle_message(self, msg: InboundMessage) -> None:
msg = _apply_effective_owner(msg)
async with self._semaphore:
try:
if msg.msg_type == InboundMessageType.COMMAND:
@@ -928,27 +877,10 @@ class ChannelManager:
# -- chat handling -----------------------------------------------------
async def _lookup_thread_id(self, msg: InboundMessage) -> str | None:
if msg.connection_id and self._connection_repo is not None:
return await self._connection_repo.get_thread_id(
msg.connection_id,
msg.chat_id,
msg.topic_id,
)
return self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id)
async def _store_thread_id(self, msg: InboundMessage, thread_id: str) -> None:
if msg.connection_id and msg.owner_user_id and self._connection_repo is not None:
await self._connection_repo.set_thread_id(
connection_id=msg.connection_id,
owner_user_id=msg.owner_user_id,
provider=msg.channel_name,
external_conversation_id=msg.chat_id,
external_topic_id=msg.topic_id,
thread_id=thread_id,
)
return
async def _create_thread(self, client, msg: InboundMessage) -> str:
"""Create a new thread through Gateway and store the mapping."""
thread = await client.threads.create()
thread_id = thread["thread_id"]
self.store.set_thread_id(
msg.channel_name,
msg.chat_id,
@@ -956,40 +888,18 @@ class ChannelManager:
topic_id=msg.topic_id,
user_id=msg.user_id,
)
async def _create_thread(self, client, msg: InboundMessage) -> str:
"""Create a new thread through Gateway and store the mapping."""
metadata = _thread_channel_metadata(msg)
owner_headers = _owner_headers(msg)
if owner_headers:
thread = await client.threads.create(metadata=metadata, headers=owner_headers)
else:
thread = await client.threads.create(metadata=metadata)
thread_id = thread["thread_id"]
await self._store_thread_id(msg, thread_id)
logger.info("[Manager] new thread created through Gateway: thread_id=%s for chat_id=%s topic_id=%s", thread_id, msg.chat_id, msg.topic_id)
return thread_id
async def _update_thread_channel_metadata(self, client, msg: InboundMessage, thread_id: str) -> None:
"""Best-effort source metadata backfill for existing IM-created threads."""
update_kwargs: dict[str, Any] = {"metadata": _thread_channel_metadata(msg)}
if owner_headers := _owner_headers(msg):
update_kwargs["headers"] = owner_headers
try:
await client.threads.update(thread_id, **update_kwargs)
except Exception:
logger.debug("[Manager] failed to update channel metadata for thread_id=%s", thread_id, exc_info=True)
async def _handle_chat(self, msg: InboundMessage, extra_context: dict[str, Any] | None = None) -> None:
client = self._get_client()
# Look up existing DeerFlow thread.
# topic_id may be None (e.g. Telegram private chats) — the store
# handles this by using the "channel:chat_id" key without a topic suffix.
thread_id = await self._lookup_thread_id(msg)
thread_id = self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id)
if thread_id:
logger.info("[Manager] reusing thread: thread_id=%s for topic_id=%s", thread_id, msg.topic_id)
await self._update_thread_channel_metadata(client, msg, thread_id)
# No existing thread found — create a new one
if thread_id is None:
@@ -1030,19 +940,14 @@ class ChannelManager:
return
logger.info("[Manager] invoking runs.wait(thread_id=%s, text=%r)", thread_id, msg.text[:100])
run_kwargs: dict[str, Any] = {
"input": {"messages": [human_message]},
"config": run_config,
"context": run_context,
"multitask_strategy": "reject",
}
if owner_headers := _owner_headers(msg):
run_kwargs["headers"] = owner_headers
try:
result = await client.runs.wait(
thread_id,
assistant_id,
**run_kwargs,
input={"messages": [human_message]},
config=run_config,
context=run_context,
multitask_strategy="reject",
)
except Exception as exc:
if _is_thread_busy_error(exc):
@@ -1079,8 +984,6 @@ class ChannelManager:
artifacts=artifacts,
attachments=attachments,
thread_ts=msg.thread_ts,
connection_id=msg.connection_id,
owner_user_id=msg.owner_user_id,
metadata=_response_metadata(msg.metadata, pending_clarification=pending_clarification),
)
logger.info("[Manager] publishing outbound message to bus: channel=%s, chat_id=%s", msg.channel_name, msg.chat_id)
@@ -1105,21 +1008,16 @@ class ChannelManager:
last_published_text = ""
last_publish_at = 0.0
stream_error: BaseException | None = None
stream_kwargs: dict[str, Any] = {
"input": {"messages": [human_message]},
"config": run_config,
"context": run_context,
"stream_mode": ["messages-tuple", "values"],
"multitask_strategy": "reject",
}
if owner_headers := _owner_headers(msg):
stream_kwargs["headers"] = owner_headers
try:
async for chunk in client.runs.stream(
thread_id,
assistant_id,
**stream_kwargs,
input={"messages": [human_message]},
config=run_config,
context=run_context,
stream_mode=["messages-tuple", "values"],
multitask_strategy="reject",
):
event = getattr(chunk, "event", "")
data = getattr(chunk, "data", None)
@@ -1149,8 +1047,6 @@ class ChannelManager:
text=latest_text,
is_final=False,
thread_ts=msg.thread_ts,
connection_id=msg.connection_id,
owner_user_id=msg.owner_user_id,
metadata=_response_metadata(msg.metadata),
)
)
@@ -1197,8 +1093,6 @@ class ChannelManager:
attachments=attachments,
is_final=True,
thread_ts=msg.thread_ts,
connection_id=msg.connection_id,
owner_user_id=msg.owner_user_id,
metadata=_response_metadata(msg.metadata, pending_clarification=pending_clarification),
)
)
@@ -1230,10 +1124,18 @@ class ChannelManager:
if reply is None and command == "new":
# Create a new thread through Gateway
client = self._get_client()
await self._create_thread(client, msg)
thread = await client.threads.create()
new_thread_id = thread["thread_id"]
self.store.set_thread_id(
msg.channel_name,
msg.chat_id,
new_thread_id,
topic_id=msg.topic_id,
user_id=msg.user_id,
)
reply = "New conversation started."
elif reply is None and command == "status":
thread_id = await self._lookup_thread_id(msg)
thread_id = self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id)
reply = f"Active thread: {thread_id}" if thread_id else "No active conversation."
elif reply is None and command == "models":
reply = await self._fetch_gateway("/api/models", "models")
@@ -1272,11 +1174,9 @@ class ChannelManager:
outbound = OutboundMessage(
channel_name=msg.channel_name,
chat_id=msg.chat_id,
thread_id=await self._lookup_thread_id(msg) or "",
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id) or "",
text=reply,
thread_ts=msg.thread_ts,
connection_id=msg.connection_id,
owner_user_id=msg.owner_user_id,
metadata=_slim_metadata(msg.metadata),
)
await self.bus.publish_outbound(outbound)
@@ -1312,11 +1212,9 @@ class ChannelManager:
outbound = OutboundMessage(
channel_name=msg.channel_name,
chat_id=msg.chat_id,
thread_id=await self._lookup_thread_id(msg) or "",
thread_id=self.store.get_thread_id(msg.channel_name, msg.chat_id, topic_id=msg.topic_id) or "",
text=error_text,
thread_ts=msg.thread_ts,
connection_id=msg.connection_id,
owner_user_id=msg.owner_user_id,
metadata=_slim_metadata(msg.metadata),
)
await self.bus.publish_outbound(outbound)
-14
View File
@@ -44,12 +44,6 @@ class InboundMessage:
Messages sharing the same ``topic_id`` within a ``chat_id`` will
reuse the same DeerFlow thread. When ``None``, each message
creates a new thread (one-shot Q&A).
connection_id: Optional DeerFlow channel connection id. When present,
conversation mapping is scoped by the connection instead of the
legacy global ``channel_name:chat_id[:topic_id]`` key.
owner_user_id: DeerFlow user id that owns the channel connection.
Platform user ids stay in ``user_id``.
workspace_id: Optional external workspace/guild/team id.
files: Optional list of file attachments (platform-specific dicts).
metadata: Arbitrary extra data from the channel.
created_at: Unix timestamp when the message was created.
@@ -62,9 +56,6 @@ class InboundMessage:
msg_type: InboundMessageType = InboundMessageType.CHAT
thread_ts: str | None = None
topic_id: str | None = None
connection_id: str | None = None
owner_user_id: str | None = None
workspace_id: str | None = None
files: list[dict[str, Any]] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
created_at: float = field(default_factory=time.time)
@@ -104,9 +95,6 @@ class OutboundMessage:
is_final: Whether this is the final message in the response stream.
thread_ts: Optional platform thread identifier for threaded replies.
metadata: Arbitrary extra data.
connection_id: Optional DeerFlow channel connection id used for
connection-specific outbound credentials.
owner_user_id: DeerFlow user id that owns the channel connection.
created_at: Unix timestamp.
"""
@@ -118,8 +106,6 @@ class OutboundMessage:
attachments: list[ResolvedAttachment] = field(default_factory=list)
is_final: bool = True
thread_ts: str | None = None
connection_id: str | None = None
owner_user_id: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
created_at: float = field(default_factory=time.time)
@@ -1,137 +0,0 @@
"""Local persistence for runtime IM channel configuration."""
from __future__ import annotations
import json
import logging
import tempfile
import threading
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class ChannelRuntimeConfigStore:
"""JSON-backed store for channel credentials entered from the UI.
This intentionally mirrors ``ChannelStore``: local/private deployments get
durable runtime configuration without needing a public callback URL or a
config.yaml edit.
"""
def __init__(self, path: str | Path | None = None) -> None:
if path is None:
from deerflow.config.paths import get_paths
path = Path(get_paths().base_dir) / "channels" / "runtime-config.json"
self._path = Path(path)
self._path.parent.mkdir(parents=True, exist_ok=True)
self._data: dict[str, dict[str, Any]] = self._load()
self._lock = threading.Lock()
def _load(self) -> dict[str, dict[str, Any]]:
if self._path.exists():
try:
raw = json.loads(self._path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
logger.warning("Corrupt channel runtime config store at %s, starting fresh", self._path)
return {}
if isinstance(raw, dict):
return {str(name): dict(value) for name, value in raw.items() if isinstance(value, dict)}
return {}
def _save(self) -> None:
fd = tempfile.NamedTemporaryFile(
mode="w",
dir=self._path.parent,
suffix=".tmp",
delete=False,
)
try:
json.dump(self._data, fd, indent=2, ensure_ascii=False)
fd.close()
Path(fd.name).replace(self._path)
try:
self._path.chmod(0o600)
except OSError:
logger.debug("Unable to chmod channel runtime config store at %s", self._path, exc_info=True)
except BaseException:
fd.close()
Path(fd.name).unlink(missing_ok=True)
raise
def load_all(self) -> dict[str, dict[str, Any]]:
with self._lock:
return {name: dict(config) for name, config in self._data.items()}
def get_provider_config(self, provider: str) -> dict[str, Any] | None:
with self._lock:
config = self._data.get(provider)
return dict(config) if isinstance(config, dict) else None
def set_provider_config(self, provider: str, config: dict[str, Any]) -> None:
with self._lock:
self._data[provider] = dict(config)
self._save()
def remove_provider_config(self, provider: str) -> bool:
with self._lock:
if provider not in self._data:
return False
del self._data[provider]
self._save()
return True
def _provider_enabled(channel_connections_config: Any, provider: str) -> bool:
provider_config = getattr(channel_connections_config, provider, None)
return bool(getattr(provider_config, "enabled", False))
def merge_runtime_channel_configs(
channels_config: dict[str, Any],
channel_connections_config: Any,
*,
store: ChannelRuntimeConfigStore | None = None,
) -> None:
"""Merge persisted runtime provider config into ``channels_config`` in-place."""
if channel_connections_config is None or not getattr(channel_connections_config, "enabled", False):
return
runtime_store = store or ChannelRuntimeConfigStore()
for provider, runtime_config in runtime_store.load_all().items():
if not _provider_enabled(channel_connections_config, provider):
continue
existing = channels_config.get(provider)
merged = dict(runtime_config)
if isinstance(existing, dict):
merged.update(existing)
channels_config[provider] = merged
def apply_runtime_connection_config(
channel_connections_config: Any,
*,
store: ChannelRuntimeConfigStore | None = None,
) -> Any:
"""Apply persisted connection metadata that lives outside ``channels``.
Telegram uses a bot username for deep links; UI-entered values are stored
with the runtime channel config so local restarts keep the provider
configured.
"""
if channel_connections_config is None or not getattr(channel_connections_config, "enabled", False):
return channel_connections_config
runtime_store = store or ChannelRuntimeConfigStore()
telegram_runtime_config = runtime_store.get_provider_config("telegram")
bot_username = ""
if isinstance(telegram_runtime_config, dict):
bot_username = str(telegram_runtime_config.get("bot_username") or "").strip()
if not bot_username or not _provider_enabled(channel_connections_config, "telegram"):
return channel_connections_config
config = channel_connections_config.model_copy(deep=True)
config.telegram.bot_username = bot_username
return config
+3 -54
View File
@@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Any
from app.channels.base import Channel
from app.channels.manager import DEFAULT_GATEWAY_URL, DEFAULT_LANGGRAPH_URL, ChannelManager
from app.channels.message_bus import MessageBus
from app.channels.runtime_config_store import merge_runtime_channel_configs
from app.channels.store import ChannelStore
logger = logging.getLogger(__name__)
@@ -53,30 +52,6 @@ def _resolve_service_url(config: dict[str, Any], config_key: str, env_key: str,
return default
def _merge_channel_connection_runtime_config(channels_config: dict[str, Any], app_config: AppConfig) -> None:
connection_config = getattr(app_config, "channel_connections", None)
merge_runtime_channel_configs(channels_config, connection_config)
def _make_connection_repo(app_config: AppConfig):
connection_config = getattr(app_config, "channel_connections", None)
if connection_config is None or not getattr(connection_config, "enabled", False):
return None
try:
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import get_session_factory
except Exception:
logger.exception("Failed to import channel connection repository")
return None
session_factory = get_session_factory()
if session_factory is None:
logger.warning("Channel connections are enabled but database persistence is not available")
return None
return ChannelConnectionRepository(session_factory)
class ChannelService:
"""Manages the lifecycle of all configured IM channels.
@@ -84,10 +59,9 @@ class ChannelService:
instantiates enabled channels, and starts the ChannelManager dispatcher.
"""
def __init__(self, channels_config: dict[str, Any] | None = None, *, connection_repo: Any | None = None) -> None:
def __init__(self, channels_config: dict[str, Any] | None = None) -> None:
self.bus = MessageBus()
self.store = ChannelStore()
self._connection_repo = connection_repo
config = dict(channels_config or {})
langgraph_url = _resolve_service_url(config, "langgraph_url", _CHANNELS_LANGGRAPH_URL_ENV, DEFAULT_LANGGRAPH_URL)
gateway_url = _resolve_service_url(config, "gateway_url", _CHANNELS_GATEWAY_URL_ENV, DEFAULT_GATEWAY_URL)
@@ -100,7 +74,6 @@ class ChannelService:
gateway_url=gateway_url,
default_session=default_session if isinstance(default_session, dict) else None,
channel_sessions=channel_sessions,
connection_repo=connection_repo,
)
self._channels: dict[str, Any] = {} # name -> Channel instance
self._config = config
@@ -117,9 +90,8 @@ class ChannelService:
# extra fields are allowed by AppConfig (extra="allow")
extra = app_config.model_extra or {}
if "channels" in extra:
channels_config = dict(extra["channels"] or {})
_merge_channel_connection_runtime_config(channels_config, app_config)
return cls(channels_config=channels_config, connection_repo=_make_connection_repo(app_config))
channels_config = extra["channels"]
return cls(channels_config=channels_config)
async def start(self) -> None:
"""Start the manager and all enabled channels."""
@@ -179,27 +151,6 @@ class ChannelService:
return await self._start_channel(name, config)
async def configure_channel(self, name: str, config: dict[str, Any]) -> bool:
"""Apply runtime config for a channel and restart it if the service is running."""
self._config[name] = dict(config)
if not self._running:
return True
return await self.restart_channel(name)
async def remove_channel(self, name: str) -> bool:
"""Remove runtime config for a channel and stop it if currently running."""
self._config.pop(name, None)
channel = self._channels.pop(name, None)
if channel is None:
return True
try:
await channel.stop()
logger.info("Channel %s stopped and removed", name)
return True
except Exception:
logger.exception("Error stopping channel %s for removal", name)
return False
async def _start_channel(self, name: str, config: dict[str, Any]) -> bool:
"""Instantiate and start a single channel."""
import_path = _CHANNEL_REGISTRY.get(name)
@@ -218,8 +169,6 @@ class ChannelService:
try:
config = dict(config)
config["channel_store"] = self.store
if self._connection_repo is not None:
config["connection_repo"] = self._connection_repo
channel = channel_cls(bus=self.bus, config=config)
self._channels[name] = channel
await channel.start()
+26 -140
View File
@@ -9,8 +9,7 @@ from typing import Any
from markdown_to_mrkdwn import SlackMarkdownConverter
from app.channels.base import Channel
from app.channels.commands import extract_connect_code, is_known_channel_command
from app.channels.connection_identity import attach_connection_identity
from app.channels.commands import is_known_channel_command
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
logger = logging.getLogger(__name__)
@@ -65,8 +64,6 @@ class SlackChannel(Channel):
self._web_client = None
self._loop: asyncio.AbstractEventLoop | None = None
self._allowed_users = _normalize_allowed_users(config.get("allowed_users", []))
self._connection_repo = config.get("connection_repo")
self._web_client_factory = config.get("web_client_factory")
configured_bot_user_id = config.get("bot_user_id")
self._bot_user_id = str(configured_bot_user_id).lstrip("@") if configured_bot_user_id else None
@@ -83,28 +80,26 @@ class SlackChannel(Channel):
return
self._SocketModeResponse = SocketModeResponse
if self._web_client_factory is None:
self._web_client_factory = WebClient
bot_token = self.config.get("bot_token", "")
app_token = self.config.get("app_token", "")
if self._connection_repo is not None and self.config.get("event_delivery") == "http":
if not bot_token:
logger.error("Slack HTTP Events mode requires bot_token")
return
await self._initialize_operator_web_client(str(bot_token))
self._loop = asyncio.get_event_loop()
self._running = True
self.bus.subscribe_outbound(self._on_outbound)
logger.info("Slack channel started in HTTP Events mode")
return
if not bot_token or not app_token:
logger.error("Slack channel requires bot_token and app_token")
return
await self._initialize_operator_web_client(str(bot_token))
self._web_client = WebClient(token=bot_token)
if self._bot_user_id is None:
try:
auth_info = await asyncio.to_thread(self._web_client.auth_test)
user_id = auth_info.get("user_id") if isinstance(auth_info, dict) else None
if user_id is None:
auth_get = getattr(auth_info, "get", None)
user_id = auth_get("user_id") if callable(auth_get) else None
if isinstance(user_id, str) and user_id:
self._bot_user_id = user_id
except Exception:
logger.warning("[Slack] failed to resolve bot user id; app mention text may include the bot mention", exc_info=True)
self._socket_client = SocketModeClient(
app_token=app_token,
web_client=self._web_client,
@@ -129,8 +124,7 @@ class SlackChannel(Channel):
logger.info("Slack channel stopped")
async def send(self, msg: OutboundMessage, *, _max_retries: int = 3) -> None:
web_client = await self._get_web_client_for_message(msg)
if not web_client:
if not self._web_client:
return
kwargs: dict[str, Any] = {
@@ -143,12 +137,11 @@ class SlackChannel(Channel):
last_exc: Exception | None = None
for attempt in range(_max_retries):
try:
await asyncio.to_thread(web_client.chat_postMessage, **kwargs)
await asyncio.to_thread(self._web_client.chat_postMessage, **kwargs)
# Add a completion reaction to the thread root
if msg.thread_ts:
await asyncio.to_thread(
self._add_reaction_with_client,
web_client,
self._add_reaction,
msg.chat_id,
msg.thread_ts,
"white_check_mark",
@@ -172,8 +165,7 @@ class SlackChannel(Channel):
if msg.thread_ts:
try:
await asyncio.to_thread(
self._add_reaction_with_client,
web_client,
self._add_reaction,
msg.chat_id,
msg.thread_ts,
"x",
@@ -185,8 +177,7 @@ class SlackChannel(Channel):
raise last_exc
async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool:
web_client = await self._get_web_client_for_message(msg)
if not web_client:
if not self._web_client:
return False
try:
@@ -199,7 +190,7 @@ class SlackChannel(Channel):
if msg.thread_ts:
kwargs["thread_ts"] = msg.thread_ts
await asyncio.to_thread(web_client.files_upload_v2, **kwargs)
await asyncio.to_thread(self._web_client.files_upload_v2, **kwargs)
logger.info("[Slack] file uploaded: %s to channel=%s", attachment.filename, msg.chat_id)
return True
except Exception:
@@ -208,38 +199,12 @@ class SlackChannel(Channel):
# -- internal ----------------------------------------------------------
async def _initialize_operator_web_client(self, bot_token: str) -> None:
self._web_client = self._web_client_factory(token=bot_token)
if self._bot_user_id is not None:
def _add_reaction(self, channel_id: str, timestamp: str, emoji: str) -> None:
"""Add an emoji reaction to a message (best-effort, non-blocking)."""
if not self._web_client:
return
try:
auth_info = await asyncio.to_thread(self._web_client.auth_test)
user_id = auth_info.get("user_id") if isinstance(auth_info, dict) else None
if user_id is None:
auth_get = getattr(auth_info, "get", None)
user_id = auth_get("user_id") if callable(auth_get) else None
if isinstance(user_id, str) and user_id:
self._bot_user_id = user_id
except Exception:
logger.warning("[Slack] failed to resolve bot user id; app mention text may include the bot mention", exc_info=True)
async def _get_web_client_for_message(self, msg: OutboundMessage):
if msg.connection_id and self._connection_repo is not None:
credentials = await self._connection_repo.get_credentials(msg.connection_id)
access_token = credentials.get("access_token") if credentials else None
if not access_token:
return self._web_client
if self._web_client_factory is None:
from slack_sdk import WebClient
self._web_client_factory = WebClient
return self._web_client_factory(token=access_token)
return self._web_client
@staticmethod
def _add_reaction_with_client(web_client, channel_id: str, timestamp: str, emoji: str) -> None:
try:
web_client.reactions_add(
self._web_client.reactions_add(
channel=channel_id,
timestamp=timestamp,
name=emoji,
@@ -248,12 +213,6 @@ class SlackChannel(Channel):
if "already_reacted" not in str(exc):
logger.warning("[Slack] failed to add reaction %s: %s", emoji, exc)
def _add_reaction(self, channel_id: str, timestamp: str, emoji: str) -> None:
"""Add an emoji reaction to a message (best-effort, non-blocking)."""
if not self._web_client:
return
self._add_reaction_with_client(self._web_client, channel_id, timestamp, emoji)
def _send_running_reply(self, channel_id: str, thread_ts: str) -> None:
"""Send a 'Working on it......' reply in the thread (called from SDK thread)."""
if not self._web_client:
@@ -290,15 +249,12 @@ class SlackChannel(Channel):
# Handle message events (DM or @mention)
if etype in ("message", "app_mention"):
self._handle_message_event(
event,
team_id=req.payload.get("team_id") or req.payload.get("team") or event.get("team"),
)
self._handle_message_event(event)
except Exception:
logger.exception("Error processing Slack event")
def _handle_message_event(self, event: dict, *, team_id: str | None = None) -> None:
def _handle_message_event(self, event: dict) -> None:
# Ignore bot messages
if event.get("bot_id") or event.get("subtype"):
return
@@ -316,19 +272,6 @@ class SlackChannel(Channel):
if not text:
return
connect_code = extract_connect_code(text)
if connect_code:
if self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(
self._bind_connection_from_connect_code(
event=event,
team_id=str(team_id or event.get("team") or ""),
code=connect_code,
),
self._loop,
)
return
channel_id = event.get("channel", "")
thread_ts = event.get("thread_ts") or event.get("ts", "")
@@ -354,61 +297,4 @@ class SlackChannel(Channel):
self._add_reaction(channel_id, event.get("ts", thread_ts), "eyes")
# Send "running" reply first (fire-and-forget from SDK thread)
self._send_running_reply(channel_id, thread_ts)
if self._connection_repo is None:
asyncio.run_coroutine_threadsafe(self.bus.publish_inbound(inbound), self._loop)
else:
asyncio.run_coroutine_threadsafe(self._publish_inbound_with_connection(inbound, team_id=team_id), self._loop)
async def _publish_inbound_with_connection(self, inbound, *, team_id: str | None = None) -> None:
inbound = await self._attach_connection_identity(inbound, team_id=team_id)
await self.bus.publish_inbound(inbound)
async def _attach_connection_identity(self, inbound, *, team_id: str | None = None):
workspace_id = str(team_id or inbound.metadata.get("team_id") or "")
return await attach_connection_identity(
inbound,
repo=self._connection_repo,
provider="slack",
workspace_id=workspace_id,
)
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)
asyncio.run_coroutine_threadsafe(self.bus.publish_inbound(inbound), self._loop)
-77
View File
@@ -8,7 +8,6 @@ import threading
from typing import Any
from app.channels.base import Channel
from app.channels.connection_identity import attach_connection_identity
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
logger = logging.getLogger(__name__)
@@ -36,7 +35,6 @@ class TelegramChannel(Channel):
pass
# chat_id -> last sent message_id for threaded replies
self._last_bot_message: dict[str, int] = {}
self._connection_repo = config.get("connection_repo")
async def start(self) -> None:
if self._running:
@@ -178,26 +176,6 @@ class TelegramChannel(Channel):
logger.exception("[Telegram] failed to send file: %s", attachment.filename)
return False
async def process_webhook_update(self, payload: dict[str, Any]) -> bool:
if not self._application:
return False
try:
from telegram import Update
except ImportError:
logger.error("python-telegram-bot is not installed. Install it with: uv add python-telegram-bot")
return False
update = Update.de_json(payload, self._application.bot)
if update is None:
return False
if self._tg_loop and self._tg_loop.is_running():
future = asyncio.run_coroutine_threadsafe(self._application.process_update(update), self._tg_loop)
await asyncio.wrap_future(future)
else:
await self._application.process_update(update)
return True
# -- helpers -----------------------------------------------------------
async def _send_running_reply(self, chat_id: str, reply_to_message_id: int) -> None:
@@ -255,54 +233,6 @@ class TelegramChannel(Channel):
return True
return user_id in self._allowed_users
@staticmethod
def _telegram_display_name(user) -> str:
full_name = getattr(user, "full_name", None)
if isinstance(full_name, str) and full_name:
return full_name
username = getattr(user, "username", None)
if isinstance(username, str) and username:
return username
return str(getattr(user, "id", ""))
async def _bind_connection_from_start_token(self, update, state_token: str) -> bool:
if self._connection_repo is None or not state_token:
return False
state = await self._connection_repo.consume_oauth_state(provider="telegram", state=state_token)
if state is None:
await update.message.reply_text("Telegram connection link is invalid or expired.")
return True
owner_user_id = state["owner_user_id"]
user_id = str(update.effective_user.id)
chat_id = str(update.effective_chat.id)
connection = await self._connection_repo.upsert_connection(
owner_user_id=owner_user_id,
provider="telegram",
external_account_id=user_id,
external_account_name=self._telegram_display_name(update.effective_user),
workspace_id=chat_id,
workspace_name=None,
metadata={
"chat_id": chat_id,
"chat_type": update.effective_chat.type,
"telegram_username": getattr(update.effective_user, "username", None),
},
status="connected",
)
logger.info("[Telegram] bound chat=%s user=%s to DeerFlow user=%s connection=%s", chat_id, user_id, owner_user_id, connection["id"])
await update.message.reply_text("Telegram connected to DeerFlow.")
return True
async def _attach_connection_identity(self, inbound: InboundMessage) -> InboundMessage:
return await attach_connection_identity(
inbound,
repo=self._connection_repo,
provider="telegram",
workspace_id=inbound.chat_id,
)
def _get_bot_username(self, context) -> str | None:
bot = getattr(context, "bot", None)
username = getattr(bot, "username", None)
@@ -334,11 +264,6 @@ class TelegramChannel(Channel):
"""Handle /start command."""
if not self._check_user(update.effective_user.id):
return
args = getattr(context, "args", []) if context is not None else []
if args:
handled = await self._bind_connection_from_start_token(update, str(args[0]))
if handled:
return
await update.message.reply_text("Welcome to DeerFlow! Send me a message to start a conversation.\nType /help for available commands.")
async def _process_incoming_with_reply(self, chat_id: str, msg_id: int, inbound: InboundMessage) -> None:
@@ -374,7 +299,6 @@ class TelegramChannel(Channel):
thread_ts=msg_id,
)
inbound.topic_id = topic_id
inbound = await self._attach_connection_identity(inbound)
if self._main_loop and self._main_loop.is_running():
fut = asyncio.run_coroutine_threadsafe(self._process_incoming_with_reply(chat_id, update.message.message_id, inbound), self._main_loop)
@@ -417,7 +341,6 @@ class TelegramChannel(Channel):
thread_ts=msg_id,
)
inbound.topic_id = topic_id
inbound = await self._attach_connection_identity(inbound)
if self._main_loop and self._main_loop.is_running():
fut = asyncio.run_coroutine_threadsafe(self._process_incoming_with_reply(chat_id, update.message.message_id, inbound), self._main_loop)
+2 -60
View File
@@ -22,9 +22,8 @@ from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.channels.base import Channel
from app.channels.commands import extract_connect_code, is_known_channel_command
from app.channels.connection_identity import attach_connection_identity
from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
from app.channels.commands import is_known_channel_command
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
logger = logging.getLogger(__name__)
@@ -254,7 +253,6 @@ class WechatChannel(Channel):
self._state_dir = self._resolve_state_dir(config.get("state_dir"))
self._cursor_path = self._state_dir / "wechat-getupdates.json" if self._state_dir else None
self._auth_path = self._state_dir / "wechat-auth.json" if self._state_dir else None
self._connection_repo = config.get("connection_repo")
self._load_state()
async def start(self) -> None:
@@ -619,16 +617,6 @@ class WechatChannel(Channel):
if thread_ts:
self._context_tokens_by_thread[thread_ts] = context_token
connect_code = extract_connect_code(text)
if connect_code and self._connection_repo is not None:
handled = await self._bind_connection_from_connect_code(
chat_id=chat_id,
context_token=context_token,
code=connect_code,
)
if handled:
return
inbound = self._make_inbound(
chat_id=chat_id,
user_id=chat_id,
@@ -644,54 +632,8 @@ class WechatChannel(Channel):
},
)
inbound.topic_id = None
inbound = await self._attach_connection_identity(inbound)
await self.bus.publish_inbound(inbound)
async def _attach_connection_identity(self, inbound: InboundMessage) -> InboundMessage:
return await attach_connection_identity(
inbound,
repo=self._connection_repo,
provider="wechat",
workspace_id=inbound.chat_id,
)
async def _bind_connection_from_connect_code(self, *, chat_id: str, context_token: str, code: str) -> bool:
if self._connection_repo is None or not code:
return False
state = await self._connection_repo.consume_oauth_state(provider="wechat", state=code)
if state is None:
await self._send_connection_reply(chat_id, context_token, "WeChat connection code is invalid or expired.")
return True
if not chat_id:
await self._send_connection_reply(chat_id, context_token, "WeChat connection could not be completed from this message.")
return True
await self._connection_repo.upsert_connection(
owner_user_id=state["owner_user_id"],
provider="wechat",
external_account_id=chat_id,
workspace_id=chat_id,
metadata={
"context_token": context_token,
},
status="connected",
)
await self._send_connection_reply(chat_id, context_token, "WeChat connected to DeerFlow.")
return True
async def _send_connection_reply(self, chat_id: str, context_token: str, text: str) -> None:
if not context_token:
return
await self._send_text_message(
chat_id=chat_id,
context_token=context_token,
text=text,
client_id_prefix="deerflow-connect",
max_retries=1,
)
async def _ensure_authenticated(self) -> bool:
async with self._auth_lock:
if self._bot_token:
+1 -58
View File
@@ -8,10 +8,8 @@ from collections.abc import Awaitable, Callable
from typing import Any, cast
from app.channels.base import Channel
from app.channels.commands import extract_connect_code, is_known_channel_command
from app.channels.connection_identity import attach_connection_identity
from app.channels.commands import is_known_channel_command
from app.channels.message_bus import (
InboundMessage,
InboundMessageType,
MessageBus,
OutboundMessage,
@@ -31,7 +29,6 @@ class WeComChannel(Channel):
self._ws_frames: dict[str, dict[str, Any]] = {}
self._ws_stream_ids: dict[str, str] = {}
self._working_message = "Working on it..."
self._connection_repo = config.get("connection_repo")
@property
def supports_streaming(self) -> bool:
@@ -274,16 +271,6 @@ class WeComChannel(Channel):
user_id = (body.get("from") or {}).get("userid")
connect_code = extract_connect_code(text)
if connect_code and self._connection_repo is not None:
handled = await self._bind_connection_from_connect_code(
frame=frame,
user_id=str(user_id or ""),
code=connect_code,
)
if handled:
return
inbound_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
inbound = self._make_inbound(
chat_id=user_id, # keep user's conversation in memory
@@ -305,52 +292,8 @@ class WeComChannel(Channel):
except Exception:
pass
inbound = await self._attach_connection_identity(inbound)
await self.bus.publish_inbound(inbound)
async def _attach_connection_identity(self, inbound: InboundMessage) -> InboundMessage:
return await attach_connection_identity(
inbound,
repo=self._connection_repo,
provider="wecom",
workspace_id=str(inbound.metadata.get("aibotid") or "") or None,
fallback_without_workspace=True,
)
async def _bind_connection_from_connect_code(self, *, frame: dict[str, Any], user_id: str, code: str) -> bool:
if self._connection_repo is None or not code:
return False
state = await self._connection_repo.consume_oauth_state(provider="wecom", state=code)
if state is None:
await self._send_connection_reply(frame, "WeCom connection code is invalid or expired.")
return True
if not user_id:
await self._send_connection_reply(frame, "WeCom connection could not be completed from this message.")
return True
body = frame.get("body", {}) or {}
workspace_id = str(body.get("aibotid") or "") or None
await self._connection_repo.upsert_connection(
owner_user_id=state["owner_user_id"],
provider="wecom",
external_account_id=user_id,
workspace_id=workspace_id,
metadata={
"aibotid": workspace_id,
"chattype": body.get("chattype"),
},
status="connected",
)
await self._send_connection_reply(frame, "WeCom connected to DeerFlow.")
return True
async def _send_connection_reply(self, frame: dict[str, Any], text: str) -> None:
if not self._ws_client:
return
await self._ws_client.reply(frame, {"msgtype": "text", "text": {"content": text}})
async def _send_ws(self, msg: OutboundMessage, *, _max_retries: int = 3) -> None:
if not self._ws_client:
return
-4
View File
@@ -16,7 +16,6 @@ from app.gateway.routers import (
artifacts,
assistants_compat,
auth,
channel_connections,
channels,
feedback,
mcp,
@@ -385,9 +384,6 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router)
# User-facing IM channel connection API is mounted at /api/channels
app.include_router(channel_connections.router)
# Channels API is mounted at /api/channels
app.include_router(channels.router)
+2 -4
View File
@@ -6,11 +6,9 @@ import logging
import os
from types import SimpleNamespace
from deerflow.runtime.user_context import DEFAULT_USER_ID
AUTH_DISABLED_ENV_VAR = "DEER_FLOW_AUTH_DISABLED"
AUTH_DISABLED_USER_ID = DEFAULT_USER_ID
AUTH_DISABLED_USER_EMAIL = "default@test.local"
AUTH_DISABLED_USER_ID = "e2e-user"
AUTH_DISABLED_USER_EMAIL = "e2e@test.local"
AUTH_SOURCE_SESSION = "session"
AUTH_SOURCE_INTERNAL = "internal"
-5
View File
@@ -276,11 +276,6 @@ def require_permission(
# strict-deny rather than strict-allow — only an *existing*
# row with a *different* user_id triggers 404.
if owner_check:
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE
if getattr(auth.user, "system_role", None) == INTERNAL_SYSTEM_ROLE:
return await func(*args, **kwargs)
thread_id = kwargs.get("thread_id")
if thread_id is None:
raise ValueError("require_permission with owner_check=True requires 'thread_id' parameter")
+2 -25
View File
@@ -5,12 +5,10 @@ from __future__ import annotations
import os
import secrets
from types import SimpleNamespace
from typing import Any
from deerflow.runtime.user_context import DEFAULT_USER_ID
INTERNAL_AUTH_HEADER_NAME = "X-DeerFlow-Internal-Token"
INTERNAL_OWNER_USER_ID_HEADER_NAME = "X-DeerFlow-Owner-User-Id"
INTERNAL_AUTH_ENV_VAR = "DEER_FLOW_INTERNAL_AUTH_TOKEN"
INTERNAL_SYSTEM_ROLE = "internal"
@@ -25,12 +23,9 @@ def _load_internal_auth_token() -> str:
_INTERNAL_AUTH_TOKEN = _load_internal_auth_token()
def create_internal_auth_headers(*, owner_user_id: str | None = None) -> dict[str, str]:
def create_internal_auth_headers() -> dict[str, str]:
"""Return headers that authenticate trusted Gateway internal calls."""
headers = {INTERNAL_AUTH_HEADER_NAME: _INTERNAL_AUTH_TOKEN}
if owner_user_id:
headers[INTERNAL_OWNER_USER_ID_HEADER_NAME] = owner_user_id
return headers
return {INTERNAL_AUTH_HEADER_NAME: _INTERNAL_AUTH_TOKEN}
def is_valid_internal_auth_token(token: str | None) -> bool:
@@ -41,21 +36,3 @@ def is_valid_internal_auth_token(token: str | None) -> bool:
def get_internal_user():
"""Return the synthetic user used for trusted internal channel calls."""
return SimpleNamespace(id=DEFAULT_USER_ID, system_role=INTERNAL_SYSTEM_ROLE)
def get_trusted_internal_owner_user_id(request: Any) -> str | None:
"""Return the owner override for a trusted internal request, if present.
The header is ignored for normal browser/API callers. It is only honored
after ``AuthMiddleware`` has validated the internal auth token and stamped
the synthetic internal user onto ``request.state.user``.
"""
user = getattr(getattr(request, "state", None), "user", None)
if getattr(user, "system_role", None) != INTERNAL_SYSTEM_ROLE:
return None
owner_user_id = request.headers.get(INTERNAL_OWNER_USER_ID_HEADER_NAME)
if not owner_user_id:
return None
owner_user_id = owner_user_id.strip()
return owner_user_id or None
@@ -1,600 +0,0 @@
"""Browser-facing APIs for user-owned IM channel bindings."""
from __future__ import annotations
import logging
import secrets
from datetime import UTC, datetime, timedelta
from typing import Any
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel, Field
from app.channels.runtime_config_store import (
ChannelRuntimeConfigStore,
apply_runtime_connection_config,
merge_runtime_channel_configs,
)
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import get_session_factory
router = APIRouter(prefix="/api/channels", tags=["channel-connections"])
logger = logging.getLogger(__name__)
_STATE_TTL_SECONDS = 600
_MASKED_CREDENTIAL_VALUE = "********"
class ChannelCredentialFieldResponse(BaseModel):
name: str
label: str
type: str = "text"
required: bool = True
class ChannelProviderResponse(BaseModel):
provider: str
display_name: str
enabled: bool
configured: bool
connectable: bool
unavailable_reason: str | None = None
auth_mode: str
connection_status: str
credential_fields: list[ChannelCredentialFieldResponse] = Field(default_factory=list)
credential_values: dict[str, str] = Field(default_factory=dict)
class ChannelProvidersResponse(BaseModel):
enabled: bool
providers: list[ChannelProviderResponse]
class ChannelConnectionResponse(BaseModel):
id: str
provider: str
status: str
external_account_id: str | None = None
external_account_name: str | None = None
workspace_id: str | None = None
workspace_name: str | None = None
scopes: list[str] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
class ChannelConnectionsResponse(BaseModel):
connections: list[ChannelConnectionResponse]
class ChannelConnectResponse(BaseModel):
provider: str
mode: str
url: str | None = None
code: str
instruction: str
expires_in: int
class ChannelRuntimeConfigRequest(BaseModel):
values: dict[str, str] = Field(default_factory=dict)
_PROVIDER_META: dict[str, dict[str, str]] = {
"telegram": {"display_name": "Telegram", "auth_mode": "deep_link"},
"slack": {"display_name": "Slack", "auth_mode": "binding_code"},
"discord": {"display_name": "Discord", "auth_mode": "binding_code"},
"feishu": {"display_name": "Feishu", "auth_mode": "binding_code"},
"dingtalk": {"display_name": "DingTalk", "auth_mode": "binding_code"},
"wechat": {"display_name": "WeChat", "auth_mode": "binding_code"},
"wecom": {"display_name": "WeCom", "auth_mode": "binding_code"},
}
_CREDENTIAL_FIELDS: dict[str, tuple[dict[str, str], ...]] = {
"telegram": (
{"name": "bot_token", "label": "Bot token", "type": "password"},
{"name": "bot_username", "label": "Bot username", "type": "text"},
),
"slack": (
{"name": "bot_token", "label": "Bot token", "type": "password"},
{"name": "app_token", "label": "App token", "type": "password"},
),
"discord": ({"name": "bot_token", "label": "Bot token", "type": "password"},),
"feishu": (
{"name": "app_id", "label": "App ID", "type": "text"},
{"name": "app_secret", "label": "App secret", "type": "password"},
),
"dingtalk": (
{"name": "client_id", "label": "Client ID", "type": "text"},
{"name": "client_secret", "label": "Client secret", "type": "password"},
),
"wechat": ({"name": "bot_token", "label": "Bot token", "type": "password"},),
"wecom": (
{"name": "bot_id", "label": "Bot ID", "type": "text"},
{"name": "bot_secret", "label": "Bot secret", "type": "password"},
),
}
_RUNTIME_REQUIREMENTS: dict[str, tuple[str, ...]] = {
"telegram": ("bot_token",),
"slack": ("bot_token", "app_token"),
"discord": ("bot_token",),
"feishu": ("app_id", "app_secret"),
"dingtalk": ("client_id", "client_secret"),
"wechat": ("bot_token",),
"wecom": ("bot_id", "bot_secret"),
}
def _get_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
return str(user.id)
def _get_app_config():
from deerflow.config.app_config import get_app_config
return get_app_config()
def _get_runtime_config_store(request: Request) -> ChannelRuntimeConfigStore:
store = getattr(request.app.state, "channel_runtime_config_store", None)
if isinstance(store, ChannelRuntimeConfigStore):
return store
store = ChannelRuntimeConfigStore()
request.app.state.channel_runtime_config_store = store
return store
def _get_channel_connections_config(request: Request) -> ChannelConnectionsConfig:
config = getattr(request.app.state, "channel_connections_config", None)
if not isinstance(config, ChannelConnectionsConfig):
config = _get_app_config().channel_connections
config = apply_runtime_connection_config(config, store=_get_runtime_config_store(request))
request.app.state.channel_connections_config = config
return config
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
result = _load_channels_config(request, _get_channel_connections_config(request))
request.app.state.channels_config = result
return result
def _load_channels_config(request: Request, config: ChannelConnectionsConfig) -> dict[str, Any]:
app_config = _get_app_config()
extra = app_config.model_extra or {}
channels_config = extra.get("channels")
result = dict(channels_config) if isinstance(channels_config, dict) else {}
merge_runtime_channel_configs(
result,
config,
store=_get_runtime_config_store(request),
)
return result
def _get_repository(request: Request, config: ChannelConnectionsConfig) -> ChannelConnectionRepository:
repo = getattr(request.app.state, "channel_connection_repo", None)
if isinstance(repo, ChannelConnectionRepository):
return repo
sf = get_session_factory()
if sf is None:
raise HTTPException(status_code=503, detail="Channel connection persistence is not available")
repo = ChannelConnectionRepository(sf)
request.app.state.channel_connection_repo = repo
return repo
def _provider_config(config: ChannelConnectionsConfig, provider: str):
provider_config = getattr(config, provider, None)
if provider_config is None:
raise HTTPException(status_code=404, detail="Unknown channel provider")
return provider_config
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:
meta = _PROVIDER_META.get(provider)
display_name = meta["display_name"] if meta else provider
return f"Enter the required {display_name} credentials to connect this channel."
def _runtime_not_running_reason(provider: str) -> str:
meta = _PROVIDER_META.get(provider)
display_name = meta["display_name"] if meta else provider
return f"{display_name} channel is configured but is not running. Check the credentials and save this channel again."
def _runtime_channel_running(provider: str) -> bool | None:
try:
from app.channels.service import get_channel_service
except Exception:
logger.debug("Unable to inspect channel service status", exc_info=True)
return None
service = get_channel_service()
if service is None:
return None
try:
status = service.get_status()
except Exception:
logger.debug("Unable to read channel service status", exc_info=True)
return None
if not status.get("service_running"):
return False
channel_status = status.get("channels", {}).get(provider)
if not isinstance(channel_status, dict):
return None
return bool(channel_status.get("running"))
def _provider_unavailable_reason(
config: ChannelConnectionsConfig,
channels_config: dict[str, Any],
provider: str,
) -> str | None:
provider_config = _provider_config(config, provider)
if not provider_config.enabled:
return None
if not provider_config.configured:
return _runtime_unavailable_reason(provider)
if not _runtime_channel_configured(provider, channels_config):
return _runtime_unavailable_reason(provider)
if _runtime_channel_running(provider) is False:
return _runtime_not_running_reason(provider)
return None
def _provider_status(
config: ChannelConnectionsConfig,
channels_config: dict[str, Any],
provider: str,
) -> tuple[dict[str, bool], str | None]:
declared = config.provider_status(provider)
unavailable_reason = _provider_unavailable_reason(config, channels_config, provider)
configured = declared["configured"] and _runtime_channel_configured(provider, channels_config)
return {"enabled": declared["enabled"], "configured": configured}, unavailable_reason
def _new_binding_code() -> str:
return secrets.token_urlsafe(16)
async def _create_state(
repo: ChannelConnectionRepository,
*,
owner_user_id: str,
provider: str,
) -> str:
state = _new_binding_code()
await repo.create_oauth_state(
owner_user_id=owner_user_id,
provider=provider,
state=state,
expires_at=datetime.now(UTC) + timedelta(seconds=_STATE_TTL_SECONDS),
)
return state
def _connect_instruction(provider: str, code: str) -> str:
if provider == "telegram":
return f"Send /start {code} to the DeerFlow Telegram bot."
meta = _PROVIDER_META.get(provider)
if meta is None:
raise HTTPException(status_code=404, detail="Unknown channel provider")
return f"Send /connect {code} to the DeerFlow {meta['display_name']} bot."
def _connect_url(config: ChannelConnectionsConfig, provider: str, code: str) -> str | None:
if provider == "telegram":
provider_config = _provider_config(config, provider)
return f"https://t.me/{provider_config.bot_username}?start={code}"
if _PROVIDER_META.get(provider, {}).get("auth_mode") == "binding_code":
return None
raise HTTPException(status_code=404, detail="Unknown channel provider")
def _connection_updated_at(connection: dict[str, Any]) -> datetime:
value = connection.get("updated_at")
if isinstance(value, datetime):
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
if isinstance(value, str) and value:
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
pass
return datetime.min.replace(tzinfo=UTC)
def _newest_connection_by_provider(connections: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
by_provider: dict[str, dict[str, Any]] = {}
for item in connections:
existing = by_provider.get(item["provider"])
if existing is None or _connection_updated_at(item) > _connection_updated_at(existing):
by_provider[item["provider"]] = item
return by_provider
def _credential_fields(provider: str) -> list[ChannelCredentialFieldResponse]:
fields = _CREDENTIAL_FIELDS.get(provider)
if fields is None:
raise HTTPException(status_code=404, detail="Unknown channel provider")
return [ChannelCredentialFieldResponse(**field) for field in fields]
def _credential_values(provider: str, channels_config: dict[str, Any]) -> dict[str, str]:
runtime_config = channels_config.get(provider)
if not isinstance(runtime_config, dict):
return {}
values: dict[str, str] = {}
for field in _credential_fields(provider):
value = str(runtime_config.get(field.name) or "").strip()
if not value:
continue
values[field.name] = _MASKED_CREDENTIAL_VALUE if field.type == "password" else value
return values
def _provider_response(
config: ChannelConnectionsConfig,
channels_config: dict[str, Any],
provider: str,
meta: dict[str, str],
connection: dict[str, Any] | None = None,
) -> ChannelProviderResponse:
status, unavailable_reason = _provider_status(config, channels_config, provider)
if connection:
connection_status = connection["status"]
elif status["configured"] and unavailable_reason is None:
connection_status = "connected"
else:
connection_status = "not_connected"
credential_values = _credential_values(provider, channels_config)
if provider == "telegram" and not credential_values.get("bot_username"):
bot_username = str(_provider_config(config, provider).bot_username or "").strip()
if bot_username:
credential_values["bot_username"] = bot_username
return ChannelProviderResponse(
provider=provider,
display_name=meta["display_name"],
enabled=status["enabled"],
configured=status["configured"],
connectable=status["enabled"] and status["configured"] and unavailable_reason is None,
unavailable_reason=unavailable_reason,
auth_mode=meta["auth_mode"],
connection_status=connection_status,
credential_fields=_credential_fields(provider),
credential_values=credential_values,
)
def _required_runtime_values(
provider: str,
values: dict[str, str],
existing_config: dict[str, Any] | None = None,
) -> dict[str, str]:
fields = _credential_fields(provider)
cleaned: dict[str, str] = {}
missing: list[str] = []
existing_config = existing_config or {}
for field in fields:
raw_value = values.get(field.name, "")
if field.type == "password" and raw_value == _MASKED_CREDENTIAL_VALUE:
existing_value = str(existing_config.get(field.name) or "").strip()
if existing_value:
cleaned[field.name] = existing_value
continue
value = raw_value.strip() if isinstance(raw_value, str) else str(raw_value or "").strip()
if field.required and not value:
missing.append(field.label)
cleaned[field.name] = value
if missing:
raise HTTPException(status_code=400, detail=f"Missing required channel configuration: {', '.join(missing)}")
return cleaned
async def _restart_runtime_channel_if_available(provider: str, runtime_config: dict[str, Any]) -> bool | None:
try:
from app.channels.service import get_channel_service
except Exception:
logger.exception("Failed to import channel service while configuring %s", provider)
return None
service = get_channel_service()
if service is None:
return None
return await service.configure_channel(provider, runtime_config)
async def _sync_runtime_channel_after_removal(provider: str, channels_config: dict[str, Any]) -> bool | None:
try:
from app.channels.service import get_channel_service
except Exception:
logger.exception("Failed to import channel service while disconnecting %s", provider)
return None
service = get_channel_service()
if service is None:
return None
runtime_config = channels_config.get(provider)
if isinstance(runtime_config, dict) and runtime_config.get("enabled", False):
return await service.configure_channel(provider, runtime_config)
return await service.remove_channel(provider)
@router.get("/providers", response_model=ChannelProvidersResponse)
async def get_channel_providers(request: Request) -> ChannelProvidersResponse:
config = _get_channel_connections_config(request)
channels_config = _get_channels_config(request)
repo = None
if config.enabled:
try:
repo = _get_repository(request, config)
except HTTPException as exc:
if exc.status_code != 503:
raise
owner_user_id = _get_user_id(request)
connections = await repo.list_connections(owner_user_id) if repo is not None else []
by_provider = _newest_connection_by_provider(connections)
providers: list[ChannelProviderResponse] = []
for provider, meta in _PROVIDER_META.items():
if not config.provider_status(provider)["enabled"]:
continue
connection = by_provider.get(provider)
providers.append(_provider_response(config, channels_config, provider, meta, connection))
return ChannelProvidersResponse(enabled=config.enabled, providers=providers)
@router.get("/connections", response_model=ChannelConnectionsResponse)
async def get_channel_connections(request: Request) -> ChannelConnectionsResponse:
config = _get_channel_connections_config(request)
if not config.enabled:
return ChannelConnectionsResponse(connections=[])
repo = _get_repository(request, config)
rows = await repo.list_connections(_get_user_id(request))
return ChannelConnectionsResponse(connections=[ChannelConnectionResponse(**row) for row in rows])
@router.delete("/connections/{connection_id}", status_code=204)
async def disconnect_channel_connection(connection_id: str, request: Request) -> Response:
config = _get_channel_connections_config(request)
if not config.enabled:
raise HTTPException(status_code=400, detail="Channel connections are disabled")
repo = _get_repository(request, config)
disconnected = await repo.disconnect_connection(
connection_id=connection_id,
owner_user_id=_get_user_id(request),
)
if not disconnected:
raise HTTPException(status_code=404, detail="Channel connection not found")
return Response(status_code=204)
@router.delete("/{provider}/runtime-config", response_model=ChannelProviderResponse)
async def disconnect_channel_provider_runtime(provider: str, request: Request) -> ChannelProviderResponse:
config = _get_channel_connections_config(request)
if not config.enabled:
raise HTTPException(status_code=400, detail="Channel connections are disabled")
provider_config = _provider_config(config, provider)
if not provider_config.enabled:
raise HTTPException(status_code=400, detail="Channel provider is not enabled")
owner_user_id = _get_user_id(request)
try:
repo = _get_repository(request, config)
except HTTPException as exc:
if exc.status_code != 503:
raise
repo = None
if repo is not None:
for connection in await repo.list_connections(owner_user_id):
if connection["provider"] == provider and connection["status"] != "revoked":
await repo.disconnect_connection(
connection_id=connection["id"],
owner_user_id=owner_user_id,
)
_get_runtime_config_store(request).remove_provider_config(provider)
channels_config = _load_channels_config(request, config)
request.app.state.channels_config = channels_config
stopped = await _sync_runtime_channel_after_removal(provider, channels_config)
if stopped is False:
display_name = _PROVIDER_META[provider]["display_name"]
raise HTTPException(status_code=400, detail=f"Failed to stop {display_name} channel. Try again.")
return _provider_response(config, channels_config, provider, _PROVIDER_META[provider])
@router.post("/{provider}/connect", response_model=ChannelConnectResponse)
async def connect_channel_provider(provider: str, request: Request) -> ChannelConnectResponse:
config = _get_channel_connections_config(request)
channels_config = _get_channels_config(request)
if not config.enabled:
raise HTTPException(status_code=400, detail="Channel connections are disabled")
status, unavailable_reason = _provider_status(config, channels_config, provider)
if not status["enabled"]:
raise HTTPException(status_code=400, detail="Channel provider is not enabled")
if unavailable_reason:
raise HTTPException(status_code=400, detail=unavailable_reason)
if not status["configured"]:
raise HTTPException(status_code=400, detail="Channel provider is not configured")
repo = _get_repository(request, config)
code = await _create_state(
repo,
owner_user_id=_get_user_id(request),
provider=provider,
)
return ChannelConnectResponse(
provider=provider,
mode=_PROVIDER_META[provider]["auth_mode"],
url=_connect_url(config, provider, code),
code=code,
instruction=_connect_instruction(provider, code),
expires_in=_STATE_TTL_SECONDS,
)
@router.post("/{provider}/runtime-config", response_model=ChannelProviderResponse)
async def configure_channel_provider_runtime(
provider: str,
body: ChannelRuntimeConfigRequest,
request: Request,
) -> ChannelProviderResponse:
config = _get_channel_connections_config(request)
if not config.enabled:
raise HTTPException(status_code=400, detail="Channel connections are disabled")
provider_config = _provider_config(config, provider)
if not provider_config.enabled:
raise HTTPException(status_code=400, detail="Channel provider is not enabled")
channels_config = _get_channels_config(request)
existing = channels_config.get(provider)
runtime_config = dict(existing) if isinstance(existing, dict) else {}
values = _required_runtime_values(provider, body.values, runtime_config)
runtime_config["enabled"] = True
for key in _RUNTIME_REQUIREMENTS[provider]:
runtime_config[key] = values[key]
if provider == "telegram":
runtime_config["bot_username"] = values["bot_username"]
provider_config.bot_username = values["bot_username"]
request.app.state.channel_connections_config = config
channels_config[provider] = runtime_config
request.app.state.channels_config = channels_config
started = await _restart_runtime_channel_if_available(provider, runtime_config)
if started is False:
display_name = _PROVIDER_META[provider]["display_name"]
raise HTTPException(status_code=400, detail=f"Failed to start {display_name} channel. Check the values and try again.")
_get_runtime_config_store(request).set_provider_config(provider, runtime_config)
return _provider_response(config, channels_config, provider, _PROVIDER_META[provider])
+1 -11
View File
@@ -22,7 +22,6 @@ from pydantic import BaseModel, Field, field_validator
from app.gateway.authz import require_permission
from app.gateway.deps import get_checkpointer
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
from app.gateway.utils import sanitize_log_param
from deerflow.config.paths import Paths, get_paths
from deerflow.runtime import serialize_channel_values
@@ -258,19 +257,11 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
thread_store = get_thread_store(request)
thread_id = body.thread_id or str(uuid.uuid4())
now = now_iso()
thread_owner_user_id = get_trusted_internal_owner_user_id(request)
thread_owner_kwargs = {"user_id": thread_owner_user_id} if thread_owner_user_id else {}
# ``body.metadata`` is already stripped of server-reserved keys by
# ``ThreadCreateRequest._strip_reserved`` — see the model definition.
# Idempotency: return existing record when already present
existing_record = await thread_store.get(thread_id, **thread_owner_kwargs)
if existing_record is None and thread_owner_user_id:
unscoped_record = await thread_store.get(thread_id, user_id=None)
if unscoped_record is not None:
if unscoped_record.get("user_id") != thread_owner_user_id:
await thread_store.update_owner(thread_id, thread_owner_user_id, user_id=None)
existing_record = await thread_store.get(thread_id, **thread_owner_kwargs)
existing_record = await thread_store.get(thread_id)
if existing_record is not None:
return ThreadResponse(
thread_id=thread_id,
@@ -285,7 +276,6 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
await thread_store.create(
thread_id,
assistant_id=getattr(body, "assistant_id", None),
**thread_owner_kwargs,
metadata=body.metadata,
)
except Exception:
+63 -78
View File
@@ -12,7 +12,6 @@ import json
import logging
import re
from collections.abc import Mapping
from types import SimpleNamespace
from typing import Any
from fastapi import HTTPException, Request
@@ -20,7 +19,7 @@ from langchain_core.messages import BaseMessage
from langchain_core.messages.utils import convert_to_messages
from app.gateway.deps import get_run_context, get_run_manager, get_stream_bridge
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE, get_trusted_internal_owner_user_id
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE
from app.gateway.utils import sanitize_log_param
from deerflow.config.app_config import get_app_config
from deerflow.runtime import (
@@ -36,7 +35,6 @@ from deerflow.runtime import (
run_agent,
)
from deerflow.runtime.runs.naming import resolve_root_run_name
from deerflow.runtime.user_context import reset_current_user, set_current_user
logger = logging.getLogger(__name__)
@@ -317,7 +315,6 @@ async def start_run(
detail=f"Model {model_name!r} is not in the configured model allowlist",
)
owner_user_id = get_trusted_internal_owner_user_id(request)
# Stateless run endpoints carry thread_id in the request *body*, so the
# @require_permission(owner_check=True) decorator -- which resolves ownership
# from the path param -- cannot protect them. Enforce thread ownership here,
@@ -333,84 +330,72 @@ async def start_run(
if not await run_ctx.thread_store.check_access(thread_id, str(user.id)):
raise HTTPException(status_code=404, detail=f"Thread {thread_id} not found")
owner_context_token = set_current_user(SimpleNamespace(id=owner_user_id)) if owner_user_id else None
try:
try:
record = await run_mgr.create_or_reject(
thread_id,
body.assistant_id,
on_disconnect=disconnect,
metadata=body.metadata or {},
kwargs={"input": body.input, "config": body.config},
multitask_strategy=body.multitask_strategy,
model_name=model_name,
user_id=owner_user_id,
)
except ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except UnsupportedStrategyError as exc:
raise HTTPException(status_code=501, detail=str(exc)) from exc
# Upsert thread metadata so the thread appears in /threads/search,
# even for threads that were never explicitly created via POST /threads
# (e.g. stateless runs).
try:
existing = await run_ctx.thread_store.get(thread_id)
if existing is None and owner_user_id:
unscoped_existing = await run_ctx.thread_store.get(thread_id, user_id=None)
if unscoped_existing is not None:
if unscoped_existing.get("user_id") != owner_user_id:
await run_ctx.thread_store.update_owner(thread_id, owner_user_id, user_id=None)
existing = await run_ctx.thread_store.get(thread_id)
if existing is None:
await run_ctx.thread_store.create(
thread_id,
assistant_id=body.assistant_id,
metadata=body.metadata,
)
else:
await run_ctx.thread_store.update_status(thread_id, "running")
except Exception:
logger.warning("Failed to upsert thread_meta for %s (non-fatal)", sanitize_log_param(thread_id))
agent_factory = resolve_agent_factory(body.assistant_id)
graph_input = normalize_input(body.input)
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
# The ``context`` field is a custom extension for the langgraph-compat layer
# that carries agent configuration (model_name, thinking_enabled, etc.).
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
merge_run_context_overrides(config, getattr(body, "context", None))
inject_authenticated_user_context(config, request)
stream_modes = normalize_stream_modes(body.stream_mode)
task = asyncio.create_task(
run_agent(
bridge,
run_mgr,
record,
ctx=run_ctx,
agent_factory=agent_factory,
graph_input=graph_input,
config=config,
stream_modes=stream_modes,
stream_subgraphs=body.stream_subgraphs,
interrupt_before=body.interrupt_before,
interrupt_after=body.interrupt_after,
)
record = await run_mgr.create_or_reject(
thread_id,
body.assistant_id,
on_disconnect=disconnect,
metadata=body.metadata or {},
kwargs={"input": body.input, "config": body.config},
multitask_strategy=body.multitask_strategy,
model_name=model_name,
)
record.task = task
except ConflictError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except UnsupportedStrategyError as exc:
raise HTTPException(status_code=501, detail=str(exc)) from exc
# Title sync is handled by worker.py's finally block which reads the
# title from the checkpoint and calls thread_store.update_display_name
# after the run completes.
# Upsert thread metadata so the thread appears in /threads/search,
# even for threads that were never explicitly created via POST /threads
# (e.g. stateless runs).
try:
existing = await run_ctx.thread_store.get(thread_id)
if existing is None:
await run_ctx.thread_store.create(
thread_id,
assistant_id=body.assistant_id,
metadata=body.metadata,
)
else:
await run_ctx.thread_store.update_status(thread_id, "running")
except Exception:
logger.warning("Failed to upsert thread_meta for %s (non-fatal)", sanitize_log_param(thread_id))
return record
finally:
if owner_context_token is not None:
reset_current_user(owner_context_token)
agent_factory = resolve_agent_factory(body.assistant_id)
graph_input = normalize_input(body.input)
config = build_run_config(thread_id, body.config, body.metadata, assistant_id=body.assistant_id)
# Merge DeerFlow-specific context overrides into both ``configurable`` and ``context``.
# The ``context`` field is a custom extension for the langgraph-compat layer
# that carries agent configuration (model_name, thinking_enabled, etc.).
# Only agent-relevant keys are forwarded; unknown keys (e.g. thread_id) are ignored.
merge_run_context_overrides(config, getattr(body, "context", None))
inject_authenticated_user_context(config, request)
stream_modes = normalize_stream_modes(body.stream_mode)
task = asyncio.create_task(
run_agent(
bridge,
run_mgr,
record,
ctx=run_ctx,
agent_factory=agent_factory,
graph_input=graph_input,
config=config,
stream_modes=stream_modes,
stream_subgraphs=body.stream_subgraphs,
interrupt_before=body.interrupt_before,
interrupt_after=body.interrupt_after,
)
)
record.task = task
# Title sync is handled by worker.py's finally block which reads the
# title from the checkpoint and calls thread_store.update_display_name
# after the run completes.
return record
async def sse_consumer(
-121
View File
@@ -1,121 +0,0 @@
# IM Channel Connections
DeerFlow supports user-owned IM channel bindings for Telegram, Slack, Discord, Feishu/Lark, DingTalk, WeChat, and WeCom. 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
Configure the actual IM bots under the existing `channels` block:
```yaml
channels:
telegram:
enabled: true
bot_token: $TELEGRAM_BOT_TOKEN
slack:
enabled: true
bot_token: $SLACK_BOT_TOKEN
app_token: $SLACK_APP_TOKEN
discord:
enabled: true
bot_token: $DISCORD_BOT_TOKEN
feishu:
enabled: true
app_id: $FEISHU_APP_ID
app_secret: $FEISHU_APP_SECRET
dingtalk:
enabled: true
client_id: $DINGTALK_CLIENT_ID
client_secret: $DINGTALK_CLIENT_SECRET
wechat:
enabled: true
bot_token: $WECHAT_BOT_TOKEN
wecom:
enabled: true
bot_id: $WECOM_BOT_ID
bot_secret: $WECOM_BOT_SECRET
```
Then enable user bindings in `channel_connections`:
```yaml
channel_connections:
enabled: true
telegram:
enabled: true
bot_username: $TELEGRAM_BOT_USERNAME
slack:
enabled: true
discord:
enabled: true
feishu:
enabled: true
dingtalk:
enabled: true
wechat:
enabled: true
wecom:
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:
- The frontend creates a short one-time code.
- The Connect button opens `https://t.me/<bot_username>?start=<code>`.
- The existing Telegram long-polling worker receives `/start <code>` and binds that Telegram chat/user to the current DeerFlow user.
Slack:
- The frontend creates a short one-time code.
- The UI shows `Send /connect <code> to the DeerFlow Slack bot.`
- The existing Slack Socket Mode worker receives the message and binds the Slack user/team to the current DeerFlow user.
Discord:
- The frontend creates a short one-time code.
- The UI shows `Send /connect <code> to the DeerFlow Discord bot.`
- The existing Discord Gateway worker receives the message and binds the Discord user/guild to the current DeerFlow user.
Feishu/Lark, DingTalk, WeChat, and WeCom:
- The frontend creates a short one-time code.
- The UI shows `Send /connect <code> to the DeerFlow <Provider> bot.`
- The already-running long-connection or polling worker receives the message and binds the platform user/workspace identity to the current DeerFlow user.
Codes expire after 10 minutes and are single-use.
## Runtime Model
Connection records live in SQL tables under `deerflow.persistence.channel_connections`:
- `channel_connections`: owner user, provider identity, workspace/guild/team, status, metadata.
- `channel_oauth_states`: one-time connect codes and Telegram deep-link state.
- `channel_conversations`: connection-scoped IM conversation to DeerFlow thread mapping.
- `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 raw platform user id as `channel_user_id`.
## Security Notes
- Browser APIs remain authenticated and CSRF-protected.
- Connect codes are random, short-lived, and single-use.
- Provider bot tokens remain in `channels.*` and are never returned to the browser.
- This implementation does not add public provider callback or webhook routes.
@@ -470,14 +470,32 @@ class AioSandboxProvider(SandboxProvider):
existing_id = self._thread_sandboxes[thread_id]
if existing_id in self._sandboxes:
suffix = " (post-lock check)" if post_lock else ""
logger.info(f"Reusing in-process sandbox {existing_id} for thread {thread_id}{suffix}")
self._last_activity[existing_id] = time.time()
return existing_id
info = self._sandbox_infos.get(existing_id)
else:
del self._thread_sandboxes[thread_id]
return None
del self._thread_sandboxes[thread_id]
alive = self._check_tracked_sandbox_alive(existing_id, info) if info is not None else True
if alive is False:
self._drop_unhealthy_sandbox(
existing_id,
"in-process cache failed health check",
expected_info=info,
)
return None
with self._lock:
if self._thread_sandboxes.get(thread_id) != existing_id:
return None
if existing_id not in self._sandboxes:
self._thread_sandboxes.pop(thread_id, None)
return None
suffix = " (post-lock check)" if post_lock else ""
logger.info(f"Reusing in-process sandbox {existing_id} for thread {thread_id}{suffix}")
self._last_activity[existing_id] = time.time()
return existing_id
def _reclaim_warm_pool_sandbox(self, thread_id: str | None, sandbox_id: str, *, post_lock: bool = False) -> str | None:
"""Promote a warm-pool sandbox back to active tracking if available."""
if thread_id is None:
@@ -487,7 +505,22 @@ class AioSandboxProvider(SandboxProvider):
if sandbox_id not in self._warm_pool:
return None
info, _ = self._warm_pool.pop(sandbox_id)
info, _ = self._warm_pool[sandbox_id]
alive = self._check_tracked_sandbox_alive(sandbox_id, info)
if alive is False:
self._drop_unhealthy_sandbox(
sandbox_id,
"warm-pool cache failed health check",
expected_info=info,
)
return None
with self._lock:
warm_item = self._warm_pool.pop(sandbox_id, None)
if warm_item is None:
return None
info, _ = warm_item
sandbox = AioSandbox(id=sandbox_id, base_url=info.sandbox_url)
self._sandboxes[sandbox_id] = sandbox
self._sandbox_infos[sandbox_id] = info
@@ -527,6 +560,70 @@ class AioSandboxProvider(SandboxProvider):
logger.info(f"Created sandbox {sandbox_id} for thread {thread_id} at {info.sandbox_url}")
return sandbox_id
def _check_tracked_sandbox_alive(self, sandbox_id: str, info: SandboxInfo) -> bool | None:
"""Return whether a tracked sandbox appears alive, or None if unknown."""
try:
return self._backend.is_alive(info)
except Exception as e:
logger.warning(f"Failed to check sandbox {sandbox_id} health: {e}")
return None
def _remove_tracked_sandbox(
self,
sandbox_id: str,
*,
expected_info: SandboxInfo | None = None,
) -> tuple[Sandbox | None, SandboxInfo | None, bool]:
"""Remove a sandbox from in-process tracking maps.
When expected_info is provided, removal only happens if the currently
tracked active or warm-pool entry is the exact info object that was
checked. This prevents a stale health-check result from deleting a
freshly recreated sandbox with the same deterministic id.
"""
thread_ids_to_remove: list[str] = []
with self._lock:
active_info = self._sandbox_infos.get(sandbox_id)
warm_item = self._warm_pool.get(sandbox_id)
warm_info = warm_item[0] if warm_item is not None else None
if expected_info is not None and active_info is not expected_info and warm_info is not expected_info:
return None, None, False
sandbox = self._sandboxes.pop(sandbox_id, None)
info = self._sandbox_infos.pop(sandbox_id, None)
thread_ids_to_remove = [tid for tid, sid in self._thread_sandboxes.items() if sid == sandbox_id]
for tid in thread_ids_to_remove:
del self._thread_sandboxes[tid]
self._last_activity.pop(sandbox_id, None)
if info is None and sandbox_id in self._warm_pool:
info, _ = self._warm_pool.pop(sandbox_id)
else:
self._warm_pool.pop(sandbox_id, None)
return sandbox, info, True
def _drop_unhealthy_sandbox(self, sandbox_id: str, reason: str, *, expected_info: SandboxInfo | None = None) -> None:
"""Remove and destroy a sandbox after a definitive failed health check."""
sandbox, info, removed = self._remove_tracked_sandbox(sandbox_id, expected_info=expected_info)
if not removed:
logger.info(f"Skipped dropping sandbox {sandbox_id}: tracked info changed after health check")
return
if sandbox is not None:
try:
sandbox.close()
except Exception as e:
logger.warning(f"Error closing unhealthy sandbox {sandbox_id}: {e}")
if info is not None:
try:
self._backend.destroy(info)
except Exception as e:
logger.warning(f"Error destroying unhealthy sandbox {sandbox_id}: {e}")
logger.warning(f"Dropped unhealthy sandbox {sandbox_id}: {reason}")
def _replica_count(self) -> tuple[int, int]:
"""Return configured replicas and currently tracked sandbox count."""
replicas = self._config.get("replicas", DEFAULT_REPLICAS)
@@ -617,7 +714,7 @@ class AioSandboxProvider(SandboxProvider):
async def _acquire_internal_async(self, thread_id: str | None) -> str:
"""Async counterpart to ``_acquire_internal``."""
cached_id = self._reuse_in_process_sandbox(thread_id)
cached_id = await asyncio.to_thread(self._reuse_in_process_sandbox, thread_id)
if cached_id is not None:
return cached_id
@@ -625,7 +722,7 @@ class AioSandboxProvider(SandboxProvider):
sandbox_id = self._sandbox_id_for_thread(thread_id)
# ── Layer 1.5: Warm pool (container still running, no cold-start) ──
reclaimed_id = self._reclaim_warm_pool_sandbox(thread_id, sandbox_id)
reclaimed_id = await asyncio.to_thread(self._reclaim_warm_pool_sandbox, thread_id, sandbox_id)
if reclaimed_id is not None:
return reclaimed_id
@@ -681,7 +778,7 @@ class AioSandboxProvider(SandboxProvider):
locked = True
# Re-check in-process caches under the file lock in case another
# thread in this process won the race while we were waiting.
cached_id = self._recheck_cached_sandbox(thread_id, sandbox_id)
cached_id = await asyncio.to_thread(self._recheck_cached_sandbox, thread_id, sandbox_id)
if cached_id is not None:
return cached_id
@@ -837,22 +934,7 @@ class AioSandboxProvider(SandboxProvider):
Args:
sandbox_id: The ID of the sandbox to destroy.
"""
info = None
sandbox = None
thread_ids_to_remove: list[str] = []
with self._lock:
sandbox = self._sandboxes.pop(sandbox_id, None)
info = self._sandbox_infos.pop(sandbox_id, None)
thread_ids_to_remove = [tid for tid, sid in self._thread_sandboxes.items() if sid == sandbox_id]
for tid in thread_ids_to_remove:
del self._thread_sandboxes[tid]
self._last_activity.pop(sandbox_id, None)
# Also pull from warm pool if it was parked there
if info is None and sandbox_id in self._warm_pool:
info, _ = self._warm_pool.pop(sandbox_id)
else:
self._warm_pool.pop(sandbox_id, None)
sandbox, info, _ = self._remove_tracked_sandbox(sandbox_id)
if sandbox is not None:
# Defense-in-depth: close() already swallows its own errors; this
@@ -169,6 +169,24 @@ def _resolve_docker_bind_host(sandbox_host: str | None = None, bind_host: str |
return "0.0.0.0"
def _is_no_such_container_error(stderr: str, container_name: str) -> bool:
"""Return True only when stderr definitively says the container does not exist.
Docker reports "No such object" / "No such container". Apple Container
reports a generic "not found", so that phrase is only trusted when the
message also names the inspected container (or refers to a
container/object); transient failures whose text happens to contain
"not found" (e.g. "command not found", "context not found") must stay on
the raise path instead of being misread as a dead container.
"""
message = stderr.lower()
if "no such object" in message or "no such container" in message:
return True
if "not found" not in message:
return False
return container_name.lower() in message or "container" in message or "object" in message
class LocalContainerBackend(SandboxBackend):
"""Backend that manages sandbox containers locally using Docker or Apple Container.
@@ -335,11 +353,21 @@ class LocalContainerBackend(SandboxBackend):
sandbox_id: The deterministic sandbox ID (determines container name).
Returns:
SandboxInfo if container found and healthy, None otherwise.
SandboxInfo if container found and healthy, None otherwise. A
failed runtime check (e.g. transient daemon error) also returns
None discovery must not adopt a container it cannot verify, and
falling through to create keeps acquire recoverable instead of
hard-failing on a hiccup.
"""
container_name = f"{self._container_prefix}-{sandbox_id}"
if not self._is_container_running(container_name):
try:
running = self._is_container_running(container_name)
except RuntimeError as e:
logger.warning(f"Could not verify container {container_name} during discovery; not adopting it: {e}")
return None
if not running:
return None
port = self._get_container_port(container_name)
@@ -582,6 +610,13 @@ class LocalContainerBackend(SandboxBackend):
This enables cross-process container discovery any process can detect
containers started by another process via the deterministic container name.
Raises:
RuntimeError: If the container runtime cannot answer the inspect
query. A failed check is intentionally distinct from a
definitive "container does not exist" result so callers do not
destroy healthy containers during transient Docker/Container
daemon failures.
"""
try:
result = subprocess.run(
@@ -590,9 +625,14 @@ class LocalContainerBackend(SandboxBackend):
text=True,
timeout=5,
)
return result.returncode == 0 and result.stdout.strip().lower() == "true"
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
except subprocess.TimeoutExpired as exc:
raise RuntimeError(f"Timed out checking container {container_name}") from exc
if result.returncode == 0:
return result.stdout.strip().lower() == "true"
if _is_no_such_container_error(result.stderr, container_name):
return False
raise RuntimeError(f"Failed to inspect container {container_name}: {result.stderr.strip()}")
def _get_container_port(self, container_name: str) -> int | None:
"""Get the host port of a running container.
@@ -176,12 +176,16 @@ class RemoteSandboxBackend(SandboxBackend):
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
timeout=10,
)
if resp.ok:
data = resp.json()
return data.get("status") == "Running"
return False
except requests.RequestException:
except requests.RequestException as exc:
raise RuntimeError(f"Provisioner health check failed for {sandbox_id}: {exc}") from exc
if resp.status_code == 404:
return False
if not resp.ok:
raise RuntimeError(f"Provisioner health check failed for {sandbox_id}: HTTP {resp.status_code} {resp.text}")
data = resp.json()
return data.get("status") == "Running"
def _provisioner_discover(self, sandbox_id: str) -> SandboxInfo | None:
"""GET /api/sandboxes/{sandbox_id} → discover existing sandbox."""
@@ -11,7 +11,6 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
from deerflow.config.acp_config import ACPAgentConfig, load_acp_config_from_dict
from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.extensions_config import ExtensionsConfig
@@ -117,7 +116,6 @@ class AppConfig(BaseModel):
subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration")
guardrails: GuardrailsConfig = Field(default_factory=GuardrailsConfig, description="Guardrail middleware configuration")
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig, description="LLM circuit breaker configuration")
channel_connections: ChannelConnectionsConfig = Field(default_factory=ChannelConnectionsConfig, description="User-facing IM channel connection configuration")
loop_detection: LoopDetectionConfig = Field(default_factory=LoopDetectionConfig, description="Loop detection middleware configuration")
safety_finish_reason: SafetyFinishReasonConfig = Field(default_factory=SafetyFinishReasonConfig, description="Provider safety-filter finish_reason interception middleware configuration")
model_config = ConfigDict(extra="allow")
@@ -1,61 +0,0 @@
"""Configuration for user-owned IM channel connections."""
from __future__ import annotations
from pydantic import BaseModel, Field
class SlackChannelConnectionConfig(BaseModel):
enabled: bool = False
@property
def configured(self) -> bool:
return True
class TelegramChannelConnectionConfig(BaseModel):
enabled: bool = False
bot_username: str = ""
@property
def configured(self) -> bool:
return bool(self.bot_username)
class DiscordChannelConnectionConfig(BaseModel):
enabled: bool = False
@property
def configured(self) -> bool:
return True
class BindingCodeChannelConnectionConfig(BaseModel):
enabled: bool = False
@property
def configured(self) -> bool:
return True
class ChannelConnectionsConfig(BaseModel):
"""Top-level config for browser-connectable IM channels."""
enabled: bool = False
slack: SlackChannelConnectionConfig = Field(default_factory=SlackChannelConnectionConfig)
telegram: TelegramChannelConnectionConfig = Field(default_factory=TelegramChannelConnectionConfig)
discord: DiscordChannelConnectionConfig = Field(default_factory=DiscordChannelConnectionConfig)
feishu: BindingCodeChannelConnectionConfig = Field(default_factory=BindingCodeChannelConnectionConfig)
dingtalk: BindingCodeChannelConnectionConfig = Field(default_factory=BindingCodeChannelConnectionConfig)
wechat: BindingCodeChannelConnectionConfig = Field(default_factory=BindingCodeChannelConnectionConfig)
wecom: BindingCodeChannelConnectionConfig = Field(default_factory=BindingCodeChannelConnectionConfig)
def provider_status(self, provider: str) -> dict[str, bool]:
config = getattr(self, provider, None)
if config is None:
return {"enabled": False, "configured": False}
enabled = bool(config.enabled)
return {
"enabled": enabled,
"configured": enabled and bool(config.configured),
}
@@ -47,7 +47,7 @@ def make_safe_user_id(raw: str) -> str:
sanitized = _UNSAFE_USER_ID_CHAR_RE.sub("-", raw)
if sanitized == raw:
return raw
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:_SAFE_USER_ID_DIGEST_HEX_LEN]
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()[:_SAFE_USER_ID_DIGEST_HEX_LEN]
return f"{sanitized}-{digest}"
@@ -1,21 +0,0 @@
"""User-owned IM channel connection persistence."""
from deerflow.persistence.channel_connections.model import (
ChannelConnectionRow,
ChannelConversationRow,
ChannelCredentialRow,
ChannelOAuthStateRow,
)
from deerflow.persistence.channel_connections.sql import (
ChannelConnectionRepository,
ChannelCredentialCipher,
)
__all__ = [
"ChannelConnectionRepository",
"ChannelConnectionRow",
"ChannelConversationRow",
"ChannelCredentialCipher",
"ChannelCredentialRow",
"ChannelOAuthStateRow",
]
@@ -1,111 +0,0 @@
"""ORM models for user-owned IM channel connections."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import JSON, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
def _utc_now() -> datetime:
return datetime.now(UTC)
class ChannelConnectionRow(Base):
__tablename__ = "channel_connections"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
owner_user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
provider: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="connected")
external_account_id: Mapped[str] = mapped_column(String(128), nullable=False, default="")
external_account_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
workspace_id: Mapped[str] = mapped_column(String(128), nullable=False, default="")
workspace_name: Mapped[str | None] = mapped_column(String(256), nullable=True)
bot_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
scopes_json: Mapped[list] = mapped_column(JSON, default=list)
capabilities_json: Mapped[dict] = mapped_column(JSON, default=dict)
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utc_now, onupdate=_utc_now)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint(
"owner_user_id",
"provider",
"external_account_id",
"workspace_id",
name="uq_channel_connection_owner_provider_identity",
),
Index("idx_channel_connections_event_lookup", "provider", "workspace_id", "bot_user_id"),
)
class ChannelCredentialRow(Base):
__tablename__ = "channel_credentials"
connection_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("channel_connections.id", ondelete="CASCADE"),
primary_key=True,
)
encrypted_access_token: Mapped[str | None] = mapped_column(Text, nullable=True)
encrypted_refresh_token: Mapped[str | None] = mapped_column(Text, nullable=True)
token_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
refresh_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
encrypted_extra_json: Mapped[str | None] = mapped_column(Text, nullable=True)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utc_now, onupdate=_utc_now)
class ChannelOAuthStateRow(Base):
__tablename__ = "channel_oauth_states"
state_hash: Mapped[str] = mapped_column(String(128), primary_key=True)
owner_user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
provider: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
code_verifier_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
nonce_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
redirect_after: Mapped[str | None] = mapped_column(Text, nullable=True)
requested_scopes_json: Mapped[list] = mapped_column(JSON, default=list)
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utc_now)
class ChannelConversationRow(Base):
__tablename__ = "channel_conversations"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
connection_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("channel_connections.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
owner_user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
provider: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
external_conversation_id: Mapped[str] = mapped_column(String(128), nullable=False)
external_topic_id: Mapped[str] = mapped_column(String(128), nullable=False, default="")
thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utc_now)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utc_now, onupdate=_utc_now)
__table_args__ = (
UniqueConstraint(
"connection_id",
"external_conversation_id",
"external_topic_id",
name="uq_channel_conversation_connection_external",
),
)
@@ -1,349 +0,0 @@
"""SQL repository for user-owned IM channel connections."""
from __future__ import annotations
import base64
import hashlib
import json
import uuid
from datetime import UTC, datetime
from typing import Any
from cryptography.fernet import Fernet
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.channel_connections.model import (
ChannelConnectionRow,
ChannelConversationRow,
ChannelCredentialRow,
ChannelOAuthStateRow,
)
from deerflow.utils.time import coerce_iso
class ChannelCredentialCipher:
"""Encrypts provider credentials before they are persisted."""
def __init__(self, fernet: Fernet) -> None:
self._fernet = fernet
@classmethod
def from_key(cls, key: str) -> ChannelCredentialCipher:
digest = hashlib.sha256(key.encode("utf-8")).digest()
return cls(Fernet(base64.urlsafe_b64encode(digest)))
def encrypt_text(self, value: str | None) -> str | None:
if value is None:
return None
return "fernet:v1:" + self._fernet.encrypt(value.encode("utf-8")).decode("ascii")
def decrypt_text(self, value: str | None) -> str | None:
if value is None:
return None
token = value.removeprefix("fernet:v1:")
return self._fernet.decrypt(token.encode("ascii")).decode("utf-8")
class ChannelConnectionRepository:
"""Persistence facade for channel connections, credentials, and conversations."""
def __init__(
self,
session_factory: async_sessionmaker[AsyncSession],
*,
cipher: ChannelCredentialCipher | None = None,
) -> None:
self.session_factory = session_factory
self._cipher = cipher
async def close(self) -> None:
from deerflow.persistence.engine import close_engine
await close_engine()
@staticmethod
def _new_id() -> str:
return uuid.uuid4().hex
@staticmethod
def _normalize_optional_identity(value: str | None) -> str:
return value or ""
@staticmethod
def _coerce_datetime(value: datetime | None) -> datetime | None:
if value is None or value.tzinfo is not None:
return value
return value.replace(tzinfo=UTC)
def _encrypt_optional_secret(self, value: str | None) -> str | None:
if value is None:
return None
if self._cipher is None:
raise RuntimeError("channel connection encryption key is required")
return self._cipher.encrypt_text(value)
@staticmethod
def _connection_to_dict(row: ChannelConnectionRow) -> dict[str, Any]:
data = row.to_dict()
data["external_account_id"] = data["external_account_id"] or None
data["workspace_id"] = data["workspace_id"] or None
data["scopes"] = data.pop("scopes_json") or []
data["capabilities"] = data.pop("capabilities_json") or {}
data["metadata"] = data.pop("metadata_json") or {}
for key in ("created_at", "updated_at", "last_seen_at", "last_error_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = coerce_iso(value)
return data
async def upsert_connection(
self,
*,
owner_user_id: str,
provider: str,
external_account_id: str | None = None,
external_account_name: str | None = None,
workspace_id: str | None = None,
workspace_name: str | None = None,
bot_user_id: str | None = None,
scopes: list[str] | None = None,
capabilities: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
status: str = "connected",
) -> dict[str, Any]:
external_account_id_value = self._normalize_optional_identity(external_account_id)
workspace_id_value = self._normalize_optional_identity(workspace_id)
async with self.session_factory() as session:
stmt = select(ChannelConnectionRow).where(
ChannelConnectionRow.owner_user_id == owner_user_id,
ChannelConnectionRow.provider == provider,
ChannelConnectionRow.external_account_id == external_account_id_value,
ChannelConnectionRow.workspace_id == workspace_id_value,
)
row = (await session.execute(stmt)).scalar_one_or_none()
if row is None:
row = ChannelConnectionRow(
id=self._new_id(),
owner_user_id=owner_user_id,
provider=provider,
external_account_id=external_account_id_value,
workspace_id=workspace_id_value,
)
session.add(row)
row.status = status
row.external_account_name = external_account_name
row.workspace_name = workspace_name
row.bot_user_id = bot_user_id
row.scopes_json = list(scopes or [])
row.capabilities_json = dict(capabilities or {})
row.metadata_json = dict(metadata or {})
await session.commit()
await session.refresh(row)
return self._connection_to_dict(row)
async def list_connections(self, owner_user_id: str) -> list[dict[str, Any]]:
async with self.session_factory() as session:
result = await session.execute(select(ChannelConnectionRow).where(ChannelConnectionRow.owner_user_id == owner_user_id).order_by(ChannelConnectionRow.updated_at.desc(), ChannelConnectionRow.id.desc()))
return [self._connection_to_dict(row) for row in result.scalars()]
async def disconnect_connection(self, *, connection_id: str, owner_user_id: str) -> bool:
async with self.session_factory() as session:
row = await session.get(ChannelConnectionRow, connection_id)
if row is None or row.owner_user_id != owner_user_id:
return False
row.status = "revoked"
credential = await session.get(ChannelCredentialRow, connection_id)
if credential is not None:
await session.delete(credential)
await session.commit()
return True
async def store_credentials(
self,
connection_id: str,
*,
access_token: str | None,
refresh_token: str | None = None,
token_type: str | None = None,
expires_at: datetime | None = None,
refresh_expires_at: datetime | None = None,
extra: dict[str, Any] | None = None,
) -> None:
if self._cipher is None:
raise RuntimeError("channel connection encryption key is required")
async with self.session_factory() as session:
row = await session.get(ChannelCredentialRow, connection_id)
if row is None:
row = ChannelCredentialRow(connection_id=connection_id)
session.add(row)
row.encrypted_access_token = self._cipher.encrypt_text(access_token)
row.encrypted_refresh_token = self._cipher.encrypt_text(refresh_token)
row.token_type = token_type
row.expires_at = expires_at
row.refresh_expires_at = refresh_expires_at
row.encrypted_extra_json = self._cipher.encrypt_text(json.dumps(extra or {}, ensure_ascii=False))
row.version = (row.version or 0) + 1
await session.commit()
async def get_credentials(self, connection_id: str) -> dict[str, Any] | None:
if self._cipher is None:
return None
async with self.session_factory() as session:
row = await session.get(ChannelCredentialRow, connection_id)
if row is None:
return None
extra_raw = self._cipher.decrypt_text(row.encrypted_extra_json)
return {
"connection_id": row.connection_id,
"access_token": self._cipher.decrypt_text(row.encrypted_access_token),
"refresh_token": self._cipher.decrypt_text(row.encrypted_refresh_token),
"token_type": row.token_type,
"expires_at": self._coerce_datetime(row.expires_at),
"refresh_expires_at": self._coerce_datetime(row.refresh_expires_at),
"extra": json.loads(extra_raw) if extra_raw else {},
}
@staticmethod
def hash_state(state: str) -> str:
return hashlib.sha256(state.encode("utf-8")).hexdigest()
async def create_oauth_state(
self,
*,
owner_user_id: str,
provider: str,
state: str,
expires_at: datetime,
code_verifier: str | None = None,
nonce_hash: str | None = None,
redirect_after: str | None = None,
requested_scopes: list[str] | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
row = ChannelOAuthStateRow(
state_hash=self.hash_state(state),
owner_user_id=owner_user_id,
provider=provider,
code_verifier_encrypted=self._encrypt_optional_secret(code_verifier),
nonce_hash=nonce_hash,
redirect_after=redirect_after,
requested_scopes_json=list(requested_scopes or []),
metadata_json=dict(metadata or {}),
expires_at=expires_at,
)
async with self.session_factory() as session:
session.add(row)
await session.commit()
async def count_oauth_states(self, *, owner_user_id: str, provider: str) -> int:
async with self.session_factory() as session:
result = await session.execute(
select(ChannelOAuthStateRow).where(
ChannelOAuthStateRow.owner_user_id == owner_user_id,
ChannelOAuthStateRow.provider == provider,
)
)
return len(list(result.scalars()))
async def consume_oauth_state(
self,
*,
provider: str,
state: str,
now: datetime | None = None,
) -> dict[str, Any] | None:
current_time = now or datetime.now(UTC)
async with self.session_factory() as session:
await session.execute(delete(ChannelOAuthStateRow).where(ChannelOAuthStateRow.expires_at < current_time))
row = await session.get(ChannelOAuthStateRow, self.hash_state(state))
if row is None or row.provider != provider or row.consumed_at is not None:
await session.commit()
return None
expires_at = self._coerce_datetime(row.expires_at)
if expires_at is not None and expires_at < current_time:
await session.commit()
return None
row.consumed_at = current_time
await session.commit()
return {
"owner_user_id": row.owner_user_id,
"provider": row.provider,
"requested_scopes": row.requested_scopes_json or [],
"metadata": row.metadata_json or {},
"redirect_after": row.redirect_after,
}
async def find_connection_by_external_identity(
self,
*,
provider: str,
external_account_id: str,
workspace_id: str | None = None,
) -> dict[str, Any] | None:
async with self.session_factory() as session:
result = await session.execute(
select(ChannelConnectionRow)
.where(
ChannelConnectionRow.provider == provider,
ChannelConnectionRow.external_account_id == self._normalize_optional_identity(external_account_id),
ChannelConnectionRow.workspace_id == self._normalize_optional_identity(workspace_id),
ChannelConnectionRow.status == "connected",
)
.order_by(ChannelConnectionRow.updated_at.desc(), ChannelConnectionRow.id.desc())
.limit(1)
)
row = result.scalar_one_or_none()
return self._connection_to_dict(row) if row is not None else None
async def set_thread_id(
self,
*,
connection_id: str,
owner_user_id: str,
provider: str,
external_conversation_id: str,
thread_id: str,
external_topic_id: str | None = None,
) -> None:
topic_id = external_topic_id or ""
async with self.session_factory() as session:
stmt = select(ChannelConversationRow).where(
ChannelConversationRow.connection_id == connection_id,
ChannelConversationRow.external_conversation_id == external_conversation_id,
ChannelConversationRow.external_topic_id == topic_id,
)
row = (await session.execute(stmt)).scalar_one_or_none()
if row is None:
row = ChannelConversationRow(
id=self._new_id(),
connection_id=connection_id,
owner_user_id=owner_user_id,
provider=provider,
external_conversation_id=external_conversation_id,
external_topic_id=topic_id,
thread_id=thread_id,
)
session.add(row)
else:
row.thread_id = thread_id
row.owner_user_id = owner_user_id
row.provider = provider
await session.commit()
async def get_thread_id(
self,
connection_id: str,
external_conversation_id: str,
external_topic_id: str | None = None,
) -> str | None:
async with self.session_factory() as session:
stmt = select(ChannelConversationRow.thread_id).where(
ChannelConversationRow.connection_id == connection_id,
ChannelConversationRow.external_conversation_id == external_conversation_id,
ChannelConversationRow.external_topic_id == (external_topic_id or ""),
)
return (await session.execute(stmt)).scalar_one_or_none()
@@ -14,26 +14,10 @@ its storage implementation lives in ``deerflow.runtime.events.store.db`` and
there is no matching entity directory.
"""
from deerflow.persistence.channel_connections.model import (
ChannelConnectionRow,
ChannelConversationRow,
ChannelCredentialRow,
ChannelOAuthStateRow,
)
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.user.model import UserRow
__all__ = [
"ChannelConnectionRow",
"ChannelConversationRow",
"ChannelCredentialRow",
"ChannelOAuthStateRow",
"FeedbackRow",
"RunEventRow",
"RunRow",
"ThreadMetaRow",
"UserRow",
]
__all__ = ["FeedbackRow", "RunEventRow", "RunRow", "ThreadMetaRow", "UserRow"]
@@ -71,15 +71,6 @@ class ThreadMetaStore(abc.ABC):
"""
pass
@abc.abstractmethod
async def update_owner(self, thread_id: str, owner_user_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
"""Move a thread metadata row to a new owner.
Intended for trusted internal repair/migration paths. No-op if the
row does not exist or the caller fails the owner check.
"""
pass
@abc.abstractmethod
async def check_access(self, thread_id: str, user_id: str, *, require_existing: bool = False) -> bool:
"""Check if ``user_id`` has access to ``thread_id``."""
@@ -127,14 +127,6 @@ class MemoryThreadMetaStore(ThreadMetaStore):
record["updated_at"] = now_iso()
await self._store.aput(THREADS_NS, thread_id, record)
async def update_owner(self, thread_id: str, owner_user_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.update_owner")
if record is None:
return
record["user_id"] = owner_user_id
record["updated_at"] = now_iso()
await self._store.aput(THREADS_NS, thread_id, record)
async def delete(self, thread_id: str, *, user_id: str | None | _AutoSentinel = AUTO) -> None:
record = await self._get_owned_record(thread_id, user_id, "MemoryThreadMetaStore.delete")
if record is None:
@@ -211,21 +211,6 @@ class ThreadMetaRepository(ThreadMetaStore):
row.updated_at = datetime.now(UTC)
await session.commit()
async def update_owner(
self,
thread_id: str,
owner_user_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
) -> None:
"""Move a thread metadata row to ``owner_user_id``."""
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.update_owner")
async with self._sf() as session:
if not await self._check_ownership(session, thread_id, resolved_user_id):
return
await session.execute(update(ThreadMetaRow).where(ThreadMetaRow.thread_id == thread_id).values(user_id=owner_user_id, updated_at=datetime.now(UTC)))
await session.commit()
async def delete(
self,
thread_id: str,
@@ -83,7 +83,6 @@ class RunRecord:
multitask_strategy: str = "reject"
metadata: dict = field(default_factory=dict)
kwargs: dict = field(default_factory=dict)
user_id: str | None = None
created_at: str = ""
updated_at: str = ""
task: asyncio.Task | None = field(default=None, repr=False)
@@ -125,7 +124,7 @@ class RunManager:
@staticmethod
def _store_put_payload(record: RunRecord, *, error: str | None = None) -> dict[str, Any]:
payload = {
return {
"thread_id": record.thread_id,
"assistant_id": record.assistant_id,
"status": record.status.value,
@@ -136,9 +135,6 @@ class RunManager:
"created_at": record.created_at,
"model_name": record.model_name,
}
if record.user_id is not None:
payload["user_id"] = record.user_id
return payload
async def _call_store_with_retry(
self,
@@ -245,7 +241,6 @@ class RunManager:
kwargs=row.get("kwargs") or {},
created_at=row.get("created_at") or "",
updated_at=row.get("updated_at") or "",
user_id=row.get("user_id"),
error=row.get("error"),
model_name=row.get("model_name"),
store_only=True,
@@ -325,7 +320,6 @@ class RunManager:
metadata: dict | None = None,
kwargs: dict | None = None,
multitask_strategy: str = "reject",
user_id: str | None = None,
) -> RunRecord:
"""Create a new pending run and register it."""
run_id = str(uuid.uuid4())
@@ -339,7 +333,6 @@ class RunManager:
multitask_strategy=multitask_strategy,
metadata=metadata or {},
kwargs=kwargs or {},
user_id=user_id,
created_at=now,
updated_at=now,
)
@@ -511,7 +504,6 @@ class RunManager:
kwargs: dict | None = None,
multitask_strategy: str = "reject",
model_name: str | None = None,
user_id: str | None = None,
) -> RunRecord:
"""Atomically check for inflight runs and create a new one.
@@ -554,7 +546,6 @@ class RunManager:
multitask_strategy=multitask_strategy,
metadata=metadata or {},
kwargs=kwargs or {},
user_id=user_id,
created_at=now,
updated_at=now,
model_name=model_name,
@@ -1,10 +1,15 @@
import asyncio
import logging
from collections.abc import Awaitable, Callable
from dataclasses import replace as dc_replace
from typing import NotRequired, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware
from langchain_core.messages import ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.runtime import Runtime
from langgraph.types import Command
from deerflow.agents.thread_state import SandboxState, ThreadDataState
from deerflow.sandbox import get_sandbox_provider
@@ -126,3 +131,87 @@ class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]):
# No sandbox to release
return await super().aafter_agent(state, runtime)
# ------------------------------------------------------------------
# Tool-call wrappers: persist lazily-acquired sandbox state into the
# graph state via Command(update=...).
#
# Background:
# ``ensure_sandbox_initialized*`` in ``deerflow.sandbox.tools`` mutates
# ``runtime.state["sandbox"]`` directly. That mutation is local to the
# current tool invocation and is NOT picked up by LangGraph's channel
# reducer, so subsequent graph steps (and downstream consumers such as
# ``ToolOutputBudgetMiddleware`` and the sub-agent ``task_tool``)
# cannot observe the sandbox id. Wrapping the tool call lets us detect
# a fresh lazy init by diffing the state snapshot before/after the
# handler and emit a proper state update via ``Command``.
# ------------------------------------------------------------------
@staticmethod
def _read_sandbox_id_from_state(state: object) -> str | None:
if not isinstance(state, dict):
return None
sandbox_state = state.get("sandbox")
if not isinstance(sandbox_state, dict):
return None
sandbox_id = sandbox_state.get("sandbox_id")
return sandbox_id if isinstance(sandbox_id, str) else None
@staticmethod
def _attach_sandbox_update(result: ToolMessage | Command, sandbox_id: str) -> ToolMessage | Command:
"""Wrap or merge ``result`` so that ``sandbox.sandbox_id`` is persisted.
- ``ToolMessage`` -> ``Command(update={"sandbox": ..., "messages": [msg]})``
- ``Command`` with dict update -> merge ``sandbox`` key, preserve all
existing fields (``messages``, ``goto``, ``graph``, ``resume``, ...).
- ``Command`` with non-dict / None update -> leave it untouched to
avoid silent data loss on unknown update shapes.
"""
sandbox_update = {"sandbox": {"sandbox_id": sandbox_id}}
if isinstance(result, ToolMessage):
return Command(update={**sandbox_update, "messages": [result]})
existing_update = result.update
if isinstance(existing_update, dict):
merged_update = {**existing_update, **sandbox_update}
return dc_replace(result, update=merged_update)
return result
@staticmethod
def _read_sandbox_id_from_request(request: ToolCallRequest) -> str | None:
"""Read sandbox_id from runtime.state (where ensure_sandbox_initialized writes)."""
runtime = request.runtime
if runtime is None or runtime.state is None:
return None
return SandboxMiddleware._read_sandbox_id_from_state(runtime.state)
@override
def wrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command],
) -> ToolMessage | Command:
prev_sandbox_id = self._read_sandbox_id_from_request(request)
result = handler(request)
if prev_sandbox_id is not None:
return result
curr_sandbox_id = self._read_sandbox_id_from_request(request)
if curr_sandbox_id is None:
return result
return self._attach_sandbox_update(result, curr_sandbox_id)
@override
async def awrap_tool_call(
self,
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command]],
) -> ToolMessage | Command:
prev_sandbox_id = self._read_sandbox_id_from_request(request)
result = await handler(request)
if prev_sandbox_id is not None:
return result
curr_sandbox_id = self._read_sandbox_id_from_request(request)
if curr_sandbox_id is None:
return result
return self._attach_sandbox_update(result, curr_sandbox_id)
-1
View File
@@ -36,7 +36,6 @@ dependencies = [
"sqlalchemy[asyncio]>=2.0,<3.0",
"aiosqlite>=0.19",
"alembic>=1.13",
"cryptography>=43.0.0",
]
[project.optional-dependencies]
@@ -69,6 +69,7 @@
"keys": [
"artifacts",
"messages",
"sandbox",
"thread_data",
"title",
"viewed_images"
@@ -79,6 +80,7 @@
"keys": [
"artifacts",
"messages",
"sandbox",
"thread_data",
"title",
"viewed_images"
@@ -89,6 +91,7 @@
"keys": [
"artifacts",
"messages",
"sandbox",
"thread_data",
"title",
"viewed_images"
@@ -99,6 +102,7 @@
"keys": [
"artifacts",
"messages",
"sandbox",
"thread_data",
"title",
"viewed_images"
@@ -109,6 +113,7 @@
"keys": [
"artifacts",
"messages",
"sandbox",
"thread_data",
"title",
"viewed_images"
@@ -119,6 +124,7 @@
"keys": [
"artifacts",
"messages",
"sandbox",
"thread_data",
"title",
"viewed_images"
@@ -1,251 +0,0 @@
"""Connection binding tests for browser-connectable IM channels beyond Telegram/Slack/Discord."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
from app.channels.message_bus import InboundMessage, MessageBus
async def _make_repo(tmp_path, name: str):
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import get_session_factory, init_engine
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / f'{name}.db'}", sqlite_dir=str(tmp_path))
return ChannelConnectionRepository(get_session_factory())
async def _seed_state(repo, provider: str, state: str, owner_user_id: str = "deerflow-user-1") -> None:
await repo.create_oauth_state(
owner_user_id=owner_user_id,
provider=provider,
state=state,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
)
def test_feishu_connect_command_binds_identity(tmp_path):
import anyio
from app.channels.feishu import FeishuChannel
async def go():
repo = await _make_repo(tmp_path, "feishu")
state = "feishu-bind-code"
await _seed_state(repo, "feishu", state)
channel = FeishuChannel(
bus=MessageBus(),
config={"app_id": "app", "app_secret": "secret", "connection_repo": repo},
)
channel._reply_card = AsyncMock()
handled = await channel._bind_connection_from_connect_code(
message_id="om-message-1",
chat_id="oc-chat-1",
user_id="ou-user-1",
code=state,
)
connections = await repo.list_connections("deerflow-user-1")
assert handled is True
assert len(connections) == 1
assert connections[0]["provider"] == "feishu"
assert connections[0]["external_account_id"] == "ou-user-1"
assert connections[0]["workspace_id"] == "oc-chat-1"
channel._reply_card.assert_awaited_once_with("om-message-1", "Feishu connected to DeerFlow.")
await repo.close()
anyio.run(go)
def test_dingtalk_connect_command_binds_identity(tmp_path):
import anyio
from app.channels.dingtalk import _CONVERSATION_TYPE_GROUP, DingTalkChannel
async def go():
repo = await _make_repo(tmp_path, "dingtalk")
state = "dingtalk-bind-code"
await _seed_state(repo, "dingtalk", state)
channel = DingTalkChannel(
bus=MessageBus(),
config={"client_id": "client", "client_secret": "secret", "connection_repo": repo},
)
channel._send_connection_reply = AsyncMock()
handled = await channel._bind_connection_from_connect_code(
conversation_type=_CONVERSATION_TYPE_GROUP,
sender_staff_id="staff-user-1",
sender_nick="Alice",
conversation_id="cid-group-1",
code=state,
)
connections = await repo.list_connections("deerflow-user-1")
assert handled is True
assert len(connections) == 1
assert connections[0]["provider"] == "dingtalk"
assert connections[0]["external_account_id"] == "staff-user-1"
assert connections[0]["external_account_name"] == "Alice"
assert connections[0]["workspace_id"] == "cid-group-1"
channel._send_connection_reply.assert_awaited_once()
await repo.close()
anyio.run(go)
def test_wechat_connect_command_binds_identity(tmp_path):
import anyio
from app.channels.wechat import WechatChannel
async def go():
repo = await _make_repo(tmp_path, "wechat")
state = "wechat-bind-code"
await _seed_state(repo, "wechat", state)
channel = WechatChannel(
bus=MessageBus(),
config={"bot_token": "token", "connection_repo": repo},
)
channel._send_connection_reply = AsyncMock()
handled = await channel._bind_connection_from_connect_code(
chat_id="wx-user-1",
context_token="ctx-1",
code=state,
)
connections = await repo.list_connections("deerflow-user-1")
assert handled is True
assert len(connections) == 1
assert connections[0]["provider"] == "wechat"
assert connections[0]["external_account_id"] == "wx-user-1"
assert connections[0]["workspace_id"] == "wx-user-1"
channel._send_connection_reply.assert_awaited_once_with("wx-user-1", "ctx-1", "WeChat connected to DeerFlow.")
await repo.close()
anyio.run(go)
def test_wecom_connect_command_binds_identity(tmp_path):
import anyio
from app.channels.wecom import WeComChannel
async def go():
repo = await _make_repo(tmp_path, "wecom")
state = "wecom-bind-code"
await _seed_state(repo, "wecom", state)
channel = WeComChannel(
bus=MessageBus(),
config={"bot_id": "bot", "bot_secret": "secret", "connection_repo": repo},
)
channel._ws_client = MagicMock()
channel._ws_client.reply = AsyncMock()
frame = {"body": {"aibotid": "bot-1", "chattype": "single"}}
handled = await channel._bind_connection_from_connect_code(
frame=frame,
user_id="wecom-user-1",
code=state,
)
connections = await repo.list_connections("deerflow-user-1")
assert handled is True
assert len(connections) == 1
assert connections[0]["provider"] == "wecom"
assert connections[0]["external_account_id"] == "wecom-user-1"
assert connections[0]["workspace_id"] == "bot-1"
channel._ws_client.reply.assert_awaited_once_with(frame, {"msgtype": "text", "text": {"content": "WeCom connected to DeerFlow."}})
await repo.close()
anyio.run(go)
def test_additional_channels_attach_owner_identity(tmp_path):
import anyio
from app.channels.dingtalk import _CONVERSATION_TYPE_GROUP, DingTalkChannel
from app.channels.feishu import FeishuChannel
from app.channels.wechat import WechatChannel
from app.channels.wecom import WeComChannel
async def go():
repo = await _make_repo(tmp_path, "additional-identity")
await repo.upsert_connection(
owner_user_id="deerflow-user-1",
provider="feishu",
external_account_id="ou-user-1",
workspace_id="oc-chat-1",
)
await repo.upsert_connection(
owner_user_id="deerflow-user-1",
provider="dingtalk",
external_account_id="staff-user-1",
workspace_id="cid-group-1",
)
await repo.upsert_connection(
owner_user_id="deerflow-user-1",
provider="wechat",
external_account_id="wx-user-1",
workspace_id="wx-user-1",
)
await repo.upsert_connection(
owner_user_id="deerflow-user-1",
provider="wecom",
external_account_id="wecom-user-1",
workspace_id="bot-1",
)
cases = [
(
FeishuChannel(bus=MessageBus(), config={"connection_repo": repo}),
InboundMessage(channel_name="feishu", chat_id="oc-chat-1", user_id="ou-user-1", text="hello"),
),
(
DingTalkChannel(bus=MessageBus(), config={"connection_repo": repo}),
InboundMessage(
channel_name="dingtalk",
chat_id="cid-group-1",
user_id="staff-user-1",
text="hello",
metadata={
"conversation_type": _CONVERSATION_TYPE_GROUP,
"conversation_id": "cid-group-1",
},
),
),
(
WechatChannel(bus=MessageBus(), config={"connection_repo": repo}),
InboundMessage(channel_name="wechat", chat_id="wx-user-1", user_id="wx-user-1", text="hello"),
),
(
WeComChannel(bus=MessageBus(), config={"connection_repo": repo}),
InboundMessage(
channel_name="wecom",
chat_id="wecom-user-1",
user_id="wecom-user-1",
text="hello",
metadata={"aibotid": "bot-1"},
),
),
]
for channel, inbound in cases:
attached = await channel._attach_connection_identity(inbound)
assert attached.owner_user_id == "deerflow-user-1"
assert attached.connection_id
assert (
attached.workspace_id
== {
"feishu": "oc-chat-1",
"dingtalk": "cid-group-1",
"wechat": "wx-user-1",
"wecom": "bot-1",
}[channel.name]
)
await repo.close()
anyio.run(go)
@@ -1,7 +1,10 @@
import logging
import os
import subprocess
from types import SimpleNamespace
import pytest
from deerflow.community.aio_sandbox.local_backend import (
LocalContainerBackend,
_format_container_command_for_log,
@@ -234,3 +237,99 @@ def test_start_container_keeps_apple_container_port_format(monkeypatch):
captured_cmd = _capture_start_container_command(monkeypatch, backend, runtime="container")
assert captured_cmd[captured_cmd.index("-p") + 1] == "18080:8080"
def _backend_for_inspect_tests() -> LocalContainerBackend:
backend = LocalContainerBackend(
image="sandbox:latest",
base_port=8080,
container_prefix="sandbox",
config_mounts=[],
environment={},
)
backend._runtime = "docker"
return backend
def test_is_container_running_false_when_container_missing(monkeypatch):
backend = _backend_for_inspect_tests()
def fake_run(cmd, **kwargs):
return SimpleNamespace(stdout="", stderr="Error: No such object: sandbox-missing", returncode=1)
monkeypatch.setattr("subprocess.run", fake_run)
assert backend._is_container_running("sandbox-missing") is False
def test_is_container_running_raises_on_runtime_error(monkeypatch):
backend = _backend_for_inspect_tests()
def fake_run(cmd, **kwargs):
return SimpleNamespace(stdout="", stderr="Cannot connect to the Docker daemon", returncode=1)
monkeypatch.setattr("subprocess.run", fake_run)
with pytest.raises(RuntimeError, match="Failed to inspect container sandbox-busy"):
backend._is_container_running("sandbox-busy")
def test_is_container_running_raises_on_timeout(monkeypatch):
backend = _backend_for_inspect_tests()
def fake_run(cmd, **kwargs):
raise subprocess.TimeoutExpired(cmd=cmd, timeout=kwargs["timeout"])
monkeypatch.setattr("subprocess.run", fake_run)
with pytest.raises(RuntimeError, match="Timed out checking container sandbox-timeout"):
backend._is_container_running("sandbox-timeout")
def test_discover_returns_none_when_runtime_check_fails(monkeypatch):
"""A transient daemon error during discovery must fall through to create, not fail acquire."""
backend = _backend_for_inspect_tests()
def fake_run(cmd, **kwargs):
return SimpleNamespace(stdout="", stderr="Cannot connect to the Docker daemon", returncode=1)
monkeypatch.setattr("subprocess.run", fake_run)
assert backend.discover("sandbox-blip") is None
def test_discover_returns_none_when_runtime_check_times_out(monkeypatch):
"""An inspect timeout during discovery must not propagate out of discover()."""
backend = _backend_for_inspect_tests()
def fake_run(cmd, **kwargs):
raise subprocess.TimeoutExpired(cmd=cmd, timeout=kwargs["timeout"])
monkeypatch.setattr("subprocess.run", fake_run)
assert backend.discover("sandbox-timeout") is None
def test_is_container_running_false_on_apple_container_not_found(monkeypatch):
"""Apple Container's generic "not found" is trusted when it names the container."""
backend = _backend_for_inspect_tests()
def fake_run(cmd, **kwargs):
return SimpleNamespace(stdout="", stderr='Error: not found: "sandbox-apple"', returncode=1)
monkeypatch.setattr("subprocess.run", fake_run)
assert backend._is_container_running("sandbox-apple") is False
def test_is_container_running_raises_on_unrelated_not_found_error(monkeypatch):
"""Transient errors whose text contains "not found" must not be misread as a dead container."""
backend = _backend_for_inspect_tests()
def fake_run(cmd, **kwargs):
return SimpleNamespace(stdout="", stderr="Error: credential helper not found in $PATH", returncode=1)
monkeypatch.setattr("subprocess.run", fake_run)
with pytest.raises(RuntimeError, match="Failed to inspect container sandbox-busy"):
backend._is_container_running("sandbox-busy")
+152
View File
@@ -317,6 +317,28 @@ async def test_acquire_async_cancelled_waiter_does_not_block_successor(tmp_path,
pytest.fail("provider thread lock was not released after successor acquire_async")
@pytest.mark.anyio
async def test_acquire_internal_async_offloads_cached_reuse_health_check(tmp_path, monkeypatch):
"""Async cached reuse must keep backend health checks off the event loop."""
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
provider, _sandbox, _ = _make_provider_with_active_sandbox(tmp_path, "sandbox-cached-async")
provider._thread_sandboxes = {"thread-cached-async": "sandbox-cached-async"}
provider._backend.is_alive = MagicMock(return_value=True)
to_thread_calls: list[tuple[object, tuple[object, ...]]] = []
async def fake_to_thread(func, /, *args, **kwargs):
to_thread_calls.append((func, args))
return func(*args, **kwargs)
monkeypatch.setattr(aio_mod.asyncio, "to_thread", fake_to_thread)
sandbox_id = await provider._acquire_internal_async("thread-cached-async")
assert sandbox_id == "sandbox-cached-async"
assert to_thread_calls == [(provider._reuse_in_process_sandbox, ("thread-cached-async",))]
def test_remote_backend_create_forwards_effective_user_id(monkeypatch):
"""Provisioner mode must receive user_id so PVC subPath matches user isolation."""
remote_mod = importlib.import_module("deerflow.community.aio_sandbox.remote_backend")
@@ -424,6 +446,136 @@ def test_release_swallows_close_errors(tmp_path, caplog):
assert "sandbox-rel-err" in provider._warm_pool
def test_get_uses_in_memory_registry_only(tmp_path):
"""get() must stay event-loop safe by avoiding backend health checks."""
provider, sandbox, _ = _make_provider_with_active_sandbox(tmp_path, "sandbox-dead")
provider._backend.is_alive = MagicMock(side_effect=AssertionError("get must not call backend health checks"))
assert provider.get("sandbox-dead") is sandbox
def test_acquire_drops_dead_cached_sandbox(tmp_path, monkeypatch):
"""acquire() must replace a stale active cache entry after its container dies."""
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
provider, sandbox, _ = _make_provider_with_active_sandbox(tmp_path, "sandbox-dead")
provider._thread_locks = {}
provider._thread_sandboxes = {"thread-dead": "sandbox-dead"}
provider._config = {"replicas": 3}
provider._backend.is_alive = MagicMock(return_value=False)
provider._backend.discover = MagicMock(return_value=None)
provider._backend.create = MagicMock(
return_value=aio_mod.SandboxInfo(
sandbox_id="sandbox-dead",
sandbox_url="http://fresh-sandbox",
container_name="deer-flow-sandbox-sandbox-dead",
)
)
monkeypatch.setattr(aio_mod.AioSandboxProvider, "_sandbox_id_for_thread", lambda _self, _thread_id: "sandbox-dead")
monkeypatch.setattr(aio_mod.AioSandboxProvider, "_get_extra_mounts", lambda _self, _thread_id: [])
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr(aio_mod, "get_effective_user_id", lambda: None)
monkeypatch.setattr(aio_mod, "wait_for_sandbox_ready", lambda _url, timeout=60: True)
sandbox_id = provider.acquire("thread-dead")
assert sandbox_id == "sandbox-dead"
sandbox.close.assert_called_once_with()
provider._backend.destroy.assert_called_once()
provider._backend.create.assert_called_once()
assert provider._thread_sandboxes["thread-dead"] == "sandbox-dead"
assert provider._sandboxes["sandbox-dead"].base_url == "http://fresh-sandbox"
def test_acquire_keeps_cached_sandbox_when_health_check_errors(tmp_path):
"""Transient backend health-check errors must not destroy a tracked sandbox."""
provider, sandbox, _ = _make_provider_with_active_sandbox(tmp_path, "sandbox-transient")
provider._thread_locks = {}
provider._thread_sandboxes = {"thread-transient": "sandbox-transient"}
provider._backend.is_alive = MagicMock(side_effect=OSError("docker daemon busy"))
sandbox_id = provider.acquire("thread-transient")
assert sandbox_id == "sandbox-transient"
sandbox.close.assert_not_called()
provider._backend.destroy.assert_not_called()
assert provider._sandboxes["sandbox-transient"] is sandbox
def test_drop_unhealthy_sandbox_skips_recreated_entry(tmp_path):
"""A stale health-check result must not delete a newly registered sandbox."""
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
provider = _make_provider(tmp_path)
provider._lock = aio_mod.threading.Lock()
provider._warm_pool = {}
provider._last_activity = {"sandbox-toctou": 1.0}
provider._thread_sandboxes = {"thread-toctou": "sandbox-toctou"}
old_info = aio_mod.SandboxInfo(sandbox_id="sandbox-toctou", sandbox_url="http://old-sandbox")
new_info = aio_mod.SandboxInfo(sandbox_id="sandbox-toctou", sandbox_url="http://new-sandbox")
new_sandbox = MagicMock()
provider._sandbox_infos = {"sandbox-toctou": new_info}
provider._sandboxes = {"sandbox-toctou": new_sandbox}
provider._backend = SimpleNamespace(destroy=MagicMock())
provider._drop_unhealthy_sandbox("sandbox-toctou", "stale health check", expected_info=old_info)
new_sandbox.close.assert_not_called()
provider._backend.destroy.assert_not_called()
assert provider._sandbox_infos["sandbox-toctou"] is new_info
assert provider._sandboxes["sandbox-toctou"] is new_sandbox
assert provider._thread_sandboxes == {"thread-toctou": "sandbox-toctou"}
def test_acquire_skips_dead_warm_pool_sandbox(tmp_path, monkeypatch):
"""acquire() must create a fresh sandbox when the warm-pool entry died."""
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
provider = _make_provider(tmp_path)
provider._lock = aio_mod.threading.Lock()
provider._thread_locks = {}
provider._sandboxes = {}
provider._sandbox_infos = {}
provider._thread_sandboxes = {}
provider._last_activity = {}
provider._warm_pool = {
"sandbox-warm-dead": (
aio_mod.SandboxInfo(
sandbox_id="sandbox-warm-dead",
sandbox_url="http://stale-sandbox",
container_name="deer-flow-sandbox-sandbox-warm-dead",
),
0.0,
)
}
provider._config = {"replicas": 3}
provider._backend = SimpleNamespace(
is_alive=MagicMock(return_value=False),
destroy=MagicMock(),
discover=MagicMock(return_value=None),
create=MagicMock(
return_value=aio_mod.SandboxInfo(
sandbox_id="sandbox-warm-dead",
sandbox_url="http://fresh-sandbox",
container_name="deer-flow-sandbox-sandbox-warm-dead",
)
),
)
monkeypatch.setattr(aio_mod.AioSandboxProvider, "_sandbox_id_for_thread", lambda _self, _thread_id: "sandbox-warm-dead")
monkeypatch.setattr(aio_mod.AioSandboxProvider, "_get_extra_mounts", lambda _self, _thread_id: [])
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
monkeypatch.setattr(aio_mod, "get_effective_user_id", lambda: None)
monkeypatch.setattr(aio_mod, "wait_for_sandbox_ready", lambda _url, timeout=60: True)
sandbox_id = provider.acquire("thread-warm-dead")
assert sandbox_id == "sandbox-warm-dead"
provider._backend.destroy.assert_called_once()
provider._backend.create.assert_called_once()
assert provider._warm_pool == {}
assert provider._thread_sandboxes["thread-warm-dead"] == "sandbox-warm-dead"
assert provider._sandboxes["sandbox-warm-dead"].base_url == "http://fresh-sandbox"
def test_destroy_swallows_close_errors_and_still_destroys_backend(tmp_path, caplog):
"""A failure in sandbox.close() must not skip backend container destruction."""
provider, sandbox, _ = _make_provider_with_active_sandbox(tmp_path, "sandbox-dest-err")
+9 -12
View File
@@ -39,8 +39,6 @@ def test_public_paths(path: str):
"/api/threads/123/uploads",
"/api/agents",
"/api/channels",
"/api/channels/providers",
"/api/channels/slack/connect",
"/api/runs/stream",
"/api/threads/123/runs",
"/api/v1/auth/me",
@@ -185,7 +183,7 @@ def _make_auth_csrf_app():
@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "")
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
return TestClient(_make_app())
@@ -223,7 +221,7 @@ def test_auth_disabled_allows_protected_path_without_cookie(monkeypatch):
assert res.json() == {"models": []}
def test_auth_disabled_stamps_default_admin_user_without_cookie(monkeypatch):
def test_auth_disabled_stamps_e2e_admin_user_without_cookie(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
client = TestClient(_make_app())
@@ -231,10 +229,10 @@ def test_auth_disabled_stamps_default_admin_user_without_cookie(monkeypatch):
assert res.status_code == 200
assert res.json() == {
"id": "default",
"email": "default@test.local",
"id": "e2e-user",
"email": "e2e@test.local",
"system_role": "admin",
"context_user_id": "default",
"context_user_id": "e2e-user",
}
@@ -246,8 +244,8 @@ def test_auth_disabled_auth_me_reuses_middleware_user_without_cookie(monkeypatch
assert res.status_code == 200
assert res.json() == {
"id": "default",
"email": "default@test.local",
"id": "e2e-user",
"email": "e2e@test.local",
"system_role": "admin",
"needs_setup": False,
}
@@ -331,7 +329,7 @@ def test_auth_disabled_startup_warning_when_effective(monkeypatch, caplog):
warn_if_auth_disabled_enabled()
assert "authentication is bypassed" in caplog.text
assert "default" in caplog.text
assert "e2e-user" in caplog.text
def test_auth_disabled_startup_warning_suppressed_in_explicit_production_env(monkeypatch, caplog):
@@ -350,8 +348,7 @@ def test_protected_path_with_junk_cookie_rejected(client):
"""Junk cookie → 401. Middleware strictly validates the JWT now
(AUTH_TEST_PLAN test 7.5.8); it no longer silently passes bad
tokens through to the route handler."""
client.cookies.set("access_token", "some-token")
res = client.get("/api/models")
res = client.get("/api/models", cookies={"access_token": "some-token"})
assert res.status_code == 401
@@ -1,56 +0,0 @@
"""Tests for user-facing IM channel connection configuration."""
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
def test_channel_connections_disabled_by_default():
config = ChannelConnectionsConfig()
assert config.enabled is False
assert config.slack.enabled is False
assert config.telegram.enabled is False
assert config.discord.enabled is False
assert config.feishu.enabled is False
assert config.dingtalk.enabled is False
assert config.wechat.enabled is False
assert config.wecom.enabled is False
def test_enabled_channel_connections_do_not_require_public_url_or_encryption_key():
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"telegram": {
"enabled": True,
"bot_username": "deerflow_bot",
},
"slack": {"enabled": True},
"discord": {"enabled": True},
"feishu": {"enabled": True},
"dingtalk": {"enabled": True},
"wechat": {"enabled": True},
"wecom": {"enabled": True},
}
)
assert config.enabled is True
assert config.provider_status("telegram") == {"enabled": True, "configured": True}
assert config.provider_status("slack") == {"enabled": True, "configured": True}
assert config.provider_status("discord") == {"enabled": True, "configured": True}
assert config.provider_status("feishu") == {"enabled": True, "configured": True}
assert config.provider_status("dingtalk") == {"enabled": True, "configured": True}
assert config.provider_status("wechat") == {"enabled": True, "configured": True}
assert config.provider_status("wecom") == {"enabled": True, "configured": True}
def test_provider_status_reports_disabled_and_unknown_providers():
config = ChannelConnectionsConfig.model_validate({"enabled": True})
assert config.provider_status("slack") == {"enabled": False, "configured": False}
assert config.provider_status("telegram") == {"enabled": False, "configured": False}
assert config.provider_status("discord") == {"enabled": False, "configured": False}
assert config.provider_status("feishu") == {"enabled": False, "configured": False}
assert config.provider_status("dingtalk") == {"enabled": False, "configured": False}
assert config.provider_status("wechat") == {"enabled": False, "configured": False}
assert config.provider_status("wecom") == {"enabled": False, "configured": False}
assert config.provider_status("unknown") == {"enabled": False, "configured": False}
@@ -1,226 +0,0 @@
"""Tests for per-user IM channel connection persistence."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import select
from deerflow.persistence.channel_connections import (
ChannelConnectionRepository,
ChannelConnectionRow,
ChannelCredentialCipher,
ChannelCredentialRow,
ChannelOAuthStateRow,
)
@pytest.fixture
async def repo(tmp_path):
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
url = f"sqlite+aiosqlite:///{tmp_path / 'channels.db'}"
await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))
try:
yield ChannelConnectionRepository(
get_session_factory(),
cipher=ChannelCredentialCipher.from_key("test-encryption-key"),
)
finally:
await close_engine()
class TestChannelConnectionRepository:
@pytest.mark.anyio
async def test_connections_are_listed_per_owner(self, repo):
alice = await repo.upsert_connection(
owner_user_id="alice",
provider="slack",
external_account_id="U-alice",
external_account_name="Alice",
workspace_id="T1",
workspace_name="Team One",
scopes=["chat:write"],
)
await repo.upsert_connection(
owner_user_id="bob",
provider="slack",
external_account_id="U-bob",
external_account_name="Bob",
workspace_id="T1",
workspace_name="Team One",
scopes=["chat:write"],
)
results = await repo.list_connections("alice")
assert [item["id"] for item in results] == [alice["id"]]
assert results[0]["owner_user_id"] == "alice"
assert results[0]["provider"] == "slack"
assert results[0]["scopes"] == ["chat:write"]
assert "encrypted_access_token" not in results[0]
@pytest.mark.anyio
async def test_upsert_connection_updates_existing_provider_identity(self, repo):
first = await repo.upsert_connection(
owner_user_id="alice",
provider="telegram",
external_account_id="42",
external_account_name="Alice",
workspace_id=None,
workspace_name=None,
status="pending",
)
second = await repo.upsert_connection(
owner_user_id="alice",
provider="telegram",
external_account_id="42",
external_account_name="Alice Telegram",
workspace_id=None,
workspace_name=None,
status="connected",
)
assert second["id"] == first["id"]
assert second["status"] == "connected"
assert second["external_account_name"] == "Alice Telegram"
assert len(await repo.list_connections("alice")) == 1
@pytest.mark.anyio
async def test_credentials_are_encrypted_at_rest_and_decrypted_by_repository(self, repo):
connection = await repo.upsert_connection(
owner_user_id="alice",
provider="slack",
external_account_id="U-alice",
workspace_id="T1",
)
expires_at = datetime.now(UTC) + timedelta(hours=1)
await repo.store_credentials(
connection["id"],
access_token="xoxb-secret-access-token",
refresh_token="secret-refresh-token",
token_type="Bearer",
expires_at=expires_at,
extra={"bot_user_id": "B123"},
)
async with repo.session_factory() as session:
row = (await session.execute(select(ChannelCredentialRow))).scalar_one()
assert row.encrypted_access_token is not None
assert "xoxb-secret-access-token" not in row.encrypted_access_token
assert "secret-refresh-token" not in (row.encrypted_refresh_token or "")
assert "B123" not in (row.encrypted_extra_json or "")
credentials = await repo.get_credentials(connection["id"])
assert credentials is not None
assert credentials["access_token"] == "xoxb-secret-access-token"
assert credentials["refresh_token"] == "secret-refresh-token"
assert credentials["token_type"] == "Bearer"
assert credentials["expires_at"] == expires_at
assert credentials["extra"] == {"bot_user_id": "B123"}
@pytest.mark.anyio
async def test_conversations_are_scoped_by_connection(self, repo):
alice = await repo.upsert_connection(
owner_user_id="alice",
provider="slack",
external_account_id="U-alice",
workspace_id="T1",
)
bob = await repo.upsert_connection(
owner_user_id="bob",
provider="slack",
external_account_id="U-bob",
workspace_id="T1",
)
await repo.set_thread_id(
connection_id=alice["id"],
owner_user_id="alice",
provider="slack",
external_conversation_id="C-shared",
external_topic_id="1710000000.000100",
thread_id="thread-alice",
)
await repo.set_thread_id(
connection_id=bob["id"],
owner_user_id="bob",
provider="slack",
external_conversation_id="C-shared",
external_topic_id="1710000000.000100",
thread_id="thread-bob",
)
assert await repo.get_thread_id(alice["id"], "C-shared", "1710000000.000100") == "thread-alice"
assert await repo.get_thread_id(bob["id"], "C-shared", "1710000000.000100") == "thread-bob"
@pytest.mark.anyio
async def test_disconnect_connection_revokes_owner_connection_and_removes_credentials(self, repo):
connection = await repo.upsert_connection(
owner_user_id="alice",
provider="telegram",
external_account_id="42",
)
await repo.store_credentials(connection["id"], access_token="secret-token")
disconnected = await repo.disconnect_connection(
connection_id=connection["id"],
owner_user_id="alice",
)
assert disconnected is True
async with repo.session_factory() as session:
connection_row = await session.get(ChannelConnectionRow, connection["id"])
credential_row = await session.get(ChannelCredentialRow, connection["id"])
assert connection_row is not None
assert connection_row.status == "revoked"
assert credential_row is None
assert (
await repo.find_connection_by_external_identity(
provider="telegram",
external_account_id="42",
)
is None
)
@pytest.mark.anyio
async def test_disconnect_connection_is_owner_scoped(self, repo):
connection = await repo.upsert_connection(
owner_user_id="alice",
provider="telegram",
external_account_id="42",
)
disconnected = await repo.disconnect_connection(
connection_id=connection["id"],
owner_user_id="bob",
)
assert disconnected is False
assert (await repo.list_connections("alice"))[0]["status"] == "connected"
@pytest.mark.anyio
async def test_consume_oauth_state_deletes_expired_states(self, repo):
now = datetime.now(UTC)
await repo.create_oauth_state(
owner_user_id="alice",
provider="slack",
state="expired-state",
expires_at=now - timedelta(minutes=1),
)
await repo.create_oauth_state(
owner_user_id="alice",
provider="slack",
state="active-state",
expires_at=now + timedelta(minutes=5),
)
consumed = await repo.consume_oauth_state(provider="slack", state="expired-state", now=now)
assert consumed is None
async with repo.session_factory() as session:
states = (await session.execute(select(ChannelOAuthStateRow))).scalars().all()
assert [state.state_hash for state in states] == [repo.hash_state("active-state")]
@@ -1,707 +0,0 @@
"""Router tests for browser-connectable IM channels."""
from __future__ import annotations
from types import SimpleNamespace
from uuid import UUID
from _router_auth_helpers import make_authed_test_app
from fastapi.testclient import TestClient
from app.channels.runtime_config_store import ChannelRuntimeConfigStore
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):
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import get_session_factory, init_engine
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'router.db'}", sqlite_dir=str(tmp_path))
return ChannelConnectionRepository(get_session_factory())
def _make_app(
config: ChannelConnectionsConfig,
repo,
channels_config: dict | None = None,
*,
runtime_config_store: ChannelRuntimeConfigStore | None = None,
set_channels_config_state: bool = True,
):
app = make_authed_test_app(user_factory=_user)
app.state.channel_connections_config = config
app.state.channel_connection_repo = repo
if set_channels_config_state:
app.state.channels_config = channels_config or {}
if runtime_config_store is not None:
app.state.channel_runtime_config_store = runtime_config_store
app.include_router(channel_connections.router)
return app
def _enabled_connections_config() -> ChannelConnectionsConfig:
return ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"telegram": {"enabled": True, "bot_username": "deerflow_bot"},
"slack": {"enabled": True},
"discord": {"enabled": True},
"feishu": {"enabled": True},
"dingtalk": {"enabled": True},
"wechat": {"enabled": True},
"wecom": {"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"},
"feishu": {"enabled": True, "app_id": "feishu-app", "app_secret": "feishu-secret"},
"dingtalk": {"enabled": True, "client_id": "dingtalk-client", "client_secret": "dingtalk-secret"},
"wechat": {"enabled": True, "bot_token": "wechat-token"},
"wecom": {"enabled": True, "bot_id": "wecom-bot", "bot_secret": "wecom-secret"},
}
def test_get_providers_only_returns_enabled_channels_and_setup_fields(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
"discord": {"enabled": False},
}
)
app = _make_app(config, repo, {})
with TestClient(app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
body = response.json()
assert body["enabled"] is True
assert [provider["provider"] for provider in body["providers"]] == ["slack"]
assert body["providers"][0]["configured"] is False
assert body["providers"][0]["connectable"] is False
assert body["providers"][0]["credential_fields"] == [
{
"name": "bot_token",
"label": "Bot token",
"type": "password",
"required": True,
},
{
"name": "app_token",
"label": "App token",
"type": "password",
"required": True,
},
]
anyio.run(repo.close)
def test_get_providers_uses_existing_channels_config(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.get("/api/channels/providers")
assert response.status_code == 200
body = response.json()
assert body["enabled"] is True
by_provider = {item["provider"]: item for item in body["providers"]}
assert set(by_provider) == {"telegram", "slack", "discord", "feishu", "dingtalk", "wechat", "wecom"}
assert by_provider["telegram"]["configured"] is True
assert by_provider["telegram"]["auth_mode"] == "deep_link"
assert by_provider["telegram"]["credential_values"] == {
"bot_token": "********",
"bot_username": "deerflow_bot",
}
assert by_provider["slack"]["configured"] is True
assert by_provider["slack"]["auth_mode"] == "binding_code"
assert by_provider["slack"]["connection_status"] == "connected"
assert by_provider["slack"]["credential_values"] == {
"bot_token": "********",
"app_token": "********",
}
assert by_provider["discord"]["configured"] is True
assert by_provider["discord"]["auth_mode"] == "binding_code"
assert by_provider["discord"]["credential_values"] == {"bot_token": "********"}
assert by_provider["feishu"]["configured"] is True
assert by_provider["feishu"]["auth_mode"] == "binding_code"
assert by_provider["feishu"]["connection_status"] == "connected"
assert by_provider["feishu"]["credential_values"] == {
"app_id": "feishu-app",
"app_secret": "********",
}
assert by_provider["dingtalk"]["configured"] is True
assert by_provider["dingtalk"]["auth_mode"] == "binding_code"
assert by_provider["dingtalk"]["credential_values"] == {
"client_id": "dingtalk-client",
"client_secret": "********",
}
assert by_provider["wechat"]["configured"] is True
assert by_provider["wechat"]["auth_mode"] == "binding_code"
assert by_provider["wechat"]["credential_values"] == {"bot_token": "********"}
assert by_provider["wecom"]["configured"] is True
assert by_provider["wecom"]["auth_mode"] == "binding_code"
assert by_provider["wecom"]["credential_values"] == {
"bot_id": "wecom-bot",
"bot_secret": "********",
}
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 "Slack credentials" in by_provider["slack"]["unavailable_reason"]
assert by_provider["discord"]["configured"] is False
assert "Discord credentials" in by_provider["discord"]["unavailable_reason"]
assert by_provider["feishu"]["configured"] is False
assert "Feishu credentials" in by_provider["feishu"]["unavailable_reason"]
assert by_provider["dingtalk"]["configured"] is False
assert "DingTalk credentials" in by_provider["dingtalk"]["unavailable_reason"]
assert by_provider["wechat"]["configured"] is False
assert "WeChat credentials" in by_provider["wechat"]["unavailable_reason"]
assert by_provider["wecom"]["configured"] is False
assert "WeCom credentials" in by_provider["wecom"]["unavailable_reason"]
anyio.run(repo.close)
def test_get_providers_reports_configured_channel_not_running(tmp_path, monkeypatch):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
service = SimpleNamespace(
get_status=lambda: {
"service_running": True,
"channels": {
"feishu": {
"enabled": True,
"running": False,
}
},
}
)
monkeypatch.setattr("app.channels.service.get_channel_service", lambda: service)
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["feishu"]["configured"] is True
assert by_provider["feishu"]["connectable"] is False
assert by_provider["feishu"]["connection_status"] == "not_connected"
assert "configured but is not running" in by_provider["feishu"]["unavailable_reason"]
anyio.run(repo.close)
def test_get_providers_uses_newest_connection_status_per_provider(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
async def seed_connections():
await repo.upsert_connection(
owner_user_id=str(_user().id),
provider="slack",
external_account_id="U-old",
workspace_id="T-old",
status="revoked",
)
await anyio.sleep(0.01)
await repo.upsert_connection(
owner_user_id=str(_user().id),
provider="slack",
external_account_id="U-new",
workspace_id="T-new",
status="connected",
)
anyio.run(seed_connections)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
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["slack"]["connection_status"] == "connected"
anyio.run(repo.close)
def test_get_connections_returns_current_user_connections_only(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
async def seed_connections():
await repo.upsert_connection(
owner_user_id=str(_user().id),
provider="telegram",
external_account_id="42",
external_account_name="Alice",
status="connected",
)
await repo.upsert_connection(
owner_user_id="other-user",
provider="telegram",
external_account_id="99",
external_account_name="Bob",
status="connected",
)
anyio.run(seed_connections)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.get("/api/channels/connections")
assert response.status_code == 200
body = response.json()
assert len(body["connections"]) == 1
assert body["connections"][0]["provider"] == "telegram"
assert body["connections"][0]["external_account_id"] == "42"
anyio.run(repo.close)
def test_connect_telegram_returns_deep_link_and_persists_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.post("/api/channels/telegram/connect")
assert response.status_code == 200
body = response.json()
assert body["provider"] == "telegram"
assert body["mode"] == "deep_link"
assert body["url"].startswith("https://t.me/deerflow_bot?start=")
assert body["code"]
assert "/start" in body["instruction"]
async def count_states():
return await repo.count_oauth_states(owner_user_id=str(_user().id), provider="telegram")
assert anyio.run(count_states) == 1
anyio.run(repo.close)
def test_connect_slack_returns_binding_command_and_persists_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.post("/api/channels/slack/connect")
assert response.status_code == 200
body = response.json()
assert body["provider"] == "slack"
assert body["mode"] == "binding_code"
assert body["url"] is None
assert len(body["code"]) >= 22
assert body["instruction"] == f"Send /connect {body['code']} to the DeerFlow Slack bot."
async def count_states():
return await repo.count_oauth_states(owner_user_id=str(_user().id), provider="slack")
assert anyio.run(count_states) == 1
anyio.run(repo.close)
def test_connect_discord_returns_binding_command_and_persists_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.post("/api/channels/discord/connect")
assert response.status_code == 200
body = response.json()
assert body["provider"] == "discord"
assert body["mode"] == "binding_code"
assert body["url"] is None
assert body["code"]
assert body["instruction"] == f"Send /connect {body['code']} to the DeerFlow Discord bot."
async def count_states():
return await repo.count_oauth_states(owner_user_id=str(_user().id), provider="discord")
assert anyio.run(count_states) == 1
anyio.run(repo.close)
def test_connect_existing_binding_code_channels_return_command_and_persist_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
providers = ["feishu", "dingtalk", "wechat", "wecom"]
with TestClient(app) as client:
responses = {provider: client.post(f"/api/channels/{provider}/connect") for provider in providers}
for provider, response in responses.items():
expected_display_name = {
"feishu": "Feishu",
"dingtalk": "DingTalk",
"wechat": "WeChat",
"wecom": "WeCom",
}[provider]
assert response.status_code == 200
body = response.json()
assert body["provider"] == provider
assert body["mode"] == "binding_code"
assert body["url"] is None
assert len(body["code"]) >= 22
assert body["instruction"] == f"Send /connect {body['code']} to the DeerFlow {expected_display_name} bot."
async def count_states(provider=provider):
return await repo.count_oauth_states(owner_user_id=str(_user().id), provider=provider)
assert anyio.run(count_states) == 1
anyio.run(repo.close)
def test_connect_unconfigured_runtime_channel_returns_400(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
app = _make_app(_enabled_connections_config(), repo, {})
with TestClient(app) as client:
response = client.post("/api/channels/slack/connect")
assert response.status_code == 400
assert "Slack credentials" in response.json()["detail"]
anyio.run(repo.close)
def test_configure_provider_runtime_credentials_enables_connect_without_file_edits(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
}
)
app = _make_app(config, repo, {})
with TestClient(app) as client:
configure_response = client.post(
"/api/channels/slack/runtime-config",
json={"values": {"bot_token": "xoxb-ui", "app_token": "xapp-ui"}},
)
connect_response = client.post("/api/channels/slack/connect")
assert configure_response.status_code == 200
configured = configure_response.json()
assert configured["provider"] == "slack"
assert configured["configured"] is True
assert configured["connectable"] is True
assert configured["connection_status"] == "connected"
assert app.state.channels_config["slack"] == {
"enabled": True,
"bot_token": "xoxb-ui",
"app_token": "xapp-ui",
}
assert connect_response.status_code == 200
assert connect_response.json()["provider"] == "slack"
anyio.run(repo.close)
def test_configure_provider_runtime_credentials_survive_local_restart(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
}
)
runtime_config_path = tmp_path / "channels" / "runtime-config.json"
first_app = _make_app(
config,
repo,
{},
runtime_config_store=ChannelRuntimeConfigStore(runtime_config_path),
)
with TestClient(first_app) as client:
configure_response = client.post(
"/api/channels/slack/runtime-config",
json={"values": {"bot_token": "xoxb-ui", "app_token": "xapp-ui"}},
)
assert configure_response.status_code == 200
restarted_app = _make_app(
config,
repo,
runtime_config_store=ChannelRuntimeConfigStore(runtime_config_path),
set_channels_config_state=False,
)
with TestClient(restarted_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["slack"]["configured"] is True
assert by_provider["slack"]["connectable"] is True
assert by_provider["slack"]["connection_status"] == "connected"
assert restarted_app.state.channels_config["slack"] == {
"enabled": True,
"bot_token": "xoxb-ui",
"app_token": "xapp-ui",
}
anyio.run(repo.close)
def test_configure_provider_runtime_credentials_preserves_masked_secrets(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"feishu": {"enabled": True},
}
)
runtime_config_store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json")
app = _make_app(
config,
repo,
{
"feishu": {
"enabled": True,
"app_id": "old-app-id",
"app_secret": "old-secret",
}
},
runtime_config_store=runtime_config_store,
)
with TestClient(app) as client:
configure_response = client.post(
"/api/channels/feishu/runtime-config",
json={
"values": {
"app_id": "new-app-id",
"app_secret": "********",
}
},
)
providers_response = client.get("/api/channels/providers")
assert configure_response.status_code == 200
assert app.state.channels_config["feishu"] == {
"enabled": True,
"app_id": "new-app-id",
"app_secret": "old-secret",
}
assert runtime_config_store.get_provider_config("feishu") == {
"enabled": True,
"app_id": "new-app-id",
"app_secret": "old-secret",
}
by_provider = {item["provider"]: item for item in providers_response.json()["providers"]}
assert by_provider["feishu"]["credential_values"] == {
"app_id": "new-app-id",
"app_secret": "********",
}
anyio.run(repo.close)
def test_disconnect_provider_runtime_config_clears_connected_state(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
}
)
runtime_config_store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json")
app = _make_app(config, repo, {}, runtime_config_store=runtime_config_store)
with TestClient(app) as client:
configure_response = client.post(
"/api/channels/slack/runtime-config",
json={"values": {"bot_token": "xoxb-ui", "app_token": "xapp-ui"}},
)
disconnect_response = client.delete("/api/channels/slack/runtime-config")
providers_response = client.get("/api/channels/providers")
assert configure_response.status_code == 200
assert disconnect_response.status_code == 200
disconnected = disconnect_response.json()
assert disconnected["provider"] == "slack"
assert disconnected["configured"] is False
assert disconnected["connectable"] is False
assert disconnected["connection_status"] == "not_connected"
assert runtime_config_store.get_provider_config("slack") is None
assert providers_response.status_code == 200
by_provider = {item["provider"]: item for item in providers_response.json()["providers"]}
assert by_provider["slack"]["connection_status"] == "not_connected"
anyio.run(repo.close)
def test_disconnect_provider_runtime_config_revokes_current_user_provider_connections(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
async def seed_connection():
await repo.upsert_connection(
owner_user_id=str(_user().id),
provider="slack",
external_account_id="U123",
status="connected",
)
anyio.run(seed_connection)
config = ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
}
)
runtime_config_store = ChannelRuntimeConfigStore(tmp_path / "channels" / "runtime-config.json")
app = _make_app(config, repo, {}, runtime_config_store=runtime_config_store)
with TestClient(app) as client:
configure_response = client.post(
"/api/channels/slack/runtime-config",
json={"values": {"bot_token": "xoxb-ui", "app_token": "xapp-ui"}},
)
disconnect_response = client.delete("/api/channels/slack/runtime-config")
assert configure_response.status_code == 200
assert disconnect_response.status_code == 200
async def get_connection_status():
return (await repo.list_connections(str(_user().id)))[0]["status"]
assert anyio.run(get_connection_status) == "revoked"
anyio.run(repo.close)
def test_disconnect_connection_revokes_current_user_connection(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
async def seed_connection():
connection = await repo.upsert_connection(
owner_user_id=str(_user().id),
provider="telegram",
external_account_id="42",
status="connected",
)
return connection["id"]
connection_id = anyio.run(seed_connection)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.delete(f"/api/channels/connections/{connection_id}")
assert response.status_code == 204
async def get_connection_status():
return (await repo.list_connections(str(_user().id)))[0]["status"]
assert anyio.run(get_connection_status) == "revoked"
anyio.run(repo.close)
def test_disconnect_connection_is_current_user_scoped(tmp_path):
import anyio
repo = anyio.run(_make_repo, tmp_path)
async def seed_connection():
connection = await repo.upsert_connection(
owner_user_id="other-user",
provider="telegram",
external_account_id="42",
status="connected",
)
return connection["id"]
connection_id = anyio.run(seed_connection)
app = _make_app(_enabled_connections_config(), repo, _channels_config())
with TestClient(app) as client:
response = client.delete(f"/api/channels/connections/{connection_id}")
assert response.status_code == 404
async def get_connection_status():
return (await repo.list_connections("other-user"))[0]["status"]
assert anyio.run(get_connection_status) == "connected"
anyio.run(repo.close)
+7 -347
View File
@@ -487,7 +487,6 @@ def _make_mock_langgraph_client(thread_id="test-thread-123", run_result=None):
# threads.create() returns a Thread-like dict
mock_client.threads.create = AsyncMock(return_value={"thread_id": thread_id})
mock_client.threads.update = AsyncMock(return_value={"thread_id": thread_id})
# threads.get() returns thread info (succeeds by default)
mock_client.threads.get = AsyncMock(return_value={"thread_id": thread_id})
@@ -505,17 +504,6 @@ def _make_mock_langgraph_client(thread_id="test-thread-123", run_result=None):
return mock_client
async def _make_channel_connection_repo(tmp_path: Path):
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
from deerflow.persistence.engine import get_session_factory, init_engine
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'channel-connections.db'}", sqlite_dir=str(tmp_path))
return ChannelConnectionRepository(
get_session_factory(),
cipher=ChannelCredentialCipher.from_key("test-channel-key"),
)
def _make_stream_part(event: str, data):
return SimpleNamespace(event=event, data=data)
@@ -668,34 +656,16 @@ class TestChannelManager:
await manager.start()
inbound = InboundMessage(
channel_name="test",
chat_id="chat1",
user_id="user1",
text="hi",
topic_id="topic1",
thread_ts="msg1",
connection_id="conn1",
)
inbound = InboundMessage(channel_name="test", chat_id="chat1", user_id="user1", text="hi")
await bus.publish_inbound(inbound)
await _wait_for(lambda: len(outbound_received) >= 1)
await manager.stop()
# Thread should be created through Gateway
mock_client.threads.create.assert_called_once()
assert mock_client.threads.create.call_args.kwargs["metadata"] == {
"channel_source": {
"type": "im_channel",
"provider": "test",
"chat_id": "chat1",
"topic_id": "topic1",
"thread_ts": "msg1",
"connection_id": "conn1",
}
}
# Thread ID should be stored
thread_id = store.get_thread_id("test", "chat1", topic_id="topic1")
thread_id = store.get_thread_id("test", "chat1")
assert thread_id == "test-thread-123"
# runs.wait should be called with the thread_id
@@ -913,12 +883,10 @@ class TestChannelManager:
_run(go())
def test_clarification_follow_up_preserves_history(self, monkeypatch):
def test_clarification_follow_up_preserves_history(self):
"""Conversation should continue after ask_clarification instead of resetting history."""
from app.channels.manager import ChannelManager
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
@@ -1986,12 +1954,10 @@ class TestChannelManager:
_run(go())
def test_same_topic_reuses_thread(self, monkeypatch):
def test_same_topic_reuses_thread(self):
"""Messages with the same topic_id should reuse the same DeerFlow thread."""
from app.channels.manager import ChannelManager
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
async def go():
bus = MessageBus()
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
@@ -2024,17 +1990,6 @@ class TestChannelManager:
# threads.create should be called only ONCE (second message reuses the thread)
mock_client.threads.create.assert_called_once()
mock_client.threads.update.assert_called_once_with(
"topic-thread-1",
metadata={
"channel_source": {
"type": "im_channel",
"provider": "test",
"chat_id": "chat1",
"topic_id": "topic-root-123",
}
},
)
# Both runs.wait calls should use the same thread_id
assert mock_client.runs.wait.call_count == 2
@@ -2370,9 +2325,8 @@ class TestResolveRunParamsUserId:
store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json")
return ChannelManager(bus=bus, store=store)
def test_safe_user_id_is_passed_through(self, monkeypatch):
def test_safe_user_id_is_passed_through(self):
manager = self._manager()
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
msg = InboundMessage(channel_name="telegram", chat_id="c", user_id="123456", text="hi")
_, _, run_context = manager._resolve_run_params(msg, "thread-1")
@@ -2380,78 +2334,10 @@ class TestResolveRunParamsUserId:
assert run_context["user_id"] == "123456"
assert run_context["channel_user_id"] == "123456"
def test_connection_owner_user_id_takes_precedence_over_platform_user_id(self, monkeypatch):
manager = self._manager()
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
msg = InboundMessage(
channel_name="slack",
chat_id="C123",
user_id="U-platform",
owner_user_id="deerflow-user-1",
connection_id="connection-1",
text="hi",
)
_, _, run_context = manager._resolve_run_params(msg, "thread-1")
assert run_context["user_id"] == "deerflow-user-1"
assert run_context["channel_user_id"] == "U-platform"
def test_auth_disabled_user_id_is_used_for_unbound_channel_messages(self, monkeypatch):
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME
manager = self._manager()
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
msg = InboundMessage(channel_name="slack", chat_id="C123", user_id="U-platform", text="hi")
_, _, run_context = manager._resolve_run_params(msg, "thread-1")
assert run_context["user_id"] == AUTH_DISABLED_USER_ID
assert run_context["channel_user_id"] == "U-platform"
from app.channels.manager import _owner_headers
headers = _owner_headers(msg)
assert headers is not None
assert headers[INTERNAL_OWNER_USER_ID_HEADER_NAME] == AUTH_DISABLED_USER_ID
def test_auth_disabled_user_id_overrides_bound_owner_for_local_visibility(self, monkeypatch):
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID
manager = self._manager()
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
msg = InboundMessage(
channel_name="slack",
chat_id="C123",
user_id="U-platform",
owner_user_id="real-user-from-old-binding",
text="hi",
)
_, _, run_context = manager._resolve_run_params(msg, "thread-1")
assert run_context["user_id"] == AUTH_DISABLED_USER_ID
assert run_context["channel_user_id"] == "U-platform"
def test_unbound_channel_messages_keep_platform_user_id_when_auth_is_enabled(self, monkeypatch):
from app.channels.manager import _owner_headers
manager = self._manager()
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
msg = InboundMessage(channel_name="slack", chat_id="C123", user_id="U-platform", text="hi")
_, _, run_context = manager._resolve_run_params(msg, "thread-1")
assert run_context["user_id"] == "U-platform"
assert run_context["channel_user_id"] == "U-platform"
assert _owner_headers(msg) is None
def test_unsafe_user_id_is_normalized_but_raw_preserved(self, monkeypatch):
def test_unsafe_user_id_is_normalized_but_raw_preserved(self):
from deerflow.config.paths import make_safe_user_id
manager = self._manager()
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
raw = "user@example.com"
msg = InboundMessage(channel_name="feishu", chat_id="c", user_id=raw, text="hi")
@@ -2462,9 +2348,8 @@ class TestResolveRunParamsUserId:
assert run_context["channel_user_id"] == raw
@pytest.mark.parametrize("raw_user_id", ["", None])
def test_empty_or_none_user_id_is_not_injected(self, raw_user_id, monkeypatch):
def test_empty_or_none_user_id_is_not_injected(self, raw_user_id):
manager = self._manager()
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
msg = InboundMessage(channel_name="feishu", chat_id="c", user_id=raw_user_id, text="hi")
_, _, run_context = manager._resolve_run_params(msg, "thread-1")
@@ -2473,93 +2358,6 @@ class TestResolveRunParamsUserId:
assert "channel_user_id" not in run_context
class TestChannelManagerConnectionRouting:
def test_connection_scoped_conversations_do_not_share_threads(self, tmp_path, monkeypatch):
from app.channels.manager import ChannelManager
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME
from deerflow.persistence.engine import close_engine
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
async def go():
repo = await _make_channel_connection_repo(tmp_path)
alice = await repo.upsert_connection(
owner_user_id="alice",
provider="slack",
external_account_id="U-alice",
workspace_id="T1",
)
bob = await repo.upsert_connection(
owner_user_id="bob",
provider="slack",
external_account_id="U-bob",
workspace_id="T1",
)
bus = MessageBus()
store = ChannelStore(path=tmp_path / "legacy-store.json")
manager = ChannelManager(bus=bus, store=store, connection_repo=repo)
mock_client = _make_mock_langgraph_client()
mock_client.threads.create = AsyncMock(
side_effect=[
{"thread_id": "thread-alice"},
{"thread_id": "thread-bob"},
]
)
manager._client = mock_client
await manager._handle_chat(
InboundMessage(
channel_name="slack",
chat_id="C-shared",
user_id="U-alice",
owner_user_id="alice",
connection_id=alice["id"],
text="hello",
thread_ts="1710000000.000100",
topic_id="1710000000.000100",
)
)
await manager._handle_chat(
InboundMessage(
channel_name="slack",
chat_id="C-shared",
user_id="U-bob",
owner_user_id="bob",
connection_id=bob["id"],
text="hello",
thread_ts="1710000000.000100",
topic_id="1710000000.000100",
)
)
assert await repo.get_thread_id(alice["id"], "C-shared", "1710000000.000100") == "thread-alice"
assert await repo.get_thread_id(bob["id"], "C-shared", "1710000000.000100") == "thread-bob"
assert store.list_entries() == []
first_context = mock_client.runs.wait.call_args_list[0].kwargs["context"]
second_context = mock_client.runs.wait.call_args_list[1].kwargs["context"]
assert first_context["user_id"] == "alice"
assert first_context["channel_user_id"] == "U-alice"
assert second_context["user_id"] == "bob"
assert second_context["channel_user_id"] == "U-bob"
first_create_headers = mock_client.threads.create.call_args_list[0].kwargs["headers"]
second_create_headers = mock_client.threads.create.call_args_list[1].kwargs["headers"]
assert first_create_headers[INTERNAL_OWNER_USER_ID_HEADER_NAME] == "alice"
assert second_create_headers[INTERNAL_OWNER_USER_ID_HEADER_NAME] == "bob"
first_run_headers = mock_client.runs.wait.call_args_list[0].kwargs["headers"]
second_run_headers = mock_client.runs.wait.call_args_list[1].kwargs["headers"]
assert first_run_headers[INTERNAL_OWNER_USER_ID_HEADER_NAME] == "alice"
assert second_run_headers[INTERNAL_OWNER_USER_ID_HEADER_NAME] == "bob"
try:
_run(go())
finally:
_run(close_engine())
# ---------------------------------------------------------------------------
# ChannelService tests
# ---------------------------------------------------------------------------
@@ -3377,144 +3175,6 @@ class TestChannelService:
assert service._config == {"telegram": {"enabled": False}}
def test_from_app_config_does_not_create_runtime_channels_from_channel_connections(
self,
monkeypatch,
tmp_path,
):
from app.channels.service import ChannelService
from deerflow.config import paths as paths_module
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr(paths_module, "_paths", None)
app_config = SimpleNamespace(
model_extra={},
channel_connections=ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"telegram": {"enabled": True, "bot_username": "deerflow_bot"},
"slack": {"enabled": True},
"discord": {"enabled": True},
}
),
)
service = ChannelService.from_app_config(app_config)
assert service._config == {}
def test_from_app_config_preserves_existing_runtime_channels_with_channel_connections_enabled(
self,
monkeypatch,
tmp_path,
):
from app.channels.runtime_config_store import ChannelRuntimeConfigStore
from app.channels.service import ChannelService
from deerflow.config import paths as paths_module
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr(paths_module, "_paths", None)
ChannelRuntimeConfigStore().set_provider_config(
"slack",
{
"enabled": True,
"bot_token": "xoxb-ui",
"app_token": "xapp-ui",
},
)
app_config = SimpleNamespace(
model_extra={
"channels": {
"telegram": {"enabled": True, "bot_token": "telegram-token"},
"slack": {"enabled": True, "bot_token": "xoxb", "app_token": "xapp"},
"discord": {"enabled": True, "bot_token": "discord-bot-token"},
}
},
channel_connections=ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"telegram": {"enabled": True, "bot_username": "deerflow_bot"},
"slack": {"enabled": True},
"discord": {"enabled": True},
}
),
)
service = ChannelService.from_app_config(app_config)
assert service._config["telegram"]["bot_token"] == "telegram-token"
assert service._config["slack"]["app_token"] == "xapp"
assert service._config["discord"]["bot_token"] == "discord-bot-token"
def test_from_app_config_loads_persisted_runtime_channel_config(self, monkeypatch, tmp_path):
from app.channels.runtime_config_store import ChannelRuntimeConfigStore
from app.channels.service import ChannelService
from deerflow.config import paths as paths_module
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr(paths_module, "_paths", None)
ChannelRuntimeConfigStore().set_provider_config(
"slack",
{
"enabled": True,
"bot_token": "xoxb-ui",
"app_token": "xapp-ui",
},
)
app_config = SimpleNamespace(
model_extra={},
channel_connections=ChannelConnectionsConfig.model_validate(
{
"enabled": True,
"slack": {"enabled": True},
}
),
)
service = ChannelService.from_app_config(app_config)
assert service._config["slack"] == {
"enabled": True,
"bot_token": "xoxb-ui",
"app_token": "xapp-ui",
}
def test_connection_repo_is_forwarded_to_manager(self):
from app.channels.service import ChannelService
repo = object()
service = ChannelService(channels_config={}, connection_repo=repo)
assert service.manager._connection_repo is repo
def test_remove_channel_stops_running_channel_and_forgets_config(self):
from app.channels.service import ChannelService
async def go():
service = ChannelService(
channels_config={
"slack": {
"enabled": True,
"bot_token": "xoxb-ui",
"app_token": "xapp-ui",
},
}
)
channel = AsyncMock()
service._channels["slack"] = channel
service._running = True
assert await service.remove_channel("slack") is True
channel.stop.assert_awaited_once()
assert "slack" not in service._channels
assert "slack" not in service._config
_run(go())
def test_disabled_channel_with_string_creds_emits_warning(self, caplog):
"""Warning is emitted when a channel has string credentials but enabled=false."""
import logging
-12
View File
@@ -233,15 +233,3 @@ def test_non_auth_mutation_rejects_mismatched_double_submit_token():
assert response.status_code == 403
assert response.json()["detail"] == "CSRF token mismatch."
def test_channel_posts_require_double_submit_csrf():
client = TestClient(_make_app(), base_url="https://deerflow.example")
response = client.post(
"/api/channels/slack/connect",
headers={"Origin": "https://deerflow.example"},
)
assert response.status_code == 403
assert response.json()["detail"] == "CSRF token missing. Include X-CSRF-Token header."
@@ -1,88 +0,0 @@
"""Discord connection routing tests."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.channels.discord import DiscordChannel
from app.channels.message_bus import InboundMessage, MessageBus
@pytest.fixture
async def repo(tmp_path):
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'discord.db'}", sqlite_dir=str(tmp_path))
try:
yield ChannelConnectionRepository(
get_session_factory(),
cipher=ChannelCredentialCipher.from_key("discord-secret"),
)
finally:
await close_engine()
@pytest.mark.anyio
async def test_discord_inbound_attaches_owner_identity_from_user_level_connection(repo):
connection = await repo.upsert_connection(
owner_user_id="alice",
provider="discord",
external_account_id="987",
external_account_name="Alice",
status="connected",
)
channel = DiscordChannel(
bus=MessageBus(),
config={"bot_token": "discord-bot", "connection_repo": repo},
)
inbound = InboundMessage(
channel_name="discord",
chat_id="C123",
user_id="987",
text="hello",
)
attached = await channel._attach_connection_identity(inbound, guild_id="G123")
assert attached.connection_id == connection["id"]
assert attached.owner_user_id == "alice"
assert attached.workspace_id is None
@pytest.mark.anyio
async def test_discord_connect_command_binds_gateway_identity(repo):
state = "discord-bind-code"
await repo.create_oauth_state(
owner_user_id="deerflow-user-1",
provider="discord",
state=state,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
)
channel = DiscordChannel(
bus=MessageBus(),
config={"bot_token": "discord-bot", "connection_repo": repo},
)
message = MagicMock()
message.author.id = 987
message.author.display_name = "Alice"
message.guild.id = 123
message.guild.name = "Deer Guild"
message.channel.id = 456
message.channel.send = AsyncMock()
handled = await channel._bind_connection_from_connect_code(message, state)
connections = await repo.list_connections("deerflow-user-1")
assert handled is True
assert len(connections) == 1
assert connections[0]["provider"] == "discord"
assert connections[0]["external_account_id"] == "987"
assert connections[0]["external_account_name"] == "Alice"
assert connections[0]["workspace_id"] == "123"
assert connections[0]["workspace_name"] == "Deer Guild"
assert connections[0]["metadata"]["channel_id"] == "456"
message.channel.send.assert_awaited_once()
-25
View File
@@ -73,31 +73,6 @@ def test_feishu_on_message_plain_text():
assert mock_make_inbound.call_args[1]["text"] == "Hello world"
def test_feishu_is_not_running_when_ws_thread_exits():
bus = MessageBus()
channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test"})
channel._running = True
channel._thread = MagicMock()
channel._thread.is_alive.return_value = False
assert channel.is_running is False
def test_feishu_event_handler_ignores_non_content_message_events():
import lark_oapi as lark
bus = MessageBus()
channel = FeishuChannel(bus, {"app_id": "test", "app_secret": "test"})
event_handler = channel._build_event_handler(lark)
assert "p2.im.message.receive_v1" in event_handler._processorMap
assert "p2.im.message.message_read_v1" in event_handler._processorMap
assert "p2.im.message.reaction.created_v1" in event_handler._processorMap
assert "p2.im.message.reaction.deleted_v1" in event_handler._processorMap
assert "p2.im.message.recalled_v1" in event_handler._processorMap
def test_feishu_on_message_rich_text():
bus = MessageBus()
config = {"app_id": "test", "app_secret": "test"}
-77
View File
@@ -474,83 +474,6 @@ def test_inject_authenticated_user_context_skips_internal_role():
assert config["context"]["user_id"] == "channel-user-7"
def test_start_run_uses_internal_owner_header_for_persistence():
import asyncio
from types import SimpleNamespace
from unittest.mock import patch
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
from app.gateway.services import start_run
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.runtime import RunManager
from deerflow.runtime.runs.store.memory import MemoryRunStore
from deerflow.runtime.user_context import get_effective_user_id
async def _scenario():
run_store = MemoryRunStore()
thread_store = MemoryThreadMetaStore(InMemoryStore())
await thread_store.create("channel-thread", user_id="default", metadata={"legacy": True})
run_manager = RunManager(store=run_store)
state = SimpleNamespace(
stream_bridge=SimpleNamespace(),
run_manager=run_manager,
checkpointer=InMemorySaver(),
store=InMemoryStore(),
run_event_store=SimpleNamespace(),
run_events_config=None,
thread_store=thread_store,
)
request = SimpleNamespace(
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
state=SimpleNamespace(user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE)),
app=SimpleNamespace(state=state),
)
body = SimpleNamespace(
assistant_id="lead_agent",
input={"messages": [{"role": "human", "content": "hi"}]},
metadata={},
config=None,
context=None,
on_disconnect="cancel",
multitask_strategy="reject",
stream_mode=None,
stream_subgraphs=False,
interrupt_before=None,
interrupt_after=None,
)
task_context: dict[str, str] = {}
async def fake_run_agent(*args, **kwargs):
task_context["user_id"] = get_effective_user_id()
with (
patch("app.gateway.services.resolve_agent_factory", return_value=object()),
patch("app.gateway.services.run_agent", side_effect=fake_run_agent),
):
record = await start_run(body, "channel-thread", request)
await record.task
owner_run = await run_store.get(record.run_id, user_id="owner-1")
default_run = await run_store.get(record.run_id, user_id="default")
owner_thread = await thread_store.get("channel-thread", user_id="owner-1")
default_thread = await thread_store.get("channel-thread", user_id="default")
return owner_run, default_run, owner_thread, default_thread, task_context
owner_run, default_run, owner_thread, default_thread, task_context = asyncio.run(_scenario())
assert owner_run is not None
assert owner_run["user_id"] == "owner-1"
assert default_run is None
assert owner_thread is not None
assert owner_thread["user_id"] == "owner-1"
assert owner_thread["metadata"] == {"legacy": True}
assert default_thread is None
assert task_context["user_id"] == "owner-1"
# ---------------------------------------------------------------------------
# build_run_config — context / configurable precedence (LangGraph >= 0.6.0)
# ---------------------------------------------------------------------------
-15
View File
@@ -33,18 +33,3 @@ def test_internal_auth_generates_process_local_fallback(monkeypatch):
assert reloaded.is_valid_internal_auth_token(token) is True
finally:
importlib.reload(reloaded)
def test_internal_auth_headers_can_carry_owner_user_id(monkeypatch):
import app.gateway.internal_auth as internal_auth
monkeypatch.setenv("DEER_FLOW_INTERNAL_AUTH_TOKEN", "shared-token")
reloaded = importlib.reload(internal_auth)
try:
headers = reloaded.create_internal_auth_headers(owner_user_id="owner-1")
assert headers[reloaded.INTERNAL_AUTH_HEADER_NAME] == "shared-token"
assert headers[reloaded.INTERNAL_OWNER_USER_ID_HEADER_NAME] == "owner-1"
finally:
monkeypatch.delenv("DEER_FLOW_INTERNAL_AUTH_TOKEN", raising=False)
importlib.reload(reloaded)
+26 -2
View File
@@ -257,14 +257,38 @@ def test_provisioner_is_alive_true_only_when_status_running(monkeypatch):
assert backend._provisioner_is_alive("abc123") is False
def test_provisioner_is_alive_returns_false_on_request_exception(monkeypatch):
def test_provisioner_is_alive_returns_false_on_404(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get(url: str, timeout: int):
return _StubResponse(status_code=404)
monkeypatch.setattr(requests, "get", mock_get)
assert backend._provisioner_is_alive("abc123") is False
def test_provisioner_is_alive_raises_on_request_exception(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get(url: str, timeout: int):
raise requests.RequestException("boom")
monkeypatch.setattr(requests, "get", mock_get)
assert backend._provisioner_is_alive("abc123") is False
with pytest.raises(RuntimeError, match="Provisioner health check failed for abc123"):
backend._provisioner_is_alive("abc123")
def test_provisioner_is_alive_raises_on_server_error(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get(url: str, timeout: int):
response = _StubResponse(status_code=503)
response.text = "unavailable"
return response
monkeypatch.setattr(requests, "get", mock_get)
with pytest.raises(RuntimeError, match="HTTP 503 unavailable"):
backend._provisioner_is_alive("abc123")
def test_discover_delegates_to_provisioner_discover(monkeypatch):
+183
View File
@@ -5,7 +5,10 @@ import asyncio
import pytest
from langchain.agents.middleware import AgentMiddleware
from langchain.tools import ToolRuntime
from langchain_core.messages import ToolMessage
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.runtime import Runtime
from langgraph.types import Command
from deerflow.sandbox.middleware import SandboxMiddleware
from deerflow.sandbox.sandbox import Sandbox
@@ -223,3 +226,183 @@ async def test_aafter_agent_delegates_to_super_when_no_sandbox(monkeypatch: pyte
assert result == {"delegated": True}
assert calls == [(state, runtime)]
# ---------------------------------------------------------------------------
# wrap_tool_call / awrap_tool_call: persistent sandbox state via Command
# ---------------------------------------------------------------------------
def _make_tool_call_request(state: dict) -> ToolCallRequest:
"""Build a minimal ToolCallRequest backed by a real ToolRuntime."""
runtime = ToolRuntime(
state=state,
context={},
config={"configurable": {}},
stream_writer=lambda _: None,
tools=[],
tool_call_id="call-1",
store=None,
)
return ToolCallRequest(
tool_call={"id": "call-1", "name": "bash", "args": {}},
tool=None,
state=state,
runtime=runtime,
)
def test_wrap_tool_call_emits_command_when_lazy_init_happens() -> None:
middleware = SandboxMiddleware()
state: dict = {}
request = _make_tool_call_request(state)
def handler(req: ToolCallRequest) -> ToolMessage:
# Simulate ensure_sandbox_initialized() mutating runtime.state in-place.
req.runtime.state["sandbox"] = {"sandbox_id": "new-sandbox"}
return ToolMessage(content="ok", tool_call_id="call-1", name="bash")
result = middleware.wrap_tool_call(request, handler)
assert isinstance(result, Command)
assert isinstance(result.update, dict)
assert result.update["sandbox"] == {"sandbox_id": "new-sandbox"}
messages = result.update["messages"]
assert len(messages) == 1
assert messages[0].content == "ok"
assert messages[0].tool_call_id == "call-1"
def test_wrap_tool_call_passthrough_when_sandbox_already_in_state() -> None:
middleware = SandboxMiddleware()
state: dict = {"sandbox": {"sandbox_id": "existing"}}
request = _make_tool_call_request(state)
original = ToolMessage(content="ok", tool_call_id="call-1", name="bash")
def handler(req: ToolCallRequest) -> ToolMessage:
return original
result = middleware.wrap_tool_call(request, handler)
assert result is original
def test_wrap_tool_call_passthrough_when_handler_did_not_initialize_sandbox() -> None:
middleware = SandboxMiddleware()
state: dict = {}
request = _make_tool_call_request(state)
original = ToolMessage(content="ok", tool_call_id="call-1", name="bash")
def handler(req: ToolCallRequest) -> ToolMessage:
return original
result = middleware.wrap_tool_call(request, handler)
assert result is original
def test_wrap_tool_call_merges_with_existing_command_update() -> None:
middleware = SandboxMiddleware()
state: dict = {}
request = _make_tool_call_request(state)
tool_msg = ToolMessage(content="ok", tool_call_id="call-1", name="bash")
def handler(req: ToolCallRequest) -> Command:
req.runtime.state["sandbox"] = {"sandbox_id": "new-sandbox"}
return Command(
update={
"messages": [tool_msg],
"viewed_images": {"a.png": {"base64": "x", "mime_type": "image/png"}},
},
goto="next-node",
)
result = middleware.wrap_tool_call(request, handler)
assert isinstance(result, Command)
assert result.goto == "next-node"
assert isinstance(result.update, dict)
assert result.update["messages"] == [tool_msg]
assert result.update["viewed_images"] == {"a.png": {"base64": "x", "mime_type": "image/png"}}
assert result.update["sandbox"] == {"sandbox_id": "new-sandbox"}
def test_wrap_tool_call_does_not_override_non_dict_update() -> None:
middleware = SandboxMiddleware()
state: dict = {}
request = _make_tool_call_request(state)
cmd = Command(update=[("messages", [ToolMessage(content="x", tool_call_id="c", name="bash")])])
def handler(req: ToolCallRequest) -> Command:
req.runtime.state["sandbox"] = {"sandbox_id": "new-sandbox"}
return cmd
result = middleware.wrap_tool_call(request, handler)
# Non-dict update is left untouched to avoid silent data loss.
assert result is cmd
@pytest.mark.anyio
async def test_awrap_tool_call_emits_command_when_lazy_init_happens() -> None:
middleware = SandboxMiddleware()
state: dict = {}
request = _make_tool_call_request(state)
async def handler(req: ToolCallRequest) -> ToolMessage:
req.runtime.state["sandbox"] = {"sandbox_id": "async-new"}
return ToolMessage(content="ok", tool_call_id="call-1", name="bash")
result = await middleware.awrap_tool_call(request, handler)
assert isinstance(result, Command)
assert isinstance(result.update, dict)
assert result.update["sandbox"] == {"sandbox_id": "async-new"}
messages = result.update["messages"]
assert len(messages) == 1
assert messages[0].content == "ok"
@pytest.mark.anyio
async def test_awrap_tool_call_passthrough_when_sandbox_already_in_state() -> None:
middleware = SandboxMiddleware()
state: dict = {"sandbox": {"sandbox_id": "existing"}}
request = _make_tool_call_request(state)
original = ToolMessage(content="ok", tool_call_id="call-1", name="bash")
async def handler(req: ToolCallRequest) -> ToolMessage:
return original
result = await middleware.awrap_tool_call(request, handler)
assert result is original
def test_wrap_tool_call_preserves_existing_command_fields_when_merging() -> None:
"""Regression: when merging sandbox_update into an existing Command,
all other Command fields (e.g. graph, goto, resume) must be preserved.
"""
middleware = SandboxMiddleware()
state: dict = {}
request = _make_tool_call_request(state)
def handler(req: ToolCallRequest) -> Command:
req.runtime.state["sandbox"] = {"sandbox_id": "sbx-merge"}
return Command(
update={"existing_key": "existing_value"},
graph="parent",
goto="next_node",
resume="resume-token",
)
result = middleware.wrap_tool_call(request, handler)
assert isinstance(result, Command)
assert result.update == {
"existing_key": "existing_value",
"sandbox": {"sandbox_id": "sbx-merge"},
}
# Critical: other Command fields must NOT be dropped by the merge.
assert result.graph == "parent"
assert result.goto == "next_node"
assert result.resume == "resume-token"
-75
View File
@@ -7,9 +7,7 @@ Run from repo root:
from __future__ import annotations
import yaml
from wizard import ui as wizard_ui
from wizard.providers import LLM_PROVIDERS, SEARCH_PROVIDERS, WEB_FETCH_PROVIDERS, LLMProvider
from wizard.steps import channels as channels_step
from wizard.steps import llm as llm_step
from wizard.steps import search as search_step
from wizard.writer import (
@@ -329,44 +327,6 @@ class TestBuildMinimalConfig:
assert model["when_thinking_enabled"]["extra_body"]["thinking"]["type"] == "enabled"
assert model["when_thinking_disabled"]["extra_body"]["thinking"]["type"] == "disabled"
def test_can_enable_selected_channel_connections(self):
content = build_minimal_config(
provider_use="langchain_openai:ChatOpenAI",
model_name="gpt-4o",
display_name="OpenAI",
api_key_field="api_key",
env_var="OPENAI_API_KEY",
channel_connection_providers=["feishu", "slack"],
)
data = yaml.safe_load(content)
channel_connections = data["channel_connections"]
assert channel_connections["enabled"] is True
assert channel_connections["feishu"]["enabled"] is True
assert channel_connections["slack"]["enabled"] is True
assert channel_connections["telegram"]["enabled"] is False
assert channel_connections["discord"]["enabled"] is False
assert channel_connections["dingtalk"]["enabled"] is False
assert channel_connections["wechat"]["enabled"] is False
assert channel_connections["wecom"]["enabled"] is False
def test_channel_connections_disabled_when_no_channels_selected(self):
content = build_minimal_config(
provider_use="langchain_openai:ChatOpenAI",
model_name="gpt-4o",
display_name="OpenAI",
api_key_field="api_key",
env_var="OPENAI_API_KEY",
channel_connection_providers=[],
)
data = yaml.safe_load(content)
channel_connections = data["channel_connections"]
assert channel_connections["enabled"] is False
assert all(not config["enabled"] for provider, config in channel_connections.items() if provider != "enabled")
class TestLLMStep:
def test_model_selection_defaults_to_provider_default_model(self, monkeypatch):
@@ -424,41 +384,6 @@ class TestLLMStep:
assert result.base_url == "https://gateway.example/v1"
class TestChannelsStep:
def test_returns_selected_channel_keys(self, monkeypatch):
monkeypatch.setattr(channels_step, "print_header", lambda *_args, **_kwargs: None)
monkeypatch.setattr(channels_step, "print_info", lambda *_args, **_kwargs: None)
monkeypatch.setattr(channels_step, "print_success", lambda *_args, **_kwargs: None)
monkeypatch.setattr(channels_step, "ask_multi_choice", lambda *_args, **_kwargs: [0, 3, 6])
result = channels_step.run_channels_step()
assert result.enabled_providers == ["telegram", "feishu", "wecom"]
def test_empty_selection_disables_channel_connections(self, monkeypatch):
monkeypatch.setattr(channels_step, "print_header", lambda *_args, **_kwargs: None)
monkeypatch.setattr(channels_step, "print_info", lambda *_args, **_kwargs: None)
monkeypatch.setattr(channels_step, "print_success", lambda *_args, **_kwargs: None)
monkeypatch.setattr(channels_step, "ask_multi_choice", lambda *_args, **_kwargs: [])
result = channels_step.run_channels_step()
assert result.enabled_providers == []
class TestWizardUi:
def test_multi_choice_blank_requires_input_without_default(self, monkeypatch):
answers = iter(["", "2"])
monkeypatch.setattr("builtins.input", lambda _prompt: next(answers))
assert wizard_ui.ask_multi_choice("Pick", ["First", "Second"], default=None) == [1]
def test_multi_choice_blank_accepts_empty_default(self, monkeypatch):
monkeypatch.setattr("builtins.input", lambda _prompt: "")
assert wizard_ui.ask_multi_choice("Pick", ["First", "Second"], default=[]) == []
# ---------------------------------------------------------------------------
# writer.py — env file helpers
# ---------------------------------------------------------------------------
@@ -1,154 +0,0 @@
"""Slack connection tests for user-owned channel bindings."""
from __future__ import annotations
import sys
from datetime import UTC, datetime, timedelta
from types import ModuleType
from unittest.mock import AsyncMock, MagicMock
from app.channels.message_bus import MessageBus, OutboundMessage
async def _make_repo(tmp_path):
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
from deerflow.persistence.engine import get_session_factory, init_engine
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'slack.db'}", sqlite_dir=str(tmp_path))
return ChannelConnectionRepository(
get_session_factory(),
cipher=ChannelCredentialCipher.from_key("slack-secret"),
)
def test_slack_connect_command_binds_socket_mode_identity(tmp_path):
import anyio
from app.channels.slack import SlackChannel
async def go():
repo = await _make_repo(tmp_path)
state = "slack-bind-code"
await repo.create_oauth_state(
owner_user_id="deerflow-user-1",
provider="slack",
state=state,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
)
channel = SlackChannel(
bus=MessageBus(),
config={"bot_token": "xoxb-operator", "app_token": "xapp-operator", "connection_repo": repo},
)
channel._web_client = MagicMock()
handled = await channel._bind_connection_from_connect_code(
event={
"user": "U123",
"channel": "C123",
"ts": "1710000000.000100",
},
team_id="T123",
code=state,
)
connections = await repo.list_connections("deerflow-user-1")
assert handled is True
assert len(connections) == 1
assert connections[0]["provider"] == "slack"
assert connections[0]["external_account_id"] == "U123"
assert connections[0]["workspace_id"] == "T123"
assert connections[0]["metadata"]["channel_id"] == "C123"
channel._web_client.chat_postMessage.assert_called_once()
await repo.close()
anyio.run(go)
def test_slack_send_uses_connection_bot_token_when_connection_id_is_present():
import anyio
from app.channels.slack import SlackChannel
async def go():
repo = AsyncMock()
repo.get_credentials.return_value = {"access_token": "xoxb-connection-token"}
web_client = MagicMock()
web_client_factory = MagicMock(return_value=web_client)
channel = SlackChannel(
bus=MessageBus(),
config={
"connection_repo": repo,
"web_client_factory": web_client_factory,
},
)
msg = OutboundMessage(
channel_name="slack",
chat_id="C123",
thread_id="thread-1",
text="hello",
connection_id="connection-1",
)
await channel.send(msg)
repo.get_credentials.assert_awaited_once_with("connection-1")
web_client_factory.assert_called_once_with(token="xoxb-connection-token")
web_client.chat_postMessage.assert_called_once()
anyio.run(go)
def test_slack_http_events_mode_initializes_operator_web_client(monkeypatch):
import anyio
from app.channels.slack import SlackChannel
class FakeWebClient:
def __init__(self, token: str) -> None:
self.token = token
self.messages: list[dict] = []
def auth_test(self):
return {"user_id": "B-http"}
def chat_postMessage(self, **kwargs):
self.messages.append(kwargs)
slack_sdk = ModuleType("slack_sdk")
slack_sdk.WebClient = FakeWebClient
socket_mode = ModuleType("slack_sdk.socket_mode")
socket_mode.SocketModeClient = object
response = ModuleType("slack_sdk.socket_mode.response")
response.SocketModeResponse = object
monkeypatch.setitem(sys.modules, "slack_sdk", slack_sdk)
monkeypatch.setitem(sys.modules, "slack_sdk.socket_mode", socket_mode)
monkeypatch.setitem(sys.modules, "slack_sdk.socket_mode.response", response)
async def go():
channel = SlackChannel(
bus=MessageBus(),
config={
"bot_token": "xoxb-operator",
"event_delivery": "http",
"connection_repo": MagicMock(),
},
)
await channel.start()
assert channel._running is True
assert channel._web_client is not None
assert channel._web_client.token == "xoxb-operator"
assert channel._bot_user_id == "B-http"
channel._post_connection_reply("C123", "Slack connected to DeerFlow.", "1710000000.000100")
assert channel._web_client.messages == [
{
"channel": "C123",
"text": "Slack connected to DeerFlow.",
"thread_ts": "1710000000.000100",
}
]
await channel.stop()
anyio.run(go)
@@ -1,100 +0,0 @@
"""Tests for Telegram deep-link channel connections."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
from app.channels.message_bus import MessageBus
from app.channels.telegram import TelegramChannel
@pytest.fixture
async def repo(tmp_path: Path):
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'telegram.db'}", sqlite_dir=str(tmp_path))
try:
yield ChannelConnectionRepository(
get_session_factory(),
cipher=ChannelCredentialCipher.from_key("telegram-secret"),
)
finally:
await close_engine()
def _telegram_update(*, text: str = "/start", user_id: int = 42, chat_id: int = 100, chat_type: str = "private"):
update = MagicMock()
update.effective_user.id = user_id
update.effective_user.username = "alice"
update.effective_user.full_name = "Alice Example"
update.effective_chat.id = chat_id
update.effective_chat.type = chat_type
update.message.text = text
update.message.message_id = 55
update.message.reply_to_message = None
update.message.reply_text = AsyncMock()
return update
@pytest.mark.anyio
async def test_start_with_deep_link_state_binds_telegram_chat(repo):
state = "telegram-bind-state"
await repo.create_oauth_state(
owner_user_id="deerflow-user-1",
provider="telegram",
state=state,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
)
channel = TelegramChannel(
bus=MessageBus(),
config={"bot_token": "test-token", "connection_repo": repo},
)
update = _telegram_update(text=f"/start {state}")
context = MagicMock()
context.args = [state]
await channel._cmd_start(update, context)
connections = await repo.list_connections("deerflow-user-1")
assert len(connections) == 1
assert connections[0]["provider"] == "telegram"
assert connections[0]["external_account_id"] == "42"
assert connections[0]["external_account_name"] == "Alice Example"
assert connections[0]["workspace_id"] == "100"
assert connections[0]["metadata"]["chat_type"] == "private"
update.message.reply_text.assert_awaited_once()
assert "connected" in update.message.reply_text.await_args.args[0].lower()
@pytest.mark.anyio
async def test_bound_telegram_message_publishes_connection_identity(repo):
connection = await repo.upsert_connection(
owner_user_id="deerflow-user-1",
provider="telegram",
external_account_id="42",
external_account_name="Alice Example",
workspace_id="100",
metadata={"chat_type": "private"},
)
bus = MessageBus()
channel = TelegramChannel(
bus=bus,
config={"bot_token": "test-token", "connection_repo": repo},
)
channel._main_loop = __import__("asyncio").get_event_loop()
channel._send_running_reply = AsyncMock()
await channel._on_text(_telegram_update(text="hello"), None)
inbound = await bus.get_inbound()
assert inbound.connection_id == connection["id"]
assert inbound.owner_user_id == "deerflow-user-1"
assert inbound.workspace_id == "100"
assert inbound.user_id == "42"
assert inbound.chat_id == "100"
assert inbound.text == "hello"
-13
View File
@@ -137,19 +137,6 @@ class TestThreadMetaRepository:
async def test_update_metadata_nonexistent_is_noop(self, repo):
await repo.update_metadata("nonexistent", {"k": "v"}) # should not raise
@pytest.mark.anyio
async def test_update_owner_with_bypass_moves_row(self, repo):
await repo.create("t1", user_id="default", metadata={"source": "channel"})
await repo.update_owner("t1", "owner-1", user_id=None)
owner_row = await repo.get("t1", user_id="owner-1")
default_row = await repo.get("t1", user_id="default")
assert owner_row is not None
assert owner_row["user_id"] == "owner-1"
assert owner_row["metadata"] == {"source": "channel"}
assert default_row is None
# --- search with metadata filter (SQL push-down) ---
@pytest.mark.anyio
-32
View File
@@ -1,5 +1,4 @@
import re
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -219,37 +218,6 @@ def test_create_thread_returns_iso_timestamps() -> None:
assert body["created_at"] == body["updated_at"]
def test_internal_owner_header_assigns_thread_to_owner() -> None:
import asyncio
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
store = InMemoryStore()
checkpointer = InMemorySaver()
thread_store = MemoryThreadMetaStore(store)
request = SimpleNamespace(
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
state=SimpleNamespace(user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE)),
app=SimpleNamespace(state=SimpleNamespace(checkpointer=checkpointer, thread_store=thread_store)),
)
async def _scenario():
response = await threads.create_thread(
threads.ThreadCreateRequest(thread_id="channel-thread", metadata={}),
request,
)
owner_row = await thread_store.get("channel-thread", user_id="owner-1")
internal_row = await thread_store.get("channel-thread", user_id="default")
return response, owner_row, internal_row
response, owner_row, internal_row = asyncio.run(_scenario())
assert response.thread_id == "channel-thread"
assert owner_row is not None
assert owner_row["user_id"] == "owner-1"
assert internal_row is None
def test_get_thread_returns_iso_for_legacy_unix_record() -> None:
"""A thread record written by older versions stores ``time.time()``
floats. ``get_thread`` must transparently surface them as ISO so the
-2
View File
@@ -820,7 +820,6 @@ dependencies = [
{ name = "agent-sandbox" },
{ name = "aiosqlite" },
{ name = "alembic" },
{ name = "cryptography" },
{ name = "ddgs" },
{ name = "dotenv" },
{ name = "duckdb" },
@@ -872,7 +871,6 @@ requires-dist = [
{ name = "aiosqlite", specifier = ">=0.19" },
{ name = "alembic", specifier = ">=1.13" },
{ name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29" },
{ name = "cryptography", specifier = ">=43.0.0" },
{ name = "ddgs", specifier = ">=9.10.0" },
{ name = "dotenv", specifier = ">=0.9.9" },
{ name = "duckdb", specifier = ">=1.4.4" },
-39
View File
@@ -1140,45 +1140,6 @@ run_events:
max_trace_content: 10240
track_token_usage: true
# ============================================================================
# User-Owned IM Channel Connections
# ============================================================================
# Lets logged-in users connect their own IM accounts from the DeerFlow frontend
# while reusing the existing `channels` runtime configuration below.
#
# Security notes:
# - No public IP, OAuth callback URL, or provider webhook is required.
# - Provider bot/app credentials stay under `channels.*`.
# - `channel_connections` stores per-user bindings and one-time connect codes.
# - Telegram uses a deep link when `bot_username` is configured.
# - Slack, Discord, Feishu, DingTalk, WeChat, and WeCom use `/connect <code>`
# through the already-running bot/app.
#
# channel_connections:
# enabled: false
#
# telegram:
# enabled: false
# bot_username: $TELEGRAM_BOT_USERNAME
#
# slack:
# enabled: false
#
# discord:
# enabled: false
#
# feishu:
# enabled: false
#
# dingtalk:
# enabled: false
#
# wechat:
# enabled: false
#
# wecom:
# enabled: false
# ============================================================================
# IM Channels Configuration
# ============================================================================
+14 -32
View File
@@ -6,10 +6,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
ThreadChannelBadge,
ThreadChannelIcon,
} from "@/components/workspace/thread-channel-source";
import {
WorkspaceBody,
WorkspaceContainer,
@@ -17,11 +13,7 @@ import {
} from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks";
import { useInfiniteThreads } from "@/core/threads/hooks";
import {
channelSourceOfThread,
pathOfThread,
titleOfThread,
} from "@/core/threads/utils";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime";
export default function ChatsPage() {
@@ -90,30 +82,20 @@ export default function ChatsPage() {
<main className="min-h-0 flex-1">
<ScrollArea className="size-full py-4">
<div className="mx-auto flex size-full max-w-(--container-width-md) flex-col">
{filteredThreads.map((thread) => {
const channelSource = channelSourceOfThread(thread);
return (
<Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4">
<div className="flex min-w-0 items-center gap-2">
<ThreadChannelIcon source={channelSource} />
<div className="min-w-0 flex-1 truncate">
{titleOfThread(thread)}
</div>
<ThreadChannelBadge
source={channelSource}
className="hidden sm:inline-flex"
/>
</div>
{thread.updated_at && (
<div className="text-muted-foreground text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
{filteredThreads?.map((thread) => (
<Link key={thread.thread_id} href={pathOfThread(thread)}>
<div className="flex flex-col gap-2 border-b p-4">
<div>
<div>{titleOfThread(thread)}</div>
</div>
</Link>
);
})}
{thread.updated_at && (
<div className="text-muted-foreground text-sm">
{formatTimeAgo(thread.updated_at)}
</div>
)}
</div>
</Link>
))}
{hasNextPage && !isSearching && (
<div
ref={sentinelRef}
@@ -1,184 +0,0 @@
"use client";
import { MessageCircleIcon } from "lucide-react";
import type { SVGProps } from "react";
import { cn } from "@/lib/utils";
type ChannelProviderIconProps = SVGProps<SVGSVGElement> & {
provider: string;
};
export function ChannelProviderIcon({
provider,
className,
...props
}: ChannelProviderIconProps) {
const normalizedProvider = provider.toLowerCase();
if (normalizedProvider === "telegram") {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={cn("size-5", className)}
{...props}
>
<circle cx="12" cy="12" r="11" fill="#2AABEE" />
<path
fill="#FFFFFF"
d="M17.4 7.2 15.7 16c-.1.7-.5.9-1 .6l-2.8-2.1-1.4 1.3c-.1.2-.3.3-.6.3l.2-2.9 5.3-4.8c.2-.2 0-.3-.3-.1l-6.6 4.1-2.8-.9c-.6-.2-.6-.6.1-.8l10.9-4.2c.5-.2.9.1.7.7Z"
/>
</svg>
);
}
if (normalizedProvider === "slack") {
return (
<svg
viewBox="0 0 256 256"
aria-hidden="true"
className={cn("size-5", className)}
{...props}
>
<path
fill="#E01E5A"
d="M53.841 161.32c0 14.832-11.987 26.82-26.819 26.82S.203 176.152.203 161.32c0-14.831 11.987-26.818 26.82-26.818H53.84zm13.41 0c0-14.831 11.987-26.818 26.819-26.818s26.819 11.987 26.819 26.819v67.047c0 14.832-11.987 26.82-26.82 26.82c-14.83 0-26.818-11.988-26.818-26.82z"
/>
<path
fill="#36C5F0"
d="M94.07 53.638c-14.832 0-26.82-11.987-26.82-26.819S79.239 0 94.07 0s26.819 11.987 26.819 26.819v26.82zm0 13.613c14.832 0 26.819 11.987 26.819 26.819s-11.987 26.819-26.82 26.819H26.82C11.987 120.889 0 108.902 0 94.069c0-14.83 11.987-26.818 26.819-26.818z"
/>
<path
fill="#2EB67D"
d="M201.55 94.07c0-14.832 11.987-26.82 26.818-26.82s26.82 11.988 26.82 26.82s-11.988 26.819-26.82 26.819H201.55zm-13.41 0c0 14.832-11.988 26.819-26.82 26.819c-14.831 0-26.818-11.987-26.818-26.82V26.82C134.502 11.987 146.489 0 161.32 0s26.819 11.987 26.819 26.819z"
/>
<path
fill="#ECB22E"
d="M161.32 201.55c14.832 0 26.82 11.987 26.82 26.818s-11.988 26.82-26.82 26.82c-14.831 0-26.818-11.988-26.818-26.82V201.55zm0-13.41c-14.831 0-26.818-11.988-26.818-26.82c0-14.831 11.987-26.818 26.819-26.818h67.25c14.832 0 26.82 11.987 26.82 26.819s-11.988 26.819-26.82 26.819z"
/>
</svg>
);
}
if (normalizedProvider === "discord") {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={cn("size-5", className)}
{...props}
>
<circle cx="12" cy="12" r="11" fill="#5865F2" />
<path
fill="#FFFFFF"
d="M8.1 8.4c1.4-.6 2.7-.7 3.9-.7s2.5.1 3.9.7c1 1.5 1.5 3.1 1.4 4.8-.9.7-1.8 1.1-2.8 1.3l-.7-1.1c.4-.1.7-.3 1.1-.5-.3.1-.6.3-.9.4-.7.3-1.4.4-2 .4s-1.3-.1-2-.4c-.3-.1-.6-.2-.9-.4.3.2.7.4 1.1.5l-.7 1.1c-1-.2-1.9-.6-2.8-1.3-.1-1.7.4-3.3 1.4-4.8Zm2.1 3.9c.5 0 .9-.5.9-1.1s-.4-1.1-.9-1.1-.9.5-.9 1.1.4 1.1.9 1.1Zm3.6 0c.5 0 .9-.5.9-1.1s-.4-1.1-.9-1.1-.9.5-.9 1.1.4 1.1.9 1.1Z"
/>
</svg>
);
}
if (normalizedProvider === "feishu") {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={cn("size-5", className)}
{...props}
>
<rect
x="1.25"
y="1.25"
width="21.5"
height="21.5"
rx="5.25"
fill="#FFFFFF"
stroke="#E5E7EB"
strokeWidth=".5"
/>
<path
d="M6.1 4.5h8.3c.9 0 1.7.4 2.2 1.1a16 16 0 0 1 2.9 6.2c-1.8-.8-3.8-.9-5.9-.3L5.6 5.8c-.6-.5-.3-1.3.5-1.3Z"
fill="#14D6C5"
/>
<path
d="M3.2 8.9c3.6 3.4 7.5 5.7 11.7 6.8 2.7.7 5.1.4 7-.6-1.6 3.1-5.2 5.4-9.4 5.4-3.4 0-6.7-.9-9.2-2.6a2 2 0 0 1-.9-1.7V9.6c0-.7.4-1 .8-.7Z"
fill="#3370FF"
/>
<path
d="M11 14.1c2.3-3.1 6.1-4.6 10.5-3.3l.8.2-2.6 4.1a6.3 6.3 0 0 1-6 2.9c-1.9-.2-3.9-.8-5.9-1.7 1.1-.7 2.2-1.4 3.2-2.2Z"
fill="#1E3A9F"
/>
</svg>
);
}
if (normalizedProvider === "dingtalk") {
return (
<svg
viewBox="0 0 1024 1024"
aria-hidden="true"
className={cn("size-5", className)}
{...props}
>
<g transform="translate(512 512) scale(1.35) translate(-512 -512)">
<path
fill="#0B86FF"
d="M739 449.3c-1 4.2-3.5 10.4-7 17.8h.1l-.4.7c-20.3 43.1-73.1 127.7-73.1 127.7l-.3-.5-15.5 26.8h74.5L575.1 810l32.3-128h-58.6l20.4-84.7c-16.5 3.9-35.9 9.4-59 16.8 0 0-31.2 18.2-89.9-35 0 0-39.6-34.7-16.6-43.4 9.8-3.7 47.4-8.4 77-12.3 40-5.4 64.6-8.2 64.6-8.2S422 517 392.7 512.5c-29.3-4.6-66.4-53.1-74.3-95.8 0 0-12.2-23.4 26.3-12.3s197.9 43.2 197.9 43.2-207.4-63.3-221.2-78.7-40.6-84.2-37.1-126.5c0 0 1.5-10.5 12.4-7.7 0 0 153.3 69.7 258.1 107.9 104.8 37.9 195.9 57.3 184.2 106.7Z"
/>
</g>
</svg>
);
}
if (normalizedProvider === "wechat") {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={cn("size-5", className)}
{...props}
>
<circle cx="12" cy="12" r="11" fill="#07C160" />
<path
fill="#FFFFFF"
d="M10.4 6.5c-3 0-5.4 2-5.4 4.5 0 1.4.8 2.7 2.1 3.5l-.5 1.8 2-.9c.6.1 1.2.2 1.8.2 3 0 5.4-2 5.4-4.5s-2.4-4.6-5.4-4.6Zm-1.9 3.7a.7.7 0 1 1 0-1.4.7.7 0 0 1 0 1.4Zm3.7 0a.7.7 0 1 1 0-1.4.7.7 0 0 1 0 1.4Z"
/>
<path
fill="#FFFFFF"
fillOpacity=".86"
d="M14.4 12.3c2.5 0 4.6 1.7 4.6 3.8 0 1.1-.6 2.1-1.6 2.8l.4 1.5-1.7-.8c-.5.1-1.1.2-1.7.2-2.5 0-4.6-1.7-4.6-3.8s2.1-3.7 4.6-3.7Zm-1.6 3.1a.6.6 0 1 0 0-1.2.6.6 0 0 0 0 1.2Zm3.1 0a.6.6 0 1 0 0-1.2.6.6 0 0 0 0 1.2Z"
/>
</svg>
);
}
if (normalizedProvider === "wecom") {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={cn("size-5", className)}
{...props}
>
<rect
x="1.25"
y="1.25"
width="21.5"
height="21.5"
rx="5.25"
fill="#FFFFFF"
stroke="#E5E7EB"
strokeWidth=".5"
/>
<path
fill="#168DEB"
d="m17.326 8.158-.003-.007a6.6 6.6 0 0 0-1.178-1.674c-1.266-1.307-3.067-2.19-5.102-2.417a9.3 9.3 0 0 0-2.124 0h-.001c-2.061.228-3.882 1.107-5.14 2.405a6.7 6.7 0 0 0-1.194 1.682A5.7 5.7 0 0 0 2 10.657c0 1.106.332 2.218.988 3.201l.006.01c.391.594 1.092 1.39 1.637 1.83l.983.793-.208.875.527-.267.708-.358.761.225c.467.137.955.227 1.517.29h.005q.515.06 1.026.059c.355 0 .724-.02 1.095-.06a9 9 0 0 0 1.346-.258c.095.7.43 1.337.932 1.81-.658.208-1.352.358-2.061.436-.442.048-.883.072-1.312.072q-.627 0-1.253-.072a10.7 10.7 0 0 1-1.861-.36l-2.84 1.438s-.29.131-.44.131c-.418 0-.702-.285-.702-.704 0-.252.067-.598.128-.84l.394-1.653c-.728-.586-1.563-1.544-2.052-2.287A7.76 7.76 0 0 1 0 10.658a7.7 7.7 0 0 1 .787-3.39 8.7 8.7 0 0 1 1.551-2.19c1.61-1.665 3.878-2.73 6.359-3.006a11.3 11.3 0 0 1 2.565 0c2.47.275 4.712 1.353 6.323 3.017a8.6 8.6 0 0 1 1.539 2.192c.466.945.769 1.937.769 2.978a3.06 3.06 0 0 0-2-.005c-.001-.644-.189-1.329-.564-2.09zm4.125 6.977-.024-.024-.024-.018-.024-.018-.096-.095a4.24 4.24 0 0 1-1.169-2.192q0-.038-.006-.075l-.006-.056-.035-.144a1.3 1.3 0 0 0-.358-.61 1.386 1.386 0 0 0-1.957 0 1.4 1.4 0 0 0 0 1.963c.191.191.418.311.668.371.024.012.06.012.084.012q.019 0 .041.006.023.005.042.006a4.24 4.24 0 0 1 2.231 1.186c.048.048.096.095.131.143a.323.323 0 0 0 .466 0 .35.35 0 0 0 .036-.455m-1.05 4.37-.025.025c-.119.096-.31.096-.453-.036a.326.326 0 0 1 0-.467c.047-.036.094-.083.141-.13l.002-.002a4.27 4.27 0 0 0 1.187-2.28q.005-.024.006-.043c0-.024 0-.06.012-.084a1.386 1.386 0 0 1 2.326-.67 1.4 1.4 0 0 1 0 1.964c-.167.18-.382.299-.608.359l-.143.036-.057.005q-.035.006-.075.007a4.2 4.2 0 0 0-2.183 1.173l-.095.096q-.009.01-.018.024t-.018.024m-4.392-1.053.024.024.024.018q.015.009.024.018l.096.096a4.25 4.25 0 0 1 1.169 2.19q0 .04.006.076.005.03.006.057l.035.143c.06.228.18.443.358.611.537.539 1.42.539 1.957 0a1.4 1.4 0 0 0 0-1.964 1.4 1.4 0 0 0-.668-.371c-.024-.012-.06-.012-.084-.012q-.018 0-.041-.006l-.042-.006a4.25 4.25 0 0 1-2.231-1.185 1.4 1.4 0 0 1-.131-.144.323.323 0 0 0-.466 0 .325.325 0 0 0-.036.455m1.039-4.358.024-.024a.32.32 0 0 1 .453.035.326.326 0 0 1 0 .467c-.047.036-.094.083-.141.13l-.002.002a4.27 4.27 0 0 0-1.187 2.281l-.006.042c0 .024 0 .06-.012.084a1.386 1.386 0 0 1-2.326.67 1.4 1.4 0 0 1 0-1.963c.166-.18.381-.3.608-.36l.143-.035q.026 0 .056-.006.037-.005.075-.006a4.2 4.2 0 0 0 2.183-1.174l.096-.095.018-.025z"
/>
</svg>
);
}
return (
<MessageCircleIcon aria-hidden="true" className={cn("size-5", className)} />
);
}
@@ -1,159 +0,0 @@
"use client";
import { LoaderCircleIcon } from "lucide-react";
import {
type CSSProperties,
type FormEvent,
useEffect,
useMemo,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import type {
ChannelProvider,
ChannelRuntimeConfigValues,
} from "@/core/channels/types";
import { useI18n } from "@/core/i18n/hooks";
type ChannelRuntimeConfigDialogProps = {
provider: ChannelProvider | null;
open: boolean;
submitting: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (
provider: ChannelProvider,
values: ChannelRuntimeConfigValues,
) => void;
};
type SecretInputStyle = CSSProperties & {
WebkitTextSecurity?: "disc";
};
const SECRET_INPUT_STYLE: SecretInputStyle = {
WebkitTextSecurity: "disc",
};
export function ChannelRuntimeConfigDialog({
provider,
open,
submitting,
onOpenChange,
onSubmit,
}: ChannelRuntimeConfigDialogProps) {
const { t } = useI18n();
const [values, setValues] = useState<ChannelRuntimeConfigValues>({});
const fields = useMemo(
() => provider?.credential_fields ?? [],
[provider?.credential_fields],
);
const credentialValues = useMemo<ChannelRuntimeConfigValues>(
() => provider?.credential_values ?? {},
[provider?.credential_values],
);
useEffect(() => {
if (!open || !provider) {
setValues({});
return;
}
setValues(
Object.fromEntries(
fields.map((field) => [field.name, credentialValues[field.name] ?? ""]),
) as ChannelRuntimeConfigValues,
);
}, [credentialValues, fields, open, provider]);
if (!provider) {
return null;
}
const isEditing = provider.configured;
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
onSubmit(provider, values);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<form onSubmit={handleSubmit} className="space-y-4">
<DialogHeader>
<DialogTitle>
{isEditing
? t.channels.setupEditTitle(provider.display_name)
: t.channels.setupTitle(provider.display_name)}
</DialogTitle>
<DialogDescription>{t.channels.setupDescription}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{fields.map((field) => {
const inputId = `channel-${provider.provider}-${field.name}`;
const isSecretField = field.type === "password";
return (
<div key={field.name} className="space-y-1.5">
<label
htmlFor={inputId}
className="text-sm leading-none font-medium"
>
{field.label}
</label>
<Input
id={inputId}
type="text"
value={values[field.name] ?? ""}
required={field.required}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
data-1p-ignore={isSecretField ? "true" : undefined}
data-bwignore={isSecretField ? "true" : undefined}
data-form-type={isSecretField ? "other" : undefined}
data-lpignore={isSecretField ? "true" : undefined}
style={isSecretField ? SECRET_INPUT_STYLE : undefined}
onChange={(event) => {
setValues((current) => ({
...current,
[field.name]: event.target.value,
}));
}}
/>
</div>
);
})}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={submitting}
onClick={() => onOpenChange(false)}
>
{t.common.cancel}
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? (
<LoaderCircleIcon className="animate-spin" />
) : null}
{isEditing ? t.channels.saveChanges : t.channels.saveAndConnect}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -1,219 +0,0 @@
"use client";
import { CheckIcon, LoaderCircleIcon } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { Skeleton } from "@/components/ui/skeleton";
import {
useConfigureChannelProvider,
useChannelProviders,
useConnectChannelProvider,
} from "@/core/channels/hooks";
import {
closeConnectWindow,
openConnectUrl,
prepareConnectWindow,
} from "@/core/channels/open-connect-url";
import type { ChannelProvider } from "@/core/channels/types";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { ChannelProviderIcon } from "./channel-provider-icon";
import { ChannelRuntimeConfigDialog } from "./channel-runtime-config-dialog";
function providerCanConnect(provider: ChannelProvider): boolean {
return (
(provider.connectable ?? (provider.enabled && provider.configured)) &&
provider.connection_status !== "connected"
);
}
function providerCanEditRuntimeConfig(provider: ChannelProvider): boolean {
return provider.enabled && (provider.credential_fields?.length ?? 0) > 0;
}
function getProviderUnavailableReason(
provider: ChannelProvider,
t: ReturnType<typeof useI18n>["t"],
): string | undefined {
if (provider.unavailable_reason) {
return provider.unavailable_reason;
}
if (!provider.enabled) {
return t.channels.disabled;
}
if (!provider.configured) {
return t.channels.unconfigured;
}
return provider.unavailable_reason ?? undefined;
}
function providerNeedsRuntimeConfig(provider: ChannelProvider): boolean {
return (
provider.enabled &&
!provider.configured &&
(provider.credential_fields?.length ?? 0) > 0
);
}
export function WorkspaceChannelsList() {
const { open: isSidebarOpen } = useSidebar();
const { t } = useI18n();
const { enabled, providers, isLoading, error } = useChannelProviders();
const connectMutation = useConnectChannelProvider();
const configureMutation = useConfigureChannelProvider();
const [setupProvider, setSetupProvider] = useState<ChannelProvider | null>(
null,
);
const visibleProviders = providers.filter((provider) => provider.enabled);
const startConnect = (
provider: ChannelProvider,
preparedWindow?: Window | null,
) => {
const connectWindow =
preparedWindow !== undefined
? preparedWindow
: provider.auth_mode === "deep_link"
? prepareConnectWindow()
: null;
void connectMutation
.mutateAsync(provider.provider)
.then((result) => {
if (result.url) {
openConnectUrl(result.url, connectWindow);
return;
}
closeConnectWindow(connectWindow);
toast.success(result.instruction);
})
.catch((error) => {
closeConnectWindow(connectWindow);
toast.error(
error instanceof Error ? error.message : t.channels.unavailable,
);
});
};
if (!isSidebarOpen) {
return null;
}
if (isLoading) {
return (
<SidebarGroup className="pt-0">
<SidebarGroupLabel>{t.sidebar.channels}</SidebarGroupLabel>
<div className="space-y-2 px-2 py-1">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
</SidebarGroup>
);
}
if (error || !enabled || visibleProviders.length === 0) {
return null;
}
return (
<SidebarGroup className="pt-0">
<SidebarGroupLabel>{t.sidebar.channels}</SidebarGroupLabel>
<SidebarMenu>
{visibleProviders.map((provider) => {
const canEditRuntimeConfig = providerCanEditRuntimeConfig(provider);
const isConnected = provider.connection_status === "connected";
const isPending =
(connectMutation.isPending &&
connectMutation.variables === provider.provider) ||
(configureMutation.isPending &&
configureMutation.variables?.provider === provider.provider);
const canConnect = providerCanConnect(provider);
const unavailableReason = getProviderUnavailableReason(provider, t);
return (
<SidebarMenuItem key={provider.provider}>
<div className="hover:bg-sidebar-accent flex h-10 items-center gap-2 rounded-md px-2 transition-colors">
<ChannelProviderIcon
provider={provider.provider}
className="size-5 shrink-0"
/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{provider.display_name}
</span>
<Button
type="button"
size="sm"
variant={isConnected ? "outline" : "secondary"}
className={cn(
"h-8 w-24 px-2 text-xs",
isConnected && "gap-1",
)}
disabled={isPending}
title={unavailableReason}
onClick={() => {
if (
providerNeedsRuntimeConfig(provider) ||
canEditRuntimeConfig
) {
setSetupProvider(provider);
return;
}
if (!canConnect) {
toast.error(unavailableReason ?? t.channels.unavailable);
return;
}
startConnect(provider);
}}
>
{isPending ? (
<LoaderCircleIcon className="size-3.5 animate-spin" />
) : isConnected ? (
<CheckIcon className="size-3.5" />
) : null}
<span>
{isConnected ? t.channels.connected : t.channels.connect}
</span>
</Button>
</div>
</SidebarMenuItem>
);
})}
</SidebarMenu>
<ChannelRuntimeConfigDialog
provider={setupProvider}
open={setupProvider !== null}
submitting={configureMutation.isPending}
onOpenChange={(open) => {
if (!open) {
setSetupProvider(null);
}
}}
onSubmit={(provider, values) => {
void configureMutation
.mutateAsync({ provider: provider.provider, values })
.then(() => {
setSetupProvider(null);
toast.success(t.channels.connected);
})
.catch((error) => {
toast.error(
error instanceof Error ? error.message : t.channels.unavailable,
);
});
}}
/>
</SidebarGroup>
);
}
@@ -55,16 +55,10 @@ import {
useRenameThread,
} from "@/core/threads/hooks";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
channelSourceOfThread,
pathOfThread,
titleOfThread,
} from "@/core/threads/utils";
import { pathOfThread, titleOfThread } from "@/core/threads/utils";
import { env } from "@/env";
import { isIMEComposing } from "@/lib/ime";
import { ThreadChannelIcon } from "./thread-channel-source";
export function RecentChatList() {
const { t } = useI18n();
const router = useRouter();
@@ -216,7 +210,6 @@ export function RecentChatList() {
<div className="flex w-full flex-col gap-1">
{threads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
const channelSource = channelSourceOfThread(thread);
return (
<SidebarMenuItem
key={thread.thread_id}
@@ -225,23 +218,10 @@ export function RecentChatList() {
<SidebarMenuButton isActive={isActive} asChild>
<div>
<Link
className="text-muted-foreground flex min-w-0 items-center gap-1.5 pr-7 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
<ThreadChannelIcon source={channelSource} />
<span className="min-w-0 truncate">
{titleOfThread(thread)}
</span>
{channelSource && (
<span
className="bg-muted text-muted-foreground ml-auto inline-flex h-5 max-w-14 shrink-0 items-center rounded-md px-1.5 text-[10px] font-medium"
title={`${channelSource.label} channel`}
>
<span className="truncate">
{channelSource.label}
</span>
</span>
)}
{titleOfThread(thread)}
</Link>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
@@ -1,364 +0,0 @@
"use client";
import {
AlertCircleIcon,
CheckCircle2Icon,
LoaderCircleIcon,
PlugIcon,
UnplugIcon,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemMedia,
ItemTitle,
} from "@/components/ui/item";
import {
useConfigureChannelProvider,
useChannelConnections,
useChannelProviders,
useConnectChannelProvider,
useDisconnectChannelProvider,
} from "@/core/channels/hooks";
import {
closeConnectWindow,
openConnectUrl,
prepareConnectWindow,
} from "@/core/channels/open-connect-url";
import type { ChannelConnection, ChannelProvider } from "@/core/channels/types";
import { useI18n } from "@/core/i18n/hooks";
import { cn } from "@/lib/utils";
import { ChannelProviderIcon } from "../channels/channel-provider-icon";
import { ChannelRuntimeConfigDialog } from "../channels/channel-runtime-config-dialog";
import { SettingsSection } from "./settings-section";
function getProviderDescription(
provider: ChannelProvider,
descriptions: Record<string, string>,
): string {
return descriptions[provider.provider] ?? provider.display_name;
}
function getConnectionLabel(connection: ChannelConnection): string | null {
const account = connection.external_account_name;
const workspace = connection.workspace_name;
if (account && workspace) {
return `${account} · ${workspace}`;
}
return account ?? workspace ?? connection.external_account_id ?? null;
}
function getStatusLabel(
provider: ChannelProvider,
connection: ChannelConnection | undefined,
t: ReturnType<typeof useI18n>["t"],
): string {
if (!provider.enabled) {
return t.channels.disabled;
}
if (!provider.configured) {
return t.channels.unconfigured;
}
if (provider.unavailable_reason) {
return t.channels.unavailableShort;
}
const status = connection?.status ?? provider.connection_status;
if (status === "connected") {
return t.channels.connected;
}
if (status === "pending") {
return t.channels.pending;
}
if (status === "revoked") {
return t.channels.revoked;
}
return t.channels.notConnected;
}
function getProviderUnavailableReason(
provider: ChannelProvider,
t: ReturnType<typeof useI18n>["t"],
): string | undefined {
if (provider.unavailable_reason) {
return provider.unavailable_reason;
}
if (!provider.enabled) {
return t.channels.disabled;
}
if (!provider.configured) {
return t.channels.unconfigured;
}
return provider.unavailable_reason ?? undefined;
}
function providerNeedsRuntimeConfig(provider: ChannelProvider): boolean {
return (
provider.enabled &&
!provider.configured &&
(provider.credential_fields?.length ?? 0) > 0
);
}
function providerCanEditRuntimeConfig(provider: ChannelProvider): boolean {
return provider.enabled && (provider.credential_fields?.length ?? 0) > 0;
}
function ChannelProviderItem({
provider,
connection,
}: {
provider: ChannelProvider;
connection?: ChannelConnection;
}) {
const { t } = useI18n();
const connectMutation = useConnectChannelProvider();
const configureMutation = useConfigureChannelProvider();
const disconnectProviderMutation = useDisconnectChannelProvider();
const [setupOpen, setSetupOpen] = useState(false);
const isConnected =
connection?.status === "connected" ||
provider.connection_status === "connected";
const canEditRuntimeConfig = providerCanEditRuntimeConfig(provider);
const canConnect =
(provider.connectable ?? (provider.enabled && provider.configured)) &&
!isConnected;
const isConnecting =
(connectMutation.isPending &&
connectMutation.variables === provider.provider) ||
(configureMutation.isPending &&
configureMutation.variables?.provider === provider.provider);
const isDisconnecting =
disconnectProviderMutation.isPending &&
disconnectProviderMutation.variables === provider.provider;
const connectionLabel = connection ? getConnectionLabel(connection) : null;
const statusLabel = getStatusLabel(provider, connection, t);
const unavailableReason = getProviderUnavailableReason(provider, t);
const startConnect = (
connectProvider: ChannelProvider,
preparedWindow?: Window | null,
) => {
const connectWindow =
preparedWindow !== undefined
? preparedWindow
: connectProvider.auth_mode === "deep_link"
? prepareConnectWindow()
: null;
void connectMutation
.mutateAsync(connectProvider.provider)
.then((result) => {
if (result.url) {
openConnectUrl(result.url, connectWindow);
return;
}
closeConnectWindow(connectWindow);
toast.success(result.instruction);
})
.catch((error) => {
closeConnectWindow(connectWindow);
toast.error(
error instanceof Error ? error.message : t.channels.unavailable,
);
});
};
return (
<>
<Item variant="outline" className="w-full items-start">
<ItemMedia variant="icon" className="bg-background">
<ChannelProviderIcon
provider={provider.provider}
className="size-5"
/>
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="w-full">
<span className="truncate">{provider.display_name}</span>
<Badge
variant={isConnected ? "default" : "outline"}
className={cn(!isConnected && "text-muted-foreground")}
>
{isConnected ? <CheckCircle2Icon /> : <AlertCircleIcon />}
{statusLabel}
</Badge>
</ItemTitle>
<ItemDescription className="line-clamp-none">
{getProviderDescription(provider, t.channels.descriptions)}
{connectionLabel
? ` ${t.channels.connectedAs(connectionLabel)}`
: ""}
{!isConnected && provider.unavailable_reason
? ` ${provider.unavailable_reason}`
: ""}
</ItemDescription>
</ItemContent>
<ItemActions className="ml-auto">
{isConnected ? (
<>
{canEditRuntimeConfig ? (
<Button
type="button"
variant="outline"
size="sm"
disabled={isConnecting || isDisconnecting}
onClick={() => setSetupOpen(true)}
>
{isConnecting ? (
<LoaderCircleIcon className="animate-spin" />
) : (
<PlugIcon />
)}
{t.channels.modify}
</Button>
) : null}
<Button
type="button"
variant="outline"
size="sm"
disabled={isDisconnecting}
onClick={() => {
void disconnectProviderMutation
.mutateAsync(provider.provider)
.then(() => {
toast.success(t.channels.revoked);
})
.catch((error) => {
toast.error(
error instanceof Error
? error.message
: t.channels.unavailable,
);
});
}}
>
{isDisconnecting ? (
<LoaderCircleIcon className="animate-spin" />
) : (
<UnplugIcon />
)}
{t.channels.disconnect}
</Button>
</>
) : (
<Button
type="button"
size="sm"
disabled={isConnecting}
title={unavailableReason}
onClick={() => {
if (
providerNeedsRuntimeConfig(provider) ||
canEditRuntimeConfig
) {
setSetupOpen(true);
return;
}
if (!canConnect) {
toast.error(unavailableReason ?? t.channels.unavailable);
return;
}
startConnect(provider);
}}
>
{isConnecting ? (
<LoaderCircleIcon className="animate-spin" />
) : (
<PlugIcon />
)}
{connection?.status === "revoked"
? t.channels.reconnect
: t.channels.connect}
</Button>
)}
</ItemActions>
</Item>
<ChannelRuntimeConfigDialog
provider={provider}
open={setupOpen}
submitting={configureMutation.isPending}
onOpenChange={setSetupOpen}
onSubmit={(submitProvider, values) => {
void configureMutation
.mutateAsync({ provider: submitProvider.provider, values })
.then(() => {
setSetupOpen(false);
toast.success(t.channels.connected);
})
.catch((error) => {
toast.error(
error instanceof Error ? error.message : t.channels.unavailable,
);
});
}}
/>
</>
);
}
export function ChannelsSettingsPage() {
const { t } = useI18n();
const {
enabled,
providers,
isLoading: providersLoading,
error: providersError,
} = useChannelProviders();
const {
connections,
isLoading: connectionsLoading,
error: connectionsError,
} = useChannelConnections();
const isLoading = providersLoading || connectionsLoading;
const error = providersError ?? connectionsError;
const visibleProviders = providers.filter((provider) => provider.enabled);
const connectionByProvider = new Map<string, ChannelConnection>();
for (const connection of connections) {
const existing = connectionByProvider.get(connection.provider);
if (!existing || connection.status === "connected") {
connectionByProvider.set(connection.provider, connection);
}
}
return (
<SettingsSection
title={t.settings.channels.title}
description={t.settings.channels.description}
>
{isLoading ? (
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
) : error ? (
<div className="text-destructive text-sm">{t.channels.unavailable}</div>
) : !enabled ? (
<div className="text-muted-foreground text-sm">
{t.settings.channels.disabled}
</div>
) : visibleProviders.length === 0 ? (
<div className="text-muted-foreground text-sm">
{t.settings.channels.disabled}
</div>
) : (
<div className="flex w-full flex-col gap-4">
{visibleProviders.map((provider) => (
<ChannelProviderItem
key={provider.provider}
provider={provider}
connection={connectionByProvider.get(provider.provider)}
/>
))}
</div>
)}
</SettingsSection>
);
}
@@ -2,7 +2,6 @@
import {
BellIcon,
CableIcon,
InfoIcon,
BrainIcon,
PaletteIcon,
@@ -22,7 +21,6 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
import { ChannelsSettingsPage } from "@/components/workspace/settings/channels-settings-page";
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
@@ -33,7 +31,6 @@ import { cn } from "@/lib/utils";
type SettingsSection =
| "account"
| "appearance"
| "channels"
| "memory"
| "tools"
| "skills"
@@ -75,11 +72,6 @@ export function SettingsDialog(props: SettingsDialogProps) {
label: t.settings.sections.notification,
icon: BellIcon,
},
{
id: "channels",
label: t.settings.sections.channels,
icon: CableIcon,
},
{
id: "memory",
label: t.settings.sections.memory,
@@ -92,7 +84,6 @@ export function SettingsDialog(props: SettingsDialogProps) {
[
t.settings.sections.account,
t.settings.sections.appearance,
t.settings.sections.channels,
t.settings.sections.memory,
t.settings.sections.tools,
t.settings.sections.skills,
@@ -152,7 +143,6 @@ export function SettingsDialog(props: SettingsDialogProps) {
/>
)}
{activeSection === "notification" && <NotificationSettingsPage />}
{activeSection === "channels" && <ChannelsSettingsPage />}
{activeSection === "about" && <AboutSettingsPage />}
</div>
</ScrollArea>
@@ -1,56 +0,0 @@
"use client";
import { ChannelProviderIcon } from "@/components/workspace/channels/channel-provider-icon";
import type { ChannelThreadSource } from "@/core/threads/utils";
import { cn } from "@/lib/utils";
type ThreadChannelIconProps = {
source: ChannelThreadSource | null;
className?: string;
};
export function ThreadChannelIcon({
source,
className,
}: ThreadChannelIconProps) {
if (!source) {
return null;
}
return (
<span
aria-label={`${source.label} channel`}
title={`${source.label} channel`}
className={cn("inline-flex shrink-0 items-center", className)}
>
<ChannelProviderIcon provider={source.provider} className="size-4" />
</span>
);
}
type ThreadChannelBadgeProps = {
source: ChannelThreadSource | null;
className?: string;
};
export function ThreadChannelBadge({
source,
className,
}: ThreadChannelBadgeProps) {
if (!source) {
return null;
}
return (
<span
className={cn(
"bg-muted text-muted-foreground inline-flex h-6 max-w-32 items-center gap-1 rounded-md px-2 text-xs font-medium",
className,
)}
title={`${source.label} channel`}
>
<ChannelProviderIcon provider={source.provider} className="size-3.5" />
<span className="truncate">{source.label}</span>
</span>
);
}
@@ -9,7 +9,6 @@ import {
useSidebar,
} from "@/components/ui/sidebar";
import { WorkspaceChannelsList } from "./channels/workspace-channels-list";
import { RecentChatList } from "./recent-chat-list";
import { WorkspaceHeader } from "./workspace-header";
import { WorkspaceNavChatList } from "./workspace-nav-chat-list";
@@ -27,7 +26,6 @@ export function WorkspaceSidebar({
</SidebarHeader>
<SidebarContent>
<WorkspaceNavChatList />
<WorkspaceChannelsList />
{isSidebarOpen && <RecentChatList />}
</SidebarContent>
<SidebarFooter>
+2 -2
View File
@@ -1,8 +1,8 @@
import type { User } from "./types";
export const AUTH_DISABLED_USER: User = {
id: "default",
email: "default@test.local",
id: "e2e-user",
email: "e2e@test.local",
system_role: "admin",
needs_setup: false,
};
-117
View File
@@ -1,117 +0,0 @@
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import type {
ChannelConnectResponse,
ChannelConnection,
ChannelConnectionsResponse,
ChannelProviderId,
ChannelProvider,
ChannelProvidersResponse,
ChannelRuntimeConfigValues,
} from "./types";
function channelsUrl(path: string): string {
return `${getBackendBaseURL()}/api/channels${path}`;
}
async function throwChannelApiError(
response: Response,
fallback: string,
): Promise<never> {
const body = (await response.json().catch(() => ({}))) as {
detail?: unknown;
};
throw new Error(typeof body.detail === "string" ? body.detail : fallback);
}
export async function listChannelProviders(): Promise<ChannelProvidersResponse> {
const response = await fetch(channelsUrl("/providers"));
if (!response.ok) {
await throwChannelApiError(
response,
`Failed to load channel providers: ${response.statusText}`,
);
}
return response.json() as Promise<ChannelProvidersResponse>;
}
export async function listChannelConnections(): Promise<ChannelConnection[]> {
const response = await fetch(channelsUrl("/connections"));
if (!response.ok) {
await throwChannelApiError(
response,
`Failed to load channel connections: ${response.statusText}`,
);
}
const data = (await response.json()) as ChannelConnectionsResponse;
return data.connections;
}
export async function connectChannelProvider(
provider: ChannelProviderId,
): Promise<ChannelConnectResponse> {
const response = await fetch(
channelsUrl(`/${encodeURIComponent(provider)}/connect`),
{ method: "POST" },
);
if (!response.ok) {
await throwChannelApiError(
response,
`Failed to connect ${provider}: ${response.statusText}`,
);
}
return response.json() as Promise<ChannelConnectResponse>;
}
export async function configureChannelProvider(
provider: ChannelProviderId,
values: ChannelRuntimeConfigValues,
): Promise<ChannelProvider> {
const response = await fetch(
channelsUrl(`/${encodeURIComponent(provider)}/runtime-config`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ values }),
},
);
if (!response.ok) {
await throwChannelApiError(
response,
`Failed to configure ${provider}: ${response.statusText}`,
);
}
return response.json() as Promise<ChannelProvider>;
}
export async function disconnectChannelConnection(
connectionId: string,
): Promise<void> {
const response = await fetch(
channelsUrl(`/connections/${encodeURIComponent(connectionId)}`),
{ method: "DELETE" },
);
if (!response.ok) {
await throwChannelApiError(
response,
`Failed to disconnect channel: ${response.statusText}`,
);
}
}
export async function disconnectChannelProvider(
provider: ChannelProviderId,
): Promise<ChannelProvider> {
const response = await fetch(
channelsUrl(`/${encodeURIComponent(provider)}/runtime-config`),
{ method: "DELETE" },
);
if (!response.ok) {
await throwChannelApiError(
response,
`Failed to disconnect ${provider}: ${response.statusText}`,
);
}
return response.json() as Promise<ChannelProvider>;
}
-96
View File
@@ -1,96 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
configureChannelProvider,
connectChannelProvider,
disconnectChannelConnection,
disconnectChannelProvider,
listChannelConnections,
listChannelProviders,
} from "./api";
import type { ChannelProviderId, ChannelRuntimeConfigValues } from "./types";
export const channelProviderQueryKey = ["channelProviders"] as const;
export const channelConnectionsQueryKey = ["channelConnections"] as const;
export function useChannelProviders() {
const { data, isLoading, error } = useQuery({
queryKey: channelProviderQueryKey,
queryFn: () => listChannelProviders(),
});
return {
enabled: data?.enabled ?? false,
providers: data?.providers ?? [],
isLoading,
error,
};
}
export function useChannelConnections() {
const { data, isLoading, error } = useQuery({
queryKey: channelConnectionsQueryKey,
queryFn: () => listChannelConnections(),
});
return { connections: data ?? [], isLoading, error };
}
export function useConnectChannelProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (provider: ChannelProviderId) =>
connectChannelProvider(provider),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey });
void queryClient.invalidateQueries({
queryKey: channelConnectionsQueryKey,
});
},
});
}
export function useConfigureChannelProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
provider,
values,
}: {
provider: ChannelProviderId;
values: ChannelRuntimeConfigValues;
}) => configureChannelProvider(provider, values),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey });
void queryClient.invalidateQueries({
queryKey: channelConnectionsQueryKey,
});
},
});
}
export function useDisconnectChannelConnection() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (connectionId: string) =>
disconnectChannelConnection(connectionId),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey });
void queryClient.invalidateQueries({
queryKey: channelConnectionsQueryKey,
});
},
});
}
export function useDisconnectChannelProvider() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (provider: ChannelProviderId) =>
disconnectChannelProvider(provider),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey });
void queryClient.invalidateQueries({
queryKey: channelConnectionsQueryKey,
});
},
});
}
@@ -1,27 +0,0 @@
export type ChannelConnectWindow = Window | null;
export function prepareConnectWindow(): ChannelConnectWindow {
const opened = window.open("about:blank", "_blank");
if (opened) {
opened.opener = null;
}
return opened;
}
export function openConnectUrl(
url: string,
connectWindow: ChannelConnectWindow = prepareConnectWindow(),
) {
if (connectWindow && !connectWindow.closed) {
connectWindow.location.replace(url);
return;
}
window.location.assign(url);
}
export function closeConnectWindow(connectWindow: ChannelConnectWindow) {
if (connectWindow && !connectWindow.closed) {
connectWindow.close();
}
}
-53
View File
@@ -1,53 +0,0 @@
export type ChannelProviderId = "telegram" | "slack" | "discord" | string;
export interface ChannelCredentialField {
name: string;
label: string;
type: string;
required: boolean;
}
export type ChannelRuntimeConfigValues = Record<string, string>;
export interface ChannelProvider {
provider: ChannelProviderId;
display_name: string;
enabled: boolean;
configured: boolean;
connectable?: boolean;
unavailable_reason?: string | null;
auth_mode: string;
connection_status: string;
credential_fields: ChannelCredentialField[];
credential_values?: ChannelRuntimeConfigValues;
}
export interface ChannelProvidersResponse {
enabled: boolean;
providers: ChannelProvider[];
}
export interface ChannelConnection {
id: string;
provider: ChannelProviderId;
status: string;
external_account_id?: string | null;
external_account_name?: string | null;
workspace_id?: string | null;
workspace_name?: string | null;
scopes: string[];
metadata: Record<string, unknown>;
}
export interface ChannelConnectionsResponse {
connections: ChannelConnection[];
}
export interface ChannelConnectResponse {
provider: ChannelProviderId;
mode: string;
url?: string | null;
code: string;
instruction: string;
expires_in: number;
}
-42
View File
@@ -170,7 +170,6 @@ export const enUS: Translations = {
sidebar: {
newChat: "New chat",
chats: "Chats",
channels: "Channels",
recentChats: "Recent chats",
demoChats: "Demo chats",
agents: "Agents",
@@ -258,39 +257,6 @@ export const enUS: Translations = {
loadOlderChats: "Load older chats",
},
// Channels
channels: {
title: "Channels",
connect: "Connect",
modify: "Modify",
reconnect: "Reconnect",
disconnect: "Disconnect",
connected: "Connected",
notConnected: "Not connected",
pending: "Pending",
revoked: "Disconnected",
disabled: "Disabled",
unconfigured: "Not configured",
unavailable: "Channel connections are unavailable right now.",
unavailableShort: "Unavailable",
setupTitle: (name: string) => `Connect ${name}`,
setupEditTitle: (name: string) => `Modify ${name}`,
setupDescription:
"Enter the values needed by this server process. They are not written to config.yaml.",
saveAndConnect: "Save and connect",
saveChanges: "Save changes",
descriptions: {
telegram: "Telegram direct messages through your DeerFlow bot.",
slack: "Slack workspace messages and mentions.",
discord: "Discord server messages through your DeerFlow bot.",
feishu: "Feishu and Lark messages through your DeerFlow app.",
dingtalk: "DingTalk Stream Push messages through your DeerFlow bot.",
wechat: "WeChat iLink messages through your DeerFlow bot.",
wecom: "WeCom messages through your DeerFlow AI bot.",
},
connectedAs: (name: string) => `Connected as ${name}.`,
},
// Page titles (document title)
pages: {
appName: "DeerFlow",
@@ -391,7 +357,6 @@ export const enUS: Translations = {
sections: {
account: "Account",
appearance: "Appearance",
channels: "Channels",
memory: "Memory",
tools: "Tools",
skills: "Skills",
@@ -494,13 +459,6 @@ export const enUS: Translations = {
title: "Tools",
description: "Manage the configuration and enabled status of MCP tools.",
},
channels: {
title: "Channels",
description:
"Connect IM accounts that can send messages to DeerFlow from outside the browser.",
disabled:
"Channel connections are not enabled on this server. Ask an administrator to enable channel_connections.",
},
skills: {
title: "Agent Skills",
description:
-31
View File
@@ -117,7 +117,6 @@ export interface Translations {
chats: string;
demoChats: string;
agents: string;
channels: string;
};
// Agents
@@ -189,30 +188,6 @@ export interface Translations {
loadOlderChats: string;
};
// Channels
channels: {
title: string;
connect: string;
modify: string;
reconnect: string;
disconnect: string;
connected: string;
notConnected: string;
pending: string;
revoked: string;
disabled: string;
unconfigured: string;
unavailable: string;
unavailableShort: string;
setupTitle: (name: string) => string;
setupEditTitle: (name: string) => string;
setupDescription: string;
saveAndConnect: string;
saveChanges: string;
descriptions: Record<string, string>;
connectedAs: (name: string) => string;
};
// Page titles (document title)
pages: {
appName: string;
@@ -309,7 +284,6 @@ export interface Translations {
sections: {
account: string;
appearance: string;
channels: string;
memory: string;
tools: string;
skills: string;
@@ -405,11 +379,6 @@ export interface Translations {
title: string;
description: string;
};
channels: {
title: string;
description: string;
disabled: string;
};
skills: {
title: string;
description: string;
-41
View File
@@ -164,7 +164,6 @@ export const zhCN: Translations = {
sidebar: {
newChat: "新对话",
chats: "对话",
channels: "渠道",
recentChats: "最近的对话",
demoChats: "演示对话",
agents: "智能体",
@@ -246,39 +245,6 @@ export const zhCN: Translations = {
loadOlderChats: "加载更早的对话",
},
// Channels
channels: {
title: "渠道",
connect: "连接",
modify: "修改",
reconnect: "重新连接",
disconnect: "断开连接",
connected: "已连接",
notConnected: "未连接",
pending: "待完成",
revoked: "已断开",
disabled: "已停用",
unconfigured: "未配置",
unavailable: "当前无法使用渠道连接。",
unavailableShort: "不可用",
setupTitle: (name: string) => `连接 ${name}`,
setupEditTitle: (name: string) => `修改 ${name}`,
setupDescription:
"填写当前服务进程需要的配置值。这些内容不会写入 config.yaml。",
saveAndConnect: "保存并连接",
saveChanges: "保存修改",
descriptions: {
telegram: "通过 DeerFlow Bot 接收 Telegram 私聊消息。",
slack: "接收 Slack 工作区消息和提及。",
discord: "通过 DeerFlow Bot 接收 Discord 服务器消息。",
feishu: "通过 DeerFlow 应用接收飞书和 Lark 消息。",
dingtalk: "通过 DeerFlow Bot 接收钉钉 Stream Push 消息。",
wechat: "通过 DeerFlow Bot 接收微信 iLink 消息。",
wecom: "通过 DeerFlow AI Bot 接收企业微信消息。",
},
connectedAs: (name: string) => `已连接为 ${name}`,
},
// Page titles (document title)
pages: {
appName: "DeerFlow",
@@ -375,7 +341,6 @@ export const zhCN: Translations = {
sections: {
account: "账号",
appearance: "外观",
channels: "渠道",
memory: "记忆",
tools: "工具",
skills: "技能",
@@ -475,12 +440,6 @@ export const zhCN: Translations = {
title: "工具",
description: "管理 MCP 工具的配置和启用状态。",
},
channels: {
title: "渠道",
description: "连接可在浏览器外向 DeerFlow 发送消息的即时通讯账号。",
disabled:
"当前服务器未启用渠道连接。请联系管理员开启 channel_connections。",
},
skills: {
title: "技能",
description: "管理 Agent Skill 配置和启用状态。",
+60 -7
View File
@@ -26,11 +26,6 @@ import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";
import { fetchThreadTokenUsage } from "./api";
import {
buildThreadsSearchQueryOptions,
DEFAULT_THREAD_SEARCH_PARAMS,
type ThreadSearchParams,
} from "./thread-search-query";
import { threadTokenUsageQueryKey } from "./token-usage";
import type {
AgentThread,
@@ -1131,11 +1126,69 @@ export function useThreadHistory(
}
export function useThreads(
params: ThreadSearchParams = DEFAULT_THREAD_SEARCH_PARAMS,
params: Parameters<ThreadsClient["search"]>[0] = {
limit: 50,
sortBy: "updated_at",
sortOrder: "desc",
select: ["thread_id", "updated_at", "values", "metadata"],
},
) {
const apiClient = getAPIClient();
return useQuery<AgentThread[]>({
...buildThreadsSearchQueryOptions(apiClient, params),
queryKey: ["threads", "search", params],
queryFn: async () => {
const maxResults = params.limit;
const initialOffset = params.offset ?? 0;
const DEFAULT_PAGE_SIZE = 50;
// Preserve prior semantics: if a non-positive limit is explicitly provided,
// delegate to a single search call with the original parameters.
if (maxResults !== undefined && maxResults <= 0) {
const response =
await apiClient.threads.search<AgentThreadState>(params);
return response as AgentThread[];
}
const pageSize =
typeof maxResults === "number" && maxResults > 0
? Math.min(DEFAULT_PAGE_SIZE, maxResults)
: DEFAULT_PAGE_SIZE;
const threads: AgentThread[] = [];
let offset = initialOffset;
while (true) {
if (typeof maxResults === "number" && threads.length >= maxResults) {
break;
}
const currentLimit =
typeof maxResults === "number"
? Math.min(pageSize, maxResults - threads.length)
: pageSize;
if (typeof maxResults === "number" && currentLimit <= 0) {
break;
}
const response = (await apiClient.threads.search<AgentThreadState>({
...params,
limit: currentLimit,
offset,
})) as AgentThread[];
threads.push(...response);
if (response.length < currentLimit) {
break;
}
offset += response.length;
}
return threads;
},
refetchOnWindowFocus: false,
});
}
@@ -1,86 +0,0 @@
import type { ThreadsClient } from "@langchain/langgraph-sdk/client";
import type { AgentThread, AgentThreadState } from "./types";
type ThreadsSearchClient = {
threads: {
search: ThreadsClient["search"];
};
};
export type ThreadSearchParams = NonNullable<
Parameters<ThreadsClient["search"]>[0]
>;
export const DEFAULT_THREAD_SEARCH_PARAMS: ThreadSearchParams = {
limit: 50,
sortBy: "updated_at",
sortOrder: "desc",
select: ["thread_id", "updated_at", "values", "metadata"],
};
export const THREAD_SEARCH_REFETCH_INTERVAL_MS = 5000;
export function buildThreadsSearchQueryOptions(
apiClient: ThreadsSearchClient,
params: ThreadSearchParams = DEFAULT_THREAD_SEARCH_PARAMS,
) {
return {
queryKey: ["threads", "search", params],
queryFn: async () => {
const maxResults = params.limit;
const initialOffset = params.offset ?? 0;
const DEFAULT_PAGE_SIZE = 50;
// Preserve prior semantics: if a non-positive limit is explicitly provided,
// delegate to a single search call with the original parameters.
if (maxResults !== undefined && maxResults <= 0) {
const response =
await apiClient.threads.search<AgentThreadState>(params);
return response as AgentThread[];
}
const pageSize =
typeof maxResults === "number" && maxResults > 0
? Math.min(DEFAULT_PAGE_SIZE, maxResults)
: DEFAULT_PAGE_SIZE;
const threads: AgentThread[] = [];
let offset = initialOffset;
while (true) {
if (typeof maxResults === "number" && threads.length >= maxResults) {
break;
}
const currentLimit =
typeof maxResults === "number"
? Math.min(pageSize, maxResults - threads.length)
: pageSize;
if (typeof maxResults === "number" && currentLimit <= 0) {
break;
}
const response = (await apiClient.threads.search<AgentThreadState>({
...params,
limit: currentLimit,
offset,
})) as AgentThread[];
threads.push(...response);
if (response.length < currentLimit) {
break;
}
offset += response.length;
}
return threads;
},
refetchInterval: THREAD_SEARCH_REFETCH_INTERVAL_MS,
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
};
}
-45
View File
@@ -2,12 +2,6 @@ import type { Message } from "@langchain/langgraph-sdk";
import type { AgentThread, AgentThreadContext } from "./types";
export type ChannelThreadSource = {
type: "im_channel";
provider: string;
label: string;
};
type ThreadRouteTarget =
| string
| {
@@ -55,42 +49,3 @@ export function textOfMessage(message: Message) {
export function titleOfThread(thread: AgentThread) {
return thread.values?.title ?? "Untitled";
}
const CHANNEL_PROVIDER_LABELS: Record<string, string> = {
dingtalk: "DingTalk",
discord: "Discord",
feishu: "Feishu",
slack: "Slack",
telegram: "Telegram",
wechat: "WeChat",
wecom: "WeCom",
};
function labelOfChannelProvider(provider: string) {
return CHANNEL_PROVIDER_LABELS[provider] ?? provider;
}
export function channelSourceOfThread(
thread: Pick<AgentThread, "metadata">,
): ChannelThreadSource | null {
const source = thread.metadata?.channel_source;
if (!source || typeof source !== "object" || Array.isArray(source)) {
return null;
}
if (Reflect.get(source, "type") !== "im_channel") {
return null;
}
const provider = Reflect.get(source, "provider");
if (typeof provider !== "string" || provider.trim().length === 0) {
return null;
}
const normalizedProvider = provider.trim().toLowerCase();
return {
type: "im_channel",
provider: normalizedProvider,
label: labelOfChannelProvider(normalizedProvider),
};
}
+2 -8
View File
@@ -19,22 +19,16 @@ interface Shortcut {
export function useGlobalShortcuts(shortcuts: Shortcut[]) {
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (typeof event.key !== "string" || event.key.length === 0) {
return;
}
const meta = event.metaKey || event.ctrlKey;
const eventKey = event.key.toLowerCase();
for (const shortcut of shortcuts) {
const shortcutKey = shortcut.key.toLowerCase();
if (
eventKey === shortcutKey &&
event.key.toLowerCase() === shortcut.key.toLowerCase() &&
meta === shortcut.meta &&
(shortcut.shift ?? false) === event.shiftKey
) {
// Allow Cmd+K even in inputs (standard command palette behavior)
if (shortcutKey !== "k") {
if (shortcut.key !== "k") {
const target = event.target as HTMLElement;
const tag = target.tagName;
if (
-310
View File
@@ -1,310 +0,0 @@
import { expect, test, type Page } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const channelProviders = [
["telegram", "Telegram", "deep_link"],
["slack", "Slack", "binding_code"],
["discord", "Discord", "binding_code"],
["feishu", "Feishu", "binding_code"],
["dingtalk", "DingTalk", "binding_code"],
["wechat", "WeChat", "binding_code"],
["wecom", "WeCom", "binding_code"],
] as const;
type MockChannelProvider = {
provider: string;
display_name: string;
enabled: boolean;
configured: boolean;
connectable: boolean;
auth_mode: string;
connection_status: string;
unavailable_reason?: string | null;
credential_fields?: Array<{
name: string;
label: string;
type: string;
required: boolean;
}>;
credential_values?: Record<string, string>;
};
function defaultProviders(): MockChannelProvider[] {
return channelProviders.map(([provider, displayName, authMode]) => ({
provider,
display_name: displayName,
enabled: true,
configured: true,
connectable: true,
auth_mode: authMode,
connection_status: "connected",
credential_fields: [
{
name: "token",
label: "Token",
type: "password",
required: true,
},
],
}));
}
function mockChannelsAPI(
page: Page,
providers: MockChannelProvider[] = defaultProviders(),
onSlackConnect?: () => void,
) {
void page.route("**/api/channels/providers", (route) => {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
enabled: true,
providers,
}),
});
});
void page.route("**/api/channels/connections", (route) => {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ connections: [] }),
});
});
void page.route("**/api/channels/slack/connect", (route) => {
onSlackConnect?.();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
provider: "slack",
mode: "binding_code",
url: null,
code: "abc123",
instruction: "Send /connect abc123 to the DeerFlow Slack bot.",
expires_in: 600,
}),
});
});
}
test.describe("IM channels", () => {
test("sidebar and settings expose channel connections", async ({ page }) => {
mockLangGraphAPI(page);
mockChannelsAPI(page);
await page.goto("/workspace/chats/new");
const sidebar = page.locator("[data-sidebar='sidebar']");
await expect(sidebar.getByText("Channels")).toBeVisible({
timeout: 15_000,
});
await expect(sidebar.getByText("Telegram")).toBeVisible();
await expect(sidebar.getByText("Slack")).toBeVisible();
await expect(sidebar.getByText("Discord")).toBeVisible();
await expect(sidebar.getByText("Feishu")).toBeVisible();
await expect(sidebar.getByText("DingTalk")).toBeVisible();
await expect(sidebar.getByText("WeChat")).toBeVisible();
await expect(sidebar.getByText("WeCom")).toBeVisible();
await expect(
sidebar.getByRole("button", { name: "Connected" }),
).toHaveCount(7);
await sidebar.getByRole("button", { name: /Settings and more/ }).click();
await page.getByRole("menuitem", { name: "Settings" }).click();
await page.getByRole("button", { name: "Channels" }).click();
await expect(page.getByText("Telegram direct messages")).toBeVisible();
await expect(page.getByText("Slack workspace messages")).toBeVisible();
await expect(page.getByText("Discord server messages")).toBeVisible();
await expect(page.getByText("Feishu and Lark messages")).toBeVisible();
await expect(page.getByText("DingTalk Stream Push messages")).toBeVisible();
await expect(page.getByText("WeChat iLink messages")).toBeVisible();
await expect(page.getByText("WeCom messages")).toBeVisible();
const dialog = page.getByRole("dialog", { name: "Settings" });
await expect(dialog.getByRole("button", { name: "Modify" })).toHaveCount(7);
});
test("only enabled providers are shown and runtime setup stays editable", async ({
page,
}) => {
mockLangGraphAPI(page);
let slackConfigured = false;
let submittedValues: Record<string, string> | undefined;
void page.route("**/api/channels/providers", (route) => {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
enabled: true,
providers: [
{
provider: "slack",
display_name: "Slack",
enabled: true,
configured: slackConfigured,
connectable: slackConfigured,
auth_mode: "binding_code",
connection_status: slackConfigured
? "connected"
: "not_connected",
credential_fields: [
{
name: "bot_token",
label: "Bot token",
type: "password",
required: true,
},
{
name: "app_token",
label: "App token",
type: "password",
required: true,
},
],
credential_values: slackConfigured
? {
bot_token: "********",
app_token: "********",
}
: {},
},
{
provider: "discord",
display_name: "Discord",
enabled: false,
configured: false,
connectable: false,
auth_mode: "binding_code",
connection_status: "not_connected",
credential_fields: [],
},
],
}),
});
});
void page.route("**/api/channels/connections", (route) => {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ connections: [] }),
});
});
void page.route("**/api/channels/slack/runtime-config", async (route) => {
const body = route.request().postDataJSON() as {
values: Record<string, string>;
};
submittedValues = body.values;
slackConfigured = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
provider: "slack",
display_name: "Slack",
enabled: true,
configured: true,
connectable: true,
auth_mode: "binding_code",
connection_status: "connected",
credential_fields: [],
credential_values: {},
}),
});
});
void page.route("**/api/channels/slack/connect", (route) => route.abort());
await page.goto("/workspace/chats/new");
const sidebar = page.locator("[data-sidebar='sidebar']");
await expect(sidebar.getByText("Slack")).toBeVisible({ timeout: 15_000 });
await expect(sidebar.getByText("Discord")).toBeHidden();
const connectButton = sidebar.getByRole("button", { name: "Connect" });
await expect(connectButton).toBeEnabled();
await connectButton.click();
const setupDialog = page.getByRole("dialog", { name: "Connect Slack" });
await expect(setupDialog).toBeVisible();
const botTokenInput = setupDialog.getByLabel("Bot token");
await expect(botTokenInput).toHaveAttribute("type", "text");
await expect(botTokenInput).toHaveAttribute("autocomplete", "off");
await expect(botTokenInput).toHaveAttribute("data-lpignore", "true");
await expect(botTokenInput).toHaveAttribute("data-1p-ignore", "true");
await expect(botTokenInput).toHaveCSS("-webkit-text-security", "disc");
await setupDialog.getByLabel("Bot token").fill("xoxb-ui");
await setupDialog.getByLabel("App token").fill("xapp-ui");
await setupDialog.getByRole("button", { name: "Save and connect" }).click();
await expect(setupDialog).toBeHidden();
await expect(
sidebar.getByRole("button", { name: "Connected" }),
).toBeVisible();
await sidebar.getByRole("button", { name: "Connected" }).click();
await expect(
page.getByRole("dialog", { name: "Modify Slack" }),
).toBeVisible();
await expect(page.getByLabel("Bot token")).toHaveValue("********");
await expect(page.getByLabel("App token")).toHaveValue("********");
expect(submittedValues).toEqual({
bot_token: "xoxb-ui",
app_token: "xapp-ui",
});
});
test("runtime setup dialog prefills editable credential values", async ({
page,
}) => {
mockLangGraphAPI(page);
mockChannelsAPI(page, [
{
provider: "feishu",
display_name: "Feishu",
enabled: true,
configured: true,
connectable: true,
auth_mode: "binding_code",
connection_status: "connected",
credential_fields: [
{
name: "app_id",
label: "App ID",
type: "text",
required: true,
},
{
name: "app_secret",
label: "App secret",
type: "password",
required: true,
},
],
credential_values: {
app_id: "cli_feishu_app",
app_secret: "********",
},
},
]);
await page.goto("/workspace/chats/new");
const sidebar = page.locator("[data-sidebar='sidebar']");
await expect(sidebar.getByText("Feishu")).toBeVisible({ timeout: 15_000 });
await sidebar.getByRole("button", { name: "Connected" }).click();
const setupDialog = page.getByRole("dialog", { name: "Modify Feishu" });
await expect(setupDialog).toBeVisible();
await expect(setupDialog.getByLabel("App ID")).toHaveValue(
"cli_feishu_app",
);
await expect(setupDialog.getByLabel("App secret")).toHaveValue("********");
});
});
-39
View File
@@ -152,43 +152,4 @@ test.describe("Thread history", () => {
});
await expect(main.getByText("Second conversation")).toBeVisible();
});
test("IM channel threads show their source in thread lists", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Feishu conversation",
updated_at: "2025-06-03T12:00:00Z",
metadata: {
channel_source: {
type: "im_channel",
provider: "feishu",
chat_id: "oc_mock",
},
},
},
],
});
await page.goto("/workspace/chats/new");
const sidebarThread = page.locator(
`a[href='/workspace/chats/${MOCK_THREAD_ID}']`,
);
await expect(sidebarThread).toBeVisible({ timeout: 15_000 });
await expect(sidebarThread.getByLabel("Feishu channel")).toBeVisible();
await page.goto("/workspace/chats");
const mainThread = page
.locator("main")
.locator(`a[href='/workspace/chats/${MOCK_THREAD_ID}']`);
await expect(mainThread.getByText("Feishu conversation")).toBeVisible({
timeout: 15_000,
});
await expect(mainThread.getByText("Feishu", { exact: true })).toBeVisible();
});
});
+1 -5
View File
@@ -25,7 +25,6 @@ export type MockThread = {
title?: string;
updated_at?: string;
agent_name?: string;
metadata?: Record<string, unknown>;
messages?: unknown[];
artifacts?: string[];
};
@@ -91,10 +90,7 @@ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
thread_id: t.thread_id,
created_at: "2025-01-01T00:00:00Z",
updated_at: t.updated_at ?? "2025-01-01T00:00:00Z",
metadata: {
...(t.metadata ?? {}),
...(t.agent_name ? { agent_name: t.agent_name } : {}),
},
metadata: t.agent_name ? { agent_name: t.agent_name } : {},
status: "idle",
values: { title: t.title ?? "Untitled" },
}));
@@ -1,220 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("@/core/api/fetcher", () => ({
fetch: vi.fn(),
}));
vi.mock("@/core/config", () => ({
getBackendBaseURL: () => "/backend",
}));
import { fetch as fetcher } from "@/core/api/fetcher";
import {
configureChannelProvider,
connectChannelProvider,
disconnectChannelConnection,
disconnectChannelProvider,
listChannelConnections,
listChannelProviders,
} from "@/core/channels/api";
const mockedFetch = vi.mocked(fetcher);
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
statusText: status >= 400 ? "Bad Request" : "OK",
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
mockedFetch.mockReset();
});
describe("channels api", () => {
test("loads provider catalog", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
enabled: true,
providers: [
{
provider: "telegram",
display_name: "Telegram",
enabled: true,
configured: true,
auth_mode: "deep_link",
connection_status: "not_connected",
credential_values: {
bot_token: "********",
bot_username: "deerflow_bot",
},
},
],
}),
);
await expect(listChannelProviders()).resolves.toMatchObject({
enabled: true,
providers: [
{
provider: "telegram",
display_name: "Telegram",
credential_values: {
bot_token: "********",
bot_username: "deerflow_bot",
},
},
],
});
expect(mockedFetch).toHaveBeenCalledWith("/backend/api/channels/providers");
});
test("loads current user's connections", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
connections: [
{
id: "connection-1",
provider: "telegram",
status: "connected",
external_account_name: "Alice",
scopes: [],
metadata: {},
},
],
}),
);
await expect(listChannelConnections()).resolves.toMatchObject([
{ id: "connection-1", provider: "telegram", status: "connected" },
]);
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/connections",
);
});
test("starts a provider connection flow", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
provider: "telegram",
mode: "deep_link",
url: "https://t.me/deerflow_bot?start=state",
code: "state",
instruction: "Send /start state to the DeerFlow Telegram bot.",
expires_in: 600,
}),
);
await expect(connectChannelProvider("telegram")).resolves.toMatchObject({
provider: "telegram",
url: "https://t.me/deerflow_bot?start=state",
instruction: "Send /start state to the DeerFlow Telegram bot.",
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/telegram/connect",
{ method: "POST" },
);
});
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("submits runtime provider configuration", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
provider: "slack",
display_name: "Slack",
enabled: true,
configured: true,
connectable: true,
auth_mode: "binding_code",
connection_status: "not_connected",
}),
);
await expect(
configureChannelProvider("slack", {
bot_token: "xoxb-ui",
app_token: "xapp-ui",
}),
).resolves.toMatchObject({
provider: "slack",
configured: true,
connectable: true,
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/slack/runtime-config",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
values: { bot_token: "xoxb-ui", app_token: "xapp-ui" },
}),
},
);
});
test("disconnects a channel connection", async () => {
mockedFetch.mockResolvedValueOnce(new Response(null, { status: 204 }));
await expect(
disconnectChannelConnection("connection-1"),
).resolves.toBeUndefined();
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/connections/connection-1",
{ method: "DELETE" },
);
});
test("disconnects provider runtime configuration", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {
provider: "slack",
display_name: "Slack",
enabled: true,
configured: false,
connectable: false,
auth_mode: "binding_code",
connection_status: "not_connected",
}),
);
await expect(disconnectChannelProvider("slack")).resolves.toMatchObject({
provider: "slack",
configured: false,
connection_status: "not_connected",
});
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/slack/runtime-config",
{ method: "DELETE" },
);
});
test("uses backend detail for failed requests", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(400, { detail: "Channel provider is not configured" }),
);
await expect(connectChannelProvider("slack")).rejects.toThrow(
"Channel provider is not configured",
);
});
});
@@ -1,86 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import {
closeConnectWindow,
openConnectUrl,
prepareConnectWindow,
} from "@/core/channels/open-connect-url";
type PopupStub = {
closed: boolean;
close: ReturnType<typeof vi.fn>;
location: {
replace: ReturnType<typeof vi.fn>;
};
opener: unknown;
};
function stubWindow(openResult: PopupStub | null) {
const assign = vi.fn();
const open = vi.fn(() => openResult);
vi.stubGlobal("window", {
open,
location: { assign },
});
return { assign, open };
}
function makePopup(): PopupStub {
return {
closed: false,
close: vi.fn(),
location: { replace: vi.fn() },
opener: {},
};
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("channel connect window helpers", () => {
test("opens a blank tab synchronously and detaches opener", () => {
const popup = makePopup();
const { open } = stubWindow(popup);
const prepared = prepareConnectWindow();
expect(open).toHaveBeenCalledWith("about:blank", "_blank");
expect(prepared).toBe(popup);
expect(popup.opener).toBeNull();
});
test("navigates a prepared popup without opening another window", () => {
const popup = makePopup();
const { assign, open } = stubWindow(null);
openConnectUrl(
"https://t.me/deerflow_bot?start=state",
popup as unknown as Window,
);
expect(open).not.toHaveBeenCalled();
expect(assign).not.toHaveBeenCalled();
expect(popup.location.replace).toHaveBeenCalledWith(
"https://t.me/deerflow_bot?start=state",
);
});
test("falls back to current-window navigation when no popup is available", () => {
const { assign } = stubWindow(null);
openConnectUrl("https://t.me/deerflow_bot?start=state");
expect(assign).toHaveBeenCalledWith(
"https://t.me/deerflow_bot?start=state",
);
});
test("closes a prepared popup on connect failure", () => {
const popup = makePopup();
closeConnectWindow(popup as unknown as Window);
expect(popup.close).toHaveBeenCalled();
});
});
@@ -1,19 +0,0 @@
import { expect, test, vi } from "vitest";
import {
buildThreadsSearchQueryOptions,
DEFAULT_THREAD_SEARCH_PARAMS,
THREAD_SEARCH_REFETCH_INTERVAL_MS,
} from "@/core/threads/thread-search-query";
test("thread search query refreshes so IM-created sessions appear in the sidebar", () => {
const search = vi.fn();
const options = buildThreadsSearchQueryOptions(
{ threads: { search } },
DEFAULT_THREAD_SEARCH_PARAMS,
);
expect(options.refetchInterval).toBe(THREAD_SEARCH_REFETCH_INTERVAL_MS);
expect(options.refetchIntervalInBackground).toBe(false);
expect(options.refetchOnWindowFocus).toBe(false);
});
+1 -38
View File
@@ -1,6 +1,6 @@
import { expect, test } from "vitest";
import { channelSourceOfThread, pathOfThread } from "@/core/threads/utils";
import { pathOfThread } from "@/core/threads/utils";
test("uses standard chat route when thread has no agent context", () => {
expect(pathOfThread("thread-123")).toBe("/workspace/chats/thread-123");
@@ -44,40 +44,3 @@ test("prefers context.agent_name over metadata.agent_name", () => {
}),
).toBe("/workspace/agents/from-context/chats/thread-789");
});
test("reads IM channel source metadata", () => {
expect(
channelSourceOfThread({
metadata: {
channel_source: {
type: "im_channel",
provider: "feishu",
chat_id: "oc_123",
},
},
}),
).toEqual({
type: "im_channel",
provider: "feishu",
label: "Feishu",
});
});
test("ignores threads without valid IM channel source metadata", () => {
expect(channelSourceOfThread({ metadata: {} })).toBeNull();
expect(
channelSourceOfThread({
metadata: { channel_source: { provider: "" } },
}),
).toBeNull();
expect(
channelSourceOfThread({
metadata: {
channel_source: {
type: "other",
provider: "feishu",
},
},
}),
).toBeNull();
});
@@ -1,61 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
type KeydownHandler = (event: KeyboardEvent) => void;
async function loadHookWithCapturedHandler() {
let cleanup: (() => void) | undefined;
let keydownHandler: KeydownHandler | undefined;
const addEventListener = vi.fn(
(type: string, listener: EventListenerOrEventListenerObject) => {
if (type === "keydown" && typeof listener === "function") {
keydownHandler = listener as KeydownHandler;
}
},
);
const removeEventListener = vi.fn();
vi.resetModules();
vi.doMock("react", () => ({
useEffect: (effect: () => void | (() => void)) => {
const result = effect();
cleanup = typeof result === "function" ? result : undefined;
},
}));
vi.stubGlobal("window", { addEventListener, removeEventListener });
const { useGlobalShortcuts } = await import("@/hooks/use-global-shortcuts");
return {
cleanup: () => cleanup?.(),
getKeydownHandler: () => keydownHandler,
useGlobalShortcuts,
};
}
afterEach(() => {
vi.doUnmock("react");
vi.unstubAllGlobals();
vi.resetModules();
});
describe("useGlobalShortcuts", () => {
test("ignores keydown events without a key", async () => {
const action = vi.fn();
const { getKeydownHandler, useGlobalShortcuts } =
await loadHookWithCapturedHandler();
useGlobalShortcuts([{ key: "k", meta: true, action }]);
const keydownHandler = getKeydownHandler();
expect(keydownHandler).toBeDefined();
expect(() =>
keydownHandler?.({
ctrlKey: false,
metaKey: true,
shiftKey: false,
} as KeyboardEvent),
).not.toThrow();
expect(action).not.toHaveBeenCalled();
});
});
+13 -95
View File
@@ -29,6 +29,14 @@ set -e
REPO_ROOT="$(builtin cd "$(dirname "${BASH_SOURCE[0]}")/.." >/dev/null 2>&1 && pwd -P)"
cd "$REPO_ROOT"
# ── Load .env ────────────────────────────────────────────────────────────────
if [ -f "$REPO_ROOT/.env" ]; then
set -a
source "$REPO_ROOT/.env"
set +a
fi
_pick_python() {
local candidate
for candidate in python3 python py; do
@@ -40,61 +48,6 @@ _pick_python() {
return 1
}
_load_dotenv_file() {
local env_file=$1
local python_bin
[ -f "$env_file" ] || return 0
if ! python_bin="$(_pick_python)"; then
echo "Python is required to load $env_file safely."
exit 1
fi
eval "$("$python_bin" - "$env_file" <<'PY'
import re
import shlex
import sys
from pathlib import Path
env_path = Path(sys.argv[1])
assign_re = re.compile(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$")
def strip_unquoted_comment(value: str) -> str:
for index, char in enumerate(value):
if char == "#" and (index == 0 or value[index - 1].isspace()):
return value[:index].rstrip()
return value
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
match = assign_re.match(line)
if not match:
continue
key, value = match.groups()
value = value.strip()
try:
parsed = shlex.split(value, comments=True, posix=True)
except ValueError:
value = strip_unquoted_comment(value)
else:
value = parsed[0] if parsed else ""
print(f"export {key}={shlex.quote(value)}")
PY
)"
}
# ── Load .env ────────────────────────────────────────────────────────────────
_load_dotenv_file "$REPO_ROOT/.env"
# ── Argument parsing ─────────────────────────────────────────────────────────
DEV_MODE=true
@@ -226,10 +179,7 @@ _is_port_listening() {
fi
if command -v netstat >/dev/null 2>&1; then
if netstat -ltn 2>/dev/null | awk -v port="$port" '
toupper($NF) == "LISTEN" && $4 ~ "(^|[.:])" port "$" { found = 1 }
END { exit found ? 0 : 1 }
'; then
if netstat -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "(^|[.:])${port}$"; then
return 0
fi
fi
@@ -237,21 +187,6 @@ _is_port_listening() {
return 1
}
_wait_for_port_free() {
local port=$1
local timeout=${2:-10}
local elapsed=0
while _is_port_listening "$port"; do
if [ "$elapsed" -ge "$timeout" ]; then
echo " ⚠ Port $port is still in use after ${timeout}s"
return 1
fi
sleep 1
elapsed=$((elapsed + 1))
done
}
_is_repo_nginx_pid() {
local pid=$1
local command
@@ -304,12 +239,9 @@ stop_all() {
echo "Stopping all services..."
_report_reclaimed_ports
_kill_repo_processes "uvicorn app.gateway.app:app"
_kill_repo_processes "pnpm .*run dev"
_kill_repo_processes "next dev"
_kill_repo_processes "next start"
_kill_repo_processes "next-server"
_kill_repo_processes "next/dist"
_kill_repo_processes "turbopack"
nginx -c "$REPO_ROOT/docker/nginx/nginx.local.conf" -p "$REPO_ROOT" -s quit 2>/dev/null || true
sleep 1
_kill_repo_nginx
@@ -320,9 +252,6 @@ stop_all() {
_kill_repo_port 8001
_kill_repo_port 3000
_kill_repo_port 2026
_wait_for_port_free 8001 30 || true
_wait_for_port_free 3000 30 || true
_wait_for_port_free 2026 30 || true
./scripts/cleanup-containers.sh deer-flow-sandbox 2>/dev/null || true
echo "✓ All services stopped"
}
@@ -485,7 +414,6 @@ trap 'cleanup 143' TERM
# In daemon mode, wraps with nohup. Waits for port to be ready.
run_service() {
local name="$1" cmd="$2" port="$3" timeout="$4"
local service_pid
if _is_port_listening "$port"; then
echo "$name cannot start because port $port is already in use."
@@ -495,14 +423,10 @@ run_service() {
echo "Starting $name..."
if $DAEMON_MODE; then
nohup sh -c "$cmd" < /dev/null > /dev/null 2>&1 &
nohup sh -c "$cmd" > /dev/null 2>&1 &
else
sh -c "$cmd" &
fi
service_pid=$!
if $DAEMON_MODE; then
disown "$service_pid" 2>/dev/null || true
fi
./scripts/wait-for-port.sh "$port" "$timeout" "$name" || {
local logfile="logs/$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr ' ' '-').log"
@@ -510,12 +434,6 @@ run_service() {
[ -f "$logfile" ] && tail -20 "$logfile"
cleanup 1
}
if ! kill -0 "$service_pid" 2>/dev/null; then
local logfile="logs/$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr ' ' '-').log"
echo "$name process exited after port $port became available."
[ -f "$logfile" ] && tail -20 "$logfile"
cleanup 1
fi
echo "$name started on localhost:$port"
}
@@ -526,17 +444,17 @@ mkdir -p temp/client_body_temp temp/proxy_temp temp/fastcgi_temp temp/uwsgi_temp
# 1. Gateway API
run_service "Gateway" \
"cd backend && exec env PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 $GATEWAY_EXTRA_FLAGS > ../logs/gateway.log 2>&1" \
"cd backend && PYTHONPATH=. uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 $GATEWAY_EXTRA_FLAGS > ../logs/gateway.log 2>&1" \
8001 30
# 2. Frontend
run_service "Frontend" \
"cd frontend && exec $FRONTEND_CMD > ../logs/frontend.log 2>&1" \
"cd frontend && $FRONTEND_CMD > ../logs/frontend.log 2>&1" \
3000 120
# 3. Nginx
run_service "Nginx" \
"exec nginx -g 'daemon off;' -c '$REPO_ROOT/docker/nginx/nginx.local.conf' -p '$REPO_ROOT' > logs/nginx.log 2>&1" \
"nginx -g 'daemon off;' -c '$REPO_ROOT/docker/nginx/nginx.local.conf' -p '$REPO_ROOT' > logs/nginx.log 2>&1" \
2026 10
# ── Ready ────────────────────────────────────────────────────────────────────
+1 -10
View File
@@ -58,7 +58,7 @@ def main() -> int:
return 0
print()
total_steps = 5
total_steps = 4
from wizard.steps.llm import run_llm_step
@@ -76,10 +76,6 @@ def main() -> int:
execution = run_execution_step(f"Step 3/{total_steps}")
from wizard.steps.channels import run_channels_step
channels = run_channels_step(f"Step 4/{total_steps}")
print_header(f"Step {total_steps}/{total_steps} · Writing configuration")
write_config_yaml(
@@ -101,7 +97,6 @@ def main() -> int:
allow_host_bash=execution.allow_host_bash,
include_bash_tool=execution.include_bash_tool,
include_write_tools=execution.include_write_tools,
channel_connection_providers=channels.enabled_providers,
)
print_success(f"Config written to: {config_path.relative_to(project_root)}")
@@ -153,10 +148,6 @@ def main() -> int:
print(f" {green('')} File write: enabled")
else:
print(f" {'':>3} File write: disabled")
if channels.enabled_providers:
print(f" {green('')} IM channels: {', '.join(channels.enabled_providers)}")
else:
print(f" {'':>3} IM channels: disabled")
print()
print("Next steps:")
print(f" {cyan('make install')} # Install dependencies (first time only)")
-46
View File
@@ -1,46 +0,0 @@
"""Step: browser-connectable IM channel enablement."""
from __future__ import annotations
from dataclasses import dataclass
from wizard.ui import ask_multi_choice, print_header, print_info, print_success
CHANNEL_CONNECTION_OPTIONS: tuple[tuple[str, str, str], ...] = (
("telegram", "Telegram", "direct messages through your DeerFlow bot"),
("slack", "Slack", "workspace messages and mentions"),
("discord", "Discord", "server messages through your DeerFlow bot"),
("feishu", "Feishu / Lark", "messages through your DeerFlow app"),
("dingtalk", "DingTalk", "Stream Push messages through your DeerFlow bot"),
("wechat", "WeChat", "iLink messages through your DeerFlow bot"),
("wecom", "WeCom", "messages through your DeerFlow AI bot"),
)
@dataclass
class ChannelConnectionsStepResult:
enabled_providers: list[str]
def run_channels_step(step_label: str = "Step 4/5") -> ChannelConnectionsStepResult:
print_header(f"{step_label} · IM Channels (optional)")
print_info("Choose which IM channels should appear in the DeerFlow sidebar and Settings.")
print_info("Credentials can be entered later from the browser with Connect or Modify.")
print()
options = [f"{display_name}{description}" for _, display_name, description in CHANNEL_CONNECTION_OPTIONS]
selected = ask_multi_choice(
"Enable channels (comma-separated numbers, 'all', or Enter for none)",
options,
default=[],
)
enabled_providers = [CHANNEL_CONNECTION_OPTIONS[idx][0] for idx in selected]
if enabled_providers:
display_names = [CHANNEL_CONNECTION_OPTIONS[idx][1] for idx in selected]
print_success(f"Enabled channels: {', '.join(display_names)}")
else:
print_info("No IM channels selected; channel connections will stay disabled.")
return ChannelConnectionsStepResult(enabled_providers=enabled_providers)
-43
View File
@@ -224,49 +224,6 @@ def ask_choice(prompt: str, options: list[str], default: int | None = None) -> i
return _ask_choice_with_numbers(prompt, options, default=default)
def ask_multi_choice(prompt: str, options: list[str], default: list[int] | None = None) -> list[int]:
"""Present a numbered multi-select menu and return 0-based indexes."""
has_default = default is not None
default_indexes = list(default or [])
for i, opt in enumerate(options, 1):
marker = f" {green('*')}" if has_default and i - 1 in default_indexes else " "
print(f"{marker} {i}. {opt}")
print()
suffix = ""
if default_indexes:
suffix = f" [{','.join(str(idx + 1) for idx in default_indexes)}]"
elif has_default:
suffix = " [none]"
while True:
raw = input(f"{prompt}{suffix}: ").strip().lower()
if raw == "" and has_default:
return default_indexes
if raw in {"none", "no", "n", "skip"}:
return []
if raw == "all":
return list(range(len(options)))
parts = [part.strip() for part in raw.replace(" ", ",").split(",") if part.strip()]
selected: list[int] = []
valid = bool(parts)
for part in parts:
if not part.isdigit():
valid = False
break
idx = int(part) - 1
if not 0 <= idx < len(options):
valid = False
break
if idx not in selected:
selected.append(idx)
if valid:
return selected
print(f" Enter comma-separated numbers between 1 and {len(options)}, 'all', or 'none'.")
def ask_text(prompt: str, default: str = "", required: bool = False) -> str:
"""Ask for a text value, returning default if the user presses Enter."""
suffix = f" [{default}]" if default else ""
-27
View File
@@ -12,16 +12,6 @@ from typing import Any
import yaml
CHANNEL_CONNECTION_PROVIDERS: tuple[str, ...] = (
"telegram",
"slack",
"discord",
"feishu",
"dingtalk",
"wechat",
"wecom",
)
def _project_root() -> Path:
return Path(__file__).resolve().parents[2]
@@ -161,18 +151,6 @@ def _make_model_config_name(model_name: str) -> str:
return base.replace(".", "-")
def _build_channel_connections_config(enabled_providers: list[str]) -> dict[str, Any]:
selected = set(enabled_providers)
unknown = selected.difference(CHANNEL_CONNECTION_PROVIDERS)
if unknown:
raise ValueError(f"Unknown channel connection provider(s): {', '.join(sorted(unknown))}")
return {
"enabled": bool(selected),
**{provider: {"enabled": provider in selected} for provider in CHANNEL_CONNECTION_PROVIDERS},
}
def build_minimal_config(
*,
provider_use: str,
@@ -192,7 +170,6 @@ def build_minimal_config(
allow_host_bash: bool = False,
include_bash_tool: bool = False,
include_write_tools: bool = True,
channel_connection_providers: list[str] | None = None,
config_version: int = 5,
base_config: dict[str, Any] | None = None,
) -> str:
@@ -242,8 +219,6 @@ def build_minimal_config(
else:
sandbox_config.pop("allow_host_bash", None)
data["sandbox"] = sandbox_config
if channel_connection_providers is not None:
data["channel_connections"] = _build_channel_connections_config(channel_connection_providers)
header = (
f"# DeerFlow Configuration\n"
@@ -275,7 +250,6 @@ def write_config_yaml(
allow_host_bash: bool = False,
include_bash_tool: bool = False,
include_write_tools: bool = True,
channel_connection_providers: list[str] | None = None,
) -> None:
"""Write (or overwrite) config.yaml with a minimal working configuration."""
# Read config_version from config.example.yaml if present
@@ -310,7 +284,6 @@ def write_config_yaml(
allow_host_bash=allow_host_bash,
include_bash_tool=include_bash_tool,
include_write_tools=include_write_tools,
channel_connection_providers=channel_connection_providers,
config_version=config_version,
base_config=example_defaults,
)