diff --git a/CHANGELOG.md b/CHANGELOG.md index cec31465b2..a5d4424097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1397,6 +1397,14 @@ As of 2026-10-21, the milestone includes **829 merged pull requests**. ### Fixed +- **channels:** Connecting Slack, Discord, Feishu, DingTalk, WeCom, Buzz or QQ + now opens a dialog that keeps the `/connect` command on screen with a copy + button, its expiry time and the binding status, instead of a toast that + disappears. The bots' `/connect` and `/goal` replies follow the owner's web UI + language (else `channel_connections.notification_locale`), and `/goal` in IM + reports whether the goal is in progress, why it stopped, or that it was met, + as the web goal bar does. ([#7591]) + - **community:** Async community tools (Jina, Crawl4AI, Browserless, Tavily, SearXNG, Firecrawl, InfoQuest, RAGFlow, LightRAG, Jsonify and the browser automation tools) no longer read `config.yaml` and `extensions_config.json` on the @@ -13154,3 +13162,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#7560]: https://github.com/bytedance/deer-flow/pull/7560 [#7576]: https://github.com/bytedance/deer-flow/pull/7576 [#7580]: https://github.com/bytedance/deer-flow/pull/7580 +[#7591]: https://github.com/bytedance/deer-flow/pull/7591 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 2f6e397aa8..f36fbae26b 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -1112,6 +1112,12 @@ ### 修复 +- **渠道:** 连接 Slack、Discord、飞书、钉钉、企业微信、Buzz 或 QQ 时,现在会打开 + 一个对话框,持续显示 `/connect` 命令,并提供复制按钮、过期时间和绑定状态,不再只是 + 一闪而过的提示。机器人对 `/connect` 和 `/goal` 的回复会跟随用户在网页端选择的语言 + (否则使用 `channel_connections.notification_locale`);IM 中的 `/goal` 也会像网页端 + 目标栏一样,说明目标是否在进行、为什么停止或已经达成。([#7591]) + - **社区工具:** 异步社区工具(Jina、Crawl4AI、Browserless、Tavily、SearXNG、Firecrawl、 InfoQuest、RAGFlow、LightRAG、Jsonify 和浏览器自动化工具)不再在事件循环上读取 `config.yaml` 和 `extensions_config.json`。每次调用最多会把这两个文件检查三次(各做一次 `stat` @@ -10500,3 +10506,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#7580]: https://github.com/bytedance/deer-flow/pull/7580 [#7576]: https://github.com/bytedance/deer-flow/pull/7576 [#7580]: https://github.com/bytedance/deer-flow/pull/7580 +[#7591]: https://github.com/bytedance/deer-flow/pull/7591 diff --git a/README.md b/README.md index d578472adc..8cf6860206 100644 --- a/README.md +++ b/README.md @@ -2476,7 +2476,7 @@ For tool-using turns, successful operation results drive the no-progress count: Regenerating an earlier response keeps the goal currently set for the chat and its used continuation budget. It does not restore a goal that was cleared or achieved. The earlier answer's completion record is removed when its historical checkpoint is replayed. -The Web UI shows the active goal above the composer, says why auto-continue stopped, and shows a "Goal met" notice after a goal is met. Editing the last message is off while a goal is set. The same command is available from the TUI and supported IM channels. In the Web UI and supported IM channels, setting `/goal ` also starts a run with the condition as the task; status and clear commands only manage goal state. Setting or clearing a goal is rejected while that thread has a run in flight, including a run owned by another Gateway worker, so the goal checkpoint cannot branch away from an active run's checkpoint lineage. +The Web UI shows the active goal above the composer, says why auto-continue stopped, and shows a "Goal met" notice after a goal is met. Editing the last message is off while a goal is set. The same command is available from the TUI and supported IM channels. In IM, `/goal` also says whether the goal is in progress, why auto-continue stopped, or that it was met (until the next goal change), in the binding owner's web UI language (else `channel_connections.notification_locale`). In the Web UI and supported IM channels, setting `/goal ` also starts a run with the condition as the task; status and clear commands only manage goal state. Setting or clearing a goal is rejected while that thread has a run in flight, including a run owned by another Gateway worker, so the goal checkpoint cannot branch away from an active run's checkpoint lineage. When your role lacks `runs:create`, the Web UI rejects a new task or `/goal ` before preparing the thread or saving the goal, and keeps your draft for retry. Goal status, goal clearing, and `/compact` remain governed by their own endpoint permissions. diff --git a/README_zh.md b/README_zh.md index f0027cbb79..fb49a20979 100644 --- a/README_zh.md +++ b/README_zh.md @@ -824,7 +824,7 @@ Web UI 输入框支持浏览器侧语音听写。浏览器提供 Web Speech API 重新生成此前的回答时,会沿用当前对话的 goal 和已用续跑预算,不会恢复已清除或已达成的旧 goal。重放历史 checkpoint 时会移除旧回答的达成记录,避免把它当作新回答已经达成的证明。 -Web UI 会在输入框上方展示当前激活的 goal,说明自动续跑为什么停下,并在 goal 达成后显示「目标已达成」提示;设置了 goal 时不能编辑最后一条消息。同样的命令在 TUI 和受支持的 IM 渠道里也可用。在 Web UI 和受支持的 IM 渠道里,设置 `/goal <完成条件>` 还会以该条件作为任务启动一次 run;状态查询和清除命令则只管理 goal 状态本身。 +Web UI 会在输入框上方展示当前激活的 goal,说明自动续跑为什么停下,并在 goal 达成后显示「目标已达成」提示;设置了 goal 时不能编辑最后一条消息。同样的命令在 TUI 和受支持的 IM 渠道里也可用。在 IM 里,`/goal` 还会说明 goal 是否在进行中、自动续跑为什么停下,或者 goal 已达成(保留到下一次修改 goal),并使用绑定账号在网页界面中选择的语言(未设置时用 `channel_connections.notification_locale`)。在 Web UI 和受支持的 IM 渠道里,设置 `/goal <完成条件>` 还会以该条件作为任务启动一次 run;状态查询和清除命令则只管理 goal 状态本身。 ### 手动上下文压缩 diff --git a/backend/app/channels/base.py b/backend/app/channels/base.py index 7c23c5dfd6..33f230cb65 100644 --- a/backend/app/channels/base.py +++ b/backend/app/channels/base.py @@ -137,6 +137,8 @@ class Channel(ABC): self.config = config self._running = False self._connection_repo: Any = config.get("connection_repo") + # Language of the /connect replies (a ReplyLocaleResolver); ChannelService injects it. + self._reply_locale: Any = config.get("reply_locale") # Provider SDK callbacks often run on a dedicated thread and submit # preparation work to the Gateway loop. Submission and shutdown share # this lock so stop() cannot miss a future created concurrently. @@ -450,6 +452,20 @@ class Channel(ABC): return None return extract_connect_code(text) + async def _connect_reply_text(self, key: str, *, owner_user_id: str | None = None) -> str: + """A ``/connect`` (Telegram ``/start``) reply in its reader's language. + + Once the code is consumed, pass its ``owner_user_id``: the reply follows + that user's web UI language. Before that (an invalid or expired code) + it uses ``channel_connections.notification_locale``. Never raises. + """ + # Deferred: it loads the scheduler's locale list, and importing + # app.channels must stay light (tests/test_channel_capabilities.py). + from app.channels.reply_locale import ReplyLocaleResolver, connect_reply_text + + resolver = self._reply_locale or ReplyLocaleResolver() + return connect_reply_text(key, provider=self.name, locale=await resolver.locale_for(owner_user_id)) + def _make_inbound( self, chat_id: str, diff --git a/backend/app/channels/buzz.py b/backend/app/channels/buzz.py index 6ef6974e02..0d09b067f1 100644 --- a/backend/app/channels/buzz.py +++ b/backend/app/channels/buzz.py @@ -190,16 +190,6 @@ _PERMANENT_CLOSE_PREFIXES = (_AUTH_REQUIRED_CLOSE_PREFIX, "restricted:", "blocke # guessing wrong in that direction. _PERMANENT_CLOSE_MARKERS = ("revoke", "not a member", "not a channel member", "no longer a member", "access denied", "unauthorized", "forbidden", "archiv", "not found", "does not exist") -# Wording mirrors every sibling adapter's `/connect` reply style (discord.py's -# `_send_connection_reply`, slack.py's `_post_connection_reply`, and the same -# templates in wecom.py / feishu.py / dingtalk.py / wechat.py), substituting -# "Buzz" for the platform name. -_CONNECT_REPLY_TEXT = { - "success": "Buzz connected to DeerFlow.", - "invalid": "Buzz connection code is invalid or expired.", - "error": "Buzz connection could not be completed from this message.", -} - def _chunk_text(text: str, limit: int = EDIT_MAX_BYTES) -> list[str]: """Split *text* into chunks whose UTF-8 ENCODED byte length never exceeds *limit*. @@ -1428,12 +1418,15 @@ class BuzzChannel(Channel): """ if self._connection_repo is None: return # unreachable in practice: _pending_connect_code already requires this + # Known once the code is consumed; the reply then uses that user's language. + owner_user_id: str | None = None try: state = await self._connection_repo.consume_oauth_state(provider="buzz", state=code) if state is None: logger.info("[buzz] /connect code invalid, expired, or already used (pubkey=%s)", author) - outcome = "invalid" + outcome = "invalid_code" else: + owner_user_id = state["owner_user_id"] await self._connection_repo.upsert_connection( owner_user_id=state["owner_user_id"], provider="buzz", @@ -1443,17 +1436,17 @@ class BuzzChannel(Channel): status="connected", ) logger.info("[buzz] connected pubkey=%s to owner_user_id=%s", author, state["owner_user_id"]) - outcome = "success" + outcome = "connected" except Exception: # A repo/DB error binding the code must not propagate: handle_relay_frame's # outer guard would also catch it, but catching here keeps the log specific # to the bind failure instead of a generic "malformed relay event ignored". logger.exception("[buzz] failed to bind /connect code for pubkey=%s", author) - outcome = "error" + outcome = "incomplete" # Bind success/failure is already fully decided and logged above; sending # the reply is a separate, best-effort concern from here on. - await self._reply_to_connect(channel_id, author, _CONNECT_REPLY_TEXT[outcome]) + await self._reply_to_connect(channel_id, author, await self._connect_reply_text(outcome, owner_user_id=owner_user_id)) async def _reply_to_connect(self, channel_id: str, author: str, text: str) -> None: """Best-effort confirmation/error reply for a ``/connect`` attempt. diff --git a/backend/app/channels/dingtalk.py b/backend/app/channels/dingtalk.py index 2c81b7f522..2bd2286503 100644 --- a/backend/app/channels/dingtalk.py +++ b/backend/app/channels/dingtalk.py @@ -1017,7 +1017,7 @@ class DingTalkChannel(Channel): conversation_type, sender_staff_id, conversation_id, - "DingTalk connection code is invalid or expired.", + await self._connect_reply_text("invalid_code"), ) return True @@ -1026,7 +1026,7 @@ class DingTalkChannel(Channel): conversation_type, sender_staff_id, conversation_id, - "DingTalk connection could not be completed from this message.", + await self._connect_reply_text("incomplete", owner_user_id=state["owner_user_id"]), ) return True @@ -1046,7 +1046,7 @@ class DingTalkChannel(Channel): conversation_type, sender_staff_id, conversation_id, - "DingTalk connected to DeerFlow.", + await self._connect_reply_text("connected", owner_user_id=state["owner_user_id"]), ) return True diff --git a/backend/app/channels/discord.py b/backend/app/channels/discord.py index b07ddeeeb9..b0450e3a66 100644 --- a/backend/app/channels/discord.py +++ b/backend/app/channels/discord.py @@ -999,7 +999,8 @@ class DiscordChannel(Channel): """Run the bind flow on the Gateway loop; replies go back through the Discord loop.""" state = await self._connection_repo.consume_oauth_state(provider="discord", state=code) if state is None: - await self._run_on_discord_loop(self._send_connection_reply(message, "Discord connection code is invalid or expired.")) + text = await self._connect_reply_text("invalid_code") + await self._run_on_discord_loop(self._send_connection_reply(message, text)) return True guild = getattr(message, "guild", None) @@ -1007,7 +1008,8 @@ class DiscordChannel(Channel): author = getattr(message, "author", None) user_id = str(getattr(author, "id", "") or "") if not user_id: - await self._run_on_discord_loop(self._send_connection_reply(message, "Discord connection could not be completed from this message.")) + text = await self._connect_reply_text("incomplete", owner_user_id=state["owner_user_id"]) + await self._run_on_discord_loop(self._send_connection_reply(message, text)) return True guild_id = str(getattr(guild, "id", "") or "") or None @@ -1024,7 +1026,8 @@ class DiscordChannel(Channel): }, status="connected", ) - await self._run_on_discord_loop(self._send_connection_reply(message, "Discord connected to DeerFlow.")) + text = await self._connect_reply_text("connected", owner_user_id=state["owner_user_id"]) + await self._run_on_discord_loop(self._send_connection_reply(message, text)) return True @staticmethod diff --git a/backend/app/channels/feishu.py b/backend/app/channels/feishu.py index 704267b0c1..937a6dc994 100644 --- a/backend/app/channels/feishu.py +++ b/backend/app/channels/feishu.py @@ -1833,11 +1833,11 @@ class FeishuChannel(Channel): 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.") + await self._reply_card(message_id, await self._connect_reply_text("invalid_code")) 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.") + await self._reply_card(message_id, await self._connect_reply_text("incomplete", owner_user_id=state["owner_user_id"])) return True await self._connection_repo.upsert_connection( @@ -1853,7 +1853,7 @@ class FeishuChannel(Channel): }, status="connected", ) - await self._reply_card(message_id, "Feishu connected to DeerFlow.") + await self._reply_card(message_id, await self._connect_reply_text("connected", owner_user_id=state["owner_user_id"])) return True def _parse_message_event(self, event) -> _ParsedMessage | None: diff --git a/backend/app/channels/goal_status.py b/backend/app/channels/goal_status.py new file mode 100644 index 0000000000..750b4a7849 --- /dev/null +++ b/backend/app/channels/goal_status.py @@ -0,0 +1,125 @@ +"""Text of the IM ``/goal`` replies, mirroring the web goal bar. + +The status reply follows ``describeGoalBar`` in +``frontend/src/components/workspace/goal-status-helpers.ts``: an active goal +wins over a met record; while a run is in flight, a stand-down left by an +earlier run is not shown; when idle, a stand-down, then a paused auto-continue, +then a plain set goal. Stop reasons are host-written codes +(``contracts/thread_goal_contract.json``) worded with the scheduled-notice +texts; the evaluator's own words and provider errors are never sent. A met goal +is cleared and recorded as ``goal_outcome``, which reads as met until the next +goal change. +""" + +from __future__ import annotations + +import math +from collections.abc import Mapping + +from app.scheduler.notification_text import goal_reason_text +from deerflow.persistence.scheduled_task_runs.finalization import CHECK_FAILURE_CODES +from deerflow.runtime.goal import is_active_goal + +_TEXT: dict[str, dict[str, str]] = { + "en-US": { + "goal": "Goal: {objective}", + "met": "Goal met: {objective}", + "met_with_assumption": "Goal met, with an assumption: {objective}", + "none": "No active goal.", + "in_progress": "In progress.", + "continuing": "In progress: auto-continued {count}/{max} times.", + "paused": "Paused: auto-continued {count}/{max} times; the run ended before the goal was confirmed.", + "stopped": "Stopped: {reason}.", + "stopped_bare": "Stopped.", + "waiting_for_you": "Waiting for you: {reason}.", + "waiting": "Waiting: {reason}.", + "unchecked": "{reason}. It's checked again after your next message.", + "cleared": "Goal cleared.", + "fetch_failed": "Failed to fetch goal information.", + "clear_failed": "Failed to clear goal.", + "set_failed": "Failed to set goal.", + }, + "zh-CN": { + "goal": "目标:{objective}", + "met": "目标已达成:{objective}", + "met_with_assumption": "目标已达成(含假设):{objective}", + "none": "当前没有目标。", + "in_progress": "进行中。", + "continuing": "进行中:已自动续跑 {count}/{max} 次。", + "paused": "已暂停:已自动续跑 {count}/{max} 次,运行在确认达成前结束了。", + "stopped": "已停止:{reason}。", + "stopped_bare": "已停止。", + "waiting_for_you": "等你回复:{reason}。", + "waiting": "等待中:{reason}。", + "unchecked": "{reason}。下次发消息后会重新检查。", + "cleared": "目标已清除。", + "fetch_failed": "无法获取目标信息。", + "clear_failed": "无法清除目标。", + "set_failed": "无法设置目标。", + }, +} + +# Stand-downs where the user, or something outside DeerFlow, acts next (the web bar's chips). +_WAITING_KEYS = {"needs_user_input": "waiting_for_you", "external_wait": "waiting"} + + +def goal_reply_text(key: str, locale: str) -> str: + """A fixed ``/goal`` reply (``none``, ``cleared``, ``*_failed``) in ``locale``.""" + return _TEXT[locale][key] + + +def is_active_goal_state(value: object) -> bool: + """An active goal with an objective, the web bar's ``isGoalState``.""" + return is_active_goal(value) and isinstance(value.get("objective"), str) + + +def _count(value: object) -> int: + """A positive whole count, else 0 (the web bar's ``countOf``).""" + if isinstance(value, bool) or not isinstance(value, int | float) or not math.isfinite(value) or value <= 0: + return 0 + return math.floor(value) + + +def _stop_line(code: str, max_continuations: int, locale: str) -> str: + """Why auto-continue stood down, by who acts next (the web bar's ``stopRowOf``).""" + text = _TEXT[locale] + if code in CHECK_FAILURE_CODES: + # Not a verdict: the goal is checked again after the next message. + reason = goal_reason_text(code, locale) + return text["unchecked"].format(reason=reason[:1].upper() + reason[1:]) if reason else text["stopped_bare"] + bare = code.removeprefix("blocked:") + if bare == "max_continuations_reached" and max_continuations <= 0: + # Auto-continue was off for this goal: the check just found it unmet. + bare = "goal_not_met_yet" + reason = goal_reason_text(bare, locale) + if reason is None: + return text["stopped_bare"] + return text[_WAITING_KEYS.get(bare, "stopped")].format(reason=reason) + + +def goal_status_text(goal: object, outcome: object, *, running: bool, locale: str) -> str: + """The ``/goal`` status reply: the active goal and its state, a met goal, or none. + + ``running`` says whether the thread has a run pending or in progress. + """ + text = _TEXT[locale] + if is_active_goal_state(goal): + lines = [text["goal"].format(objective=goal["objective"])] + evaluation = goal.get("last_evaluation") + evaluation = evaluation if isinstance(evaluation, Mapping) else None + code = evaluation.get("stand_down_reason") if evaluation is not None else None + code = code.strip() if isinstance(code, str) else "" + count = _count(goal.get("continuation_count")) + maximum = _count(goal.get("max_continuations")) + if running: + satisfied = evaluation is not None and evaluation.get("satisfied") is True + lines.append(text["continuing"].format(count=count, max=maximum) if not code and not satisfied and count > 0 else text["in_progress"]) + elif code: + lines.append(_stop_line(code, maximum, locale)) + elif evaluation is not None and count > 0: + lines.append(text["paused"].format(count=count, max=maximum)) + return "\n".join(lines) + if isinstance(outcome, Mapping) and outcome.get("status") == "achieved" and isinstance(outcome.get("objective"), str) and isinstance(outcome.get("goal_created_at"), str): + key = "met_with_assumption" if outcome.get("relied_on_assumption") is True else "met" + return text[key].format(objective=outcome["objective"]) + return text["none"] diff --git a/backend/app/channels/manager.py b/backend/app/channels/manager.py index 9d5331e34a..373b8301a6 100644 --- a/backend/app/channels/manager.py +++ b/backend/app/channels/manager.py @@ -32,6 +32,7 @@ from app.channels.capabilities import CHANNEL_CAPABILITIES from app.channels.commands import KNOWN_CHANNEL_COMMANDS from app.channels.connection_identity import lookup_thread_id from app.channels.dedupe_store import InboundDedupeStore, MemoryInboundDedupeStore +from app.channels.goal_status import goal_reply_text, goal_status_text, is_active_goal_state from app.channels.message_bus import ( HUMAN_INPUT_REQUEST_METADATA_KEY, HUMAN_INPUT_RESPONSE_METADATA_KEY, @@ -44,6 +45,7 @@ from app.channels.message_bus import ( ResolvedAttachment, cleanup_inbound_files, ) +from app.channels.reply_locale import ReplyLocaleResolver from app.channels.run_policy import CHANNEL_RUN_POLICY, ChannelRunPolicy from app.channels.store import ChannelStore from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, CSRF_HEADER_NAME, generate_csrf_token @@ -1566,6 +1568,7 @@ class ChannelManager: require_bound_identity: bool = False, inbound_dedupe_store: InboundDedupeStore | None = None, get_stream_bridge: Callable[[], StreamBridge | None] | None = None, + reply_locale: ReplyLocaleResolver | None = None, ) -> None: if isinstance(max_concurrency, bool) or not isinstance(max_concurrency, int) or max_concurrency <= 0: raise ValueError("max_concurrency must be a positive integer") @@ -1582,6 +1585,9 @@ class ChannelManager: self._channel_sessions = dict(channel_sessions or {}) self._connection_repo = connection_repo self._require_bound_identity = require_bound_identity + # Language of the /goal replies; ChannelService passes the owner-aware + # resolver, and a directly built manager replies in en-US. + self._reply_locale = reply_locale or ReplyLocaleResolver() # Zero-arg accessor for the FastAPI app's StreamBridge singleton, # threaded in from app.py's lifespan via start_channel_service() -> # ChannelService.__init__ (mirrors how ScheduledTaskService gets a @@ -3996,30 +4002,66 @@ class ChannelManager: response.raise_for_status() return response.json() or {} + async def _goal_reply(self, msg: InboundMessage, key: str) -> str: + """A fixed ``/goal`` reply in the owner's language.""" + return goal_reply_text(key, await self._reply_locale.locale_for(_effective_owner_user_id(msg))) + + async def _goal_status_reply(self, msg: InboundMessage, thread_id: str | None, *, headers: dict[str, str]) -> str: + """``/goal`` with no argument: the goal's state, why it stopped, or that it was met.""" + locale = await self._reply_locale.locale_for(_effective_owner_user_id(msg)) + if not thread_id: + return goal_reply_text("none", locale) + try: + goal = (await self._goal_request("get", thread_id, headers=headers)).get("goal") + active = is_active_goal_state(goal) + # A met goal is cleared; its goal_outcome record stays in the thread + # state until the next goal change. + outcome = None if active else await self._goal_outcome(msg, thread_id) + except Exception: + logger.exception("Failed to fetch goal from gateway") + return goal_reply_text("fetch_failed", locale) + running = active and await self._thread_run_in_flight(msg, thread_id) + return goal_status_text(goal, outcome, running=running, locale=locale) + + async def _thread_run_in_flight(self, msg: InboundMessage, thread_id: str) -> bool: + """Whether the thread's newest run is pending or running; False when unreadable.""" + try: + page = await self._fetch_gateway_json(f"/api/threads/{quote(thread_id, safe='')}/runs/page?limit=1", msg=msg) + except Exception: + logger.warning("[Manager] failed to read the newest run for /goal: thread_id=%s", thread_id, exc_info=True) + return False + runs = page.get("data") if isinstance(page, Mapping) else None + newest = runs[0] if isinstance(runs, list) and runs else None + return isinstance(newest, Mapping) and newest.get("status") in ("pending", "running") + + async def _goal_outcome(self, msg: InboundMessage, thread_id: str) -> Any: + """The thread's ``goal_outcome`` (its latest met goal), or None. Raises when unreadable.""" + try: + state = await self._fetch_gateway_json(f"/api/threads/{quote(thread_id, safe='')}/state", msg=msg) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + return None # no checkpoint yet, so no goal was ever met here + raise + values = state.get("values") if isinstance(state, Mapping) else None + return values.get("goal_outcome") if isinstance(values, Mapping) else None + async def _handle_goal_command(self, msg: InboundMessage, args: str) -> str | None: command = parse_goal_command(args) thread_id = await self._lookup_thread_id(msg) headers = _owner_headers(msg) or create_internal_auth_headers() if command.kind == "status": - if not thread_id: - return "No active goal." - try: - goal = (await self._goal_request("get", thread_id, headers=headers)).get("goal") - except Exception: - logger.exception("Failed to fetch goal from gateway") - return "Failed to fetch goal information." - return f"Goal: {goal.get('objective')}" if goal else "No active goal." + return await self._goal_status_reply(msg, thread_id, headers=headers) if command.kind == "clear": if not thread_id: - return "Goal cleared." + return await self._goal_reply(msg, "cleared") try: await self._goal_request("delete", thread_id, headers=headers) except Exception: logger.exception("Failed to clear goal through gateway") - return "Failed to clear goal." - return "Goal cleared." + return await self._goal_reply(msg, "clear_failed") + return await self._goal_reply(msg, "cleared") if not thread_id: thread_id, _ = await self._create_first_thread(self._get_client(), msg) @@ -4028,7 +4070,7 @@ class ChannelManager: await self._goal_request("put", thread_id, headers=headers, json={"objective": command.objective}) except Exception: logger.exception("Failed to set goal through gateway") - return "Failed to set goal." + return await self._goal_reply(msg, "set_failed") from dataclasses import replace as _dc_replace diff --git a/backend/app/channels/qq.py b/backend/app/channels/qq.py index 73beaebfd8..6f54969ba7 100644 --- a/backend/app/channels/qq.py +++ b/backend/app/channels/qq.py @@ -445,7 +445,7 @@ class QQChannel(Channel): self._replies.popitem(last=False) if code: state = await self._connection_repo.consume_oauth_state(provider="qq", state=code) - reply = "QQ connection code is invalid or expired." + reply_key, owner_user_id = "invalid_code", None if state is not None: group_name = str(data.get("group_name") or "").strip() if group else "" upsert_kwargs: dict[str, Any] = {} @@ -460,14 +460,14 @@ class QQChannel(Channel): status="connected", **upsert_kwargs, ) - reply = "QQ connected to DeerFlow." + reply_key, owner_user_id = "connected", state["owner_user_id"] await self.send( OutboundMessage( channel_name="qq", chat_id=chat_id, thread_id="", thread_ts=message_id, - text=reply, + text=await self._connect_reply_text(reply_key, owner_user_id=owner_user_id), ) ) return diff --git a/backend/app/channels/reply_locale.py b/backend/app/channels/reply_locale.py new file mode 100644 index 0000000000..e02a8fb1f5 --- /dev/null +++ b/backend/app/channels/reply_locale.py @@ -0,0 +1,107 @@ +"""Language and wording of the replies DeerFlow itself writes in IM chats. + +Host replies (the ``/connect`` handshake below, ``/goal`` in +``goal_status.py``) use the binding owner's web UI language (the ``locale`` +user preference, which the web app keeps in sync), else +``channel_connections.notification_locale``, else en-US: the same order as +scheduled-task notices. A reply with no known owner, such as one to an invalid +or expired code, uses the configured default. Agent output is never translated. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from typing import Any + +from app.scheduler.notification_text import DEFAULT_NOTIFICATION_LOCALE, NOTIFICATION_LOCALES +from deerflow.persistence.user.preferences import UserPreferencesRepository + +logger = logging.getLogger(__name__) + + +def supported_locale(value: object, default: str = DEFAULT_NOTIFICATION_LOCALE) -> str: + """``value`` when it is a supported reply language, else ``default``.""" + return value if isinstance(value, str) and value in NOTIFICATION_LOCALES else default + + +async def resolve_reply_locale(owner_user_id: str | None, *, session_factory: Any | None, default_locale: str = DEFAULT_NOTIFICATION_LOCALE) -> str: + """The owner's web UI language, else ``default_locale``, else en-US. + + Never raises: without an owner or a database the default applies, and a + failed read is logged and falls back, so a reply is never lost to it. + """ + default = supported_locale(default_locale) + if not owner_user_id or session_factory is None: + return default + try: + locale = (await UserPreferencesRepository(session_factory).get(owner_user_id)).get("locale") + except Exception: + logger.warning("Could not read the owner's language preference; replying in %s", default, exc_info=True) + return default + return supported_locale(locale, default) + + +@dataclass(frozen=True) +class ReplyLocaleResolver: + """``resolve_reply_locale`` bound to the shared database and the configured default. + + ``ChannelService`` builds one for its manager and every channel it starts; + a manager or channel built without one replies in en-US. + """ + + session_factory: Any | None = None + default_locale: str = DEFAULT_NOTIFICATION_LOCALE + + async def locale_for(self, owner_user_id: str | None) -> str: + return await resolve_reply_locale(owner_user_id, session_factory=self.session_factory, default_locale=self.default_locale) + + +# Platform names in replies; zh-CN uses the web UI's Chinese names where it has them. +_PROVIDER_NAMES: dict[str, dict[str, str]] = { + "en-US": { + "buzz": "Buzz", + "dingtalk": "DingTalk", + "discord": "Discord", + "feishu": "Feishu", + "qq": "QQ", + "slack": "Slack", + "telegram": "Telegram", + "wechat": "WeChat", + "wecom": "WeCom", + }, + "zh-CN": {"dingtalk": "钉钉", "feishu": "飞书", "wechat": "微信", "wecom": "企业微信"}, +} + +# Replies to ``/connect `` (Telegram: the ``/start `` deep link). +# ``{provider}`` is the platform's name. The English wording predates the table. +CONNECT_REPLY_TEXT: dict[str, dict[str, str]] = { + "en-US": { + "connected": "{provider} connected to DeerFlow.", + "invalid_code": "{provider} connection code is invalid or expired.", + "invalid_link": "{provider} connection link is invalid or expired.", + "incomplete": "{provider} connection could not be completed from this message.", + "private_chat_only": "Please send the connection code in a private chat with the bot.", + }, + "zh-CN": { + "connected": "{provider} 已连接到 DeerFlow。", + "invalid_code": "{provider} 绑定码无效或已过期。", + "invalid_link": "{provider} 绑定链接无效或已过期。", + "incomplete": "无法通过这条消息完成 {provider} 绑定。", + "private_chat_only": "请在与机器人的私聊中发送绑定码。", + }, +} + +_LATIN_NAME = re.compile(r"[A-Za-z0-9]") + + +def connect_reply_text(key: str, *, provider: str, locale: str) -> str: + """The ``key`` reply for ``provider`` (a channel name such as ``feishu``) in ``locale``.""" + locale = supported_locale(locale) + name = _PROVIDER_NAMES[locale].get(provider) or _PROVIDER_NAMES[DEFAULT_NOTIFICATION_LOCALE].get(provider) or provider + template = CONNECT_REPLY_TEXT[locale][key] + if not _LATIN_NAME.match(name): + # As on the web: a Latin name keeps its spaces ("Slack 已连接"), a Chinese one does not ("飞书已连接"). + template = template.replace(" {provider}", "{provider}").replace("{provider} ", "{provider}") + return template.format(provider=name) diff --git a/backend/app/channels/service.py b/backend/app/channels/service.py index 66ce7429bb..803fb84645 100644 --- a/backend/app/channels/service.py +++ b/backend/app/channels/service.py @@ -16,6 +16,7 @@ from app.channels.base import Channel, ChannelStopTimeout from app.channels.leadership import ChannelLeadership, RenewOutcome from app.channels.manager import DEFAULT_CHANNEL_MAX_CONCURRENCY, DEFAULT_CHANNEL_SHUTDOWN_GRACE_PERIOD_SECONDS, DEFAULT_GATEWAY_URL, DEFAULT_LANGGRAPH_URL, ChannelManager from app.channels.message_bus import DEFAULT_INBOUND_QUEUE_MAXSIZE, MessageBus +from app.channels.reply_locale import ReplyLocaleResolver, supported_locale from app.channels.runtime_config_store import RuntimeConfigStore, SqlChannelRuntimeConfigStore, merge_runtime_channel_configs, overlay_runtime_channel_configs, resolve_runtime_config_store, runtime_channel_disconnected from app.channels.store import ChannelStore, SqlChannelStore, resolve_channel_store from deerflow.config.channel_leader_lease_config import CHANNEL_LEADER_LEASE_FIELD, CHANNEL_LEADER_LEASE_KEY, ChannelLeaderLeaseConfig, channel_leader_lease_config @@ -235,6 +236,13 @@ class ChannelService: # runs this constructor off the loop. self.store: ChannelStore = store if store is not None else resolve_channel_store(app_config) self._connection_repo = connection_repo + # Language of DeerFlow's own replies (/connect, /goal): the owner's web UI + # language, read from the connection repository's database, else + # channel_connections.notification_locale (captured here; restart-scoped). + self._reply_locale = ReplyLocaleResolver( + session_factory=getattr(connection_repo, "session_factory", None), + default_locale=supported_locale(getattr(getattr(app_config, "channel_connections", None), "notification_locale", None)), + ) self._get_stream_bridge = get_stream_bridge 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) @@ -255,6 +263,7 @@ class ChannelService: require_bound_identity=require_bound_identity, inbound_dedupe_store=make_inbound_dedupe_store(app_config), get_stream_bridge=get_stream_bridge, + reply_locale=self._reply_locale, ) self._channels: dict[str, Any] = {} # name -> Channel instance self._config = config @@ -786,6 +795,7 @@ class ChannelService: config["seen_event_store_path"] = await asyncio.to_thread(_default_seen_store_path) if self._connection_repo is not None: config["connection_repo"] = self._connection_repo + config["reply_locale"] = self._reply_locale # A stop may have started while this coroutine was suspended in # pre-start I/O above. Fence publication again immediately before # the new instance becomes service-owned. diff --git a/backend/app/channels/slack.py b/backend/app/channels/slack.py index 83d552560a..9274be0b15 100644 --- a/backend/app/channels/slack.py +++ b/backend/app/channels/slack.py @@ -752,12 +752,12 @@ class SlackChannel(Channel): 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: - await self._post_connection_reply(channel_id, "Slack connection code is invalid or expired.", thread_ts) + await self._post_connection_reply(channel_id, await self._connect_reply_text("invalid_code"), thread_ts) return True user_id = str(event.get("user") or "") if not user_id or not team_id: - await self._post_connection_reply(channel_id, "Slack connection could not be completed from this message.", thread_ts) + await self._post_connection_reply(channel_id, await self._connect_reply_text("incomplete", owner_user_id=state["owner_user_id"]), thread_ts) return True await self._connection_repo.upsert_connection( @@ -771,7 +771,7 @@ class SlackChannel(Channel): }, status="connected", ) - await self._post_connection_reply(channel_id, "Slack connected to DeerFlow.", thread_ts) + await self._post_connection_reply(channel_id, await self._connect_reply_text("connected", owner_user_id=state["owner_user_id"]), thread_ts) return True async def _post_connection_reply(self, channel_id: str, text: str, thread_ts: str | None = None) -> None: diff --git a/backend/app/channels/telegram.py b/backend/app/channels/telegram.py index 1444a658ee..4b5f07bdfd 100644 --- a/backend/app/channels/telegram.py +++ b/backend/app/channels/telegram.py @@ -1052,7 +1052,8 @@ class TelegramChannel(Channel): state = await self._connection_repo.consume_oauth_state(provider="telegram", state=state_token) if state is None: - await self._run_on_telegram_loop(update.message.reply_text("Telegram connection link is invalid or expired.")) + text = await self._connect_reply_text("invalid_link") + await self._run_on_telegram_loop(update.message.reply_text(text)) return True owner_user_id = state["owner_user_id"] @@ -1073,7 +1074,8 @@ class TelegramChannel(Channel): 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 self._run_on_telegram_loop(update.message.reply_text("Telegram connected to DeerFlow.")) + text = await self._connect_reply_text("connected", owner_user_id=owner_user_id) + await self._run_on_telegram_loop(update.message.reply_text(text)) return True async def _bind_connection_from_start_token(self, update, state_token: str) -> bool: diff --git a/backend/app/channels/wechat.py b/backend/app/channels/wechat.py index d604265a11..9200413f4f 100644 --- a/backend/app/channels/wechat.py +++ b/backend/app/channels/wechat.py @@ -805,7 +805,7 @@ class WechatChannel(Channel): connect_code = self._pending_connect_code(text) if connect_code: if raw_message.get("group_id"): - await self._send_connection_reply(chat_id, context_token, "Please send the connection code in a private chat with the bot.") + await self._send_connection_reply(chat_id, context_token, await self._connect_reply_text("private_chat_only")) return handled = await self._bind_connection_from_connect_code( chat_id=chat_id, @@ -871,11 +871,11 @@ class WechatChannel(Channel): 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.") + await self._send_connection_reply(chat_id, context_token, await self._connect_reply_text("invalid_code")) return True if not chat_id: - await self._send_connection_reply(chat_id, context_token, "WeChat connection could not be completed from this message.") + await self._send_connection_reply(chat_id, context_token, await self._connect_reply_text("incomplete", owner_user_id=state["owner_user_id"])) return True connection = await self._connection_repo.upsert_connection( @@ -886,7 +886,7 @@ class WechatChannel(Channel): status="connected", ) await self._remember_notification_context(connection_id=connection["id"], owner_user_id=state["owner_user_id"], chat_id=chat_id, context_token=context_token) - await self._send_connection_reply(chat_id, context_token, "WeChat connected to DeerFlow.") + await self._send_connection_reply(chat_id, context_token, await self._connect_reply_text("connected", owner_user_id=state["owner_user_id"])) return True async def _send_connection_reply(self, chat_id: str, context_token: str, text: str) -> None: diff --git a/backend/app/channels/wecom.py b/backend/app/channels/wecom.py index 4eeae3950f..6911e7426d 100644 --- a/backend/app/channels/wecom.py +++ b/backend/app/channels/wecom.py @@ -655,11 +655,11 @@ class WeComChannel(Channel): 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.") + await self._send_connection_reply(frame, await self._connect_reply_text("invalid_code")) return True if not user_id: - await self._send_connection_reply(frame, "WeCom connection could not be completed from this message.") + await self._send_connection_reply(frame, await self._connect_reply_text("incomplete", owner_user_id=state["owner_user_id"])) return True body = frame.get("body", {}) or {} @@ -669,7 +669,7 @@ class WeComChannel(Channel): if chattype == "group": chatid = str(body.get("chatid") or "").strip() if not chatid: - await self._send_connection_reply(frame, "WeCom connection could not be completed from this message.") + await self._send_connection_reply(frame, await self._connect_reply_text("incomplete", owner_user_id=state["owner_user_id"])) return True workspace_id = f"{aibotid}:group:{chatid}" if aibotid else f"group:{chatid}" raw_group_name = str(body.get("chatname") or body.get("group_name") or "").strip() @@ -694,7 +694,7 @@ class WeComChannel(Channel): metadata=metadata, status="connected", ) - await self._send_connection_reply(frame, "WeCom connected to DeerFlow.") + await self._send_connection_reply(frame, await self._connect_reply_text("connected", owner_user_id=state["owner_user_id"])) return True async def _send_connection_reply(self, frame: dict[str, Any], text: str) -> None: diff --git a/backend/app/gateway/routers/user_preferences.py b/backend/app/gateway/routers/user_preferences.py index 1d6e951a11..fc6ba88f25 100644 --- a/backend/app/gateway/routers/user_preferences.py +++ b/backend/app/gateway/routers/user_preferences.py @@ -25,7 +25,8 @@ class Preferences(BaseModel): # declared contracts while preserving legacy direct-request behavior. reasoning_effort: Annotated[str, StringConstraints(max_length=32, pattern=r"^[A-Za-z0-9_.-]+$")] | None = None # The web app's UI language, synced on sign-in and on every language - # switch. Scheduled-task IM notices are written in it; without it they use + # switch. Scheduled-task IM notices and the IM /connect and /goal replies + # are written in it; without it they use # ``channel_connections.notification_locale``. locale: NotificationLocale | None = None diff --git a/backend/app/scheduler/notification_text.py b/backend/app/scheduler/notification_text.py index 8ecf012ce8..4ad4231e3c 100644 --- a/backend/app/scheduler/notification_text.py +++ b/backend/app/scheduler/notification_text.py @@ -242,14 +242,23 @@ def _truncate(text: str, *, limit: int) -> str: _NO_EXTRA_REASON = frozenset({"goal_not_met_yet", "unknown"}) +def goal_reason_text(code: object, locale: str) -> str | None: + """Readable text of a host goal reason code (``blocked:`` prefix optional), else None. + + ``locale`` is one of ``NOTIFICATION_LOCALES``. Only host-defined codes have + text, so evaluator or provider wording never passes through. The IM + ``/goal`` reply (``app/channels/goal_status.py``) shares these texts. + """ + if not isinstance(code, str): + return None + return _GOAL_REASON_TEXT[locale].get(code.removeprefix("blocked:")) + + def _reason_text(code: object, locale: str) -> str | None: """Readable reason for a goal code, or None when it would add nothing.""" - texts = _GOAL_REASON_TEXT[locale] - if isinstance(code, str): - code = code.removeprefix("blocked:") - if not isinstance(code, str) or code not in texts or code in _NO_EXTRA_REASON: + if isinstance(code, str) and code.removeprefix("blocked:") in _NO_EXTRA_REASON: return None - return texts[code] + return goal_reason_text(code, locale) def _with_reason(key: str, code: object, locale: str) -> str: diff --git a/backend/docs/API.md b/backend/docs/API.md index 4fa93b1033..c131397f88 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -62,8 +62,9 @@ patches preserve each other's changes, and same-field writes are last-commit-win Storage requires SQLite or PostgreSQL (`503` when unavailable). Browser notification permission remains device-local and is not changed by this API. `locale` is the web UI language; the web app writes it after sign-in and on -every language switch, and scheduled-task IM notices are written in it (without -it they use `channel_connections.notification_locale`). +every language switch, and scheduled-task IM notices and the IM `/connect` and +`/goal` replies are written in it (without it they use +`channel_connections.notification_locale`). ### Personal Access Tokens diff --git a/backend/docs/IM_CHANNEL_CONNECTIONS.md b/backend/docs/IM_CHANNEL_CONNECTIONS.md index 6149c85fd2..51775dbe1c 100644 --- a/backend/docs/IM_CHANNEL_CONNECTIONS.md +++ b/backend/docs/IM_CHANNEL_CONNECTIONS.md @@ -23,6 +23,12 @@ The selection is written to the new Gateway thread's channel metadata and, for a Because selecting an agent always creates a new thread instead of mutating the current one, an existing conversation keeps its original runtime, prompt, skills, and checkpoint lineage. `/agent use lead_agent` starts a new conversation with the default agent. +### Goal status (`/goal`) + +`/goal ` sets a goal on the chat's conversation and starts a run with the condition as the task; `/goal clear` clears it. `/goal` alone reports what the web goal bar shows: the goal, `In progress.` while a run is pending or running, and once auto-continue has stood down, why (for example `Stopped: the continuation limit was reached.` or `Waiting for you: it needs your input.`). A met goal is cleared and recorded in the thread state, so `/goal` answers `Goal met: ` until the next goal change. Reasons come only from host-written codes; the goal checker's notes and provider errors are never sent. + +DeerFlow's own replies here and to `/connect` use the binding owner's web UI language (the `locale` user preference), else `channel_connections.notification_locale` (`en-US` by default). A reply with no known owner, such as one to an invalid or expired code, uses the configured default. Agent replies are not translated. + Connect codes are deliberately **bind-time defenses**, not chat-time defenses. After binding, ordinary `allowed_users` continue to gate regular messages exactly as before. ## Connect-code Flow @@ -37,22 +43,22 @@ sequenceDiagram participant Store as SQL store
channel_oauth_states participant Worker as Provider worker
(Telegram/Slack/...) participant Repo as ChannelConnection repo
(upsert_connection) + participant Chat as IM chat Browser->>Gateway: POST /api/channels/{provider}/connect Gateway->>Store: insert code (token_urlsafe(16), TTL=600s, single-use) Gateway-->>Browser: code + (Telegram: deep-link URL) - Note over Browser,Worker: User sends /connect (or /start ) to the provider bot - + Chat->>Worker: /connect (Telegram: /start ) Worker->>Store: consume_oauth_state(code) alt valid + unexpired Store-->>Worker: ok (state consumed once) Worker->>Repo: upsert_connection(provider, external_account_id, workspace_id, owner_user_id) Repo-->>Worker: connection row (active) - Worker-->>Browser: success reply (via channel callback) + Worker-->>Chat: "connected" reply in the code owner's web UI language else invalid / expired / used Store-->>Worker: reject - Worker-->>Browser: rejected (no reply in chat) + Worker-->>Chat: "invalid or expired" reply in channel_connections.notification_locale end Note over Repo: Partial unique index uq_channel_connection_active_identity
revokes prior owner's active row for the same identity @@ -381,6 +387,8 @@ Upgrade note: existing auth-enabled deployments that already have `channel_conne ## Connect Flow +Every worker answers the bind message in the chat: ` connected to DeerFlow.` on success, otherwise that the code (Telegram: the link) is invalid or expired, or that the bind could not be completed from that message. Replies use the code owner's web UI language once the code is consumed, else `channel_connections.notification_locale` ([reply language](#goal-status-goal)). + Telegram: - The frontend creates a short one-time code. diff --git a/backend/packages/harness/deerflow/config/channel_connections_config.py b/backend/packages/harness/deerflow/config/channel_connections_config.py index c1ee178093..266f02518e 100644 --- a/backend/packages/harness/deerflow/config/channel_connections_config.py +++ b/backend/packages/harness/deerflow/config/channel_connections_config.py @@ -6,7 +6,8 @@ from typing import Literal from pydantic import BaseModel, Field -# Languages scheduled-task IM notices are rendered in (the web UI's languages). +# Languages of DeerFlow's own IM texts (scheduled-task notices, /connect and +# /goal replies): the web UI's languages. NotificationLocale = Literal["en-US", "zh-CN"] @@ -48,9 +49,11 @@ class ChannelConnectionsConfig(BaseModel): enabled: bool = False require_bound_identity: bool = True - # Language of scheduled-task IM notices for an owner who has no UI language - # preference (auth disabled, or never signed in to the web app). The owner's - # own preference, which the web app keeps in sync with its UI language, wins. + # Language of scheduled-task IM notices and the /connect and /goal replies + # for an owner who has no UI language preference (auth disabled, or never + # signed in to the web app), and for replies with no known owner (an invalid + # connect code). The owner's own preference, which the web app keeps in sync + # with its UI language, wins. notification_locale: NotificationLocale = "en-US" slack: SlackChannelConnectionConfig = Field(default_factory=SlackChannelConnectionConfig) telegram: TelegramChannelConnectionConfig = Field(default_factory=TelegramChannelConnectionConfig) diff --git a/backend/packages/harness/deerflow/config/reload_boundary.py b/backend/packages/harness/deerflow/config/reload_boundary.py index 7d6fc8f579..c38dfe7dcd 100644 --- a/backend/packages/harness/deerflow/config/reload_boundary.py +++ b/backend/packages/harness/deerflow/config/reload_boundary.py @@ -73,7 +73,7 @@ STARTUP_ONLY_FIELDS: dict[str, str] = { ), "channel_connections": ( "start_channel_service() wires the connection repository and channel workers once at startup, and the channel-connections router caches the merged provider config on app.state; " - "the scheduled-task notification delivery worker captures channel_connections.notification_locale when it starts. channel_connections.* edits need a restart." + "the scheduled-task notification delivery worker and ChannelService (for the IM /connect and /goal replies) capture channel_connections.notification_locale when they start. channel_connections.* edits need a restart." ), "scheduler": ( "ScheduledTaskService is constructed and started once during Gateway lifespan startup; enabled, poll_interval_seconds, lease_seconds, " diff --git a/backend/tests/test_additional_channel_connections.py b/backend/tests/test_additional_channel_connections.py index 55c76cc87d..01005b3cac 100644 --- a/backend/tests/test_additional_channel_connections.py +++ b/backend/tests/test_additional_channel_connections.py @@ -136,6 +136,55 @@ def test_feishu_connect_command_binds_identity(tmp_path): anyio.run(go) +def test_feishu_connect_replies_follow_the_code_owner_language(tmp_path, monkeypatch): + """Success speaks the binding owner's web UI language; an invalid code has no owner yet.""" + import anyio + + from app.channels.feishu import FeishuChannel + from app.channels.reply_locale import ReplyLocaleResolver + + lookups = [] + + class _Preferences: # stands in for the user_preferences table + def __init__(self, sessions): + self.sessions = sessions + + async def get(self, user_id): + lookups.append((user_id, self.sessions)) + return {"locale": "zh-CN"} if user_id == "deerflow-user-1" else {} + + monkeypatch.setattr("app.channels.reply_locale.UserPreferencesRepository", _Preferences) + + async def go(): + repo = await _make_repo(tmp_path, "feishu-zh") + state = "feishu-bind-code-zh" + await _seed_state(repo, "feishu", state) + channel = FeishuChannel( + bus=MessageBus(), + config={ + "app_id": "app", + "app_secret": "secret", + "connection_repo": repo, + # What ChannelService injects: the shared database, configured default en-US. + "reply_locale": ReplyLocaleResolver(session_factory=repo.session_factory, default_locale="en-US"), + }, + ) + channel._reply_card = AsyncMock() + + await channel._bind_connection_from_connect_code(message_id="om-1", chat_id="oc-chat-1", user_id="ou-user-1", code=state) + await channel._bind_connection_from_connect_code(message_id="om-2", chat_id="oc-chat-1", user_id="ou-user-1", code=state) + + assert [call.args for call in channel._reply_card.await_args_list] == [ + ("om-1", "飞书已连接到 DeerFlow。"), + # The code is spent: no owner is known, so the configured default applies. + ("om-2", "Feishu connection code is invalid or expired."), + ] + assert lookups == [("deerflow-user-1", repo.session_factory)] + await repo.close() + + anyio.run(go) + + def test_dingtalk_connect_command_binds_identity(tmp_path): import anyio diff --git a/backend/tests/test_buzz_channel.py b/backend/tests/test_buzz_channel.py index 98c6bb7983..926ffe5a2d 100644 --- a/backend/tests/test_buzz_channel.py +++ b/backend/tests/test_buzz_channel.py @@ -692,6 +692,26 @@ def test_connect_repo_error_sends_error_reply_and_does_not_crash(): assert event["content"] == "Buzz connection could not be completed from this message." +@pytest.mark.parametrize( + ("repo_kwargs", "expected"), + [ + ({"states": {"tok-1": "owner-zh"}}, "Buzz 已连接到 DeerFlow。"), + ({}, "Buzz 绑定码无效或已过期。"), + ({"raise_on_consume": True}, "无法通过这条消息完成 Buzz 绑定。"), + ], +) +def test_connect_replies_use_the_configured_language(repo_kwargs, expected): + # No user_preferences store here, so every reply uses channel_connections.notification_locale. + from app.channels.reply_locale import ReplyLocaleResolver + + ch, _ = _started(connection_repo=FakeConnectionRepo(**repo_kwargs), reply_locale=ReplyLocaleResolver(default_locale="zh-CN")) + transport = FakeTransport() + ch._transport = transport + _dispatch(ch, _event(sk=SK_NEWCOMER, content="/connect tok-1", mentions=())) + (event,) = _events_of(transport) + assert event["content"] == expected + + def test_connect_success_survives_a_failed_confirmation_send(caplog): """CRITICAL (Task 4 review carry-forward pitfall): a failure to SEND the confirmation for an otherwise-successful bind must never be reported or diff --git a/backend/tests/test_channel_reply_locale.py b/backend/tests/test_channel_reply_locale.py new file mode 100644 index 0000000000..6b1181e72d --- /dev/null +++ b/backend/tests/test_channel_reply_locale.py @@ -0,0 +1,151 @@ +"""Language of DeerFlow's own IM replies (``app/channels/reply_locale.py``).""" + +from __future__ import annotations + +import logging +from types import SimpleNamespace + +import pytest +from sqlalchemy import text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from app.channels import goal_status +from app.channels.reply_locale import CONNECT_REPLY_TEXT, ReplyLocaleResolver, connect_reply_text, resolve_reply_locale +from app.scheduler.notification_text import NOTIFICATION_LOCALES +from deerflow.persistence.base import Base +from deerflow.persistence.user.model import UserRow +from deerflow.persistence.user.preferences import UserPreferencesRepository + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture +async def sessions(tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/preferences.db") + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + factory = async_sessionmaker(engine, expire_on_commit=False) + async with factory() as session: + session.add(UserRow(id="alice", email="alice@example.com")) + await session.commit() + yield factory + await engine.dispose() + + +@pytest.mark.anyio +@pytest.mark.parametrize("stored", ["zh-CN", "en-US"]) +async def test_owner_preference_wins_over_the_configured_default(sessions, stored): + await UserPreferencesRepository(sessions).patch("alice", {"locale": stored}) + other = "en-US" if stored == "zh-CN" else "zh-CN" + assert await resolve_reply_locale("alice", session_factory=sessions, default_locale=other) == stored + + +@pytest.mark.anyio +@pytest.mark.parametrize("stored", ["fr-FR", "zh", 7, None]) +async def test_unsupported_preference_uses_the_configured_default(sessions, stored): + await UserPreferencesRepository(sessions).patch("alice", {"locale": stored}) + assert await resolve_reply_locale("alice", session_factory=sessions, default_locale="zh-CN") == "zh-CN" + + +@pytest.mark.anyio +async def test_missing_preference_uses_the_configured_default(sessions): + await UserPreferencesRepository(sessions).patch("alice", {"model_name": "model-a"}) + assert await resolve_reply_locale("alice", session_factory=sessions, default_locale="zh-CN") == "zh-CN" + assert await resolve_reply_locale("bob", session_factory=sessions, default_locale="zh-CN") == "zh-CN" + + +@pytest.mark.anyio +async def test_read_error_falls_back_and_logs(sessions, caplog): + # An undecodable stored value makes the repository raise on read. + async with sessions() as session: + await session.execute(text("INSERT INTO user_preferences (user_id, key, value) VALUES ('alice', 'locale', 'not json {')")) + await session.commit() + with caplog.at_level(logging.WARNING, logger="app.channels.reply_locale"): + assert await resolve_reply_locale("alice", session_factory=sessions, default_locale="zh-CN") == "zh-CN" + assert "language preference" in caplog.text + + +@pytest.mark.anyio +async def test_no_owner_or_database_skips_the_read(monkeypatch): + class _Unreachable: + def __init__(self, sessions): + raise AssertionError("the preference store must not be read") + + monkeypatch.setattr("app.channels.reply_locale.UserPreferencesRepository", _Unreachable) + assert await resolve_reply_locale(None, session_factory=object(), default_locale="zh-CN") == "zh-CN" + assert await resolve_reply_locale("alice", session_factory=None, default_locale="zh-CN") == "zh-CN" + # An unsupported configured default ends at en-US. + assert await resolve_reply_locale(None, session_factory=None, default_locale="fr-FR") == "en-US" + assert await ReplyLocaleResolver().locale_for("alice") == "en-US" + + +_PROVIDERS = ("buzz", "dingtalk", "discord", "feishu", "qq", "slack", "telegram", "wechat", "wecom") +_ENGLISH_NAMES = {"buzz": "Buzz", "dingtalk": "DingTalk", "discord": "Discord", "feishu": "Feishu", "qq": "QQ", "slack": "Slack", "telegram": "Telegram", "wechat": "WeChat", "wecom": "WeCom"} + + +@pytest.mark.parametrize("provider", _PROVIDERS) +def test_english_connect_replies_are_unchanged(provider): + name = _ENGLISH_NAMES[provider] + assert connect_reply_text("connected", provider=provider, locale="en-US") == f"{name} connected to DeerFlow." + assert connect_reply_text("invalid_code", provider=provider, locale="en-US") == f"{name} connection code is invalid or expired." + assert connect_reply_text("incomplete", provider=provider, locale="en-US") == f"{name} connection could not be completed from this message." + assert connect_reply_text("invalid_link", provider="telegram", locale="en-US") == "Telegram connection link is invalid or expired." + assert connect_reply_text("private_chat_only", provider="wechat", locale="en-US") == "Please send the connection code in a private chat with the bot." + + +def test_chinese_connect_replies_space_latin_names_only(): + assert connect_reply_text("connected", provider="feishu", locale="zh-CN") == "飞书已连接到 DeerFlow。" + assert connect_reply_text("connected", provider="slack", locale="zh-CN") == "Slack 已连接到 DeerFlow。" + assert connect_reply_text("invalid_code", provider="wecom", locale="zh-CN") == "企业微信绑定码无效或已过期。" + assert connect_reply_text("invalid_code", provider="qq", locale="zh-CN") == "QQ 绑定码无效或已过期。" + assert connect_reply_text("incomplete", provider="dingtalk", locale="zh-CN") == "无法通过这条消息完成钉钉绑定。" + assert connect_reply_text("incomplete", provider="discord", locale="zh-CN") == "无法通过这条消息完成 Discord 绑定。" + assert connect_reply_text("invalid_link", provider="telegram", locale="zh-CN") == "Telegram 绑定链接无效或已过期。" + assert connect_reply_text("private_chat_only", provider="wechat", locale="zh-CN") == "请在与机器人的私聊中发送绑定码。" + + +@pytest.mark.parametrize("table", [CONNECT_REPLY_TEXT, goal_status._TEXT], ids=["connect", "goal"]) +def test_reply_tables_have_the_same_keys_in_every_language(table): + assert set(table) == set(NOTIFICATION_LOCALES) + for locale in NOTIFICATION_LOCALES: + assert set(table[locale]) == set(table["en-US"]) + + +@pytest.mark.anyio +async def test_channel_service_gives_its_manager_and_channels_one_resolver(monkeypatch, tmp_path): + from app.channels import service as channel_service + from app.channels.service import ChannelService + from app.channels.store import JsonChannelStore + from deerflow import reflection + + started_configs = [] + + class _RecordingChannel: + def __init__(self, *, bus, config): + started_configs.append(config) + self.is_running = False + + async def start(self): + self.is_running = True + + async def stop(self): + self.is_running = False + + repo = SimpleNamespace(session_factory=object()) + app_config = SimpleNamespace(channel_connections=SimpleNamespace(notification_locale="zh-CN")) + service = ChannelService(channels_config={"fake": {"enabled": True}}, connection_repo=repo, app_config=app_config, store=JsonChannelStore(path=tmp_path / "store.json")) + service._running = True + monkeypatch.setitem(channel_service._CHANNEL_REGISTRY, "fake", "tests.fake:FakeChannel") + monkeypatch.setattr(reflection, "resolve_class", lambda *_args, **_kwargs: _RecordingChannel) + + assert await service._start_channel("fake", {"enabled": True}) is True + resolver = started_configs[0]["reply_locale"] + assert resolver is service.manager._reply_locale + assert resolver.default_locale == "zh-CN" + assert resolver.session_factory is repo.session_factory + + # Without channel connections configured, replies default to en-US. + assert ChannelService(channels_config={}, store=JsonChannelStore(path=tmp_path / "other.json")).manager._reply_locale == ReplyLocaleResolver() diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py index 273c477d64..14dc1261f0 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -13424,12 +13424,22 @@ class TestHandleGoalCommand: """Covers the IM-channel ``/goal`` handler (get/set/clear via the Gateway).""" @staticmethod - def _install_mock_httpx(monkeypatch, calls, *, goal_payload=None, fail_method=None): + def _install_mock_httpx(monkeypatch, calls, *, goal_payload=None, fail_method=None, state_values=None, state_status=200, runs=()): class MockResponse: + def __init__(self, url): + self.url = url + def raise_for_status(self): - return None + if self.url.endswith("/state") and state_status != 200: + request = httpx.Request("GET", self.url) + raise httpx.HTTPStatusError("state unavailable", request=request, response=httpx.Response(state_status, request=request)) def json(self): + # The Gateway endpoints the status reply reads, routed by path. + if self.url.endswith("/state"): + return {"values": dict(state_values or {})} + if "/runs/page" in self.url: + return {"data": list(runs), "has_more": False} return {"goal": goal_payload} class MockAsyncClient: @@ -13446,7 +13456,7 @@ class TestHandleGoalCommand: calls.append({"method": method, "url": url, **kwargs}) if fail_method == method: raise RuntimeError("gateway down") - return MockResponse() + return MockResponse(url) async def get(self, url, **kwargs): return await self._record("get", url, **kwargs) @@ -13460,12 +13470,12 @@ class TestHandleGoalCommand: monkeypatch.setattr("app.channels.manager.httpx.AsyncClient", MockAsyncClient) @staticmethod - def _make_manager(monkeypatch, *, thread_id): + def _make_manager(monkeypatch, *, thread_id, **kwargs): from app.channels.manager import ChannelManager bus = MessageBus() store = JsonChannelStore(path=Path(tempfile.mkdtemp()) / "store.json") - manager = ChannelManager(bus=bus, store=store, gateway_url="http://gateway:8001") + manager = ChannelManager(bus=bus, store=store, gateway_url="http://gateway:8001", **kwargs) async def _lookup(msg): return thread_id @@ -13497,7 +13507,7 @@ class TestHandleGoalCommand: def test_status_with_active_goal_reports_objective(self, monkeypatch): calls = [] - self._install_mock_httpx(monkeypatch, calls, goal_payload={"objective": "ship it"}) + self._install_mock_httpx(monkeypatch, calls, goal_payload={"objective": "ship it", "status": "active"}) async def go(): manager = self._make_manager(monkeypatch, thread_id="t-1") @@ -13505,6 +13515,71 @@ class TestHandleGoalCommand: assert reply == "Goal: ship it" assert calls[0]["method"] == "get" assert calls[0]["url"].endswith("/api/threads/t-1/goal") + # An active goal also asks whether a run is in flight, never for the met record. + assert [call["url"].removeprefix("http://gateway:8001") for call in calls[1:]] == ["/api/threads/t-1/runs/page?limit=1"] + + _run(go()) + + # The evaluator's own words: never part of an IM reply (host codes only). + _EVALUATOR_NOTE = "The checker says the provider returned 401 for token sk-secret" + + @pytest.mark.parametrize( + ("goal_fields", "run_status", "state_line"), + [ + pytest.param({}, "running", "In progress.", id="in-progress"), + pytest.param({"last_evaluation": {"satisfied": False, "stand_down_reason": "blocked:needs_user_input"}}, "pending", "In progress.", id="in-flight-run-hides-earlier-stand-down"), + pytest.param({"continuation_count": 2, "last_evaluation": {"satisfied": False}}, "running", "In progress: auto-continued 2/8 times.", id="continuing"), + pytest.param({"continuation_count": 8, "last_evaluation": {"satisfied": False, "stand_down_reason": "max_continuations_reached"}}, "success", "Stopped: the continuation limit was reached.", id="stopped-at-limit"), + pytest.param({"last_evaluation": {"satisfied": False, "stand_down_reason": "no_progress_detected"}}, "success", "Stopped: no progress was made between turns.", id="stopped-no-progress"), + pytest.param({"max_continuations": 0, "last_evaluation": {"satisfied": False, "stand_down_reason": "max_continuations_reached"}}, "success", "Stopped: the goal is not met yet.", id="stopped-auto-continue-off"), + pytest.param({"last_evaluation": {"satisfied": False, "stand_down_reason": "blocked:needs_user_input"}}, "success", "Waiting for you: it needs your input.", id="waiting-for-you"), + pytest.param({"last_evaluation": {"satisfied": False, "stand_down_reason": "blocked:external_wait"}}, "error", "Waiting: it is waiting on something external.", id="waiting"), + pytest.param({"last_evaluation": {"satisfied": False, "stand_down_reason": "no_durable_end_of_turn"}}, "success", "No final reply was saved. It's checked again after your next message.", id="unchecked"), + pytest.param({"last_evaluation": {"satisfied": False, "stand_down_reason": "a_code_from_a_newer_server"}}, "success", "Stopped.", id="unknown-code-is-not-shown"), + pytest.param({"continuation_count": 3, "last_evaluation": {"satisfied": False}}, "success", "Paused: auto-continued 3/8 times; the run ended before the goal was confirmed.", id="paused"), + pytest.param({}, "success", None, id="set"), + ], + ) + def test_status_reports_goal_state_like_the_web_goal_bar(self, monkeypatch, goal_fields, run_status, state_line): + goal = {"objective": "ship it", "status": "active", "continuation_count": 0, "max_continuations": 8, **goal_fields} + if "last_evaluation" in goal: + goal["last_evaluation"] = {**goal["last_evaluation"], "reason": self._EVALUATOR_NOTE} + calls = [] + self._install_mock_httpx(monkeypatch, calls, goal_payload=goal, runs=[{"run_id": "r-1", "status": run_status}]) + + async def go(): + manager = self._make_manager(monkeypatch, thread_id="t-1") + reply = await manager._handle_goal_command(self._msg("/goal"), "") + assert reply == "\n".join(["Goal: ship it"] + ([state_line] if state_line else [])) + assert "401" not in reply and "secret" not in reply + + _run(go()) + + @pytest.mark.parametrize( + ("relied_on_assumption", "expected"), + [(False, "Goal met: ship it"), (True, "Goal met, with an assumption: ship it")], + ) + def test_status_reports_a_met_goal_from_its_outcome_record(self, monkeypatch, relied_on_assumption, expected): + # A met goal is cleared (GET /goal says none) and recorded as goal_outcome. + outcome = { + "status": "achieved", + "objective": "ship it", + "goal_created_at": "2026-10-26T08:00:00Z", + "achieved_at": "2026-10-26T08:30:00Z", + "continuation_count": 1, + "max_continuations": 8, + "reason": self._EVALUATOR_NOTE, + "relied_on_assumption": relied_on_assumption, + "reply_message_id": "ai-1", + } + calls = [] + self._install_mock_httpx(monkeypatch, calls, goal_payload=None, state_values={"goal_outcome": outcome}) + + async def go(): + manager = self._make_manager(monkeypatch, thread_id="t-1") + reply = await manager._handle_goal_command(self._msg("/goal"), "") + assert reply == expected + assert [call["url"].removeprefix("http://gateway:8001") for call in calls] == ["/api/threads/t-1/goal", "/api/threads/t-1/state"] _run(go()) @@ -13519,6 +13594,59 @@ class TestHandleGoalCommand: _run(go()) + @pytest.mark.parametrize(("state_status", "expected"), [(404, "No active goal."), (500, "Failed to fetch goal information.")]) + def test_status_when_the_thread_state_cannot_be_read(self, monkeypatch, state_status, expected): + # 404: a bound thread with no checkpoint yet, so nothing was ever met there. + calls = [] + self._install_mock_httpx(monkeypatch, calls, goal_payload=None, state_status=state_status) + + async def go(): + manager = self._make_manager(monkeypatch, thread_id="t-1") + assert await manager._handle_goal_command(self._msg("/goal"), "") == expected + + _run(go()) + + def test_goal_replies_follow_the_owner_language(self, monkeypatch): + from dataclasses import replace + + from app.channels.reply_locale import ReplyLocaleResolver + + lookups = [] + + class _Preferences: # stands in for the user_preferences table + def __init__(self, sessions): + pass + + async def get(self, user_id): + lookups.append(user_id) + return {"locale": "zh-CN"} if user_id == "owner-zh" else {} + + monkeypatch.setattr("app.channels.reply_locale.UserPreferencesRepository", _Preferences) + stopped = {"objective": "写周报", "status": "active", "continuation_count": 8, "max_continuations": 8, "last_evaluation": {"satisfied": False, "stand_down_reason": "max_continuations_reached"}} + outcome = {"status": "achieved", "objective": "写周报", "goal_created_at": "", "relied_on_assumption": False} + resolver = ReplyLocaleResolver(session_factory=object(), default_locale="en-US") + + async def reply(args, owner, **mock): + calls = [] + self._install_mock_httpx(monkeypatch, calls, **mock) + manager = self._make_manager(monkeypatch, thread_id="t-1", reply_locale=resolver) + msg = replace(self._msg(f"/goal {args}".strip()), owner_user_id=owner) + return await manager._handle_goal_command(msg, args) + + async def go(): + assert await reply("", "owner-zh", goal_payload=stopped) == "目标:写周报\n已停止:已达到继续处理的次数上限。" + assert await reply("", "owner-zh", goal_payload=None, state_values={"goal_outcome": outcome}) == "目标已达成:写周报" + assert await reply("", "owner-zh", goal_payload=None) == "当前没有目标。" + assert await reply("", "owner-zh", fail_method="get") == "无法获取目标信息。" + assert await reply("clear", "owner-zh") == "目标已清除。" + assert await reply("clear", "owner-zh", fail_method="delete") == "无法清除目标。" + assert await reply("do X", "owner-zh", fail_method="put") == "无法设置目标。" + # No language preference: the configured default (en-US here). + assert await reply("", "owner-en", goal_payload=stopped) == "Goal: 写周报\nStopped: the continuation limit was reached." + assert set(lookups) == {"owner-zh", "owner-en"} + + _run(go()) + def test_clear_with_thread_calls_delete(self, monkeypatch): calls = [] self._install_mock_httpx(monkeypatch, calls) diff --git a/config.example.yaml b/config.example.yaml index 86e882d6fe..e2ebc53ad0 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -3040,8 +3040,10 @@ run_ownership: # # Security: keep this enabled unless you intentionally want legacy open-bot behavior. # # Disabling it lets unbound external IM users create DeerFlow threads/runs. # require_bound_identity: true -# # Language of scheduled-task IM notices (en-US or zh-CN) for owners without -# # a web UI language preference (e.g. auth disabled). Restart-required. +# # Language (en-US or zh-CN) of DeerFlow's own IM texts: scheduled-task +# # notices and the /connect and /goal replies. Used for owners without a web +# # UI language preference (e.g. auth disabled) and for replies with no known +# # owner (an invalid connect code). Restart-required. # notification_locale: en-US # # telegram: diff --git a/frontend/src/components/workspace/channels/channel-binding-code-dialog.tsx b/frontend/src/components/workspace/channels/channel-binding-code-dialog.tsx new file mode 100644 index 0000000000..cb4084ec8f --- /dev/null +++ b/frontend/src/components/workspace/channels/channel-binding-code-dialog.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { CheckIcon, CopyIcon, LoaderCircleIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { GatewayApiError } from "@/core/api/errors"; +import { channelProviderName } from "@/core/channels/provider-state"; +import type { ChannelProvider } from "@/core/channels/types"; +import { useBindingCode } from "@/core/channels/use-binding-code"; +import { writeTextToClipboard } from "@/core/clipboard"; +import { useI18n } from "@/core/i18n/hooks"; + +import { ChannelProviderIcon } from "./channel-provider-icon"; + +/** + * Connects an app whose bot links accounts with `/connect ` (Slack, + * Discord, Feishu, …). The command stays on screen, copyable, until the bot + * reports the binding or the code expires. A code is created when the dialog + * opens and kept until it closes. + */ +export function ChannelBindingCodeDialog({ + provider, + open, + onOpenChange, +}: { + provider: ChannelProvider | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + if (!provider) return null; + return ( + + + onOpenChange(false)} + /> + + + ); +} + +function ChannelBindingCode({ + provider, + onDone, +}: { + provider: ChannelProvider; + onDone: () => void; +}) { + const { t, locale } = useI18n(); + const text = t.channels.bindingCode; + const name = channelProviderName(provider, t); + const { + stage, + binding, + command, + error, + copied, + copyFailed, + reportCopy, + retry, + } = useBindingCode({ + provider: provider.provider, + alreadyConnected: provider.connection_status === "connected", + }); + // The server caps unused codes per app; its English detail is not shown. + const tooManyCodes = error instanceof GatewayApiError && error.status === 429; + const errorDetail = + error instanceof GatewayApiError && !tooManyCodes ? error.rawMessage : ""; + const expiresAt = binding + ? new Intl.DateTimeFormat(locale, { + hour: "2-digit", + minute: "2-digit", + }).format(binding.expiresAt) + : ""; + + const copy = async () => { + reportCopy(await writeTextToClipboard(command)); + }; + + return ( + <> +
+
+ +
+ + {t.channels.setupTitle(name)} + {text.description(name)} + +
+
+ {stage === "connected" ? ( + <> +
+ +
+

{text.connected(name)}

+ + ) : stage === "binding" ? ( + <> + + {command} + + + {copyFailed && ( +

{text.copyFailed}

+ )} +

+ {text.expiresAt(expiresAt)} +

+

+ + {text.waiting(name)} +

+ + ) : stage === "loading" ? ( + <> + +

{text.loading}

+ + ) : stage === "expired" ? ( + <> +

{text.expired}

+ + + ) : ( + <> +

+ {tooManyCodes ? text.tooManyCodes : text.failed} +

+ {errorDetail ? ( +

+ {errorDetail} +

+ ) : null} + + + )} +
+ + + ); +} diff --git a/frontend/src/components/workspace/channels/wechat-qr-completion.tsx b/frontend/src/components/workspace/channels/wechat-qr-completion.tsx index 5cf4e32965..cf3aa226b4 100644 --- a/frontend/src/components/workspace/channels/wechat-qr-completion.tsx +++ b/frontend/src/components/workspace/channels/wechat-qr-completion.tsx @@ -1,35 +1,21 @@ "use client"; -import { useQueryClient } from "@tanstack/react-query"; import { CheckIcon, CopyIcon, LoaderCircleIcon, QrCodeIcon, } from "lucide-react"; -import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; -import { - connectChannelProvider, - listChannelConnections, - listChannelProviders, -} from "@/core/channels/api"; -import { - startConnectionPoll, - type ConnectPollHandle, -} from "@/core/channels/connect-poll"; -import { - channelConnectionsQueryKey, - channelProviderQueryKey, -} from "@/core/channels/hooks"; import type { ChannelProvider } from "@/core/channels/types"; +import { + useBindingCode, + type PendingBindingCode, +} from "@/core/channels/use-binding-code"; import { useI18n } from "@/core/i18n/hooks"; -export type PendingWechatBinding = { - code: string; - expiresAt: number; -}; +export type PendingWechatBinding = PendingBindingCode; /** Keep credential setup and user identity binding visible as separate steps. */ export function WechatQRCompletion({ @@ -45,112 +31,21 @@ export function WechatQRCompletion({ }) { const { t } = useI18n(); const text = t.channels.wechatQr; - const queryClient = useQueryClient(); - const alreadyConnected = provider.connection_status === "connected"; - const [stage, setStage] = useState< - "loading" | "binding" | "connected" | "error" | "expired" - >(alreadyConnected ? "connected" : "loading"); - const [attempt, setAttempt] = useState(0); - const [binding, setBinding] = useState(null); - const command = binding ? `/connect ${binding.code}` : ""; - const [copied, setCopied] = useState(false); - const [copyFailed, setCopyFailed] = useState(false); - - useEffect(() => { - if (alreadyConnected) { - setStage("connected"); - return; - } - let stopped = false; - let poller: ConnectPollHandle | undefined; - let expiry: ReturnType | undefined; - setStage("loading"); - setCopied(false); - setCopyFailed(false); - const connected = () => { - if (stopped) return; - clearTimeout(expiry); - setStage("connected"); - void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey }); - void queryClient.invalidateQueries({ - queryKey: channelConnectionsQueryKey, - }); - }; - const start = setTimeout(() => { - void (async () => { - // Runtime setup's response does not include existing user bindings. - const current = await queryClient.fetchQuery({ - queryKey: channelProviderQueryKey, - queryFn: listChannelProviders, - staleTime: 0, - }); - if (stopped) return; - if ( - current.providers.some( - (item) => - item.provider === "wechat" && - item.connection_status === "connected", - ) - ) { - connected(); - return; - } - // Keep the command already copied to the phone, with its original - // deadline. Rescanning must not create another copy/switch-app loop. - let nextBinding = bindingToResume; - if (!nextBinding || nextBinding.expiresAt <= Date.now()) { - const result = await connectChannelProvider("wechat"); - const lifetime = - Number.isFinite(result.expires_in) && result.expires_in > 0 - ? result.expires_in - : 600; - nextBinding = { - code: result.code, - expiresAt: Date.now() + lifetime * 1000, - }; - } - if (stopped) return; - setBinding(nextBinding); - const expiresIn = (nextBinding.expiresAt - Date.now()) / 1000; - if (expiresIn <= 0) { - setStage("expired"); - return; - } - setStage("binding"); - expiry = setTimeout(() => { - poller?.cancel(); - if (!stopped) setStage("expired"); - }, expiresIn * 1000); - poller = startConnectionPoll({ - provider: "wechat", - expiresInSeconds: expiresIn, - fetchConnections: () => - queryClient.fetchQuery({ - queryKey: channelConnectionsQueryKey, - queryFn: listChannelConnections, - staleTime: 0, - }), - onConnected: connected, - }); - })().catch(() => { - if (!stopped) setStage("error"); - }); - }, 0); - return () => { - stopped = true; - clearTimeout(start); - clearTimeout(expiry); - poller?.cancel(); - }; - }, [alreadyConnected, attempt, bindingToResume, queryClient]); + // Rescanning hands back the command already copied to the phone, so the + // user is not sent through another copy/switch-app loop. + const { stage, binding, command, copied, copyFailed, reportCopy, retry } = + useBindingCode({ + provider: "wechat", + alreadyConnected: provider.connection_status === "connected", + bindingToResume, + }); const copy = async () => { try { await navigator.clipboard.writeText(command); - setCopied(true); - setCopyFailed(false); + reportCopy(true); } catch { - setCopyFailed(true); + reportCopy(false); } }; @@ -213,11 +108,7 @@ export function WechatQRCompletion({

{stage === "expired" ? text.bindExpired : text.bindFailed}

- diff --git a/frontend/src/components/workspace/channels/workspace-channels-list.tsx b/frontend/src/components/workspace/channels/workspace-channels-list.tsx index e75b8e38fb..95741b7f7c 100644 --- a/frontend/src/components/workspace/channels/workspace-channels-list.tsx +++ b/frontend/src/components/workspace/channels/workspace-channels-list.tsx @@ -34,6 +34,7 @@ import { cn } from "@/lib/utils"; import { useWorkspaceSidebarExpanded } from "../workspace-sidebar-frame"; +import { ChannelBindingCodeDialog } from "./channel-binding-code-dialog"; import { ChannelProviderIcon } from "./channel-provider-icon"; import { ChannelRuntimeConfigDialog } from "./channel-runtime-config-dialog"; import { ChannelScheduledUpdates } from "./channel-scheduled-updates"; @@ -64,6 +65,8 @@ export function WorkspaceChannelsList() { const [setupProvider, setSetupProvider] = useState( null, ); + const [bindingProvider, setBindingProvider] = + useState(null); const visibleProviders = providers.filter((provider) => provider.enabled); const startConnect = ( @@ -76,6 +79,12 @@ export function WorkspaceChannelsList() { setSetupProvider(provider); return; } + // The command stays in a dialog with copy and expiry, not a passing toast. + if (provider.auth_mode === "binding_code") { + closeConnectWindow(preparedWindow ?? null); + setBindingProvider(provider); + return; + } const connectWindow = preparedWindow !== undefined ? preparedWindow @@ -238,6 +247,15 @@ export function WorkspaceChannelsList() { }); }} /> + { + if (!open) { + setBindingProvider(null); + } + }} + /> ); } diff --git a/frontend/src/components/workspace/settings/channels-settings-page.tsx b/frontend/src/components/workspace/settings/channels-settings-page.tsx index b646689baa..f91ad03f29 100644 --- a/frontend/src/components/workspace/settings/channels-settings-page.tsx +++ b/frontend/src/components/workspace/settings/channels-settings-page.tsx @@ -42,6 +42,7 @@ import type { ChannelConnection, ChannelProvider } from "@/core/channels/types"; import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; +import { ChannelBindingCodeDialog } from "../channels/channel-binding-code-dialog"; import { ChannelProviderIcon } from "../channels/channel-provider-icon"; import { ChannelRuntimeConfigDialog } from "../channels/channel-runtime-config-dialog"; import { ChannelScheduledUpdates } from "../channels/channel-scheduled-updates"; @@ -120,6 +121,7 @@ function ChannelProviderItem({ const disconnectProviderMutation = useDisconnectChannelProvider(); const [setupOpen, setSetupOpen] = useState(false); const [setupStep, setSetupStep] = useState<"setup" | "binding">("setup"); + const [bindingOpen, setBindingOpen] = useState(false); const openSetup = () => { setSetupStep("setup"); setSetupOpen(true); @@ -155,6 +157,12 @@ function ChannelProviderItem({ setSetupOpen(true); return; } + // The command stays in a dialog with copy and expiry, not a passing toast. + if (connectProvider.auth_mode === "binding_code") { + closeConnectWindow(preparedWindow ?? null); + setBindingOpen(true); + return; + } const connectWindow = preparedWindow !== undefined ? preparedWindow @@ -340,6 +348,11 @@ function ChannelProviderItem({ }); }} /> + ); } diff --git a/frontend/src/core/channels/use-binding-code.ts b/frontend/src/core/channels/use-binding-code.ts new file mode 100644 index 0000000000..2bd0fbbea6 --- /dev/null +++ b/frontend/src/core/channels/use-binding-code.ts @@ -0,0 +1,171 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; + +import { + connectChannelProvider, + listChannelConnections, + listChannelProviders, +} from "./api"; +import { startConnectionPoll, type ConnectPollHandle } from "./connect-poll"; +import { channelConnectionsQueryKey, channelProviderQueryKey } from "./hooks"; +import type { ChannelProviderId } from "./types"; + +/** A binding code shown to the user, with the moment it stops working. */ +export type PendingBindingCode = { + code: string; + expiresAt: number; +}; + +export type BindingCodeStage = + | "loading" + | "binding" + | "connected" + | "error" + | "expired"; + +// The backend's code lifetime, for a response that omits or garbles `expires_in`. +const DEFAULT_CODE_LIFETIME_S = 600; + +/** + * Link the signed-in user to a provider whose bot accepts `/connect `: + * reuse a code the user may already have copied or mint one, keep it on screen + * until it expires, and poll until the bot reports the binding. Unmounting + * stops the poll; `retry` starts over. + */ +export function useBindingCode({ + provider, + alreadyConnected = false, + bindingToResume, +}: { + provider: ChannelProviderId; + /** The provider already reports a connection, so no code is needed. */ + alreadyConnected?: boolean; + /** Reused until its original deadline instead of minting another code. */ + bindingToResume?: PendingBindingCode | null; +}) { + const queryClient = useQueryClient(); + const [stage, setStage] = useState( + alreadyConnected ? "connected" : "loading", + ); + const [attempt, setAttempt] = useState(0); + const [binding, setBinding] = useState(null); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [copyFailed, setCopyFailed] = useState(false); + + useEffect(() => { + if (alreadyConnected) { + setStage("connected"); + return; + } + let stopped = false; + let poller: ConnectPollHandle | undefined; + let expiry: ReturnType | undefined; + setStage("loading"); + setError(null); + setCopied(false); + setCopyFailed(false); + const connected = () => { + if (stopped) return; + clearTimeout(expiry); + setStage("connected"); + void queryClient.invalidateQueries({ queryKey: channelProviderQueryKey }); + void queryClient.invalidateQueries({ + queryKey: channelConnectionsQueryKey, + }); + }; + // Defer one tick so Strict Mode's setup/cleanup probe cannot mint a + // second code. + const start = setTimeout(() => { + void (async () => { + // The caller's provider can be stale (a runtime-setup response does + // not include existing user bindings), so check before minting. + const current = await queryClient.fetchQuery({ + queryKey: channelProviderQueryKey, + queryFn: listChannelProviders, + staleTime: 0, + }); + if (stopped) return; + if ( + current.providers.some( + (item) => + item.provider === provider && + item.connection_status === "connected", + ) + ) { + connected(); + return; + } + // Keep a command the user may already have copied, with its original + // deadline, instead of sending them back for another one. + let nextBinding = bindingToResume; + if (!nextBinding || nextBinding.expiresAt <= Date.now()) { + const result = await connectChannelProvider(provider); + const lifetime = + Number.isFinite(result.expires_in) && result.expires_in > 0 + ? result.expires_in + : DEFAULT_CODE_LIFETIME_S; + nextBinding = { + code: result.code, + expiresAt: Date.now() + lifetime * 1000, + }; + } + if (stopped) return; + setBinding(nextBinding); + const expiresIn = (nextBinding.expiresAt - Date.now()) / 1000; + if (expiresIn <= 0) { + setStage("expired"); + return; + } + setStage("binding"); + expiry = setTimeout(() => { + poller?.cancel(); + if (!stopped) setStage("expired"); + }, expiresIn * 1000); + poller = startConnectionPoll({ + provider, + expiresInSeconds: expiresIn, + fetchConnections: () => + queryClient.fetchQuery({ + queryKey: channelConnectionsQueryKey, + queryFn: listChannelConnections, + staleTime: 0, + }), + onConnected: connected, + }); + })().catch((reason: unknown) => { + if (stopped) return; + setError(reason); + setStage("error"); + }); + }, 0); + return () => { + stopped = true; + clearTimeout(start); + clearTimeout(expiry); + poller?.cancel(); + }; + }, [alreadyConnected, attempt, bindingToResume, provider, queryClient]); + + return { + stage, + /** The latest code shown; kept after expiry so a restart can hand it back. */ + binding, + command: binding ? `/connect ${binding.code}` : "", + /** Why the last attempt failed, while `stage` is "error". */ + error, + copied, + copyFailed, + /** Record a clipboard write of `command`; every attempt starts uncopied. */ + reportCopy: (ok: boolean) => { + if (ok) { + setCopied(true); + setCopyFailed(false); + } else { + setCopyFailed(true); + } + }, + /** Start a new attempt: reuse `bindingToResume` if still valid, else mint. */ + retry: () => setAttempt((value) => value + 1), + }; +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index d85281fd0c..e901204419 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -1713,6 +1713,24 @@ export const enUS: Translations = { expiredTitle: "QR code expired", failedTitle: "Unable to connect", }, + bindingCode: { + description: (provider: string) => + `Send this command to the ${provider} bot to link it to your DeerFlow account.`, + loading: "Generating a binding code…", + copy: "Copy command", + copied: "Copied", + copyFailed: "Could not copy. Select and copy the command above.", + expiresAt: (time: string) => `Expires at ${time}`, + waiting: (provider: string) => `Waiting for your message in ${provider}…`, + connected: (provider: string) => `${provider} is connected`, + done: "Done", + expired: "This binding code has expired.", + newCode: "Generate a new code", + failed: "Could not create a binding code.", + tooManyCodes: + "Too many unused codes. Use a code you already have or wait about 10 minutes for them to expire.", + retry: "Try again", + }, setupDescription: "Enter the values needed by this server process. They are not written to config.yaml.", saveAndConnect: "Save and connect", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 88bbe3d241..8784c8a6a6 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -1410,6 +1410,23 @@ export interface Translations { confirmed: string; retry: string; }; + /** The `/connect ` dialog for apps linked by messaging their bot. */ + bindingCode: { + description: (provider: string) => string; + loading: string; + copy: string; + copied: string; + copyFailed: string; + expiresAt: (time: string) => string; + waiting: (provider: string) => string; + connected: (provider: string) => string; + done: string; + expired: string; + newCode: string; + failed: string; + tooManyCodes: string; + retry: string; + }; saveAndConnect: string; saveChanges: string; descriptions: Record; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 150c024ca3..1b88636704 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -1550,7 +1550,8 @@ export const zhCN: Translations = { unconfigured: "未配置", unavailable: "当前无法使用渠道连接。", unavailableShort: "不可用", - setupTitle: (name: string) => `连接 ${name}`, + setupTitle: (name: string) => + /^[A-Za-z0-9]/.test(name) ? `连接 ${name}` : `连接${name}`, setupEditTitle: (name: string) => `修改 ${name}`, wechatQr: { restart: "重新扫码", @@ -1607,6 +1608,34 @@ export const zhCN: Translations = { expiredTitle: "二维码已过期", failedTitle: "连接未完成", }, + // A Latin name keeps the usual spaces ("发给 Slack 机器人"); a Chinese one + // does not ("发给飞书机器人"). + bindingCode: { + description: (provider: string) => + /^[A-Za-z0-9]/.test(provider) + ? `把下面这条命令发给 ${provider} 机器人,即可绑定到你的 DeerFlow 账号。` + : `把下面这条命令发给${provider}机器人,即可绑定到你的 DeerFlow 账号。`, + loading: "正在生成绑定码…", + copy: "复制命令", + copied: "已复制", + copyFailed: "复制失败,请选中上方命令手动复制。", + expiresAt: (time: string) => `${time} 过期`, + waiting: (provider: string) => + /^[A-Za-z0-9]/.test(provider) + ? `正在等待 ${provider} 中的绑定消息…` + : `正在等待${provider}中的绑定消息…`, + connected: (provider: string) => + /^[A-Za-z0-9]/.test(provider) + ? `${provider} 已连接` + : `${provider}已连接`, + done: "完成", + expired: "绑定码已过期。", + newCode: "重新生成绑定码", + failed: "无法生成绑定码。", + tooManyCodes: + "未使用的绑定码太多。请先用已有的绑定码,或等它们过期(约 10 分钟)后再试。", + retry: "重试", + }, setupDescription: "填写当前服务进程需要的配置值。这些内容不会写入 config.yaml。", saveAndConnect: "保存并连接", diff --git a/frontend/tests/e2e/channels.spec.ts b/frontend/tests/e2e/channels.spec.ts index b69553986d..036e2dc13e 100644 --- a/frontend/tests/e2e/channels.spec.ts +++ b/frontend/tests/e2e/channels.spec.ts @@ -304,9 +304,20 @@ test.describe("IM channels", () => { await expect(sidebar.getByText("Slack")).toBeVisible({ timeout: 15_000 }); await sidebar.getByRole("button", { name: "Connect" }).click(); + const dialog = page.getByRole("dialog", { name: "Connect Slack" }); await expect( - page.getByText("Send /connect abc123 to the DeerFlow Slack bot."), + dialog.getByText("/connect abc123", { exact: true }), ).toBeVisible(); + await page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"]); + await dialog.getByRole("button", { name: "Copy command" }).click(); + await expect( + dialog.getByRole("button", { name: "Copied", exact: true }), + ).toBeVisible(); + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe( + "/connect abc123", + ); expect(slackConnectCalls).toBe(1); }); @@ -401,9 +412,14 @@ test.describe("IM channels", () => { await setupDialog.getByLabel("Bot token").fill("xoxb-ui"); await setupDialog.getByRole("button", { name: "Save and connect" }).click(); - await expect(setupDialog).toBeHidden(); + // The binding step keeps the "Connect Slack" title; the form is gone. + await expect(page.getByLabel("Bot token")).toBeHidden(); + const bindingDialog = page.getByRole("dialog", { name: "Connect Slack" }); await expect( - page.getByText("Send /connect abc123 to the DeerFlow Slack bot."), + bindingDialog.getByText("/connect abc123", { exact: true }), + ).toBeVisible(); + await expect( + bindingDialog.getByRole("button", { name: "Copy command" }), ).toBeVisible(); expect(slackConnectCalls).toBe(1); }); diff --git a/frontend/tests/unit/components/workspace/channels/channel-binding-code-dialog.dom.test.tsx b/frontend/tests/unit/components/workspace/channels/channel-binding-code-dialog.dom.test.tsx new file mode 100644 index 0000000000..b94e735d1f --- /dev/null +++ b/frontend/tests/unit/components/workspace/channels/channel-binding-code-dialog.dom.test.tsx @@ -0,0 +1,262 @@ +import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from "@testing-library/react"; +import { StrictMode } from "react"; + +rs.mock("@/core/channels/api", () => ({ + connectChannelProvider: rs.fn(), + listChannelProviders: rs.fn(), + listChannelConnections: rs.fn(), +})); +rs.mock("@/core/clipboard", () => ({ writeTextToClipboard: rs.fn() })); + +import { ChannelBindingCodeDialog } from "@/components/workspace/channels/channel-binding-code-dialog"; +import { GatewayApiError } from "@/core/api/errors"; +import { + connectChannelProvider, + listChannelConnections, + listChannelProviders, +} from "@/core/channels/api"; +import type { + ChannelConnectResponse, + ChannelConnection, + ChannelProvider, +} from "@/core/channels/types"; +import { writeTextToClipboard } from "@/core/clipboard"; +import type { Locale } from "@/core/i18n"; +import { I18nContext } from "@/core/i18n/context"; +import { enUS } from "@/core/i18n/locales/en-US"; +import { zhCN } from "@/core/i18n/locales/zh-CN"; + +function provider(id = "slack", displayName = "Slack"): ChannelProvider { + return { + provider: id, + display_name: displayName, + enabled: true, + configured: true, + connectable: true, + auth_mode: "binding_code", + connection_status: "not_connected", + credential_fields: [], + }; +} + +function minted(code: string, expiresIn = 600): ChannelConnectResponse { + return { + provider: "slack", + mode: "binding_code", + url: null, + code, + instruction: `Send /connect ${code} to the DeerFlow Slack bot.`, + expires_in: expiresIn, + }; +} + +function gatewayError(status: number, detail: string) { + return new GatewayApiError({ + message: detail, + status, + code: null, + params: {}, + rawMessage: detail, + }); +} + +beforeEach(() => { + rs.mocked(listChannelProviders).mockResolvedValue({ + enabled: true, + providers: [], + }); + rs.mocked(listChannelConnections).mockResolvedValue([]); + rs.mocked(connectChannelProvider).mockResolvedValue(minted("abc123")); + rs.mocked(writeTextToClipboard).mockResolvedValue(true); +}); +afterEach(() => { + cleanup(); + rs.useRealTimers(); + rs.resetAllMocks(); +}); + +function mount(target = provider(), locale: Locale = "en-US") { + const onOpenChange = rs.fn(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const view = (current: ChannelProvider) => ( + + + undefined, + t: locale === "zh-CN" ? zhCN : enUS, + }} + > + + + + + ); + const { rerender } = render(view(target)); + return { + onOpenChange, + rerender: (next: ChannelProvider) => rerender(view(next)), + }; +} + +describe("ChannelBindingCodeDialog", () => { + it("keeps the command on screen and copies it", async () => { + const { rerender } = mount(); + expect(await screen.findByText("/connect abc123")).toBeTruthy(); + expect(screen.getByRole("dialog", { name: "Connect Slack" })).toBeTruthy(); + expect( + screen.getByText( + "Send this command to the Slack bot to link it to your DeerFlow account.", + ), + ).toBeTruthy(); + expect(screen.getByText("Waiting for your message in Slack…")).toBeTruthy(); + // The server's English sentence is no longer shown. + expect(screen.queryByText(/DeerFlow Slack bot/)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Copy command" })); + expect(await screen.findByRole("button", { name: "Copied" })).toBeTruthy(); + expect(writeTextToClipboard).toHaveBeenCalledExactlyOnceWith( + "/connect abc123", + ); + + // A refreshed provider list re-renders the dialog with the same code. + rerender({ ...provider() }); + expect(screen.getByText("/connect abc123")).toBeTruthy(); + expect(connectChannelProvider).toHaveBeenCalledExactlyOnceWith("slack"); + }); + + it("says so when copying fails, leaving the command selectable", async () => { + rs.mocked(writeTextToClipboard).mockResolvedValue(false); + mount(); + fireEvent.click( + await screen.findByRole("button", { name: "Copy command" }), + ); + expect( + await screen.findByText( + "Could not copy. Select and copy the command above.", + ), + ).toBeTruthy(); + expect(screen.getByText("/connect abc123")).toBeTruthy(); + }); + + it("shows when the code expires in the UI language's time format", async () => { + rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + rs.setSystemTime(new Date(2026, 9, 26, 9, 0)); + mount(); + await act(() => rs.advanceTimersByTimeAsync(1)); + const tenMinutesLater = new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + }).format(new Date(2026, 9, 26, 9, 10)); + expect(screen.getByText(`Expires at ${tenMinutesLater}`)).toBeTruthy(); + }); + + it("shows the connection once the bot reports it", async () => { + rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + const { onOpenChange } = mount(); + await act(() => rs.advanceTimersByTimeAsync(1)); + expect(screen.getByText("/connect abc123")).toBeTruthy(); + + rs.mocked(listChannelConnections).mockResolvedValue([ + { provider: "slack", status: "connected" } as ChannelConnection, + ]); + await act(() => rs.advanceTimersByTimeAsync(2000)); + expect(screen.getByText("Slack is connected")).toBeTruthy(); + expect(screen.queryByText("/connect abc123")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Done" })); + expect(onOpenChange).toHaveBeenCalledExactlyOnceWith(false); + }); + + it("offers a new code once the old one expires", async () => { + rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + rs.mocked(connectChannelProvider) + .mockResolvedValueOnce(minted("first")) + .mockResolvedValueOnce(minted("second")); + mount(); + await act(() => rs.advanceTimersByTimeAsync(1)); + expect(screen.getByText("/connect first")).toBeTruthy(); + + await act(() => rs.advanceTimersByTimeAsync(600_000)); + expect(screen.getByText("This binding code has expired.")).toBeTruthy(); + expect(screen.queryByText("/connect first")).toBeNull(); + + fireEvent.click( + screen.getByRole("button", { name: "Generate a new code" }), + ); + await act(() => rs.advanceTimersByTimeAsync(1)); + expect(screen.getByText("/connect second")).toBeTruthy(); + expect(connectChannelProvider).toHaveBeenCalledTimes(2); + }); + + it("explains the unused-code limit instead of the server's English detail", async () => { + const detail = + "Too many pending channel connection codes. Wait for existing codes to expire or use one of them."; + rs.mocked(connectChannelProvider).mockRejectedValueOnce( + gatewayError(429, detail), + ); + mount(); + expect( + await screen.findByText( + "Too many unused codes. Use a code you already have or wait about 10 minutes for them to expire.", + ), + ).toBeTruthy(); + expect(screen.queryByText(detail)).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(await screen.findByText("/connect abc123")).toBeTruthy(); + expect(connectChannelProvider).toHaveBeenCalledTimes(2); + }); + + it("keeps the server's reason for other failures", async () => { + rs.mocked(connectChannelProvider).mockRejectedValueOnce( + gatewayError(400, "Channel provider is not configured"), + ); + mount(); + expect( + await screen.findByText("Could not create a binding code."), + ).toBeTruthy(); + expect(screen.getByText("Channel provider is not configured")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Try again" })).toBeTruthy(); + }); + + it.each([ + { + target: provider("feishu", "Feishu"), + title: "连接飞书", + sentence: "把下面这条命令发给飞书机器人,即可绑定到你的 DeerFlow 账号。", + }, + { + target: provider(), + title: "连接 Slack", + sentence: + "把下面这条命令发给 Slack 机器人,即可绑定到你的 DeerFlow 账号。", + }, + ])( + "speaks Chinese in a Chinese UI ($title)", + async ({ target, title, sentence }) => { + rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + rs.setSystemTime(new Date(2026, 9, 26, 9, 0)); + mount(target, "zh-CN"); + await act(() => rs.advanceTimersByTimeAsync(1)); + expect(screen.getByRole("dialog", { name: title })).toBeTruthy(); + expect(screen.getByText(sentence)).toBeTruthy(); + expect(screen.getByRole("button", { name: "复制命令" })).toBeTruthy(); + expect(screen.getByText("09:10 过期")).toBeTruthy(); + }, + ); +}); diff --git a/frontend/tests/unit/core/channels/use-binding-code.dom.test.tsx b/frontend/tests/unit/core/channels/use-binding-code.dom.test.tsx new file mode 100644 index 0000000000..b086b01861 --- /dev/null +++ b/frontend/tests/unit/core/channels/use-binding-code.dom.test.tsx @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, expect, it, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import { StrictMode, type ReactNode } from "react"; + +rs.mock("@/core/channels/api", () => ({ + connectChannelProvider: rs.fn(), + listChannelProviders: rs.fn(), + listChannelConnections: rs.fn(), +})); + +import { + connectChannelProvider, + listChannelConnections, + listChannelProviders, +} from "@/core/channels/api"; +import type { + ChannelConnectResponse, + ChannelConnection, + ChannelProvider, +} from "@/core/channels/types"; +import { useBindingCode } from "@/core/channels/use-binding-code"; + +function minted(code: string, expiresIn = 600): ChannelConnectResponse { + return { + provider: "discord", + mode: "binding_code", + code, + instruction: `Send /connect ${code}`, + expires_in: expiresIn, + }; +} + +beforeEach(() => { + rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + rs.mocked(listChannelProviders).mockResolvedValue({ + enabled: true, + providers: [], + }); + rs.mocked(listChannelConnections).mockResolvedValue([]); + rs.mocked(connectChannelProvider).mockResolvedValue(minted("abc123")); +}); +afterEach(() => { + cleanup(); + rs.useRealTimers(); + rs.resetAllMocks(); +}); + +function mount(provider = "discord") { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + return renderHook(() => useBindingCode({ provider }), { wrapper }); +} + +it("binds the requested provider and ignores other apps' connections", async () => { + const { result } = mount(); + await act(() => rs.advanceTimersByTimeAsync(1)); + expect(connectChannelProvider).toHaveBeenCalledExactlyOnceWith("discord"); + expect(result.current.stage).toBe("binding"); + expect(result.current.command).toBe("/connect abc123"); + + rs.mocked(listChannelConnections).mockResolvedValue([ + { provider: "slack", status: "connected" } as ChannelConnection, + ]); + await act(() => rs.advanceTimersByTimeAsync(2000)); + expect(result.current.stage).toBe("binding"); + + rs.mocked(listChannelConnections).mockResolvedValue([ + { provider: "discord", status: "connected" } as ChannelConnection, + ]); + await act(() => rs.advanceTimersByTimeAsync(2000)); + expect(result.current.stage).toBe("connected"); +}); + +it("mints no code when the provider already reports a connection", async () => { + rs.mocked(listChannelProviders).mockResolvedValue({ + enabled: true, + providers: [ + { + provider: "discord", + connection_status: "connected", + } as ChannelProvider, + ], + }); + const { result } = mount(); + await act(() => rs.advanceTimersByTimeAsync(1)); + expect(result.current.stage).toBe("connected"); + expect(connectChannelProvider).not.toHaveBeenCalled(); +}); + +it("stops polling when the caller unmounts", async () => { + const { unmount } = mount(); + await act(() => rs.advanceTimersByTimeAsync(2000)); + expect(listChannelConnections).toHaveBeenCalledTimes(1); + unmount(); + await act(() => rs.advanceTimersByTimeAsync(10_000)); + expect(listChannelConnections).toHaveBeenCalledTimes(1); +}); + +it("starts every new code uncopied", async () => { + rs.mocked(connectChannelProvider) + .mockResolvedValueOnce(minted("first", 60)) + .mockResolvedValueOnce(minted("second")); + const { result } = mount(); + await act(() => rs.advanceTimersByTimeAsync(1)); + act(() => result.current.reportCopy(true)); + expect(result.current.copied).toBe(true); + + await act(() => rs.advanceTimersByTimeAsync(60_000)); + expect(result.current.stage).toBe("expired"); + act(() => result.current.retry()); + await act(() => rs.advanceTimersByTimeAsync(1)); + expect(result.current.command).toBe("/connect second"); + expect(result.current.copied).toBe(false); +});