diff --git a/README.md b/README.md index 9a6d0ab769..025531244e 100644 --- a/README.md +++ b/README.md @@ -3001,7 +3001,7 @@ In the task form, switching an hourly, daily, weekly, or monthly schedule to **C Open **Settings → Notifications → IM notifications** to turn scheduled-task updates on or off for each connected, push-capable app. Existing connections keep receiving updates until you opt out. Turning a channel off cancels its -pending and retrying notices, including an explicitly selected Feishu group; +pending and retrying notices, including an explicitly selected group; turning it on again does not resend the old backlog. Notifications whose platform delivery already started may still arrive. Chat replies and scheduled execution are unaffected. All Gateway workers must run the upgraded version @@ -3010,16 +3010,27 @@ for the preference to be enforced. WeChat needs a private message after binding to save its current conversation context securely; an old binding or changed bot token also needs a fresh private message. QQ requires a C2C/private binding and platform permission to send -proactively. This version does not add WeChat or QQ group destinations. +proactively. WeChat remains private-only; QQ and WeCom also support explicitly +selected group destinations. See [setup and delivery limits](backend/docs/IM_CHANNEL_CONNECTIONS.md#proactive-notification-settings). -### Optional Feishu group notifications +The same page offers separate **Task & goal run notifications** for completion, +terminal failure and requests for input. These ordinary web/background reminders +are off by default. A pending input request takes precedence over completion; +unmet goal stand-downs stay silent, and IM-originated chats keep their original +reply rather than receiving a second proactive notice. Turning an event off +also cancels its waiting backlog. Messages direct you back to Conversations. -In a task's create or edit form, **Also notify a Feishu group (optional)** adds +### Optional group notifications + +In a task's create or edit form, **Also notify a group (optional)** adds one chosen group to the existing personal notifications. Add the app bot to the -group, use **Bind a Feishu group** to get a command, send it while mentioning the -bot in that group, then refresh the list and select the group by name. The app -needs bot-send permission and `im:chat:read` to display its group names. +group, choose its supported channel in the binding panel, then send the displayed +`/connect` command while mentioning the bot in that group. QQ and WeCom require +a name in the binding panel so you can identify the receiving group; the name +travels with the owner-issued binding code and is saved on the connection. +Refresh and select the group by name. Feishu additionally needs `im:chat:read` +to retrieve its group names; every provider needs permission to send proactively. While the form checks group-notification support, it shows a loading hint and retains the saved selection. @@ -3028,7 +3039,7 @@ No group is selected automatically. Clearing the selection stops new group notices; changing it affects future notices, while already queued notices keep their original target. A disconnected binding never receives a waiting notice. The first version is configured through the task form or REST API; conversation -schedule tools keep their existing inputs. See the [group delivery guide](backend/docs/IM_CHANNEL_CONNECTIONS.md#feishu-group-notifications). +schedule tools keep their existing inputs. See the [group delivery guide](backend/docs/IM_CHANNEL_CONNECTIONS.md#group-notifications). ### Lifecycle, safety caps and stop conditions diff --git a/backend/app/channels/AGENTS.md b/backend/app/channels/AGENTS.md index f9280f0a89..09f80712bb 100644 --- a/backend/app/channels/AGENTS.md +++ b/backend/app/channels/AGENTS.md @@ -2,7 +2,7 @@ IM channels reach DeerFlow through `langgraph-sdk` HTTP; Gateway owns thread lifecycle. -WeChat `_read_outbound_bytes` reads cap+1 off-loop; overflow returns `None` before encryption/upload; caps <=0 are unlimited. +WeChat `_read_outbound_bytes`: cap+1 off-loop; overflow returns `None` before encryption/upload; nonpositive caps are unlimited. Cursor writes drain before cancellation; token expiry drains cursor/auth clearing under `_auth_lock`. Telegram retries share bootstrap/Bot cleanup. Dead loops warn once; clear @@ -17,7 +17,7 @@ Finalize asyncgens; keep primary errors; propagate lone Bot errors. - `manager.py` - Core dispatcher: creates threads via `client.threads.create()`, routes commands including `/goal` (setting a goal persists it through Gateway and then routes the objective as a chat turn) and `/agent` (`list` is owner-scoped; `use` creates a fresh thread and persists the Custom Agent selection under both the channel restart key and Web's canonical routing metadata so existing checkpoint lineage never changes agent across IM/Web), keeps Slack/Discord on `client.runs.wait()`, uses `client.runs.stream(["messages-tuple", "values"])` for Feishu/Telegram incremental outbound updates, serializes same-thread Feishu turns in-manager when the channel's `ChannelRunPolicy.serialize_thread_runs=True` so rapid follow-ups queue instead of tripping the runtime busy reply, and switches to `client.runs.create()` (fire-and-forget, returns once the run is `pending`) for channels whose `ChannelRunPolicy.fire_and_forget=True` so long autonomous runs do not hit the SDK default 300s `httpx.ReadTimeout`; `runs.wait`/`runs.stream` pass `on_disconnect: continue`, so a run outlives the instance that requested it (reply lost, thread persisted); hence once a run request is sent, cancellation or a post-request transport error keeps the inbound dedupe key (`_keep_dedupe_key_after`) and only Gateway rejections, pre-request failures and `error` frames release it Streaming `error` frames use the same failure path as transport exceptions. Publish the final outbound before releasing the inbound dedupe key, so provider redelivery can retry without overtaking the terminal reply. Log the frame's error name and message at WARNING with the thread ID; keep the synthetic error generic so payload details do not change reply selection. **What may be published from the stream is an allowlist, not a denylist** (`_accumulate_stream_text`): only assistant message types — LangChain serializes `AIMessage.type` as `"ai"` and `AIMessageChunk.type` as `"AIMessageChunk"`, plus the OpenAI-style `"assistant"` spelling for foreign runtimes — become displayable text. The previous rule rejected only payloads whose `type` contained `"tool"` and therefore published everything else, which leaked DeerFlow's hidden model context to every streaming IM channel: `DynamicContextMiddleware` injects recalled memory as a hidden `HumanMessage` (`type == "human"`) and rewrites the user's own turn into a new `HumanMessage`, `DurableContextMiddleware` injects a hidden `` `HumanMessage`, and LangGraph fans those state writes out on the `messages-tuple` stream. Proved live on a Buzz relay. Matching is by prefix (`ai` / `assistant`), never substring, because ordinary words contain `"ai"` (`chain`, `domain`). The message type is resolved by `_stream_payload_type`, which handles both the `model_dump()` shape DeerFlow's own gateway emits and LangChain's `to_json()` constructor shape (whose top-level `type` is the literal `"constructor"`, with the class name at the tail of the `id` path). A bare `str` payload is no longer accepted at all: it carries no type information, so it cannot be attributed to the assistant, and nothing in DeerFlow produces one (`runtime/serialization.py::serialize_messages_tuple` always emits `[message_dict, metadata]`). -- `base.py` - Abstract `Channel` base class (start/stop/send lifecycle). Provider callbacks that submit coroutines from SDK threads must use `_submit_threadsafe_coroutine()` (or `_submit_threadsafe_coroutine_future()` to await the outcome through `asyncio.shield`): it creates and retains the real `asyncio.Task` on the owner loop instead of treating `run_coroutine_threadsafe()`'s proxy Future as a completion signal. Submission is closed atomically with shutdown, and `stop()` must call `_close_and_drain_threadsafe_futures()` before tearing down SDK resources. Blocking SDK teardown in `stop()` (a client `close()`, a thread `join()`) must run off the event loop through `asyncio.to_thread`: the shared Gateway loop also serves `POST /api/channels/{name}/restart`; Slack cleanup awaits connect then closes off-loop; a 5s deadline/cancellation retains it for retry. SDK close errors propagate. Anchored by `tests/blocking_io/test_channel_stop_offloop.py`. **Proactive push** (`send_notification`): WeCom, Feishu, WeChat and QQ C2C use the shared scheduled outbox; `capabilities.py` gates enqueue/UI. Unsupported rows fail terminally. Await ACK; `ChannelUnavailable` parks, business rejection counts. WeCom socket `RuntimeError` parks except `Reply ack error`. Feishu resolves owner p2p; explicit groups use connection IDs and a separate hook. QQ verifies owner/current-App C2C, never group member OpenIDs or passive reply context. WeChat uses encrypted owner/bot/private context, restored after restart; stale public metadata is not trusted. `store_connection_context` locks/checks the active identity before merging encrypted extra. The browser connection API strips legacy context tokens. Shared IM settings (0041) gate enqueue/send; disabling atomically closes pending/sending rows, and reenabling does not replay them. In-flight provider sends may finish. Binding lookup failures park; revoked targets fail before liveness. Terminal rows fence late writes. `_on_finalization` stages one notice per target with the outcome (stop > pause > finish > run outcome); recovery dedupes, idle ends use `idle:`, trials/interrupts stay silent. Localized text has no IDs/links. Lifespan starts delivery after channels, detaches enqueue if it cannot, and stops delivery first. Setup: [IM guide](../../docs/IM_CHANNEL_CONNECTIONS.md#proactive-notification-settings). +- `base.py` - Abstract `Channel` base class (start/stop/send lifecycle). Provider callbacks that submit coroutines from SDK threads must use `_submit_threadsafe_coroutine()` (or `_submit_threadsafe_coroutine_future()` to await the outcome through `asyncio.shield`): it creates and retains the real `asyncio.Task` on the owner loop instead of treating `run_coroutine_threadsafe()`'s proxy Future as a completion signal. Submission is closed atomically with shutdown, and `stop()` must call `_close_and_drain_threadsafe_futures()` before tearing down SDK resources. Blocking SDK teardown in `stop()` (a client `close()`, a thread `join()`) must run off the event loop through `asyncio.to_thread`: the shared Gateway loop also serves `POST /api/channels/{name}/restart`; Slack cleanup awaits connect then closes off-loop; a 5s deadline/cancellation retains it for retry. SDK close errors propagate. Anchored by `tests/blocking_io/test_channel_stop_offloop.py`. **Proactive push**: WeCom, Feishu, WeChat and QQ C2C share the outbox; `capabilities.py` gates enqueue/UI. Unsupported rows fail terminally. Await ACK; `ChannelUnavailable` parks, business rejection counts. WeCom parks socket `RuntimeError` except `Reply ack error`. Feishu/QQ/WeCom group hooks use owner connection IDs; Feishu p2p resolves owner. `group_name`: OAuth state only, no suffix/raw-ID labels. WeCom legacy-group fallback excludes private bindings. QQ: owner/current-App C2C only, never member OpenIDs/passive context. WeChat: encrypted owner/bot/private context across restart; ignore public tokens. `store_connection_context` locks/checks the active identity before merging encrypted extra; browser responses strip legacy tokens. Preferences gate enqueue/send/retry, cancel pending/sending and fence late writes; reenable never replays. In-flight sends may finish. Binding lookup errors park; revoked targets fail before liveness. `_on_finalization` commits one notice with the outcome (stop > pause > finish > run); recovery dedupes, idle uses `idle:`, trials/interrupts stay silent. Web/background events default off: confirm durable terminal state; recheck Human Input before send/retry. Text: localized, no IDs/links. Delivery starts after channels/stops first; failure detaches enqueue. [IM guide](../../docs/IM_CHANNEL_CONNECTIONS.md#proactive-notification-settings). - `wecom.py` - WeCom WebSocket channel with proactive `send_notification`; `_ws_transport_up` maps disconnects to `ChannelUnavailable` (no retry used). `capabilities.py` - SDK-free `CHANNEL_CAPABILITIES`. - `service.py` - Drain manager work before transports; retain timed-out instances for retry. Feishu/DingTalk `stop()` stop the SDK thread's private loop via `_request_*_shutdown` (Feishu disarms lark's `_auto_reconnect` first, else its reconnect branch eats the stop; DingTalk runs its own copy of the SDK connect loop), then join; a live join or Slack cleanup raises `ChannelStopTimeout`: retain, defer replacement/restart, log WARNING without traceback; wedged instances report not running. Reload returns snapshots; restart owns cached fallback/publication under the channel lock. `_stopping` fences starts/publication (including after pre-start I/O) until shutdown succeeds. Lock manager startup through stale cleanup; capture the shutdown generation before locking to fence queued starts. `stop()` stays unlocked to fence in-flight startup. Leader lease (`leadership.py`): only the `channel_leases` holder starts a non-GitHub transport (`_start_channel` checks `holds()`; callers acquire first); `_supervise` renews at a fixed rate, runs starts/stops per provider, stops a transport before its lease lapses. - `slack.py` / `feishu.py` / `telegram.py` / `discord.py` / `dingtalk.py` - Platform-specific implementations (`feishu.py` tracks the running card `message_id` in memory and patches the same card in place; `telegram.py` accepts inbound text/photos/documents, preserves media captions, hands token-free attachment bytes to the shared upload pipeline, edits the "Working on it..." stream target in place via `editMessageText`, and can optionally send final Markdown replies as Rich Messages through `channels.telegram.rich_messages` — the Rich path only fires when `rich_messages` is on **and** the text contains a rich construct (fenced code, emphasis, a table, a task list, `
`, block math, or a link), so structured command/error replies stay plain text and their newlines and `` tokens survive; `discord.py` runs `_on_message` on discord.py's private loop, so connection-repository work (identity lookup with the reservation commit, `/connect` binding) goes to the Gateway loop through `_submit_threadsafe_coroutine` — the SQLAlchemy engine is bound there — and bind replies return through `_run_on_discord_loop`; typing registers before that hand-off settles, and a failed hand-off skips the ack and stops the indicator it started unless another message still relies on it; `discord.py` registers typing loops before inbound handling yields and `_start_typing()` refuses work once `_running` is false; each tick sends the indicator with discord.py 2.x's `await channel.typing()`; because `stop()` runs on the main loop while typing tasks belong to `_discord_loop`, normal cross-thread cancellation, awaiting, and map cleanup are scheduled there with a bounded wait, while `_run_client()` drains tasks in its `finally` block before an exception/disconnect can make that loop unusable; an already-stopped foreign loop must never have its tasks awaited from the main loop; to that end every outbound cross-loop call (`send` / `send_file` / `_get_channel_or_thread`) goes through `_run_on_discord_loop`, which bounds the await (`DISCORD_OUTBOUND_TIMEOUT_SECONDS` 30s; `DISCORD_UPLOAD_TIMEOUT_SECONDS` 120s for file uploads: slow uplink plus 429 retry-after) and fails fast with a `RuntimeError` when the client loop is missing or not running — a dead client becomes a logged send failure instead of a permanently hung `ChannelManager` worker — and `is_running` reports client-thread aliveness (like `feishu.py`) so `ensure_channel_ready` can restart the channel after `_run_client()` exits on a fatal error; `dingtalk.py` optionally uses AI Card streaming for in-place updates when `card_template_id` is configured, and overrides `receive_file` to download inbound images (`picture`/`richText`) and documents (`file`) by `downloadCode` into the thread uploads bucket, mirroring `feishu.py`) diff --git a/backend/app/channels/capabilities.py b/backend/app/channels/capabilities.py index d6866f40f7..d5d4ac20e5 100644 --- a/backend/app/channels/capabilities.py +++ b/backend/app/channels/capabilities.py @@ -10,21 +10,24 @@ sync with the channel registry and the channel classes. ``Channel.send_notification`` (push with no inbound message to reply to). Only these providers get scheduled-task notification rows; to add one, see the ``Channel.send_notification`` docstring. +- ``proactive_group_notifications``: the provider's channel class implements + ``Channel.send_group_notification_for_owner`` for explicit group notification + bindings. """ from __future__ import annotations CHANNEL_CAPABILITIES: dict[str, dict[str, bool]] = { - "buzz": {"supports_streaming": True, "proactive_notifications": False}, - "dingtalk": {"supports_streaming": False, "proactive_notifications": False}, - "discord": {"supports_streaming": False, "proactive_notifications": False}, - "feishu": {"supports_streaming": True, "proactive_notifications": True}, - "github": {"supports_streaming": False, "proactive_notifications": False}, - "qq": {"supports_streaming": False, "proactive_notifications": True}, - "slack": {"supports_streaming": False, "proactive_notifications": False}, - "telegram": {"supports_streaming": True, "proactive_notifications": False}, - "wechat": {"supports_streaming": False, "proactive_notifications": True}, - "wecom": {"supports_streaming": True, "proactive_notifications": True}, + "buzz": {"supports_streaming": True, "proactive_notifications": False, "proactive_group_notifications": False}, + "dingtalk": {"supports_streaming": False, "proactive_notifications": False, "proactive_group_notifications": False}, + "discord": {"supports_streaming": False, "proactive_notifications": False, "proactive_group_notifications": False}, + "feishu": {"supports_streaming": True, "proactive_notifications": True, "proactive_group_notifications": True}, + "github": {"supports_streaming": False, "proactive_notifications": False, "proactive_group_notifications": False}, + "qq": {"supports_streaming": False, "proactive_notifications": True, "proactive_group_notifications": True}, + "slack": {"supports_streaming": False, "proactive_notifications": False, "proactive_group_notifications": False}, + "telegram": {"supports_streaming": True, "proactive_notifications": False, "proactive_group_notifications": False}, + "wechat": {"supports_streaming": False, "proactive_notifications": True, "proactive_group_notifications": False}, + "wecom": {"supports_streaming": True, "proactive_notifications": True, "proactive_group_notifications": True}, } @@ -32,3 +35,9 @@ def supports_proactive_notifications(provider: str) -> bool: """Whether scheduled-task notices can be pushed to ``provider`` (unknown provider: False).""" entry = CHANNEL_CAPABILITIES.get(provider) if isinstance(provider, str) else None return bool(entry) and entry.get("proactive_notifications") is True + + +def supports_proactive_group_notifications(provider: str) -> bool: + """Whether explicit group notices can be pushed to ``provider`` (unknown provider: False).""" + entry = CHANNEL_CAPABILITIES.get(provider) if isinstance(provider, str) else None + return bool(entry) and entry.get("proactive_group_notifications") is True diff --git a/backend/app/channels/feishu.py b/backend/app/channels/feishu.py index 22d6f19c9d..e89464fcfb 100644 --- a/backend/app/channels/feishu.py +++ b/backend/app/channels/feishu.py @@ -36,7 +36,7 @@ from app.channels.message_bus import ( OutboundMessage, ResolvedAttachment, ) -from app.channels.notification_groups import is_feishu_notification_group +from app.channels.notification_groups import is_feishu_notification_group, notification_group_display_name from app.channels.sandbox_files import sync_file_to_thread_sandbox from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths from deerflow.runtime.user_context import get_effective_user_id @@ -1833,7 +1833,7 @@ class FeishuChannel(Channel): provider="feishu", external_account_id=user_id, workspace_id=chat_id, - workspace_name=await self._get_group_name(chat_id) if chat_type == "group" else None, + workspace_name=(notification_group_display_name(state.get("metadata")) or await self._get_group_name(chat_id)) if chat_type == "group" else None, metadata={ "chat_id": chat_id, "message_id": message_id, diff --git a/backend/app/channels/notification_groups.py b/backend/app/channels/notification_groups.py index ff54f9accf..335fd1c2b0 100644 --- a/backend/app/channels/notification_groups.py +++ b/backend/app/channels/notification_groups.py @@ -1,6 +1,87 @@ -"""SDK-free checks for explicit Feishu group notification destinations.""" +"""SDK-free checks for explicit group notification destinations.""" + +from __future__ import annotations + +from collections.abc import Mapping + + +def notification_group_display_name(metadata: object) -> str | None: + """Read the bounded label from the owner's browser-issued binding state.""" + if not isinstance(metadata, Mapping): + return None + value = metadata.get("notification_group_name") + if not isinstance(value, str): + return None + return " ".join(value.split())[:256] or None def is_feishu_notification_group(*, provider: object, status: object, workspace_id: object, metadata: object) -> bool: - """Check the group shape; callers must separately verify owner and ID.""" - return provider == "feishu" and status == "connected" and isinstance(workspace_id, str) and bool(workspace_id.strip()) and isinstance(metadata, dict) and metadata.get("chat_type") == "group" and metadata.get("chat_id") == workspace_id + """Check the Feishu group shape; callers must separately verify owner and ID.""" + return ( + provider == "feishu" and status == "connected" and isinstance(workspace_id, str) and bool(workspace_id.strip()) and isinstance(metadata, Mapping) and metadata.get("chat_type") == "group" and metadata.get("chat_id") == workspace_id + ) + + +def qq_notification_group_openid( + *, + provider: object, + status: object, + workspace_id: object, + metadata: object, + app_id: str | None = None, +) -> str | None: + """Return the bound QQ ``group_openid`` when the connection is a valid QQ group binding.""" + if provider != "qq" or status != "connected" or not isinstance(workspace_id, str) or ":group:" not in workspace_id: + return None + bound_app_id, _sep, group_openid = workspace_id.partition(":group:") + if not bound_app_id.strip() or bound_app_id != bound_app_id.strip() or not group_openid.strip() or group_openid != group_openid.strip(): + return None + if app_id is not None and bound_app_id != app_id: + return None + if not isinstance(metadata, Mapping) or metadata.get("chat_type") != "group": + return None + meta_openid = metadata.get("group_openid") + if meta_openid is not None and (not isinstance(meta_openid, str) or meta_openid != group_openid): + return None + return group_openid + + +def wecom_notification_group_chat_id( + *, + provider: object, + status: object, + workspace_id: object, + metadata: object, + bot_id: str | None = None, +) -> str | None: + """Return the bound WeCom ``chatid`` when the connection is a valid WeCom group binding.""" + if provider != "wecom" or status != "connected" or not isinstance(workspace_id, str) or not isinstance(metadata, Mapping): + return None + if metadata.get("chattype") != "group": + return None + chatid = metadata.get("chatid") + if not isinstance(chatid, str) or not chatid.strip() or chatid != chatid.strip(): + return None + aibotid = metadata.get("aibotid") + if aibotid is None or aibotid == "": + expected_workspace_id = f"group:{chatid}" + elif isinstance(aibotid, str) and bool(aibotid.strip()) and aibotid == aibotid.strip(): + if bot_id and aibotid != bot_id: + return None + expected_workspace_id = f"{aibotid}:group:{chatid}" + else: + return None + if workspace_id != expected_workspace_id: + return None + return chatid + + +def is_notification_group(*, provider: object, status: object, workspace_id: object, metadata: object) -> bool: + """Whether a channel connection record represents an eligible group notification target.""" + if is_feishu_notification_group(provider=provider, status=status, workspace_id=workspace_id, metadata=metadata): + return True + if qq_notification_group_openid(provider=provider, status=status, workspace_id=workspace_id, metadata=metadata) is not None: + return True + if wecom_notification_group_chat_id(provider=provider, status=status, workspace_id=workspace_id, metadata=metadata) is not None: + return True + return False diff --git a/backend/app/channels/qq.py b/backend/app/channels/qq.py index a4a365b445..73beaebfd8 100644 --- a/backend/app/channels/qq.py +++ b/backend/app/channels/qq.py @@ -23,6 +23,7 @@ from app.channels.base import Channel, ChannelUnavailable from app.channels.commands import extract_connect_code, is_known_channel_command from app.channels.connection_identity import attach_connection_identity from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage +from app.channels.notification_groups import notification_group_display_name, qq_notification_group_openid from deerflow.utils.file_io import await_drained logger = logging.getLogger(__name__) @@ -446,13 +447,18 @@ class QQChannel(Channel): state = await self._connection_repo.consume_oauth_state(provider="qq", state=code) reply = "QQ connection code is invalid or expired." if state is not None: + group_name = str(data.get("group_name") or "").strip() if group else "" + upsert_kwargs: dict[str, Any] = {} + if group: + upsert_kwargs["workspace_name"] = notification_group_display_name(state.get("metadata")) or group_name[:256] or None await self._connection_repo.upsert_connection( owner_user_id=state["owner_user_id"], provider="qq", external_account_id=sender, workspace_id=workspace, - metadata={"chat_type": scope}, + metadata={"chat_type": "group", "group_openid": target} if group else {"chat_type": scope}, status="connected", + **upsert_kwargs, ) reply = "QQ connected to DeerFlow." await self.send( @@ -514,15 +520,40 @@ class QQChannel(Channel): return raise QQAPIError("QQ private notification identity is no longer connected") - async def send_notification(self, *, target: str, text_markdown: str) -> None: - """Send one proactive C2C message; the outbox owns retries and delivery state.""" + async def send_group_notification_for_owner(self, *, owner_user_id: str | None, target: str, text_markdown: str) -> None: + """Deliver to an explicit QQ group connection owned by ``owner_user_id``.""" + if not owner_user_id or self._connection_repo is None: + raise ChannelUnavailable("QQ notification binding lookup is unavailable") + target_id = _parse_open_id(target) + if not target_id: + raise QQAPIError("QQ group notification target is empty") + try: + connections = await self._connection_repo.list_connections(owner_user_id) + except Exception: + raise ChannelUnavailable("QQ notification binding lookup is unavailable") from None + if not isinstance(connections, (list, tuple)): + raise ChannelUnavailable("QQ notification binding lookup is unavailable") + for connection in connections: + if connection.get("id") != target_id or connection.get("owner_user_id") != owner_user_id: + continue + group_openid = qq_notification_group_openid( + provider=connection.get("provider"), + status=connection.get("status"), + workspace_id=connection.get("workspace_id"), + metadata=connection.get("metadata"), + app_id=self._app_id, + ) + if group_openid is not None: + await self._send_proactive_message(f"/v2/groups/{quote(group_openid, safe='')}/messages", text_markdown=text_markdown) + return + raise QQAPIError("QQ group notification destination is no longer connected") + + async def _send_proactive_message(self, path: str, *, text_markdown: str) -> None: if not self.is_running or self._http is None or self._http.is_closed: raise ChannelUnavailable("QQ notification transport is unavailable") - if not _parse_open_id(target): - raise QQAPIError("QQ notification target is empty") try: # No source ID or reply sequence: passive reply quotas are separate. - payload = await self._api_request("POST", f"/v2/users/{quote(target, safe='')}/messages", json={"msg_type": 0, "content": text_markdown}) + payload = await self._api_request("POST", path, json={"msg_type": 0, "content": text_markdown}) except QQTransportError: raise ChannelUnavailable("QQ notification transport is unavailable") from None except QQAPIError: @@ -534,6 +565,15 @@ class QQChannel(Channel): if not isinstance(payload.get("id"), str) or not payload["id"].strip(): raise QQAPIError("QQ notification response has no message acknowledgement") + async def send_notification(self, *, target: str, text_markdown: str) -> None: + """Send one proactive C2C message; the outbox owns retries and delivery state.""" + if not self.is_running or self._http is None or self._http.is_closed: + raise ChannelUnavailable("QQ notification transport is unavailable") + target_id = _parse_open_id(target) + if not target_id: + raise QQAPIError("QQ notification target is empty") + await self._send_proactive_message(f"/v2/users/{quote(target_id, safe='')}/messages", text_markdown=text_markdown) + async def send(self, msg: OutboundMessage) -> None: if not msg.is_final or not msg.text: return diff --git a/backend/app/channels/wecom.py b/backend/app/channels/wecom.py index b762c6f774..0ffee1a5da 100644 --- a/backend/app/channels/wecom.py +++ b/backend/app/channels/wecom.py @@ -5,7 +5,7 @@ import base64 import hashlib import inspect import logging -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Mapping from typing import Any, cast from app.channels.base import Channel, ChannelUnavailable @@ -18,6 +18,7 @@ from app.channels.message_bus import ( OutboundMessage, ResolvedAttachment, ) +from app.channels.notification_groups import notification_group_display_name, wecom_notification_group_chat_id logger = logging.getLogger(__name__) @@ -381,6 +382,35 @@ class WeComChannel(Channel): raise RuntimeError(f"WeCom send_message rejected with errcode={errcode}") self._ws_transport_up = True + async def send_group_notification_for_owner(self, *, owner_user_id: str | None, target: str, text_markdown: str) -> None: + """Deliver to an explicit WeCom group connection owned by ``owner_user_id``.""" + if not owner_user_id or self._connection_repo is None: + raise ChannelUnavailable("WeCom notification binding lookup is unavailable") + target_id = target.strip() if isinstance(target, str) else "" + if not target_id or target_id != target: + raise RuntimeError("WeCom group notification target is empty") + try: + connections = await self._connection_repo.list_connections(owner_user_id) + except Exception as exc: + raise ChannelUnavailable("WeCom notification binding lookup is unavailable") from exc + if not isinstance(connections, (list, tuple)): + raise ChannelUnavailable("WeCom notification binding lookup is unavailable") + bot_id = self._bot_id or (str(self.config.get("bot_id") or "").strip() or None) + for connection in connections: + if connection.get("id") != target_id or connection.get("owner_user_id") != owner_user_id: + continue + chatid = wecom_notification_group_chat_id( + provider=connection.get("provider"), + status=connection.get("status"), + workspace_id=connection.get("workspace_id"), + metadata=connection.get("metadata"), + bot_id=bot_id, + ) + if chatid is not None: + await self.send_notification(target=chatid, text_markdown=text_markdown) + return + raise RuntimeError("WeCom group notification destination is no longer connected") + async def _on_outbound(self, msg: OutboundMessage) -> None: if msg.channel_name != self.name: return @@ -553,6 +583,13 @@ class WeComChannel(Channel): return inbound_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT + inbound_metadata: dict[str, Any] = { + "aibotid": body.get("aibotid"), + "chattype": body.get("chattype"), + "message_id": msg_id, + } + if body.get("chatid") is not None: + inbound_metadata["chatid"] = body.get("chatid") inbound = self._make_inbound( chat_id=user_id, # keep user's conversation in memory user_id=user_id, @@ -560,11 +597,7 @@ class WeComChannel(Channel): msg_type=inbound_type, thread_ts=msg_id, files=files or [], - metadata={ - "aibotid": body.get("aibotid"), - "chattype": body.get("chattype"), - "message_id": msg_id, - }, + metadata=inbound_metadata, ) inbound.topic_id = user_id # keep the same thread @@ -589,11 +622,31 @@ class WeComChannel(Channel): reservation.release() async def _attach_connection_identity(self, inbound: InboundMessage) -> InboundMessage: + aibotid = str(inbound.metadata.get("aibotid") or "").strip() or None + chattype = inbound.metadata.get("chattype") + chatid = str(inbound.metadata.get("chatid") or "").strip() or None + if chattype == "group": + if not chatid or self._connection_repo is None: + return inbound + workspace_id = f"{aibotid}:group:{chatid}" if aibotid else f"group:{chatid}" + inbound = await attach_connection_identity(inbound, repo=self._connection_repo, provider="wecom", workspace_id=workspace_id, fallback_without_workspace=False) + if inbound.connection_id: + return inbound + # Pre-group-target versions stored group bindings under the bot ID, + # without chatid. Preserve that existing chat identity, but never + # let a new private binding authorize an unbound group. + legacy = await self._connection_repo.find_connection_by_external_identity(provider="wecom", external_account_id=inbound.user_id, workspace_id=aibotid) + metadata = legacy.get("metadata") if isinstance(legacy, Mapping) else None + if isinstance(metadata, Mapping) and metadata.get("chattype") == "group" and "chatid" not in metadata and metadata.get("aibotid") == aibotid: + inbound.connection_id = legacy["id"] + inbound.owner_user_id = legacy["owner_user_id"] + inbound.workspace_id = legacy.get("workspace_id") + return inbound return await attach_connection_identity( inbound, repo=self._connection_repo, provider="wecom", - workspace_id=str(inbound.metadata.get("aibotid") or "") or None, + workspace_id=aibotid, fallback_without_workspace=True, ) @@ -611,16 +664,35 @@ class WeComChannel(Channel): return True body = frame.get("body", {}) or {} - workspace_id = str(body.get("aibotid") or "") or None + aibotid = str(body.get("aibotid") or "").strip() or None + chattype = body.get("chattype") + workspace_name: str | None = None + 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.") + 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() + workspace_name = notification_group_display_name(state.get("metadata")) or raw_group_name[:256] or None + metadata: dict[str, Any] = { + "aibotid": aibotid, + "chattype": "group", + "chatid": chatid, + } + else: + workspace_id = aibotid + metadata = { + "aibotid": workspace_id, + "chattype": chattype, + } await self._connection_repo.upsert_connection( owner_user_id=state["owner_user_id"], provider="wecom", external_account_id=user_id, workspace_id=workspace_id, - metadata={ - "aibotid": workspace_id, - "chattype": body.get("chattype"), - }, + workspace_name=workspace_name, + metadata=metadata, status="connected", ) await self._send_connection_reply(frame, "WeCom connected to DeerFlow.") diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index a6db46f749..1787cbe7af 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -2,6 +2,7 @@ import asyncio import logging from collections.abc import AsyncGenerator from contextlib import asynccontextmanager, suppress +from typing import Any from deerflow_extension_api import ( EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY, @@ -514,6 +515,46 @@ async def _start_scheduled_task_notification_delivery(app: FastAPI, startup_conf row = await run_repo.get(run_id, user_id=user_id) return row.get("last_ai_message") if row else None + async def resolve_human_input_pending(delivery: dict[str, Any]) -> bool | None: + payload = delivery.get("payload") or {} + thread_id = payload.get("thread_id") + request_id = payload.get("request_id") + owner_user_id = delivery.get("owner_user_id") + if not isinstance(thread_id, str) or not thread_id: + return False + thread_store = getattr(app.state, "thread_store", None) + if thread_store is not None and owner_user_id: + thread_meta = await thread_store.get(thread_id, user_id=owner_user_id) + if thread_meta is None: + return False + checkpointer = getattr(app.state, "checkpointer", None) + if checkpointer is None: + return True + from types import SimpleNamespace + + from app.gateway.context_usage import _load_checkpoint_messages + from app.gateway.services import build_thread_checkpoint_state_accessor + from deerflow.agents.human_input import extract_unanswered_human_input_request + from deerflow.runtime.user_context import reset_current_user, set_current_user + + owner_token = set_current_user(SimpleNamespace(id=owner_user_id)) if isinstance(owner_user_id, str) and owner_user_id else None + try: + accessor, checkpoint_config = await build_thread_checkpoint_state_accessor( + SimpleNamespace(app=app, headers={}, state=SimpleNamespace(), cookies={}), + thread_id=thread_id, + ) + messages = await _load_checkpoint_messages(accessor, checkpoint_config) + finally: + if owner_token is not None: + reset_current_user(owner_token) + current_request = extract_unanswered_human_input_request(messages) + if current_request is None: + return False + if isinstance(request_id, str) and request_id and current_request.get("request_id") != request_id: + return False + return True + + prefs_repo = ImNotificationPreferencesRepository(get_session_factory()) worker = NotificationDeliveryWorker( delivery_repo=notification_repo, resolve_channel=channel_service.get_channel, @@ -526,7 +567,9 @@ async def _start_scheduled_task_notification_delivery(app: FastAPI, startup_conf # Under channels.leader_lease, rows for a provider whose transport # another instance holds are left to that instance. excluded_providers=getattr(channel_service, "notification_excluded_providers", None), - resolve_notification_preferences=ImNotificationPreferencesRepository(get_session_factory()).get, + resolve_notification_preferences=prefs_repo.get, + resolve_notification_event_preferences=prefs_repo.get_event_preferences, + resolve_human_input_pending=resolve_human_input_pending, poll_interval_seconds=startup_config.scheduler.poll_interval_seconds, # Language of notices whose owner has no UI language preference. default_locale=startup_config.channel_connections.notification_locale, diff --git a/backend/app/gateway/routers/channel_connections.py b/backend/app/gateway/routers/channel_connections.py index 8667086e89..572db0213d 100644 --- a/backend/app/gateway/routers/channel_connections.py +++ b/backend/app/gateway/routers/channel_connections.py @@ -12,7 +12,7 @@ from typing import Any, Literal from fastapi import APIRouter, HTTPException, Request, Response from pydantic import BaseModel, Field -from app.channels.capabilities import supports_proactive_notifications +from app.channels.capabilities import supports_proactive_group_notifications, supports_proactive_notifications from app.channels.runtime_config_store import ( ChannelRuntimeConfigStore, apply_runtime_connection_config, @@ -57,6 +57,7 @@ class ChannelProviderResponse(BaseModel): # Whether scheduled-task updates can be pushed to this app (the provider # implements proactive push); Settings labels each provider card with it. proactive_notifications: bool = False + proactive_group_notifications: bool = False class ChannelProvidersResponse(BaseModel): @@ -89,6 +90,10 @@ class ChannelConnectResponse(BaseModel): expires_in: int +class ChannelConnectRequest(BaseModel): + group_name: str | None = Field(default=None, max_length=256) + + class WechatQRLoginResponse(BaseModel): id: str status: Literal["pending", "scanned", "verification_required", "confirmed", "expired", "failed"] @@ -358,6 +363,7 @@ async def _create_state( *, owner_user_id: str, provider: str, + metadata: dict[str, Any] | None = None, ) -> str: now = datetime.now(UTC) state = _new_binding_code() @@ -370,6 +376,7 @@ async def _create_state( expires_at=now + timedelta(seconds=_STATE_TTL_SECONDS), max_pending=_MAX_PENDING_CONNECT_CODES_PER_PROVIDER, now=now, + metadata=metadata, ) if not inserted: raise HTTPException( @@ -483,6 +490,7 @@ def _provider_response( credential_fields=_credential_fields(provider), credential_values=credential_values, proactive_notifications=supports_proactive_notifications(provider), + proactive_group_notifications=supports_proactive_group_notifications(provider), ) @@ -651,7 +659,7 @@ async def _disconnect_channel_provider_runtime(provider: str, request: Request) @router.post("/{provider}/connect", response_model=ChannelConnectResponse) -async def connect_channel_provider(provider: str, request: Request) -> ChannelConnectResponse: +async def connect_channel_provider(provider: str, request: Request, body: ChannelConnectRequest | None = None) -> ChannelConnectResponse: config = await _get_channel_connections_config(request) channels_config = await _get_channels_config(request) if not config.enabled: @@ -670,10 +678,14 @@ async def connect_channel_provider(provider: str, request: Request) -> ChannelCo raise HTTPException(status_code=400, detail="Channel provider is not configured") repo = _get_repository(request, config) + group_name = body.group_name.strip() if body and body.group_name else "" + if group_name and not supports_proactive_group_notifications(provider): + raise HTTPException(status_code=422, detail="This channel does not support notification group names.") code = await _create_state( repo, owner_user_id=_get_user_id(request), provider=provider, + metadata={"notification_group_name": group_name} if group_name else None, ) return ChannelConnectResponse( provider=provider, diff --git a/backend/app/gateway/routers/features.py b/backend/app/gateway/routers/features.py index ac22c59310..a155b5ab18 100644 --- a/backend/app/gateway/routers/features.py +++ b/backend/app/gateway/routers/features.py @@ -73,7 +73,7 @@ class ScheduledTasksFeature(BaseModel): running: bool = Field(..., description="Whether this process's scheduler poller is running, so tasks run on schedule and new tasks can be created") tool_enabled: bool = Field(..., description="Whether chats can create and manage tasks and scheduled runs can stop their own schedule") min_interval_seconds: int = Field(..., description="Shortest interval and earliest one-time delay, in seconds") - group_notifications: bool = Field(..., description="Whether scheduled tasks support explicitly selected Feishu group notifications") + group_notifications: bool = Field(..., description="Whether scheduled tasks support explicitly selected group notifications") class ThreadActivityFeature(BaseModel): diff --git a/backend/app/gateway/routers/im_notification_preferences.py b/backend/app/gateway/routers/im_notification_preferences.py index d05cf2864a..1c17d06636 100644 --- a/backend/app/gateway/routers/im_notification_preferences.py +++ b/backend/app/gateway/routers/im_notification_preferences.py @@ -15,6 +15,9 @@ class ImNotificationPreferences(BaseModel): wecom: bool | None = None wechat: bool | None = None qq: bool | None = None + run_completed: bool | None = None + run_failed: bool | None = None + human_input_requested: bool | None = None def _owner(request: Request, expected_user_id: str) -> str: @@ -34,13 +37,13 @@ def _repository() -> ImNotificationPreferencesRepository: return ImNotificationPreferencesRepository(sessions) -@router.get("", response_model=ImNotificationPreferences) +@router.get("", response_model=ImNotificationPreferences, response_model_exclude_none=True) async def get_preferences(request: Request, x_expected_user_id: str = Header(...)) -> dict[str, bool]: owner = _owner(request, x_expected_user_id) return await _repository().get(owner) -@router.patch("", response_model=ImNotificationPreferences) +@router.patch("", response_model=ImNotificationPreferences, response_model_exclude_none=True) async def patch_preferences(body: ImNotificationPreferences, request: Request, x_expected_user_id: str = Header(...)) -> dict[str, bool]: owner = _owner(request, x_expected_user_id) return await _repository().patch(owner, body.model_dump(exclude_unset=True)) diff --git a/backend/app/gateway/routers/scheduled_tasks.py b/backend/app/gateway/routers/scheduled_tasks.py index 575f060803..7b980a5198 100644 --- a/backend/app/gateway/routers/scheduled_tasks.py +++ b/backend/app/gateway/routers/scheduled_tasks.py @@ -9,7 +9,8 @@ from zoneinfo import ZoneInfo from fastapi import APIRouter, HTTPException, Query, Request from pydantic import AwareDatetime, BaseModel, Field -from app.channels.notification_groups import is_feishu_notification_group +from app.channels.capabilities import supports_proactive_group_notifications +from app.channels.notification_groups import is_notification_group from app.gateway.authz import require_permission from app.gateway.deps import ( get_config, @@ -110,7 +111,7 @@ async def _validate_notification_group(request: Request, connection_id: str | No if connection_id is None: return None if not connection_id.strip() or len(connection_id) > 64: - raise scheduler_error(422, "invalid_notification_group", "Select a connected Feishu group belonging to this user") + raise scheduler_error(422, "invalid_notification_group", "Select a connected notification group belonging to this user") # Reuse the connection UI's current config and owner-scoped repository. # Keep this REST-only: the conversation scheduling tools have no group field. @@ -126,9 +127,19 @@ async def _validate_notification_group(request: Request, connection_id: str | No for connection in connections: if not isinstance(connection, dict) or connection.get("id") != connection_id or connection.get("owner_user_id") != user_id: continue - if is_feishu_notification_group(provider=connection.get("provider"), status=connection.get("status"), workspace_id=connection.get("workspace_id"), metadata=connection.get("metadata")): + provider = connection.get("provider") + if ( + isinstance(provider, str) + and supports_proactive_group_notifications(provider) + and is_notification_group( + provider=provider, + status=connection.get("status"), + workspace_id=connection.get("workspace_id"), + metadata=connection.get("metadata"), + ) + ): return connection_id - raise scheduler_error(422, "invalid_notification_group", "Select a connected Feishu group belonging to this user") + raise scheduler_error(422, "invalid_notification_group", "Select a connected notification group belonging to this user") class ScheduledTaskCreateRequest(BaseModel): diff --git a/backend/app/scheduler/notification_delivery.py b/backend/app/scheduler/notification_delivery.py index 4988f65033..58e3a61de6 100644 --- a/backend/app/scheduler/notification_delivery.py +++ b/backend/app/scheduler/notification_delivery.py @@ -19,10 +19,14 @@ from datetime import UTC, datetime, timedelta from typing import Any from app.channels.base import ChannelUnavailable -from app.channels.capabilities import supports_proactive_notifications -from app.channels.notification_groups import is_feishu_notification_group +from app.channels.capabilities import supports_proactive_group_notifications, supports_proactive_notifications +from app.channels.notification_groups import is_notification_group from app.scheduler.notification_text import DEFAULT_NOTIFICATION_LOCALE, redact_egress_text, render_notification_text, wants_result_summary -from deerflow.persistence.user.im_notifications import DISABLED_BY_OWNER +from deerflow.persistence.user.im_notifications import ( + DISABLED_BY_OWNER, + IM_RUNTIME_NOTIFICATION_EVENTS, + RUNTIME_NOTIFICATION_SOURCE_KINDS, +) __all__ = ["NotificationDeliveryWorker", "redact_egress_text", "render_notification_text"] @@ -77,6 +81,8 @@ class NotificationDeliveryWorker: resolve_connections: ConnectionResolver | None = None, excluded_providers: ExcludedProvidersResolver | None = None, resolve_notification_preferences: Callable[[str], Any] | None = None, + resolve_notification_event_preferences: Callable[[str], Any] | None = None, + resolve_human_input_pending: Callable[..., Any] | None = None, stale_sending_timeout_seconds: int = _STALE_SENDING_TIMEOUT_SECONDS, stop_timeout_seconds: float = _STOP_TIMEOUT_SECONDS, default_locale: str = DEFAULT_NOTIFICATION_LOCALE, @@ -87,6 +93,8 @@ class NotificationDeliveryWorker: self._resolve_connections = resolve_connections self._excluded_providers = excluded_providers self._resolve_notification_preferences = resolve_notification_preferences + self._resolve_notification_event_preferences = resolve_notification_event_preferences + self._resolve_human_input_pending = resolve_human_input_pending self._poll_interval_seconds = poll_interval_seconds self._batch_size = batch_size self._stale_sending_timeout_seconds = stale_sending_timeout_seconds @@ -242,7 +250,14 @@ class NotificationDeliveryWorker: if ( row_owner == owner_user_id and connection.get("id") == target - and is_feishu_notification_group(provider=connection.get("provider"), status=connection.get("status"), workspace_id=connection.get("workspace_id"), metadata=connection.get("metadata")) + and connection.get("provider") == provider + and supports_proactive_group_notifications(provider) + and is_notification_group( + provider=connection.get("provider"), + status=connection.get("status"), + workspace_id=connection.get("workspace_id"), + metadata=connection.get("metadata"), + ) ): return True continue @@ -266,13 +281,74 @@ class NotificationDeliveryWorker: if not preferences[delivery["provider"]]: await self._delivery_repo.mark_failed(delivery["id"], claim_token=delivery.get("claim_token"), error=DISABLED_BY_OWNER, count_attempt=False, terminal=True) return False + source_kind = delivery.get("source_kind") or (delivery.get("payload") or {}).get("source_kind") + event = delivery.get("event") + if source_kind in RUNTIME_NOTIFICATION_SOURCE_KINDS and isinstance(event, str) and event in IM_RUNTIME_NOTIFICATION_EVENTS: + if self._resolve_notification_event_preferences is not None: + try: + event_prefs = self._resolve_notification_event_preferences(delivery.get("owner_user_id") or "") + if inspect.isawaitable(event_prefs): + event_prefs = await event_prefs + except Exception: + logger.warning("Could not verify runtime event preference for delivery %s; parking it", delivery["id"], exc_info=True) + await self._delivery_repo.mark_failed(delivery["id"], claim_token=delivery.get("claim_token"), error="could not verify notification preference", count_attempt=False) + return False + if isinstance(event_prefs, dict) and not event_prefs.get(event, False): + await self._delivery_repo.mark_failed(delivery["id"], claim_token=delivery.get("claim_token"), error=DISABLED_BY_OWNER, count_attempt=False, terminal=True) + return False + elif preferences.get(event) is False: + await self._delivery_repo.mark_failed(delivery["id"], claim_token=delivery.get("claim_token"), error=DISABLED_BY_OWNER, count_attempt=False, terminal=True) + return False + return True + + async def _check_human_input_pending(self, delivery: dict[str, Any]) -> bool: + if delivery.get("event") != "human_input_requested" or self._resolve_human_input_pending is None: + return True + try: + params = inspect.signature(self._resolve_human_input_pending).parameters + positional = [p for p in params.values() if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)] + if len(positional) >= 2: + payload = delivery.get("payload") if isinstance(delivery.get("payload"), dict) else {} + thread_id = payload.get("thread_id") or "" + request_id = payload.get("request_id") or payload.get("tool_call_id") + pending = self._resolve_human_input_pending(thread_id, request_id) + else: + pending = self._resolve_human_input_pending(delivery) + if inspect.isawaitable(pending): + pending = await pending + except Exception: + logger.warning("Could not verify human input request state for delivery %s; parking it", delivery["id"], exc_info=True) + await self._delivery_repo.mark_failed( + delivery["id"], + claim_token=delivery.get("claim_token"), + error="could not verify human input request state", + count_attempt=False, + ) + return False + if pending is None: + await self._delivery_repo.mark_failed( + delivery["id"], + claim_token=delivery.get("claim_token"), + error="could not verify human input request state", + count_attempt=False, + ) + return False + if not pending: + await self._delivery_repo.mark_failed( + delivery["id"], + claim_token=delivery.get("claim_token"), + error="human_input_request_already_resolved", + count_attempt=False, + terminal=True, + ) + return False return True async def _deliver(self, delivery: dict[str, Any]) -> None: delivery_id = delivery["id"] provider = delivery.get("provider") or "" target_type = (delivery.get("payload") or {}).get("target_type") - if target_type not in (None, "group") or (target_type == "group" and provider != "feishu"): + if target_type not in (None, "group") or (target_type == "group" and not supports_proactive_group_notifications(provider)): await self._delivery_repo.mark_failed(delivery_id, claim_token=delivery.get("claim_token"), error="unsupported notification target type", terminal=True) return if not supports_proactive_notifications(provider): @@ -308,6 +384,8 @@ class NotificationDeliveryWorker: return if not await self._check_notification_preference(delivery): return + if not await self._check_human_input_pending(delivery): + return # Re-check channel liveness at delivery time, not enqueue time: the # channel may have been disabled or disconnected after the outbox row # was written, and the row stays retryable for when it comes back. @@ -331,6 +409,8 @@ class NotificationDeliveryWorker: # Summary/config lookups can await long enough for a user to mute. if not await self._check_notification_preference(delivery): return + if not await self._check_human_input_pending(delivery): + return is_active_claim = getattr(self._delivery_repo, "is_active_claim", None) if callable(is_active_claim) and not await is_active_claim(delivery_id, claim_token=delivery.get("claim_token")): return diff --git a/backend/app/scheduler/notification_text.py b/backend/app/scheduler/notification_text.py index 813132d181..4dc5e5c37e 100644 --- a/backend/app/scheduler/notification_text.py +++ b/backend/app/scheduler/notification_text.py @@ -133,8 +133,16 @@ _TEXT: dict[str, dict[str, str]] = { "last_unmet": "The last run didn't meet the goal: {reason}.", "last_unmet_bare": "The last run didn't meet the goal.", "last_interrupted": "The last run was interrupted.", + "runtime_title": "Task “{title}”", + "runtime_untitled": "Untitled conversation", + "runtime_completed": "Completed.", + "runtime_failed": "Failed to complete.", + "human_input_requested": "Waiting for your input in the conversation.", + "question": "Question: {question}", "result": "Result: {summary}", "closing": "Open DeerFlow → Scheduled tasks for details.", + "runtime_closing": "Open DeerFlow → Conversations for details.", + "human_input_closing": "Open DeerFlow → Conversations to continue.", "sentence_gap": " ", }, "zh-CN": { @@ -160,8 +168,16 @@ _TEXT: dict[str, dict[str, str]] = { "last_unmet": "最后一次运行未达成目标:{reason}。", "last_unmet_bare": "最后一次运行未达成目标。", "last_interrupted": "最后一次运行被中断了。", + "runtime_title": "任务“{title}”", + "runtime_untitled": "未命名对话", + "runtime_completed": "已完成。", + "runtime_failed": "运行失败,未能完成。", + "human_input_requested": "正在等待你在对话中确认或补充信息。", + "question": "待确认:{question}", "result": "结果:{summary}", "closing": "在 DeerFlow 的定时任务页查看详情。", + "runtime_closing": "在 DeerFlow 的对话页查看详情。", + "human_input_closing": "在 DeerFlow 的对话页继续处理。", "sentence_gap": "", }, } @@ -173,6 +189,7 @@ _SUMMARY_EVENTS = frozenset({"run_completed", "run_unmet", "task_paused", "task_ _SUMMARY_RUN_STATUSES = frozenset({"success", "unmet"}) # Rows written before payload version 2 carry no ``run_status`` for these. _LEGACY_RUN_STATUS = {"run_completed": "success", "run_failed": "failed", "run_unmet": "unmet", "task_paused": "unmet"} +_RUNTIME_SOURCE_KINDS = frozenset({"run", "human_input_request"}) def _payload(delivery: dict[str, Any]) -> dict[str, Any]: @@ -180,6 +197,16 @@ def _payload(delivery: dict[str, Any]) -> dict[str, Any]: return payload if isinstance(payload, dict) else {} +def _is_runtime_delivery(delivery: dict[str, Any]) -> bool: + payload = _payload(delivery) + source_kind = delivery.get("source_kind") or payload.get("source_kind") + if isinstance(source_kind, str) and source_kind in _RUNTIME_SOURCE_KINDS: + return True + if delivery.get("event") == "human_input_requested": + return True + return not delivery.get("task_id") and not delivery.get("task_run_id") and bool(delivery.get("run_id")) + + def _run_status(delivery: dict[str, Any]) -> str | None: status = _payload(delivery).get("run_status") if isinstance(status, str) and status: @@ -230,10 +257,11 @@ def _with_reason(key: str, code: object, locale: str) -> str: return text[f"{key}_bare"] if reason is None else text[key].format(reason=reason) -def _title(payload: dict[str, Any], locale: str) -> str: - title = payload.get("task_title") - title = _one_line(title) if isinstance(title, str) else "" - return _truncate(title, limit=_TITLE_LIMIT) if title else _TEXT[locale]["untitled"] +def _title(payload: dict[str, Any], locale: str, *, runtime: bool = False) -> str: + raw_title = payload.get("task_title") or payload.get("thread_title") + title = _one_line(redact_egress_text(raw_title)) if isinstance(raw_title, str) else "" + fallback_key = "runtime_untitled" if runtime else "untitled" + return _truncate(title, limit=_TITLE_LIMIT) if title else _TEXT[locale][fallback_key] def _positive_int(value: object) -> int | None: @@ -254,19 +282,21 @@ def _last_run_suffix(event: str, run_status: str | None, payload: dict[str, Any] return None -def _what_happened(delivery: dict[str, Any], locale: str) -> str: +def _what_happened(delivery: dict[str, Any], locale: str, *, runtime: bool = False) -> str: text = _TEXT[locale] payload = _payload(delivery) event = delivery.get("event") or "" run_status = _run_status(delivery) parts: list[str] - if event == "run_completed": - parts = [text["run_completed"]] + if event == "human_input_requested": + parts = [text["human_input_requested"]] + elif event == "run_completed": + parts = [text["runtime_completed" if runtime else "run_completed"]] if "relied_on_assumption" in payload: parts.append(text["goal_met_with_assumption"] if payload.get("relied_on_assumption") is True else text["goal_met"]) elif event == "run_failed": # Raw error text never leaves: tracebacks can carry hosts, paths and tokens. - parts = [text["run_failed"]] + parts = [text["runtime_failed" if runtime else "run_failed"]] elif event == "run_unmet": parts = [_with_reason("run_unmet", payload.get("reason_code"), locale)] elif event == "task_paused": @@ -308,11 +338,22 @@ def render_notification_text(delivery: dict[str, Any], *, default_locale: str = locale = resolve_locale(delivery, default_locale) text = _TEXT[locale] payload = _payload(delivery) - lines = [text["title"].format(title=_title(payload, locale)), _what_happened(delivery, locale)] - reply = payload.get("result_summary") - if wants_result_summary(delivery) and isinstance(reply, str): - summary = run_summary(redact_egress_text(reply)) - if summary: - lines.append(text["result"].format(summary=summary)) - lines.append(text["closing"]) + runtime = _is_runtime_delivery(delivery) + event = delivery.get("event") or "" + title_template = text["runtime_title" if runtime else "title"] + lines = [title_template.format(title=_title(payload, locale, runtime=runtime)), _what_happened(delivery, locale, runtime=runtime)] + if event == "human_input_requested": + raw_question = payload.get("question") or payload.get("result_summary") + if isinstance(raw_question, str): + question = _truncate(_one_line(redact_egress_text(raw_question)), limit=_CONDITION_LIMIT) + if question: + lines.append(text["question"].format(question=question)) + else: + reply = payload.get("result_summary") + if wants_result_summary(delivery) and isinstance(reply, str): + summary = run_summary(redact_egress_text(reply)) + if summary: + lines.append(text["result"].format(summary=summary)) + closing_key = "human_input_closing" if event == "human_input_requested" else ("runtime_closing" if runtime else "closing") + lines.append(text[closing_key]) return "\n".join(lines) diff --git a/backend/app/scheduler/service.py b/backend/app/scheduler/service.py index 4b1cf1dc31..76a21df9b4 100644 --- a/backend/app/scheduler/service.py +++ b/backend/app/scheduler/service.py @@ -2,9 +2,11 @@ from __future__ import annotations import asyncio import hashlib +import inspect import logging import socket import uuid +from collections.abc import Callable, Mapping from datetime import UTC, datetime, timedelta from typing import Any, Literal from zoneinfo import ZoneInfo @@ -12,7 +14,8 @@ from zoneinfo import ZoneInfo from fastapi import HTTPException from sqlalchemy import select -from app.channels.capabilities import supports_proactive_notifications +from app.channels.capabilities import supports_proactive_group_notifications, supports_proactive_notifications +from app.channels.notification_groups import is_notification_group from app.scheduler.notification_text import NOTIFICATION_LOCALES from deerflow.persistence.channel_connections.model import ChannelConnectionRow from deerflow.persistence.run.model import RunRow @@ -25,6 +28,7 @@ from deerflow.persistence.thread_meta.model import ThreadMetaRow from deerflow.persistence.user.im_notifications import ImNotificationPreferencesRepository from deerflow.persistence.user.model import UserPreferenceRow from deerflow.runtime import ConflictError, RunRecord +from deerflow.runtime.run_origin import origin_kind_of from deerflow.scheduler.host_notes import RUN_ERROR_DELETED_WHILE_QUEUED, RUN_ERROR_INTERRUPTED, RUN_ERROR_LEASE_LOST, RUN_ERROR_PAUSED_WHILE_QUEUED, RUN_ERROR_QUEUE_TIMEOUT, RUN_ERROR_RESTARTED from deerflow.scheduler.schedules import next_run_at from deerflow.scheduler.stop_rule import launch_prompt @@ -218,6 +222,7 @@ class ScheduledTaskService: connection_repo=None, notification_repo=None, own_stop_available: bool | None = None, + resolve_human_input_request: Callable[[RunRecord], Any] | None = None, ) -> None: self._task_repo = task_repo self._task_run_repo = task_run_repo @@ -238,6 +243,7 @@ class ScheduledTaskService: # sends. Either being None sends no IM notices. self._connection_repo = connection_repo self._notification_repo = notification_repo + self._resolve_human_input_request = resolve_human_input_request # Whether scheduled runs get stop_scheduled_task (scheduler.tool_enabled). # Decides how a launch phrases the user's stop rule (stop_rule.launch_prompt). # None (production) reads the live config at each launch, the same source @@ -373,10 +379,33 @@ class ScheduledTaskService: payload = _notice_payload(task, occurrence, event, reason, locale=await self._owner_locale(session, task.user_id)) task_run_id = occurrence.id if occurrence is not None else _idle_notice_key(task.id, lifecycle_anchor(task, occurrence, now=now)) recipients = [(binding.provider, binding.external_account_id, payload) for binding in bindings] - if group_connection_id and preferences.get("feishu", False): - # Keep the selected binding identity even if it was removed: the - # worker records an explicit failed delivery after its owner check. - recipients.append(("feishu", group_connection_id, {**payload, "target_type": "group"})) + if group_connection_id: + group_row = await session.scalar(select(ChannelConnectionRow).where(ChannelConnectionRow.id == group_connection_id)) + if group_row is None: + # Normal disconnects soft-revoke ChannelConnectionRow (status="revoked"), + # keeping group_row.provider intact. If the row was hard-deleted after a + # prior delivery, recover its provider from the outbox history before + # falling back to legacy "feishu". + from deerflow.persistence.notification_deliveries.model import NotificationDeliveryRow + + prior_provider = await session.scalar( + select(NotificationDeliveryRow.provider) + .where( + NotificationDeliveryRow.owner_user_id == task.user_id, + NotificationDeliveryRow.target == group_connection_id, + ) + .order_by(NotificationDeliveryRow.created_at.desc()) + .limit(1) + ) + group_provider: str | None = prior_provider if isinstance(prior_provider, str) and supports_proactive_group_notifications(prior_provider) else "feishu" + elif group_row.owner_user_id == task.user_id and isinstance(group_row.provider, str) and supports_proactive_group_notifications(group_row.provider): + group_provider = group_row.provider + else: + group_provider = None + if group_provider and preferences.get(group_provider, False): + # Keep the selected binding identity even if it was removed: the + # worker records an explicit failed delivery after its owner check. + recipients.append((group_provider, group_connection_id, {**payload, "target_type": "group"})) for provider, target, notice_payload in recipients: await self._notification_repo.enqueue_in_session( session, @@ -951,10 +980,15 @@ class ScheduledTaskService: ) async def handle_run_completion(self, record: RunRecord) -> None: + if getattr(record, "terminal_status_persisted", True) is False: + return metadata = record.metadata or {} task_id = metadata.get("scheduled_task_id") task_run_id = metadata.get("scheduled_task_run_id") user_id = record.user_id + if not isinstance(task_id, str) and not isinstance(task_run_id, str): + await self._handle_ordinary_run_completion(record) + return if not isinstance(task_id, str) or not isinstance(task_run_id, str) or not user_id: return @@ -998,6 +1032,182 @@ class ScheduledTaskService: **completion_kwargs, ) + async def _handle_ordinary_run_completion(self, record: RunRecord) -> None: + if self._notification_repo is None or self._connection_repo is None: + return + if getattr(record, "terminal_status_persisted", True) is False: + return + user_id = record.user_id + if not isinstance(user_id, str) or not user_id.strip(): + return + operation_kind = str(getattr(record, "operation_kind", "run")) + if operation_kind != "run": + return + metadata = record.metadata if isinstance(record.metadata, Mapping) else {} + if origin_kind_of(metadata) in {"im_channel", "github", "schedule"} or metadata.get("channel_source") or metadata.get("im_channel"): + return + abort_event = getattr(record, "abort_event", None) + if (abort_event is not None and abort_event.is_set()) or record.status.value == "interrupted": + return + + human_input = getattr(record, "human_input_request", None) + if human_input is None and self._resolve_human_input_request is not None: + try: + resolved = self._resolve_human_input_request(record) + if inspect.isawaitable(resolved): + resolved = await resolved + human_input = resolved + except Exception: + logger.warning("Could not inspect human input request for run %s", record.run_id, exc_info=True) + human_input = None + if not isinstance(human_input, Mapping) or human_input.get("kind") not in {None, "human_input_request"}: + human_input = None + + if record.status.value == "success" and human_input is not None: + event = "human_input_requested" + source_kind = "human_input_request" + request_id = str(human_input.get("request_id") or human_input.get("tool_call_id") or record.run_id).strip() or record.run_id + source_key = f"{record.thread_id}:{request_id}"[:128] + question = str(human_input.get("question") or "").strip() or None + elif record.status.value == "success": + verdict = getattr(record, "goal_verdict", None) + if isinstance(verdict, Mapping) and verdict.get("satisfied") is not True: + return + event = "run_completed" + source_kind = "run" + source_key = record.run_id + request_id = None + question = None + elif record.status.value in {"error", "timeout"}: + event = "run_failed" + source_kind = "run" + source_key = record.run_id + request_id = None + question = None + else: + return + + session_factory = getattr(self._notification_repo, "session_factory", None) + if session_factory is None: + return + group_connection_id = metadata.get("notification_group_connection_id") + if not isinstance(group_connection_id, str) or not group_connection_id.strip(): + group_connection_id = None + else: + group_connection_id = group_connection_id.strip() + + async with session_factory() as session: + # Cheap read-only pre-gate: runtime notices are opt-in (default False), + # so runs for users who have not enabled this event exit without + # scanning bindings, querying thread metadata, or taking write locks. + unlocked_preferences = await ImNotificationPreferencesRepository.get_in_session( + session, + user_id, + lock_owner=False, + include_events=True, + ) + if not unlocked_preferences.get(event, False): + return + persisted_run = await session.get(RunRow, record.run_id) + if persisted_run is not None and persisted_run.status != record.status.value: + return + candidate_bindings = [ + (binding.provider, binding.external_account_id) + for binding in await session.scalars( + select(ChannelConnectionRow).where( + ChannelConnectionRow.owner_user_id == user_id, + ChannelConnectionRow.status == "connected", + ) + ) + if binding.provider + and binding.external_account_id + and supports_proactive_notifications(binding.provider) + and (binding.provider != "qq" or ((binding.metadata_json or {}).get("chat_type") == "c2c" and (binding.workspace_id or "").endswith(":c2c"))) + ] + candidate_group_provider: str | None = None + if group_connection_id: + group_row = await session.scalar(select(ChannelConnectionRow).where(ChannelConnectionRow.id == group_connection_id)) + if ( + group_row is not None + and group_row.owner_user_id == user_id + and isinstance(group_row.provider, str) + and supports_proactive_group_notifications(group_row.provider) + and is_notification_group( + provider=group_row.provider, + status=group_row.status, + workspace_id=group_row.workspace_id, + metadata=group_row.metadata_json, + ) + ): + candidate_group_provider = group_row.provider + if not candidate_bindings and candidate_group_provider is None: + return + + thread_title = None + try: + thread_title = await session.scalar( + select(ThreadMetaRow.display_name).where( + ThreadMetaRow.thread_id == record.thread_id, + ThreadMetaRow.user_id == user_id, + ) + ) + except Exception: + thread_title = None + if not isinstance(thread_title, str) or not thread_title.strip(): + raw_title = metadata.get("title") or getattr(record, "first_human_message", None) + thread_title = raw_title.strip() if isinstance(raw_title, str) and raw_title.strip() else None + else: + thread_title = thread_title.strip() + locale = await self._owner_locale(session, user_id) + + run_status = "success" if record.status.value == "success" else "failed" + payload: dict[str, Any] = { + "payload_version": NOTIFICATION_PAYLOAD_VERSION, + "source_kind": source_kind, + "thread_id": record.thread_id, + "task_title": thread_title, + "locale": locale, + "run_status": run_status, + } + if event == "human_input_requested": + payload["request_id"] = request_id + if question: + payload["question"] = question + elif event == "run_completed": + verdict = getattr(record, "goal_verdict", None) + if isinstance(verdict, Mapping) and verdict.get("satisfied") is True: + payload["relied_on_assumption"] = verdict.get("relied_on_assumption") is True + last_ai = getattr(record, "last_ai_message", None) + if isinstance(last_ai, str) and last_ai.strip(): + payload["result_summary"] = last_ai + + async with session_factory() as session, session.begin(): + preferences = await ImNotificationPreferencesRepository.get_in_session( + session, + user_id, + lock_owner=True, + include_events=True, + ) + if not preferences.get(event, False): + return + recipients = [(provider, target, payload) for provider, target in candidate_bindings if preferences.get(provider, False)] + if group_connection_id and candidate_group_provider and preferences.get(candidate_group_provider, False): + recipients.append((candidate_group_provider, group_connection_id, {**payload, "target_type": "group"})) + for provider, target, notice_payload in recipients: + await self._notification_repo.enqueue_in_session( + session, + source_kind=source_kind, + source_key=source_key, + task_id=None, + task_run_id=None, + run_id=record.run_id, + event=event, + provider=provider, + target=target, + owner_user_id=user_id, + payload=notice_payload, + ) + async def start(self) -> None: if self._task is not None: return diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index b17ba9784b..eec21e03a9 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -727,8 +727,8 @@ Notes: - Manual trigger uses the same scheduled-task resource and run lifecycle. - Scheduled task definitions and task-run history are persisted in the application database. - With `channel_connections.enabled: true`, the scheduler sends IM notices ("scheduled task updates") to the task owner's connected identities. A delivery worker (same poll cadence as the scheduler) pushes them. - - **Which apps:** only providers whose channel implements proactive push get notices; today those are WeCom and Feishu/Lark (`proactive_notifications` in `app/channels/capabilities.py`, also returned by `GET /api/channels/providers`, where Settings shows "sent here" or "not available for this app yet"). Other providers get no outbox rows at all, and rows queued for them before the upgrade end once as `failed` without retries. - - **Optional Feishu group:** each task may select one additional owner-bound group through the task form or `notification_group_connection_id` in REST. No group is selected automatically; personal delivery is retained. The app needs `im:chat:read` for names, and a private binding never becomes a group destination. [Group setup and delivery](IM_CHANNEL_CONNECTIONS.md#feishu-group-notifications). + - **Which apps:** only providers whose channel implements proactive push get notices; today those are WeCom, Feishu/Lark, WeChat and QQ (`proactive_notifications` in `app/channels/capabilities.py`, also returned by `GET /api/channels/providers`). Other providers get no outbox rows, and unsupported queued rows end as `failed` without retries. + - **Optional group:** each task may select one additional owner-bound Feishu, QQ or WeCom group through the task form or `notification_group_connection_id` in REST. No group is selected automatically; personal delivery is retained. QQ/WeCom bindings take an owner-chosen group name; Feishu can read names with `im:chat:read`. A new private binding never authorizes an unbound group. [Group setup and delivery](IM_CHANNEL_CONNECTIONS.md#group-notifications). - **Events:** `run_completed`, `run_failed`, `run_unmet` (goal missed), `task_paused` (automatic pause after three missed goals), `task_stopped` (paused by the agent because its stop condition was met) and `task_finished` (all `max_runs` done or `end_at` reached). - **One message per occurrence:** when one occurrence produces several events, only the first of `task_stopped` > `task_paused` > `task_finished` > `run_failed` > `run_unmet` > `run_completed` is sent, and that message still says how the last run went ("The last run failed." / "didn't meet the goal"). A one-time task sends only its run's outcome, never a separate `task_finished`. - **Transactional:** notices are queued by the finalization observer in the same database transaction that records the outcome and are deduplicated per occurrence (or per end time for a task that ends between runs), so an occurrence finalized by crash or lease recovery notifies exactly once. Plain manual "run now" trials and interrupted runs stay silent; a trial that pauses or finishes the task still sends that notice. Occurrences that end without a finished run (launch error, queue timeout, interrupted by a restart) stay silent. diff --git a/backend/docs/IM_CHANNEL_CONNECTIONS.md b/backend/docs/IM_CHANNEL_CONNECTIONS.md index 511472f9d5..c3aa0da381 100644 --- a/backend/docs/IM_CHANNEL_CONNECTIONS.md +++ b/backend/docs/IM_CHANNEL_CONNECTIONS.md @@ -474,22 +474,26 @@ can retry an ambiguously acknowledged send; it does not promise exactly-once messages at the provider. Record whether domestic Feishu or international Lark was actually exercised. -### Feishu group notifications +### Group notifications -Tasks may explicitly select one additional connected Feishu group. Personal +Tasks may explicitly select one additional connected Feishu, QQ or WeCom group. Personal notifications keep their existing recipients. The task stores the chosen `notification_group_connection_id`; a group notice stores that connection ID as its outbox target with `payload.target_type="group"`. It never infers a group destination from all of the owner's bindings. -Add the app bot to a test or work group, grant `im:chat:read` for the group-name -lookup, and publish the app changes. In the task form choose **Bind a Feishu -group**, mention the bot and send the displayed `/connect` command in that -group, then refresh and select its name. Name lookup failure does not discard -the binding, but unnamed groups cannot be newly selected in the form: check -the read permission and bind again. API selection and each delivery verify the +In the task form select the supported channel's group-binding button. For QQ +and WeCom, enter a recognizable group name before generating the code; the +browser sends optional `group_name` to `POST /api/channels/{provider}/connect`, +which stores the name with the single-use OAuth state. Send the unchanged +`/connect` command in that group, then refresh and select the saved name. +Bindings for two groups remain distinct from each other and from private chats. +Feishu can retrieve its group name using `im:chat:read`; grant that permission +and publish the app changes. A failed lookup does not discard the binding, +but unnamed groups cannot be newly selected: fix the permission or name and +bind again. API selection and each delivery verify the task owner, connection ID, connected status, and recorded group/chat identity. -The provider sends to that verified `chat_id`; a lost binding cannot fall back +The adapter resolves its verified group identity; a lost binding cannot fall back to another group or a private chat. A bot removed from the group produces a normal platform rejection handled by the existing retry budget. @@ -667,7 +671,7 @@ the request was sent, still releases the key for a retry. See ## Proactive notification settings -The shared scheduler outbox sends result/lifecycle notices to bound private +The shared outbox sends scheduled result/lifecycle notices to bound private identities on WeCom, Feishu/Lark, WeChat and QQ. **Settings → Notifications → IM notifications** lists connected, push-capable apps with the same per-provider control. The setting is durable and owner-scoped, and also works in local @@ -675,13 +679,28 @@ control. The setting is durable and owner-scoped, and also works in local account or stop a scheduled task. Browser notifications have a separate switch. Opting out stops new enqueueing and permanently closes pending/retrying notices -for that owner/provider, including any selected Feishu group. Re-enabling does +for that owner/provider, including any selected group. Re-enabling does not replay these notices. A platform send already in progress may still arrive. Preference lookup failures park instead of sending; ordinary binding revocation, localized summaries, redaction, deduplication and retry budgets remain shared. Upgrade every Gateway worker before relying on an opt-out during a rolling upgrade. Downgrading below `0041_im_notice_preferences` removes the choices. +The same settings page has three default-off ordinary task/goal event controls: +completion, terminal failure and requests for input. These use the same outbox, +ownership checks, localized renderer and send/retry opt-out. An unanswered +Human Input Card takes precedence over completion and is rechecked before +sending; an answered or superseded request is dropped. Ordinary goal stand-downs, +recoverable tool errors and interrupted runs do not become failure or input +notices. IM-originated runs retain their normal conversation replies. Migration +`0043_runtime_im_notices` adds real source identities for non-scheduled notices; +downgrading that revision deletes the runtime notices and retains scheduled ones. + +WeCom groups bound before group destinations were introduced keep their legacy +chat identity. That compatibility applies only to the old group-binding shape; +a new private binding never authorizes messages from an unbound group. Bind a +specific group again with a name to make it available as a notification target. + ### WeChat private notifications Use the existing iLink QR/bot setup and bind in a private chat. Send one private @@ -711,5 +730,8 @@ Transport failures park; platform rejections use the shared retry budget. Current official rules are in the [QQ message overview](https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/overview.html). Older GitHub bot-docs snapshots say proactive sending was discontinued; use the -current live documentation and verify the actual bot account. QQ group -notification destinations remain separate from the existing group chat replies. +current live documentation and verify the actual bot account. QQ group notices +use the selected connection's current-App `group_openid`, not a member OpenID +or the passive reply context. WeCom group notices use the selected current-bot +`chatid` through the same smart-bot WebSocket sender; neither path falls back to +a private destination when a group binding is unavailable. diff --git a/backend/packages/harness/deerflow/agents/human_input.py b/backend/packages/harness/deerflow/agents/human_input.py index bea935b78a..f9f235d42b 100644 --- a/backend/packages/harness/deerflow/agents/human_input.py +++ b/backend/packages/harness/deerflow/agents/human_input.py @@ -2,8 +2,8 @@ from __future__ import annotations -from collections.abc import Mapping -from typing import Literal, TypedDict +from collections.abc import Mapping, Sequence +from typing import Any, Literal, TypedDict HUMAN_INPUT_RESPONSE_KEY = "human_input_response" @@ -74,3 +74,52 @@ def read_human_input_response(additional_kwargs: Mapping[str, object] | None) -> "value": value, } return None + + +def _message_type(message: Any) -> str | None: + if isinstance(message, Mapping): + msg_type = message.get("type") or message.get("role") + else: + msg_type = getattr(message, "type", None) or getattr(message, "role", None) + return msg_type if isinstance(msg_type, str) else None + + +def extract_unanswered_human_input_request(messages: Sequence[Any] | None) -> dict[str, str] | None: + """Return the trailing unanswered ``human_input_request`` from *messages*, or ``None``. + + ``ask_clarification`` and structured human-input tool interrupts place the + request in a ``ToolMessage`` artifact and route the graph to ``END``, so an + active prompt sits in the trailing run of tool messages. Once the user + replies (with a structured ``human_input_response`` or any later non-tool + turn), the request is no longer pending. + """ + if not messages: + return None + + answered_request_ids: set[str] = set() + for message in messages: + additional_kwargs = message.get("additional_kwargs") if isinstance(message, Mapping) else getattr(message, "additional_kwargs", None) + response = read_human_input_response(additional_kwargs if isinstance(additional_kwargs, Mapping) else None) + if response is not None: + answered_request_ids.add(response["request_id"]) + + for message in reversed(messages): + if _message_type(message) != "tool": + return None + artifact = message.get("artifact") if isinstance(message, Mapping) else getattr(message, "artifact", None) + human_input = artifact.get("human_input") if isinstance(artifact, Mapping) else None + if not isinstance(human_input, Mapping) or human_input.get("kind") != "human_input_request": + continue + msg_id = message.get("id") if isinstance(message, Mapping) else getattr(message, "id", None) + request_id = _non_empty_string(human_input.get("request_id")) or _non_empty_string(msg_id) + if request_id is not None and request_id in answered_request_ids: + return None + question = _non_empty_string(human_input.get("question")) or "" + source = _non_empty_string(human_input.get("source")) or "ask_clarification" + return { + "kind": "human_input_request", + "request_id": request_id or "human_input_request", + "question": question, + "source": source, + } + return None diff --git a/backend/packages/harness/deerflow/persistence/migrations/versions/0043_runtime_im_notices.py b/backend/packages/harness/deerflow/persistence/migrations/versions/0043_runtime_im_notices.py new file mode 100644 index 0000000000..4b052b9637 --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/migrations/versions/0043_runtime_im_notices.py @@ -0,0 +1,124 @@ +"""Generalize notification_deliveries source columns for runtime IM notifications. + +Revision ID: 0043_runtime_im_notices +Revises: 0042_channel_leases +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0043_runtime_im_notices" +down_revision: str | Sequence[str] | None = "0042_channel_leases" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_TABLE = "notification_deliveries" +_OLD_UQ = "uq_notification_delivery_run_event_target" +_NEW_UQ = "uq_notification_delivery_source_event_target" +_SOURCE_KEY_IX = "ix_notification_deliveries_source_key" + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if not inspector.has_table(_TABLE): + return + + columns = {col["name"]: col for col in inspector.get_columns(_TABLE)} + if "source_kind" not in columns or "source_key" not in columns: + with op.batch_alter_table(_TABLE) as batch: + if "source_kind" not in columns: + batch.add_column(sa.Column("source_kind", sa.String(length=32), nullable=True)) + if "source_key" not in columns: + batch.add_column(sa.Column("source_key", sa.String(length=128), nullable=True)) + + bind.execute(sa.text("UPDATE notification_deliveries SET source_kind = COALESCE(source_kind, 'scheduled_task'), source_key = COALESCE(source_key, task_run_id, run_id, id) WHERE source_kind IS NULL OR source_key IS NULL")) + + inspector = sa.inspect(bind) + columns = {col["name"]: col for col in inspector.get_columns(_TABLE)} + unique_constraints = {uc["name"] for uc in inspector.get_unique_constraints(_TABLE) if uc.get("name")} + indexes = {idx["name"] for idx in inspector.get_indexes(_TABLE) if idx.get("name")} + + needs_batch = columns["source_kind"]["nullable"] or columns["source_key"]["nullable"] or not columns["task_id"]["nullable"] or not columns["task_run_id"]["nullable"] or _OLD_UQ in unique_constraints or _NEW_UQ not in unique_constraints + if needs_batch: + with op.batch_alter_table(_TABLE) as batch: + if _OLD_UQ in unique_constraints: + batch.drop_constraint(_OLD_UQ, type_="unique") + batch.alter_column( + "source_kind", + existing_type=sa.String(length=32), + nullable=False, + server_default=None, + ) + batch.alter_column( + "source_key", + existing_type=sa.String(length=128), + nullable=False, + server_default=None, + ) + batch.alter_column( + "task_id", + existing_type=sa.String(length=64), + nullable=True, + ) + batch.alter_column( + "task_run_id", + existing_type=sa.String(length=64), + nullable=True, + ) + if _NEW_UQ not in unique_constraints: + batch.create_unique_constraint( + _NEW_UQ, + ["source_kind", "source_key", "event", "provider", "target"], + ) + + inspector = sa.inspect(bind) + indexes = {idx["name"] for idx in inspector.get_indexes(_TABLE) if idx.get("name")} + if _SOURCE_KEY_IX not in indexes: + op.create_index(_SOURCE_KEY_IX, _TABLE, ["source_key"], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + if not inspector.has_table(_TABLE): + return + + bind.execute(sa.text("DELETE FROM notification_deliveries WHERE task_id IS NULL OR task_run_id IS NULL")) + + indexes = {idx["name"] for idx in inspector.get_indexes(_TABLE) if idx.get("name")} + if _SOURCE_KEY_IX in indexes: + op.drop_index(_SOURCE_KEY_IX, table_name=_TABLE) + + inspector = sa.inspect(bind) + columns = {col["name"]: col for col in inspector.get_columns(_TABLE)} + unique_constraints = {uc["name"] for uc in inspector.get_unique_constraints(_TABLE) if uc.get("name")} + + with op.batch_alter_table(_TABLE) as batch: + if _NEW_UQ in unique_constraints: + batch.drop_constraint(_NEW_UQ, type_="unique") + if "task_id" in columns: + batch.alter_column( + "task_id", + existing_type=sa.String(length=64), + nullable=False, + ) + if "task_run_id" in columns: + batch.alter_column( + "task_run_id", + existing_type=sa.String(length=64), + nullable=False, + ) + if _OLD_UQ not in unique_constraints: + batch.create_unique_constraint( + _OLD_UQ, + ["task_run_id", "event", "provider", "target"], + ) + if "source_key" in columns: + batch.drop_column("source_key") + if "source_kind" in columns: + batch.drop_column("source_kind") diff --git a/backend/packages/harness/deerflow/persistence/notification_deliveries/model.py b/backend/packages/harness/deerflow/persistence/notification_deliveries/model.py index 7ef2bceb7b..81a57a1eff 100644 --- a/backend/packages/harness/deerflow/persistence/notification_deliveries/model.py +++ b/backend/packages/harness/deerflow/persistence/notification_deliveries/model.py @@ -27,8 +27,10 @@ class NotificationDeliveryRow(Base): __tablename__ = "notification_deliveries" id: Mapped[str] = mapped_column(String(64), primary_key=True) - task_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) - task_run_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + source_kind: Mapped[str] = mapped_column(String(32), nullable=False, default="scheduled_task") + source_key: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + task_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) + task_run_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) run_id: Mapped[str | None] = mapped_column(String(64), nullable=True) event: Mapped[str] = mapped_column(String(64), nullable=False) provider: Mapped[str] = mapped_column(String(32), nullable=False) @@ -60,11 +62,21 @@ class NotificationDeliveryRow(Base): __table_args__ = ( UniqueConstraint( - "task_run_id", + "source_kind", + "source_key", "event", "provider", "target", - name="uq_notification_delivery_run_event_target", + name="uq_notification_delivery_source_event_target", ), Index("ix_notification_deliveries_due", "status", "available_at"), ) + + def __init__(self, **kwargs): + if kwargs.get("source_kind") is None: + kwargs["source_kind"] = "scheduled_task" if kwargs.get("task_run_id") or kwargs.get("task_id") else "run" + if kwargs.get("source_key") is None: + source_key = kwargs.get("task_run_id") or kwargs.get("run_id") or kwargs.get("id") + if source_key is not None: + kwargs["source_key"] = str(source_key) + super().__init__(**kwargs) diff --git a/backend/packages/harness/deerflow/persistence/notification_deliveries/sql.py b/backend/packages/harness/deerflow/persistence/notification_deliveries/sql.py index 7ab707a90f..b5aaa662a7 100644 --- a/backend/packages/harness/deerflow/persistence/notification_deliveries/sql.py +++ b/backend/packages/harness/deerflow/persistence/notification_deliveries/sql.py @@ -67,11 +67,28 @@ class NotificationDeliveryRepository: data["payload"] = data.pop("payload_json") or {} return data + @staticmethod + def _resolve_source( + *, + source_kind: str | None, + source_key: str | None, + task_id: str | None, + task_run_id: str | None, + run_id: str | None, + ) -> tuple[str, str]: + resolved_kind = (source_kind or "").strip() or ("scheduled_task" if task_run_id or task_id else "run") + resolved_key = (source_key or "").strip() or (task_run_id or run_id or "").strip() + if not resolved_key: + raise ValueError("Notification delivery requires source_key, task_run_id, or run_id") + return resolved_kind, resolved_key + async def enqueue( self, *, - task_id: str, - task_run_id: str, + task_id: str | None = None, + task_run_id: str | None = None, + source_kind: str | None = None, + source_key: str | None = None, event: str, provider: str, target: str, @@ -81,10 +98,19 @@ class NotificationDeliveryRepository: available_at: datetime | None = None, max_attempts: int = 5, ) -> dict[str, Any]: - """Insert one delivery; a duplicate (task_run_id, event, provider, - target) returns the existing row instead of raising.""" + """Insert one delivery; a duplicate (source_kind, source_key, event, + provider, target) returns the existing row instead of raising.""" + resolved_kind, resolved_key = self._resolve_source( + source_kind=source_kind, + source_key=source_key, + task_id=task_id, + task_run_id=task_run_id, + run_id=run_id, + ) row = NotificationDeliveryRow( id=self._new_id(), + source_kind=resolved_kind, + source_key=resolved_key, task_id=task_id, task_run_id=task_run_id, run_id=run_id, @@ -105,7 +131,8 @@ class NotificationDeliveryRepository: return self._to_dict(row) except IntegrityError: existing = await self._find_by_idempotency_key( - task_run_id=task_run_id, + source_kind=resolved_kind, + source_key=resolved_key, event=event, provider=provider, target=target, @@ -118,8 +145,10 @@ class NotificationDeliveryRepository: self, session: AsyncSession, *, - task_id: str, - task_run_id: str, + task_id: str | None = None, + task_run_id: str | None = None, + source_kind: str | None = None, + source_key: str | None = None, event: str, provider: str, target: str, @@ -138,9 +167,18 @@ class NotificationDeliveryRepository: dialect = session.get_bind().dialect.name if dialect not in {"sqlite", "postgresql"}: raise ValueError(f"Unsupported notification database dialect: {dialect}") + resolved_kind, resolved_key = self._resolve_source( + source_kind=source_kind, + source_key=source_key, + task_id=task_id, + task_run_id=task_run_id, + run_id=run_id, + ) insert = sqlite_insert if dialect == "sqlite" else pg_insert values = { "id": self._new_id(), + "source_kind": resolved_kind, + "source_key": resolved_key, "task_id": task_id, "task_run_id": task_run_id, "run_id": run_id, @@ -153,11 +191,12 @@ class NotificationDeliveryRepository: } if available_at is not None: values["available_at"] = available_at - statement = insert(NotificationDeliveryRow).values(**values).on_conflict_do_nothing(index_elements=["task_run_id", "event", "provider", "target"]) + statement = insert(NotificationDeliveryRow).values(**values).on_conflict_do_nothing(index_elements=["source_kind", "source_key", "event", "provider", "target"]) await session.execute(statement) row = await session.scalar( select(NotificationDeliveryRow).where( - NotificationDeliveryRow.task_run_id == task_run_id, + NotificationDeliveryRow.source_kind == resolved_kind, + NotificationDeliveryRow.source_key == resolved_key, NotificationDeliveryRow.event == event, NotificationDeliveryRow.provider == provider, NotificationDeliveryRow.target == target, @@ -170,7 +209,8 @@ class NotificationDeliveryRepository: async def _find_by_idempotency_key( self, *, - task_run_id: str, + source_kind: str, + source_key: str, event: str, provider: str, target: str, @@ -178,7 +218,8 @@ class NotificationDeliveryRepository: async with self.session_factory() as session: result = await session.execute( select(NotificationDeliveryRow).where( - NotificationDeliveryRow.task_run_id == task_run_id, + NotificationDeliveryRow.source_kind == source_kind, + NotificationDeliveryRow.source_key == source_key, NotificationDeliveryRow.event == event, NotificationDeliveryRow.provider == provider, NotificationDeliveryRow.target == target, diff --git a/backend/packages/harness/deerflow/persistence/user/im_notifications.py b/backend/packages/harness/deerflow/persistence/user/im_notifications.py index 4aecb5be34..c4d757ee04 100644 --- a/backend/packages/harness/deerflow/persistence/user/im_notifications.py +++ b/backend/packages/harness/deerflow/persistence/user/im_notifications.py @@ -2,7 +2,7 @@ from datetime import UTC, datetime -from sqlalchemy import Boolean, String, select, true, update +from sqlalchemy import Boolean, String, delete, select, true, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.orm import Mapped, mapped_column @@ -11,6 +11,9 @@ from deerflow.persistence.base import Base from deerflow.persistence.notification_deliveries.model import NotificationDeliveryRow IM_NOTIFICATION_PROVIDERS = ("feishu", "wecom", "wechat", "qq") +IM_RUNTIME_NOTIFICATION_EVENTS = ("run_completed", "run_failed", "human_input_requested") +IM_NOTIFICATION_PREFERENCE_KEYS = (*IM_NOTIFICATION_PROVIDERS, *IM_RUNTIME_NOTIFICATION_EVENTS) +RUNTIME_NOTIFICATION_SOURCE_KINDS = ("run", "human_input_request") DISABLED_BY_OWNER = "disabled_by_owner" @@ -26,50 +29,102 @@ class ImNotificationPreferenceRow(Base): class ImNotificationPreferencesRepository: """Independent preference rows; disabling also permanently closes the backlog. - Missing values preserve the existing connected-channel default. Browser - notifications use a different preference and remain unchanged. + Missing values preserve the existing connected-channel default for providers + (True) while runtime task/goal notification events remain opt-in (False). + Browser notifications use a different preference and remain unchanged. """ def __init__(self, sessions): self.sessions = sessions @staticmethod - async def get_in_session(session, user_id: str, *, lock_owner: bool = False) -> dict[str, bool]: - # Serialize enqueue with opt-out on Postgres. The scheduler already holds - # SQLite's writer lock; a patch takes it at its first upsert below. - statement = select(ImNotificationPreferenceRow.provider, ImNotificationPreferenceRow.enabled).where(ImNotificationPreferenceRow.owner_user_id == user_id, ImNotificationPreferenceRow.provider.in_(IM_NOTIFICATION_PROVIDERS)) - if lock_owner and session.get_bind().dialect.name == "postgresql": - # Materialize missing defaults so even the first opt-out serializes - # with enqueue. Locks are always acquired in provider order. - for provider in sorted(IM_NOTIFICATION_PROVIDERS): - await session.execute(pg_insert(ImNotificationPreferenceRow).values(owner_user_id=user_id, provider=provider, enabled=True).on_conflict_do_nothing()) - statement = statement.order_by(ImNotificationPreferenceRow.provider).with_for_update() + async def get_in_session( + session, + user_id: str, + *, + lock_owner: bool = False, + include_events: bool = False, + ) -> dict[str, bool]: + # Serialize enqueue with opt-out on Postgres and SQLite. Materializing + # provider defaults acquires SQLite's writer lock (or row locks on + # Postgres) in deterministic key order before reading preferences. + statement = select(ImNotificationPreferenceRow.provider, ImNotificationPreferenceRow.enabled).where( + ImNotificationPreferenceRow.owner_user_id == user_id, + ImNotificationPreferenceRow.provider.in_(IM_NOTIFICATION_PREFERENCE_KEYS), + ) + if lock_owner: + dialect = session.get_bind().dialect.name + if dialect in ("postgresql", "sqlite"): + insert_fn = pg_insert if dialect == "postgresql" else sqlite_insert + for key in sorted(IM_NOTIFICATION_PROVIDERS): + await session.execute(insert_fn(ImNotificationPreferenceRow).values(owner_user_id=user_id, provider=key, enabled=True).on_conflict_do_nothing()) + if dialect == "postgresql": + statement = statement.order_by(ImNotificationPreferenceRow.provider).with_for_update() rows = await session.execute(statement) stored = dict(rows.all()) - return {provider: stored.get(provider, True) is True for provider in IM_NOTIFICATION_PROVIDERS} + result = {provider: stored.get(provider, True) is True for provider in IM_NOTIFICATION_PROVIDERS} + for event in IM_RUNTIME_NOTIFICATION_EVENTS: + if include_events or event in stored: + result[event] = stored.get(event, False) is True + return result - async def get(self, user_id: str) -> dict[str, bool]: + async def get(self, user_id: str, *, include_events: bool = False) -> dict[str, bool]: async with self.sessions() as session: - return await self.get_in_session(session, user_id) + return await self.get_in_session(session, user_id, include_events=include_events) + + async def get_event_preferences(self, user_id: str) -> dict[str, bool]: + prefs = await self.get(user_id, include_events=True) + return {event: prefs.get(event, False) is True for event in IM_RUNTIME_NOTIFICATION_EVENTS} async def patch(self, user_id: str, values: dict[str, bool | None]) -> dict[str, bool]: - if any(provider not in IM_NOTIFICATION_PROVIDERS or (value is not None and type(value) is not bool) for provider, value in values.items()): + if any(key not in IM_NOTIFICATION_PREFERENCE_KEYS or (value is not None and type(value) is not bool) for key, value in values.items()): raise ValueError("Invalid IM notification preference") async with self.sessions() as session, session.begin(): dialect = session.get_bind().dialect.name - if dialect == "postgresql": + if dialect in ("postgresql", "sqlite"): await self.get_in_session(session, user_id, lock_owner=True) insert = pg_insert if dialect == "postgresql" else sqlite_insert - for provider, value in sorted(values.items()): - statement = insert(ImNotificationPreferenceRow).values(owner_user_id=user_id, provider=provider, enabled=value is not False) - await session.execute(statement.on_conflict_do_update(index_elements=["owner_user_id", "provider"], set_={"enabled": statement.excluded.enabled})) - disabled = [provider for provider, value in values.items() if value is False] - if disabled: + for key, value in sorted(values.items()): + if key in IM_RUNTIME_NOTIFICATION_EVENTS and value is None: + await session.execute( + delete(ImNotificationPreferenceRow).where( + ImNotificationPreferenceRow.owner_user_id == user_id, + ImNotificationPreferenceRow.provider == key, + ) + ) + continue + enabled = (value is not False) if key in IM_NOTIFICATION_PROVIDERS else (value is True) + statement = insert(ImNotificationPreferenceRow).values(owner_user_id=user_id, provider=key, enabled=enabled) + await session.execute( + statement.on_conflict_do_update( + index_elements=["owner_user_id", "provider"], + set_={"enabled": statement.excluded.enabled}, + ) + ) + now = datetime.now(UTC) + disabled_providers = [key for key, value in values.items() if key in IM_NOTIFICATION_PROVIDERS and value is False] + if disabled_providers: # Do not merely defer: reopening must never release old notices. # Clear tokens so a late worker cannot overwrite the opt-out. await session.execute( update(NotificationDeliveryRow) - .where(NotificationDeliveryRow.owner_user_id == user_id, NotificationDeliveryRow.provider.in_(disabled), NotificationDeliveryRow.status.in_(("pending", "sending"))) - .values(status="failed", last_error=DISABLED_BY_OWNER, claim_token=None, updated_at=datetime.now(UTC)) + .where( + NotificationDeliveryRow.owner_user_id == user_id, + NotificationDeliveryRow.provider.in_(disabled_providers), + NotificationDeliveryRow.status.in_(("pending", "sending")), + ) + .values(status="failed", last_error=DISABLED_BY_OWNER, claim_token=None, updated_at=now) + ) + disabled_events = [key for key, value in values.items() if key in IM_RUNTIME_NOTIFICATION_EVENTS and value is not True] + if disabled_events: + await session.execute( + update(NotificationDeliveryRow) + .where( + NotificationDeliveryRow.owner_user_id == user_id, + NotificationDeliveryRow.source_kind.in_(RUNTIME_NOTIFICATION_SOURCE_KINDS), + NotificationDeliveryRow.event.in_(disabled_events), + NotificationDeliveryRow.status.in_(("pending", "sending")), + ) + .values(status="failed", last_error=DISABLED_BY_OWNER, claim_token=None, updated_at=now) ) return await self.get_in_session(session, user_id) diff --git a/backend/packages/harness/deerflow/runtime/runs/manager.py b/backend/packages/harness/deerflow/runtime/runs/manager.py index c1c5558298..c2c84088ce 100644 --- a/backend/packages/harness/deerflow/runtime/runs/manager.py +++ b/backend/packages/harness/deerflow/runtime/runs/manager.py @@ -254,6 +254,8 @@ class RunRecord: ownership_lost: bool = False stop_reason: str | None = None goal_verdict: dict[str, Any] | None = None + human_input_request: dict[str, Any] | None = None + terminal_status_persisted: bool = True # Process-local finalization barrier for a worker-installed scheduled goal. scheduled_goal_cleanup_pending: bool = False # Process-local renewal barrier: the worker stages its terminal status in @@ -581,6 +583,9 @@ class RunManager: if updated is False: existing = await self._store.get(run_id) requested_status = kwargs.get("status") + if existing is not None and existing.get("status") == requested_status: + if record is not None and requested_status == record.status.value: + record.terminal_status_persisted = True if existing is not None and existing.get("status") != requested_status: existing_status = existing.get("status") logger.warning( @@ -607,6 +612,9 @@ class RunManager: ) if recovered is False: logger.warning("Run completion update for %s affected no rows after row recreation", run_id) + return + if record is not None and kwargs.get("status") in (None, record.status.value): + record.terminal_status_persisted = True except Exception: logger.warning("Failed to persist run completion for %s", run_id, exc_info=True) @@ -1059,6 +1067,8 @@ class RunManager: return record.status = status record.updated_at = _now_iso() + if status not in (RunStatus.pending, RunStatus.running): + record.terminal_status_persisted = self._store is None if error is not None: record.error = error if stop_reason is not None: @@ -1067,6 +1077,9 @@ class RunManager: record.goal_verdict = copy.deepcopy(goal_verdict) if persist: persisted = await self._persist_status(record, status, error=error, stop_reason=stop_reason) + if status not in (RunStatus.pending, RunStatus.running): + async with self._lock: + record.terminal_status_persisted = persisted if not persisted and self.heartbeat_enabled and status == RunStatus.success and not record.ownership_lost: await self._mark_ownership_lost( record, @@ -1088,6 +1101,9 @@ class RunManager: error = record.error stop_reason = record.stop_reason persisted = await self._persist_status(record, status, error=error, stop_reason=stop_reason) + if status not in (RunStatus.pending, RunStatus.running): + async with self._lock: + record.terminal_status_persisted = persisted if not persisted and self.heartbeat_enabled and status == RunStatus.success and not record.ownership_lost: await self._mark_ownership_lost( record, @@ -1133,6 +1149,8 @@ class RunManager: except Exception: async with self._lock: record = self._runs.get(run_id) + if record is not None: + record.terminal_status_persisted = False if record is not None: await self._mark_ownership_lost( record, @@ -1157,6 +1175,11 @@ class RunManager: goal_verdict=goal_verdict, persist=not result.finalized, ) + if result.finalized: + async with self._lock: + record = self._runs.get(run_id) + if record is not None: + record.terminal_status_persisted = True return None async def _ensure_delivery_receipt(self, record: RunRecord) -> bool: diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py index 9b546e7a64..606c9d2ad3 100644 --- a/backend/packages/harness/deerflow/runtime/runs/worker.py +++ b/backend/packages/harness/deerflow/runtime/runs/worker.py @@ -1872,7 +1872,21 @@ async def run_agent( except Exception: logger.debug("Failed to update thread_meta status for %s (non-fatal)", thread_id) - if not record.ownership_lost and ctx.on_run_completed is not None: + if started and not record.ownership_lost and accessor is not None and record.status == RunStatus.success and not record.abort_event.is_set() and record.human_input_request is None: + try: + from deerflow.agents.human_input import extract_unanswered_human_input_request + + record.human_input_request = extract_unanswered_human_input_request(await _materialized_checkpoint_messages(accessor, thread_id)) + except Exception: + logger.debug("Failed to inspect trailing human_input_request for run %s (non-fatal)", run_id, exc_info=True) + + if not record.ownership_lost and not getattr(record, "terminal_status_persisted", True): + try: + await run_manager.persist_current_status(run_id) + except Exception: + logger.warning("Failed to confirm terminal status for run %s before completion hook", run_id, exc_info=True) + + if not record.ownership_lost and getattr(record, "terminal_status_persisted", True) and ctx.on_run_completed is not None: try: await ctx.on_run_completed(record) except Exception: diff --git a/backend/tests/test_channel_capabilities.py b/backend/tests/test_channel_capabilities.py index e3f47f7e87..21c78e5053 100644 --- a/backend/tests/test_channel_capabilities.py +++ b/backend/tests/test_channel_capabilities.py @@ -14,7 +14,11 @@ from pathlib import Path import pytest from app.channels.base import Channel -from app.channels.capabilities import CHANNEL_CAPABILITIES, supports_proactive_notifications +from app.channels.capabilities import ( + CHANNEL_CAPABILITIES, + supports_proactive_group_notifications, + supports_proactive_notifications, +) from app.channels.service import _CHANNEL_REGISTRY from deerflow.reflection import resolve_class @@ -31,7 +35,7 @@ def test_capabilities_cover_channel_registry(): def test_every_entry_declares_both_flags_as_bools(): for provider, entry in CHANNEL_CAPABILITIES.items(): - assert set(entry) == {"supports_streaming", "proactive_notifications"}, provider + assert set(entry) == {"supports_streaming", "proactive_notifications", "proactive_group_notifications"}, provider assert all(isinstance(value, bool) for value in entry.values()), provider @@ -41,9 +45,17 @@ def test_supported_notification_channels_push_proactively(): assert supports_proactive_notifications(provider) is True +def test_supported_group_notification_channels(): + assert {provider for provider, entry in CHANNEL_CAPABILITIES.items() if entry["proactive_group_notifications"]} == {"wecom", "feishu", "qq"} + for provider in ("wecom", "feishu", "qq"): + assert supports_proactive_group_notifications(provider) is True + assert supports_proactive_group_notifications("wechat") is False + + @pytest.mark.parametrize("provider", ["", "unknown", "WeCom", None, 1]) def test_unknown_provider_has_no_proactive_push(provider): assert supports_proactive_notifications(provider) is False + assert supports_proactive_group_notifications(provider) is False def _missing_optional_module(exc: BaseException) -> str | None: @@ -70,6 +82,8 @@ def test_proactive_flag_matches_send_notification_override(provider): pytest.skip(f"{provider}: optional SDK {missing} not installed") overrides = cls.send_notification is not Channel.send_notification assert CHANNEL_CAPABILITIES[provider]["proactive_notifications"] is overrides + group_overrides = cls.send_group_notification_for_owner is not Channel.send_group_notification_for_owner + assert CHANNEL_CAPABILITIES[provider]["proactive_group_notifications"] is group_overrides def test_importing_capabilities_loads_no_channel_sdk(): diff --git a/backend/tests/test_channel_connections_router.py b/backend/tests/test_channel_connections_router.py index 3b3d4c454f..4b66cae761 100644 --- a/backend/tests/test_channel_connections_router.py +++ b/backend/tests/test_channel_connections_router.py @@ -599,6 +599,48 @@ def test_connect_existing_binding_code_channels_return_command_and_persist_state anyio.run(repo.close) +@pytest.mark.parametrize("provider", ["qq", "wecom"]) +def test_browser_group_names_survive_state_consumption_and_bind_two_groups(tmp_path, monkeypatch, provider): + import anyio + + from app.channels.message_bus import MessageBus + from app.channels.qq import QQChannel + from app.channels.wecom import WeComChannel + + monkeypatch.setattr(channel_connections, "_ensure_runtime_channel_ready_if_available", AsyncMock()) + repo = anyio.run(_make_repo, tmp_path) + config = ChannelConnectionsConfig.model_validate({"enabled": True, provider: {"enabled": True}}) + runtime = {"qq": {"enabled": True, "app_id": "app-1", "client_secret": "offline"}, "wecom": {"enabled": True, "bot_id": "bot-1", "bot_secret": "offline"}} + app = _make_app(config, repo, runtime) + try: + with TestClient(app) as client: + responses = [client.post(f"/api/channels/{provider}/connect", json={"group_name": name}) for name in ("Release team", "Support team")] + assert all(response.status_code == 200 for response in responses), [response.text for response in responses] + + async def bind_groups(): + if provider == "qq": + channel = QQChannel(MessageBus(), {"app_id": "app-1", "connection_repo": repo}) + channel.send = AsyncMock() + else: + channel = WeComChannel(MessageBus(), {"connection_repo": repo}) + channel._send_connection_reply = AsyncMock() + for index, response in enumerate(responses): + code = response.json()["code"] + if provider == "qq": + await channel._handle_inbound({"t": "GROUP_AT_MESSAGE_CREATE", "d": {"id": f"message-{index}", "author": {"member_openid": "alice"}, "group_openid": f"group-{index}", "content": f"/connect {code}"}}) + else: + await channel._bind_connection_from_connect_code(frame={"body": {"aibotid": "bot-1", "chattype": "group", "chatid": f"wr-group-{index}"}}, user_id="alice", code=code) + return await repo.list_connections(str(_user().id)) + + rows = anyio.run(bind_groups) + assert len(rows) == 2 + assert len({row["id"] for row in rows}) == 2 + assert len({row["workspace_id"] for row in rows}) == 2 + assert {row["workspace_name"] for row in rows} == {"Release team", "Support team"} + finally: + anyio.run(repo.close) + + def test_connect_unconfigured_runtime_channel_returns_400(tmp_path): import anyio diff --git a/backend/tests/test_feishu_notifications.py b/backend/tests/test_feishu_notifications.py index 43e2994c4a..cb36845669 100644 --- a/backend/tests/test_feishu_notifications.py +++ b/backend/tests/test_feishu_notifications.py @@ -122,6 +122,17 @@ async def test_group_binding_reads_name_offloop_without_blocking_binding_on_fail assert binding["metadata"]["chat_id"] == "oc_group" +@pytest.mark.asyncio +@pytest.mark.parametrize("chat_type,expected", [("group", "Release team"), ("p2p", None)]) +async def test_owner_chosen_group_name_is_consumed_only_for_group_binding(channel, chat_type, expected): + channel._connection_repo = SimpleNamespace(consume_oauth_state=AsyncMock(return_value={"owner_user_id": "owner-1", "metadata": {"notification_group_name": "Release team"}}), upsert_connection=AsyncMock()) + channel._reply_card = AsyncMock() + channel._get_group_name = AsyncMock() + await channel._bind_connection_from_connect_code(message_id="om_bind", chat_id="oc_group", user_id="ou_bound_user", code="connect-code", chat_type=chat_type) + assert channel._connection_repo.upsert_connection.await_args.kwargs["workspace_name"] == expected + channel._get_group_name.assert_not_awaited() + + @pytest.mark.asyncio async def test_base_owner_notification_preserves_existing_provider_send(channel): channel.send_notification = AsyncMock() diff --git a/backend/tests/test_im_notification_preferences.py b/backend/tests/test_im_notification_preferences.py index 326922cefd..12423271e7 100644 --- a/backend/tests/test_im_notification_preferences.py +++ b/backend/tests/test_im_notification_preferences.py @@ -368,3 +368,55 @@ async def test_public_wechat_connection_metadata_omits_legacy_context_token(repo response = await channel_connections.get_channel_connections(request) assert response.connections[0].metadata == {"chat_type": "p2p"} assert row["metadata"]["context_token"] == "private-routing-token" + + +@pytest.mark.anyio +async def test_runtime_event_preferences_default_off_and_cancel_only_matching_runtime_backlog(repositories): + preferences, deliveries = repositories + assert await preferences.get_event_preferences("alice") == { + "run_completed": False, + "run_failed": False, + "human_input_requested": False, + } + updated = await preferences.patch("alice", {"run_completed": True, "human_input_requested": True}) + assert updated["run_completed"] is True + assert updated["human_input_requested"] is True + assert "run_failed" not in updated + assert await preferences.get_event_preferences("alice") == { + "run_completed": True, + "run_failed": False, + "human_input_requested": True, + } + + scheduled_row = await enqueue(deliveries, suffix="sched") + runtime_completed = await deliveries.enqueue( + source_kind="run", + source_key="run-1", + provider="feishu", + target="recipient-rt-1", + owner_user_id="alice", + event="run_completed", + run_id="run-1", + payload={"source_kind": "run"}, + ) + runtime_input = await deliveries.enqueue( + source_kind="human_input_request", + source_key="run-2:call-1", + provider="feishu", + target="recipient-rt-2", + owner_user_id="alice", + event="human_input_requested", + run_id="run-2", + payload={"source_kind": "human_input_request"}, + ) + + # Disabling `run_completed` cancels the pending runtime `run_completed` notice, + # but leaves scheduled-task `run_completed` and runtime `human_input_requested` untouched. + await preferences.patch("alice", {"run_completed": False}) + async with deliveries.session_factory() as session: + sched = await session.get(NotificationDeliveryRow, scheduled_row["id"]) + rt_done = await session.get(NotificationDeliveryRow, runtime_completed["id"]) + rt_input = await session.get(NotificationDeliveryRow, runtime_input["id"]) + assert sched.status == "pending" + assert (rt_done.status, rt_done.last_error) == ("failed", "disabled_by_owner") + assert rt_input.status == "pending" diff --git a/backend/tests/test_migration_0027_0028_notification_outbox.py b/backend/tests/test_migration_0027_0028_notification_outbox.py index 6b0ef60df9..cf828618a3 100644 --- a/backend/tests/test_migration_0027_0028_notification_outbox.py +++ b/backend/tests/test_migration_0027_0028_notification_outbox.py @@ -23,7 +23,7 @@ OUTBOX = "0027_notification_deliveries" PARKED = "0028_parked_attempts" SCHEDULER = "0029_scheduler_agent_tasks" CLAIM_TOKENS = "0030_notification_claim_tokens" -HEAD = "0042_channel_leases" +HEAD = "0043_runtime_im_notices" PREVIOUS = "0026_mcp_task_lease_tokens" TABLE = "notification_deliveries" diff --git a/backend/tests/test_migration_0029_scheduler_agent_tasks.py b/backend/tests/test_migration_0029_scheduler_agent_tasks.py index 3407419bb6..37a46739a2 100644 --- a/backend/tests/test_migration_0029_scheduler_agent_tasks.py +++ b/backend/tests/test_migration_0029_scheduler_agent_tasks.py @@ -23,7 +23,7 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository REVISION = "0029_scheduler_agent_tasks" -CURRENT_HEAD = "0042_channel_leases" +CURRENT_HEAD = "0043_runtime_im_notices" NEXT = "0030_notification_claim_tokens" PREVIOUS = "0028_parked_attempts" TASK_FIELDS = {"origin_thread_id", "goal_objective", "max_runs", "end_at", "standing_notes"} diff --git a/backend/tests/test_migration_0032_activity_and_task_events.py b/backend/tests/test_migration_0032_activity_and_task_events.py index 7ce3ef735c..ad9a8cca43 100644 --- a/backend/tests/test_migration_0032_activity_and_task_events.py +++ b/backend/tests/test_migration_0032_activity_and_task_events.py @@ -24,7 +24,7 @@ IDEMPOTENCY = "0036_run_idempotency_request" DOCUMENT_SUMMARIES = "0037_project_document_summaries" CHANNEL_BINDINGS = "0038_channel_thread_bindings" USER_DISABLED = "0039_user_disabled" -CURRENT_HEAD = "0042_channel_leases" +CURRENT_HEAD = "0043_runtime_im_notices" PREVIOUS = "0031_scheduled_streak_boundary" TABLES = {"thread_read_markers", "thread_read_versions", "scheduled_task_events"} INDEXES = { diff --git a/backend/tests/test_migration_0034_run_event_seq_watermark.py b/backend/tests/test_migration_0034_run_event_seq_watermark.py index 954ba78dbe..70f23e9b1a 100644 --- a/backend/tests/test_migration_0034_run_event_seq_watermark.py +++ b/backend/tests/test_migration_0034_run_event_seq_watermark.py @@ -24,7 +24,7 @@ IDEMPOTENCY = "0036_run_idempotency_request" DOCUMENT_SUMMARIES = "0037_project_document_summaries" CHANNEL_BINDINGS = "0038_channel_thread_bindings" USER_DISABLED = "0039_user_disabled" -CURRENT_HEAD = "0042_channel_leases" +CURRENT_HEAD = "0043_runtime_im_notices" TABLE = "run_event_thread_seq" pytestmark = pytest.mark.asyncio diff --git a/backend/tests/test_migration_0035_login_throttle.py b/backend/tests/test_migration_0035_login_throttle.py index 8ec51acefc..a01a31b17a 100644 --- a/backend/tests/test_migration_0035_login_throttle.py +++ b/backend/tests/test_migration_0035_login_throttle.py @@ -25,7 +25,7 @@ IDEMPOTENCY = "0036_run_idempotency_request" DOCUMENT_SUMMARIES = "0037_project_document_summaries" CHANNEL_BINDINGS = "0038_channel_thread_bindings" USER_DISABLED = "0039_user_disabled" -CURRENT_HEAD = "0042_channel_leases" +CURRENT_HEAD = "0043_runtime_im_notices" PREVIOUS = "0034_run_event_seq_watermark" TABLE = "login_throttle" COLUMNS = {"ip", "fail_count", "locked_at", "lock_duration_seconds", "updated_at"} diff --git a/backend/tests/test_migration_0037_project_document_summaries.py b/backend/tests/test_migration_0037_project_document_summaries.py index 9461ce0b3c..ebfbac75ac 100644 --- a/backend/tests/test_migration_0037_project_document_summaries.py +++ b/backend/tests/test_migration_0037_project_document_summaries.py @@ -22,7 +22,8 @@ CHANNEL_BINDINGS = "0038_channel_thread_bindings" USER_DISABLED = "0039_user_disabled" GROUP_NOTICE = "0040_scheduled_group_notice" IM_NOTICE_PREFERENCES = "0041_im_notice_preferences" -CURRENT_HEAD = "0042_channel_leases" +CHANNEL_LEASES = "0042_channel_leases" +CURRENT_HEAD = "0043_runtime_im_notices" pytestmark = pytest.mark.asyncio @@ -33,7 +34,8 @@ async def test_0037_chains_after_0036_and_is_single_head(): assert script.get_revision(USER_DISABLED).down_revision == CHANNEL_BINDINGS assert script.get_revision(GROUP_NOTICE).down_revision == USER_DISABLED assert script.get_revision(IM_NOTICE_PREFERENCES).down_revision == GROUP_NOTICE - assert script.get_revision(CURRENT_HEAD).down_revision == IM_NOTICE_PREFERENCES + assert script.get_revision(CHANNEL_LEASES).down_revision == IM_NOTICE_PREFERENCES + assert script.get_revision(CURRENT_HEAD).down_revision == CHANNEL_LEASES assert script.get_revision(REVISION).down_revision == PREVIOUS # alembic_version.version_num is VARCHAR(32). assert len(REVISION) <= 32 diff --git a/backend/tests/test_migration_0038_channel_thread_bindings.py b/backend/tests/test_migration_0038_channel_thread_bindings.py index 2dbe8e3065..5c4ed15ea0 100644 --- a/backend/tests/test_migration_0038_channel_thread_bindings.py +++ b/backend/tests/test_migration_0038_channel_thread_bindings.py @@ -22,7 +22,7 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args REVISION = "0038_channel_thread_bindings" PREVIOUS = "0037_project_document_summaries" -CURRENT_HEAD = "0042_channel_leases" +CURRENT_HEAD = "0043_runtime_im_notices" TABLE = "channel_thread_bindings" COLUMNS = {"key", "channel_name", "chat_id", "topic_id", "thread_id", "user_id", "created_at", "updated_at"} INDEX = "ix_channel_thread_bindings_channel_chat" diff --git a/backend/tests/test_migration_0040_scheduled_group_notice.py b/backend/tests/test_migration_0040_scheduled_group_notice.py index ea0cc4e101..a68f6cf3a5 100644 --- a/backend/tests/test_migration_0040_scheduled_group_notice.py +++ b/backend/tests/test_migration_0040_scheduled_group_notice.py @@ -16,7 +16,7 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args REVISION = "0040_scheduled_group_notice" PREVIOUS = "0039_user_disabled" -CURRENT_HEAD = "0042_channel_leases" +CURRENT_HEAD = "0043_runtime_im_notices" COLUMN = "notification_group_connection_id" diff --git a/backend/tests/test_migration_0042_channel_leases.py b/backend/tests/test_migration_0042_channel_leases.py index 9fc8ff2042..3e80c4e47c 100644 --- a/backend/tests/test_migration_0042_channel_leases.py +++ b/backend/tests/test_migration_0042_channel_leases.py @@ -22,7 +22,7 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args REVISION = "0042_channel_leases" PREVIOUS = "0041_im_notice_preferences" -CURRENT_HEAD = "0042_channel_leases" +CURRENT_HEAD = "0043_runtime_im_notices" TABLE = "channel_leases" COLUMNS = {"provider", "owner_id", "epoch", "lease_expires_at", "transport_running", "updated_at"} pytestmark = pytest.mark.asyncio @@ -31,6 +31,7 @@ pytestmark = pytest.mark.asyncio async def test_0042_remains_in_the_single_migration_chain(): script = ScriptDirectory(str(bootstrap._MIGRATIONS_DIR)) assert script.get_heads() == [CURRENT_HEAD] + assert script.get_revision(CURRENT_HEAD).down_revision == REVISION assert script.get_revision(REVISION).down_revision == PREVIOUS # alembic_version.version_num is VARCHAR(32). assert len(REVISION) <= 32 diff --git a/backend/tests/test_notification_delivery_worker.py b/backend/tests/test_notification_delivery_worker.py index ce5cc11ec6..d900bccdbe 100644 --- a/backend/tests/test_notification_delivery_worker.py +++ b/backend/tests/test_notification_delivery_worker.py @@ -165,7 +165,7 @@ async def test_group_notice_requires_current_exact_group_binding(updates): @pytest.mark.asyncio -@pytest.mark.parametrize("provider,target_type", [("wecom", "group"), ("feishu", "unknown"), ("feishu", "private")]) +@pytest.mark.parametrize("provider,target_type", [("wecom", "group"), ("wechat", "group"), ("feishu", "unknown"), ("feishu", "private")]) async def test_invalid_group_target_type_never_falls_back_to_personal_notice(provider, target_type): repo = FakeDeliveryRepo([_delivery_row(provider=provider, target="ou_owner", payload={"target_type": target_type})]) channel = FakeChannel() @@ -177,6 +177,26 @@ async def test_invalid_group_target_type_never_falls_back_to_personal_notice(pro assert channel.group_sent == [] +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("provider", "workspace_id", "metadata"), + [ + ("qq", "app1:group:group-openid", {"chat_type": "group", "group_openid": "group-openid"}), + ("wecom", "bot1:group:wr_chat_1", {"aibotid": "bot1", "chattype": "group", "chatid": "wr_chat_1"}), + ], +) +async def test_qq_and_wecom_group_notices_use_group_hook_only(provider, workspace_id, metadata): + repo = FakeDeliveryRepo([_delivery_row(provider=provider, target="group-binding", payload={"target_type": "group", "run_status": "success"})]) + channel = FakeChannel() + group_conn = _group_connection(provider=provider, workspace_id=workspace_id, metadata=metadata) + worker = NotificationDeliveryWorker(delivery_repo=repo, resolve_channel=lambda p: channel if p == provider else None, resolve_connections=lambda owner: [group_conn]) + await worker.run_once(now=datetime.now(UTC)) + assert repo.sent == ["delivery-1"] + assert channel.group_sent[0][:2] == ("user-1", "group-binding") + assert channel.sent == [] + assert channel.owners == [] + + @pytest.mark.asyncio @pytest.mark.parametrize("resolver_missing", [False, True]) async def test_group_notice_parks_when_binding_cannot_be_checked(resolver_missing): @@ -1057,3 +1077,113 @@ async def test_worker_ignores_a_non_collection_exclusion_result(): await worker.run_once(now=datetime.now(UTC)) assert repo.exclusions == [None] assert repo.sent == ["delivery-1"] + + +@pytest.mark.asyncio +async def test_wecom_group_notification_for_owner_and_multi_group_binding_isolation(tmp_path): + from datetime import timedelta + from unittest.mock import AsyncMock + + from deerflow.persistence.channel_connections import ChannelConnectionRepository + from deerflow.persistence.engine import close_engine, get_session_factory, init_engine + + await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'wecom_group.db'}", sqlite_dir=str(tmp_path)) + try: + conn_repo = ChannelConnectionRepository(get_session_factory()) + ws_client = FakeWsClient() + ws_client.reply = AsyncMock() + ws_client.reply_stream = AsyncMock() + channel = WeComChannel(bus=MessageBus(), config={"bot_id": "bot-1", "connection_repo": conn_repo}) + channel._bot_id = "bot-1" + channel._ws_client = ws_client + channel._running = True + channel._ws_transport_up = True + + for code in ("code-priv", "code-g1", "code-g2"): + await conn_repo.create_oauth_state( + owner_user_id="user-1", + provider="wecom", + state=code, + expires_at=datetime.now(UTC) + timedelta(minutes=5), + ) + + await channel._bind_connection_from_connect_code( + frame={"body": {"aibotid": "bot-1", "chattype": "single"}}, + user_id="GaoZhiChao", + code="code-priv", + ) + await channel._bind_connection_from_connect_code( + frame={"body": {"aibotid": "bot-1", "chattype": "group", "chatid": "wr_group_1"}}, + user_id="GaoZhiChao", + code="code-g1", + ) + await channel._bind_connection_from_connect_code( + frame={"body": {"aibotid": "bot-1", "chattype": "group", "chatid": "wr_group_2"}}, + user_id="GaoZhiChao", + code="code-g2", + ) + + connections = await conn_repo.list_connections("user-1") + assert len(connections) == 3 + by_workspace = {row["workspace_id"]: row for row in connections} + assert set(by_workspace) == {"bot-1", "bot-1:group:wr_group_1", "bot-1:group:wr_group_2"} + assert by_workspace["bot-1:group:wr_group_1"]["workspace_name"] is None + assert by_workspace["bot-1:group:wr_group_2"]["workspace_name"] is None + g1_id = by_workspace["bot-1:group:wr_group_1"]["id"] + g2_id = by_workspace["bot-1:group:wr_group_2"]["id"] + priv_id = by_workspace["bot-1"]["id"] + assert len({g1_id, g2_id, priv_id}) == 3 + + # A group-specific binding resolves only that group; a private binding + # must not authorize an unrelated, unbound group. + bound_group_inbound = channel._make_inbound( + chat_id="wr_group_1", + user_id="GaoZhiChao", + text="hello", + metadata={"aibotid": "bot-1", "chattype": "group", "chatid": "wr_group_1"}, + ) + bound_group_inbound = await channel._attach_connection_identity(bound_group_inbound) + assert bound_group_inbound.connection_id == g1_id + assert bound_group_inbound.owner_user_id == "user-1" + + legacy_group_inbound = channel._make_inbound( + chat_id="wr_legacy_group", + user_id="GaoZhiChao", + text="hello from legacy group", + metadata={"aibotid": "bot-1", "chattype": "group", "chatid": "wr_legacy_group"}, + ) + legacy_group_inbound = await channel._attach_connection_identity(legacy_group_inbound) + assert legacy_group_inbound.connection_id is None + assert legacy_group_inbound.owner_user_id is None + + await channel.send_group_notification_for_owner( + owner_user_id="user-1", + target=g1_id, + text_markdown="**group 1 notice**", + ) + await channel.send_group_notification_for_owner( + owner_user_id="user-1", + target=g2_id, + text_markdown="**group 2 notice**", + ) + assert ws_client.calls == [ + ("wr_group_1", {"msgtype": "markdown", "markdown": {"content": "**group 1 notice**"}}), + ("wr_group_2", {"msgtype": "markdown", "markdown": {"content": "**group 2 notice**"}}), + ] + + # Private binding ID or foreign owner must not be accepted as a group target. + with pytest.raises(RuntimeError, match="group notification destination"): + await channel.send_group_notification_for_owner( + owner_user_id="user-1", + target=priv_id, + text_markdown="nope", + ) + with pytest.raises(RuntimeError, match="group notification destination"): + await channel.send_group_notification_for_owner( + owner_user_id="other-user", + target=g1_id, + text_markdown="nope", + ) + assert len(ws_client.calls) == 2 + finally: + await close_engine() diff --git a/backend/tests/test_notification_groups.py b/backend/tests/test_notification_groups.py index ac49f86b35..861e64be3f 100644 --- a/backend/tests/test_notification_groups.py +++ b/backend/tests/test_notification_groups.py @@ -2,6 +2,24 @@ import pytest +from app.channels.notification_groups import notification_group_display_name + + +@pytest.mark.parametrize( + "metadata,expected", + [ + ({"notification_group_name": "Release team"}, "Release team"), + ({"notification_group_name": " 发布 小组 "}, "发布 小组"), + ({"notification_group_name": "名" * 300}, "名" * 256), + ({}, None), + ({"notification_group_name": 123}, None), + ({"notification_group_name": " "}, None), + (None, None), + ], +) +def test_group_binding_names_are_explicit_bounded_labels(metadata, expected): + assert notification_group_display_name(metadata) == expected + @pytest.mark.parametrize( "overrides,expected", @@ -26,3 +44,66 @@ def test_feishu_group_predicate(overrides, expected): value = {"provider": "feishu", "status": "connected", "workspace_id": "oc_group", "metadata": {"chat_id": "oc_group", "chat_type": "group"}, **overrides} assert is_feishu_notification_group(**value) is expected + + +@pytest.mark.parametrize( + "overrides,app_id,expected", + [ + ({}, None, "group-1"), + ({}, "app-1", "group-1"), + ({"metadata": {"chat_type": "group"}}, "app-1", "group-1"), + ({}, "app-2", None), + ({"provider": "feishu"}, None, None), + ({"status": "revoked"}, None, None), + ({"workspace_id": "app-1:c2c"}, None, None), + ({"workspace_id": ":group:group-1"}, None, None), + ({"workspace_id": "app-1:group:"}, None, None), + ({"workspace_id": " app-1:group:group-1"}, None, None), + ({"metadata": {"chat_type": "c2c", "group_openid": "group-1"}}, None, None), + ({"metadata": {"chat_type": "group", "group_openid": "other"}}, None, None), + ], +) +def test_qq_group_predicate(overrides, app_id, expected): + from app.channels.notification_groups import is_notification_group, qq_notification_group_openid + + value = { + "provider": "qq", + "status": "connected", + "workspace_id": "app-1:group:group-1", + "metadata": {"chat_type": "group", "group_openid": "group-1"}, + **overrides, + } + assert qq_notification_group_openid(**value, app_id=app_id) == expected + if app_id is None: + assert is_notification_group(**value) is (expected is not None) + + +@pytest.mark.parametrize( + "overrides,bot_id,expected", + [ + ({}, None, "wr_chat_1"), + ({}, "bot-1", "wr_chat_1"), + ({"workspace_id": "group:wr_chat_1", "metadata": {"aibotid": None, "chattype": "group", "chatid": "wr_chat_1"}}, None, "wr_chat_1"), + ({}, "bot-2", None), + ({"provider": "wechat"}, None, None), + ({"status": "revoked"}, None, None), + ({"workspace_id": "bot-1"}, None, None), + ({"workspace_id": "bot-1:group:other"}, None, None), + ({"metadata": {"aibotid": "bot-1", "chattype": "single", "chatid": "wr_chat_1"}}, None, None), + ({"metadata": {"aibotid": "bot-1", "chattype": "group", "chatid": ""}}, None, None), + ({"metadata": {"aibotid": "bot-1", "chattype": "group", "chatid": " wr_chat_1 "}}, None, None), + ], +) +def test_wecom_group_predicate(overrides, bot_id, expected): + from app.channels.notification_groups import is_notification_group, wecom_notification_group_chat_id + + value = { + "provider": "wecom", + "status": "connected", + "workspace_id": "bot-1:group:wr_chat_1", + "metadata": {"aibotid": "bot-1", "chattype": "group", "chatid": "wr_chat_1"}, + **overrides, + } + assert wecom_notification_group_chat_id(**value, bot_id=bot_id) == expected + if bot_id is None: + assert is_notification_group(**value) is (expected is not None) diff --git a/backend/tests/test_notification_text.py b/backend/tests/test_notification_text.py index 8bfcd4082e..c383a5fb4f 100644 --- a/backend/tests/test_notification_text.py +++ b/backend/tests/test_notification_text.py @@ -306,3 +306,107 @@ def test_locale_falls_back_to_the_configured_default(): assert render_notification_text(notice("run_failed", locale="zh-CN", run_status="failed"), default_locale="en-US").splitlines()[-1] == CLOSING["zh-CN"] assert render_notification_text(notice("run_failed", locale="fr-FR", run_status="failed"), default_locale="zh-CN").splitlines()[-1] == CLOSING["zh-CN"] assert render_notification_text(delivery, default_locale="fr-FR").splitlines()[-1] == CLOSING["en-US"] + + +def runtime_notice(event, *, source_kind="run", locale="en-US", run_id=RUN_ID, **payload): + base = { + "payload_version": 2, + "source_kind": source_kind, + "task_title": "Ship release notes", + "locale": locale, + } + return { + "id": "delivery-rt-1", + "source_kind": source_kind, + "source_key": run_id or "req-1", + "task_id": None, + "task_run_id": None, + "run_id": run_id, + "event": event, + "provider": "feishu", + "target": "ou_123", + "payload": {**base, **payload}, + } + + +@pytest.mark.parametrize("locale", NOTIFICATION_LOCALES) +def test_runtime_notices_render_localized_without_ids_or_links(locale): + completed = render_notification_text( + runtime_notice( + "run_completed", + locale=locale, + run_status="success", + relied_on_assumption=True, + result_summary="Drafted the changelog and verified links.", + ) + ) + failed = render_notification_text( + runtime_notice( + "run_failed", + locale=locale, + run_status="failed", + ) + ) + input_needed = render_notification_text( + runtime_notice( + "human_input_requested", + source_kind="human_input_request", + locale=locale, + run_status="success", + question="Which target branch should we tag?", + result_summary="Should not appear on human_input_requested", + ) + ) + for text in (completed, failed, input_needed): + assert_no_raw_identifiers(text) + assert RUN_ID not in text + assert "http" not in text + if locale == "en-US": + assert completed.splitlines() == [ + "Task “Ship release notes”", + "Completed. Goal met, with an assumption.", + "Result: Drafted the changelog and verified links.", + "Open DeerFlow → Conversations for details.", + ] + assert failed.splitlines() == [ + "Task “Ship release notes”", + "Failed to complete.", + "Open DeerFlow → Conversations for details.", + ] + assert input_needed.splitlines() == [ + "Task “Ship release notes”", + "Waiting for your input in the conversation.", + "Question: Which target branch should we tag?", + "Open DeerFlow → Conversations to continue.", + ] + else: + assert completed.splitlines() == [ + "任务“Ship release notes”", + "已完成。目标已达成(含假设)。", + "结果:Drafted the changelog and verified links.", + "在 DeerFlow 的对话页查看详情。", + ] + assert failed.splitlines() == [ + "任务“Ship release notes”", + "运行失败,未能完成。", + "在 DeerFlow 的对话页查看详情。", + ] + assert input_needed.splitlines() == [ + "任务“Ship release notes”", + "正在等待你在对话中确认或补充信息。", + "待确认:Which target branch should we tag?", + "在 DeerFlow 的对话页继续处理。", + ] + + +def test_runtime_human_input_question_redacts_secrets_and_skips_result_summary(): + secret = "sk-" + ("S" * 40) + delivery = runtime_notice( + "human_input_requested", + source_kind="human_input_request", + question=f"Should we rotate {secret} now?", + ) + assert not wants_result_summary(delivery) + text = render_notification_text(delivery) + assert secret not in text + assert "Question: Should we rotate [redacted] now?" in text diff --git a/backend/tests/test_qq_binding.py b/backend/tests/test_qq_binding.py index f5c987f195..1310645f9f 100644 --- a/backend/tests/test_qq_binding.py +++ b/backend/tests/test_qq_binding.py @@ -114,3 +114,23 @@ async def test_private_group_and_app_bindings_do_not_fall_back(repo): finally: await channel.stop() await other_app.stop() + + +@pytest.mark.asyncio +async def test_named_group_connections_keep_friendly_names_and_stable_ids(repo): + channel = QQChannel(MessageBus(), {"app_id": "app1", "client_secret": "fixture", "connection_repo": repo}) + channel.send = AsyncMock() + try: + for code, group, label in (("code-1", "group1", "Release team"), ("code-2", "group2", "Support team"), ("code-3", "group1", "Release team renamed")): + await repo.create_oauth_state(owner_user_id="owner", provider="qq", state=code, expires_at=datetime.now(UTC) + timedelta(minutes=5), metadata={"notification_group_name": label}) + await channel._handle_inbound(incoming(f"msg-{code}", f"/connect {code}", group)) + rows = await repo.list_connections("owner") + if code == "code-1": + group1_id = rows[0]["id"] + by_workspace = {row["workspace_id"]: row for row in rows} + assert len(rows) == 2 + assert by_workspace["app1:group:group1"]["id"] == group1_id + assert by_workspace["app1:group:group1"]["workspace_name"] == "Release team renamed" + assert by_workspace["app1:group:group2"]["workspace_name"] == "Support team" + finally: + await channel.stop() diff --git a/backend/tests/test_qq_notifications.py b/backend/tests/test_qq_notifications.py index cbc30ae2e3..82fc491a12 100644 --- a/backend/tests/test_qq_notifications.py +++ b/backend/tests/test_qq_notifications.py @@ -233,10 +233,54 @@ async def test_auth_refresh_keeps_proactive_payload_and_no_passive_budget(): assert not channel._replies -async def test_group_notification_remains_unsupported(): - async with connected() as (channel, requests): - with pytest.raises(NotImplementedError): - await channel.send_group_notification_for_owner(owner_user_id="owner", target="group-binding", text_markdown="Done") +def group_binding(**overrides): + return { + "id": "group-binding", + "owner_user_id": "owner", + "provider": "qq", + "status": "connected", + "external_account_id": "member-openid", + "workspace_id": "app1:group:group-openid", + "metadata": {"chat_type": "group", "group_openid": "group-openid"}, + **overrides, + } + + +async def test_group_notification_serializes_to_group_endpoint_without_passive_context(): + async with connected(bindings=[group_binding()]) as (channel, requests): + await channel.send_group_notification_for_owner( + owner_user_id="owner", + target="group-binding", + text_markdown="任务已完成。\nOpen DeerFlow for details.", + ) + channel._connection_repo.list_connections.assert_awaited_once_with("owner") + assert len(requests) == 2 + _token, message = requests + assert str(message.url) == "https://api.sgroup.qq.com/v2/groups/group-openid/messages" + assert json.loads(message.content) == {"msg_type": 0, "content": "任务已完成。\nOpen DeerFlow for details."} + + +@pytest.mark.parametrize( + "override", + [ + {"id": "other-binding"}, + {"owner_user_id": "other-owner"}, + {"provider": "feishu"}, + {"status": "revoked"}, + {"workspace_id": "another-app:group:group-openid"}, + {"workspace_id": "app1:c2c", "metadata": {"chat_type": "c2c"}}, + {"metadata": {"chat_type": "c2c"}}, + {"metadata": {"chat_type": "group", "group_openid": "other-group"}}, + ], +) +async def test_group_notification_refuses_invalid_or_unowned_binding_without_private_fallback(override): + async with connected(bindings=[binding(), group_binding(**override)]) as (channel, requests): + with pytest.raises(QQAPIError, match="group.*connected"): + await channel.send_group_notification_for_owner( + owner_user_id="owner", + target="group-binding", + text_markdown="Done", + ) assert requests == [] @@ -258,3 +302,39 @@ async def test_notification_cancellation_propagates_without_retry(): with pytest.raises(asyncio.CancelledError): await task assert len(requests) == 1 + + +async def test_qq_group_bind_does_not_use_raw_ids_as_display_names(): + from unittest.mock import AsyncMock + + async with connected() as (channel, _requests): + channel._connection_repo.consume_oauth_state = AsyncMock(return_value={"owner_user_id": "owner"}) + channel._connection_repo.upsert_connection = AsyncMock(return_value={"id": "bound"}) + await channel._handle_inbound( + { + "t": "GROUP_AT_MESSAGE_CREATE", + "d": { + "group_openid": "group-open-1", + "author": {"member_openid": "member-1"}, + "id": "m-1", + "content": "/connect code-1", + }, + } + ) + await channel._handle_inbound( + { + "t": "GROUP_AT_MESSAGE_CREATE", + "d": { + "group_openid": "group-open-2", + "author": {"member_openid": "member-1"}, + "id": "m-2", + "content": "/connect code-2", + }, + } + ) + calls = channel._connection_repo.upsert_connection.await_args_list + assert len(calls) == 2 + assert calls[0].kwargs["workspace_id"] == "app1:group:group-open-1" + assert calls[0].kwargs["workspace_name"] is None + assert calls[1].kwargs["workspace_id"] == "app1:group:group-open-2" + assert calls[1].kwargs["workspace_name"] is None diff --git a/backend/tests/test_runtime_im_notifications.py b/backend/tests/test_runtime_im_notifications.py new file mode 100644 index 0000000000..c9b487a79f --- /dev/null +++ b/backend/tests/test_runtime_im_notifications.py @@ -0,0 +1,709 @@ +"""Stage 2 ordinary run / goal / human_input_request IM notifications.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +import pytest +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from sqlalchemy import event, select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +import deerflow.persistence.models # noqa: F401 +from app.scheduler.notification_delivery import NotificationDeliveryWorker +from app.scheduler.service import ScheduledTaskService +from deerflow.agents.human_input import ( + HUMAN_INPUT_RESPONSE_KEY, + extract_unanswered_human_input_request, +) +from deerflow.persistence.base import Base +from deerflow.persistence.channel_connections.model import ChannelConnectionRow +from deerflow.persistence.notification_deliveries import NotificationDeliveryRepository +from deerflow.persistence.notification_deliveries.model import NotificationDeliveryRow +from deerflow.persistence.user.im_notifications import ImNotificationPreferencesRepository +from deerflow.persistence.user.model import UserRow +from deerflow.runtime import DisconnectMode, RunRecord, RunStatus + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture +async def env(tmp_path): + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/runtime_notices.db") + + @event.listens_for(engine.sync_engine, "connect") + def _fk(conn, _): + conn.execute("PRAGMA foreign_keys=ON") + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as session, session.begin(): + session.add(UserRow(id="alice", email="alice@example.com")) + for provider in ("feishu", "wecom", "wechat", "qq"): + session.add( + ChannelConnectionRow( + id=f"personal-{provider}", + owner_user_id="alice", + provider=provider, + status="connected", + external_account_id=f"{provider}-user", + workspace_id="app:c2c" if provider == "qq" else "private", + metadata_json={"chat_type": "c2c" if provider == "qq" else "p2p"}, + ) + ) + session.add( + ChannelConnectionRow( + id="qq-group-conn", + owner_user_id="alice", + provider="qq", + status="connected", + external_account_id="qq-group-member", + workspace_id="app:group:grp-1", + metadata_json={"chat_type": "group", "chat_id": "grp-1", "group_openid": "grp-1"}, + ) + ) + + preferences = ImNotificationPreferencesRepository(sessions) + deliveries = NotificationDeliveryRepository(sessions) + service = ScheduledTaskService( + task_repo=SimpleNamespace(session_factory=sessions), + task_run_repo=SimpleNamespace(session_factory=sessions), + launch_run=None, + connection_repo=object(), + notification_repo=deliveries, + poll_interval_seconds=5, + lease_seconds=30, + max_concurrent_runs=1, + ) + try: + yield sessions, preferences, deliveries, service + finally: + await engine.dispose() + + +def _make_run( + *, + run_id: str = "run-101", + thread_id: str = "thread-101", + status: RunStatus = RunStatus.success, + metadata: dict | None = None, + goal_verdict: dict | None = None, + human_input_request: dict | None = None, + error: str | None = None, +) -> RunRecord: + base_meta = {"owner": "alice", "title": "Prepare quarterly roadmap"} + if metadata: + base_meta.update(metadata) + return RunRecord( + run_id=run_id, + thread_id=thread_id, + assistant_id="lead_agent", + status=status, + on_disconnect=DisconnectMode.continue_, + user_id="alice", + metadata=base_meta, + error=error, + goal_verdict=goal_verdict, + human_input_request=human_input_request, + ) + + +def test_extract_unanswered_human_input_request_distinguishes_pending_and_answered(): + pending_ai = AIMessage( + content="", + tool_calls=[ + { + "id": "call-ask-1", + "name": "ask_clarification", + "args": { + "question": "Which region should we deploy to first?", + "clarification_type": "missing_info", + "options": ["us-east", "eu-west"], + }, + } + ], + ) + prompt_tool = ToolMessage( + content="Which region should we deploy to first?", + tool_call_id="call-ask-1", + name="ask_clarification", + artifact={ + "human_input": { + "version": 1, + "kind": "human_input_request", + "source": "ask_clarification", + "request_id": "req-ask-1", + "question": "Which region should we deploy to first?", + } + }, + ) + extracted = extract_unanswered_human_input_request([HumanMessage(content="Deploy it"), pending_ai, prompt_tool]) + assert extracted == { + "kind": "human_input_request", + "request_id": "req-ask-1", + "question": "Which region should we deploy to first?", + "source": "ask_clarification", + } + + # Answered by a structured human_input_response -> no longer pending. + answered_user = HumanMessage( + content="us-east", + additional_kwargs={ + HUMAN_INPUT_RESPONSE_KEY: { + "version": 1, + "kind": "human_input_response", + "source": "ask_clarification", + "request_id": "req-ask-1", + "response_kind": "text", + "value": "us-east", + } + }, + ) + assert extract_unanswered_human_input_request([HumanMessage(content="Deploy it"), pending_ai, prompt_tool, answered_user]) is None + + # Followed by a plain user message in the conversation -> no longer pending. + assert extract_unanswered_human_input_request([HumanMessage(content="Deploy it"), pending_ai, prompt_tool, HumanMessage(content="Use us-east")]) is None + + +@pytest.mark.anyio +async def test_ordinary_run_notifications_are_opt_in_by_default(env): + sessions, _, _, service = env + await service.handle_run_completion(_make_run(status=RunStatus.success)) + await service.handle_run_completion(_make_run(run_id="run-102", status=RunStatus.error, error="boom")) + await service.handle_run_completion( + _make_run( + run_id="run-103", + status=RunStatus.success, + human_input_request={"tool_call_id": "call-1", "question": "Confirm?", "clarification_type": "approach_choice"}, + ) + ) + async with sessions() as session: + assert (await session.scalars(select(NotificationDeliveryRow))).all() == [] + + +@pytest.mark.anyio +async def test_human_input_request_check_precedes_success_and_never_emits_false_completion(env): + sessions, preferences, _, service = env + await preferences.patch( + "alice", + {"run_completed": True, "run_failed": True, "human_input_requested": True}, + ) + record = _make_run( + run_id="run-clarify", + status=RunStatus.success, + human_input_request={ + "tool_call_id": "call-clarify-1", + "question": "Should we overwrite the existing staging table?", + "clarification_type": "risk_confirmation", + }, + ) + await service.handle_run_completion(record) + + async with sessions() as session: + rows = (await session.scalars(select(NotificationDeliveryRow))).all() + assert len(rows) == 4 + assert {row.provider for row in rows} == {"feishu", "wecom", "wechat", "qq"} + assert all(row.event == "human_input_requested" for row in rows) + assert all(row.source_kind == "human_input_request" for row in rows) + assert all(row.source_key == "thread-101:call-clarify-1" for row in rows) + assert all(row.task_id is None and row.task_run_id is None for row in rows) + assert all(row.target != "qq-group-member" for row in rows) + assert all(row.payload_json["question"] == "Should we overwrite the existing staging table?" for row in rows) + + +@pytest.mark.anyio +async def test_ordinary_run_completed_and_explicit_group_target_selection(env): + sessions, preferences, _, service = env + await preferences.patch("alice", {"run_completed": True}) + record = _make_run( + run_id="run-goal-ok", + status=RunStatus.success, + metadata={"notification_group_connection_id": "qq-group-conn"}, + goal_verdict={"satisfied": True, "relied_on_assumption": True, "reason_code": None}, + ) + await service.handle_run_completion(record) + + async with sessions() as session: + rows = (await session.scalars(select(NotificationDeliveryRow))).all() + assert len(rows) == 5 + personal = [r for r in rows if r.payload_json.get("target_type") != "group"] + group = [r for r in rows if r.payload_json.get("target_type") == "group"] + assert {r.provider for r in personal} == {"feishu", "wecom", "wechat", "qq"} + assert [(r.provider, r.target) for r in group] == [("qq", "qq-group-conn")] + assert all(r.event == "run_completed" and r.source_kind == "run" and r.source_key == "run-goal-ok" for r in rows) + assert all(r.payload_json.get("relied_on_assumption") is True for r in rows) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "reason_code", + [ + "max_continuations_reached", + "no_progress_detected", + "token_capped", + "evaluator_failed", + "blocked:needs_user_input", + ], +) +async def test_ordinary_goal_unmet_stand_down_without_human_input_card_does_not_emit_false_notice(env, reason_code): + sessions, preferences, _, service = env + await preferences.patch( + "alice", + {"run_completed": True, "run_failed": True, "human_input_requested": True}, + ) + record = _make_run( + run_id=f"run-unmet-{reason_code}", + status=RunStatus.success, + goal_verdict={"satisfied": False, "reason_code": reason_code, "relied_on_assumption": False}, + human_input_request=None, + ) + await service.handle_run_completion(record) + async with sessions() as session: + assert (await session.scalars(select(NotificationDeliveryRow))).all() == [] + + +@pytest.mark.anyio +async def test_interrupted_and_im_originated_runs_do_not_enqueue_runtime_notifications(env): + sessions, preferences, _, service = env + await preferences.patch( + "alice", + {"run_completed": True, "run_failed": True, "human_input_requested": True}, + ) + await service.handle_run_completion(_make_run(run_id="run-int", status=RunStatus.interrupted)) + await service.handle_run_completion( + _make_run( + run_id="run-im", + status=RunStatus.success, + metadata={"deerflow_origin": {"kind": "im_channel", "provider": "feishu"}}, + ) + ) + await service.handle_run_completion( + _make_run( + run_id="run-err", + status=RunStatus.error, + error="model timeout", + ) + ) + async with sessions() as session: + rows = (await session.scalars(select(NotificationDeliveryRow))).all() + assert {r.run_id for r in rows} == {"run-err"} + assert all(r.event == "run_failed" for r in rows) + + +@pytest.mark.anyio +async def test_worker_rechecks_human_input_pending_and_drops_resolved_requests(env): + sessions, preferences, deliveries, service = env + await preferences.patch( + "alice", + {"human_input_requested": True, "wecom": False, "wechat": False, "qq": False}, + ) + await service.handle_run_completion( + _make_run( + run_id="run-stale-ask", + thread_id="thread-stale-ask", + status=RunStatus.success, + human_input_request={"tool_call_id": "call-stale-1", "question": "Still need this?"}, + ) + ) + (claimed,) = await deliveries.claim_due_deliveries(now=datetime.now(UTC), limit=10) + sent_messages = [] + + class FakeChannel: + is_running = True + + async def send_notification_for_owner(self, **kwargs): + sent_messages.append(kwargs) + + async def already_resolved(thread_id: str, tool_call_id: str | None) -> bool: + assert (thread_id, tool_call_id) == ("thread-stale-ask", "call-stale-1") + return False + + worker = NotificationDeliveryWorker( + delivery_repo=deliveries, + resolve_channel=lambda _: FakeChannel(), + resolve_notification_preferences=preferences.get, + resolve_notification_event_preferences=preferences.get_event_preferences, + resolve_human_input_pending=already_resolved, + ) + await worker._deliver(claimed) + assert sent_messages == [] + async with sessions() as session: + row = await session.get(NotificationDeliveryRow, claimed["id"]) + assert (row.status, row.last_error) == ("failed", "human_input_request_already_resolved") + + # Still-pending request delivers normally. + await service.handle_run_completion( + _make_run( + run_id="run-live-ask", + thread_id="thread-live-ask", + status=RunStatus.success, + human_input_request={"tool_call_id": "call-live-1", "question": "Approve production deploy?"}, + ) + ) + (claimed_live,) = await deliveries.claim_due_deliveries(now=datetime.now(UTC), limit=10) + + async def still_pending(thread_id: str, tool_call_id: str | None) -> bool: + assert (thread_id, tool_call_id) == ("thread-live-ask", "call-live-1") + return True + + worker_live = NotificationDeliveryWorker( + delivery_repo=deliveries, + resolve_channel=lambda _: FakeChannel(), + resolve_notification_preferences=preferences.get, + resolve_notification_event_preferences=preferences.get_event_preferences, + resolve_human_input_pending=still_pending, + ) + await worker_live._deliver(claimed_live) + assert len(sent_messages) == 1 + assert "Question: Approve production deploy?" in sent_messages[0]["text_markdown"] + async with sessions() as session: + row = await session.get(NotificationDeliveryRow, claimed_live["id"]) + assert row.status == "sent" + + +@pytest.mark.anyio +async def test_unpersisted_terminal_status_does_not_emit_premature_success_notification(env): + from deerflow.persistence.run.model import RunRow + from deerflow.runtime.runs.manager import RunManager + + sessions, preferences, _, service = env + await preferences.patch( + "alice", + {"run_completed": True, "human_input_requested": True}, + ) + + # Case 1: in-memory RunRecord explicitly marked terminal_status_persisted=False. + unpersisted_record = _make_run(run_id="run-unpersisted-flag", status=RunStatus.success) + unpersisted_record.terminal_status_persisted = False + await service.handle_run_completion(unpersisted_record) + + # Case 2: durable RunRow still says "running" because terminal persistence failed. + async with sessions() as session, session.begin(): + session.add( + RunRow( + run_id="run-still-running-in-db", + thread_id="thread-101", + assistant_id="lead_agent", + user_id="alice", + status="running", + ) + ) + stale_db_record = _make_run(run_id="run-still-running-in-db", status=RunStatus.success) + await service.handle_run_completion(stale_db_record) + + async with sessions() as session: + assert (await session.scalars(select(NotificationDeliveryRow))).all() == [] + + # Case 3: RunManager.set_status marks terminal_status_persisted=False when store write fails. + class FailingRunStore: + async def put(self, run_id, **kwargs): + return None + + async def update_status(self, run_id, status, **kwargs): + raise RuntimeError("db write failed") + + manager = RunManager(store=FailingRunStore()) + created = await manager.create(thread_id="thread-101", assistant_id="lead_agent", user_id="alice") + await manager.set_status(created.run_id, RunStatus.success, persist=True) + assert created.terminal_status_persisted is False + + +@pytest.mark.anyio +@pytest.mark.parametrize("completion_recovers", [False, True]) +@pytest.mark.parametrize("outcome", ["completed", "human_input", "failed"]) +async def test_worker_notifies_only_after_terminal_status_is_confirmed(env, completion_recovers, outcome): + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from langgraph.checkpoint.memory import InMemorySaver + from langgraph.graph import END, START, StateGraph + + from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware + from deerflow.agents.thread_state import ThreadState + from deerflow.runtime.events.store.memory import MemoryRunEventStore + from deerflow.runtime.runs.manager import RunManager + from deerflow.runtime.runs.store.memory import MemoryRunStore + from deerflow.runtime.runs.worker import RunContext, run_agent + + sessions, preferences, _, service = env + await preferences.patch( + "alice", + {"run_completed": True, "run_failed": True, "human_input_requested": True, "wecom": False, "wechat": False, "qq": False}, + ) + + class FailingTerminalStore(MemoryRunStore): + async def update_status(self, run_id, status, **kwargs): + if status not in {"pending", "running"}: + raise RuntimeError("terminal status write unavailable") + return await super().update_status(run_id, status, **kwargs) + + async def update_run_completion(self, run_id, *, status, **kwargs): + if not completion_recovers: + raise RuntimeError("completion fallback unavailable") + return await super().update_run_completion(run_id, status=status, **kwargs) + + store = FailingTerminalStore() + manager = RunManager(store=store) + record = await manager.create(f"thread-{outcome}-{completion_recovers}", user_id="alice") + checkpointer = InMemorySaver() + + def fixed_node(state): + if outcome == "failed": + raise RuntimeError("fixed execution failure") + if outcome == "human_input": + call = {"id": "call-confirm", "name": "ask_clarification", "args": {"question": "Which environment?"}, "type": "tool_call"} + request = ClarificationMiddleware()._handle_clarification(SimpleNamespace(tool_call=call)) + return {"messages": [AIMessage(content="", tool_calls=[call]), *request.update["messages"]]} + return {"messages": [AIMessage(content="Fixed completed answer")]} + + builder = StateGraph(ThreadState) + builder.add_node("fixed", fixed_node) + builder.add_edge(START, "fixed") + builder.add_edge("fixed", END) + graph = builder.compile(checkpointer=checkpointer) + completion_hook = AsyncMock(wraps=service.handle_run_completion) + await run_agent( + SimpleNamespace(publish=AsyncMock(), publish_end=AsyncMock(), cleanup=AsyncMock()), + manager, + record, + ctx=RunContext(checkpointer=checkpointer, event_store=MemoryRunEventStore(), on_run_completed=completion_hook), + agent_factory=lambda *, config: graph, + graph_input={"messages": [HumanMessage(content="Run the fixed task")]}, + config={"configurable": {"thread_id": record.thread_id}}, + ) + + stored = await store.get(record.run_id, user_id="alice") + async with sessions() as session: + rows = (await session.scalars(select(NotificationDeliveryRow))).all() + assert record.terminal_status_persisted is completion_recovers + if completion_recovers: + assert stored["status"] == record.status.value + completion_hook.assert_awaited_once_with(record) + expected_event = {"completed": "run_completed", "human_input": "human_input_requested", "failed": "run_failed"}[outcome] + assert [(row.run_id, row.event) for row in rows] == [(record.run_id, expected_event)] + else: + assert stored["status"] == "running" + completion_hook.assert_not_awaited() + assert rows == [] + + +@pytest.mark.anyio +async def test_sqlite_lock_owner_materializes_rows_and_serializes_opt_out(env): + import asyncio + + from deerflow.persistence.user.im_notifications import ImNotificationPreferenceRow + + sessions, preferences, _, service = env + # Before any patch, get_in_session(lock_owner=True) on SQLite materializes the 4 provider rows + # so SQLite acquires an immediate write lock on the transaction's first statement. + async with sessions() as session, session.begin(): + locked = await ImNotificationPreferencesRepository.get_in_session( + session, + "bob", + lock_owner=True, + include_events=True, + ) + assert locked["feishu"] is True + assert locked["run_completed"] is False + async with sessions() as session: + bob_rows = (await session.scalars(select(ImNotificationPreferenceRow).where(ImNotificationPreferenceRow.owner_user_id == "bob"))).all() + assert len(bob_rows) == 4 + + await preferences.patch("alice", {"run_completed": True}) + await asyncio.gather( + service.handle_run_completion(_make_run(run_id="run-concurrent-optout", status=RunStatus.success)), + preferences.patch("alice", {"run_completed": False}), + ) + # After opt-out completes, re-enabling must never leave a pending row from before opt-out + # if the opt-out ran after enqueue, nor enqueue a row if opt-out ran before enqueue. + final_events = await preferences.get_event_preferences("alice") + assert final_events["run_completed"] is False + async with sessions() as session: + pending = ( + await session.scalars( + select(NotificationDeliveryRow).where( + NotificationDeliveryRow.run_id == "run-concurrent-optout", + NotificationDeliveryRow.status == "pending", + ) + ) + ).all() + assert pending == [] + + +@pytest.mark.anyio +@pytest.mark.parametrize("mode", ["full", "delta"]) +async def test_gateway_notification_delivery_human_input_closure_works_in_both_checkpoint_modes( + env, + mode, + monkeypatch, +): + from fastapi import FastAPI + from langgraph.checkpoint.memory import InMemorySaver + from langgraph.graph import StateGraph + from langgraph.store.memory import InMemoryStore + + import app.channels.service as channel_service_mod + import app.gateway.app as gateway_app_mod + import app.gateway.deps as gateway_deps + import app.gateway.services as gateway_services + from deerflow.agents.thread_state import get_thread_state_schema + from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore + from deerflow.runtime.checkpoint_mode import inject_checkpoint_mode + + sessions, preferences, deliveries, service = env + await preferences.patch( + "alice", + {"human_input_requested": True, "wecom": False, "wechat": False, "qq": False}, + ) + + checkpointer = InMemorySaver() + store = InMemoryStore() + thread_store = MemoryThreadMetaStore(store) + await thread_store.create("thread-gw-ask", assistant_id="lead_agent", user_id="alice") + + state_schema = get_thread_state_schema(mode) + + def _noop_node(state): + return {} + + builder = StateGraph(state_schema) + builder.add_node("agent", _noop_node) + builder.set_entry_point("agent") + builder.set_finish_point("agent") + graph = builder.compile(checkpointer=checkpointer) + + monkeypatch.setattr( + gateway_services, + "resolve_agent_factory", + lambda assistant_id=None: lambda config: graph, + ) + monkeypatch.setattr(gateway_deps, "get_config", lambda: SimpleNamespace(models=[])) + + pending_ai = AIMessage( + content="", + id="ai-gw-1", + tool_calls=[ + { + "id": "call-gw-1", + "name": "ask_clarification", + "args": {"question": "Proceed with migration?", "clarification_type": "confirmation"}, + } + ], + ) + prompt_tool = ToolMessage( + content="Proceed with migration?", + id="tool-gw-1", + tool_call_id="call-gw-1", + name="ask_clarification", + artifact={ + "human_input": { + "version": 1, + "kind": "human_input_request", + "source": "ask_clarification", + "request_id": "req-gw-1", + "question": "Proceed with migration?", + } + }, + ) + config = {"configurable": {"thread_id": "thread-gw-ask", "checkpoint_ns": ""}} + inject_checkpoint_mode(config, mode) + await graph.ainvoke( + {"messages": [HumanMessage(content="Start", id="h-gw-1"), pending_ai, prompt_tool]}, + config, + ) + + sent_messages = [] + + class FakeChannel: + is_running = True + + async def send_notification_for_owner(self, **kwargs): + sent_messages.append(kwargs) + + fake_channel_service = SimpleNamespace( + get_channel=lambda provider: FakeChannel() if provider == "feishu" else None, + notification_excluded_providers=lambda: frozenset(), + ) + monkeypatch.setattr(channel_service_mod, "get_channel_service", lambda: fake_channel_service) + monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: sessions) + + app = FastAPI() + app.state.scheduled_task_service = service + app.state.checkpointer = checkpointer + app.state.store = store + app.state.thread_store = thread_store + app.state.run_event_store = SimpleNamespace() + app.state.checkpoint_channel_mode = mode + app.state.checkpoint_snapshot_frequency = None + + startup_config = SimpleNamespace( + scheduler=SimpleNamespace(poll_interval_seconds=60), + channel_connections=SimpleNamespace(notification_locale="en-US"), + ) + conn_repo = SimpleNamespace( + list_connections=lambda user_id: [ + { + "id": "personal-feishu", + "owner_user_id": user_id, + "provider": "feishu", + "status": "connected", + "external_account_id": "feishu-user", + } + ] + ) + + await gateway_app_mod._start_scheduled_task_notification_delivery( + app, + startup_config, + deliveries, + conn_repo, + ) + worker = app.state.notification_delivery_worker + try: + # 1. Unanswered request in checkpoint -> gateway closure returns True and delivers. + await service.handle_run_completion( + _make_run( + run_id=f"run-gw-live-{mode}", + thread_id="thread-gw-ask", + status=RunStatus.success, + human_input_request={"request_id": "req-gw-1", "question": "Proceed with migration?"}, + ) + ) + await worker.run_once(now=datetime.now(UTC)) + assert len(sent_messages) == 1 + + # 2. User answers in checkpoint -> gateway closure returns False and marks terminal failed. + answer_msg = HumanMessage( + content="Yes", + id="h-gw-2", + additional_kwargs={ + HUMAN_INPUT_RESPONSE_KEY: { + "version": 1, + "kind": "human_input_response", + "request_id": "req-gw-1", + } + }, + ) + await graph.ainvoke({"messages": [answer_msg]}, config) + await service.handle_run_completion( + _make_run( + run_id=f"run-gw-answered-{mode}", + thread_id="thread-gw-ask", + status=RunStatus.success, + human_input_request={"request_id": "req-gw-2", "question": "Proceed with migration?"}, + ) + ) + await worker.run_once(now=datetime.now(UTC)) + assert len(sent_messages) == 1 + async with sessions() as session: + rows = (await session.scalars(select(NotificationDeliveryRow).where(NotificationDeliveryRow.run_id == f"run-gw-answered-{mode}"))).all() + assert len(rows) == 1 + assert (rows[0].status, rows[0].last_error) == ("failed", "human_input_request_already_resolved") + finally: + await worker.stop() diff --git a/backend/tests/test_scheduled_task_group_targets.py b/backend/tests/test_scheduled_task_group_targets.py index 29d7c49cdf..960ccb2bb6 100644 --- a/backend/tests/test_scheduled_task_group_targets.py +++ b/backend/tests/test_scheduled_task_group_targets.py @@ -183,3 +183,23 @@ async def test_group_selection_requires_enabled_channel_connections(env): response = await env.client.post("/api/scheduled-tasks", json=_body(**{FIELD: group["id"]})) assert response.status_code == 422, response.text assert response.json()["detail"]["code"] == "invalid_notification_group" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("provider", "workspace_id", "metadata", "allowed"), + [ + ("qq", "app1:group:group-openid", {"chat_type": "group", "group_openid": "group-openid"}, True), + ("wecom", "bot1:group:wr_chat_1", {"aibotid": "bot1", "chattype": "group", "chatid": "wr_chat_1"}, True), + ("wechat", "group:wx_chat_1", {"chat_type": "group", "chat_id": "group:wx_chat_1"}, False), + ], +) +async def test_qq_and_wecom_group_destinations_round_trip_and_wechat_is_rejected(env, provider, workspace_id, metadata, allowed): + group = await _bind(env, provider=provider, workspace_id=workspace_id, metadata=metadata) + response = await env.client.post("/api/scheduled-tasks", json=_body(**{FIELD: group["id"]})) + if allowed: + assert response.status_code == 200, response.text + assert response.json()[FIELD] == group["id"] + else: + assert response.status_code == 422, response.text + assert response.json()["detail"]["code"] == "invalid_notification_group" diff --git a/backend/tests/test_wecom_group_bindings.py b/backend/tests/test_wecom_group_bindings.py new file mode 100644 index 0000000000..00476c7324 --- /dev/null +++ b/backend/tests/test_wecom_group_bindings.py @@ -0,0 +1,69 @@ +"""WeCom group binding names and legacy identity boundaries, without provider I/O.""" + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio + +from app.channels.connection_identity import lookup_thread_id +from app.channels.message_bus import InboundMessage, MessageBus +from app.channels.wecom import WeComChannel +from deerflow.persistence.channel_connections import ChannelConnectionRepository +from deerflow.persistence.engine import close_engine, get_session_factory, init_engine + + +@pytest_asyncio.fixture +async def repo(tmp_path): + await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'wecom.db'}", sqlite_dir=str(tmp_path)) + try: + yield ChannelConnectionRepository(get_session_factory()) + finally: + await close_engine() + + +def inbound(group="wr-group", bot="bot-1"): + return InboundMessage(channel_name="wecom", chat_id="alice", user_id="alice", text="group follow-up", topic_id="alice", metadata={"aibotid": bot, "chattype": "group", "chatid": group}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("private_metadata", [{"aibotid": "bot-1", "chattype": "single"}, {"aibotid": "bot-1"}, {}]) +async def test_private_binding_does_not_authorize_an_unbound_group(repo, private_metadata): + await repo.upsert_connection(owner_user_id="owner", provider="wecom", external_account_id="alice", workspace_id="bot-1", metadata=private_metadata) + channel = WeComChannel(MessageBus(), {"connection_repo": repo}) + message = await channel._attach_connection_identity(inbound()) + assert message.connection_id is None + assert message.owner_user_id is None + + +@pytest.mark.asyncio +async def test_legacy_group_binding_retains_owner_and_existing_thread(repo): + legacy = await repo.upsert_connection(owner_user_id="owner", provider="wecom", external_account_id="alice", workspace_id="bot-1", metadata={"aibotid": "bot-1", "chattype": "group"}) + await repo.set_thread_id(connection_id=legacy["id"], owner_user_id="owner", provider="wecom", external_conversation_id="alice", external_topic_id="alice", thread_id="existing-thread") + channel = WeComChannel(MessageBus(), {"connection_repo": repo}) + message = await channel._attach_connection_identity(inbound()) + assert message.connection_id == legacy["id"] + assert message.owner_user_id == "owner" + assert await lookup_thread_id(message, repo=repo, store=None) == "existing-thread" + other_bot = await channel._attach_connection_identity(inbound(bot="bot-2")) + assert other_bot.connection_id is None + await repo.disconnect_connection(connection_id=legacy["id"], owner_user_id="owner") + revoked = await channel._attach_connection_identity(inbound()) + assert revoked.connection_id is None + + +@pytest.mark.asyncio +async def test_named_group_connect_records_a_human_label_without_exposing_chatid(repo): + channel = WeComChannel(MessageBus(), {"connection_repo": repo}) + channel._ws_client = SimpleNamespace(reply=AsyncMock(), reply_stream=AsyncMock()) + for code, group, label in (("code-1", "wr-group-1", "Release team"), ("code-2", "wr-group-2", "Support team")): + await repo.create_oauth_state(owner_user_id="owner", provider="wecom", state=code, expires_at=datetime.now(UTC) + timedelta(minutes=5), metadata={"notification_group_name": label}) + await channel._publish_ws_inbound( + {"body": {"aibotid": "bot-1", "chattype": "group", "chatid": group, "msgid": f"msg-{code}", "from": {"userid": "alice"}, "text": {"content": f"/connect {code}"}}}, + f"/connect {code}", + ) + rows = await repo.list_connections("owner") + assert len(rows) == 2 + assert {row["workspace_name"] for row in rows} == {"Release team", "Support team"} + assert {row["workspace_id"] for row in rows} == {"bot-1:group:wr-group-1", "bot-1:group:wr-group-2"} diff --git a/frontend/src/components/workspace/scheduled-tasks/bind-feishu-group.tsx b/frontend/src/components/workspace/scheduled-tasks/bind-feishu-group.tsx index 2550706890..2e839641cc 100644 --- a/frontend/src/components/workspace/scheduled-tasks/bind-feishu-group.tsx +++ b/frontend/src/components/workspace/scheduled-tasks/bind-feishu-group.tsx @@ -1,35 +1,90 @@ "use client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; +import { useId, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { connectChannelProvider } from "@/core/channels/api"; import { channelConnectionsQueryKey } from "@/core/channels/hooks"; +import type { ChannelConnectResponse } from "@/core/channels/types"; import { useI18n } from "@/core/i18n/hooks"; import { fill } from "./shared"; +type GroupChannelProvider = "feishu" | "wecom" | "qq"; + +interface CachedGroupBinding { + data: ChannelConnectResponse; + expiresAt: number; + groupName?: string; +} + /** Group binding needs its own command; a connected private chat is not completion. */ export function BindFeishuGroup() { const { t, locale } = useI18n(); const labels = t.scheduledTasks.notificationGroup; const queryClient = useQueryClient(); const [visible, setVisible] = useState(false); + const [provider, setProvider] = useState("feishu"); + const [namesByProvider, setNamesByProvider] = useState< + Partial> + >({}); + const groupNameId = useId(); + const [cachedByProvider, setCachedByProvider] = useState< + Partial> + >({}); const [copied, setCopied] = useState(false); const [copyFailed, setCopyFailed] = useState(false); const [refreshing, setRefreshing] = useState(false); - const [expiresAt, setExpiresAt] = useState(null); const binding = useMutation({ - mutationFn: () => connectChannelProvider("feishu"), - onSuccess: (result) => { + mutationFn: ({ + targetProvider, + groupName, + }: { + targetProvider: GroupChannelProvider; + groupName?: string; + }) => + groupName + ? connectChannelProvider(targetProvider, { groupName }) + : connectChannelProvider(targetProvider), + onSuccess: (result, { targetProvider, groupName }) => { setCopied(false); setCopyFailed(false); - setExpiresAt(Date.now() + result.expires_in * 1000); + setCachedByProvider((prev) => ({ + ...prev, + [targetProvider]: { + data: result, + expiresAt: Date.now() + result.expires_in * 1000, + groupName, + }, + })); }, }); - const command = binding.data ? `/connect ${binding.data.code}` : ""; + const currentCached = cachedByProvider[provider]; + const needsName = provider !== "feishu"; + const groupName = (namesByProvider[provider] ?? "").trim(); + const command = + currentCached && (!needsName || currentCached.groupName === groupName) + ? `/connect ${currentCached.data.code}` + : ""; + const expiresAt = currentCached?.expiresAt ?? null; + const startBind = (targetProvider: GroupChannelProvider) => { + setProvider(targetProvider); + setVisible(true); + setCopied(false); + setCopyFailed(false); + const cached = cachedByProvider[targetProvider]; + const name = namesByProvider[targetProvider]?.trim(); + if ( + (!cached || + cached.expiresAt <= Date.now() || + cached.groupName !== name) && + (targetProvider === "feishu" || name) + ) { + binding.mutate({ targetProvider, groupName: name }); + } + }; const copy = async () => { try { await navigator.clipboard.writeText(command); @@ -58,13 +113,28 @@ export function BindFeishuGroup() { variant="outline" size="sm" disabled={binding.isPending} - onClick={() => { - setVisible(true); - if (!binding.data) binding.mutate(); - }} + onClick={() => startBind("feishu")} > {labels.bind} + + diff --git a/frontend/src/components/workspace/scheduled-tasks/notification-group.tsx b/frontend/src/components/workspace/scheduled-tasks/notification-group.tsx index 5c8dcd6aeb..10bcd8efcc 100644 --- a/frontend/src/components/workspace/scheduled-tasks/notification-group.tsx +++ b/frontend/src/components/workspace/scheduled-tasks/notification-group.tsx @@ -12,8 +12,8 @@ import { useChannelConnections } from "@/core/channels/hooks"; import { useScheduledTasksFeature } from "@/core/features/hooks"; import { useI18n } from "@/core/i18n/hooks"; import { - feishuNotificationGroups, notificationGroupName, + notificationGroups, } from "@/core/scheduled-tasks/notification-groups"; import { BindFeishuGroup } from "./bind-feishu-group"; @@ -69,7 +69,7 @@ function EnabledNotificationGroupSelect({ const { t } = useI18n(); const labels = t.scheduledTasks.notificationGroup; const { connections, isLoading, error } = useChannelConnections(); - const groups = feishuNotificationGroups(connections); + const groups = notificationGroups(connections); const selected = groups.find((group) => group.id === value); const selectedMissing = Boolean(value) && !selected; const unnamed = groups.some((group) => !notificationGroupName(group)); @@ -138,7 +138,7 @@ export function NotificationGroupSummary({ const { t } = useI18n(); const labels = t.scheduledTasks.notificationGroup; const { connections, isLoading, error } = useChannelConnections(); - const group = feishuNotificationGroups(connections).find( + const group = notificationGroups(connections).find( (connection) => connection.id === connectionId, ); const name = notificationGroupName(group); diff --git a/frontend/src/components/workspace/settings/channel-notification-settings.tsx b/frontend/src/components/workspace/settings/channel-notification-settings.tsx index cc8e84be8f..1856653d03 100644 --- a/frontend/src/components/workspace/settings/channel-notification-settings.tsx +++ b/frontend/src/components/workspace/settings/channel-notification-settings.tsx @@ -10,9 +10,10 @@ import { useAuth } from "@/core/auth/AuthProvider"; import { listChannelProviders } from "@/core/channels/api"; import { loadChannelNotificationPreferences, + notificationEventIds, notificationProviderIds, updateChannelNotificationPreference, - type NotificationProviderId, + type NotificationPreferenceKey, } from "@/core/channels/notification-preferences"; import { useI18n } from "@/core/i18n/hooks"; import { isStaticWebsiteOnly } from "@/core/static-mode"; @@ -51,26 +52,26 @@ function UserChannelNotifications({ userId }: { userId: string }) { retry: false, gcTime: 0, }); - const [saving, setSaving] = useState(null); + const [saving, setSaving] = useState(null); const [saveFailed, setSaveFailed] = useState(false); const saveRequest = useRef(null); useEffect(() => () => saveRequest.current?.abort(), []); - async function save(provider: NotificationProviderId, enabled: boolean) { + async function save(key: NotificationPreferenceKey, enabled: boolean) { if ( saveRequest.current || !query.data || - query.data.preferences[provider] === enabled + Boolean(query.data.preferences[key]) === enabled ) return; const request = new AbortController(); saveRequest.current = request; - setSaving(provider); + setSaving(key); setSaveFailed(false); try { const preferences = await updateChannelNotificationPreference( userId, - provider, + key, enabled, request.signal, ); @@ -149,6 +150,33 @@ function UserChannelNotifications({ userId }: { userId: string }) { /> ))} + {providers.length > 0 && ( +
+
+

{text.eventsTitle}

+

+ {text.eventsDescription} +

+
+ {notificationEventIds.map((event) => ( +
+ + void save(event, enabled)} + /> +
+ ))} +
+ )} {saving && (

{text.saving} diff --git a/frontend/src/core/channels/api.ts b/frontend/src/core/channels/api.ts index 411071b460..8734e999d8 100644 --- a/frontend/src/core/channels/api.ts +++ b/frontend/src/core/channels/api.ts @@ -47,10 +47,17 @@ export async function listChannelConnections(): Promise { export async function connectChannelProvider( provider: ChannelProviderId, + options?: { groupName?: string }, ): Promise { const response = await fetch( channelsUrl(`/${encodeURIComponent(provider)}/connect`), - { method: "POST" }, + options?.groupName + ? { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ group_name: options.groupName }), + } + : { method: "POST" }, ); if (!response.ok) { await throwGatewayApiError( diff --git a/frontend/src/core/channels/notification-preferences.ts b/frontend/src/core/channels/notification-preferences.ts index 49171fad18..06f0f8b1ec 100644 --- a/frontend/src/core/channels/notification-preferences.ts +++ b/frontend/src/core/channels/notification-preferences.ts @@ -12,11 +12,24 @@ export const notificationProviderIds = [ ] as const; export type NotificationProviderId = (typeof notificationProviderIds)[number]; +export const notificationEventIds = [ + "run_completed", + "run_failed", + "human_input_requested", +] as const; +export type NotificationEventId = (typeof notificationEventIds)[number]; +export type NotificationPreferenceKey = + | NotificationProviderId + | NotificationEventId; + const preferencesSchema = z.object({ feishu: z.boolean(), wecom: z.boolean(), wechat: z.boolean(), qq: z.boolean(), + run_completed: z.boolean().optional(), + run_failed: z.boolean().optional(), + human_input_requested: z.boolean().optional(), }); export type ChannelNotificationPreferences = z.infer; @@ -54,9 +67,9 @@ export function loadChannelNotificationPreferences( export function updateChannelNotificationPreference( userId: string, - provider: NotificationProviderId, + key: NotificationPreferenceKey, enabled: boolean, signal: AbortSignal, ) { - return requestPreferences(userId, signal, { [provider]: enabled }); + return requestPreferences(userId, signal, { [key]: enabled }); } diff --git a/frontend/src/core/channels/types.ts b/frontend/src/core/channels/types.ts index 31d6fa0e1b..2d07818b52 100644 --- a/frontend/src/core/channels/types.ts +++ b/frontend/src/core/channels/types.ts @@ -25,6 +25,7 @@ export interface ChannelProvider { * is a capability, not a notification preference. Older backends may omit it. */ proactive_notifications?: boolean; + proactive_group_notifications?: boolean; } export interface ChannelProvidersResponse { diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index ff495a6900..106957245b 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -1037,6 +1037,11 @@ export const enUS: Translations = { "This task has a saved group destination, but group notifications are unavailable on this server.", clearSaved: "Clear group notification", bind: "Bind a Feishu group", + bindWecom: "Bind a WeCom group", + bindQq: "Bind a QQ group", + name: "Group name", + nameHint: + "Name this group so you can recognize it when choosing a notification destination. Rebind a group to change its name.", refresh: "Refresh group list", bindInstructions: "Add the bot to the group, then mention the bot and send this command in that group. Refresh the group list after binding.", @@ -1049,19 +1054,19 @@ export const enUS: Translations = { copyFailed: "Could not copy. Select and copy the command above.", newCode: "Generate a new code", close: "Close binding instructions", - label: "Also notify a Feishu group (optional)", - detailLabel: "Additional Feishu group", + label: "Also notify a group (optional)", + detailLabel: "Additional group", none: "No additional group", personalHint: "Your personal notifications stay unchanged. The selected group also receives task updates.", - emptyHint: "Bind a Feishu group below, then refresh the list.", + emptyHint: "Bind a group below, then refresh the list.", loading: "Loading groups…", unavailable: "Selected group unavailable", unavailableHint: "The selected group is no longer available. Keep it, remove it, or choose another group.", unnamed: "Group name unavailable", unnamedHint: - "Check the app's group-information permission, then bind the group again to load its name.", + "Bind the group again with a recognizable name before selecting it.", loadFailed: "Could not load groups. Your selection is kept; you can save other changes or remove it.", }, @@ -2414,6 +2419,15 @@ export const enUS: Translations = { }, toggleLabel: (provider: string) => `${provider} scheduled task notifications`, + eventsTitle: "Task & goal run notifications", + eventsDescription: + "Optional IM reminders for ordinary web or background task runs and goals on your enabled apps above (off by default).", + events: { + run_completed: "Task or goal completed", + run_failed: "Task or goal failed", + human_input_requested: "Needs your input", + }, + eventToggleLabel: (event: string) => `${event} notifications`, loading: "Loading notification preferences…", saving: "Saving…", loadFailed: diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index f1b19e3458..7c4ea2b141 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -843,6 +843,10 @@ export interface Translations { savedUnavailable: string; clearSaved: string; bind: string; + bindWecom: string; + bindQq: string; + name: string; + nameHint: string; refresh: string; bindInstructions: string; generating: string; @@ -1947,6 +1951,14 @@ export interface Translations { qq: string; }; toggleLabel: (provider: string) => string; + eventsTitle: string; + eventsDescription: string; + events: { + run_completed: string; + run_failed: string; + human_input_requested: string; + }; + eventToggleLabel: (event: string) => string; loading: string; saving: string; loadFailed: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 404c4114bf..b6ba00421e 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -973,6 +973,11 @@ export const zhCN: Translations = { savedUnavailable: "已有群通知配置,当前服务未启用群通知。", clearSaved: "清除群通知", bind: "绑定飞书群", + bindWecom: "绑定企业微信群", + bindQq: "绑定 QQ 群", + name: "群名称", + nameHint: + "为这个群取一个容易识别的名称,便于选择通知目标。重新绑定可修改群名称。", refresh: "刷新群列表", bindInstructions: "先把机器人加入群聊,再在群里 @机器人 并发送这条命令。完成绑定后刷新群列表。", @@ -985,16 +990,16 @@ export const zhCN: Translations = { copyFailed: "复制失败,请选中上方命令手动复制。", newCode: "生成新绑定码", close: "关闭绑定说明", - label: "额外通知到飞书群(可选)", - detailLabel: "额外通知到飞书群", + label: "额外通知到群聊(可选)", + detailLabel: "额外通知到群聊", none: "仅通知本人", personalHint: "你现有的个人通知保持不变,所选群也会收到任务更新。", - emptyHint: "先在下方绑定飞书群,再刷新群列表。", + emptyHint: "先在下方绑定群聊,再刷新群列表。", loading: "正在加载群列表…", unavailable: "已选群暂不可用", unavailableHint: "已选群当前不可用。可以保留、移除或选择其他群。", unnamed: "群名称不可用", - unnamedHint: "请检查应用的群信息读取权限,再重新绑定该群以获取群名称。", + unnamedHint: "请重新绑定该群并设置容易识别的名称,再选择通知目标。", loadFailed: "无法加载群列表,已保留当前选择。仍可保存其他修改或移除所选群。", }, @@ -2266,6 +2271,15 @@ export const zhCN: Translations = { qq: "QQ", }, toggleLabel: (provider: string) => `${provider}定时任务通知`, + eventsTitle: "普通任务与目标提醒", + eventsDescription: + "可选:通过上方已开启的应用接收普通网页或后台任务、目标的运行提醒(默认关闭)。", + events: { + run_completed: "任务或目标已完成", + run_failed: "任务或目标运行失败", + human_input_requested: "需要你参与确认", + }, + eventToggleLabel: (event: string) => `${event}通知`, loading: "正在加载通知偏好…", saving: "正在保存…", loadFailed: "无法加载通知偏好,请重试。", diff --git a/frontend/src/core/scheduled-tasks/notification-groups.ts b/frontend/src/core/scheduled-tasks/notification-groups.ts index fb8baaed27..bea621ea9a 100644 --- a/frontend/src/core/scheduled-tasks/notification-groups.ts +++ b/frontend/src/core/scheduled-tasks/notification-groups.ts @@ -1,24 +1,130 @@ import type { ChannelConnection } from "@/core/channels/types"; +function isNonEmptyTrimmedString(value: unknown): value is string { + return ( + typeof value === "string" && + value.trim().length > 0 && + value === value.trim() + ); +} + +export function isFeishuNotificationGroup( + connection: ChannelConnection, +): boolean { + return ( + connection.provider === "feishu" && + connection.status === "connected" && + connection.metadata?.chat_type === "group" && + typeof connection.workspace_id === "string" && + connection.workspace_id.trim().length > 0 && + connection.metadata?.chat_id === connection.workspace_id + ); +} + +export function isQqNotificationGroup(connection: ChannelConnection): boolean { + if ( + connection.provider !== "qq" || + connection.status !== "connected" || + connection.metadata?.chat_type !== "group" || + typeof connection.workspace_id !== "string" + ) { + return false; + } + const sepIndex = connection.workspace_id.indexOf(":group:"); + if (sepIndex <= 0) return false; + const appId = connection.workspace_id.slice(0, sepIndex); + const groupOpenid = connection.workspace_id.slice( + sepIndex + ":group:".length, + ); + if ( + !isNonEmptyTrimmedString(appId) || + !isNonEmptyTrimmedString(groupOpenid) + ) { + return false; + } + const metaOpenid = connection.metadata?.group_openid; + if ( + metaOpenid !== undefined && + metaOpenid !== null && + metaOpenid !== groupOpenid + ) { + return false; + } + return true; +} + +export function isWecomNotificationGroup( + connection: ChannelConnection, +): boolean { + if ( + connection.provider !== "wecom" || + connection.status !== "connected" || + connection.metadata?.chattype !== "group" || + typeof connection.workspace_id !== "string" + ) { + return false; + } + const chatid = connection.metadata?.chatid; + if (!isNonEmptyTrimmedString(chatid)) return false; + const aibotid = connection.metadata?.aibotid; + if (aibotid === undefined || aibotid === null || aibotid === "") { + return connection.workspace_id === `group:${chatid}`; + } + if (!isNonEmptyTrimmedString(aibotid)) return false; + return connection.workspace_id === `${aibotid}:group:${chatid}`; +} + +export function isNotificationGroup(connection: ChannelConnection): boolean { + return ( + isFeishuNotificationGroup(connection) || + isQqNotificationGroup(connection) || + isWecomNotificationGroup(connection) + ); +} + /** The Gateway still validates ownership and the binding when the task is saved. */ -export function feishuNotificationGroups( +export function notificationGroups( connections: readonly ChannelConnection[], ): ChannelConnection[] { - return connections.filter( - (connection) => - connection.provider === "feishu" && - connection.status === "connected" && - connection.metadata?.chat_type === "group" && - typeof connection.workspace_id === "string" && - connection.workspace_id.trim().length > 0 && - connection.metadata?.chat_id === connection.workspace_id, + return connections.filter(isNotificationGroup); +} + +export const feishuNotificationGroups = notificationGroups; + +function qqGroupId(connection: ChannelConnection): string | null { + if (!isQqNotificationGroup(connection)) return null; + const metaOpenid = connection.metadata?.group_openid; + if (isNonEmptyTrimmedString(metaOpenid)) return metaOpenid; + if (typeof connection.workspace_id !== "string") return null; + const sepIndex = connection.workspace_id.indexOf(":group:"); + if (sepIndex <= 0) return null; + const groupOpenid = connection.workspace_id.slice( + sepIndex + ":group:".length, ); + return isNonEmptyTrimmedString(groupOpenid) ? groupOpenid : null; +} + +function wecomGroupId(connection: ChannelConnection): string | null { + if (!isWecomNotificationGroup(connection)) return null; + const chatid = connection.metadata?.chatid; + return isNonEmptyTrimmedString(chatid) ? chatid : null; } export function notificationGroupName( connection: ChannelConnection | undefined, ): string | null { - const name = connection?.workspace_name?.trim(); + if (!connection) return null; + const name = connection.workspace_name?.trim(); if (!name) return null; + // Older bindings used raw platform IDs as generated labels. Require a real + // group name or an owner-chosen label before making them selectable. + if (connection.provider === "qq") { + const groupId = qqGroupId(connection); + if (groupId && name === `QQ group (${groupId})`) return null; + } + if (connection.provider === "wecom") { + const groupId = wecomGroupId(connection); + if (groupId && name === `WeCom group (${groupId})`) return null; + } return name; } diff --git a/frontend/tests/e2e/scheduled-notification-groups.spec.ts b/frontend/tests/e2e/scheduled-notification-groups.spec.ts index 040cd82241..5572347862 100644 --- a/frontend/tests/e2e/scheduled-notification-groups.spec.ts +++ b/frontend/tests/e2e/scheduled-notification-groups.spec.ts @@ -87,7 +87,7 @@ test("selecting and clearing an additional group preserves the schedule and disp await page.getByRole("button", { name: "Edit", exact: true }).click(); const form = page.getByTestId("scheduled-task-form"); const select = form.getByRole("combobox", { - name: "Also notify a Feishu group (optional)", + name: "Also notify a group (optional)", }); await expect(select).toHaveText("No additional group"); await select.click(); @@ -112,7 +112,7 @@ test("selecting and clearing an additional group preserves the schedule and disp ).toContainText("Planning team"); await page.getByRole("button", { name: "Edit", exact: true }).click(); await form - .getByRole("combobox", { name: "Also notify a Feishu group (optional)" }) + .getByRole("combobox", { name: "Also notify a group (optional)" }) .click(); await page .getByRole("option", { name: "No additional group", exact: true }) @@ -147,7 +147,7 @@ test("a connection-list failure does not clear an existing group when saving ano ).toBeVisible({ timeout: 15_000 }); await expect( form.getByRole("combobox", { - name: "Also notify a Feishu group (optional)", + name: "Also notify a group (optional)", }), ).toHaveText("Selected group unavailable"); await expect(form).not.toContainText("missing-group-binding"); @@ -176,7 +176,7 @@ test("duplicating a task copies its selected group", async ({ page }) => { const form = page.getByTestId("scheduled-task-form"); await expect( form.getByRole("combobox", { - name: "Also notify a Feishu group (optional)", + name: "Also notify a group (optional)", }), ).toHaveText("Planning team"); const request = page.waitForRequest( @@ -259,7 +259,7 @@ test("the task form provides a persistent group binding command and refreshes th groupBound = true; await form.getByRole("button", { name: "Refresh group list" }).click(); const selector = form.getByRole("combobox", { - name: "Also notify a Feishu group (optional)", + name: "Also notify a group (optional)", }); await expect(selector).toHaveText("No additional group"); await selector.click(); @@ -279,7 +279,7 @@ test("a backend without the group capability does not offer a group destination" ).toHaveCount(0); await expect( form.getByRole("combobox", { - name: "Also notify a Feishu group (optional)", + name: "Also notify a group (optional)", }), ).toHaveCount(0); }); diff --git a/frontend/tests/unit/components/workspace/scheduled-tasks/task-form-dialog.dom.test.tsx b/frontend/tests/unit/components/workspace/scheduled-tasks/task-form-dialog.dom.test.tsx index c9639fb09f..8e98b28f6c 100644 --- a/frontend/tests/unit/components/workspace/scheduled-tasks/task-form-dialog.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/scheduled-tasks/task-form-dialog.dom.test.tsx @@ -161,7 +161,7 @@ describe("TaskFormDialog", () => { ).toBeNull(); expect( screen.queryByRole("combobox", { - name: "Also notify a Feishu group (optional)", + name: "Also notify a group (optional)", }), ).toBeNull(); expect( @@ -185,7 +185,7 @@ describe("TaskFormDialog", () => { await waitFor(() => expect( screen.getByRole("combobox", { - name: "Also notify a Feishu group (optional)", + name: "Also notify a group (optional)", }).textContent, ).toBe("Planning team"), ); @@ -274,7 +274,7 @@ describe("TaskFormDialog", () => { ).toBeNull(); expect( screen.queryByRole("combobox", { - name: "Also notify a Feishu group (optional)", + name: "Also notify a group (optional)", }), ).toBeNull(); fireEvent.click( @@ -297,7 +297,7 @@ describe("TaskFormDialog", () => { }); expect( screen.queryByRole("combobox", { - name: "Also notify a Feishu group (optional)", + name: "Also notify a group (optional)", }), ).toBeNull(); expect( @@ -365,17 +365,98 @@ describe("TaskFormDialog", () => { ); }); + test("WeCom and QQ group bind buttons request provider-specific binding codes", async () => { + connectProvider.mockImplementation(async (provider: string) => ({ + provider, + mode: "binding_code", + code: `${provider}-group-code`, + instruction: "unused", + expires_in: 600, + })); + renderForm({ mode: "create" }); + + fireEvent.click(screen.getByRole("button", { name: "Bind a WeCom group" })); + fireEvent.change( + await screen.findByRole("textbox", { name: "Group name" }), + { + target: { value: "Release team" }, + }, + ); + fireEvent.click( + screen.getByRole("button", { name: "Generate a new code" }), + ); + const command = await screen.findByRole("textbox", { + name: "Group binding command", + }); + await waitFor(() => + expect(command).toHaveProperty("value", "/connect wecom-group-code"), + ); + expect(connectProvider).toHaveBeenCalledWith("wecom", { + groupName: "Release team", + }); + + fireEvent.click(screen.getByRole("button", { name: "Bind a QQ group" })); + expect(screen.getByRole("textbox", { name: "Group name" })).toHaveProperty( + "value", + "", + ); + expect( + screen.queryByRole("textbox", { name: "Group binding command" }), + ).toBeNull(); + fireEvent.change(screen.getByRole("textbox", { name: "Group name" }), { + target: { value: "Support team" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Generate a new code" }), + ); + await waitFor(() => + expect( + screen.getByRole("textbox", { name: "Group binding command" }), + ).toHaveProperty("value", "/connect qq-group-code"), + ); + expect(connectProvider).toHaveBeenCalledWith("qq", { + groupName: "Support team", + }); + fireEvent.click( + screen.getByRole("button", { name: "Bind a Feishu group" }), + ); + await waitFor(() => + expect( + screen.getByRole("textbox", { name: "Group binding command" }), + ).toHaveProperty("value", "/connect feishu-group-code"), + ); + expect(screen.queryByRole("textbox", { name: "Group name" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Bind a WeCom group" })); + expect( + screen.getByRole("textbox", { name: "Group binding command" }), + ).toHaveProperty("value", "/connect wecom-group-code"); + expect(connectProvider).toHaveBeenCalledTimes(3); + fireEvent.change(screen.getByRole("textbox", { name: "Group name" }), { + target: { value: "Operations" }, + }); + expect( + screen.queryByRole("textbox", { name: "Group binding command" }), + ).toBeNull(); + fireEvent.click( + screen.getByRole("button", { name: "Generate a new code" }), + ); + await waitFor(() => + expect(connectProvider).toHaveBeenLastCalledWith("wecom", { + groupName: "Operations", + }), + ); + await screen.findByRole("textbox", { name: "Group binding command" }); + }); + test("refreshing after binding discovers the group without changing the task automatically", async () => { listConnections.mockResolvedValueOnce([]).mockResolvedValue([group()]); renderForm({ mode: "create" }); - await screen.findByText( - "Bind a Feishu group below, then refresh the list.", - ); + await screen.findByText("Bind a group below, then refresh the list."); fireEvent.click(screen.getByRole("button", { name: "Refresh group list" })); await waitFor(() => expect(listConnections).toHaveBeenCalledTimes(2)); fireEvent.keyDown( screen.getByRole("combobox", { - name: "Also notify a Feishu group (optional)", + name: "Also notify a group (optional)", }), { key: "ArrowDown" }, ); @@ -393,7 +474,7 @@ describe("TaskFormDialog", () => { await waitFor(() => expect(listConnections).toHaveBeenCalled()); fireEvent.keyDown( screen.getByRole("combobox", { - name: "Also notify a Feishu group (optional)", + name: "Also notify a group (optional)", }), { key: "ArrowDown" }, ); @@ -419,7 +500,7 @@ describe("TaskFormDialog", () => { ); expect( screen.getByRole("combobox", { - name: "Also notify a Feishu group (optional)", + name: "Also notify a group (optional)", }).textContent, ).toBe("Selected group unavailable"); expect(document.body.textContent).not.toContain("saved-group-binding"); @@ -445,7 +526,7 @@ describe("TaskFormDialog", () => { ); fireEvent.keyDown( screen.getByRole("combobox", { - name: "Also notify a Feishu group (optional)", + name: "Also notify a group (optional)", }), { key: "ArrowDown" }, ); @@ -463,11 +544,11 @@ describe("TaskFormDialog", () => { listConnections.mockResolvedValue([group({ workspace_name: null })]); renderForm({ mode: "create" }); await screen.findByText( - "Check the app's group-information permission, then bind the group again to load its name.", + "Bind the group again with a recognizable name before selecting it.", ); fireEvent.keyDown( screen.getByRole("combobox", { - name: "Also notify a Feishu group (optional)", + name: "Also notify a group (optional)", }), { key: "ArrowDown" }, ); @@ -483,13 +564,13 @@ describe("TaskFormDialog", () => { test("the Chinese group field explains that personal notifications stay unchanged", async () => { renderForm({ mode: "create" }, { locale: "zh-CN" }); expect( - screen.getByRole("combobox", { name: "额外通知到飞书群(可选)" }) + screen.getByRole("combobox", { name: "额外通知到群聊(可选)" }) .textContent, ).toBe("仅通知本人"); expect( screen.getByText("你现有的个人通知保持不变,所选群也会收到任务更新。"), ).toBeTruthy(); - await screen.findByText("先在下方绑定飞书群,再刷新群列表。"); + await screen.findByText("先在下方绑定群聊,再刷新群列表。"); }); test("every field resolves by its label", () => { diff --git a/frontend/tests/unit/components/workspace/settings/channel-notification-settings.dom.test.tsx b/frontend/tests/unit/components/workspace/settings/channel-notification-settings.dom.test.tsx index c13677436e..ca5bbc0df4 100644 --- a/frontend/tests/unit/components/workspace/settings/channel-notification-settings.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/settings/channel-notification-settings.dom.test.tsx @@ -335,3 +335,41 @@ test("a late preference load cannot replace the next viewer's settings", async ( .getAttribute("aria-checked"), ).toBe("true"); }); + +test("renders default-off task & goal event switches and saves an event toggle", async () => { + const original = rs.mocked(fetcher).getMockImplementation()!; + rs.mocked(fetcher).mockImplementation((url, init) => + init?.method === "PATCH" + ? Promise.resolve( + json({ + ...defaults, + run_completed: false, + run_failed: false, + human_input_requested: true, + }), + ) + : original(url, init), + ); + mount(); + for (const label of [ + "Task or goal completed notifications", + "Task or goal failed notifications", + "Needs your input notifications", + ]) { + const toggle = await screen.findByRole("switch", { name: label }); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + } + const inputToggle = screen.getByRole("switch", { + name: "Needs your input notifications", + }); + fireEvent.click(inputToggle); + await waitFor(() => + expect(inputToggle.getAttribute("aria-checked")).toBe("true"), + ); + const [, request] = rs + .mocked(fetcher) + .mock.calls.find(([, init]) => init?.method === "PATCH")!; + expect(JSON.parse(request!.body as string)).toEqual({ + human_input_requested: true, + }); +}); diff --git a/frontend/tests/unit/core/channels/api.test.ts b/frontend/tests/unit/core/channels/api.test.ts index 8744db8133..091568e975 100644 --- a/frontend/tests/unit/core/channels/api.test.ts +++ b/frontend/tests/unit/core/channels/api.test.ts @@ -36,6 +36,21 @@ beforeEach(() => { }); describe("channels api", () => { + test("sends an owner-chosen group name with the binding-code request", async () => { + mockedFetch.mockResolvedValueOnce( + jsonResponse(200, { provider: "qq", code: "code", expires_in: 600 }), + ); + await connectChannelProvider("qq", { groupName: "Release team" }); + expect(mockedFetch).toHaveBeenCalledWith( + "/backend/api/channels/qq/connect", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ group_name: "Release team" }), + }, + ); + }); + test("loads provider catalog", async () => { mockedFetch.mockResolvedValueOnce( jsonResponse(200, { diff --git a/frontend/tests/unit/core/channels/notification-preferences.test.ts b/frontend/tests/unit/core/channels/notification-preferences.test.ts index 5980b6eb1c..9e8e08b216 100644 --- a/frontend/tests/unit/core/channels/notification-preferences.test.ts +++ b/frontend/tests/unit/core/channels/notification-preferences.test.ts @@ -69,3 +69,35 @@ test("keeps an unavailable endpoint distinguishable from valid default values", loadChannelNotificationPreferences("alice", new AbortController().signal), ).rejects.toBeInstanceOf(GatewayApiError); }); + +test("updates a runtime notification event key and parses optional event fields", async () => { + rs.mocked(fetcher).mockResolvedValue( + new Response( + JSON.stringify({ + ...defaults, + run_completed: true, + run_failed: false, + human_input_requested: true, + }), + ), + ); + const signal = new AbortController().signal; + const updated = await updateChannelNotificationPreference( + "alice", + "human_input_requested", + true, + signal, + ); + expect(updated.human_input_requested).toBe(true); + expect(updated.run_completed).toBe(true); + expect(updated.run_failed).toBe(false); + expect(rs.mocked(fetcher)).toHaveBeenCalledWith( + "/backend/api/channels/notification-preferences", + expect.objectContaining({ + method: "PATCH", + signal, + body: '{"human_input_requested":true}', + headers: expect.objectContaining({ "X-Expected-User-Id": "alice" }), + }), + ); +}); diff --git a/frontend/tests/unit/core/scheduled-tasks/notification-groups.test.ts b/frontend/tests/unit/core/scheduled-tasks/notification-groups.test.ts index f348dda013..9e3ce838bb 100644 --- a/frontend/tests/unit/core/scheduled-tasks/notification-groups.test.ts +++ b/frontend/tests/unit/core/scheduled-tasks/notification-groups.test.ts @@ -44,4 +44,86 @@ describe("additional Feishu notification groups", () => { notificationGroupName({ ...group, workspace_name: " 项目会议 " }), ).toBe("项目会议"); }); + + test("includes connected QQ and WeCom group bindings while excluding private or unsupported bindings", () => { + const qqGroup: ChannelConnection = { + id: "qq-group", + provider: "qq", + status: "connected", + workspace_id: "app1:group:group-openid", + scopes: [], + metadata: { chat_type: "group", group_openid: "group-openid" }, + }; + const wecomGroup: ChannelConnection = { + id: "wecom-group", + provider: "wecom", + status: "connected", + workspace_id: "bot1:group:wr_chat_1", + scopes: [], + metadata: { aibotid: "bot1", chattype: "group", chatid: "wr_chat_1" }, + }; + const qqPrivate: ChannelConnection = { + id: "qq-private", + provider: "qq", + status: "connected", + workspace_id: "app1:c2c", + scopes: [], + metadata: { chat_type: "c2c" }, + }; + const wechatGroup: ChannelConnection = { + id: "wechat-group", + provider: "wechat", + status: "connected", + workspace_id: "group:wx_1", + scopes: [], + metadata: { chat_type: "group", chat_id: "group:wx_1" }, + }; + const qqGroup2: ChannelConnection = { + ...qqGroup, + id: "qq-group-2", + workspace_id: "app1:group:group-openid-2", + metadata: { chat_type: "group", group_openid: "group-openid-2" }, + }; + const wecomGroup2: ChannelConnection = { + ...wecomGroup, + id: "wecom-group-2", + workspace_id: "bot1:group:wr_chat_2", + metadata: { aibotid: "bot1", chattype: "group", chatid: "wr_chat_2" }, + }; + expect( + feishuNotificationGroups([ + qqGroup, + qqGroup2, + wecomGroup, + wecomGroup2, + qqPrivate, + wechatGroup, + ]), + ).toEqual([qqGroup, qqGroup2, wecomGroup, wecomGroup2]); + expect(notificationGroupName(qqGroup)).toBeNull(); + expect(notificationGroupName(qqGroup2)).toBeNull(); + expect(notificationGroupName(wecomGroup)).toBeNull(); + expect(notificationGroupName(wecomGroup2)).toBeNull(); + expect( + notificationGroupName({ ...qqGroup, workspace_name: "Release team" }), + ).toBe("Release team"); + expect( + notificationGroupName({ ...qqGroup2, workspace_name: "Support team" }), + ).toBe("Support team"); + expect( + notificationGroupName({ + ...qqGroup, + workspace_name: "QQ group (group-openid)", + }), + ).toBeNull(); + expect( + notificationGroupName({ + ...wecomGroup, + workspace_name: "WeCom group (wr_chat_1)", + }), + ).toBeNull(); + expect( + notificationGroupName({ ...wecomGroup, workspace_name: "Ops War Room" }), + ).toBe("Ops War Room"); + }); });