From cb90f5dca47f68fec329746a53df7b437a11c4e8 Mon Sep 17 00:00:00 2001 From: Totoro Date: Wed, 14 Oct 2026 11:14:48 +0800 Subject: [PATCH] feat(channels): control IM notifications and add WeChat/QQ delivery (#6850) * feat(channels): control IM notifications and add WeChat/QQ delivery * fix(scheduler): skip notification preference locks without recipients --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com> Co-authored-by: Willem Jiang --- README.md | 19 +- backend/AGENTS.md | 2 +- backend/app/channels/AGENTS.md | 2 +- backend/app/channels/capabilities.py | 4 +- backend/app/channels/qq.py | 63 ++- backend/app/channels/wechat.py | 114 +++++- backend/app/gateway/app.py | 4 + .../gateway/routers/channel_connections.py | 9 +- .../routers/im_notification_preferences.py | 46 +++ .../app/scheduler/notification_delivery.py | 29 ++ backend/app/scheduler/service.py | 12 +- backend/docs/API.md | 24 +- backend/docs/IM_CHANNEL_CONNECTIONS.md | 53 ++- .../persistence/channel_connections/sql.py | 43 ++ .../deerflow/persistence/migrations/AGENTS.md | 12 +- .../versions/0041_im_notice_preferences.py | 28 ++ .../deerflow/persistence/models/__init__.py | 2 + .../notification_deliveries/sql.py | 8 + .../deerflow/persistence/user/AGENTS.md | 9 + .../persistence/user/im_notifications.py | 75 ++++ backend/tests/blocking_io/test_qq_channel.py | 43 ++ backend/tests/test_channel_capabilities.py | 10 +- .../tests/test_channel_connection_context.py | 189 +++++++++ .../tests/test_channel_connections_router.py | 4 +- .../tests/test_im_notification_preferences.py | 370 ++++++++++++++++++ ...migration_0027_0028_notification_outbox.py | 2 +- ...st_migration_0029_scheduler_agent_tasks.py | 2 +- ...migration_0032_activity_and_task_events.py | 2 +- ..._migration_0034_run_event_seq_watermark.py | 2 +- .../test_migration_0035_login_throttle.py | 2 +- ...gration_0037_project_document_summaries.py | 6 +- ..._migration_0038_channel_thread_bindings.py | 2 +- ...t_migration_0040_scheduled_group_notice.py | 3 +- ...st_migration_0041_im_notice_preferences.py | 168 ++++++++ backend/tests/test_qq_notifications.py | 260 ++++++++++++ backend/tests/test_scheduled_task_events.py | 3 +- backend/tests/test_wechat_notifications.py | 288 ++++++++++++++ frontend/AGENTS.md | 1 + .../channels/channel-scheduled-updates.tsx | 9 +- .../channel-notification-settings.tsx | 164 ++++++++ .../settings/notification-settings-page.tsx | 10 + frontend/src/core/channels/api.ts | 9 +- .../core/channels/notification-preferences.ts | 62 +++ frontend/src/core/channels/types.ts | 4 +- frontend/src/core/i18n/locales/en-US.ts | 29 +- frontend/src/core/i18n/locales/types.ts | 18 + frontend/src/core/i18n/locales/zh-CN.ts | 23 +- .../e2e/channel-notification-settings.spec.ts | 143 +++++++ frontend/tests/e2e/channels.spec.ts | 9 +- .../channel-scheduled-updates.dom.test.tsx | 10 +- ...channel-notification-settings.dom.test.tsx | 337 ++++++++++++++++ .../channels/notification-preferences.test.ts | 71 ++++ 52 files changed, 2740 insertions(+), 73 deletions(-) create mode 100644 backend/app/gateway/routers/im_notification_preferences.py create mode 100644 backend/packages/harness/deerflow/persistence/migrations/versions/0041_im_notice_preferences.py create mode 100644 backend/packages/harness/deerflow/persistence/user/im_notifications.py create mode 100644 backend/tests/test_channel_connection_context.py create mode 100644 backend/tests/test_im_notification_preferences.py create mode 100644 backend/tests/test_migration_0041_im_notice_preferences.py create mode 100644 backend/tests/test_qq_notifications.py create mode 100644 backend/tests/test_wechat_notifications.py create mode 100644 frontend/src/components/workspace/settings/channel-notification-settings.tsx create mode 100644 frontend/src/core/channels/notification-preferences.ts create mode 100644 frontend/tests/e2e/channel-notification-settings.spec.ts create mode 100644 frontend/tests/unit/components/workspace/settings/channel-notification-settings.dom.test.tsx create mode 100644 frontend/tests/unit/core/channels/notification-preferences.test.ts diff --git a/README.md b/README.md index 4d72b438dd..7bad09d24d 100644 --- a/README.md +++ b/README.md @@ -2904,7 +2904,7 @@ Current MVP capabilities: - Pause, resume, trigger, inspect history, and delete tasks - Search task titles or prompts, combined with status/type filters and the current thread scope. - Execute scheduled work through the normal DeerFlow run lifecycle -- When `channel_connections.enabled: true`, send scheduled task updates to the task owner's connected IM identities (outbox + delivery worker) on apps that support proactive push, which today is WeCom and Feishu/Lark; Settings shows for each app whether updates are sent there, and other apps get none. Each occurrence queues at most one message per target: a finished, failed or goal-missed run, the automatic pause after three missed goals, a pause by the agent (its stop condition was met) or the task finishing (all `max_runs` done, `end_at` reached); when several apply, the pause or finish wins and still says how the last run went. A one-time task sends only its run's outcome. The message is queued in the same database transaction that records the outcome, so runs finalized after a crash or a lost lease do not enqueue a duplicate notice for the same target. It reads on its own, in your web UI language (else `channel_connections.notification_locale`): the task title, what happened, a one-line result when the agent replied, and "Open DeerFlow → Scheduled tasks for details.", with no IDs and no links. Plain manual "run now" trials and interrupted runs stay silent, and so do occurrences that end without a finished run (launch error, queue timeout, interrupted by a restart). Channel/transport outages park deliveries without exhausting retries, for up to about a day; platform rejections retry for roughly 15 minutes before settling as `failed`. An identity you disconnect while a delivery is waiting is never pushed to: the row is dropped as `failed`. +- When `channel_connections.enabled: true`, send scheduled task updates to the task owner's connected IM identities (outbox + delivery worker) on apps that support proactive push, which today is WeCom, Feishu/Lark, WeChat and QQ private chat; Settings → Notifications controls proactive updates per connected app, independently of browser notifications and ordinary chat replies. Each occurrence queues at most one message per target: a finished, failed or goal-missed run, the automatic pause after three missed goals, a pause by the agent (its stop condition was met) or the task finishing (all `max_runs` done, `end_at` reached); when several apply, the pause or finish wins and still says how the last run went. A one-time task sends only its run's outcome. The message is queued in the same database transaction that records the outcome, so runs finalized after a crash or a lost lease do not enqueue a duplicate notice for the same target. It reads on its own, in your web UI language (else `channel_connections.notification_locale`): the task title, what happened, a one-line result when the agent replied, and "Open DeerFlow → Scheduled tasks for details.", with no IDs and no links. Plain manual "run now" trials and interrupted runs stay silent, and so do occurrences that end without a finished run (launch error, queue timeout, interrupted by a restart). Channel/transport outages park deliveries without exhausting retries, for up to about a day; platform rejections retry for roughly 15 minutes before settling as `failed`. An identity you disconnect while a delivery is waiting is never pushed to: the row is dropped as `failed`. - Browse execution history in pages of 50; older pages pause automatic refresh, with an explicit return to the latest runs. Counts appear only after a successful read; loading and failed reads are not reported as zero runs. Derived run summaries omit leading inline `` reasoning before limiting the text, so a long thought does not replace the actual answer in previews or scheduled notices. Original response history and literal tags in the answer are unchanged; previously stored run summaries are not rewritten. @@ -2924,6 +2924,23 @@ Enable background polling with `config.yaml -> scheduler.enabled`. Manual trigge In the task form, switching an hourly, daily, weekly, or monthly schedule to **Custom cron** starts from its current timing, including selected weekdays or day of the month. Switching back to Custom cron after preset edits uses those edits rather than an older expression. +### Control proactive IM notifications + +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; +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 +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. +See [setup and delivery limits](backend/docs/IM_CHANNEL_CONNECTIONS.md#proactive-notification-settings). + ### Optional Feishu group notifications In a task's create or edit form, **Also notify a Feishu group (optional)** adds diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 254b6c0654..b53fe95583 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -15,7 +15,7 @@ The LangGraph backend provides sandbox execution, persistent memory, subagent de - Gateway streams `write_file` and `str_replace` argument deltas in bounded batches for multi-mode `messages-tuple` consumers; single-mode message consumers retain the original per-chunk contract. Non-message frames flush pending batches, and `values` remains an optional complete-state snapshot rather than a prerequisite for batching. - `stream_subgraphs` preserves subgraph SSE namespaces (`values|`, LangGraph Platform style); bare `values` would overwrite the SDK thread view (#4399). Delegated subagents inherit the parent's checkpoint namespace. Root-only consumers (file-tool chunk batcher, subagent event persistence, LLM error-fallback detection) ignore namespaced frames. The web frontend uses root `task_*` events without subgraph streaming. - Background subagent identity is deliberately split: the provider `tool_call_id` remains the correlation key for `ToolMessage`, `task_*` SSE events, persisted lifecycle events, frontend cards, and the public `ExtensionData.scope_id` contract (stored as `SubagentResult.external_task_id`), while `SubagentExecutor.execute_async()` generates a full server-side `execution_id` for `SubagentResult.task_id`, the process-wide registry, polling, cancellation, timeout handling, and cleanup. Provider IDs are not globally unique across parent runs, so they must never become registry ownership keys; scheduler closures retain their own `SubagentResult` rather than resolving ownership again through the mutable registry. Terminal subagent token usage travels in the current run's `ToolMessage.additional_kwargs` and is attributed from message state, never through a process-global provider-ID cache. -- Scheduled tasks dispatch through the normal Gateway run path. `launch_scheduled_thread_run` reads `get_app_config()` at dispatch and passes `scheduler.recursion_limit` (default 1000, matching the web UI; clamped by `max_recursion_limit`), so YAML changes apply on the next run without restarting Gateway. +- Scheduled runs use Gateway's normal run path. `launch_scheduled_thread_run` reads `get_app_config()` at dispatch: `scheduler.recursion_limit` defaults to 1000 (web UI), capped by `max_recursion_limit`; YAML changes apply next run without restart. IM enqueue checks recipients before locking preferences in the outcome transaction. - Run-history `status` filters are occurrence states, not task states. `ScheduledTaskRunStatus` in `persistence/scheduled_tasks/model.py` is the shared API/repository vocabulary and must match the active and terminal occurrence-status sets. Keep owner lookup before reading history, and apply SQL task/status predicates before pagination; omitted status preserves the existing response. - The background scheduler is single-instance by default. `scheduler.multi_instance=true` opts into lease-aware recovery across Gateway instances and requires shared Postgres, `run_ownership.heartbeat_enabled=true`, and `run_events.backend=db`; otherwise startup rejects the configuration. Live scheduled runs are preserved when a peer starts; expired launch claims return to the durable queue, expired run leases are atomically taken over, stale launch writes are fenced by lease ownership, and the Postgres advisory-locked budget makes `max_concurrent_runs` a shared global cap for `launching`/`running` rows. - The multi-process startup gate also fires on `deployment.multi_instance: true`; contract in `docs/CONFIGURATION.md`. diff --git a/backend/app/channels/AGENTS.md b/backend/app/channels/AGENTS.md index ab5ab973ad..baed8f4122 100644 --- a/backend/app/channels/AGENTS.md +++ b/backend/app/channels/AGENTS.md @@ -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` 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 (a `` fact block; a verbatim echo of the user's message as the reply). 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** (`Channel.send_notification`): the scheduled-task outbox pushes markdown to a bound identity with no inbound frame. The base default raises `NotImplementedError` (after a `ChannelUnavailable` check when not running). `capabilities.py` gates enqueue/UI; unsupported legacy rows fail terminally (`test_channel_capabilities.py`). Await ACK; `ChannelUnavailable` parks transport failures, rejections count. Group targets use owner-bound connection IDs and a separate group hook; see [group delivery](../../docs/IM_CHANNEL_CONNECTIONS.md#feishu-group-notifications). **WeCom/Feishu push**; Feishu uses owner-scoped p2p routing. WeCom uses `WSClient.send_message`; its SDK raises `RuntimeError` for a dead socket and a rejection alike, so only the `Reply ack error` prefix counts; other `RuntimeError`s, `ConnectionError`/`TimeoutError`/`OSError` and the `websockets` close exception park. Before sending, the worker re-checks the binding (owner-scoped `resolve_connections`, before liveness): a gone target is `mark_failed(terminal=True)`; a lookup failure parks. Terminal rows ignore later status writes. Scheduler side: `_on_finalization` enqueues each notice in the outcome's transaction, so recovery notifies once (unique key; idle ends use `idle:`); one message per occurrence (stop > pause > finish > run outcome); plain trials and interrupts stay silent; text (`scheduler/notification_text.py`) is localized, without IDs or links. Lifespan starts `NotificationDeliveryWorker` after the channel service (detaching enqueue if it cannot) and stops it first. +- `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). - `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. - `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()` (first failed tick logs at WARNING, later ones at DEBUG); 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 ecc45b602b..d6866f40f7 100644 --- a/backend/app/channels/capabilities.py +++ b/backend/app/channels/capabilities.py @@ -20,10 +20,10 @@ CHANNEL_CAPABILITIES: dict[str, dict[str, bool]] = { "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": 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": False}, + "wechat": {"supports_streaming": False, "proactive_notifications": True}, "wecom": {"supports_streaming": True, "proactive_notifications": True}, } diff --git a/backend/app/channels/qq.py b/backend/app/channels/qq.py index 334ee8dc4c..a4a365b445 100644 --- a/backend/app/channels/qq.py +++ b/backend/app/channels/qq.py @@ -19,7 +19,7 @@ from websockets.asyncio.client import connect from websockets.exceptions import ConnectionClosed from app.channels.allowed_users import parse_allowed_users -from app.channels.base import Channel +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 @@ -45,6 +45,10 @@ class QQAPIError(RuntimeError): """A sanitized provider failure, safe for shared channel error logging.""" +class QQTransportError(QQAPIError): + """A transient connection failure, distinct from a provider rejection.""" + + def validate_gateway_url(url: str) -> str: try: parsed = urlsplit(url) @@ -178,8 +182,8 @@ class QQChannel(Channel): async with self._token_lock: if self._access_token and time.monotonic() < self._token_expires_at: return self._access_token - if self._http is None: - raise QQAPIError("QQ HTTP transport is not available") + if self._http is None or self._http.is_closed: + raise QQTransportError("QQ HTTP transport is not available") try: response = await self._http.post( TOKEN_URL, @@ -191,6 +195,8 @@ class QQChannel(Channel): lifetime = float(payload["expires_in"]) if not isinstance(token, str) or not token or not math.isfinite(lifetime) or lifetime <= 0: raise ValueError + except httpx.TransportError: + raise QQTransportError("QQ access token transport failed") from None except Exception: raise QQAPIError("QQ access token request failed") from None self._access_token = token @@ -210,9 +216,11 @@ class QQChannel(Channel): }, **kwargs, ) - except Exception: + except httpx.TransportError: # An ambiguous timeout must not trigger another passive reply. - raise QQAPIError("QQ API transport failed") from None + raise QQTransportError("QQ API transport failed") from None + except Exception: + raise QQAPIError("QQ API request failed") from None if response.status_code == 401 and attempt == 0: if token == self._access_token: self._token_expires_at = 0 @@ -481,6 +489,51 @@ class QQChannel(Channel): finally: reservation.release() + async def send_notification_for_owner(self, *, owner_user_id: str | None, target: str, text_markdown: str) -> None: + """Only C2C bindings from this bot establish a private notification target.""" + if not owner_user_id or self._connection_repo is None: + raise ChannelUnavailable("QQ notification binding lookup is unavailable") + 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: + metadata = connection.get("metadata") + if ( + connection.get("owner_user_id") == owner_user_id + and connection.get("provider") == self.name + and connection.get("status") == "connected" + and connection.get("external_account_id") == target + and connection.get("workspace_id") == f"{self._app_id}:c2c" + and isinstance(metadata, dict) + and metadata.get("chat_type") == "c2c" + ): + await self.send_notification(target=target, text_markdown=text_markdown) + 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.""" + 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}) + except QQTransportError: + raise ChannelUnavailable("QQ notification transport is unavailable") from None + except QQAPIError: + if not self.is_running or self._http is None or self._http.is_closed: + raise ChannelUnavailable("QQ notification transport is unavailable") from None + raise + if payload.get("err_code", 0) not in (0, None): + raise QQAPIError("QQ notification was rejected") + if not isinstance(payload.get("id"), str) or not payload["id"].strip(): + raise QQAPIError("QQ notification response has no message acknowledgement") + async def send(self, msg: OutboundMessage) -> None: if not msg.is_final or not msg.text: return diff --git a/backend/app/channels/wechat.py b/backend/app/channels/wechat.py index cb15d0e331..9cba7864a8 100644 --- a/backend/app/channels/wechat.py +++ b/backend/app/channels/wechat.py @@ -24,7 +24,7 @@ from cryptography.hazmat.primitives import padding from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from app.channels.allowed_users import parse_allowed_users -from app.channels.base import Channel +from app.channels.base import Channel, ChannelUnavailable from app.channels.commands import is_known_channel_command from app.channels.connection_identity import attach_connection_identity from app.channels.message_bus import InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment @@ -409,6 +409,78 @@ class WechatChannel(Channel): max_retries=_max_retries, ) + async def send_notification(self, *, target: str, text_markdown: str) -> None: + """Notifications need the owner's current binding, not a chat cache.""" + await self.send_notification_for_owner(owner_user_id=None, target=target, text_markdown=text_markdown) + + async def send_notification_for_owner(self, *, owner_user_id: str | None, target: str, text_markdown: str) -> None: + self._require_notification_transport() + if not owner_user_id or self._connection_repo is None: + raise RuntimeError("WeChat notifications require an owner-bound private conversation") + connection = await self._notification_binding(owner_user_id, target) + try: + credentials = await self._connection_repo.get_credentials(connection["id"]) + except Exception as exc: + raise ChannelUnavailable("WeChat notification context lookup is unavailable") from exc + extra = credentials.get("extra") if isinstance(credentials, dict) else None + context = extra.get("wechat_notification_context") if isinstance(extra, dict) else None + token = context.get("context_token") if isinstance(context, dict) else None + if not isinstance(token, str) or not token.strip() or context.get("chat_id") != target or context.get("bot_token_sha256") != hashlib.sha256(self._bot_token.encode()).hexdigest(): + raise RuntimeError("WeChat notification needs fresh context; send the bot a private message first") + current = await self._notification_binding(owner_user_id, target) + if current["id"] != connection["id"]: + raise RuntimeError("WeChat notification identity is no longer connected") + self._require_notification_transport() + payload = self._text_message_payload(chat_id=target, context_token=token, text=text_markdown, client_id_prefix="deerflow-notification") + try: + # The outbox owns retries; do not call the chat reply retry helper. + data = await self._request_json("/ilink/bot/sendmessage", payload, require_object=True) + except httpx.TransportError as exc: + raise ChannelUnavailable("WeChat notification transport is unavailable") from exc + except httpx.HTTPStatusError as exc: + raise RuntimeError(f"WeChat notification rejected: HTTP {exc.response.status_code}") from None + # iLink permits omitted success codes, but malformed codes are not ACKs. + if any(code is not None and (type(code) not in (int, float) or code != 0) for code in (data.get("ret"), data.get("errcode"))): + # The provider's free-form errmsg can echo context credentials. + raise RuntimeError("WeChat notification was not acknowledged by iLink") + + def _require_notification_transport(self) -> None: + if not self.is_running or not self._bot_token or self._client is None or self._client.is_closed: + raise ChannelUnavailable("WeChat channel is not connected") + + async def _notification_binding(self, owner_user_id: str, target: str) -> dict[str, Any]: + try: + connection = await self._connection_repo.find_connection_by_external_identity(provider=self.name, external_account_id=target, workspace_id=target) + except Exception as exc: + raise ChannelUnavailable("WeChat notification binding lookup is unavailable") from exc + if ( + not connection + or not connection.get("id") + or connection.get("owner_user_id") != owner_user_id + or connection.get("provider") != self.name + or connection.get("status") != "connected" + or connection.get("external_account_id") != target + or connection.get("workspace_id") != target + ): + raise RuntimeError("WeChat notification identity is no longer connected") + return connection + + async def _remember_notification_context(self, *, connection_id: str, owner_user_id: str, chat_id: str, context_token: str) -> None: + if not context_token or not self._bot_token: + return + try: + await self._connection_repo.store_connection_context( + connection_id, + owner_user_id=owner_user_id, + provider=self.name, + external_account_id=chat_id, + workspace_id=chat_id, + extra={"wechat_notification_context": {"chat_id": chat_id, "context_token": context_token, "bot_token_sha256": hashlib.sha256(self._bot_token.encode()).hexdigest()}}, + ) + except Exception as exc: + # A context write failure must not drop an otherwise valid chat turn. + logger.warning("[WeChat] notification context was not persisted: %s", type(exc).__name__) + async def _send_text_message( self, *, @@ -418,7 +490,20 @@ class WechatChannel(Channel): client_id_prefix: str, max_retries: int, ) -> None: - payload = { + payload = self._text_message_payload(chat_id=chat_id, context_token=context_token, text=text, client_id_prefix=client_id_prefix) + + async def send_message() -> None: + data = await self._request_json("/ilink/bot/sendmessage", payload) + self._ensure_success(data, "sendmessage") + + await self._send_with_retry( + send_message, + max_retries=max_retries, + log_prefix="[WeChat]", + ) + + def _text_message_payload(self, *, chat_id: str, context_token: str, text: str, client_id_prefix: str) -> dict[str, Any]: + return { "msg": { "from_user_id": "", "to_user_id": chat_id, @@ -436,16 +521,6 @@ class WechatChannel(Channel): "base_info": self._base_info(), } - async def send_message() -> None: - data = await self._request_json("/ilink/bot/sendmessage", payload) - self._ensure_success(data, "sendmessage") - - await self._send_with_retry( - send_message, - max_retries=max_retries, - log_prefix="[WeChat]", - ) - async def send_file(self, msg: OutboundMessage, attachment: ResolvedAttachment) -> bool: if attachment.is_image: return await self._send_image_attachment(msg, attachment) @@ -725,6 +800,9 @@ class WechatChannel(Channel): # bind can bootstrap an external identity that is not yet whitelisted. connect_code = self._pending_connect_code(text) if connect_code: + if raw_message.get("group_id"): + await self._send_connection_reply(chat_id, context_token, "Please send the connection code in a private chat with the bot.") + return handled = await self._bind_connection_from_connect_code( chat_id=chat_id, context_token=context_token, @@ -764,6 +842,8 @@ class WechatChannel(Channel): ) inbound.topic_id = None inbound = await self._attach_connection_identity(inbound) + if context_token and not raw_message.get("group_id") and inbound.connection_id and inbound.owner_user_id: + await self._remember_notification_context(connection_id=inbound.connection_id, owner_user_id=inbound.owner_user_id, chat_id=chat_id, context_token=context_token) # The iLink poll loop processes updates sequentially on the Gateway # loop, so no provider-side task needs a pre-handoff reservation. await self._publish_inbound_or_drop(inbound) @@ -789,16 +869,14 @@ class WechatChannel(Channel): await self._send_connection_reply(chat_id, context_token, "WeChat connection could not be completed from this message.") return True - await self._connection_repo.upsert_connection( + connection = await self._connection_repo.upsert_connection( owner_user_id=state["owner_user_id"], provider="wechat", external_account_id=chat_id, workspace_id=chat_id, - metadata={ - "context_token": context_token, - }, status="connected", ) + await self._remember_notification_context(connection_id=connection["id"], owner_user_id=state["owner_user_id"], chat_id=chat_id, context_token=context_token) await self._send_connection_reply(chat_id, context_token, "WeChat connected to DeerFlow.") return True @@ -937,7 +1015,7 @@ class WechatChannel(Channel): ) raise TimeoutError("Timed out waiting for WeChat QR confirmation") - async def _request_json(self, path: str, payload: dict[str, Any], *, timeout: float | None = None) -> dict[str, Any]: + async def _request_json(self, path: str, payload: dict[str, Any], *, timeout: float | None = None, require_object: bool = False) -> dict[str, Any]: client = await self._ensure_client() response = await client.post( f"{self._base_url}{path}", @@ -947,6 +1025,8 @@ class WechatChannel(Channel): ) response.raise_for_status() data = response.json() + if require_object and not isinstance(data, dict): + raise RuntimeError("iLink response is not a JSON object") return data if isinstance(data, dict) else {} async def _request_public_get_json( diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index ae06798fe2..b7fc547d96 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -33,6 +33,7 @@ from app.gateway.routers import ( features, feedback, github_webhooks, + im_notification_preferences, input_polish, integrations, knowledge, @@ -503,6 +504,7 @@ async def _start_scheduled_task_notification_delivery(app: FastAPI, startup_conf from app.scheduler.notification_delivery import NotificationDeliveryWorker from deerflow.persistence.engine import get_session_factory from deerflow.persistence.run import RunRepository + from deerflow.persistence.user.im_notifications import ImNotificationPreferencesRepository run_repo = RunRepository(get_session_factory()) @@ -521,6 +523,7 @@ async def _start_scheduled_task_notification_delivery(app: FastAPI, startup_conf # Both repositories come from the same session factory, so the # connection repository is present whenever the outbox one is. resolve_connections=connection_repo.list_connections, + resolve_notification_preferences=ImNotificationPreferencesRepository(get_session_factory()).get, 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, @@ -1335,6 +1338,7 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for # Auth API is mounted at /api/v1/auth app.include_router(auth.router) app.include_router(user_preferences.router) + app.include_router(im_notification_preferences.router) # Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback app.include_router(feedback.router) diff --git a/backend/app/gateway/routers/channel_connections.py b/backend/app/gateway/routers/channel_connections.py index 1f6682f81f..8667086e89 100644 --- a/backend/app/gateway/routers/channel_connections.py +++ b/backend/app/gateway/routers/channel_connections.py @@ -578,7 +578,14 @@ async def get_channel_connections(request: Request) -> ChannelConnectionsRespons return ChannelConnectionsResponse(connections=[]) repo = _get_repository(request, config) rows = await repo.list_connections(_get_user_id(request)) - return ChannelConnectionsResponse(connections=[ChannelConnectionResponse(**row) for row in rows]) + connections = [] + for row in rows: + if row.get("provider") == "wechat": + # Old bindings stored this private routing token in metadata. New + # workers keep it encrypted; never return legacy tokens to browsers. + row = {**row, "metadata": {key: value for key, value in (row.get("metadata") or {}).items() if key != "context_token"}} + connections.append(ChannelConnectionResponse(**row)) + return ChannelConnectionsResponse(connections=connections) @router.delete("/connections/{connection_id}", status_code=204) diff --git a/backend/app/gateway/routers/im_notification_preferences.py b/backend/app/gateway/routers/im_notification_preferences.py new file mode 100644 index 0000000000..d05cf2864a --- /dev/null +++ b/backend/app/gateway/routers/im_notification_preferences.py @@ -0,0 +1,46 @@ +"""Owner-scoped proactive IM opt-outs, independent of browser notifications.""" + +from fastapi import APIRouter, Header, HTTPException, Request +from pydantic import BaseModel, ConfigDict + +from deerflow.persistence.engine import get_session_factory +from deerflow.persistence.user.im_notifications import ImNotificationPreferencesRepository + +router = APIRouter(prefix="/api/channels/notification-preferences", tags=["channel-connections"]) + + +class ImNotificationPreferences(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + feishu: bool | None = None + wecom: bool | None = None + wechat: bool | None = None + qq: bool | None = None + + +def _owner(request: Request, expected_user_id: str) -> str: + user = getattr(request.state, "user", None) + if user is None: + raise HTTPException(401, "Authentication required") + owner = str(user.id) + if owner != expected_user_id: + raise HTTPException(409, "The signed-in account changed; reload this page") + return owner + + +def _repository() -> ImNotificationPreferencesRepository: + sessions = get_session_factory() + if sessions is None: + raise HTTPException(503, "Notification preference persistence is unavailable") + return ImNotificationPreferencesRepository(sessions) + + +@router.get("", response_model=ImNotificationPreferences) +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) +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/scheduler/notification_delivery.py b/backend/app/scheduler/notification_delivery.py index f295763c4c..009cc2c21d 100644 --- a/backend/app/scheduler/notification_delivery.py +++ b/backend/app/scheduler/notification_delivery.py @@ -22,6 +22,7 @@ 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.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 __all__ = ["NotificationDeliveryWorker", "redact_egress_text", "render_notification_text"] @@ -68,6 +69,7 @@ class NotificationDeliveryWorker: batch_size: int = 10, resolve_run_summary: SummaryResolver | None = None, resolve_connections: ConnectionResolver | None = None, + resolve_notification_preferences: Callable[[str], 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, @@ -76,6 +78,7 @@ class NotificationDeliveryWorker: self._resolve_channel = resolve_channel self._resolve_run_summary = resolve_run_summary self._resolve_connections = resolve_connections + self._resolve_notification_preferences = resolve_notification_preferences self._poll_interval_seconds = poll_interval_seconds self._batch_size = batch_size self._stale_sending_timeout_seconds = stale_sending_timeout_seconds @@ -226,6 +229,24 @@ class NotificationDeliveryWorker: return True return False + async def _check_notification_preference(self, delivery: dict[str, Any]) -> bool: + if self._resolve_notification_preferences is None: + return True + try: + preferences = self._resolve_notification_preferences(delivery.get("owner_user_id") or "") + if inspect.isawaitable(preferences): + preferences = await preferences + if not isinstance(preferences, dict) or type(preferences.get(delivery.get("provider"))) is not bool: + raise ValueError("Invalid notification preference response") + except Exception: + logger.warning("Could not verify notification 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 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 + return True + async def _deliver(self, delivery: dict[str, Any]) -> None: delivery_id = delivery["id"] provider = delivery.get("provider") or "" @@ -264,6 +285,8 @@ class NotificationDeliveryWorker: if still_connected is None: await self._delivery_repo.mark_failed(delivery_id, claim_token=delivery.get("claim_token"), error="could not verify the target's channel connection", count_attempt=False) return + if not await self._check_notification_preference(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. @@ -284,6 +307,12 @@ class NotificationDeliveryWorker: # in place (its payload is the durable outbox snapshot). enriched = dict(delivery) enriched["payload"] = {**(delivery.get("payload") or {}), "result_summary": summary} + # Summary/config lookups can await long enough for a user to mute. + if not await self._check_notification_preference(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 try: send = channel.send_group_notification_for_owner if target_type == "group" else channel.send_notification_for_owner await send( diff --git a/backend/app/scheduler/service.py b/backend/app/scheduler/service.py index 1d3c1b3c2a..4b1cf1dc31 100644 --- a/backend/app/scheduler/service.py +++ b/backend/app/scheduler/service.py @@ -22,6 +22,7 @@ from deerflow.persistence.scheduled_task_runs.finalization import LIFECYCLE_EVEN from deerflow.persistence.scheduled_task_runs.model import ScheduledTaskRunRow from deerflow.persistence.scheduled_task_runs.sql import run_number_expression 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.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 @@ -360,10 +361,19 @@ class ScheduledTaskService: group_connection_id = getattr(task, "notification_group_connection_id", None) if not bindings and not group_connection_id: return + # Serialize with opt-out only when there is a potential recipient. + preferences = await ImNotificationPreferencesRepository.get_in_session(session, task.user_id, lock_owner=True) + bindings = [ + binding + for binding in bindings + if preferences.get(binding.provider, False) + # QQ group member OpenIDs are not the C2C recipient namespace. + and (binding.provider != "qq" or ((binding.metadata_json or {}).get("chat_type") == "c2c" and (binding.workspace_id or "").endswith(":c2c"))) + ] 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: + 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"})) diff --git a/backend/docs/API.md b/backend/docs/API.md index d33b9f4912..c4a4be16e8 100644 --- a/backend/docs/API.md +++ b/backend/docs/API.md @@ -1719,7 +1719,7 @@ stripped from client-supplied messages and state updates. **IM notices ("scheduled task updates").** With `channel_connections.enabled`, each item of `GET /api/channels/providers` carries `proactive_notifications` (bool): whether scheduled-task updates are pushed to -that app. Only providers with proactive push (WeCom and Feishu/Lark today) get notices; the +that app. Only providers with proactive push (WeCom, Feishu/Lark, WeChat and QQ private chat today) get notices; the others get none, and Settings says so. An occurrence sends at most one message, in the owner's `locale` preference (see Account Preferences), else `channel_connections.notification_locale`. Notices carry no IDs and no links; @@ -2091,3 +2091,25 @@ a JSON boolean. Writes containing only boolean pin/archive flags preserve `updated_at` and all other metadata. The owner-checked endpoint returns the normal thread metadata response; original thread and artifact URLs remain available. Archiving does not cancel runs, pause schedules, or change retention. + +### Proactive IM notification preferences + +`GET/PATCH /api/channels/notification-preferences` is authenticated and owner-scoped. +Both require `X-Expected-User-Id` matching the authenticated viewer; a stale +account returns 409. Session mutations retain normal CSRF protection. The local +auth-disabled default identity is supported without creating an auth account. + +GET and PATCH return `{ "feishu": true, "wecom": true, "wechat": true, "qq": true }` +with the owner's effective booleans. PATCH accepts only supplied provider fields, +strict booleans or `null` (reset to enabled); other fields are rejected. Missing +preferences preserve the existing connected-channel default. Browser notification +preferences and chat connection state remain independent. + +Opt-out and cancellation of that owner's pending/sending outbox rows for the +provider commit together; the internal terminal reason is `disabled_by_owner`. +Retries consume no additional attempts, and opting back in cannot reopen the +old rows. The worker rechecks preferences and claim ownership before starting a +provider send. A provider call already started may still complete; it cannot be +retracted. All Gateway workers must be upgraded to honor the shared preference. +Migration `0041_im_notice_preferences` adds only a small owner/provider preference +table; downgrade removes these choices, so subsequent upgrades use defaults. diff --git a/backend/docs/IM_CHANNEL_CONNECTIONS.md b/backend/docs/IM_CHANNEL_CONNECTIONS.md index 2c7fbe062b..e316a7f1d8 100644 --- a/backend/docs/IM_CHANNEL_CONNECTIONS.md +++ b/backend/docs/IM_CHANNEL_CONNECTIONS.md @@ -567,8 +567,8 @@ OpenID denies every sender and logs an error. The MVP supports text-only `C2C_MESSAGE_CREATE` and `GROUP_AT_MESSAGE_CREATE` events and final text replies. It does not download/upload attachments, stream -partial answers, handle guild/channel messages, or proactively push scheduled -results. Replies require the original message ID: C2C replies have a 60-minute +partial answers or handle guild/channel messages. Scheduled private results +use the separate proactive notification path described below. Replies require the original message ID: C2C replies have a 60-minute window with at most four replies per source message; group replies have a 5-minute window with at most five replies. Long answers are split at UTF-8 character boundaries with a conservative 4,000-byte adapter budget per message; overflow is marked as @@ -646,3 +646,52 @@ the channel state directory. - Provider bot tokens remain in `channels.*` and are never returned to the browser. - Stored per-connection credentials are encrypted. If stored credential material cannot be decrypted, DeerFlow treats it as unavailable instead of using corrupt secrets. - This implementation does not add public provider callback or webhook routes. + +## Proactive notification settings + +The shared scheduler outbox sends 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 +`DEER_FLOW_AUTH_DISABLED=1` mode. It does not disable chatting, disconnect an +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 +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. + +### WeChat private notifications + +Use the existing iLink QR/bot setup and bind in a private chat. Send one private +message so the worker can securely save the current conversation context. New +contexts live in encrypted connection credentials and survive restart; private +context tokens are not exposed by the browser connection API. Old bindings do +not automatically trust a token previously stored in public metadata, and a +changed bot token needs a fresh private message. Missing context or a platform +rejection fails visibly rather than being recorded as sent. A server-expired +context may need another private message. Notifications use the existing +`sendmessage` HTTP path and require its success ACK, without a provider-local +retry loop. WeChat group notification targets are not part of this version. + +Official protocol: [Tencent/openclaw-weixin](https://github.com/Tencent/openclaw-weixin/blob/main/docs/protocol.md). +The client-side protocol does not guarantee that the server accepts missing or +arbitrarily old conversation contexts; real account acceptance is still required. + +### QQ private notifications + +Configure the existing QQ WebSocket bot and `/connect` in **private/C2C chat**. +Group member OpenIDs are a different recipient namespace; a group-only binding +is not used as a private notification destination. A proactive notice uses +`POST /v2/users/{user_openid}/messages`, without `msg_id`, `event_id`, passive reply +sequence/quota or `is_wakeup`. The recipient must permit proactive messages and +the bot must have platform access. Success requires a nonempty message ID ACK. +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. diff --git a/backend/packages/harness/deerflow/persistence/channel_connections/sql.py b/backend/packages/harness/deerflow/persistence/channel_connections/sql.py index 76a69be17d..984c77e67f 100644 --- a/backend/packages/harness/deerflow/persistence/channel_connections/sql.py +++ b/backend/packages/harness/deerflow/persistence/channel_connections/sql.py @@ -235,6 +235,49 @@ class ChannelConnectionRepository: await session.commit() return len(connection_ids) + async def store_connection_context( + self, + connection_id: str, + *, + owner_user_id: str, + provider: str, + external_account_id: str, + workspace_id: str | None, + extra: dict[str, Any], + ) -> bool: + """Merge private context only while the captured binding is still connected.""" + if self._cipher is None: + raise RuntimeError("channel connection encryption key is required") + async with self.session_factory() as session: + # The guarded write locks the parent on both SQLite and PostgreSQL. + # Transfer/disconnect must finish before this check or wait until + # our credential write commits, then revoke and delete it normally. + result = await session.execute( + update(ChannelConnectionRow) + .where( + ChannelConnectionRow.id == connection_id, + ChannelConnectionRow.owner_user_id == owner_user_id, + ChannelConnectionRow.provider == provider, + ChannelConnectionRow.external_account_id == self._normalize_optional_identity(external_account_id), + ChannelConnectionRow.workspace_id == self._normalize_optional_identity(workspace_id), + ChannelConnectionRow.status == "connected", + ) + .values(updated_at=ChannelConnectionRow.updated_at) + ) + if result.rowcount != 1: + return False + row = await session.get(ChannelCredentialRow, connection_id) + if row is None: + row = ChannelCredentialRow(connection_id=connection_id) + session.add(row) + previous = self._decrypt_optional_secret(row.encrypted_extra_json) + merged = json.loads(previous) if previous else {} + merged.update(extra) + row.encrypted_extra_json = self._encrypt_optional_secret(json.dumps(merged, ensure_ascii=False)) + row.version = (row.version or 0) + 1 + await session.commit() + return True + async def store_credentials( self, connection_id: str, diff --git a/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md b/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md index f8c7327998..356c19b1b0 100644 --- a/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md +++ b/backend/packages/harness/deerflow/persistence/migrations/AGENTS.md @@ -30,7 +30,8 @@ The empty-DB path keeps using `create_all` because `Base.metadata` is the only a `0030_notification_claim_tokens` → `0031_scheduled_streak_boundary` → `0032_activity_and_task_events` → `0033_batch_result_artifact` → `0034_run_event_seq_watermark` → `0035_login_throttle` → `0036_run_idempotency_request` → `0037_project_document_summaries` → -`0038_channel_thread_bindings` (current head). The preference +`0038_channel_thread_bindings` → `0039_user_disabled` → +`0040_scheduled_group_notice` → `0041_im_notice_preferences` (current head). The preference revision adds a separate owner/key table with a cascading users foreign key and does not alter users; the project-documents revision adds a new owner-scoped shelf table, and the MCP lease-token revision adds two nullable token columns to @@ -186,3 +187,12 @@ on installs that never enabled it. The convention is: - `persistence/bootstrap.py` — `bootstrap_schema(engine, backend=...)`, the three-branch provisioning decision, locked revision validation, and the narrow 0019 forward-compatibility exception - `extensions/loader.py::load_extensions` — registers each spec's `table_prefix` with `register_extension_table_prefix()` - Tests: `tests/test_persistence_bootstrap.py` (branches), `tests/test_persistence_bootstrap_concurrency.py` (concurrency), `tests/test_persistence_bootstrap_regression.py` (issue #3682), `tests/test_persistence_migrations_env.py` (filter, including extension-owned tables), `tests/test_extension_loader.py::TestTablePrefixRegistration` (spec-to-filter wiring), `tests/blocking_io/test_persistence_bootstrap.py` (asyncio.to_thread anchor), `tests/test_migration_0004_run_ownership_dedupe.py` + `tests/test_migration_0007_scheduled_run_active_dedupe.py` (dedupe-before-unique-index pre-steps), `tests/test_migration_0025_repair_run_change_seq.py` (issue #5516 skipped-revision heal) + + +`0041_im_notice_preferences` adds the owner/provider proactive-notification +boolean table without a users FK (local synthetic IM owners are supported). +Upgrade/drop are table-guarded; fresh bootstrap registers the ORM model. +It follows 0040 without reparenting history, has no backfill and leaves the +forward-compatibility floor unchanged. Downgrade removes the saved choices; +reenabling a provider never reopens notifications canceled before downgrade. +Tests: `test_migration_0041_im_notice_preferences.py`. diff --git a/backend/packages/harness/deerflow/persistence/migrations/versions/0041_im_notice_preferences.py b/backend/packages/harness/deerflow/persistence/migrations/versions/0041_im_notice_preferences.py new file mode 100644 index 0000000000..83e1f78ac1 --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/migrations/versions/0041_im_notice_preferences.py @@ -0,0 +1,28 @@ +"""Shared IM notification preferences, including local synthetic owners. + +Revision ID: 0041_im_notice_preferences +Revises: 0040_scheduled_group_notice +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0041_im_notice_preferences" +down_revision = "0040_scheduled_group_notice" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + if not sa.inspect(op.get_bind()).has_table("im_notification_preferences"): + op.create_table( + "im_notification_preferences", + sa.Column("owner_user_id", sa.String(64), primary_key=True), + sa.Column("provider", sa.String(32), primary_key=True), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + + +def downgrade() -> None: + if sa.inspect(op.get_bind()).has_table("im_notification_preferences"): + op.drop_table("im_notification_preferences") diff --git a/backend/packages/harness/deerflow/persistence/models/__init__.py b/backend/packages/harness/deerflow/persistence/models/__init__.py index 74f03e81f8..b27ed0cf1c 100644 --- a/backend/packages/harness/deerflow/persistence/models/__init__.py +++ b/backend/packages/harness/deerflow/persistence/models/__init__.py @@ -37,6 +37,7 @@ from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRow from deerflow.persistence.subagent_batches.model import SubagentBatchItemRow, SubagentBatchRow from deerflow.persistence.thread_meta.model import ThreadMetaRow from deerflow.persistence.thread_reads.model import ThreadReadMarkerRow, ThreadReadVersionRow +from deerflow.persistence.user.im_notifications import ImNotificationPreferenceRow from deerflow.persistence.user.model import UserPreferenceRow, UserRow from deerflow.persistence.webhook_delivery.model import WebhookDeliveryRow @@ -68,6 +69,7 @@ __all__ = [ "ThreadReadMarkerRow", "ThreadReadVersionRow", "UserPreferenceRow", + "ImNotificationPreferenceRow", "UserRow", "WebhookDeliveryRow", ] diff --git a/backend/packages/harness/deerflow/persistence/notification_deliveries/sql.py b/backend/packages/harness/deerflow/persistence/notification_deliveries/sql.py index 0af9ba28dd..3d69de2da2 100644 --- a/backend/packages/harness/deerflow/persistence/notification_deliveries/sql.py +++ b/backend/packages/harness/deerflow/persistence/notification_deliveries/sql.py @@ -259,6 +259,14 @@ class NotificationDeliveryRepository: await session.commit() return result.rowcount + async def is_active_claim(self, delivery_id: str, *, claim_token: str | None = None) -> bool: + """Do not send a row canceled while resolving its summary or preferences.""" + conditions = [NotificationDeliveryRow.id == delivery_id, NotificationDeliveryRow.status.in_(("pending", "sending"))] + if claim_token is not None: + conditions += [NotificationDeliveryRow.status == "sending", NotificationDeliveryRow.claim_token == claim_token] + async with self.session_factory() as session: + return await session.scalar(select(NotificationDeliveryRow.id).where(*conditions)) is not None + async def mark_sent(self, delivery_id: str, *, claim_token: str | None = None) -> dict[str, Any]: async with self.session_factory() as session: row = await session.get(NotificationDeliveryRow, delivery_id) diff --git a/backend/packages/harness/deerflow/persistence/user/AGENTS.md b/backend/packages/harness/deerflow/persistence/user/AGENTS.md index dc5e5e1019..73435eb2f9 100644 --- a/backend/packages/harness/deerflow/persistence/user/AGENTS.md +++ b/backend/packages/harness/deerflow/persistence/user/AGENTS.md @@ -10,3 +10,12 @@ requires a browser session plus `X-Expected-User-Id` matching that session (a stale-tab guard, never an authorization source); PAT, internal, and auth-disabled callers are rejected. Never persist arbitrary agent context or credentials through this API. See `backend/docs/API.md` for the HTTP contract. + + +`im_notifications.py` stores shared proactive-IM choices in the separate +`im_notification_preferences` owner/provider table (0041), without a users FK: +IM binding owners include the synthetic local `default` identity. Opt-out and +terminal cancellation of pending/sending outbox rows share one transaction; +reenabling never reopens them. PostgreSQL enqueue/patch lock preference rows in +provider order; SQLite uses the enclosing writer transaction. Browser preference +storage is unchanged. The app owns route auth, CSRF and stale-viewer checks. diff --git a/backend/packages/harness/deerflow/persistence/user/im_notifications.py b/backend/packages/harness/deerflow/persistence/user/im_notifications.py new file mode 100644 index 0000000000..4aecb5be34 --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/user/im_notifications.py @@ -0,0 +1,75 @@ +"""Shared, durable opt-out policy for proactive IM notices.""" + +from datetime import UTC, datetime + +from sqlalchemy import Boolean, String, 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 + +from deerflow.persistence.base import Base +from deerflow.persistence.notification_deliveries.model import NotificationDeliveryRow + +IM_NOTIFICATION_PROVIDERS = ("feishu", "wecom", "wechat", "qq") +DISABLED_BY_OWNER = "disabled_by_owner" + + +class ImNotificationPreferenceRow(Base): + """Owner IDs also include auth-disabled local identities, like IM bindings.""" + + __tablename__ = "im_notification_preferences" + owner_user_id: Mapped[str] = mapped_column(String(64), primary_key=True) + provider: Mapped[str] = mapped_column(String(32), primary_key=True) + enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=true()) + + +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. + """ + + 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() + rows = await session.execute(statement) + stored = dict(rows.all()) + return {provider: stored.get(provider, True) is True for provider in IM_NOTIFICATION_PROVIDERS} + + async def get(self, user_id: str) -> dict[str, bool]: + async with self.sessions() as session: + return await self.get_in_session(session, user_id) + + 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()): + raise ValueError("Invalid IM notification preference") + async with self.sessions() as session, session.begin(): + dialect = session.get_bind().dialect.name + if dialect == "postgresql": + 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: + # 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)) + ) + return await self.get_in_session(session, user_id) diff --git a/backend/tests/blocking_io/test_qq_channel.py b/backend/tests/blocking_io/test_qq_channel.py index 86742677d7..365cde18d6 100644 --- a/backend/tests/blocking_io/test_qq_channel.py +++ b/backend/tests/blocking_io/test_qq_channel.py @@ -1,5 +1,8 @@ """QQ transport setup, token requests and message routing must stay async.""" +import asyncio +import json +from types import SimpleNamespace from unittest.mock import AsyncMock import httpx @@ -52,3 +55,43 @@ async def test_qq_transport_and_message_path_do_not_block(monkeypatch): request.assert_awaited_once() finally: await channel.stop() + + +@pytest.mark.asyncio +async def test_qq_notification_serialized_http_path_does_not_block(): + requests = [] + + def respond(request): + requests.append(request) + if request.url.host == "bots.qq.com": + return httpx.Response(200, json={"access_token": "fixture-token", "expires_in": 7200}) + return httpx.Response(200, json={"id": "notification"}) + + repo = SimpleNamespace( + list_connections=AsyncMock( + return_value=[ + { + "owner_user_id": "owner", + "provider": "qq", + "status": "connected", + "external_account_id": "user-openid", + "workspace_id": "app1:c2c", + "metadata": {"chat_type": "c2c"}, + } + ] + ) + ) + channel = QQChannel(MessageBus(), {"app_id": "app1", "client_secret": "fixture-secret", "connection_repo": repo}) + try: + await channel._setup_transport() + await channel._http.aclose() + channel._http = await asyncio.to_thread(httpx.AsyncClient, transport=httpx.MockTransport(respond), timeout=15, trust_env=False) + channel._running = True + channel._ready.set() + channel._listener = asyncio.create_task(asyncio.Event().wait()) + await channel.send_notification_for_owner(owner_user_id="owner", target="user-openid", text_markdown="任务完成") + assert len(requests) == 2 + assert requests[1].url.path == "/v2/users/user-openid/messages" + assert json.loads(requests[1].content) == {"msg_type": 0, "content": "任务完成"} + finally: + await channel.stop() diff --git a/backend/tests/test_channel_capabilities.py b/backend/tests/test_channel_capabilities.py index d962c70b04..e3f47f7e87 100644 --- a/backend/tests/test_channel_capabilities.py +++ b/backend/tests/test_channel_capabilities.py @@ -26,7 +26,7 @@ _CHANNEL_SDK_MODULES = ("lark_oapi", "slack_sdk", "telegram", "discord", "dingta def test_capabilities_cover_channel_registry(): # A provider registered without a capability entry (as qq once was) fails here. assert set(CHANNEL_CAPABILITIES) == set(_CHANNEL_REGISTRY) - assert CHANNEL_CAPABILITIES["qq"]["proactive_notifications"] is False + assert CHANNEL_CAPABILITIES["qq"]["proactive_notifications"] is True def test_every_entry_declares_both_flags_as_bools(): @@ -35,10 +35,10 @@ def test_every_entry_declares_both_flags_as_bools(): assert all(isinstance(value, bool) for value in entry.values()), provider -def test_wecom_and_feishu_push_proactively(): - assert {provider for provider, entry in CHANNEL_CAPABILITIES.items() if entry["proactive_notifications"]} == {"wecom", "feishu"} - assert supports_proactive_notifications("wecom") is True - assert supports_proactive_notifications("feishu") is True +def test_supported_notification_channels_push_proactively(): + assert {provider for provider, entry in CHANNEL_CAPABILITIES.items() if entry["proactive_notifications"]} == {"wecom", "feishu", "wechat", "qq"} + for provider in ("wecom", "feishu", "wechat", "qq"): + assert supports_proactive_notifications(provider) is True @pytest.mark.parametrize("provider", ["", "unknown", "WeCom", None, 1]) diff --git a/backend/tests/test_channel_connection_context.py b/backend/tests/test_channel_connection_context.py new file mode 100644 index 0000000000..c4f9c4674a --- /dev/null +++ b/backend/tests/test_channel_connection_context.py @@ -0,0 +1,189 @@ +"""Private context writes cannot restore credentials after binding revocation.""" + +from __future__ import annotations + +import asyncio +import json +import os +import uuid +from unittest.mock import AsyncMock + +import httpx +import pytest +import pytest_asyncio +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.sql.dml import Update +from support.postgres import asyncpg_test_url + +from app.channels.message_bus import MessageBus +from app.channels.wechat import WechatChannel +from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher, ChannelCredentialRow +from deerflow.persistence.postgres_schema import build_asyncpg_connect_args + + +@pytest_asyncio.fixture(params=["sqlite", "postgres"]) +async def repo(tmp_path, request): + if request.param == "postgres": + uri = os.environ.get("TEST_POSTGRES_URI") + if not uri: + pytest.skip("requires TEST_POSTGRES_URI (real Postgres connection-context guards)") + import deerflow.persistence.models # noqa: F401 -- production ORM registration + from deerflow.persistence.base import Base + + schema = f"channel_context_{uuid.uuid4().hex}" + engine = create_async_engine(asyncpg_test_url(uri), connect_args=build_asyncpg_connect_args(schema)) + created = False + try: + async with engine.begin() as connection: + await connection.execute(text(f'CREATE SCHEMA "{schema}"')) + created = True + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + yield ChannelConnectionRepository(async_sessionmaker(engine, expire_on_commit=False), cipher=ChannelCredentialCipher.from_key("test-only-context-key")) + finally: + try: + if created: + async with engine.begin() as connection: + await connection.execute(text(f'DROP SCHEMA "{schema}" CASCADE')) + finally: + await engine.dispose() + return + + from deerflow.persistence.engine import close_engine, get_session_factory, init_engine + + await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'channels.db'}", sqlite_dir=str(tmp_path)) + try: + yield ChannelConnectionRepository(get_session_factory(), cipher=ChannelCredentialCipher.from_key("test-only-context-key")) + finally: + await close_engine() + + +async def _bind(repo, owner="owner-1"): + return await repo.upsert_connection(owner_user_id=owner, provider="wechat", external_account_id="wx-user", workspace_id="wx-user") + + +async def _store(repo, connection, **overrides): + args = {"owner_user_id": "owner-1", "provider": "wechat", "external_account_id": "wx-user", "workspace_id": "wx-user", "extra": {"context": "private-context"}, **overrides} + return await repo.store_connection_context(connection["id"], **args) + + +@pytest.mark.asyncio +async def test_context_is_encrypted_and_preserves_existing_credentials(repo): + connection = await _bind(repo) + await repo.store_credentials(connection["id"], access_token="access", refresh_token="refresh", token_type="Bearer", extra={"another_context": "keep"}) + assert await _store(repo, connection) + restored = ChannelConnectionRepository(repo.session_factory, cipher=ChannelCredentialCipher.from_key("test-only-context-key")) + credentials = await restored.get_credentials(connection["id"]) + assert credentials["access_token"] == "access" + assert credentials["refresh_token"] == "refresh" + assert credentials["token_type"] == "Bearer" + assert credentials["extra"] == {"another_context": "keep", "context": "private-context"} + assert (await repo.list_connections("owner-1"))[0]["metadata"] == {} + async with repo.session_factory() as session: + row = await session.get(ChannelCredentialRow, connection["id"]) + assert "private-context" not in row.encrypted_extra_json + + +@pytest.mark.asyncio +@pytest.mark.parametrize("override", [{"owner_user_id": "other"}, {"provider": "feishu"}, {"external_account_id": "other"}, {"workspace_id": "group"}]) +async def test_context_write_requires_matching_current_identity(repo, override): + connection = await _bind(repo) + assert not await _store(repo, connection, **override) + assert await repo.get_credentials(connection["id"]) is None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("change", ["disconnect", "transfer"]) +async def test_delayed_context_write_cannot_restore_revoked_credentials(repo, change): + connection = await _bind(repo) + await repo.store_credentials(connection["id"], access_token=None, extra={"context": "old"}) + ready, release = asyncio.Event(), asyncio.Event() + + async def delayed_write(): + ready.set() + await release.wait() + return await _store(repo, connection) + + pending = asyncio.create_task(delayed_write()) + try: + await ready.wait() + if change == "disconnect": + await repo.disconnect_connection(connection_id=connection["id"], owner_user_id="owner-1") + else: + await _bind(repo, "owner-2") + release.set() + assert not await pending + assert await repo.get_credentials(connection["id"]) is None + finally: + release.set() + await asyncio.gather(pending, return_exceptions=True) + + +@pytest.mark.asyncio +async def test_transfer_waits_for_context_write_then_removes_its_credentials(repo): + connection = await _bind(repo) + locked, attempted, release = asyncio.Event(), asyncio.Event(), asyncio.Event() + + class WriterSession(AsyncSession): + async def execute(self, statement, *args, **kwargs): + result = await super().execute(statement, *args, **kwargs) + if isinstance(statement, Update) and statement.table.name == "channel_connections": + locked.set() + await release.wait() + return result + + class TransferSession(AsyncSession): + async def execute(self, statement, *args, **kwargs): + if isinstance(statement, Update) and statement.table.name == "channel_connections": + attempted.set() + return await super().execute(statement, *args, **kwargs) + + cipher = ChannelCredentialCipher.from_key("test-only-context-key") + engine = repo.session_factory.kw["bind"] + writer_repo = ChannelConnectionRepository(async_sessionmaker(engine, class_=WriterSession, expire_on_commit=False), cipher=cipher) + transfer_repo = ChannelConnectionRepository(async_sessionmaker(engine, class_=TransferSession, expire_on_commit=False), cipher=cipher) + writer = asyncio.create_task(_store(writer_repo, connection)) + transfer = None + try: + await asyncio.wait_for(locked.wait(), 3) + transfer = asyncio.create_task(_bind(transfer_repo, "owner-2")) + await asyncio.wait_for(attempted.wait(), 3) + assert not transfer.done() + release.set() + assert await asyncio.wait_for(writer, 3) + replacement = await asyncio.wait_for(transfer, 3) + assert await repo.get_credentials(connection["id"]) is None + assert await repo.get_credentials(replacement["id"]) is None + finally: + release.set() + await asyncio.gather(writer, *([transfer] if transfer else []), return_exceptions=True) + + +@pytest.mark.asyncio +async def test_wechat_private_context_survives_channel_and_repository_restart(repo): + connection = await _bind(repo) + old = WechatChannel(MessageBus(), {"bot_token": "bot-token", "connection_repo": repo}) + old._publish_inbound_or_drop = AsyncMock() + await old._handle_update({"message_type": 1, "from_user_id": "wx-user", "context_token": "restored-private-context", "item_list": [{"type": 1, "text_item": {"text": "hello"}}]}) + assert "context_token" not in (await repo.list_connections("owner-1"))[0]["metadata"] + restored_repo = ChannelConnectionRepository(repo.session_factory, cipher=ChannelCredentialCipher.from_key("test-only-context-key")) + channel = WechatChannel(MessageBus(), {"bot_token": "bot-token", "connection_repo": restored_repo}) + channel._running = True + sent = [] + + def transport(request): + sent.append(json.loads(request.content)) + return httpx.Response(200, json={"ret": 0}) + + channel._client = httpx.AsyncClient(transport=httpx.MockTransport(transport)) + try: + assert not channel._context_tokens_by_chat + await channel.send_notification_for_owner(owner_user_id="owner-1", target="wx-user", text_markdown="Done") + assert sent[0]["msg"]["context_token"] == "restored-private-context" + await repo.disconnect_connection(connection_id=connection["id"], owner_user_id="owner-1") + with pytest.raises(RuntimeError, match="no longer connected"): + await channel.send_notification_for_owner(owner_user_id="owner-1", target="wx-user", text_markdown="Done") + assert len(sent) == 1 + finally: + await channel.stop() diff --git a/backend/tests/test_channel_connections_router.py b/backend/tests/test_channel_connections_router.py index f6fac5c0ae..3b3d4c454f 100644 --- a/backend/tests/test_channel_connections_router.py +++ b/backend/tests/test_channel_connections_router.py @@ -207,7 +207,7 @@ def test_get_providers_uses_existing_channels_config(tmp_path): "discord": False, "feishu": True, "dingtalk": False, - "wechat": False, + "wechat": True, "wecom": True, } @@ -1594,7 +1594,7 @@ def test_qq_provider_exposes_binding_flow_and_masks_secret(tmp_path): assert provider["auth_mode"] == "binding_code" assert provider["configured"] is True assert provider["connectable"] is True - assert provider["proactive_notifications"] is False + assert provider["proactive_notifications"] is True assert "fixture-private-secret" not in response.text fields = {field["name"]: field for field in provider["credential_fields"]} assert fields["app_id"]["type"] == "text" diff --git a/backend/tests/test_im_notification_preferences.py b/backend/tests/test_im_notification_preferences.py new file mode 100644 index 0000000000..326922cefd --- /dev/null +++ b/backend/tests/test_im_notification_preferences.py @@ -0,0 +1,370 @@ +"""Owner-controlled IM notices, including durable cancellation on opt-out.""" + +import asyncio +import os +import uuid +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import event, select, text +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from support.postgres import asyncpg_test_url + +from deerflow.persistence.base import Base +from deerflow.persistence.notification_deliveries import NotificationDeliveryRepository +from deerflow.persistence.notification_deliveries.model import NotificationDeliveryRow +from deerflow.persistence.postgres_schema import build_asyncpg_connect_args +from deerflow.persistence.user.model import UserRow + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture(params=["sqlite", "postgres"]) +async def repositories(tmp_path, request): + import deerflow.persistence.models # noqa: F401 -- production ORM registration + from deerflow.persistence.user.im_notifications import ImNotificationPreferencesRepository + + schema = None + if request.param == "postgres": + uri = os.environ.get("TEST_POSTGRES_URI") + if not uri: + pytest.skip("requires TEST_POSTGRES_URI (real IM preference transactions)") + schema = f"im_notice_{uuid.uuid4().hex}" + engine = create_async_engine(asyncpg_test_url(uri), connect_args=build_asyncpg_connect_args(schema)) + else: + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/preferences.db") + + @event.listens_for(engine.sync_engine, "connect") + def foreign_keys(connection, _): + connection.execute("PRAGMA foreign_keys=ON") + + try: + async with engine.begin() as connection: + if schema: + await connection.execute(text(f'CREATE SCHEMA "{schema}"')) + await connection.run_sync(Base.metadata.create_all) + sessions = async_sessionmaker(engine, expire_on_commit=False) + async with sessions() as session, session.begin(): + session.add_all([UserRow(id="alice", email="alice@example.com"), UserRow(id="bob", email="bob@example.com")]) + yield ImNotificationPreferencesRepository(sessions), NotificationDeliveryRepository(sessions) + finally: + if schema: + async with engine.begin() as connection: + await connection.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')) + await engine.dispose() + + +async def enqueue(repo, *, owner="alice", provider="feishu", suffix="one"): + return await repo.enqueue(task_id="task", task_run_id=suffix, provider=provider, target=f"recipient-{suffix}", owner_user_id=owner, event="run_completed") + + +@pytest.mark.anyio +async def test_defaults_persist_and_disjoint_channel_edits_do_not_clobber(repositories): + preferences, _ = repositories + assert await preferences.get("alice") == dict.fromkeys(("feishu", "wecom", "wechat", "qq"), True) + await asyncio.gather(preferences.patch("alice", {"feishu": False}), preferences.patch("alice", {"qq": False})) + values = await type(preferences)(preferences.sessions).get("alice") + assert values == {"feishu": False, "wecom": True, "wechat": True, "qq": False} + assert (await preferences.get("bob"))["feishu"] is True + assert (await preferences.patch("alice", {"feishu": None}))["feishu"] is True + assert (await preferences.get("alice"))["qq"] is False + # The actual local synthetic owner has no auth-users row. Do not fabricate + # a login account or disable production FK enforcement to store its choice. + assert (await preferences.patch("default", {"qq": False}))["qq"] is False + + +@pytest.mark.anyio +async def test_disable_cancels_pending_parked_claimed_and_group_but_not_other_owners_or_channels(repositories): + preferences, deliveries = repositories + pending = await enqueue(deliveries) + parked = await enqueue(deliveries, suffix="parked") + claimed = await enqueue(deliveries, suffix="claimed") + group = await deliveries.enqueue(task_id="task", task_run_id="group", provider="feishu", target="group-binding", owner_user_id="alice", event="run_completed", payload={"target_type": "group"}) + other_owner = await enqueue(deliveries, owner="bob", suffix="bob") + other_channel = await enqueue(deliveries, provider="wecom", suffix="wecom") + sent = await enqueue(deliveries, suffix="sent") + await deliveries.mark_sent(sent["id"]) + async with deliveries.session_factory() as session, session.begin(): + row = await session.get(NotificationDeliveryRow, parked["id"]) + row.available_at = datetime.now(UTC) + timedelta(days=1) + row.parked_attempts = 3 + row = await session.get(NotificationDeliveryRow, claimed["id"]) + row.status, row.claim_token = "sending", "old-claim" + await preferences.patch("alice", {"feishu": False}) + await preferences.patch("alice", {"feishu": True}) + # Opting in again must not resurrect the historical backlog. + async with deliveries.session_factory() as session: + for item in (pending, parked, claimed, group): + row = await session.get(NotificationDeliveryRow, item["id"]) + assert (row.status, row.last_error, row.claim_token, row.attempts) == ("failed", "disabled_by_owner", None, 0) + assert (await session.get(NotificationDeliveryRow, sent["id"])).status == "sent" + assert {row["id"] for row in await deliveries.claim_due_deliveries(now=datetime.now(UTC), limit=20)} == {other_owner["id"], other_channel["id"]} + # A stale worker cannot overwrite the opt-out or enqueue another retry. + row = await deliveries.mark_failed(claimed["id"], claim_token="old-claim", error="temporary", count_attempt=False) + assert row["status"] == "failed" + + +@pytest.mark.anyio +async def test_preference_and_outbox_cancellation_rollback_together(repositories, monkeypatch): + preferences, deliveries = repositories + item = await enqueue(deliveries) + original = preferences.sessions + + class FailingSessions: + def __call__(self): + session = original() + execute = session.execute + + async def fail_cancel(statement, *args, **kwargs): + if statement.is_update and statement.table.name == "notification_deliveries": + raise RuntimeError("outbox write failed") + return await execute(statement, *args, **kwargs) + + session.execute = fail_cancel + return session + + monkeypatch.setattr(preferences, "sessions", FailingSessions()) + with pytest.raises(RuntimeError, match="outbox write failed"): + await preferences.patch("alice", {"feishu": False}) + monkeypatch.setattr(preferences, "sessions", original) + assert (await preferences.get("alice"))["feishu"] is True + async with original() as session: + assert (await session.get(NotificationDeliveryRow, item["id"])).status == "pending" + + +@pytest.fixture +async def api(repositories, monkeypatch): + from app.gateway.routers import im_notification_preferences + + preferences, _ = repositories + app = FastAPI() + app.include_router(im_notification_preferences.router) + identity = SimpleNamespace(id="alice", source="session") + + @app.middleware("http") + async def identity_middleware(request, call_next): + if identity.id: + request.state.user = SimpleNamespace(id=identity.id) + request.state.auth_source = identity.source + return await call_next(request) + + monkeypatch.setattr(im_notification_preferences, "_repository", lambda: preferences) + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test", headers={"X-Expected-User-Id": "alice"}) as client: + yield client, identity + + +@pytest.mark.anyio +async def test_api_partial_updates_auth_and_stale_tab_fencing(api): + client, identity = api + path = "/api/channels/notification-preferences" + assert (await client.patch(path, json={"feishu": False})).json() == {"feishu": False, "wecom": True, "wechat": True, "qq": True} + identity.id = "bob" + assert (await client.patch(path, json={"qq": False})).status_code == 409 + assert (await client.get(path)).status_code == 409 + client.headers["X-Expected-User-Id"] = "bob" + assert (await client.get(path)).json()["feishu"] is True + identity.source = "auth_disabled" + assert (await client.patch(path, json={"qq": False})).json()["qq"] is False + identity.id = None + assert (await client.get(path)).status_code == 401 + + +@pytest.mark.anyio +@pytest.mark.parametrize("body", [{"qq": "false"}, {"qq": 0}, {"slack": False}, {"owner_user_id": "bob"}, {"notification_enabled": False}]) +async def test_api_rejects_coercion_unknown_platforms_and_browser_settings(api, body): + client, _ = api + assert (await client.patch("/api/channels/notification-preferences", json=body)).status_code == 422 + + +@pytest.mark.anyio +async def test_worker_rechecks_policy_after_summary_await_and_before_channel_down_parking(repositories): + from app.scheduler.notification_delivery import NotificationDeliveryWorker + + preferences, deliveries = repositories + item = await enqueue(deliveries) + item["run_id"] = "run" + item["payload"] = {"version": 2, "run_status": "success"} + item["status"] = "sending" + # Force a slow summary lookup to complete after the owner opts out. + sends = [] + + async def summary(*args): + await preferences.patch("alice", {"feishu": False}) + return "done" + + class Channel: + is_running = True + + async def send_notification_for_owner(self, **kwargs): + sends.append(kwargs) + + worker = NotificationDeliveryWorker(delivery_repo=deliveries, resolve_channel=lambda _: Channel(), resolve_notification_preferences=preferences.get, resolve_run_summary=summary) + await worker._deliver(item) + assert sends == [] + # A disabled recipient does not sit parked until the channel returns. + late = await enqueue(deliveries, suffix="late") + worker = NotificationDeliveryWorker(delivery_repo=deliveries, resolve_channel=lambda _: None, resolve_notification_preferences=preferences.get) + await worker._deliver(late) + async with deliveries.session_factory() as session: + assert (await session.get(NotificationDeliveryRow, late["id"])).status == "failed" + + +@pytest.mark.anyio +async def test_worker_preference_read_failure_parks_without_sending_or_spending_attempts(repositories): + from app.scheduler.notification_delivery import NotificationDeliveryWorker + + _, deliveries = repositories + item = await enqueue(deliveries) + + async def unavailable(_): + raise RuntimeError("database unavailable") + + worker = NotificationDeliveryWorker(delivery_repo=deliveries, resolve_channel=lambda _: pytest.fail("must not reach channel"), resolve_notification_preferences=unavailable) + await worker._deliver(item) + async with deliveries.session_factory() as session: + row = await session.get(NotificationDeliveryRow, item["id"]) + assert (row.status, row.attempts, row.parked_attempts) == ("pending", 0, 1) + + +@pytest.mark.anyio +@pytest.mark.parametrize("provider", ["feishu", "wecom", "wechat", "qq"]) +async def test_normal_finalization_enqueues_only_enabled_channels_and_no_qq_group_fallback(repositories, provider): + from app.scheduler.service import ScheduledTaskService + from deerflow.persistence.channel_connections.model import ChannelConnectionRow + + preferences, deliveries = repositories + sessions = deliveries.session_factory + async with sessions() as session, session.begin(): + for channel in ("feishu", "wecom", "wechat", "qq"): + session.add( + ChannelConnectionRow( + id=channel, + owner_user_id="alice", + provider=channel, + status="connected", + external_account_id=f"{channel}-user", + workspace_id="app:c2c" if channel == "qq" else "private", + metadata_json={"chat_type": "c2c" if channel == "qq" else "p2p"}, + ) + ) + session.add(ChannelConnectionRow(id="qq-group", owner_user_id="alice", provider="qq", status="connected", external_account_id="group-member", workspace_id="app:group:one", metadata_json={"chat_type": "group"})) + await preferences.patch("alice", {provider: False}) + # This is the production finalization observer and real transaction/outbox, + # not a duplicate of enqueue policy in a test-specific scheduler. + service = ScheduledTaskService(task_repo=SimpleNamespace(), task_run_repo=SimpleNamespace(), launch_run=None, connection_repo=object(), notification_repo=deliveries, poll_interval_seconds=5, lease_seconds=30, max_concurrent_runs=1) + task = SimpleNamespace(id="task", user_id="alice", title="Digest", origin_thread_id=None, notification_group_connection_id="feishu-group") + occurrence = SimpleNamespace(id="occurrence", trigger="scheduled", status="success", run_id="run", error=None) + async with sessions() as session, session.begin(): + await service._on_finalization(session, task, occurrence, events=("run_completed",)) + async with sessions() as session: + rows = (await session.scalars(select(NotificationDeliveryRow))).all() + assert {row.provider for row in rows} == {"feishu", "wecom", "wechat", "qq"} - {provider} + assert all(row.target != "group-member" for row in rows) + assert sum(row.target == "feishu-group" for row in rows) == (0 if provider == "feishu" else 1) + + +@pytest.mark.anyio +@pytest.mark.parametrize("binding", [None, {"status": "disconnected"}, {"provider": "slack"}, {"owner_user_id": "bob"}, {"external_account_id": ""}, {"provider": ""}]) +async def test_finalization_without_push_recipients_skips_preference_queries(repositories, binding): + from app.scheduler.service import ScheduledTaskService + from deerflow.persistence.channel_connections.model import ChannelConnectionRow + + _, deliveries = repositories + sessions = deliveries.session_factory + if binding is not None: + async with sessions() as session, session.begin(): + values = {"id": "binding", "owner_user_id": "alice", "provider": "wecom", "status": "connected", "external_account_id": "recipient"} + session.add(ChannelConnectionRow(**{**values, **binding})) + service = ScheduledTaskService(task_repo=SimpleNamespace(), task_run_repo=SimpleNamespace(), launch_run=None, connection_repo=object(), notification_repo=deliveries, poll_interval_seconds=5, lease_seconds=30, max_concurrent_runs=1) + task = SimpleNamespace(id="task", user_id="alice", title="Digest", origin_thread_id=None, notification_group_connection_id=None) + occurrence = SimpleNamespace(id="occurrence", trigger="scheduled", status="success", run_id="run", error=None) + statements = [] + + def record_statement(_connection, _cursor, statement, _parameters, _context, _executemany): + statements.append(statement) + + async with sessions() as session, session.begin(): + engine = session.get_bind() + event.listen(engine, "before_cursor_execute", record_statement) + try: + await service._on_finalization(session, task, occurrence, events=("run_completed",)) + finally: + event.remove(engine, "before_cursor_execute", record_statement) + assert any("channel_connections" in statement for statement in statements) + assert not any("im_notification_preferences" in statement for statement in statements) + async with sessions() as session: + assert (await session.scalars(select(NotificationDeliveryRow))).all() == [] + + +@pytest.mark.anyio +@pytest.mark.parametrize("enabled", [True, False]) +async def test_group_only_finalization_still_locks_and_honors_preferences(repositories, monkeypatch, enabled): + from app.scheduler.service import ScheduledTaskService + from deerflow.persistence.user.im_notifications import ImNotificationPreferencesRepository + + preferences, deliveries = repositories + await preferences.patch("alice", {"feishu": enabled}) + original = ImNotificationPreferencesRepository.get_in_session + locks = [] + + async def record_preference_lookup(session, user_id, *, lock_owner=False): + locks.append((session, user_id, lock_owner)) + return await original(session, user_id, lock_owner=lock_owner) + + monkeypatch.setattr(ImNotificationPreferencesRepository, "get_in_session", staticmethod(record_preference_lookup)) + service = ScheduledTaskService(task_repo=SimpleNamespace(), task_run_repo=SimpleNamespace(), launch_run=None, connection_repo=object(), notification_repo=deliveries, poll_interval_seconds=5, lease_seconds=30, max_concurrent_runs=1) + task = SimpleNamespace(id="task", user_id="alice", title="Digest", origin_thread_id=None, notification_group_connection_id="feishu-group") + occurrence = SimpleNamespace(id="occurrence", trigger="scheduled", status="success", run_id="run", error=None) + async with deliveries.session_factory() as session, session.begin(): + await service._on_finalization(session, task, occurrence, events=("run_completed",)) + assert locks == [(session, "alice", True)] + async with deliveries.session_factory() as session: + rows = (await session.scalars(select(NotificationDeliveryRow))).all() + assert [(row.provider, row.target, row.payload_json["target_type"]) for row in rows] == ([("feishu", "feishu-group", "group")] if enabled else []) + + +@pytest.mark.anyio +async def test_worker_does_not_send_canceled_claim_after_fast_reenable(repositories): + from app.scheduler.notification_delivery import NotificationDeliveryWorker + + preferences, deliveries = repositories + await enqueue(deliveries) + (claimed,) = await deliveries.claim_due_deliveries(now=datetime.now(UTC), limit=1) + await preferences.patch("alice", {"feishu": False}) + await preferences.patch("alice", {"feishu": True}) + + class Channel: + is_running = True + + async def send_notification_for_owner(self, **_): + pytest.fail("A canceled claim must not send after the channel is reenabled") + + worker = NotificationDeliveryWorker(delivery_repo=deliveries, resolve_channel=lambda _: Channel(), resolve_notification_preferences=preferences.get) + await worker._deliver(claimed) + + +@pytest.mark.anyio +async def test_public_wechat_connection_metadata_omits_legacy_context_token(repositories, monkeypatch): + from app.gateway.routers import channel_connections + + row = {"id": "binding", "provider": "wechat", "status": "connected", "metadata": {"context_token": "private-routing-token", "chat_type": "p2p"}} + + class Connections: + async def list_connections(self, _): + return [row] + + async def config(_): + return SimpleNamespace(enabled=True) + + monkeypatch.setattr(channel_connections, "_get_channel_connections_config", config) + monkeypatch.setattr(channel_connections, "_get_repository", lambda *_: Connections()) + request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(id="alice"))) + response = await channel_connections.get_channel_connections(request) + assert response.connections[0].metadata == {"chat_type": "p2p"} + assert row["metadata"]["context_token"] == "private-routing-token" diff --git a/backend/tests/test_migration_0027_0028_notification_outbox.py b/backend/tests/test_migration_0027_0028_notification_outbox.py index 4518de296e..c3ba9020a0 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 = "0040_scheduled_group_notice" +HEAD = "0041_im_notice_preferences" 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 643a5cc3ac..abdab0174a 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 = "0040_scheduled_group_notice" +CURRENT_HEAD = "0041_im_notice_preferences" 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 09bbce92c4..95fda94eca 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 = "0040_scheduled_group_notice" +CURRENT_HEAD = "0041_im_notice_preferences" 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 3f5ae05607..8f76d3517c 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 = "0040_scheduled_group_notice" +CURRENT_HEAD = "0041_im_notice_preferences" 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 245ff7e1f3..e39f7c3b0c 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 = "0040_scheduled_group_notice" +CURRENT_HEAD = "0041_im_notice_preferences" 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 99c1419069..fe6bbd3263 100644 --- a/backend/tests/test_migration_0037_project_document_summaries.py +++ b/backend/tests/test_migration_0037_project_document_summaries.py @@ -20,7 +20,8 @@ REVISION = "0037_project_document_summaries" PREVIOUS = "0036_run_idempotency_request" CHANNEL_BINDINGS = "0038_channel_thread_bindings" USER_DISABLED = "0039_user_disabled" -CURRENT_HEAD = "0040_scheduled_group_notice" +GROUP_NOTICE = "0040_scheduled_group_notice" +CURRENT_HEAD = "0041_im_notice_preferences" pytestmark = pytest.mark.asyncio @@ -29,7 +30,8 @@ async def test_0037_chains_after_0036_and_is_single_head(): assert script.get_heads() == [CURRENT_HEAD] assert script.get_revision(CHANNEL_BINDINGS).down_revision == REVISION assert script.get_revision(USER_DISABLED).down_revision == CHANNEL_BINDINGS - assert script.get_revision(CURRENT_HEAD).down_revision == USER_DISABLED + assert script.get_revision(GROUP_NOTICE).down_revision == USER_DISABLED + assert script.get_revision(CURRENT_HEAD).down_revision == GROUP_NOTICE 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 f628573b65..7e67cea17d 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 = "0040_scheduled_group_notice" +CURRENT_HEAD = "0041_im_notice_preferences" 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 3c3748a03a..de3053f7fd 100644 --- a/backend/tests/test_migration_0040_scheduled_group_notice.py +++ b/backend/tests/test_migration_0040_scheduled_group_notice.py @@ -21,7 +21,8 @@ COLUMN = "notification_group_connection_id" def test_group_notification_revision_has_one_head_after_0039(): script = ScriptDirectory(str(bootstrap._MIGRATIONS_DIR)) - assert script.get_heads() == [REVISION] + assert script.get_heads() == ["0041_im_notice_preferences"] + assert script.get_revision("0041_im_notice_preferences").down_revision == REVISION assert script.get_revision(REVISION).down_revision == PREVIOUS assert len(REVISION) <= 32 diff --git a/backend/tests/test_migration_0041_im_notice_preferences.py b/backend/tests/test_migration_0041_im_notice_preferences.py new file mode 100644 index 0000000000..c2a746ef83 --- /dev/null +++ b/backend/tests/test_migration_0041_im_notice_preferences.py @@ -0,0 +1,168 @@ +"""IM opt-outs migrate independently of auth users and browser preferences.""" + +import asyncio +import json +import os +import subprocess +import sys +import textwrap +import uuid + +import pytest +import sqlalchemy as sa +from alembic import command +from alembic.autogenerate import compare_metadata +from alembic.migration import MigrationContext +from alembic.script import ScriptDirectory +from sqlalchemy.ext.asyncio import create_async_engine +from support.postgres import asyncpg_test_url + +from deerflow.persistence import bootstrap +from deerflow.persistence.base import Base +from deerflow.persistence.postgres_schema import build_asyncpg_connect_args +from deerflow.persistence.user.model import UserPreferenceRow, UserRow + +REVISION = "0041_im_notice_preferences" +PREVIOUS = "0040_scheduled_group_notice" +TABLE = "im_notification_preferences" + + +def test_im_notice_preferences_follow_0040_in_the_single_chain(): + script = ScriptDirectory(str(bootstrap._MIGRATIONS_DIR)) + assert len(script.get_heads()) == 1 + assert REVISION in {revision.revision for revision in script.walk_revisions()} + assert script.get_revision(REVISION).down_revision == PREVIOUS + assert len(REVISION) <= 32 + + +def _engine(tmp_path, backend): + if backend == "postgres": + uri = os.environ.get("TEST_POSTGRES_URI") + if not uri: + pytest.skip("requires TEST_POSTGRES_URI (real Postgres migration 0041)") + schema = f"im_notice_{uuid.uuid4().hex}" + return create_async_engine(asyncpg_test_url(uri), connect_args=build_asyncpg_connect_args(schema)), schema + engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}") + + @sa.event.listens_for(engine.sync_engine, "connect") + def enable_foreign_keys(connection, _record): + cursor = connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + return engine, None + + +def _schema_diff(connection): + # Import only through the production registration module, not the new model. + import deerflow.persistence.models # noqa: F401 + + context = MigrationContext.configure( + connection, + opts={ + "include_object": lambda _obj, name, type_, _reflected, _compare_to: type_ != "table" or name == TABLE, + "compare_type": True, + "compare_server_default": True, + }, + ) + return compare_metadata(context, Base.metadata) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("backend", ["sqlite", "postgres"]) +async def test_im_preferences_upgrade_retry_downgrade_and_schema_parity(tmp_path, backend): + engine, schema = _engine(tmp_path, backend) + cfg = bootstrap._get_alembic_config(engine, postgres_schema=schema or "") + try: + if schema: + async with engine.begin() as connection: + await connection.execute(sa.text(f'CREATE SCHEMA "{schema}"')) + await asyncio.to_thread(command.upgrade, cfg, PREVIOUS) + async with engine.begin() as connection: + assert not await connection.run_sync(lambda conn: sa.inspect(conn).has_table(TABLE)) + await connection.execute(sa.insert(UserRow).values(id="alice", email="alice@example.test")) + await connection.execute(sa.insert(UserPreferenceRow).values(user_id="alice", key="notification_enabled", value=False)) + + await asyncio.to_thread(command.upgrade, cfg, REVISION) + async with engine.begin() as connection: + if backend == "sqlite": + assert (await connection.execute(sa.text("PRAGMA foreign_keys"))).scalar_one() == 1 + shape = await connection.run_sync(lambda conn: sa.inspect(conn).get_columns(TABLE)) + assert {column["name"] for column in shape} == {"owner_user_id", "provider", "enabled"} + assert all(column["nullable"] is False for column in shape) + assert await connection.run_sync(lambda conn: sa.inspect(conn).get_pk_constraint(TABLE)["constrained_columns"]) == ["owner_user_id", "provider"] + assert await connection.run_sync(lambda conn: sa.inspect(conn).get_foreign_keys(TABLE)) == [] + assert await connection.run_sync(_schema_diff) == [] + # No fake auth row is needed, even when SQLite enforces foreign keys. + assert (await connection.execute(sa.select(UserRow.id).where(UserRow.id == "default"))).scalar_one_or_none() is None + await connection.execute(sa.text(f"INSERT INTO {TABLE} (owner_user_id,provider,enabled) VALUES ('default','feishu',false)")) + # The durable server default, not an ORM client default, preserves opt-in. + await connection.execute(sa.text(f"INSERT INTO {TABLE} (owner_user_id,provider) VALUES ('default','wecom')")) + + with pytest.raises(sa.exc.IntegrityError): + async with engine.begin() as connection: + await connection.execute(sa.text(f"INSERT INTO {TABLE} (owner_user_id,provider) VALUES ('default','feishu')")) + + # Simulate retry after DDL/data committed but revision stamping did not. + await asyncio.to_thread(command.stamp, cfg, PREVIOUS) + await asyncio.to_thread(command.upgrade, cfg, REVISION) + async with engine.connect() as connection: + assert (await connection.execute(sa.text(f"SELECT provider,enabled FROM {TABLE} ORDER BY provider"))).all() == [("feishu", False), ("wecom", True)] + assert (await connection.execute(sa.select(UserPreferenceRow.value).where(UserPreferenceRow.user_id == "alice"))).scalar_one() is False + + await asyncio.to_thread(command.downgrade, cfg, PREVIOUS) + async with engine.connect() as connection: + assert not await connection.run_sync(lambda conn: sa.inspect(conn).has_table(TABLE)) + assert (await connection.execute(sa.select(UserPreferenceRow.value).where(UserPreferenceRow.user_id == "alice"))).scalar_one() is False + # Retrying a completed downgrade is also harmless. + await asyncio.to_thread(command.stamp, cfg, REVISION) + await asyncio.to_thread(command.downgrade, cfg, PREVIOUS) + await asyncio.to_thread(command.upgrade, cfg, REVISION) + async with engine.connect() as connection: + assert await connection.run_sync(_schema_diff) == [] + assert (await connection.execute(sa.text(f"SELECT COUNT(*) FROM {TABLE}"))).scalar_one() == 0 + assert (await connection.execute(sa.select(UserRow.id).where(UserRow.id == "default"))).scalar_one_or_none() is None + finally: + if schema: + async with engine.begin() as connection: + await connection.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE')) + await engine.dispose() + + +def test_fresh_bootstrap_registers_preferences_without_an_auth_user(tmp_path): + # Fresh interpreter: another collected test must not accidentally register + # the model and hide a missing import from persistence.models. + program = textwrap.dedent(""" + import asyncio, json, sys + import sqlalchemy as sa + from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker + from deerflow.persistence import bootstrap + + async def main(): + engine = create_async_engine(f"sqlite+aiosqlite:///{sys.argv[1]}") + @sa.event.listens_for(engine.sync_engine, "connect") + def foreign_keys(connection, _): + cursor = connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + try: + await bootstrap.bootstrap_schema(engine, backend="sqlite") + await bootstrap.bootstrap_schema(engine, backend="sqlite") + async with engine.connect() as connection: + assert (await connection.execute(sa.text("PRAGMA foreign_keys"))).scalar_one() == 1 + assert await connection.run_sync(lambda conn: sa.inspect(conn).has_table("im_notification_preferences")) + assert (await connection.execute(sa.text("SELECT COUNT(*) FROM users"))).scalar_one() == 0 + from deerflow.persistence.user.im_notifications import ImNotificationPreferencesRepository + repo = ImNotificationPreferencesRepository(async_sessionmaker(engine, expire_on_commit=False)) + assert (await repo.patch("default", {"qq": False}))["qq"] is False + assert (await repo.get("default"))["qq"] is False + async with engine.connect() as connection: + assert (await connection.execute(sa.text("SELECT COUNT(*) FROM users"))).scalar_one() == 0 + print(json.dumps({"fresh_bootstrap": True, "synthetic_owner_without_auth_row": True})) + finally: + await engine.dispose() + asyncio.run(main()) + """) + result = subprocess.run([sys.executable, "-c", program, str(tmp_path / "fresh.db")], capture_output=True, text=True, timeout=60) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout.strip()) == {"fresh_bootstrap": True, "synthetic_owner_without_auth_row": True} diff --git a/backend/tests/test_qq_notifications.py b/backend/tests/test_qq_notifications.py new file mode 100644 index 0000000000..cbc30ae2e3 --- /dev/null +++ b/backend/tests/test_qq_notifications.py @@ -0,0 +1,260 @@ +"""QQ proactive C2C notifications: serialized HTTP, no real provider calls.""" + +from __future__ import annotations + +import asyncio +import inspect +import json +import time +import traceback +from contextlib import asynccontextmanager +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest + +from app.channels.base import ChannelUnavailable +from app.channels.message_bus import MessageBus +from app.channels.qq import QQAPIError, QQChannel, _ReplyContext + +pytestmark = pytest.mark.asyncio + + +def binding(**overrides): + return { + "owner_user_id": "owner", + "provider": "qq", + "status": "connected", + "external_account_id": "user-openid", + "workspace_id": "app1:c2c", + "metadata": {"chat_type": "c2c"}, + **overrides, + } + + +@asynccontextmanager +async def connected(handler=None, *, bindings=None): + requests = [] + + async def respond(request): + requests.append(request) + if handler is not None: + response = handler(request) + if inspect.isawaitable(response): + response = await response + return response + if request.url.host == "bots.qq.com": + return httpx.Response(200, json={"access_token": "fixture-token", "expires_in": 7200}) + return httpx.Response(200, json={"id": "notification-id"}) + + repo = SimpleNamespace(list_connections=AsyncMock(return_value=[binding()] if bindings is None else bindings)) + channel = QQChannel(MessageBus(), {"app_id": "app1", "client_secret": "fixture-secret", "connection_repo": repo}) + channel._http = httpx.AsyncClient(transport=httpx.MockTransport(respond), timeout=15, trust_env=False) + channel._running = True + channel._ready.set() + channel._listener = asyncio.create_task(asyncio.Event().wait()) + try: + yield channel, requests + finally: + await channel.stop() + + +async def notify(channel, *, owner="owner", target="user-openid", text="任务已完成。\nOpen DeerFlow for details."): + await channel.send_notification_for_owner(owner_user_id=owner, target=target, text_markdown=text) + + +async def test_private_notification_serializes_without_passive_message_context(): + async with connected() as (channel, requests): + context = _ReplyContext("/unused/passive/path", time.monotonic() - 1, 4, sequence=4) + channel._replies[("c2c:user-openid", "old-message")] = context + await notify(channel) + channel._connection_repo.list_connections.assert_awaited_once_with("owner") + assert len(requests) == 2 + token, message = requests + assert json.loads(token.content) == {"appId": "app1", "clientSecret": "fixture-secret"} + assert str(message.url) == "https://api.sgroup.qq.com/v2/users/user-openid/messages" + assert message.headers["Authorization"] == "QQBot fixture-token" + assert message.headers["X-Union-Appid"] == "app1" + assert message.extensions["timeout"] == {"connect": 15, "read": 15, "write": 15, "pool": 15} + assert json.loads(message.content) == {"msg_type": 0, "content": "任务已完成。\nOpen DeerFlow for details."} + assert context.sequence == 4 + assert len(channel._replies) == 1 + + +async def test_notification_waits_for_message_ack(): + sending, release = asyncio.Event(), asyncio.Event() + + async def handler(request): + sending.set() + await release.wait() + return httpx.Response(200, json={"id": "notification-id"}) + + async with connected(handler) as (channel, requests): + channel._access_token = "fixture-token" + channel._token_expires_at = time.monotonic() + 60 + task = asyncio.create_task(notify(channel)) + try: + await asyncio.wait_for(sending.wait(), 1) + assert not task.done() + release.set() + await task + assert len(requests) == 1 + finally: + release.set() + await asyncio.gather(task, return_exceptions=True) + + +@pytest.mark.parametrize( + "override", + [ + {"owner_user_id": "other-owner"}, + {"provider": "feishu"}, + {"status": "revoked"}, + {"external_account_id": "other-user"}, + {"workspace_id": "another-app:c2c"}, + {"workspace_id": "app1:group:group-id", "metadata": {"chat_type": "group"}}, + {"metadata": {"chat_type": "group"}}, + {"metadata": {}}, + ], +) +async def test_notification_refuses_non_private_or_wrong_owner_binding(override): + async with connected(bindings=[binding(**override)]) as (channel, requests): + with pytest.raises(QQAPIError, match="private.*connected"): + await notify(channel) + assert requests == [] + + +async def test_notification_rechecks_binding_on_every_attempt(): + async with connected() as (channel, requests): + await notify(channel) + channel._connection_repo.list_connections.return_value = [] + with pytest.raises(QQAPIError, match="private.*connected"): + await notify(channel) + assert len(requests) == 2 + assert channel._connection_repo.list_connections.await_count == 2 + + +@pytest.mark.parametrize("case", ["lookup-error", "missing-owner", "missing-repo"]) +async def test_missing_binding_verification_parks_without_send(case, caplog): + async with connected() as (channel, requests): + owner = "owner" + if case == "lookup-error": + channel._connection_repo.list_connections.side_effect = OSError("sensitive-owner-detail") + elif case == "missing-owner": + owner = None + else: + channel._connection_repo = None + with pytest.raises(ChannelUnavailable) as caught: + await notify(channel, owner=owner) + assert requests == [] + assert "sensitive-owner-detail" not in "".join(traceback.format_exception(caught.value)) + caplog.text + + +@pytest.mark.parametrize("condition", ["stopped", "not-ready", "listener-ended", "http-closed", "http-missing"]) +async def test_notification_parks_without_live_transport(condition): + async with connected() as (channel, requests): + if condition == "stopped": + channel._running = False + elif condition == "not-ready": + channel._ready.clear() + elif condition == "listener-ended": + channel._listener.cancel() + await asyncio.gather(channel._listener, return_exceptions=True) + elif condition == "http-closed": + await channel._http.aclose() + else: + await channel._http.aclose() + channel._http = None + with pytest.raises(ChannelUnavailable): + await notify(channel) + assert requests == [] + + +@pytest.mark.parametrize("stage", ["token", "message"]) +@pytest.mark.parametrize("error_type", [httpx.ConnectError, httpx.ReadTimeout]) +async def test_transport_error_parks_without_retry_or_sensitive_details(stage, error_type, caplog): + def handler(request): + raise error_type("sensitive-provider-detail", request=request) + + async with connected(handler) as (channel, requests): + if stage == "message": + channel._access_token = "fixture-token" + channel._token_expires_at = time.monotonic() + 60 + with pytest.raises(ChannelUnavailable) as caught: + await notify(channel) + assert len(requests) == 1 + rendered = "".join(traceback.format_exception(caught.value)) + caplog.text + assert "sensitive-provider-detail" not in rendered + assert "fixture-secret" not in rendered + assert "fixture-token" not in rendered + + +@pytest.mark.parametrize( + ("status", "payload"), + [ + (403, {"err_code": 40054013, "message": "sensitive-rejection-body"}), + (429, {"err_code": 40034100, "message": "sensitive-rejection-body"}), + (200, {"code": 40034105, "id": "not-an-ack", "message": "sensitive-rejection-body"}), + (200, {"err_code": 40054013, "id": "not-an-ack", "message": "sensitive-rejection-body"}), + (200, {}), + (200, {"id": ""}), + (200, {"id": 12}), + (200, {"id": " "}), + ], +) +async def test_rejection_or_missing_ack_counts_as_failure(status, payload, caplog): + async with connected(lambda request: httpx.Response(status, json=payload)) as (channel, requests): + channel._access_token = "fixture-token" + channel._token_expires_at = time.monotonic() + 60 + with pytest.raises(QQAPIError) as caught: + await notify(channel) + assert not isinstance(caught.value, ChannelUnavailable) + assert len(requests) == 1 + assert "sensitive-rejection-body" not in "".join(traceback.format_exception(caught.value)) + caplog.text + + +async def test_auth_refresh_keeps_proactive_payload_and_no_passive_budget(): + token_requests, message_requests = [], [] + + def handler(request): + if request.url.host == "bots.qq.com": + token_requests.append(request) + return httpx.Response(200, json={"access_token": f"token-{len(token_requests)}", "expires_in": 7200}) + message_requests.append(request) + return httpx.Response(401 if len(message_requests) == 1 else 200, json={"id": "notification-id"}) + + async with connected(handler) as (channel, requests): + await notify(channel) + assert len(token_requests) == 2 + assert len(message_requests) == 2 + assert message_requests[0].content == message_requests[1].content + assert message_requests[1].headers["Authorization"] == "QQBot token-2" + 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") + assert requests == [] + + +async def test_notification_cancellation_propagates_without_retry(): + sending = asyncio.Event() + + async def handler(request): + sending.set() + await asyncio.Event().wait() + + async with connected(handler) as (channel, requests): + channel._access_token = "fixture-token" + channel._token_expires_at = time.monotonic() + 60 + task = asyncio.create_task(notify(channel)) + try: + await asyncio.wait_for(sending.wait(), 1) + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert len(requests) == 1 diff --git a/backend/tests/test_scheduled_task_events.py b/backend/tests/test_scheduled_task_events.py index 987545b2be..d2ad7d11ac 100644 --- a/backend/tests/test_scheduled_task_events.py +++ b/backend/tests/test_scheduled_task_events.py @@ -43,6 +43,7 @@ from deerflow.persistence.scheduled_task_runs.model import ScheduledTaskRunRow from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRow from deerflow.persistence.thread_meta.model import ThreadMetaRow +from deerflow.persistence.user.im_notifications import ImNotificationPreferenceRow from deerflow.persistence.user.model import UserPreferenceRow, UserRow from deerflow.runtime import RunStatus from deerflow.runtime.runs.manager import RunRecord @@ -52,7 +53,7 @@ NOW = datetime(2026, 10, 6, 8, tzinfo=UTC) _OWNER = User(id=UUID("6c1f3d0e-8a8f-4a35-9a51-0d2f5d1b7c11"), email="events@example.com", password_hash="unused", system_role="user") _OTHER = User(id=UUID("0b9d2c47-55e3-4d47-8f0c-a3b0f1f4d222"), email="other@example.com", password_hash="unused", system_role="user") OWNER = str(_OWNER.id) -_TABLES = [ScheduledTaskRow, ScheduledTaskRunRow, RunRow, ThreadMetaRow, ScheduledTaskEventRow, NotificationDeliveryRow, ChannelConnectionRow, UserRow, UserPreferenceRow] +_TABLES = [ScheduledTaskRow, ScheduledTaskRunRow, RunRow, ThreadMetaRow, ScheduledTaskEventRow, NotificationDeliveryRow, ChannelConnectionRow, UserRow, UserPreferenceRow, ImNotificationPreferenceRow] _API_FIELDS = {"id", "task_id", "event", "reason_code", "task_title", "stop_condition", "run_thread_id", "run_agent_name", "run_number", "run_status", "max_runs", "end_at", "schedule_type", "after_run_id", "created_at"} diff --git a/backend/tests/test_wechat_notifications.py b/backend/tests/test_wechat_notifications.py new file mode 100644 index 0000000000..99ef6106b0 --- /dev/null +++ b/backend/tests/test_wechat_notifications.py @@ -0,0 +1,288 @@ +"""Owner-bound iLink notifications require private context and a platform ACK.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest +import pytest_asyncio + +from app.channels.base import ChannelUnavailable +from app.channels.message_bus import MessageBus +from app.channels.wechat import WechatChannel + + +def _binding(**overrides): + return {"id": "binding-1", "owner_user_id": "owner-1", "provider": "wechat", "status": "connected", "external_account_id": "wx-user", "workspace_id": "wx-user", **overrides} + + +def _credentials(**context): + return {"connection_id": "binding-1", "extra": {"wechat_notification_context": {"chat_id": "wx-user", "context_token": "private-context", "bot_token_sha256": hashlib.sha256(b"bot-token").hexdigest(), **context}}} + + +@pytest_asyncio.fixture +async def channel(): + ch = WechatChannel(MessageBus(), {"bot_token": "bot-token"}) + ch._running = True + ch._connection_repo = SimpleNamespace( + find_connection_by_external_identity=AsyncMock(return_value=_binding()), + get_credentials=AsyncMock(return_value=_credentials()), + store_connection_context=AsyncMock(return_value=True), + ) + ch._client = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(200, json={"ret": 0}))) + try: + yield ch + finally: + await ch.stop() + + +async def _notify(channel): + await channel.send_notification_for_owner(owner_user_id="owner-1", target="wx-user", text_markdown="任务完成\n**报告已就绪**") + + +@pytest.mark.asyncio +async def test_notification_serializes_real_http_request_and_waits_for_ack(channel): + received = asyncio.Event() + release = asyncio.Event() + requests = [] + handlers = set() + + async def handle(reader, writer): + handlers.add(asyncio.current_task()) + try: + request_line = await reader.readline() + headers = {} + while (line := await reader.readline()) not in (b"\r\n", b""): + key, value = line.decode().split(":", 1) + headers[key.lower()] = value.strip() + body = await reader.readexactly(int(headers["content-length"])) + requests.append((request_line, headers, json.loads(body))) + received.set() + await release.wait() + body = b'{"ret":0}' + writer.write(f"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {len(body)}\r\nConnection: close\r\n\r\n".encode() + body) + await writer.drain() + finally: + writer.close() + await writer.wait_closed() + handlers.discard(asyncio.current_task()) + + server = await asyncio.start_server(handle, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + channel._base_url = f"http://127.0.0.1:{port}" + await channel._client.aclose() + channel._client = httpx.AsyncClient(trust_env=False) + sending = asyncio.create_task(_notify(channel)) + try: + await asyncio.wait_for(received.wait(), 3) + assert not sending.done() + release.set() + await asyncio.wait_for(sending, 3) + assert len(requests) == 1 + request_line, headers, payload = requests[0] + assert request_line == b"POST /ilink/bot/sendmessage HTTP/1.1\r\n" + assert headers["authorization"] == "Bearer bot-token" + assert headers["authorizationtype"] == "ilink_bot_token" + assert payload["msg"]["to_user_id"] == "wx-user" + assert payload["msg"]["context_token"] == "private-context" + assert payload["msg"]["message_state"] == 2 + assert payload["msg"]["item_list"] == [{"type": 1, "text_item": {"text": "任务完成\n**报告已就绪**"}}] + finally: + release.set() + if not sending.done(): + sending.cancel() + await asyncio.gather(sending, return_exceptions=True) + server.close() + await server.wait_closed() + if handlers: + await asyncio.gather(*handlers) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("overrides", [{"owner_user_id": "other"}, {"status": "revoked"}, {"provider": "feishu"}, {"external_account_id": "other"}, {"workspace_id": "group"}]) +async def test_notification_rejects_unmatched_binding(channel, overrides): + channel._connection_repo.find_connection_by_external_identity.return_value = _binding(**overrides) + channel._request_json = AsyncMock() + with pytest.raises(RuntimeError, match="no longer connected"): + await _notify(channel) + channel._request_json.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("credentials", [None, {"extra": {}}, _credentials(context_token=""), _credentials(chat_id="other"), _credentials(bot_token_sha256="old-bot")]) +async def test_notification_requires_private_persisted_context_without_cache_fallback(channel, credentials): + channel._connection_repo.get_credentials.return_value = credentials + channel._context_tokens_by_chat["wx-user"] = "unscoped-cache-token" + channel._connection_repo.find_connection_by_external_identity.return_value = _binding(metadata={"context_token": "legacy-token"}) + channel._request_json = AsyncMock() + with pytest.raises(RuntimeError, match="private message"): + await _notify(channel) + channel._request_json.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_notification_rechecks_owner_after_reading_context(channel): + channel._connection_repo.find_connection_by_external_identity.side_effect = [_binding(), _binding(owner_user_id="new-owner", id="binding-2")] + channel._request_json = AsyncMock() + with pytest.raises(RuntimeError, match="no longer connected"): + await _notify(channel) + channel._request_json.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("failure", ["stopped", "no-auth", "lookup", "stopped-during-lookup", "transport"]) +async def test_notification_unavailability_parks_without_sending_or_retrying(channel, failure): + sends = [] + + def transport(request): + sends.append(request) + raise httpx.ConnectError("offline", request=request) + + await channel._client.aclose() + channel._client = httpx.AsyncClient(transport=httpx.MockTransport(transport)) + if failure == "stopped": + channel._running = False + elif failure == "no-auth": + channel._bot_token = "" + elif failure == "lookup": + channel._connection_repo.find_connection_by_external_identity.side_effect = OSError("offline DB") + elif failure == "stopped-during-lookup": + + async def stop_during_read(connection_id): + await channel.stop() + return _credentials() + + channel._connection_repo.get_credentials.side_effect = stop_during_read + with pytest.raises(ChannelUnavailable): + await _notify(channel) + assert len(sends) == (1 if failure == "transport" else 0) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [ + {"ret": -14, "errmsg": "private-context"}, + {"ret": 0, "errcode": 40001}, + {"ret": "bad-private-context"}, + {"ret": "0"}, + {"ret": False}, + {"ret": True}, + {"ret": 0, "errcode": False}, + {"ret": 0, "errcode": "0"}, + {"ret": 0, "errcode": []}, + ], +) +async def test_platform_rejection_is_visible_and_sent_once_without_token_in_error(channel, payload): + requests = [] + + def transport(request): + requests.append(request) + return httpx.Response(200, json=payload) + + await channel._client.aclose() + channel._client = httpx.AsyncClient(transport=httpx.MockTransport(transport)) + with pytest.raises(RuntimeError) as raised: + await _notify(channel) + assert not isinstance(raised.value, ChannelUnavailable) + assert "private-context" not in str(raised.value) + assert len(requests) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("payload", [{}, {"ret": None}, {"ret": 0}, {"ret": 0.0}, {"errcode": 0}, {"errcode": 0.0}, {"errcode": None}, {"message_id": "fixture-server-id"}, {"ret": None, "errcode": None}]) +async def test_notification_accepts_optional_zero_codes_and_remains_nonblocking(channel, payload): + from support.detectors.blocking_io_runtime import detect_blocking_io_strict + + requests = [] + + def transport(request): + requests.append(request) + return httpx.Response(200, json=payload) + + await channel._client.aclose() + channel._client = httpx.AsyncClient(transport=httpx.MockTransport(transport)) + with detect_blocking_io_strict(): + await _notify(channel) + assert len(requests) == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("body", [b"null", b"[]", b'"text"']) +async def test_notification_requires_json_object_without_changing_legacy_request_parsing(channel, body): + requests = [] + + def transport(request): + requests.append(request) + return httpx.Response(200, content=body, headers={"Content-Type": "application/json"}) + + await channel._client.aclose() + channel._client = httpx.AsyncClient(transport=httpx.MockTransport(transport)) + with pytest.raises(RuntimeError, match="JSON object"): + await _notify(channel) + assert len(requests) == 1 + assert await channel._request_json("/ilink/bot/sendmessage", {}) == {} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("body", [b"", b"not JSON"]) +async def test_notification_does_not_accept_empty_or_invalid_response_body(channel, body): + await channel._client.aclose() + channel._client = httpx.AsyncClient(transport=httpx.MockTransport(lambda request: httpx.Response(200, content=body))) + with pytest.raises(ValueError): + await _notify(channel) + + +@pytest.mark.asyncio +async def test_bind_saves_private_context_outside_public_metadata(channel): + repo = channel._connection_repo + repo.consume_oauth_state = AsyncMock(return_value={"owner_user_id": "owner-1"}) + repo.upsert_connection = AsyncMock(return_value=_binding()) + channel._send_connection_reply = AsyncMock() + await channel._bind_connection_from_connect_code(chat_id="wx-user", context_token="new-private-context", code="fixture-code") + assert "context_token" not in repo.upsert_connection.call_args.kwargs.get("metadata", {}) + context = repo.store_connection_context.call_args.kwargs["extra"]["wechat_notification_context"] + assert context["context_token"] == "new-private-context" + assert context["chat_id"] == "wx-user" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("group_id", [None, "group-1"]) +async def test_inbound_private_context_refresh_does_not_use_group_context(channel, group_id): + channel._publish_inbound_or_drop = AsyncMock() + await channel._handle_update({"message_type": 1, "from_user_id": "wx-user", "context_token": "latest-context", "group_id": group_id, "item_list": [{"type": 1, "text_item": {"text": "hello"}}]}) + writes = channel._connection_repo.store_connection_context + assert writes.await_count == (0 if group_id else 1) + channel._publish_inbound_or_drop.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_notification_without_owner_never_uses_cached_context(channel): + channel._request_json = AsyncMock() + with pytest.raises(RuntimeError): + await channel.send_notification(target="wx-user", text_markdown="Done") + channel._request_json.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_context_persistence_failure_does_not_drop_inbound_chat(channel, caplog): + channel._connection_repo.store_connection_context.side_effect = OSError("private-context") + channel._publish_inbound_or_drop = AsyncMock() + await channel._handle_update({"message_type": 1, "from_user_id": "wx-user", "context_token": "private-context", "item_list": [{"type": 1, "text_item": {"text": "hello"}}]}) + channel._publish_inbound_or_drop.assert_awaited_once() + assert "private-context" not in caplog.text + + +@pytest.mark.asyncio +async def test_group_connect_code_cannot_seed_private_notification_context(channel): + channel._bind_connection_from_connect_code = AsyncMock() + channel._send_connection_reply = AsyncMock() + await channel._handle_update({"message_type": 1, "from_user_id": "wx-user", "context_token": "group-context", "group_id": "group-1", "item_list": [{"type": 1, "text_item": {"text": "/connect fixture-code"}}]}) + channel._bind_connection_from_connect_code.assert_not_awaited() + channel._send_connection_reply.assert_awaited_once() + channel._connection_repo.store_connection_context.assert_not_awaited() diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index de600cfb93..c713fac54e 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -72,6 +72,7 @@ In chat, a `schedule_task` result becomes an `assistant:scheduled-task` group (` Server-created threads: `core/threads/origin.ts` reads `metadata.deerflow_origin` (`contracts/thread_origin_contract.json`), then `channel_source`, then a legacy `scheduled_task_id`; provider names come from `threads.origin.providers`. `core/threads/activity.ts` polls `/api/thread-activity` (mount `useThreadActivity` once; unmounting resets its cursor; first poll only seeds; invalidate lists only on server-origin threads, truncation or a higher `read_version`) and `useMarkThreadRead` posts reads. `core/scheduled-tasks/events.ts` loads a chat's lifecycle events, not gated on its task list; `placeTaskEvents` puts each at the end of its `after_run_id` turn. The UI language is saved as the `locale` preference once per session and on each switch, only when the Gateway's preferences include `locale`. Thread rows (sidebar, `/workspace/chats`) show `ThreadOriginIcon` and, if `unread === true` and not the open thread, `ThreadUnreadDot` with `threads.unreadLabel` as row label. `WorkspaceSidebar` mounts `useThreadActivity`. Chat pages call `useMarkOpenThreadRead` once metadata loads (marks read on load, on an unread flip, on tab visible, and via its callback from `onFinish`). Chat pages pass `useThreadScheduledTaskEvents` data to `MessageList` (`scheduledTaskEvents`; the custom-agent page also passes its agent for the run link), which renders `ScheduledTaskEventLine` through `VirtualMessageList.renderAfterGroup` at the `placeTaskEvents` position, inside the group's measured row. Lines are history: never gated on the task list. Channel provider cards (Settings and the sidebar list) render `ChannelScheduledUpdates` from `proactive_notifications`, and nothing when the field is absent. +Keep IM notification preferences viewer-fenced and separate from browser alerts; only confirmed server writes change switches. ## Architecture diff --git a/frontend/src/components/workspace/channels/channel-scheduled-updates.tsx b/frontend/src/components/workspace/channels/channel-scheduled-updates.tsx index 8ef222a2ed..0f20fe198c 100644 --- a/frontend/src/components/workspace/channels/channel-scheduled-updates.tsx +++ b/frontend/src/components/workspace/channels/channel-scheduled-updates.tsx @@ -7,12 +7,9 @@ import { useI18n } from "@/core/i18n/hooks"; import { cn } from "@/lib/utils"; /** - * Whether scheduled-task updates reach this app ("Scheduled task updates: - * sent here" / "... available after you connect" / "... not available for - * this app yet"). Only providers that - * implement proactive push receive them (WeCom and Feishu/Lark today). Renders nothing for - * an older backend that does not report `proactive_notifications`, rather - * than claiming an app it cannot judge is unsupported. + * Advertise proactive notification capability, not the user's delivery preference. + * Preferences are managed once in Settings → Notifications. Older backends that + * omit the capability render no claim about support. */ export function ChannelScheduledUpdates({ provider, diff --git a/frontend/src/components/workspace/settings/channel-notification-settings.tsx b/frontend/src/components/workspace/settings/channel-notification-settings.tsx new file mode 100644 index 0000000000..cc8e84be8f --- /dev/null +++ b/frontend/src/components/workspace/settings/channel-notification-settings.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { GatewayApiError } from "@/core/api/errors"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { listChannelProviders } from "@/core/channels/api"; +import { + loadChannelNotificationPreferences, + notificationProviderIds, + updateChannelNotificationPreference, + type NotificationProviderId, +} from "@/core/channels/notification-preferences"; +import { useI18n } from "@/core/i18n/hooks"; +import { isStaticWebsiteOnly } from "@/core/static-mode"; + +import { SettingsSection } from "./settings-section"; + +export function ChannelNotificationSettings() { + const { user } = useAuth(); + const { t } = useI18n(); + const text = t.settings.notification.channels; + return ( + + {user && !isStaticWebsiteOnly() ? ( + + ) : ( +

{text.unavailable}

+ )} +
+ ); +} + +function UserChannelNotifications({ userId }: { userId: string }) { + const { t } = useI18n(); + const text = t.settings.notification.channels; + const client = useQueryClient(); + const queryKey = ["channelNotificationSettings", userId]; + const query = useQuery({ + queryKey, + queryFn: async ({ signal }) => { + const [preferences, catalog] = await Promise.all([ + loadChannelNotificationPreferences(userId, signal), + listChannelProviders({ signal }), + ]); + return { preferences, catalog }; + }, + retry: false, + gcTime: 0, + }); + 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) { + if ( + saveRequest.current || + !query.data || + query.data.preferences[provider] === enabled + ) + return; + const request = new AbortController(); + saveRequest.current = request; + setSaving(provider); + setSaveFailed(false); + try { + const preferences = await updateChannelNotificationPreference( + userId, + provider, + enabled, + request.signal, + ); + if (request.signal.aborted) return; + // An older focus/refetch result must not overwrite the confirmed PATCH. + await client.cancelQueries({ queryKey }); + if (request.signal.aborted) return; + client.setQueryData(queryKey, (previous) => + previous ? { ...previous, preferences } : previous, + ); + } catch { + if (!request.signal.aborted) setSaveFailed(true); + } finally { + if (!request.signal.aborted) { + setSaving(null); + saveRequest.current = null; + } + } + } + + if (query.isPending) + return ( +

+ {text.loading} +

+ ); + if (query.error) { + const unsupported = + query.error instanceof GatewayApiError && + [404, 405].includes(query.error.status); + return ( +
+

+ {unsupported ? text.unavailable : text.loadFailed} +

+ {!unsupported && ( + + )} +
+ ); + } + const { preferences, catalog } = query.data; + const providers = notificationProviderIds.filter( + (id) => + catalog.enabled && + catalog.providers.some( + (provider) => + provider.provider === id && + provider.proactive_notifications === true && + provider.connection_status === "connected", + ), + ); + return ( +
+ {providers.length === 0 && ( +

{text.empty}

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

+ {text.saving} +

+ )} + {saveFailed && ( +

+ {text.saveFailed} +

+ )} +
+ ); +} diff --git a/frontend/src/components/workspace/settings/notification-settings-page.tsx b/frontend/src/components/workspace/settings/notification-settings-page.tsx index fa876c6ba0..bb25be3ff2 100644 --- a/frontend/src/components/workspace/settings/notification-settings-page.tsx +++ b/frontend/src/components/workspace/settings/notification-settings-page.tsx @@ -8,9 +8,19 @@ import { useI18n } from "@/core/i18n/hooks"; import { useNotification } from "@/core/notification/hooks"; import { useLocalSettings } from "@/core/settings"; +import { ChannelNotificationSettings } from "./channel-notification-settings"; import { SettingsSection } from "./settings-section"; export function NotificationSettingsPage() { + return ( +
+ + +
+ ); +} + +function BrowserNotificationSettings() { const { t } = useI18n(); const { permission, isSupported, requestPermission, showNotification } = useNotification(); diff --git a/frontend/src/core/channels/api.ts b/frontend/src/core/channels/api.ts index 39ad175c50..411071b460 100644 --- a/frontend/src/core/channels/api.ts +++ b/frontend/src/core/channels/api.ts @@ -17,8 +17,13 @@ function channelsUrl(path: string): string { return `${getBackendBaseURL()}/api/channels${path}`; } -export async function listChannelProviders(): Promise { - const response = await fetch(channelsUrl("/providers")); +export async function listChannelProviders(options?: { + signal?: AbortSignal; +}): Promise { + const signal = options?.signal; + const response = signal + ? await fetch(channelsUrl("/providers"), { signal }) + : await fetch(channelsUrl("/providers")); if (!response.ok) { await throwGatewayApiError( response, diff --git a/frontend/src/core/channels/notification-preferences.ts b/frontend/src/core/channels/notification-preferences.ts new file mode 100644 index 0000000000..49171fad18 --- /dev/null +++ b/frontend/src/core/channels/notification-preferences.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; + +import { throwGatewayApiError } from "@/core/api/errors"; +import { fetch } from "@/core/api/fetcher"; +import { getBackendBaseURL } from "@/core/config"; + +export const notificationProviderIds = [ + "feishu", + "wecom", + "wechat", + "qq", +] as const; +export type NotificationProviderId = (typeof notificationProviderIds)[number]; + +const preferencesSchema = z.object({ + feishu: z.boolean(), + wecom: z.boolean(), + wechat: z.boolean(), + qq: z.boolean(), +}); +export type ChannelNotificationPreferences = z.infer; + +async function requestPreferences( + userId: string, + signal: AbortSignal, + patch?: Partial, +): Promise { + const response = await fetch( + `${getBackendBaseURL()}/api/channels/notification-preferences`, + { + method: patch ? "PATCH" : "GET", + headers: { + "Content-Type": "application/json", + "X-Expected-User-Id": userId, + }, + signal, + ...(patch ? { body: JSON.stringify(patch) } : {}), + }, + ); + if (!response.ok) + await throwGatewayApiError( + response, + "Unable to load or save notification preferences.", + ); + return preferencesSchema.parse(await response.json()); +} + +export function loadChannelNotificationPreferences( + userId: string, + signal: AbortSignal, +) { + return requestPreferences(userId, signal); +} + +export function updateChannelNotificationPreference( + userId: string, + provider: NotificationProviderId, + enabled: boolean, + signal: AbortSignal, +) { + return requestPreferences(userId, signal, { [provider]: enabled }); +} diff --git a/frontend/src/core/channels/types.ts b/frontend/src/core/channels/types.ts index 09e78d855b..31d6fa0e1b 100644 --- a/frontend/src/core/channels/types.ts +++ b/frontend/src/core/channels/types.ts @@ -21,8 +21,8 @@ export interface ChannelProvider { credential_fields: ChannelCredentialField[]; credential_values?: ChannelRuntimeConfigValues; /** - * The app can receive scheduled-task updates (proactive push; only WeCom - * today). Absent from older backends, which cannot say: show nothing. + * The app can receive scheduled-task updates through proactive push. This + * is a capability, not a notification preference. Older backends may omit it. */ proactive_notifications?: boolean; } diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index ede77b9012..8a3b9d430f 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -1644,9 +1644,10 @@ export const enUS: Translations = { }, connectedAs: (name: string) => `Connected as ${name}.`, scheduledUpdates: { - supported: "Scheduled task updates: sent here", + supported: + "Scheduled task updates: supported; manage in Notification settings", supportedAfterConnect: - "Scheduled task updates: available after you connect", + "Scheduled task updates: supported; connect to manage notifications", unsupported: "Scheduled task updates: not available for this app yet", }, }, @@ -2388,6 +2389,30 @@ export const enUS: Translations = { testBody: "This is a test notification.", notSupported: "Your browser does not support notifications.", disableNotification: "Disable notification", + channels: { + title: "Scheduled task notifications", + description: + "Choose which connected apps receive scheduled task updates. Turning these off does not affect chatting. Pending notifications and retries are cancelled, and turning them back on does not resend old updates. Notifications already being delivered may still arrive.", + providers: { + feishu: "Feishu / Lark", + wecom: "WeCom", + wechat: "WeChat", + qq: "QQ", + }, + toggleLabel: (provider: string) => + `${provider} scheduled task notifications`, + loading: "Loading notification preferences…", + saving: "Saving…", + loadFailed: + "Could not load notification preferences. Please try again.", + saveFailed: + "Could not save this change. The previous setting is still shown; please try again.", + unavailable: + "This service does not support notification preferences yet.", + empty: + "Connect an app that supports scheduled task notifications in Settings → Channels.", + retry: "Retry", + }, }, account: { profileTitle: "Profile", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 5842bc1b14..9199a24374 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -1933,6 +1933,24 @@ export interface Translations { testBody: string; notSupported: string; disableNotification: string; + channels: { + title: string; + description: string; + providers: { + feishu: string; + wecom: string; + wechat: string; + qq: string; + }; + toggleLabel: (provider: string) => string; + loading: string; + saving: string; + loadFailed: string; + saveFailed: string; + unavailable: string; + empty: string; + retry: string; + }; }; account: { profileTitle: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 0bed60f582..3e153fc8dd 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -1542,8 +1542,8 @@ export const zhCN: Translations = { }, connectedAs: (name: string) => `已连接为 ${name}。`, scheduledUpdates: { - supported: "定时任务通知:会发送到这里", - supportedAfterConnect: "定时任务通知:连接后可发送到这里", + supported: "定时任务通知:已支持,可在通知设置中管理", + supportedAfterConnect: "定时任务通知:已支持,连接后可管理通知", unsupported: "定时任务通知:此应用暂不支持", }, }, @@ -2242,6 +2242,25 @@ export const zhCN: Translations = { testBody: "这是一条测试通知。", notSupported: "当前浏览器不支持通知功能。", disableNotification: "关闭通知", + channels: { + title: "定时任务通知", + description: + "选择哪些已连接的应用接收定时任务更新。关闭通知不影响聊天。待发和重试中的通知会取消,重新开启不会补发旧通知。已经开始投递的通知仍可能送达。", + providers: { + feishu: "飞书 / Lark", + wecom: "企业微信", + wechat: "微信", + qq: "QQ", + }, + toggleLabel: (provider: string) => `${provider}定时任务通知`, + loading: "正在加载通知偏好…", + saving: "正在保存…", + loadFailed: "无法加载通知偏好,请重试。", + saveFailed: "未能保存此更改,当前仍显示之前的设置,请重试。", + unavailable: "当前服务尚不支持通知偏好设置。", + empty: "请在设置 → 频道中连接支持定时任务通知的应用。", + retry: "重试", + }, }, account: { profileTitle: "个人信息", diff --git a/frontend/tests/e2e/channel-notification-settings.spec.ts b/frontend/tests/e2e/channel-notification-settings.spec.ts new file mode 100644 index 0000000000..40f3f4d53f --- /dev/null +++ b/frontend/tests/e2e/channel-notification-settings.spec.ts @@ -0,0 +1,143 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { mockLangGraphAPI } from "./utils/mock-api"; +import { setLocaleCookie } from "./utils/scheduled-fixtures"; + +const defaults = { feishu: true, wecom: true, wechat: true, qq: true }; +const channelProviders = ["feishu", "wecom", "wechat", "qq"].map( + (provider) => ({ + provider, + display_name: provider, + auth_mode: "connect_code", + connection_status: "connected", + proactive_notifications: true, + }), +); + +async function openSettings(page: Page, chinese = false) { + await page.goto("/workspace/chats/new"); + await page + .locator("[data-sidebar='sidebar']") + .getByRole("button", { name: chinese ? /设置和更多/ : /Settings and more/ }) + .click(); + await page + .getByRole("menuitem", { name: chinese ? "设置" : "Settings", exact: true }) + .click(); + const dialog = page.getByRole("dialog", { + name: chinese ? "设置" : "Settings", + exact: true, + }); + await dialog + .getByRole("button", { + name: chinese ? "通知" : "Notification", + exact: true, + }) + .click(); + return dialog; +} + +test("one shared panel saves channel preferences independently of browser notifications", async ({ + page, +}) => { + mockLangGraphAPI(page, { channelProviders }); + let preferences = { ...defaults }; + const patches: unknown[] = []; + await page.route( + "**/api/channels/notification-preferences", + async (route) => { + if (route.request().method() === "PATCH") { + const patch = route.request().postDataJSON() as Partial< + typeof defaults + >; + patches.push(patch); + expect(route.request().headers()["x-expected-user-id"]).toBeTruthy(); + preferences = { ...preferences, ...patch }; + } + await route.fulfill({ json: preferences }); + }, + ); + const dialog = await openSettings(page); + const feishu = dialog.getByRole("switch", { + name: "Feishu / Lark scheduled task notifications", + }); + await expect(feishu).toBeChecked(); + await expect( + dialog.getByRole("switch", { name: "QQ scheduled task notifications" }), + ).toBeChecked(); + await feishu.click(); + await expect(feishu).not.toBeChecked(); + expect(patches).toEqual([{ feishu: false }]); + await expect( + dialog.getByRole("switch", { name: "WeCom scheduled task notifications" }), + ).toBeChecked(); + await expect(dialog.getByText(/does not affect chatting/)).toBeVisible(); + await page.reload(); + // Reload reads the stored server preference, with no extra write. + const reopened = await openSettings(page); + await expect( + reopened.getByRole("switch", { + name: "Feishu / Lark scheduled task notifications", + }), + ).not.toBeChecked(); + expect(patches).toHaveLength(1); +}); + +test("failed saves keep the confirmed value and offer a retry through the switch", async ({ + page, +}) => { + mockLangGraphAPI(page, { channelProviders }); + await page.route("**/api/channels/notification-preferences", (route) => + route.request().method() === "PATCH" + ? route.fulfill({ status: 503, json: { detail: "unavailable" } }) + : route.fulfill({ json: defaults }), + ); + const dialog = await openSettings(page); + const toggle = dialog.getByRole("switch", { + name: "WeChat scheduled task notifications", + }); + await expect(toggle).toBeChecked(); + await toggle.click(); + await expect(dialog.getByRole("alert")).toContainText( + "Could not save this change", + ); + await expect(toggle).toBeChecked(); + await expect(toggle).toBeEnabled(); +}); + +test("Chinese settings show all four supported channels and no unavailable event types", async ({ + page, +}) => { + await setLocaleCookie(page, "zh"); + mockLangGraphAPI(page, { channelProviders }); + await page.route("**/api/channels/notification-preferences", (route) => + route.fulfill({ json: defaults }), + ); + const dialog = await openSettings(page, true); + for (const provider of ["飞书 / Lark", "企业微信", "微信", "QQ"]) { + await expect( + dialog.getByRole("switch", { + name: `${provider}定时任务通知`, + exact: true, + }), + ).toBeChecked(); + } + await expect(dialog.getByText(/关闭通知不影响聊天/)).toBeVisible(); +}); + +test("an older Gateway exposes no pretend channel switches", async ({ + page, +}) => { + mockLangGraphAPI(page, { channelProviders }); + await page.route("**/api/channels/notification-preferences", (route) => + route.fulfill({ status: 404, json: { detail: "Not found" } }), + ); + const dialog = await openSettings(page); + await expect( + dialog.getByText( + "This service does not support notification preferences yet.", + ), + ).toBeVisible(); + await expect( + dialog.getByRole("switch", { name: /scheduled task notifications/ }), + ).toHaveCount(0); +}); diff --git a/frontend/tests/e2e/channels.spec.ts b/frontend/tests/e2e/channels.spec.ts index c56e888ebd..b69553986d 100644 --- a/frontend/tests/e2e/channels.spec.ts +++ b/frontend/tests/e2e/channels.spec.ts @@ -838,14 +838,15 @@ for (const entry of ["sidebar", "settings"] as const) { test.describe("scheduled task updates per app", () => { const COPY = { en: { - supported: "Scheduled task updates: sent here", + supported: + "Scheduled task updates: supported; manage in Notification settings", unsupported: "Scheduled task updates: not available for this app yet", dialog: "Settings", wecom: "WeCom", feishu: "Feishu", }, zh: { - supported: "定时任务通知:会发送到这里", + supported: "定时任务通知:已支持,可在通知设置中管理", unsupported: "定时任务通知:此应用暂不支持", dialog: "设置", wecom: "企业微信", @@ -854,7 +855,7 @@ test.describe("scheduled task updates per app", () => { } as const; for (const lang of ["en", "zh"] as const) { - test(`WeCom says updates are sent there, Feishu says not yet (${lang})`, async ({ + test(`WeCom shows notification capability, Feishu says not yet (${lang})`, async ({ page, }) => { const copy = COPY[lang]; @@ -866,7 +867,7 @@ test.describe("scheduled task updates per app", () => { .filter(({ provider }) => ["feishu", "wecom"].includes(provider)) .map((provider) => ({ ...provider, - // Only WeCom implements proactive push today. + // This fixture advertises push only for WeCom. proactive_notifications: provider.provider === "wecom", })), ); diff --git a/frontend/tests/unit/components/workspace/channels/channel-scheduled-updates.dom.test.tsx b/frontend/tests/unit/components/workspace/channels/channel-scheduled-updates.dom.test.tsx index 48fa62c9d4..f23066b8aa 100644 --- a/frontend/tests/unit/components/workspace/channels/channel-scheduled-updates.dom.test.tsx +++ b/frontend/tests/unit/components/workspace/channels/channel-scheduled-updates.dom.test.tsx @@ -34,14 +34,14 @@ describe("ChannelScheduledUpdates", () => { for (const [locale, supported, afterConnect, unsupported] of [ [ "en-US", - "Scheduled task updates: sent here", - "Scheduled task updates: available after you connect", + "Scheduled task updates: supported; manage in Notification settings", + "Scheduled task updates: supported; connect to manage notifications", "Scheduled task updates: not available for this app yet", ], [ "zh-CN", - "定时任务通知:会发送到这里", - "定时任务通知:连接后可发送到这里", + "定时任务通知:已支持,可在通知设置中管理", + "定时任务通知:已支持,连接后可管理通知", "定时任务通知:此应用暂不支持", ], ] as const) { @@ -59,7 +59,7 @@ describe("ChannelScheduledUpdates", () => { ); }); - test(`an app with proactive push says updates are sent here (${locale})`, () => { + test(`an app with proactive push shows capability rather than promising delivery (${locale})`, () => { renderIn(locale, true); const line = screen.getByTestId("channel-scheduled-updates"); expect(line.textContent).toBe(supported); 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 new file mode 100644 index 0000000000..c13677436e --- /dev/null +++ b/frontend/tests/unit/components/workspace/settings/channel-notification-settings.dom.test.tsx @@ -0,0 +1,337 @@ +import { afterEach, beforeEach, expect, test, rs } from "@rstest/core"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; + +import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page"; +import { fetch as fetcher } from "@/core/api/fetcher"; +import { useAuth } from "@/core/auth/AuthProvider"; +import { enUS } from "@/core/i18n/locales/en-US"; +import { useNotification } from "@/core/notification/hooks"; + +rs.mock("@/core/api/fetcher", () => ({ fetch: rs.fn() })); +rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "/backend" })); +rs.mock("@/core/auth/AuthProvider", () => ({ useAuth: rs.fn() })); +rs.mock("@/core/i18n/hooks", () => ({ useI18n: () => ({ t: enUS }) })); +rs.mock("@/core/notification/hooks", () => ({ useNotification: rs.fn() })); +rs.mock("@/core/settings", () => ({ + useLocalSettings: () => [{ notification: { enabled: true } }, rs.fn()], +})); +rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: () => false })); + +const defaults = { feishu: true, wecom: true, wechat: true, qq: true }; +const provider = (id: string, connected = true, supported = true) => ({ + provider: id, + display_name: id, + connection_status: connected ? "connected" : "not_connected", + proactive_notifications: supported, + enabled: true, + configured: true, + auth_mode: "connect_code", + credential_fields: [], +}); +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function viewer(id: string) { + rs.mocked(useAuth).mockReturnValue({ + user: { id }, + isLoading: false, + } as ReturnType); +} +function mount() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const element = () => ( + + + + ); + const result = render(element()); + return { ...result, refresh: () => result.rerender(element()), client }; +} +beforeEach(() => { + viewer("alice"); + rs.mocked(useNotification).mockReturnValue({ + isSupported: false, + permission: "default", + requestPermission: rs.fn(), + showNotification: rs.fn(), + }); + rs.mocked(fetcher).mockImplementation(async (url) => + (typeof url === "string" ? url : url.url).endsWith("/providers") + ? json({ + enabled: true, + providers: [ + provider("feishu"), + provider("wecom"), + provider("wechat"), + provider("qq"), + provider("slack", true, false), + ], + }) + : json(defaults), + ); +}); +afterEach(() => { + cleanup(); + rs.clearAllMocks(); +}); + +test("offers connected push-capable apps even when browser notifications are unsupported", async () => { + mount(); + for (const name of ["Feishu / Lark", "WeCom", "WeChat", "QQ"]) { + const toggle = await screen.findByRole("switch", { + name: `${name} scheduled task notifications`, + }); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + } + expect(screen.queryByRole("switch", { name: /Slack/ })).toBeNull(); + expect( + screen.getByText("Your browser does not support notifications."), + ).toBeTruthy(); + expect(screen.getByText(/does not affect chatting/)).toBeTruthy(); +}); + +test("saves only the changed provider with the current viewer and waits for acknowledgement", async () => { + const saved = deferred(); + const original = rs.mocked(fetcher).getMockImplementation()!; + rs.mocked(fetcher).mockImplementation((url, init) => + init?.method === "PATCH" ? saved.promise : original(url, init), + ); + mount(); + const toggle = await screen.findByRole("switch", { + name: "Feishu / Lark scheduled task notifications", + }); + fireEvent.click(toggle); + await waitFor(() => + expect( + rs + .mocked(fetcher) + .mock.calls.some(([, init]) => init?.method === "PATCH"), + ).toBe(true), + ); + const [, request] = rs + .mocked(fetcher) + .mock.calls.find(([, init]) => init?.method === "PATCH")!; + expect(JSON.parse(request!.body as string)).toEqual({ feishu: false }); + expect(new Headers(request?.headers).get("X-Expected-User-Id")).toBe("alice"); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(toggle.hasAttribute("disabled")).toBe(true); + await act(async () => saved.resolve(json({ ...defaults, feishu: false }))); + await waitFor(() => + expect(toggle.getAttribute("aria-checked")).toBe("false"), + ); +}); + +test("a failed save leaves the confirmed switch unchanged and can be retried", async () => { + const original = rs.mocked(fetcher).getMockImplementation()!; + rs.mocked(fetcher).mockImplementation((url, init) => + init?.method === "PATCH" + ? Promise.resolve(json({}, 503)) + : original(url, init), + ); + mount(); + const toggle = await screen.findByRole("switch", { + name: "WeCom scheduled task notifications", + }); + fireEvent.click(toggle); + expect(await screen.findByRole("alert")).toBeTruthy(); + expect(toggle.getAttribute("aria-checked")).toBe("true"); + expect(toggle.hasAttribute("disabled")).toBe(false); +}); + +test("pending preferences never expose default-on switches", async () => { + const pending = deferred(); + const original = rs.mocked(fetcher).getMockImplementation()!; + rs.mocked(fetcher).mockImplementation((url, init) => + (typeof url === "string" ? url : url.url).endsWith( + "/notification-preferences", + ) + ? pending.promise + : original(url, init), + ); + mount(); + expect(await screen.findByRole("status")).toBeTruthy(); + expect(screen.queryByRole("switch")).toBeNull(); + await act(async () => pending.resolve(json({ ...defaults, feishu: false }))); + expect( + ( + await screen.findByRole("switch", { + name: "Feishu / Lark scheduled task notifications", + }) + ).getAttribute("aria-checked"), + ).toBe("false"); +}); + +test.each([404, 405])( + "old backend %s shows unavailable without a local switch", + async (status) => { + const original = rs.mocked(fetcher).getMockImplementation()!; + rs.mocked(fetcher).mockImplementation((url, init) => + (typeof url === "string" ? url : url.url).endsWith( + "/notification-preferences", + ) + ? Promise.resolve(json({}, status)) + : original(url, init), + ); + mount(); + expect( + await screen.findByText(/does not support notification preferences/), + ).toBeTruthy(); + expect(screen.queryByRole("switch")).toBeNull(); + expect( + rs + .mocked(fetcher) + .mock.calls.every(([, init]) => init?.method !== "PATCH"), + ).toBe(true); + }, +); + +test("disconnected and unsupported apps are omitted", async () => { + const original = rs.mocked(fetcher).getMockImplementation()!; + rs.mocked(fetcher).mockImplementation((url, init) => + (typeof url === "string" ? url : url.url).endsWith("/providers") + ? Promise.resolve( + json({ + enabled: true, + providers: [provider("feishu", false), provider("qq", true, false)], + }), + ) + : original(url, init), + ); + mount(); + expect(await screen.findByText(/Connect an app that supports/)).toBeTruthy(); + expect(screen.queryByRole("switch")).toBeNull(); +}); + +test("switching viewers hides old values and ignores a late save", async () => { + const saved = deferred(); + const original = rs.mocked(fetcher).getMockImplementation()!; + rs.mocked(fetcher).mockImplementation((url, init) => { + if (init?.method === "PATCH") return saved.promise; + if ( + (typeof url === "string" ? url : url.url).endsWith( + "/notification-preferences", + ) && + new Headers(init?.headers).get("X-Expected-User-Id") === "bob" + ) + return Promise.resolve(json({ ...defaults, feishu: false })); + return original(url, init); + }); + const view = mount(); + fireEvent.click( + await screen.findByRole("switch", { + name: "Feishu / Lark scheduled task notifications", + }), + ); + viewer("bob"); + view.refresh(); + await waitFor(() => + expect( + screen + .getByRole("switch", { + name: "Feishu / Lark scheduled task notifications", + }) + .getAttribute("aria-checked"), + ).toBe("false"), + ); + await act(async () => saved.resolve(json(defaults))); + expect( + screen + .getByRole("switch", { + name: "Feishu / Lark scheduled task notifications", + }) + .getAttribute("aria-checked"), + ).toBe("false"); +}); + +test("a failed load exposes retry and no switches until fresh values arrive", async () => { + let failed = true; + const original = rs.mocked(fetcher).getMockImplementation()!; + rs.mocked(fetcher).mockImplementation((url, init) => + (typeof url === "string" ? url : url.url).endsWith( + "/notification-preferences", + ) && failed + ? Promise.resolve(json({}, 503)) + : original(url, init), + ); + mount(); + expect(await screen.findByRole("alert")).toBeTruthy(); + expect(screen.queryByRole("switch")).toBeNull(); + failed = false; + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + expect( + await screen.findByRole("switch", { + name: "QQ scheduled task notifications", + }), + ).toBeTruthy(); +}); + +test("auth-disabled local user can save with the default viewer identity", async () => { + viewer("default"); + mount(); + fireEvent.click( + await screen.findByRole("switch", { + name: "QQ scheduled task notifications", + }), + ); + await waitFor(() => + expect( + rs + .mocked(fetcher) + .mock.calls.some(([, init]) => init?.method === "PATCH"), + ).toBe(true), + ); + const [, request] = rs + .mocked(fetcher) + .mock.calls.find(([, init]) => init?.method === "PATCH")!; + expect(new Headers(request?.headers).get("X-Expected-User-Id")).toBe( + "default", + ); +}); + +test("a late preference load cannot replace the next viewer's settings", async () => { + const previous = deferred(); + const original = rs.mocked(fetcher).getMockImplementation()!; + rs.mocked(fetcher).mockImplementation((url, init) => + (typeof url === "string" ? url : url.url).endsWith( + "/notification-preferences", + ) && new Headers(init?.headers).get("X-Expected-User-Id") === "alice" + ? previous.promise + : original(url, init), + ); + const view = mount(); + viewer("bob"); + view.refresh(); + expect( + ( + await screen.findByRole("switch", { + name: "QQ scheduled task notifications", + }) + ).getAttribute("aria-checked"), + ).toBe("true"); + await act(async () => previous.resolve(json({ ...defaults, qq: false }))); + expect( + screen + .getByRole("switch", { name: "QQ scheduled task notifications" }) + .getAttribute("aria-checked"), + ).toBe("true"); +}); diff --git a/frontend/tests/unit/core/channels/notification-preferences.test.ts b/frontend/tests/unit/core/channels/notification-preferences.test.ts new file mode 100644 index 0000000000..5980b6eb1c --- /dev/null +++ b/frontend/tests/unit/core/channels/notification-preferences.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, expect, test, rs } from "@rstest/core"; + +import { GatewayApiError } from "@/core/api/errors"; +import { fetch as fetcher } from "@/core/api/fetcher"; +import { + loadChannelNotificationPreferences, + updateChannelNotificationPreference, +} from "@/core/channels/notification-preferences"; + +rs.mock("@/core/api/fetcher", () => ({ fetch: rs.fn() })); +rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "/backend" })); + +const defaults = { feishu: true, wecom: true, wechat: true, qq: true }; +beforeEach(() => { + rs.mocked(fetcher).mockReset(); +}); + +test("loads effective preferences with an expected-owner fence and consumed signal", async () => { + rs.mocked(fetcher).mockResolvedValue(new Response(JSON.stringify(defaults))); + const signal = new AbortController().signal; + expect(await loadChannelNotificationPreferences("default", signal)).toEqual( + defaults, + ); + expect(rs.mocked(fetcher)).toHaveBeenCalledWith( + "/backend/api/channels/notification-preferences", + expect.objectContaining({ + method: "GET", + signal, + headers: expect.objectContaining({ "X-Expected-User-Id": "default" }), + }), + ); +}); + +test("updates exactly one provider, preserving false and the authenticated viewer header", async () => { + rs.mocked(fetcher).mockResolvedValue( + new Response(JSON.stringify({ ...defaults, qq: false })), + ); + const signal = new AbortController().signal; + expect( + (await updateChannelNotificationPreference("alice", "qq", false, signal)) + .qq, + ).toBe(false); + expect(rs.mocked(fetcher)).toHaveBeenCalledWith( + "/backend/api/channels/notification-preferences", + expect.objectContaining({ + method: "PATCH", + signal, + body: '{"qq":false}', + headers: expect.objectContaining({ "X-Expected-User-Id": "alice" }), + }), + ); +}); + +test.each([{ feishu: true }, { ...defaults, qq: "false" }, null])( + "rejects malformed effective preferences instead of inventing defaults", + async (body) => { + rs.mocked(fetcher).mockResolvedValue(new Response(JSON.stringify(body))); + await expect( + loadChannelNotificationPreferences("alice", new AbortController().signal), + ).rejects.toThrow(); + }, +); + +test("keeps an unavailable endpoint distinguishable from valid default values", async () => { + rs.mocked(fetcher).mockResolvedValue( + new Response('{"detail":"Not found"}', { status: 404 }), + ); + await expect( + loadChannelNotificationPreferences("alice", new AbortController().signal), + ).rejects.toBeInstanceOf(GatewayApiError); +});