feat(channels): generalize group notifications and add opt-in task/goal IM notices (#6903)

* feat(channels): generalize IM group targets and add task/goal run notifications

- Generalize proactive group notification capability (proactive_group_notifications), binding validation, and scheduled task group selection across Feishu, QQ, and WeCom while keeping WeChat personal-only.
- Add QQ group active notification delivery via POST /v2/groups/{group_openid}/messages (msg_type=2) and WeCom group active notification delivery via WS Client.sendMessage(chatid, body) with bot_id:group:chatid workspace isolation.
- Generalize notification outbox sources via migration 0043_runtime_im_notices (source_kind, source_key, run_id, nullable task_id/task_run_id) with backward-compatible SQLite/PostgreSQL upgrades.
- Add opt-in ordinary task & goal run IM notifications (run_completed, run_failed, human_input_requested) on the shared outbox, delivery worker, renderer, and settings UI.

* fix(notifications): address review feedback and group/runtime notification edge cases

* test(migrations): update CURRENT_HEAD to 0043_runtime_im_notices across migration chain tests

* fix(channels): preserve group binding names and legacy authorization

Carry owner-chosen group names through existing connect state, keep private bindings out of legacy group fallback, and restore completion metadata retries. Cover real binding and worker finalization paths and synchronize notification docs.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Totoro 2026-10-15 10:30:55 +08:00 committed by GitHub
parent 2d6b406801
commit c994826d06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 3043 additions and 216 deletions

View File

@ -3001,7 +3001,7 @@ In the task form, switching an hourly, daily, weekly, or monthly schedule to **C
Open **Settings → Notifications → IM notifications** to turn scheduled-task
updates on or off for each connected, push-capable app. Existing connections
keep receiving updates until you opt out. Turning a channel off cancels its
pending and retrying notices, including an explicitly selected Feishu group;
pending and retrying notices, including an explicitly selected group;
turning it on again does not resend the old backlog. Notifications whose
platform delivery already started may still arrive. Chat replies and scheduled
execution are unaffected. All Gateway workers must run the upgraded version
@ -3010,16 +3010,27 @@ for the preference to be enforced.
WeChat needs a private message after binding to save its current conversation
context securely; an old binding or changed bot token also needs a fresh private
message. QQ requires a C2C/private binding and platform permission to send
proactively. This version does not add WeChat or QQ group destinations.
proactively. WeChat remains private-only; QQ and WeCom also support explicitly
selected group destinations.
See [setup and delivery limits](backend/docs/IM_CHANNEL_CONNECTIONS.md#proactive-notification-settings).
### Optional Feishu group notifications
The same page offers separate **Task & goal run notifications** for completion,
terminal failure and requests for input. These ordinary web/background reminders
are off by default. A pending input request takes precedence over completion;
unmet goal stand-downs stay silent, and IM-originated chats keep their original
reply rather than receiving a second proactive notice. Turning an event off
also cancels its waiting backlog. Messages direct you back to Conversations.
In a task's create or edit form, **Also notify a Feishu group (optional)** adds
### Optional group notifications
In a task's create or edit form, **Also notify a group (optional)** adds
one chosen group to the existing personal notifications. Add the app bot to the
group, use **Bind a Feishu group** to get a command, send it while mentioning the
bot in that group, then refresh the list and select the group by name. The app
needs bot-send permission and `im:chat:read` to display its group names.
group, choose its supported channel in the binding panel, then send the displayed
`/connect` command while mentioning the bot in that group. QQ and WeCom require
a name in the binding panel so you can identify the receiving group; the name
travels with the owner-issued binding code and is saved on the connection.
Refresh and select the group by name. Feishu additionally needs `im:chat:read`
to retrieve its group names; every provider needs permission to send proactively.
While the form checks group-notification support, it shows a loading hint and
retains the saved selection.
@ -3028,7 +3039,7 @@ No group is selected automatically. Clearing the selection stops new group
notices; changing it affects future notices, while already queued notices keep
their original target. A disconnected binding never receives a waiting notice.
The first version is configured through the task form or REST API; conversation
schedule tools keep their existing inputs. See the [group delivery guide](backend/docs/IM_CHANNEL_CONNECTIONS.md#feishu-group-notifications).
schedule tools keep their existing inputs. See the [group delivery guide](backend/docs/IM_CHANNEL_CONNECTIONS.md#group-notifications).
### Lifecycle, safety caps and stop conditions

View File

@ -2,7 +2,7 @@
IM channels reach DeerFlow through `langgraph-sdk` HTTP; Gateway owns thread lifecycle.
WeChat `_read_outbound_bytes` reads cap+1 off-loop; overflow returns `None` before encryption/upload; caps <=0 are unlimited.
WeChat `_read_outbound_bytes`: cap+1 off-loop; overflow returns `None` before encryption/upload; nonpositive caps are unlimited.
Cursor writes drain before cancellation; token expiry drains cursor/auth clearing under `_auth_lock`.
Telegram retries share bootstrap/Bot cleanup. Dead loops warn once; clear
@ -17,7 +17,7 @@ Finalize asyncgens; keep primary errors; propagate lone Bot errors.
- `manager.py` - Core dispatcher: creates threads via `client.threads.create()`, routes commands including `/goal` (setting a goal persists it through Gateway and then routes the objective as a chat turn) and `/agent` (`list` is owner-scoped; `use` creates a fresh thread and persists the Custom Agent selection under both the channel restart key and Web's canonical routing metadata so existing checkpoint lineage never changes agent across IM/Web), keeps Slack/Discord on `client.runs.wait()`, uses `client.runs.stream(["messages-tuple", "values"])` for Feishu/Telegram incremental outbound updates, serializes same-thread Feishu turns in-manager when the channel's `ChannelRunPolicy.serialize_thread_runs=True` so rapid follow-ups queue instead of tripping the runtime busy reply, and switches to `client.runs.create()` (fire-and-forget, returns once the run is `pending`) for channels whose `ChannelRunPolicy.fire_and_forget=True` so long autonomous runs do not hit the SDK default 300s `httpx.ReadTimeout`; `runs.wait`/`runs.stream` pass `on_disconnect: continue`, so a run outlives the instance that requested it (reply lost, thread persisted); hence once a run request is sent, cancellation or a post-request transport error keeps the inbound dedupe key (`_keep_dedupe_key_after`) and only Gateway rejections, pre-request failures and `error` frames release it
Streaming `error` frames use the same failure path as transport exceptions. Publish the final outbound before releasing the inbound dedupe key, so provider redelivery can retry without overtaking the terminal reply. Log the frame's error name and message at WARNING with the thread ID; keep the synthetic error generic so payload details do not change reply selection.
**What may be published from the stream is an allowlist, not a denylist** (`_accumulate_stream_text`): only assistant message types — LangChain serializes `AIMessage.type` as `"ai"` and `AIMessageChunk.type` as `"AIMessageChunk"`, plus the OpenAI-style `"assistant"` spelling for foreign runtimes — become displayable text. The previous rule rejected only payloads whose `type` contained `"tool"` and therefore published everything else, which leaked DeerFlow's hidden model context to every streaming IM channel: `DynamicContextMiddleware` injects recalled memory as a hidden `HumanMessage` (`type == "human"`) and rewrites the user's own turn into a new `HumanMessage`, `DurableContextMiddleware` injects a hidden `<durable_context_data>` `HumanMessage`, and LangGraph fans those state writes out on the `messages-tuple` stream. Proved live on a Buzz relay. Matching is by prefix (`ai` / `assistant`), never substring, because ordinary words contain `"ai"` (`chain`, `domain`). The message type is resolved by `_stream_payload_type`, which handles both the `model_dump()` shape DeerFlow's own gateway emits and LangChain's `to_json()` constructor shape (whose top-level `type` is the literal `"constructor"`, with the class name at the tail of the `id` path). A bare `str` payload is no longer accepted at all: it carries no type information, so it cannot be attributed to the assistant, and nothing in DeerFlow produces one (`runtime/serialization.py::serialize_messages_tuple` always emits `[message_dict, metadata]`).
- `base.py` - Abstract `Channel` base class (start/stop/send lifecycle). Provider callbacks that submit coroutines from SDK threads must use `_submit_threadsafe_coroutine()` (or `_submit_threadsafe_coroutine_future()` to await the outcome through `asyncio.shield`): it creates and retains the real `asyncio.Task` on the owner loop instead of treating `run_coroutine_threadsafe()`'s proxy Future as a completion signal. Submission is closed atomically with shutdown, and `stop()` must call `_close_and_drain_threadsafe_futures()` before tearing down SDK resources. Blocking SDK teardown in `stop()` (a client `close()`, a thread `join()`) must run off the event loop through `asyncio.to_thread`: the shared Gateway loop also serves `POST /api/channels/{name}/restart`; Slack cleanup awaits connect then closes off-loop; a 5s deadline/cancellation retains it for retry. SDK close errors propagate. Anchored by `tests/blocking_io/test_channel_stop_offloop.py`. **Proactive push** (`send_notification`): WeCom, Feishu, WeChat and QQ C2C use the shared scheduled outbox; `capabilities.py` gates enqueue/UI. Unsupported rows fail terminally. Await ACK; `ChannelUnavailable` parks, business rejection counts. WeCom socket `RuntimeError` parks except `Reply ack error`. Feishu resolves owner p2p; explicit groups use connection IDs and a separate hook. QQ verifies owner/current-App C2C, never group member OpenIDs or passive reply context. WeChat uses encrypted owner/bot/private context, restored after restart; stale public metadata is not trusted. `store_connection_context` locks/checks the active identity before merging encrypted extra. The browser connection API strips legacy context tokens. Shared IM settings (0041) gate enqueue/send; disabling atomically closes pending/sending rows, and reenabling does not replay them. In-flight provider sends may finish. Binding lookup failures park; revoked targets fail before liveness. Terminal rows fence late writes. `_on_finalization` stages one notice per target with the outcome (stop > pause > finish > run outcome); recovery dedupes, idle ends use `idle:`, trials/interrupts stay silent. Localized text has no IDs/links. Lifespan starts delivery after channels, detaches enqueue if it cannot, and stops delivery first. Setup: [IM guide](../../docs/IM_CHANNEL_CONNECTIONS.md#proactive-notification-settings).
- `base.py` - Abstract `Channel` base class (start/stop/send lifecycle). Provider callbacks that submit coroutines from SDK threads must use `_submit_threadsafe_coroutine()` (or `_submit_threadsafe_coroutine_future()` to await the outcome through `asyncio.shield`): it creates and retains the real `asyncio.Task` on the owner loop instead of treating `run_coroutine_threadsafe()`'s proxy Future as a completion signal. Submission is closed atomically with shutdown, and `stop()` must call `_close_and_drain_threadsafe_futures()` before tearing down SDK resources. Blocking SDK teardown in `stop()` (a client `close()`, a thread `join()`) must run off the event loop through `asyncio.to_thread`: the shared Gateway loop also serves `POST /api/channels/{name}/restart`; Slack cleanup awaits connect then closes off-loop; a 5s deadline/cancellation retains it for retry. SDK close errors propagate. Anchored by `tests/blocking_io/test_channel_stop_offloop.py`. **Proactive push**: WeCom, Feishu, WeChat and QQ C2C share the outbox; `capabilities.py` gates enqueue/UI. Unsupported rows fail terminally. Await ACK; `ChannelUnavailable` parks, business rejection counts. WeCom parks socket `RuntimeError` except `Reply ack error`. Feishu/QQ/WeCom group hooks use owner connection IDs; Feishu p2p resolves owner. `group_name`: OAuth state only, no suffix/raw-ID labels. WeCom legacy-group fallback excludes private bindings. QQ: owner/current-App C2C only, never member OpenIDs/passive context. WeChat: encrypted owner/bot/private context across restart; ignore public tokens. `store_connection_context` locks/checks the active identity before merging encrypted extra; browser responses strip legacy tokens. Preferences gate enqueue/send/retry, cancel pending/sending and fence late writes; reenable never replays. In-flight sends may finish. Binding lookup errors park; revoked targets fail before liveness. `_on_finalization` commits one notice with the outcome (stop > pause > finish > run); recovery dedupes, idle uses `idle:`, trials/interrupts stay silent. Web/background events default off: confirm durable terminal state; recheck Human Input before send/retry. Text: localized, no IDs/links. Delivery starts after channels/stops first; failure detaches enqueue. [IM guide](../../docs/IM_CHANNEL_CONNECTIONS.md#proactive-notification-settings).
- `wecom.py` - WeCom WebSocket channel with proactive `send_notification`; `_ws_transport_up` maps disconnects to `ChannelUnavailable` (no retry used). `capabilities.py` - SDK-free `CHANNEL_CAPABILITIES`.
- `service.py` - Drain manager work before transports; retain timed-out instances for retry. Feishu/DingTalk `stop()` stop the SDK thread's private loop via `_request_*_shutdown` (Feishu disarms lark's `_auto_reconnect` first, else its reconnect branch eats the stop; DingTalk runs its own copy of the SDK connect loop), then join; a live join or Slack cleanup raises `ChannelStopTimeout`: retain, defer replacement/restart, log WARNING without traceback; wedged instances report not running. Reload returns snapshots; restart owns cached fallback/publication under the channel lock. `_stopping` fences starts/publication (including after pre-start I/O) until shutdown succeeds. Lock manager startup through stale cleanup; capture the shutdown generation before locking to fence queued starts. `stop()` stays unlocked to fence in-flight startup. Leader lease (`leadership.py`): only the `channel_leases` holder starts a non-GitHub transport (`_start_channel` checks `holds()`; callers acquire first); `_supervise` renews at a fixed rate, runs starts/stops per provider, stops a transport before its lease lapses.
- `slack.py` / `feishu.py` / `telegram.py` / `discord.py` / `dingtalk.py` - Platform-specific implementations (`feishu.py` tracks the running card `message_id` in memory and patches the same card in place; `telegram.py` accepts inbound text/photos/documents, preserves media captions, hands token-free attachment bytes to the shared upload pipeline, edits the "Working on it..." stream target in place via `editMessageText`, and can optionally send final Markdown replies as Rich Messages through `channels.telegram.rich_messages` — the Rich path only fires when `rich_messages` is on **and** the text contains a rich construct (fenced code, emphasis, a table, a task list, `<details>`, block math, or a link), so structured command/error replies stay plain text and their newlines and `<placeholder>` tokens survive; `discord.py` runs `_on_message` on discord.py's private loop, so connection-repository work (identity lookup with the reservation commit, `/connect` binding) goes to the Gateway loop through `_submit_threadsafe_coroutine` — the SQLAlchemy engine is bound there — and bind replies return through `_run_on_discord_loop`; typing registers before that hand-off settles, and a failed hand-off skips the ack and stops the indicator it started unless another message still relies on it; `discord.py` registers typing loops before inbound handling yields and `_start_typing()` refuses work once `_running` is false; each tick sends the indicator with discord.py 2.x's `await channel.typing()`; because `stop()` runs on the main loop while typing tasks belong to `_discord_loop`, normal cross-thread cancellation, awaiting, and map cleanup are scheduled there with a bounded wait, while `_run_client()` drains tasks in its `finally` block before an exception/disconnect can make that loop unusable; an already-stopped foreign loop must never have its tasks awaited from the main loop; to that end every outbound cross-loop call (`send` / `send_file` / `_get_channel_or_thread`) goes through `_run_on_discord_loop`, which bounds the await (`DISCORD_OUTBOUND_TIMEOUT_SECONDS` 30s; `DISCORD_UPLOAD_TIMEOUT_SECONDS` 120s for file uploads: slow uplink plus 429 retry-after) and fails fast with a `RuntimeError` when the client loop is missing or not running — a dead client becomes a logged send failure instead of a permanently hung `ChannelManager` worker — and `is_running` reports client-thread aliveness (like `feishu.py`) so `ensure_channel_ready` can restart the channel after `_run_client()` exits on a fatal error; `dingtalk.py` optionally uses AI Card streaming for in-place updates when `card_template_id` is configured, and overrides `receive_file` to download inbound images (`picture`/`richText`) and documents (`file`) by `downloadCode` into the thread uploads bucket, mirroring `feishu.py`)

View File

@ -10,21 +10,24 @@ sync with the channel registry and the channel classes.
``Channel.send_notification`` (push with no inbound message to reply to).
Only these providers get scheduled-task notification rows; to add one, see
the ``Channel.send_notification`` docstring.
- ``proactive_group_notifications``: the provider's channel class implements
``Channel.send_group_notification_for_owner`` for explicit group notification
bindings.
"""
from __future__ import annotations
CHANNEL_CAPABILITIES: dict[str, dict[str, bool]] = {
"buzz": {"supports_streaming": True, "proactive_notifications": False},
"dingtalk": {"supports_streaming": False, "proactive_notifications": False},
"discord": {"supports_streaming": False, "proactive_notifications": False},
"feishu": {"supports_streaming": True, "proactive_notifications": True},
"github": {"supports_streaming": False, "proactive_notifications": False},
"qq": {"supports_streaming": False, "proactive_notifications": True},
"slack": {"supports_streaming": False, "proactive_notifications": False},
"telegram": {"supports_streaming": True, "proactive_notifications": False},
"wechat": {"supports_streaming": False, "proactive_notifications": True},
"wecom": {"supports_streaming": True, "proactive_notifications": True},
"buzz": {"supports_streaming": True, "proactive_notifications": False, "proactive_group_notifications": False},
"dingtalk": {"supports_streaming": False, "proactive_notifications": False, "proactive_group_notifications": False},
"discord": {"supports_streaming": False, "proactive_notifications": False, "proactive_group_notifications": False},
"feishu": {"supports_streaming": True, "proactive_notifications": True, "proactive_group_notifications": True},
"github": {"supports_streaming": False, "proactive_notifications": False, "proactive_group_notifications": False},
"qq": {"supports_streaming": False, "proactive_notifications": True, "proactive_group_notifications": True},
"slack": {"supports_streaming": False, "proactive_notifications": False, "proactive_group_notifications": False},
"telegram": {"supports_streaming": True, "proactive_notifications": False, "proactive_group_notifications": False},
"wechat": {"supports_streaming": False, "proactive_notifications": True, "proactive_group_notifications": False},
"wecom": {"supports_streaming": True, "proactive_notifications": True, "proactive_group_notifications": True},
}
@ -32,3 +35,9 @@ def supports_proactive_notifications(provider: str) -> bool:
"""Whether scheduled-task notices can be pushed to ``provider`` (unknown provider: False)."""
entry = CHANNEL_CAPABILITIES.get(provider) if isinstance(provider, str) else None
return bool(entry) and entry.get("proactive_notifications") is True
def supports_proactive_group_notifications(provider: str) -> bool:
"""Whether explicit group notices can be pushed to ``provider`` (unknown provider: False)."""
entry = CHANNEL_CAPABILITIES.get(provider) if isinstance(provider, str) else None
return bool(entry) and entry.get("proactive_group_notifications") is True

View File

@ -36,7 +36,7 @@ from app.channels.message_bus import (
OutboundMessage,
ResolvedAttachment,
)
from app.channels.notification_groups import is_feishu_notification_group
from app.channels.notification_groups import is_feishu_notification_group, notification_group_display_name
from app.channels.sandbox_files import sync_file_to_thread_sandbox
from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths
from deerflow.runtime.user_context import get_effective_user_id
@ -1833,7 +1833,7 @@ class FeishuChannel(Channel):
provider="feishu",
external_account_id=user_id,
workspace_id=chat_id,
workspace_name=await self._get_group_name(chat_id) if chat_type == "group" else None,
workspace_name=(notification_group_display_name(state.get("metadata")) or await self._get_group_name(chat_id)) if chat_type == "group" else None,
metadata={
"chat_id": chat_id,
"message_id": message_id,

View File

@ -1,6 +1,87 @@
"""SDK-free checks for explicit Feishu group notification destinations."""
"""SDK-free checks for explicit group notification destinations."""
from __future__ import annotations
from collections.abc import Mapping
def notification_group_display_name(metadata: object) -> str | None:
"""Read the bounded label from the owner's browser-issued binding state."""
if not isinstance(metadata, Mapping):
return None
value = metadata.get("notification_group_name")
if not isinstance(value, str):
return None
return " ".join(value.split())[:256] or None
def is_feishu_notification_group(*, provider: object, status: object, workspace_id: object, metadata: object) -> bool:
"""Check the group shape; callers must separately verify owner and ID."""
return provider == "feishu" and status == "connected" and isinstance(workspace_id, str) and bool(workspace_id.strip()) and isinstance(metadata, dict) and metadata.get("chat_type") == "group" and metadata.get("chat_id") == workspace_id
"""Check the Feishu group shape; callers must separately verify owner and ID."""
return (
provider == "feishu" and status == "connected" and isinstance(workspace_id, str) and bool(workspace_id.strip()) and isinstance(metadata, Mapping) and metadata.get("chat_type") == "group" and metadata.get("chat_id") == workspace_id
)
def qq_notification_group_openid(
*,
provider: object,
status: object,
workspace_id: object,
metadata: object,
app_id: str | None = None,
) -> str | None:
"""Return the bound QQ ``group_openid`` when the connection is a valid QQ group binding."""
if provider != "qq" or status != "connected" or not isinstance(workspace_id, str) or ":group:" not in workspace_id:
return None
bound_app_id, _sep, group_openid = workspace_id.partition(":group:")
if not bound_app_id.strip() or bound_app_id != bound_app_id.strip() or not group_openid.strip() or group_openid != group_openid.strip():
return None
if app_id is not None and bound_app_id != app_id:
return None
if not isinstance(metadata, Mapping) or metadata.get("chat_type") != "group":
return None
meta_openid = metadata.get("group_openid")
if meta_openid is not None and (not isinstance(meta_openid, str) or meta_openid != group_openid):
return None
return group_openid
def wecom_notification_group_chat_id(
*,
provider: object,
status: object,
workspace_id: object,
metadata: object,
bot_id: str | None = None,
) -> str | None:
"""Return the bound WeCom ``chatid`` when the connection is a valid WeCom group binding."""
if provider != "wecom" or status != "connected" or not isinstance(workspace_id, str) or not isinstance(metadata, Mapping):
return None
if metadata.get("chattype") != "group":
return None
chatid = metadata.get("chatid")
if not isinstance(chatid, str) or not chatid.strip() or chatid != chatid.strip():
return None
aibotid = metadata.get("aibotid")
if aibotid is None or aibotid == "":
expected_workspace_id = f"group:{chatid}"
elif isinstance(aibotid, str) and bool(aibotid.strip()) and aibotid == aibotid.strip():
if bot_id and aibotid != bot_id:
return None
expected_workspace_id = f"{aibotid}:group:{chatid}"
else:
return None
if workspace_id != expected_workspace_id:
return None
return chatid
def is_notification_group(*, provider: object, status: object, workspace_id: object, metadata: object) -> bool:
"""Whether a channel connection record represents an eligible group notification target."""
if is_feishu_notification_group(provider=provider, status=status, workspace_id=workspace_id, metadata=metadata):
return True
if qq_notification_group_openid(provider=provider, status=status, workspace_id=workspace_id, metadata=metadata) is not None:
return True
if wecom_notification_group_chat_id(provider=provider, status=status, workspace_id=workspace_id, metadata=metadata) is not None:
return True
return False

View File

@ -23,6 +23,7 @@ from app.channels.base import Channel, ChannelUnavailable
from app.channels.commands import extract_connect_code, is_known_channel_command
from app.channels.connection_identity import attach_connection_identity
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage
from app.channels.notification_groups import notification_group_display_name, qq_notification_group_openid
from deerflow.utils.file_io import await_drained
logger = logging.getLogger(__name__)
@ -446,13 +447,18 @@ class QQChannel(Channel):
state = await self._connection_repo.consume_oauth_state(provider="qq", state=code)
reply = "QQ connection code is invalid or expired."
if state is not None:
group_name = str(data.get("group_name") or "").strip() if group else ""
upsert_kwargs: dict[str, Any] = {}
if group:
upsert_kwargs["workspace_name"] = notification_group_display_name(state.get("metadata")) or group_name[:256] or None
await self._connection_repo.upsert_connection(
owner_user_id=state["owner_user_id"],
provider="qq",
external_account_id=sender,
workspace_id=workspace,
metadata={"chat_type": scope},
metadata={"chat_type": "group", "group_openid": target} if group else {"chat_type": scope},
status="connected",
**upsert_kwargs,
)
reply = "QQ connected to DeerFlow."
await self.send(
@ -514,15 +520,40 @@ class QQChannel(Channel):
return
raise QQAPIError("QQ private notification identity is no longer connected")
async def send_notification(self, *, target: str, text_markdown: str) -> None:
"""Send one proactive C2C message; the outbox owns retries and delivery state."""
async def send_group_notification_for_owner(self, *, owner_user_id: str | None, target: str, text_markdown: str) -> None:
"""Deliver to an explicit QQ group connection owned by ``owner_user_id``."""
if not owner_user_id or self._connection_repo is None:
raise ChannelUnavailable("QQ notification binding lookup is unavailable")
target_id = _parse_open_id(target)
if not target_id:
raise QQAPIError("QQ group notification target is empty")
try:
connections = await self._connection_repo.list_connections(owner_user_id)
except Exception:
raise ChannelUnavailable("QQ notification binding lookup is unavailable") from None
if not isinstance(connections, (list, tuple)):
raise ChannelUnavailable("QQ notification binding lookup is unavailable")
for connection in connections:
if connection.get("id") != target_id or connection.get("owner_user_id") != owner_user_id:
continue
group_openid = qq_notification_group_openid(
provider=connection.get("provider"),
status=connection.get("status"),
workspace_id=connection.get("workspace_id"),
metadata=connection.get("metadata"),
app_id=self._app_id,
)
if group_openid is not None:
await self._send_proactive_message(f"/v2/groups/{quote(group_openid, safe='')}/messages", text_markdown=text_markdown)
return
raise QQAPIError("QQ group notification destination is no longer connected")
async def _send_proactive_message(self, path: str, *, text_markdown: str) -> None:
if not self.is_running or self._http is None or self._http.is_closed:
raise ChannelUnavailable("QQ notification transport is unavailable")
if not _parse_open_id(target):
raise QQAPIError("QQ notification target is empty")
try:
# No source ID or reply sequence: passive reply quotas are separate.
payload = await self._api_request("POST", f"/v2/users/{quote(target, safe='')}/messages", json={"msg_type": 0, "content": text_markdown})
payload = await self._api_request("POST", path, json={"msg_type": 0, "content": text_markdown})
except QQTransportError:
raise ChannelUnavailable("QQ notification transport is unavailable") from None
except QQAPIError:
@ -534,6 +565,15 @@ class QQChannel(Channel):
if not isinstance(payload.get("id"), str) or not payload["id"].strip():
raise QQAPIError("QQ notification response has no message acknowledgement")
async def send_notification(self, *, target: str, text_markdown: str) -> None:
"""Send one proactive C2C message; the outbox owns retries and delivery state."""
if not self.is_running or self._http is None or self._http.is_closed:
raise ChannelUnavailable("QQ notification transport is unavailable")
target_id = _parse_open_id(target)
if not target_id:
raise QQAPIError("QQ notification target is empty")
await self._send_proactive_message(f"/v2/users/{quote(target_id, safe='')}/messages", text_markdown=text_markdown)
async def send(self, msg: OutboundMessage) -> None:
if not msg.is_final or not msg.text:
return

View File

@ -5,7 +5,7 @@ import base64
import hashlib
import inspect
import logging
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Mapping
from typing import Any, cast
from app.channels.base import Channel, ChannelUnavailable
@ -18,6 +18,7 @@ from app.channels.message_bus import (
OutboundMessage,
ResolvedAttachment,
)
from app.channels.notification_groups import notification_group_display_name, wecom_notification_group_chat_id
logger = logging.getLogger(__name__)
@ -381,6 +382,35 @@ class WeComChannel(Channel):
raise RuntimeError(f"WeCom send_message rejected with errcode={errcode}")
self._ws_transport_up = True
async def send_group_notification_for_owner(self, *, owner_user_id: str | None, target: str, text_markdown: str) -> None:
"""Deliver to an explicit WeCom group connection owned by ``owner_user_id``."""
if not owner_user_id or self._connection_repo is None:
raise ChannelUnavailable("WeCom notification binding lookup is unavailable")
target_id = target.strip() if isinstance(target, str) else ""
if not target_id or target_id != target:
raise RuntimeError("WeCom group notification target is empty")
try:
connections = await self._connection_repo.list_connections(owner_user_id)
except Exception as exc:
raise ChannelUnavailable("WeCom notification binding lookup is unavailable") from exc
if not isinstance(connections, (list, tuple)):
raise ChannelUnavailable("WeCom notification binding lookup is unavailable")
bot_id = self._bot_id or (str(self.config.get("bot_id") or "").strip() or None)
for connection in connections:
if connection.get("id") != target_id or connection.get("owner_user_id") != owner_user_id:
continue
chatid = wecom_notification_group_chat_id(
provider=connection.get("provider"),
status=connection.get("status"),
workspace_id=connection.get("workspace_id"),
metadata=connection.get("metadata"),
bot_id=bot_id,
)
if chatid is not None:
await self.send_notification(target=chatid, text_markdown=text_markdown)
return
raise RuntimeError("WeCom group notification destination is no longer connected")
async def _on_outbound(self, msg: OutboundMessage) -> None:
if msg.channel_name != self.name:
return
@ -553,6 +583,13 @@ class WeComChannel(Channel):
return
inbound_type = InboundMessageType.COMMAND if is_known_channel_command(text) else InboundMessageType.CHAT
inbound_metadata: dict[str, Any] = {
"aibotid": body.get("aibotid"),
"chattype": body.get("chattype"),
"message_id": msg_id,
}
if body.get("chatid") is not None:
inbound_metadata["chatid"] = body.get("chatid")
inbound = self._make_inbound(
chat_id=user_id, # keep user's conversation in memory
user_id=user_id,
@ -560,11 +597,7 @@ class WeComChannel(Channel):
msg_type=inbound_type,
thread_ts=msg_id,
files=files or [],
metadata={
"aibotid": body.get("aibotid"),
"chattype": body.get("chattype"),
"message_id": msg_id,
},
metadata=inbound_metadata,
)
inbound.topic_id = user_id # keep the same thread
@ -589,11 +622,31 @@ class WeComChannel(Channel):
reservation.release()
async def _attach_connection_identity(self, inbound: InboundMessage) -> InboundMessage:
aibotid = str(inbound.metadata.get("aibotid") or "").strip() or None
chattype = inbound.metadata.get("chattype")
chatid = str(inbound.metadata.get("chatid") or "").strip() or None
if chattype == "group":
if not chatid or self._connection_repo is None:
return inbound
workspace_id = f"{aibotid}:group:{chatid}" if aibotid else f"group:{chatid}"
inbound = await attach_connection_identity(inbound, repo=self._connection_repo, provider="wecom", workspace_id=workspace_id, fallback_without_workspace=False)
if inbound.connection_id:
return inbound
# Pre-group-target versions stored group bindings under the bot ID,
# without chatid. Preserve that existing chat identity, but never
# let a new private binding authorize an unbound group.
legacy = await self._connection_repo.find_connection_by_external_identity(provider="wecom", external_account_id=inbound.user_id, workspace_id=aibotid)
metadata = legacy.get("metadata") if isinstance(legacy, Mapping) else None
if isinstance(metadata, Mapping) and metadata.get("chattype") == "group" and "chatid" not in metadata and metadata.get("aibotid") == aibotid:
inbound.connection_id = legacy["id"]
inbound.owner_user_id = legacy["owner_user_id"]
inbound.workspace_id = legacy.get("workspace_id")
return inbound
return await attach_connection_identity(
inbound,
repo=self._connection_repo,
provider="wecom",
workspace_id=str(inbound.metadata.get("aibotid") or "") or None,
workspace_id=aibotid,
fallback_without_workspace=True,
)
@ -611,16 +664,35 @@ class WeComChannel(Channel):
return True
body = frame.get("body", {}) or {}
workspace_id = str(body.get("aibotid") or "") or None
aibotid = str(body.get("aibotid") or "").strip() or None
chattype = body.get("chattype")
workspace_name: str | None = None
if chattype == "group":
chatid = str(body.get("chatid") or "").strip()
if not chatid:
await self._send_connection_reply(frame, "WeCom connection could not be completed from this message.")
return True
workspace_id = f"{aibotid}:group:{chatid}" if aibotid else f"group:{chatid}"
raw_group_name = str(body.get("chatname") or body.get("group_name") or "").strip()
workspace_name = notification_group_display_name(state.get("metadata")) or raw_group_name[:256] or None
metadata: dict[str, Any] = {
"aibotid": aibotid,
"chattype": "group",
"chatid": chatid,
}
else:
workspace_id = aibotid
metadata = {
"aibotid": workspace_id,
"chattype": chattype,
}
await self._connection_repo.upsert_connection(
owner_user_id=state["owner_user_id"],
provider="wecom",
external_account_id=user_id,
workspace_id=workspace_id,
metadata={
"aibotid": workspace_id,
"chattype": body.get("chattype"),
},
workspace_name=workspace_name,
metadata=metadata,
status="connected",
)
await self._send_connection_reply(frame, "WeCom connected to DeerFlow.")

View File

@ -2,6 +2,7 @@ import asyncio
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager, suppress
from typing import Any
from deerflow_extension_api import (
EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY,
@ -514,6 +515,46 @@ async def _start_scheduled_task_notification_delivery(app: FastAPI, startup_conf
row = await run_repo.get(run_id, user_id=user_id)
return row.get("last_ai_message") if row else None
async def resolve_human_input_pending(delivery: dict[str, Any]) -> bool | None:
payload = delivery.get("payload") or {}
thread_id = payload.get("thread_id")
request_id = payload.get("request_id")
owner_user_id = delivery.get("owner_user_id")
if not isinstance(thread_id, str) or not thread_id:
return False
thread_store = getattr(app.state, "thread_store", None)
if thread_store is not None and owner_user_id:
thread_meta = await thread_store.get(thread_id, user_id=owner_user_id)
if thread_meta is None:
return False
checkpointer = getattr(app.state, "checkpointer", None)
if checkpointer is None:
return True
from types import SimpleNamespace
from app.gateway.context_usage import _load_checkpoint_messages
from app.gateway.services import build_thread_checkpoint_state_accessor
from deerflow.agents.human_input import extract_unanswered_human_input_request
from deerflow.runtime.user_context import reset_current_user, set_current_user
owner_token = set_current_user(SimpleNamespace(id=owner_user_id)) if isinstance(owner_user_id, str) and owner_user_id else None
try:
accessor, checkpoint_config = await build_thread_checkpoint_state_accessor(
SimpleNamespace(app=app, headers={}, state=SimpleNamespace(), cookies={}),
thread_id=thread_id,
)
messages = await _load_checkpoint_messages(accessor, checkpoint_config)
finally:
if owner_token is not None:
reset_current_user(owner_token)
current_request = extract_unanswered_human_input_request(messages)
if current_request is None:
return False
if isinstance(request_id, str) and request_id and current_request.get("request_id") != request_id:
return False
return True
prefs_repo = ImNotificationPreferencesRepository(get_session_factory())
worker = NotificationDeliveryWorker(
delivery_repo=notification_repo,
resolve_channel=channel_service.get_channel,
@ -526,7 +567,9 @@ async def _start_scheduled_task_notification_delivery(app: FastAPI, startup_conf
# Under channels.leader_lease, rows for a provider whose transport
# another instance holds are left to that instance.
excluded_providers=getattr(channel_service, "notification_excluded_providers", None),
resolve_notification_preferences=ImNotificationPreferencesRepository(get_session_factory()).get,
resolve_notification_preferences=prefs_repo.get,
resolve_notification_event_preferences=prefs_repo.get_event_preferences,
resolve_human_input_pending=resolve_human_input_pending,
poll_interval_seconds=startup_config.scheduler.poll_interval_seconds,
# Language of notices whose owner has no UI language preference.
default_locale=startup_config.channel_connections.notification_locale,

View File

@ -12,7 +12,7 @@ from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel, Field
from app.channels.capabilities import supports_proactive_notifications
from app.channels.capabilities import supports_proactive_group_notifications, supports_proactive_notifications
from app.channels.runtime_config_store import (
ChannelRuntimeConfigStore,
apply_runtime_connection_config,
@ -57,6 +57,7 @@ class ChannelProviderResponse(BaseModel):
# Whether scheduled-task updates can be pushed to this app (the provider
# implements proactive push); Settings labels each provider card with it.
proactive_notifications: bool = False
proactive_group_notifications: bool = False
class ChannelProvidersResponse(BaseModel):
@ -89,6 +90,10 @@ class ChannelConnectResponse(BaseModel):
expires_in: int
class ChannelConnectRequest(BaseModel):
group_name: str | None = Field(default=None, max_length=256)
class WechatQRLoginResponse(BaseModel):
id: str
status: Literal["pending", "scanned", "verification_required", "confirmed", "expired", "failed"]
@ -358,6 +363,7 @@ async def _create_state(
*,
owner_user_id: str,
provider: str,
metadata: dict[str, Any] | None = None,
) -> str:
now = datetime.now(UTC)
state = _new_binding_code()
@ -370,6 +376,7 @@ async def _create_state(
expires_at=now + timedelta(seconds=_STATE_TTL_SECONDS),
max_pending=_MAX_PENDING_CONNECT_CODES_PER_PROVIDER,
now=now,
metadata=metadata,
)
if not inserted:
raise HTTPException(
@ -483,6 +490,7 @@ def _provider_response(
credential_fields=_credential_fields(provider),
credential_values=credential_values,
proactive_notifications=supports_proactive_notifications(provider),
proactive_group_notifications=supports_proactive_group_notifications(provider),
)
@ -651,7 +659,7 @@ async def _disconnect_channel_provider_runtime(provider: str, request: Request)
@router.post("/{provider}/connect", response_model=ChannelConnectResponse)
async def connect_channel_provider(provider: str, request: Request) -> ChannelConnectResponse:
async def connect_channel_provider(provider: str, request: Request, body: ChannelConnectRequest | None = None) -> ChannelConnectResponse:
config = await _get_channel_connections_config(request)
channels_config = await _get_channels_config(request)
if not config.enabled:
@ -670,10 +678,14 @@ async def connect_channel_provider(provider: str, request: Request) -> ChannelCo
raise HTTPException(status_code=400, detail="Channel provider is not configured")
repo = _get_repository(request, config)
group_name = body.group_name.strip() if body and body.group_name else ""
if group_name and not supports_proactive_group_notifications(provider):
raise HTTPException(status_code=422, detail="This channel does not support notification group names.")
code = await _create_state(
repo,
owner_user_id=_get_user_id(request),
provider=provider,
metadata={"notification_group_name": group_name} if group_name else None,
)
return ChannelConnectResponse(
provider=provider,

View File

@ -73,7 +73,7 @@ class ScheduledTasksFeature(BaseModel):
running: bool = Field(..., description="Whether this process's scheduler poller is running, so tasks run on schedule and new tasks can be created")
tool_enabled: bool = Field(..., description="Whether chats can create and manage tasks and scheduled runs can stop their own schedule")
min_interval_seconds: int = Field(..., description="Shortest interval and earliest one-time delay, in seconds")
group_notifications: bool = Field(..., description="Whether scheduled tasks support explicitly selected Feishu group notifications")
group_notifications: bool = Field(..., description="Whether scheduled tasks support explicitly selected group notifications")
class ThreadActivityFeature(BaseModel):

View File

@ -15,6 +15,9 @@ class ImNotificationPreferences(BaseModel):
wecom: bool | None = None
wechat: bool | None = None
qq: bool | None = None
run_completed: bool | None = None
run_failed: bool | None = None
human_input_requested: bool | None = None
def _owner(request: Request, expected_user_id: str) -> str:
@ -34,13 +37,13 @@ def _repository() -> ImNotificationPreferencesRepository:
return ImNotificationPreferencesRepository(sessions)
@router.get("", response_model=ImNotificationPreferences)
@router.get("", response_model=ImNotificationPreferences, response_model_exclude_none=True)
async def get_preferences(request: Request, x_expected_user_id: str = Header(...)) -> dict[str, bool]:
owner = _owner(request, x_expected_user_id)
return await _repository().get(owner)
@router.patch("", response_model=ImNotificationPreferences)
@router.patch("", response_model=ImNotificationPreferences, response_model_exclude_none=True)
async def patch_preferences(body: ImNotificationPreferences, request: Request, x_expected_user_id: str = Header(...)) -> dict[str, bool]:
owner = _owner(request, x_expected_user_id)
return await _repository().patch(owner, body.model_dump(exclude_unset=True))

View File

@ -9,7 +9,8 @@ from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import AwareDatetime, BaseModel, Field
from app.channels.notification_groups import is_feishu_notification_group
from app.channels.capabilities import supports_proactive_group_notifications
from app.channels.notification_groups import is_notification_group
from app.gateway.authz import require_permission
from app.gateway.deps import (
get_config,
@ -110,7 +111,7 @@ async def _validate_notification_group(request: Request, connection_id: str | No
if connection_id is None:
return None
if not connection_id.strip() or len(connection_id) > 64:
raise scheduler_error(422, "invalid_notification_group", "Select a connected Feishu group belonging to this user")
raise scheduler_error(422, "invalid_notification_group", "Select a connected notification group belonging to this user")
# Reuse the connection UI's current config and owner-scoped repository.
# Keep this REST-only: the conversation scheduling tools have no group field.
@ -126,9 +127,19 @@ async def _validate_notification_group(request: Request, connection_id: str | No
for connection in connections:
if not isinstance(connection, dict) or connection.get("id") != connection_id or connection.get("owner_user_id") != user_id:
continue
if is_feishu_notification_group(provider=connection.get("provider"), status=connection.get("status"), workspace_id=connection.get("workspace_id"), metadata=connection.get("metadata")):
provider = connection.get("provider")
if (
isinstance(provider, str)
and supports_proactive_group_notifications(provider)
and is_notification_group(
provider=provider,
status=connection.get("status"),
workspace_id=connection.get("workspace_id"),
metadata=connection.get("metadata"),
)
):
return connection_id
raise scheduler_error(422, "invalid_notification_group", "Select a connected Feishu group belonging to this user")
raise scheduler_error(422, "invalid_notification_group", "Select a connected notification group belonging to this user")
class ScheduledTaskCreateRequest(BaseModel):

View File

@ -19,10 +19,14 @@ from datetime import UTC, datetime, timedelta
from typing import Any
from app.channels.base import ChannelUnavailable
from app.channels.capabilities import supports_proactive_notifications
from app.channels.notification_groups import is_feishu_notification_group
from app.channels.capabilities import supports_proactive_group_notifications, supports_proactive_notifications
from app.channels.notification_groups import is_notification_group
from app.scheduler.notification_text import DEFAULT_NOTIFICATION_LOCALE, redact_egress_text, render_notification_text, wants_result_summary
from deerflow.persistence.user.im_notifications import DISABLED_BY_OWNER
from deerflow.persistence.user.im_notifications import (
DISABLED_BY_OWNER,
IM_RUNTIME_NOTIFICATION_EVENTS,
RUNTIME_NOTIFICATION_SOURCE_KINDS,
)
__all__ = ["NotificationDeliveryWorker", "redact_egress_text", "render_notification_text"]
@ -77,6 +81,8 @@ class NotificationDeliveryWorker:
resolve_connections: ConnectionResolver | None = None,
excluded_providers: ExcludedProvidersResolver | None = None,
resolve_notification_preferences: Callable[[str], Any] | None = None,
resolve_notification_event_preferences: Callable[[str], Any] | None = None,
resolve_human_input_pending: Callable[..., Any] | None = None,
stale_sending_timeout_seconds: int = _STALE_SENDING_TIMEOUT_SECONDS,
stop_timeout_seconds: float = _STOP_TIMEOUT_SECONDS,
default_locale: str = DEFAULT_NOTIFICATION_LOCALE,
@ -87,6 +93,8 @@ class NotificationDeliveryWorker:
self._resolve_connections = resolve_connections
self._excluded_providers = excluded_providers
self._resolve_notification_preferences = resolve_notification_preferences
self._resolve_notification_event_preferences = resolve_notification_event_preferences
self._resolve_human_input_pending = resolve_human_input_pending
self._poll_interval_seconds = poll_interval_seconds
self._batch_size = batch_size
self._stale_sending_timeout_seconds = stale_sending_timeout_seconds
@ -242,7 +250,14 @@ class NotificationDeliveryWorker:
if (
row_owner == owner_user_id
and connection.get("id") == target
and is_feishu_notification_group(provider=connection.get("provider"), status=connection.get("status"), workspace_id=connection.get("workspace_id"), metadata=connection.get("metadata"))
and connection.get("provider") == provider
and supports_proactive_group_notifications(provider)
and is_notification_group(
provider=connection.get("provider"),
status=connection.get("status"),
workspace_id=connection.get("workspace_id"),
metadata=connection.get("metadata"),
)
):
return True
continue
@ -266,13 +281,74 @@ class NotificationDeliveryWorker:
if not preferences[delivery["provider"]]:
await self._delivery_repo.mark_failed(delivery["id"], claim_token=delivery.get("claim_token"), error=DISABLED_BY_OWNER, count_attempt=False, terminal=True)
return False
source_kind = delivery.get("source_kind") or (delivery.get("payload") or {}).get("source_kind")
event = delivery.get("event")
if source_kind in RUNTIME_NOTIFICATION_SOURCE_KINDS and isinstance(event, str) and event in IM_RUNTIME_NOTIFICATION_EVENTS:
if self._resolve_notification_event_preferences is not None:
try:
event_prefs = self._resolve_notification_event_preferences(delivery.get("owner_user_id") or "")
if inspect.isawaitable(event_prefs):
event_prefs = await event_prefs
except Exception:
logger.warning("Could not verify runtime event preference for delivery %s; parking it", delivery["id"], exc_info=True)
await self._delivery_repo.mark_failed(delivery["id"], claim_token=delivery.get("claim_token"), error="could not verify notification preference", count_attempt=False)
return False
if isinstance(event_prefs, dict) and not event_prefs.get(event, False):
await self._delivery_repo.mark_failed(delivery["id"], claim_token=delivery.get("claim_token"), error=DISABLED_BY_OWNER, count_attempt=False, terminal=True)
return False
elif preferences.get(event) is False:
await self._delivery_repo.mark_failed(delivery["id"], claim_token=delivery.get("claim_token"), error=DISABLED_BY_OWNER, count_attempt=False, terminal=True)
return False
return True
async def _check_human_input_pending(self, delivery: dict[str, Any]) -> bool:
if delivery.get("event") != "human_input_requested" or self._resolve_human_input_pending is None:
return True
try:
params = inspect.signature(self._resolve_human_input_pending).parameters
positional = [p for p in params.values() if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)]
if len(positional) >= 2:
payload = delivery.get("payload") if isinstance(delivery.get("payload"), dict) else {}
thread_id = payload.get("thread_id") or ""
request_id = payload.get("request_id") or payload.get("tool_call_id")
pending = self._resolve_human_input_pending(thread_id, request_id)
else:
pending = self._resolve_human_input_pending(delivery)
if inspect.isawaitable(pending):
pending = await pending
except Exception:
logger.warning("Could not verify human input request state for delivery %s; parking it", delivery["id"], exc_info=True)
await self._delivery_repo.mark_failed(
delivery["id"],
claim_token=delivery.get("claim_token"),
error="could not verify human input request state",
count_attempt=False,
)
return False
if pending is None:
await self._delivery_repo.mark_failed(
delivery["id"],
claim_token=delivery.get("claim_token"),
error="could not verify human input request state",
count_attempt=False,
)
return False
if not pending:
await self._delivery_repo.mark_failed(
delivery["id"],
claim_token=delivery.get("claim_token"),
error="human_input_request_already_resolved",
count_attempt=False,
terminal=True,
)
return False
return True
async def _deliver(self, delivery: dict[str, Any]) -> None:
delivery_id = delivery["id"]
provider = delivery.get("provider") or ""
target_type = (delivery.get("payload") or {}).get("target_type")
if target_type not in (None, "group") or (target_type == "group" and provider != "feishu"):
if target_type not in (None, "group") or (target_type == "group" and not supports_proactive_group_notifications(provider)):
await self._delivery_repo.mark_failed(delivery_id, claim_token=delivery.get("claim_token"), error="unsupported notification target type", terminal=True)
return
if not supports_proactive_notifications(provider):
@ -308,6 +384,8 @@ class NotificationDeliveryWorker:
return
if not await self._check_notification_preference(delivery):
return
if not await self._check_human_input_pending(delivery):
return
# Re-check channel liveness at delivery time, not enqueue time: the
# channel may have been disabled or disconnected after the outbox row
# was written, and the row stays retryable for when it comes back.
@ -331,6 +409,8 @@ class NotificationDeliveryWorker:
# Summary/config lookups can await long enough for a user to mute.
if not await self._check_notification_preference(delivery):
return
if not await self._check_human_input_pending(delivery):
return
is_active_claim = getattr(self._delivery_repo, "is_active_claim", None)
if callable(is_active_claim) and not await is_active_claim(delivery_id, claim_token=delivery.get("claim_token")):
return

View File

@ -133,8 +133,16 @@ _TEXT: dict[str, dict[str, str]] = {
"last_unmet": "The last run didn't meet the goal: {reason}.",
"last_unmet_bare": "The last run didn't meet the goal.",
"last_interrupted": "The last run was interrupted.",
"runtime_title": "Task “{title}”",
"runtime_untitled": "Untitled conversation",
"runtime_completed": "Completed.",
"runtime_failed": "Failed to complete.",
"human_input_requested": "Waiting for your input in the conversation.",
"question": "Question: {question}",
"result": "Result: {summary}",
"closing": "Open DeerFlow → Scheduled tasks for details.",
"runtime_closing": "Open DeerFlow → Conversations for details.",
"human_input_closing": "Open DeerFlow → Conversations to continue.",
"sentence_gap": " ",
},
"zh-CN": {
@ -160,8 +168,16 @@ _TEXT: dict[str, dict[str, str]] = {
"last_unmet": "最后一次运行未达成目标:{reason}。",
"last_unmet_bare": "最后一次运行未达成目标。",
"last_interrupted": "最后一次运行被中断了。",
"runtime_title": "任务“{title}”",
"runtime_untitled": "未命名对话",
"runtime_completed": "已完成。",
"runtime_failed": "运行失败,未能完成。",
"human_input_requested": "正在等待你在对话中确认或补充信息。",
"question": "待确认:{question}",
"result": "结果:{summary}",
"closing": "在 DeerFlow 的定时任务页查看详情。",
"runtime_closing": "在 DeerFlow 的对话页查看详情。",
"human_input_closing": "在 DeerFlow 的对话页继续处理。",
"sentence_gap": "",
},
}
@ -173,6 +189,7 @@ _SUMMARY_EVENTS = frozenset({"run_completed", "run_unmet", "task_paused", "task_
_SUMMARY_RUN_STATUSES = frozenset({"success", "unmet"})
# Rows written before payload version 2 carry no ``run_status`` for these.
_LEGACY_RUN_STATUS = {"run_completed": "success", "run_failed": "failed", "run_unmet": "unmet", "task_paused": "unmet"}
_RUNTIME_SOURCE_KINDS = frozenset({"run", "human_input_request"})
def _payload(delivery: dict[str, Any]) -> dict[str, Any]:
@ -180,6 +197,16 @@ def _payload(delivery: dict[str, Any]) -> dict[str, Any]:
return payload if isinstance(payload, dict) else {}
def _is_runtime_delivery(delivery: dict[str, Any]) -> bool:
payload = _payload(delivery)
source_kind = delivery.get("source_kind") or payload.get("source_kind")
if isinstance(source_kind, str) and source_kind in _RUNTIME_SOURCE_KINDS:
return True
if delivery.get("event") == "human_input_requested":
return True
return not delivery.get("task_id") and not delivery.get("task_run_id") and bool(delivery.get("run_id"))
def _run_status(delivery: dict[str, Any]) -> str | None:
status = _payload(delivery).get("run_status")
if isinstance(status, str) and status:
@ -230,10 +257,11 @@ def _with_reason(key: str, code: object, locale: str) -> str:
return text[f"{key}_bare"] if reason is None else text[key].format(reason=reason)
def _title(payload: dict[str, Any], locale: str) -> str:
title = payload.get("task_title")
title = _one_line(title) if isinstance(title, str) else ""
return _truncate(title, limit=_TITLE_LIMIT) if title else _TEXT[locale]["untitled"]
def _title(payload: dict[str, Any], locale: str, *, runtime: bool = False) -> str:
raw_title = payload.get("task_title") or payload.get("thread_title")
title = _one_line(redact_egress_text(raw_title)) if isinstance(raw_title, str) else ""
fallback_key = "runtime_untitled" if runtime else "untitled"
return _truncate(title, limit=_TITLE_LIMIT) if title else _TEXT[locale][fallback_key]
def _positive_int(value: object) -> int | None:
@ -254,19 +282,21 @@ def _last_run_suffix(event: str, run_status: str | None, payload: dict[str, Any]
return None
def _what_happened(delivery: dict[str, Any], locale: str) -> str:
def _what_happened(delivery: dict[str, Any], locale: str, *, runtime: bool = False) -> str:
text = _TEXT[locale]
payload = _payload(delivery)
event = delivery.get("event") or ""
run_status = _run_status(delivery)
parts: list[str]
if event == "run_completed":
parts = [text["run_completed"]]
if event == "human_input_requested":
parts = [text["human_input_requested"]]
elif event == "run_completed":
parts = [text["runtime_completed" if runtime else "run_completed"]]
if "relied_on_assumption" in payload:
parts.append(text["goal_met_with_assumption"] if payload.get("relied_on_assumption") is True else text["goal_met"])
elif event == "run_failed":
# Raw error text never leaves: tracebacks can carry hosts, paths and tokens.
parts = [text["run_failed"]]
parts = [text["runtime_failed" if runtime else "run_failed"]]
elif event == "run_unmet":
parts = [_with_reason("run_unmet", payload.get("reason_code"), locale)]
elif event == "task_paused":
@ -308,11 +338,22 @@ def render_notification_text(delivery: dict[str, Any], *, default_locale: str =
locale = resolve_locale(delivery, default_locale)
text = _TEXT[locale]
payload = _payload(delivery)
lines = [text["title"].format(title=_title(payload, locale)), _what_happened(delivery, locale)]
reply = payload.get("result_summary")
if wants_result_summary(delivery) and isinstance(reply, str):
summary = run_summary(redact_egress_text(reply))
if summary:
lines.append(text["result"].format(summary=summary))
lines.append(text["closing"])
runtime = _is_runtime_delivery(delivery)
event = delivery.get("event") or ""
title_template = text["runtime_title" if runtime else "title"]
lines = [title_template.format(title=_title(payload, locale, runtime=runtime)), _what_happened(delivery, locale, runtime=runtime)]
if event == "human_input_requested":
raw_question = payload.get("question") or payload.get("result_summary")
if isinstance(raw_question, str):
question = _truncate(_one_line(redact_egress_text(raw_question)), limit=_CONDITION_LIMIT)
if question:
lines.append(text["question"].format(question=question))
else:
reply = payload.get("result_summary")
if wants_result_summary(delivery) and isinstance(reply, str):
summary = run_summary(redact_egress_text(reply))
if summary:
lines.append(text["result"].format(summary=summary))
closing_key = "human_input_closing" if event == "human_input_requested" else ("runtime_closing" if runtime else "closing")
lines.append(text[closing_key])
return "\n".join(lines)

View File

@ -2,9 +2,11 @@ from __future__ import annotations
import asyncio
import hashlib
import inspect
import logging
import socket
import uuid
from collections.abc import Callable, Mapping
from datetime import UTC, datetime, timedelta
from typing import Any, Literal
from zoneinfo import ZoneInfo
@ -12,7 +14,8 @@ from zoneinfo import ZoneInfo
from fastapi import HTTPException
from sqlalchemy import select
from app.channels.capabilities import supports_proactive_notifications
from app.channels.capabilities import supports_proactive_group_notifications, supports_proactive_notifications
from app.channels.notification_groups import is_notification_group
from app.scheduler.notification_text import NOTIFICATION_LOCALES
from deerflow.persistence.channel_connections.model import ChannelConnectionRow
from deerflow.persistence.run.model import RunRow
@ -25,6 +28,7 @@ from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.user.im_notifications import ImNotificationPreferencesRepository
from deerflow.persistence.user.model import UserPreferenceRow
from deerflow.runtime import ConflictError, RunRecord
from deerflow.runtime.run_origin import origin_kind_of
from deerflow.scheduler.host_notes import RUN_ERROR_DELETED_WHILE_QUEUED, RUN_ERROR_INTERRUPTED, RUN_ERROR_LEASE_LOST, RUN_ERROR_PAUSED_WHILE_QUEUED, RUN_ERROR_QUEUE_TIMEOUT, RUN_ERROR_RESTARTED
from deerflow.scheduler.schedules import next_run_at
from deerflow.scheduler.stop_rule import launch_prompt
@ -218,6 +222,7 @@ class ScheduledTaskService:
connection_repo=None,
notification_repo=None,
own_stop_available: bool | None = None,
resolve_human_input_request: Callable[[RunRecord], Any] | None = None,
) -> None:
self._task_repo = task_repo
self._task_run_repo = task_run_repo
@ -238,6 +243,7 @@ class ScheduledTaskService:
# sends. Either being None sends no IM notices.
self._connection_repo = connection_repo
self._notification_repo = notification_repo
self._resolve_human_input_request = resolve_human_input_request
# Whether scheduled runs get stop_scheduled_task (scheduler.tool_enabled).
# Decides how a launch phrases the user's stop rule (stop_rule.launch_prompt).
# None (production) reads the live config at each launch, the same source
@ -373,10 +379,33 @@ class ScheduledTaskService:
payload = _notice_payload(task, occurrence, event, reason, locale=await self._owner_locale(session, task.user_id))
task_run_id = occurrence.id if occurrence is not None else _idle_notice_key(task.id, lifecycle_anchor(task, occurrence, now=now))
recipients = [(binding.provider, binding.external_account_id, payload) for binding in bindings]
if group_connection_id and preferences.get("feishu", False):
# Keep the selected binding identity even if it was removed: the
# worker records an explicit failed delivery after its owner check.
recipients.append(("feishu", group_connection_id, {**payload, "target_type": "group"}))
if group_connection_id:
group_row = await session.scalar(select(ChannelConnectionRow).where(ChannelConnectionRow.id == group_connection_id))
if group_row is None:
# Normal disconnects soft-revoke ChannelConnectionRow (status="revoked"),
# keeping group_row.provider intact. If the row was hard-deleted after a
# prior delivery, recover its provider from the outbox history before
# falling back to legacy "feishu".
from deerflow.persistence.notification_deliveries.model import NotificationDeliveryRow
prior_provider = await session.scalar(
select(NotificationDeliveryRow.provider)
.where(
NotificationDeliveryRow.owner_user_id == task.user_id,
NotificationDeliveryRow.target == group_connection_id,
)
.order_by(NotificationDeliveryRow.created_at.desc())
.limit(1)
)
group_provider: str | None = prior_provider if isinstance(prior_provider, str) and supports_proactive_group_notifications(prior_provider) else "feishu"
elif group_row.owner_user_id == task.user_id and isinstance(group_row.provider, str) and supports_proactive_group_notifications(group_row.provider):
group_provider = group_row.provider
else:
group_provider = None
if group_provider and preferences.get(group_provider, False):
# Keep the selected binding identity even if it was removed: the
# worker records an explicit failed delivery after its owner check.
recipients.append((group_provider, group_connection_id, {**payload, "target_type": "group"}))
for provider, target, notice_payload in recipients:
await self._notification_repo.enqueue_in_session(
session,
@ -951,10 +980,15 @@ class ScheduledTaskService:
)
async def handle_run_completion(self, record: RunRecord) -> None:
if getattr(record, "terminal_status_persisted", True) is False:
return
metadata = record.metadata or {}
task_id = metadata.get("scheduled_task_id")
task_run_id = metadata.get("scheduled_task_run_id")
user_id = record.user_id
if not isinstance(task_id, str) and not isinstance(task_run_id, str):
await self._handle_ordinary_run_completion(record)
return
if not isinstance(task_id, str) or not isinstance(task_run_id, str) or not user_id:
return
@ -998,6 +1032,182 @@ class ScheduledTaskService:
**completion_kwargs,
)
async def _handle_ordinary_run_completion(self, record: RunRecord) -> None:
if self._notification_repo is None or self._connection_repo is None:
return
if getattr(record, "terminal_status_persisted", True) is False:
return
user_id = record.user_id
if not isinstance(user_id, str) or not user_id.strip():
return
operation_kind = str(getattr(record, "operation_kind", "run"))
if operation_kind != "run":
return
metadata = record.metadata if isinstance(record.metadata, Mapping) else {}
if origin_kind_of(metadata) in {"im_channel", "github", "schedule"} or metadata.get("channel_source") or metadata.get("im_channel"):
return
abort_event = getattr(record, "abort_event", None)
if (abort_event is not None and abort_event.is_set()) or record.status.value == "interrupted":
return
human_input = getattr(record, "human_input_request", None)
if human_input is None and self._resolve_human_input_request is not None:
try:
resolved = self._resolve_human_input_request(record)
if inspect.isawaitable(resolved):
resolved = await resolved
human_input = resolved
except Exception:
logger.warning("Could not inspect human input request for run %s", record.run_id, exc_info=True)
human_input = None
if not isinstance(human_input, Mapping) or human_input.get("kind") not in {None, "human_input_request"}:
human_input = None
if record.status.value == "success" and human_input is not None:
event = "human_input_requested"
source_kind = "human_input_request"
request_id = str(human_input.get("request_id") or human_input.get("tool_call_id") or record.run_id).strip() or record.run_id
source_key = f"{record.thread_id}:{request_id}"[:128]
question = str(human_input.get("question") or "").strip() or None
elif record.status.value == "success":
verdict = getattr(record, "goal_verdict", None)
if isinstance(verdict, Mapping) and verdict.get("satisfied") is not True:
return
event = "run_completed"
source_kind = "run"
source_key = record.run_id
request_id = None
question = None
elif record.status.value in {"error", "timeout"}:
event = "run_failed"
source_kind = "run"
source_key = record.run_id
request_id = None
question = None
else:
return
session_factory = getattr(self._notification_repo, "session_factory", None)
if session_factory is None:
return
group_connection_id = metadata.get("notification_group_connection_id")
if not isinstance(group_connection_id, str) or not group_connection_id.strip():
group_connection_id = None
else:
group_connection_id = group_connection_id.strip()
async with session_factory() as session:
# Cheap read-only pre-gate: runtime notices are opt-in (default False),
# so runs for users who have not enabled this event exit without
# scanning bindings, querying thread metadata, or taking write locks.
unlocked_preferences = await ImNotificationPreferencesRepository.get_in_session(
session,
user_id,
lock_owner=False,
include_events=True,
)
if not unlocked_preferences.get(event, False):
return
persisted_run = await session.get(RunRow, record.run_id)
if persisted_run is not None and persisted_run.status != record.status.value:
return
candidate_bindings = [
(binding.provider, binding.external_account_id)
for binding in await session.scalars(
select(ChannelConnectionRow).where(
ChannelConnectionRow.owner_user_id == user_id,
ChannelConnectionRow.status == "connected",
)
)
if binding.provider
and binding.external_account_id
and supports_proactive_notifications(binding.provider)
and (binding.provider != "qq" or ((binding.metadata_json or {}).get("chat_type") == "c2c" and (binding.workspace_id or "").endswith(":c2c")))
]
candidate_group_provider: str | None = None
if group_connection_id:
group_row = await session.scalar(select(ChannelConnectionRow).where(ChannelConnectionRow.id == group_connection_id))
if (
group_row is not None
and group_row.owner_user_id == user_id
and isinstance(group_row.provider, str)
and supports_proactive_group_notifications(group_row.provider)
and is_notification_group(
provider=group_row.provider,
status=group_row.status,
workspace_id=group_row.workspace_id,
metadata=group_row.metadata_json,
)
):
candidate_group_provider = group_row.provider
if not candidate_bindings and candidate_group_provider is None:
return
thread_title = None
try:
thread_title = await session.scalar(
select(ThreadMetaRow.display_name).where(
ThreadMetaRow.thread_id == record.thread_id,
ThreadMetaRow.user_id == user_id,
)
)
except Exception:
thread_title = None
if not isinstance(thread_title, str) or not thread_title.strip():
raw_title = metadata.get("title") or getattr(record, "first_human_message", None)
thread_title = raw_title.strip() if isinstance(raw_title, str) and raw_title.strip() else None
else:
thread_title = thread_title.strip()
locale = await self._owner_locale(session, user_id)
run_status = "success" if record.status.value == "success" else "failed"
payload: dict[str, Any] = {
"payload_version": NOTIFICATION_PAYLOAD_VERSION,
"source_kind": source_kind,
"thread_id": record.thread_id,
"task_title": thread_title,
"locale": locale,
"run_status": run_status,
}
if event == "human_input_requested":
payload["request_id"] = request_id
if question:
payload["question"] = question
elif event == "run_completed":
verdict = getattr(record, "goal_verdict", None)
if isinstance(verdict, Mapping) and verdict.get("satisfied") is True:
payload["relied_on_assumption"] = verdict.get("relied_on_assumption") is True
last_ai = getattr(record, "last_ai_message", None)
if isinstance(last_ai, str) and last_ai.strip():
payload["result_summary"] = last_ai
async with session_factory() as session, session.begin():
preferences = await ImNotificationPreferencesRepository.get_in_session(
session,
user_id,
lock_owner=True,
include_events=True,
)
if not preferences.get(event, False):
return
recipients = [(provider, target, payload) for provider, target in candidate_bindings if preferences.get(provider, False)]
if group_connection_id and candidate_group_provider and preferences.get(candidate_group_provider, False):
recipients.append((candidate_group_provider, group_connection_id, {**payload, "target_type": "group"}))
for provider, target, notice_payload in recipients:
await self._notification_repo.enqueue_in_session(
session,
source_kind=source_kind,
source_key=source_key,
task_id=None,
task_run_id=None,
run_id=record.run_id,
event=event,
provider=provider,
target=target,
owner_user_id=user_id,
payload=notice_payload,
)
async def start(self) -> None:
if self._task is not None:
return

View File

@ -727,8 +727,8 @@ Notes:
- Manual trigger uses the same scheduled-task resource and run lifecycle.
- Scheduled task definitions and task-run history are persisted in the application database.
- With `channel_connections.enabled: true`, the scheduler sends IM notices ("scheduled task updates") to the task owner's connected identities. A delivery worker (same poll cadence as the scheduler) pushes them.
- **Which apps:** only providers whose channel implements proactive push get notices; today those are WeCom and Feishu/Lark (`proactive_notifications` in `app/channels/capabilities.py`, also returned by `GET /api/channels/providers`, where Settings shows "sent here" or "not available for this app yet"). Other providers get no outbox rows at all, and rows queued for them before the upgrade end once as `failed` without retries.
- **Optional Feishu group:** each task may select one additional owner-bound group through the task form or `notification_group_connection_id` in REST. No group is selected automatically; personal delivery is retained. The app needs `im:chat:read` for names, and a private binding never becomes a group destination. [Group setup and delivery](IM_CHANNEL_CONNECTIONS.md#feishu-group-notifications).
- **Which apps:** only providers whose channel implements proactive push get notices; today those are WeCom, Feishu/Lark, WeChat and QQ (`proactive_notifications` in `app/channels/capabilities.py`, also returned by `GET /api/channels/providers`). Other providers get no outbox rows, and unsupported queued rows end as `failed` without retries.
- **Optional group:** each task may select one additional owner-bound Feishu, QQ or WeCom group through the task form or `notification_group_connection_id` in REST. No group is selected automatically; personal delivery is retained. QQ/WeCom bindings take an owner-chosen group name; Feishu can read names with `im:chat:read`. A new private binding never authorizes an unbound group. [Group setup and delivery](IM_CHANNEL_CONNECTIONS.md#group-notifications).
- **Events:** `run_completed`, `run_failed`, `run_unmet` (goal missed), `task_paused` (automatic pause after three missed goals), `task_stopped` (paused by the agent because its stop condition was met) and `task_finished` (all `max_runs` done or `end_at` reached).
- **One message per occurrence:** when one occurrence produces several events, only the first of `task_stopped` > `task_paused` > `task_finished` > `run_failed` > `run_unmet` > `run_completed` is sent, and that message still says how the last run went ("The last run failed." / "didn't meet the goal"). A one-time task sends only its run's outcome, never a separate `task_finished`.
- **Transactional:** notices are queued by the finalization observer in the same database transaction that records the outcome and are deduplicated per occurrence (or per end time for a task that ends between runs), so an occurrence finalized by crash or lease recovery notifies exactly once. Plain manual "run now" trials and interrupted runs stay silent; a trial that pauses or finishes the task still sends that notice. Occurrences that end without a finished run (launch error, queue timeout, interrupted by a restart) stay silent.

View File

@ -474,22 +474,26 @@ can retry an ambiguously acknowledged send; it does not promise exactly-once
messages at the provider. Record whether domestic Feishu or international Lark
was actually exercised.
### Feishu group notifications
### Group notifications
Tasks may explicitly select one additional connected Feishu group. Personal
Tasks may explicitly select one additional connected Feishu, QQ or WeCom group. Personal
notifications keep their existing recipients. The task stores the chosen
`notification_group_connection_id`; a group notice stores that connection ID as
its outbox target with `payload.target_type="group"`. It never infers a group
destination from all of the owner's bindings.
Add the app bot to a test or work group, grant `im:chat:read` for the group-name
lookup, and publish the app changes. In the task form choose **Bind a Feishu
group**, mention the bot and send the displayed `/connect` command in that
group, then refresh and select its name. Name lookup failure does not discard
the binding, but unnamed groups cannot be newly selected in the form: check
the read permission and bind again. API selection and each delivery verify the
In the task form select the supported channel's group-binding button. For QQ
and WeCom, enter a recognizable group name before generating the code; the
browser sends optional `group_name` to `POST /api/channels/{provider}/connect`,
which stores the name with the single-use OAuth state. Send the unchanged
`/connect` command in that group, then refresh and select the saved name.
Bindings for two groups remain distinct from each other and from private chats.
Feishu can retrieve its group name using `im:chat:read`; grant that permission
and publish the app changes. A failed lookup does not discard the binding,
but unnamed groups cannot be newly selected: fix the permission or name and
bind again. API selection and each delivery verify the
task owner, connection ID, connected status, and recorded group/chat identity.
The provider sends to that verified `chat_id`; a lost binding cannot fall back
The adapter resolves its verified group identity; a lost binding cannot fall back
to another group or a private chat. A bot removed from the group produces a
normal platform rejection handled by the existing retry budget.
@ -667,7 +671,7 @@ the request was sent, still releases the key for a retry. See
## Proactive notification settings
The shared scheduler outbox sends result/lifecycle notices to bound private
The shared outbox sends scheduled result/lifecycle notices to bound private
identities on WeCom, Feishu/Lark, WeChat and QQ. **Settings → Notifications → IM
notifications** lists connected, push-capable apps with the same per-provider
control. The setting is durable and owner-scoped, and also works in local
@ -675,13 +679,28 @@ control. The setting is durable and owner-scoped, and also works in local
account or stop a scheduled task. Browser notifications have a separate switch.
Opting out stops new enqueueing and permanently closes pending/retrying notices
for that owner/provider, including any selected Feishu group. Re-enabling does
for that owner/provider, including any selected group. Re-enabling does
not replay these notices. A platform send already in progress may still arrive.
Preference lookup failures park instead of sending; ordinary binding revocation,
localized summaries, redaction, deduplication and retry budgets remain shared.
Upgrade every Gateway worker before relying on an opt-out during a rolling
upgrade. Downgrading below `0041_im_notice_preferences` removes the choices.
The same settings page has three default-off ordinary task/goal event controls:
completion, terminal failure and requests for input. These use the same outbox,
ownership checks, localized renderer and send/retry opt-out. An unanswered
Human Input Card takes precedence over completion and is rechecked before
sending; an answered or superseded request is dropped. Ordinary goal stand-downs,
recoverable tool errors and interrupted runs do not become failure or input
notices. IM-originated runs retain their normal conversation replies. Migration
`0043_runtime_im_notices` adds real source identities for non-scheduled notices;
downgrading that revision deletes the runtime notices and retains scheduled ones.
WeCom groups bound before group destinations were introduced keep their legacy
chat identity. That compatibility applies only to the old group-binding shape;
a new private binding never authorizes messages from an unbound group. Bind a
specific group again with a name to make it available as a notification target.
### WeChat private notifications
Use the existing iLink QR/bot setup and bind in a private chat. Send one private
@ -711,5 +730,8 @@ Transport failures park; platform rejections use the shared retry budget.
Current official rules are in the [QQ message overview](https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/overview.html).
Older GitHub bot-docs snapshots say proactive sending was discontinued; use the
current live documentation and verify the actual bot account. QQ group
notification destinations remain separate from the existing group chat replies.
current live documentation and verify the actual bot account. QQ group notices
use the selected connection's current-App `group_openid`, not a member OpenID
or the passive reply context. WeCom group notices use the selected current-bot
`chatid` through the same smart-bot WebSocket sender; neither path falls back to
a private destination when a group binding is unavailable.

View File

@ -2,8 +2,8 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Literal, TypedDict
from collections.abc import Mapping, Sequence
from typing import Any, Literal, TypedDict
HUMAN_INPUT_RESPONSE_KEY = "human_input_response"
@ -74,3 +74,52 @@ def read_human_input_response(additional_kwargs: Mapping[str, object] | None) ->
"value": value,
}
return None
def _message_type(message: Any) -> str | None:
if isinstance(message, Mapping):
msg_type = message.get("type") or message.get("role")
else:
msg_type = getattr(message, "type", None) or getattr(message, "role", None)
return msg_type if isinstance(msg_type, str) else None
def extract_unanswered_human_input_request(messages: Sequence[Any] | None) -> dict[str, str] | None:
"""Return the trailing unanswered ``human_input_request`` from *messages*, or ``None``.
``ask_clarification`` and structured human-input tool interrupts place the
request in a ``ToolMessage`` artifact and route the graph to ``END``, so an
active prompt sits in the trailing run of tool messages. Once the user
replies (with a structured ``human_input_response`` or any later non-tool
turn), the request is no longer pending.
"""
if not messages:
return None
answered_request_ids: set[str] = set()
for message in messages:
additional_kwargs = message.get("additional_kwargs") if isinstance(message, Mapping) else getattr(message, "additional_kwargs", None)
response = read_human_input_response(additional_kwargs if isinstance(additional_kwargs, Mapping) else None)
if response is not None:
answered_request_ids.add(response["request_id"])
for message in reversed(messages):
if _message_type(message) != "tool":
return None
artifact = message.get("artifact") if isinstance(message, Mapping) else getattr(message, "artifact", None)
human_input = artifact.get("human_input") if isinstance(artifact, Mapping) else None
if not isinstance(human_input, Mapping) or human_input.get("kind") != "human_input_request":
continue
msg_id = message.get("id") if isinstance(message, Mapping) else getattr(message, "id", None)
request_id = _non_empty_string(human_input.get("request_id")) or _non_empty_string(msg_id)
if request_id is not None and request_id in answered_request_ids:
return None
question = _non_empty_string(human_input.get("question")) or ""
source = _non_empty_string(human_input.get("source")) or "ask_clarification"
return {
"kind": "human_input_request",
"request_id": request_id or "human_input_request",
"question": question,
"source": source,
}
return None

View File

@ -0,0 +1,124 @@
"""Generalize notification_deliveries source columns for runtime IM notifications.
Revision ID: 0043_runtime_im_notices
Revises: 0042_channel_leases
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0043_runtime_im_notices"
down_revision: str | Sequence[str] | None = "0042_channel_leases"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TABLE = "notification_deliveries"
_OLD_UQ = "uq_notification_delivery_run_event_target"
_NEW_UQ = "uq_notification_delivery_source_event_target"
_SOURCE_KEY_IX = "ix_notification_deliveries_source_key"
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table(_TABLE):
return
columns = {col["name"]: col for col in inspector.get_columns(_TABLE)}
if "source_kind" not in columns or "source_key" not in columns:
with op.batch_alter_table(_TABLE) as batch:
if "source_kind" not in columns:
batch.add_column(sa.Column("source_kind", sa.String(length=32), nullable=True))
if "source_key" not in columns:
batch.add_column(sa.Column("source_key", sa.String(length=128), nullable=True))
bind.execute(sa.text("UPDATE notification_deliveries SET source_kind = COALESCE(source_kind, 'scheduled_task'), source_key = COALESCE(source_key, task_run_id, run_id, id) WHERE source_kind IS NULL OR source_key IS NULL"))
inspector = sa.inspect(bind)
columns = {col["name"]: col for col in inspector.get_columns(_TABLE)}
unique_constraints = {uc["name"] for uc in inspector.get_unique_constraints(_TABLE) if uc.get("name")}
indexes = {idx["name"] for idx in inspector.get_indexes(_TABLE) if idx.get("name")}
needs_batch = columns["source_kind"]["nullable"] or columns["source_key"]["nullable"] or not columns["task_id"]["nullable"] or not columns["task_run_id"]["nullable"] or _OLD_UQ in unique_constraints or _NEW_UQ not in unique_constraints
if needs_batch:
with op.batch_alter_table(_TABLE) as batch:
if _OLD_UQ in unique_constraints:
batch.drop_constraint(_OLD_UQ, type_="unique")
batch.alter_column(
"source_kind",
existing_type=sa.String(length=32),
nullable=False,
server_default=None,
)
batch.alter_column(
"source_key",
existing_type=sa.String(length=128),
nullable=False,
server_default=None,
)
batch.alter_column(
"task_id",
existing_type=sa.String(length=64),
nullable=True,
)
batch.alter_column(
"task_run_id",
existing_type=sa.String(length=64),
nullable=True,
)
if _NEW_UQ not in unique_constraints:
batch.create_unique_constraint(
_NEW_UQ,
["source_kind", "source_key", "event", "provider", "target"],
)
inspector = sa.inspect(bind)
indexes = {idx["name"] for idx in inspector.get_indexes(_TABLE) if idx.get("name")}
if _SOURCE_KEY_IX not in indexes:
op.create_index(_SOURCE_KEY_IX, _TABLE, ["source_key"], unique=False)
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table(_TABLE):
return
bind.execute(sa.text("DELETE FROM notification_deliveries WHERE task_id IS NULL OR task_run_id IS NULL"))
indexes = {idx["name"] for idx in inspector.get_indexes(_TABLE) if idx.get("name")}
if _SOURCE_KEY_IX in indexes:
op.drop_index(_SOURCE_KEY_IX, table_name=_TABLE)
inspector = sa.inspect(bind)
columns = {col["name"]: col for col in inspector.get_columns(_TABLE)}
unique_constraints = {uc["name"] for uc in inspector.get_unique_constraints(_TABLE) if uc.get("name")}
with op.batch_alter_table(_TABLE) as batch:
if _NEW_UQ in unique_constraints:
batch.drop_constraint(_NEW_UQ, type_="unique")
if "task_id" in columns:
batch.alter_column(
"task_id",
existing_type=sa.String(length=64),
nullable=False,
)
if "task_run_id" in columns:
batch.alter_column(
"task_run_id",
existing_type=sa.String(length=64),
nullable=False,
)
if _OLD_UQ not in unique_constraints:
batch.create_unique_constraint(
_OLD_UQ,
["task_run_id", "event", "provider", "target"],
)
if "source_key" in columns:
batch.drop_column("source_key")
if "source_kind" in columns:
batch.drop_column("source_kind")

View File

@ -27,8 +27,10 @@ class NotificationDeliveryRow(Base):
__tablename__ = "notification_deliveries"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
task_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
task_run_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
source_kind: Mapped[str] = mapped_column(String(32), nullable=False, default="scheduled_task")
source_key: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
task_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
task_run_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
run_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
event: Mapped[str] = mapped_column(String(64), nullable=False)
provider: Mapped[str] = mapped_column(String(32), nullable=False)
@ -60,11 +62,21 @@ class NotificationDeliveryRow(Base):
__table_args__ = (
UniqueConstraint(
"task_run_id",
"source_kind",
"source_key",
"event",
"provider",
"target",
name="uq_notification_delivery_run_event_target",
name="uq_notification_delivery_source_event_target",
),
Index("ix_notification_deliveries_due", "status", "available_at"),
)
def __init__(self, **kwargs):
if kwargs.get("source_kind") is None:
kwargs["source_kind"] = "scheduled_task" if kwargs.get("task_run_id") or kwargs.get("task_id") else "run"
if kwargs.get("source_key") is None:
source_key = kwargs.get("task_run_id") or kwargs.get("run_id") or kwargs.get("id")
if source_key is not None:
kwargs["source_key"] = str(source_key)
super().__init__(**kwargs)

View File

@ -67,11 +67,28 @@ class NotificationDeliveryRepository:
data["payload"] = data.pop("payload_json") or {}
return data
@staticmethod
def _resolve_source(
*,
source_kind: str | None,
source_key: str | None,
task_id: str | None,
task_run_id: str | None,
run_id: str | None,
) -> tuple[str, str]:
resolved_kind = (source_kind or "").strip() or ("scheduled_task" if task_run_id or task_id else "run")
resolved_key = (source_key or "").strip() or (task_run_id or run_id or "").strip()
if not resolved_key:
raise ValueError("Notification delivery requires source_key, task_run_id, or run_id")
return resolved_kind, resolved_key
async def enqueue(
self,
*,
task_id: str,
task_run_id: str,
task_id: str | None = None,
task_run_id: str | None = None,
source_kind: str | None = None,
source_key: str | None = None,
event: str,
provider: str,
target: str,
@ -81,10 +98,19 @@ class NotificationDeliveryRepository:
available_at: datetime | None = None,
max_attempts: int = 5,
) -> dict[str, Any]:
"""Insert one delivery; a duplicate (task_run_id, event, provider,
target) returns the existing row instead of raising."""
"""Insert one delivery; a duplicate (source_kind, source_key, event,
provider, target) returns the existing row instead of raising."""
resolved_kind, resolved_key = self._resolve_source(
source_kind=source_kind,
source_key=source_key,
task_id=task_id,
task_run_id=task_run_id,
run_id=run_id,
)
row = NotificationDeliveryRow(
id=self._new_id(),
source_kind=resolved_kind,
source_key=resolved_key,
task_id=task_id,
task_run_id=task_run_id,
run_id=run_id,
@ -105,7 +131,8 @@ class NotificationDeliveryRepository:
return self._to_dict(row)
except IntegrityError:
existing = await self._find_by_idempotency_key(
task_run_id=task_run_id,
source_kind=resolved_kind,
source_key=resolved_key,
event=event,
provider=provider,
target=target,
@ -118,8 +145,10 @@ class NotificationDeliveryRepository:
self,
session: AsyncSession,
*,
task_id: str,
task_run_id: str,
task_id: str | None = None,
task_run_id: str | None = None,
source_kind: str | None = None,
source_key: str | None = None,
event: str,
provider: str,
target: str,
@ -138,9 +167,18 @@ class NotificationDeliveryRepository:
dialect = session.get_bind().dialect.name
if dialect not in {"sqlite", "postgresql"}:
raise ValueError(f"Unsupported notification database dialect: {dialect}")
resolved_kind, resolved_key = self._resolve_source(
source_kind=source_kind,
source_key=source_key,
task_id=task_id,
task_run_id=task_run_id,
run_id=run_id,
)
insert = sqlite_insert if dialect == "sqlite" else pg_insert
values = {
"id": self._new_id(),
"source_kind": resolved_kind,
"source_key": resolved_key,
"task_id": task_id,
"task_run_id": task_run_id,
"run_id": run_id,
@ -153,11 +191,12 @@ class NotificationDeliveryRepository:
}
if available_at is not None:
values["available_at"] = available_at
statement = insert(NotificationDeliveryRow).values(**values).on_conflict_do_nothing(index_elements=["task_run_id", "event", "provider", "target"])
statement = insert(NotificationDeliveryRow).values(**values).on_conflict_do_nothing(index_elements=["source_kind", "source_key", "event", "provider", "target"])
await session.execute(statement)
row = await session.scalar(
select(NotificationDeliveryRow).where(
NotificationDeliveryRow.task_run_id == task_run_id,
NotificationDeliveryRow.source_kind == resolved_kind,
NotificationDeliveryRow.source_key == resolved_key,
NotificationDeliveryRow.event == event,
NotificationDeliveryRow.provider == provider,
NotificationDeliveryRow.target == target,
@ -170,7 +209,8 @@ class NotificationDeliveryRepository:
async def _find_by_idempotency_key(
self,
*,
task_run_id: str,
source_kind: str,
source_key: str,
event: str,
provider: str,
target: str,
@ -178,7 +218,8 @@ class NotificationDeliveryRepository:
async with self.session_factory() as session:
result = await session.execute(
select(NotificationDeliveryRow).where(
NotificationDeliveryRow.task_run_id == task_run_id,
NotificationDeliveryRow.source_kind == source_kind,
NotificationDeliveryRow.source_key == source_key,
NotificationDeliveryRow.event == event,
NotificationDeliveryRow.provider == provider,
NotificationDeliveryRow.target == target,

View File

@ -2,7 +2,7 @@
from datetime import UTC, datetime
from sqlalchemy import Boolean, String, select, true, update
from sqlalchemy import Boolean, String, delete, select, true, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.orm import Mapped, mapped_column
@ -11,6 +11,9 @@ from deerflow.persistence.base import Base
from deerflow.persistence.notification_deliveries.model import NotificationDeliveryRow
IM_NOTIFICATION_PROVIDERS = ("feishu", "wecom", "wechat", "qq")
IM_RUNTIME_NOTIFICATION_EVENTS = ("run_completed", "run_failed", "human_input_requested")
IM_NOTIFICATION_PREFERENCE_KEYS = (*IM_NOTIFICATION_PROVIDERS, *IM_RUNTIME_NOTIFICATION_EVENTS)
RUNTIME_NOTIFICATION_SOURCE_KINDS = ("run", "human_input_request")
DISABLED_BY_OWNER = "disabled_by_owner"
@ -26,50 +29,102 @@ class ImNotificationPreferenceRow(Base):
class ImNotificationPreferencesRepository:
"""Independent preference rows; disabling also permanently closes the backlog.
Missing values preserve the existing connected-channel default. Browser
notifications use a different preference and remain unchanged.
Missing values preserve the existing connected-channel default for providers
(True) while runtime task/goal notification events remain opt-in (False).
Browser notifications use a different preference and remain unchanged.
"""
def __init__(self, sessions):
self.sessions = sessions
@staticmethod
async def get_in_session(session, user_id: str, *, lock_owner: bool = False) -> dict[str, bool]:
# Serialize enqueue with opt-out on Postgres. The scheduler already holds
# SQLite's writer lock; a patch takes it at its first upsert below.
statement = select(ImNotificationPreferenceRow.provider, ImNotificationPreferenceRow.enabled).where(ImNotificationPreferenceRow.owner_user_id == user_id, ImNotificationPreferenceRow.provider.in_(IM_NOTIFICATION_PROVIDERS))
if lock_owner and session.get_bind().dialect.name == "postgresql":
# Materialize missing defaults so even the first opt-out serializes
# with enqueue. Locks are always acquired in provider order.
for provider in sorted(IM_NOTIFICATION_PROVIDERS):
await session.execute(pg_insert(ImNotificationPreferenceRow).values(owner_user_id=user_id, provider=provider, enabled=True).on_conflict_do_nothing())
statement = statement.order_by(ImNotificationPreferenceRow.provider).with_for_update()
async def get_in_session(
session,
user_id: str,
*,
lock_owner: bool = False,
include_events: bool = False,
) -> dict[str, bool]:
# Serialize enqueue with opt-out on Postgres and SQLite. Materializing
# provider defaults acquires SQLite's writer lock (or row locks on
# Postgres) in deterministic key order before reading preferences.
statement = select(ImNotificationPreferenceRow.provider, ImNotificationPreferenceRow.enabled).where(
ImNotificationPreferenceRow.owner_user_id == user_id,
ImNotificationPreferenceRow.provider.in_(IM_NOTIFICATION_PREFERENCE_KEYS),
)
if lock_owner:
dialect = session.get_bind().dialect.name
if dialect in ("postgresql", "sqlite"):
insert_fn = pg_insert if dialect == "postgresql" else sqlite_insert
for key in sorted(IM_NOTIFICATION_PROVIDERS):
await session.execute(insert_fn(ImNotificationPreferenceRow).values(owner_user_id=user_id, provider=key, enabled=True).on_conflict_do_nothing())
if dialect == "postgresql":
statement = statement.order_by(ImNotificationPreferenceRow.provider).with_for_update()
rows = await session.execute(statement)
stored = dict(rows.all())
return {provider: stored.get(provider, True) is True for provider in IM_NOTIFICATION_PROVIDERS}
result = {provider: stored.get(provider, True) is True for provider in IM_NOTIFICATION_PROVIDERS}
for event in IM_RUNTIME_NOTIFICATION_EVENTS:
if include_events or event in stored:
result[event] = stored.get(event, False) is True
return result
async def get(self, user_id: str) -> dict[str, bool]:
async def get(self, user_id: str, *, include_events: bool = False) -> dict[str, bool]:
async with self.sessions() as session:
return await self.get_in_session(session, user_id)
return await self.get_in_session(session, user_id, include_events=include_events)
async def get_event_preferences(self, user_id: str) -> dict[str, bool]:
prefs = await self.get(user_id, include_events=True)
return {event: prefs.get(event, False) is True for event in IM_RUNTIME_NOTIFICATION_EVENTS}
async def patch(self, user_id: str, values: dict[str, bool | None]) -> dict[str, bool]:
if any(provider not in IM_NOTIFICATION_PROVIDERS or (value is not None and type(value) is not bool) for provider, value in values.items()):
if any(key not in IM_NOTIFICATION_PREFERENCE_KEYS or (value is not None and type(value) is not bool) for key, value in values.items()):
raise ValueError("Invalid IM notification preference")
async with self.sessions() as session, session.begin():
dialect = session.get_bind().dialect.name
if dialect == "postgresql":
if dialect in ("postgresql", "sqlite"):
await self.get_in_session(session, user_id, lock_owner=True)
insert = pg_insert if dialect == "postgresql" else sqlite_insert
for provider, value in sorted(values.items()):
statement = insert(ImNotificationPreferenceRow).values(owner_user_id=user_id, provider=provider, enabled=value is not False)
await session.execute(statement.on_conflict_do_update(index_elements=["owner_user_id", "provider"], set_={"enabled": statement.excluded.enabled}))
disabled = [provider for provider, value in values.items() if value is False]
if disabled:
for key, value in sorted(values.items()):
if key in IM_RUNTIME_NOTIFICATION_EVENTS and value is None:
await session.execute(
delete(ImNotificationPreferenceRow).where(
ImNotificationPreferenceRow.owner_user_id == user_id,
ImNotificationPreferenceRow.provider == key,
)
)
continue
enabled = (value is not False) if key in IM_NOTIFICATION_PROVIDERS else (value is True)
statement = insert(ImNotificationPreferenceRow).values(owner_user_id=user_id, provider=key, enabled=enabled)
await session.execute(
statement.on_conflict_do_update(
index_elements=["owner_user_id", "provider"],
set_={"enabled": statement.excluded.enabled},
)
)
now = datetime.now(UTC)
disabled_providers = [key for key, value in values.items() if key in IM_NOTIFICATION_PROVIDERS and value is False]
if disabled_providers:
# Do not merely defer: reopening must never release old notices.
# Clear tokens so a late worker cannot overwrite the opt-out.
await session.execute(
update(NotificationDeliveryRow)
.where(NotificationDeliveryRow.owner_user_id == user_id, NotificationDeliveryRow.provider.in_(disabled), NotificationDeliveryRow.status.in_(("pending", "sending")))
.values(status="failed", last_error=DISABLED_BY_OWNER, claim_token=None, updated_at=datetime.now(UTC))
.where(
NotificationDeliveryRow.owner_user_id == user_id,
NotificationDeliveryRow.provider.in_(disabled_providers),
NotificationDeliveryRow.status.in_(("pending", "sending")),
)
.values(status="failed", last_error=DISABLED_BY_OWNER, claim_token=None, updated_at=now)
)
disabled_events = [key for key, value in values.items() if key in IM_RUNTIME_NOTIFICATION_EVENTS and value is not True]
if disabled_events:
await session.execute(
update(NotificationDeliveryRow)
.where(
NotificationDeliveryRow.owner_user_id == user_id,
NotificationDeliveryRow.source_kind.in_(RUNTIME_NOTIFICATION_SOURCE_KINDS),
NotificationDeliveryRow.event.in_(disabled_events),
NotificationDeliveryRow.status.in_(("pending", "sending")),
)
.values(status="failed", last_error=DISABLED_BY_OWNER, claim_token=None, updated_at=now)
)
return await self.get_in_session(session, user_id)

View File

@ -254,6 +254,8 @@ class RunRecord:
ownership_lost: bool = False
stop_reason: str | None = None
goal_verdict: dict[str, Any] | None = None
human_input_request: dict[str, Any] | None = None
terminal_status_persisted: bool = True
# Process-local finalization barrier for a worker-installed scheduled goal.
scheduled_goal_cleanup_pending: bool = False
# Process-local renewal barrier: the worker stages its terminal status in
@ -581,6 +583,9 @@ class RunManager:
if updated is False:
existing = await self._store.get(run_id)
requested_status = kwargs.get("status")
if existing is not None and existing.get("status") == requested_status:
if record is not None and requested_status == record.status.value:
record.terminal_status_persisted = True
if existing is not None and existing.get("status") != requested_status:
existing_status = existing.get("status")
logger.warning(
@ -607,6 +612,9 @@ class RunManager:
)
if recovered is False:
logger.warning("Run completion update for %s affected no rows after row recreation", run_id)
return
if record is not None and kwargs.get("status") in (None, record.status.value):
record.terminal_status_persisted = True
except Exception:
logger.warning("Failed to persist run completion for %s", run_id, exc_info=True)
@ -1059,6 +1067,8 @@ class RunManager:
return
record.status = status
record.updated_at = _now_iso()
if status not in (RunStatus.pending, RunStatus.running):
record.terminal_status_persisted = self._store is None
if error is not None:
record.error = error
if stop_reason is not None:
@ -1067,6 +1077,9 @@ class RunManager:
record.goal_verdict = copy.deepcopy(goal_verdict)
if persist:
persisted = await self._persist_status(record, status, error=error, stop_reason=stop_reason)
if status not in (RunStatus.pending, RunStatus.running):
async with self._lock:
record.terminal_status_persisted = persisted
if not persisted and self.heartbeat_enabled and status == RunStatus.success and not record.ownership_lost:
await self._mark_ownership_lost(
record,
@ -1088,6 +1101,9 @@ class RunManager:
error = record.error
stop_reason = record.stop_reason
persisted = await self._persist_status(record, status, error=error, stop_reason=stop_reason)
if status not in (RunStatus.pending, RunStatus.running):
async with self._lock:
record.terminal_status_persisted = persisted
if not persisted and self.heartbeat_enabled and status == RunStatus.success and not record.ownership_lost:
await self._mark_ownership_lost(
record,
@ -1133,6 +1149,8 @@ class RunManager:
except Exception:
async with self._lock:
record = self._runs.get(run_id)
if record is not None:
record.terminal_status_persisted = False
if record is not None:
await self._mark_ownership_lost(
record,
@ -1157,6 +1175,11 @@ class RunManager:
goal_verdict=goal_verdict,
persist=not result.finalized,
)
if result.finalized:
async with self._lock:
record = self._runs.get(run_id)
if record is not None:
record.terminal_status_persisted = True
return None
async def _ensure_delivery_receipt(self, record: RunRecord) -> bool:

View File

@ -1872,7 +1872,21 @@ async def run_agent(
except Exception:
logger.debug("Failed to update thread_meta status for %s (non-fatal)", thread_id)
if not record.ownership_lost and ctx.on_run_completed is not None:
if started and not record.ownership_lost and accessor is not None and record.status == RunStatus.success and not record.abort_event.is_set() and record.human_input_request is None:
try:
from deerflow.agents.human_input import extract_unanswered_human_input_request
record.human_input_request = extract_unanswered_human_input_request(await _materialized_checkpoint_messages(accessor, thread_id))
except Exception:
logger.debug("Failed to inspect trailing human_input_request for run %s (non-fatal)", run_id, exc_info=True)
if not record.ownership_lost and not getattr(record, "terminal_status_persisted", True):
try:
await run_manager.persist_current_status(run_id)
except Exception:
logger.warning("Failed to confirm terminal status for run %s before completion hook", run_id, exc_info=True)
if not record.ownership_lost and getattr(record, "terminal_status_persisted", True) and ctx.on_run_completed is not None:
try:
await ctx.on_run_completed(record)
except Exception:

View File

@ -14,7 +14,11 @@ from pathlib import Path
import pytest
from app.channels.base import Channel
from app.channels.capabilities import CHANNEL_CAPABILITIES, supports_proactive_notifications
from app.channels.capabilities import (
CHANNEL_CAPABILITIES,
supports_proactive_group_notifications,
supports_proactive_notifications,
)
from app.channels.service import _CHANNEL_REGISTRY
from deerflow.reflection import resolve_class
@ -31,7 +35,7 @@ def test_capabilities_cover_channel_registry():
def test_every_entry_declares_both_flags_as_bools():
for provider, entry in CHANNEL_CAPABILITIES.items():
assert set(entry) == {"supports_streaming", "proactive_notifications"}, provider
assert set(entry) == {"supports_streaming", "proactive_notifications", "proactive_group_notifications"}, provider
assert all(isinstance(value, bool) for value in entry.values()), provider
@ -41,9 +45,17 @@ def test_supported_notification_channels_push_proactively():
assert supports_proactive_notifications(provider) is True
def test_supported_group_notification_channels():
assert {provider for provider, entry in CHANNEL_CAPABILITIES.items() if entry["proactive_group_notifications"]} == {"wecom", "feishu", "qq"}
for provider in ("wecom", "feishu", "qq"):
assert supports_proactive_group_notifications(provider) is True
assert supports_proactive_group_notifications("wechat") is False
@pytest.mark.parametrize("provider", ["", "unknown", "WeCom", None, 1])
def test_unknown_provider_has_no_proactive_push(provider):
assert supports_proactive_notifications(provider) is False
assert supports_proactive_group_notifications(provider) is False
def _missing_optional_module(exc: BaseException) -> str | None:
@ -70,6 +82,8 @@ def test_proactive_flag_matches_send_notification_override(provider):
pytest.skip(f"{provider}: optional SDK {missing} not installed")
overrides = cls.send_notification is not Channel.send_notification
assert CHANNEL_CAPABILITIES[provider]["proactive_notifications"] is overrides
group_overrides = cls.send_group_notification_for_owner is not Channel.send_group_notification_for_owner
assert CHANNEL_CAPABILITIES[provider]["proactive_group_notifications"] is group_overrides
def test_importing_capabilities_loads_no_channel_sdk():

View File

@ -599,6 +599,48 @@ def test_connect_existing_binding_code_channels_return_command_and_persist_state
anyio.run(repo.close)
@pytest.mark.parametrize("provider", ["qq", "wecom"])
def test_browser_group_names_survive_state_consumption_and_bind_two_groups(tmp_path, monkeypatch, provider):
import anyio
from app.channels.message_bus import MessageBus
from app.channels.qq import QQChannel
from app.channels.wecom import WeComChannel
monkeypatch.setattr(channel_connections, "_ensure_runtime_channel_ready_if_available", AsyncMock())
repo = anyio.run(_make_repo, tmp_path)
config = ChannelConnectionsConfig.model_validate({"enabled": True, provider: {"enabled": True}})
runtime = {"qq": {"enabled": True, "app_id": "app-1", "client_secret": "offline"}, "wecom": {"enabled": True, "bot_id": "bot-1", "bot_secret": "offline"}}
app = _make_app(config, repo, runtime)
try:
with TestClient(app) as client:
responses = [client.post(f"/api/channels/{provider}/connect", json={"group_name": name}) for name in ("Release team", "Support team")]
assert all(response.status_code == 200 for response in responses), [response.text for response in responses]
async def bind_groups():
if provider == "qq":
channel = QQChannel(MessageBus(), {"app_id": "app-1", "connection_repo": repo})
channel.send = AsyncMock()
else:
channel = WeComChannel(MessageBus(), {"connection_repo": repo})
channel._send_connection_reply = AsyncMock()
for index, response in enumerate(responses):
code = response.json()["code"]
if provider == "qq":
await channel._handle_inbound({"t": "GROUP_AT_MESSAGE_CREATE", "d": {"id": f"message-{index}", "author": {"member_openid": "alice"}, "group_openid": f"group-{index}", "content": f"/connect {code}"}})
else:
await channel._bind_connection_from_connect_code(frame={"body": {"aibotid": "bot-1", "chattype": "group", "chatid": f"wr-group-{index}"}}, user_id="alice", code=code)
return await repo.list_connections(str(_user().id))
rows = anyio.run(bind_groups)
assert len(rows) == 2
assert len({row["id"] for row in rows}) == 2
assert len({row["workspace_id"] for row in rows}) == 2
assert {row["workspace_name"] for row in rows} == {"Release team", "Support team"}
finally:
anyio.run(repo.close)
def test_connect_unconfigured_runtime_channel_returns_400(tmp_path):
import anyio

View File

@ -122,6 +122,17 @@ async def test_group_binding_reads_name_offloop_without_blocking_binding_on_fail
assert binding["metadata"]["chat_id"] == "oc_group"
@pytest.mark.asyncio
@pytest.mark.parametrize("chat_type,expected", [("group", "Release team"), ("p2p", None)])
async def test_owner_chosen_group_name_is_consumed_only_for_group_binding(channel, chat_type, expected):
channel._connection_repo = SimpleNamespace(consume_oauth_state=AsyncMock(return_value={"owner_user_id": "owner-1", "metadata": {"notification_group_name": "Release team"}}), upsert_connection=AsyncMock())
channel._reply_card = AsyncMock()
channel._get_group_name = AsyncMock()
await channel._bind_connection_from_connect_code(message_id="om_bind", chat_id="oc_group", user_id="ou_bound_user", code="connect-code", chat_type=chat_type)
assert channel._connection_repo.upsert_connection.await_args.kwargs["workspace_name"] == expected
channel._get_group_name.assert_not_awaited()
@pytest.mark.asyncio
async def test_base_owner_notification_preserves_existing_provider_send(channel):
channel.send_notification = AsyncMock()

View File

@ -368,3 +368,55 @@ async def test_public_wechat_connection_metadata_omits_legacy_context_token(repo
response = await channel_connections.get_channel_connections(request)
assert response.connections[0].metadata == {"chat_type": "p2p"}
assert row["metadata"]["context_token"] == "private-routing-token"
@pytest.mark.anyio
async def test_runtime_event_preferences_default_off_and_cancel_only_matching_runtime_backlog(repositories):
preferences, deliveries = repositories
assert await preferences.get_event_preferences("alice") == {
"run_completed": False,
"run_failed": False,
"human_input_requested": False,
}
updated = await preferences.patch("alice", {"run_completed": True, "human_input_requested": True})
assert updated["run_completed"] is True
assert updated["human_input_requested"] is True
assert "run_failed" not in updated
assert await preferences.get_event_preferences("alice") == {
"run_completed": True,
"run_failed": False,
"human_input_requested": True,
}
scheduled_row = await enqueue(deliveries, suffix="sched")
runtime_completed = await deliveries.enqueue(
source_kind="run",
source_key="run-1",
provider="feishu",
target="recipient-rt-1",
owner_user_id="alice",
event="run_completed",
run_id="run-1",
payload={"source_kind": "run"},
)
runtime_input = await deliveries.enqueue(
source_kind="human_input_request",
source_key="run-2:call-1",
provider="feishu",
target="recipient-rt-2",
owner_user_id="alice",
event="human_input_requested",
run_id="run-2",
payload={"source_kind": "human_input_request"},
)
# Disabling `run_completed` cancels the pending runtime `run_completed` notice,
# but leaves scheduled-task `run_completed` and runtime `human_input_requested` untouched.
await preferences.patch("alice", {"run_completed": False})
async with deliveries.session_factory() as session:
sched = await session.get(NotificationDeliveryRow, scheduled_row["id"])
rt_done = await session.get(NotificationDeliveryRow, runtime_completed["id"])
rt_input = await session.get(NotificationDeliveryRow, runtime_input["id"])
assert sched.status == "pending"
assert (rt_done.status, rt_done.last_error) == ("failed", "disabled_by_owner")
assert rt_input.status == "pending"

View File

@ -23,7 +23,7 @@ OUTBOX = "0027_notification_deliveries"
PARKED = "0028_parked_attempts"
SCHEDULER = "0029_scheduler_agent_tasks"
CLAIM_TOKENS = "0030_notification_claim_tokens"
HEAD = "0042_channel_leases"
HEAD = "0043_runtime_im_notices"
PREVIOUS = "0026_mcp_task_lease_tokens"
TABLE = "notification_deliveries"

View File

@ -23,7 +23,7 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
REVISION = "0029_scheduler_agent_tasks"
CURRENT_HEAD = "0042_channel_leases"
CURRENT_HEAD = "0043_runtime_im_notices"
NEXT = "0030_notification_claim_tokens"
PREVIOUS = "0028_parked_attempts"
TASK_FIELDS = {"origin_thread_id", "goal_objective", "max_runs", "end_at", "standing_notes"}

View File

@ -24,7 +24,7 @@ IDEMPOTENCY = "0036_run_idempotency_request"
DOCUMENT_SUMMARIES = "0037_project_document_summaries"
CHANNEL_BINDINGS = "0038_channel_thread_bindings"
USER_DISABLED = "0039_user_disabled"
CURRENT_HEAD = "0042_channel_leases"
CURRENT_HEAD = "0043_runtime_im_notices"
PREVIOUS = "0031_scheduled_streak_boundary"
TABLES = {"thread_read_markers", "thread_read_versions", "scheduled_task_events"}
INDEXES = {

View File

@ -24,7 +24,7 @@ IDEMPOTENCY = "0036_run_idempotency_request"
DOCUMENT_SUMMARIES = "0037_project_document_summaries"
CHANNEL_BINDINGS = "0038_channel_thread_bindings"
USER_DISABLED = "0039_user_disabled"
CURRENT_HEAD = "0042_channel_leases"
CURRENT_HEAD = "0043_runtime_im_notices"
TABLE = "run_event_thread_seq"
pytestmark = pytest.mark.asyncio

View File

@ -25,7 +25,7 @@ IDEMPOTENCY = "0036_run_idempotency_request"
DOCUMENT_SUMMARIES = "0037_project_document_summaries"
CHANNEL_BINDINGS = "0038_channel_thread_bindings"
USER_DISABLED = "0039_user_disabled"
CURRENT_HEAD = "0042_channel_leases"
CURRENT_HEAD = "0043_runtime_im_notices"
PREVIOUS = "0034_run_event_seq_watermark"
TABLE = "login_throttle"
COLUMNS = {"ip", "fail_count", "locked_at", "lock_duration_seconds", "updated_at"}

View File

@ -22,7 +22,8 @@ CHANNEL_BINDINGS = "0038_channel_thread_bindings"
USER_DISABLED = "0039_user_disabled"
GROUP_NOTICE = "0040_scheduled_group_notice"
IM_NOTICE_PREFERENCES = "0041_im_notice_preferences"
CURRENT_HEAD = "0042_channel_leases"
CHANNEL_LEASES = "0042_channel_leases"
CURRENT_HEAD = "0043_runtime_im_notices"
pytestmark = pytest.mark.asyncio
@ -33,7 +34,8 @@ async def test_0037_chains_after_0036_and_is_single_head():
assert script.get_revision(USER_DISABLED).down_revision == CHANNEL_BINDINGS
assert script.get_revision(GROUP_NOTICE).down_revision == USER_DISABLED
assert script.get_revision(IM_NOTICE_PREFERENCES).down_revision == GROUP_NOTICE
assert script.get_revision(CURRENT_HEAD).down_revision == IM_NOTICE_PREFERENCES
assert script.get_revision(CHANNEL_LEASES).down_revision == IM_NOTICE_PREFERENCES
assert script.get_revision(CURRENT_HEAD).down_revision == CHANNEL_LEASES
assert script.get_revision(REVISION).down_revision == PREVIOUS
# alembic_version.version_num is VARCHAR(32).
assert len(REVISION) <= 32

View File

@ -22,7 +22,7 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
REVISION = "0038_channel_thread_bindings"
PREVIOUS = "0037_project_document_summaries"
CURRENT_HEAD = "0042_channel_leases"
CURRENT_HEAD = "0043_runtime_im_notices"
TABLE = "channel_thread_bindings"
COLUMNS = {"key", "channel_name", "chat_id", "topic_id", "thread_id", "user_id", "created_at", "updated_at"}
INDEX = "ix_channel_thread_bindings_channel_chat"

View File

@ -16,7 +16,7 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
REVISION = "0040_scheduled_group_notice"
PREVIOUS = "0039_user_disabled"
CURRENT_HEAD = "0042_channel_leases"
CURRENT_HEAD = "0043_runtime_im_notices"
COLUMN = "notification_group_connection_id"

View File

@ -22,7 +22,7 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
REVISION = "0042_channel_leases"
PREVIOUS = "0041_im_notice_preferences"
CURRENT_HEAD = "0042_channel_leases"
CURRENT_HEAD = "0043_runtime_im_notices"
TABLE = "channel_leases"
COLUMNS = {"provider", "owner_id", "epoch", "lease_expires_at", "transport_running", "updated_at"}
pytestmark = pytest.mark.asyncio
@ -31,6 +31,7 @@ pytestmark = pytest.mark.asyncio
async def test_0042_remains_in_the_single_migration_chain():
script = ScriptDirectory(str(bootstrap._MIGRATIONS_DIR))
assert script.get_heads() == [CURRENT_HEAD]
assert script.get_revision(CURRENT_HEAD).down_revision == REVISION
assert script.get_revision(REVISION).down_revision == PREVIOUS
# alembic_version.version_num is VARCHAR(32).
assert len(REVISION) <= 32

View File

@ -165,7 +165,7 @@ async def test_group_notice_requires_current_exact_group_binding(updates):
@pytest.mark.asyncio
@pytest.mark.parametrize("provider,target_type", [("wecom", "group"), ("feishu", "unknown"), ("feishu", "private")])
@pytest.mark.parametrize("provider,target_type", [("wecom", "group"), ("wechat", "group"), ("feishu", "unknown"), ("feishu", "private")])
async def test_invalid_group_target_type_never_falls_back_to_personal_notice(provider, target_type):
repo = FakeDeliveryRepo([_delivery_row(provider=provider, target="ou_owner", payload={"target_type": target_type})])
channel = FakeChannel()
@ -177,6 +177,26 @@ async def test_invalid_group_target_type_never_falls_back_to_personal_notice(pro
assert channel.group_sent == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
("provider", "workspace_id", "metadata"),
[
("qq", "app1:group:group-openid", {"chat_type": "group", "group_openid": "group-openid"}),
("wecom", "bot1:group:wr_chat_1", {"aibotid": "bot1", "chattype": "group", "chatid": "wr_chat_1"}),
],
)
async def test_qq_and_wecom_group_notices_use_group_hook_only(provider, workspace_id, metadata):
repo = FakeDeliveryRepo([_delivery_row(provider=provider, target="group-binding", payload={"target_type": "group", "run_status": "success"})])
channel = FakeChannel()
group_conn = _group_connection(provider=provider, workspace_id=workspace_id, metadata=metadata)
worker = NotificationDeliveryWorker(delivery_repo=repo, resolve_channel=lambda p: channel if p == provider else None, resolve_connections=lambda owner: [group_conn])
await worker.run_once(now=datetime.now(UTC))
assert repo.sent == ["delivery-1"]
assert channel.group_sent[0][:2] == ("user-1", "group-binding")
assert channel.sent == []
assert channel.owners == []
@pytest.mark.asyncio
@pytest.mark.parametrize("resolver_missing", [False, True])
async def test_group_notice_parks_when_binding_cannot_be_checked(resolver_missing):
@ -1057,3 +1077,113 @@ async def test_worker_ignores_a_non_collection_exclusion_result():
await worker.run_once(now=datetime.now(UTC))
assert repo.exclusions == [None]
assert repo.sent == ["delivery-1"]
@pytest.mark.asyncio
async def test_wecom_group_notification_for_owner_and_multi_group_binding_isolation(tmp_path):
from datetime import timedelta
from unittest.mock import AsyncMock
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'wecom_group.db'}", sqlite_dir=str(tmp_path))
try:
conn_repo = ChannelConnectionRepository(get_session_factory())
ws_client = FakeWsClient()
ws_client.reply = AsyncMock()
ws_client.reply_stream = AsyncMock()
channel = WeComChannel(bus=MessageBus(), config={"bot_id": "bot-1", "connection_repo": conn_repo})
channel._bot_id = "bot-1"
channel._ws_client = ws_client
channel._running = True
channel._ws_transport_up = True
for code in ("code-priv", "code-g1", "code-g2"):
await conn_repo.create_oauth_state(
owner_user_id="user-1",
provider="wecom",
state=code,
expires_at=datetime.now(UTC) + timedelta(minutes=5),
)
await channel._bind_connection_from_connect_code(
frame={"body": {"aibotid": "bot-1", "chattype": "single"}},
user_id="GaoZhiChao",
code="code-priv",
)
await channel._bind_connection_from_connect_code(
frame={"body": {"aibotid": "bot-1", "chattype": "group", "chatid": "wr_group_1"}},
user_id="GaoZhiChao",
code="code-g1",
)
await channel._bind_connection_from_connect_code(
frame={"body": {"aibotid": "bot-1", "chattype": "group", "chatid": "wr_group_2"}},
user_id="GaoZhiChao",
code="code-g2",
)
connections = await conn_repo.list_connections("user-1")
assert len(connections) == 3
by_workspace = {row["workspace_id"]: row for row in connections}
assert set(by_workspace) == {"bot-1", "bot-1:group:wr_group_1", "bot-1:group:wr_group_2"}
assert by_workspace["bot-1:group:wr_group_1"]["workspace_name"] is None
assert by_workspace["bot-1:group:wr_group_2"]["workspace_name"] is None
g1_id = by_workspace["bot-1:group:wr_group_1"]["id"]
g2_id = by_workspace["bot-1:group:wr_group_2"]["id"]
priv_id = by_workspace["bot-1"]["id"]
assert len({g1_id, g2_id, priv_id}) == 3
# A group-specific binding resolves only that group; a private binding
# must not authorize an unrelated, unbound group.
bound_group_inbound = channel._make_inbound(
chat_id="wr_group_1",
user_id="GaoZhiChao",
text="hello",
metadata={"aibotid": "bot-1", "chattype": "group", "chatid": "wr_group_1"},
)
bound_group_inbound = await channel._attach_connection_identity(bound_group_inbound)
assert bound_group_inbound.connection_id == g1_id
assert bound_group_inbound.owner_user_id == "user-1"
legacy_group_inbound = channel._make_inbound(
chat_id="wr_legacy_group",
user_id="GaoZhiChao",
text="hello from legacy group",
metadata={"aibotid": "bot-1", "chattype": "group", "chatid": "wr_legacy_group"},
)
legacy_group_inbound = await channel._attach_connection_identity(legacy_group_inbound)
assert legacy_group_inbound.connection_id is None
assert legacy_group_inbound.owner_user_id is None
await channel.send_group_notification_for_owner(
owner_user_id="user-1",
target=g1_id,
text_markdown="**group 1 notice**",
)
await channel.send_group_notification_for_owner(
owner_user_id="user-1",
target=g2_id,
text_markdown="**group 2 notice**",
)
assert ws_client.calls == [
("wr_group_1", {"msgtype": "markdown", "markdown": {"content": "**group 1 notice**"}}),
("wr_group_2", {"msgtype": "markdown", "markdown": {"content": "**group 2 notice**"}}),
]
# Private binding ID or foreign owner must not be accepted as a group target.
with pytest.raises(RuntimeError, match="group notification destination"):
await channel.send_group_notification_for_owner(
owner_user_id="user-1",
target=priv_id,
text_markdown="nope",
)
with pytest.raises(RuntimeError, match="group notification destination"):
await channel.send_group_notification_for_owner(
owner_user_id="other-user",
target=g1_id,
text_markdown="nope",
)
assert len(ws_client.calls) == 2
finally:
await close_engine()

View File

@ -2,6 +2,24 @@
import pytest
from app.channels.notification_groups import notification_group_display_name
@pytest.mark.parametrize(
"metadata,expected",
[
({"notification_group_name": "Release team"}, "Release team"),
({"notification_group_name": " 发布 小组 "}, "发布 小组"),
({"notification_group_name": "名" * 300}, "名" * 256),
({}, None),
({"notification_group_name": 123}, None),
({"notification_group_name": " "}, None),
(None, None),
],
)
def test_group_binding_names_are_explicit_bounded_labels(metadata, expected):
assert notification_group_display_name(metadata) == expected
@pytest.mark.parametrize(
"overrides,expected",
@ -26,3 +44,66 @@ def test_feishu_group_predicate(overrides, expected):
value = {"provider": "feishu", "status": "connected", "workspace_id": "oc_group", "metadata": {"chat_id": "oc_group", "chat_type": "group"}, **overrides}
assert is_feishu_notification_group(**value) is expected
@pytest.mark.parametrize(
"overrides,app_id,expected",
[
({}, None, "group-1"),
({}, "app-1", "group-1"),
({"metadata": {"chat_type": "group"}}, "app-1", "group-1"),
({}, "app-2", None),
({"provider": "feishu"}, None, None),
({"status": "revoked"}, None, None),
({"workspace_id": "app-1:c2c"}, None, None),
({"workspace_id": ":group:group-1"}, None, None),
({"workspace_id": "app-1:group:"}, None, None),
({"workspace_id": " app-1:group:group-1"}, None, None),
({"metadata": {"chat_type": "c2c", "group_openid": "group-1"}}, None, None),
({"metadata": {"chat_type": "group", "group_openid": "other"}}, None, None),
],
)
def test_qq_group_predicate(overrides, app_id, expected):
from app.channels.notification_groups import is_notification_group, qq_notification_group_openid
value = {
"provider": "qq",
"status": "connected",
"workspace_id": "app-1:group:group-1",
"metadata": {"chat_type": "group", "group_openid": "group-1"},
**overrides,
}
assert qq_notification_group_openid(**value, app_id=app_id) == expected
if app_id is None:
assert is_notification_group(**value) is (expected is not None)
@pytest.mark.parametrize(
"overrides,bot_id,expected",
[
({}, None, "wr_chat_1"),
({}, "bot-1", "wr_chat_1"),
({"workspace_id": "group:wr_chat_1", "metadata": {"aibotid": None, "chattype": "group", "chatid": "wr_chat_1"}}, None, "wr_chat_1"),
({}, "bot-2", None),
({"provider": "wechat"}, None, None),
({"status": "revoked"}, None, None),
({"workspace_id": "bot-1"}, None, None),
({"workspace_id": "bot-1:group:other"}, None, None),
({"metadata": {"aibotid": "bot-1", "chattype": "single", "chatid": "wr_chat_1"}}, None, None),
({"metadata": {"aibotid": "bot-1", "chattype": "group", "chatid": ""}}, None, None),
({"metadata": {"aibotid": "bot-1", "chattype": "group", "chatid": " wr_chat_1 "}}, None, None),
],
)
def test_wecom_group_predicate(overrides, bot_id, expected):
from app.channels.notification_groups import is_notification_group, wecom_notification_group_chat_id
value = {
"provider": "wecom",
"status": "connected",
"workspace_id": "bot-1:group:wr_chat_1",
"metadata": {"aibotid": "bot-1", "chattype": "group", "chatid": "wr_chat_1"},
**overrides,
}
assert wecom_notification_group_chat_id(**value, bot_id=bot_id) == expected
if bot_id is None:
assert is_notification_group(**value) is (expected is not None)

View File

@ -306,3 +306,107 @@ def test_locale_falls_back_to_the_configured_default():
assert render_notification_text(notice("run_failed", locale="zh-CN", run_status="failed"), default_locale="en-US").splitlines()[-1] == CLOSING["zh-CN"]
assert render_notification_text(notice("run_failed", locale="fr-FR", run_status="failed"), default_locale="zh-CN").splitlines()[-1] == CLOSING["zh-CN"]
assert render_notification_text(delivery, default_locale="fr-FR").splitlines()[-1] == CLOSING["en-US"]
def runtime_notice(event, *, source_kind="run", locale="en-US", run_id=RUN_ID, **payload):
base = {
"payload_version": 2,
"source_kind": source_kind,
"task_title": "Ship release notes",
"locale": locale,
}
return {
"id": "delivery-rt-1",
"source_kind": source_kind,
"source_key": run_id or "req-1",
"task_id": None,
"task_run_id": None,
"run_id": run_id,
"event": event,
"provider": "feishu",
"target": "ou_123",
"payload": {**base, **payload},
}
@pytest.mark.parametrize("locale", NOTIFICATION_LOCALES)
def test_runtime_notices_render_localized_without_ids_or_links(locale):
completed = render_notification_text(
runtime_notice(
"run_completed",
locale=locale,
run_status="success",
relied_on_assumption=True,
result_summary="Drafted the changelog and verified links.",
)
)
failed = render_notification_text(
runtime_notice(
"run_failed",
locale=locale,
run_status="failed",
)
)
input_needed = render_notification_text(
runtime_notice(
"human_input_requested",
source_kind="human_input_request",
locale=locale,
run_status="success",
question="Which target branch should we tag?",
result_summary="Should not appear on human_input_requested",
)
)
for text in (completed, failed, input_needed):
assert_no_raw_identifiers(text)
assert RUN_ID not in text
assert "http" not in text
if locale == "en-US":
assert completed.splitlines() == [
"Task “Ship release notes”",
"Completed. Goal met, with an assumption.",
"Result: Drafted the changelog and verified links.",
"Open DeerFlow → Conversations for details.",
]
assert failed.splitlines() == [
"Task “Ship release notes”",
"Failed to complete.",
"Open DeerFlow → Conversations for details.",
]
assert input_needed.splitlines() == [
"Task “Ship release notes”",
"Waiting for your input in the conversation.",
"Question: Which target branch should we tag?",
"Open DeerFlow → Conversations to continue.",
]
else:
assert completed.splitlines() == [
"任务“Ship release notes”",
"已完成。目标已达成(含假设)。",
"结果:Drafted the changelog and verified links.",
"在 DeerFlow 的对话页查看详情。",
]
assert failed.splitlines() == [
"任务“Ship release notes”",
"运行失败,未能完成。",
"在 DeerFlow 的对话页查看详情。",
]
assert input_needed.splitlines() == [
"任务“Ship release notes”",
"正在等待你在对话中确认或补充信息。",
"待确认:Which target branch should we tag?",
"在 DeerFlow 的对话页继续处理。",
]
def test_runtime_human_input_question_redacts_secrets_and_skips_result_summary():
secret = "sk-" + ("S" * 40)
delivery = runtime_notice(
"human_input_requested",
source_kind="human_input_request",
question=f"Should we rotate {secret} now?",
)
assert not wants_result_summary(delivery)
text = render_notification_text(delivery)
assert secret not in text
assert "Question: Should we rotate [redacted] now?" in text

View File

@ -114,3 +114,23 @@ async def test_private_group_and_app_bindings_do_not_fall_back(repo):
finally:
await channel.stop()
await other_app.stop()
@pytest.mark.asyncio
async def test_named_group_connections_keep_friendly_names_and_stable_ids(repo):
channel = QQChannel(MessageBus(), {"app_id": "app1", "client_secret": "fixture", "connection_repo": repo})
channel.send = AsyncMock()
try:
for code, group, label in (("code-1", "group1", "Release team"), ("code-2", "group2", "Support team"), ("code-3", "group1", "Release team renamed")):
await repo.create_oauth_state(owner_user_id="owner", provider="qq", state=code, expires_at=datetime.now(UTC) + timedelta(minutes=5), metadata={"notification_group_name": label})
await channel._handle_inbound(incoming(f"msg-{code}", f"/connect {code}", group))
rows = await repo.list_connections("owner")
if code == "code-1":
group1_id = rows[0]["id"]
by_workspace = {row["workspace_id"]: row for row in rows}
assert len(rows) == 2
assert by_workspace["app1:group:group1"]["id"] == group1_id
assert by_workspace["app1:group:group1"]["workspace_name"] == "Release team renamed"
assert by_workspace["app1:group:group2"]["workspace_name"] == "Support team"
finally:
await channel.stop()

View File

@ -233,10 +233,54 @@ async def test_auth_refresh_keeps_proactive_payload_and_no_passive_budget():
assert not channel._replies
async def test_group_notification_remains_unsupported():
async with connected() as (channel, requests):
with pytest.raises(NotImplementedError):
await channel.send_group_notification_for_owner(owner_user_id="owner", target="group-binding", text_markdown="Done")
def group_binding(**overrides):
return {
"id": "group-binding",
"owner_user_id": "owner",
"provider": "qq",
"status": "connected",
"external_account_id": "member-openid",
"workspace_id": "app1:group:group-openid",
"metadata": {"chat_type": "group", "group_openid": "group-openid"},
**overrides,
}
async def test_group_notification_serializes_to_group_endpoint_without_passive_context():
async with connected(bindings=[group_binding()]) as (channel, requests):
await channel.send_group_notification_for_owner(
owner_user_id="owner",
target="group-binding",
text_markdown="任务已完成。\nOpen DeerFlow for details.",
)
channel._connection_repo.list_connections.assert_awaited_once_with("owner")
assert len(requests) == 2
_token, message = requests
assert str(message.url) == "https://api.sgroup.qq.com/v2/groups/group-openid/messages"
assert json.loads(message.content) == {"msg_type": 0, "content": "任务已完成。\nOpen DeerFlow for details."}
@pytest.mark.parametrize(
"override",
[
{"id": "other-binding"},
{"owner_user_id": "other-owner"},
{"provider": "feishu"},
{"status": "revoked"},
{"workspace_id": "another-app:group:group-openid"},
{"workspace_id": "app1:c2c", "metadata": {"chat_type": "c2c"}},
{"metadata": {"chat_type": "c2c"}},
{"metadata": {"chat_type": "group", "group_openid": "other-group"}},
],
)
async def test_group_notification_refuses_invalid_or_unowned_binding_without_private_fallback(override):
async with connected(bindings=[binding(), group_binding(**override)]) as (channel, requests):
with pytest.raises(QQAPIError, match="group.*connected"):
await channel.send_group_notification_for_owner(
owner_user_id="owner",
target="group-binding",
text_markdown="Done",
)
assert requests == []
@ -258,3 +302,39 @@ async def test_notification_cancellation_propagates_without_retry():
with pytest.raises(asyncio.CancelledError):
await task
assert len(requests) == 1
async def test_qq_group_bind_does_not_use_raw_ids_as_display_names():
from unittest.mock import AsyncMock
async with connected() as (channel, _requests):
channel._connection_repo.consume_oauth_state = AsyncMock(return_value={"owner_user_id": "owner"})
channel._connection_repo.upsert_connection = AsyncMock(return_value={"id": "bound"})
await channel._handle_inbound(
{
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {
"group_openid": "group-open-1",
"author": {"member_openid": "member-1"},
"id": "m-1",
"content": "/connect code-1",
},
}
)
await channel._handle_inbound(
{
"t": "GROUP_AT_MESSAGE_CREATE",
"d": {
"group_openid": "group-open-2",
"author": {"member_openid": "member-1"},
"id": "m-2",
"content": "/connect code-2",
},
}
)
calls = channel._connection_repo.upsert_connection.await_args_list
assert len(calls) == 2
assert calls[0].kwargs["workspace_id"] == "app1:group:group-open-1"
assert calls[0].kwargs["workspace_name"] is None
assert calls[1].kwargs["workspace_id"] == "app1:group:group-open-2"
assert calls[1].kwargs["workspace_name"] is None

View File

@ -0,0 +1,709 @@
"""Stage 2 ordinary run / goal / human_input_request IM notifications."""
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
import pytest
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from sqlalchemy import event, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import deerflow.persistence.models # noqa: F401
from app.scheduler.notification_delivery import NotificationDeliveryWorker
from app.scheduler.service import ScheduledTaskService
from deerflow.agents.human_input import (
HUMAN_INPUT_RESPONSE_KEY,
extract_unanswered_human_input_request,
)
from deerflow.persistence.base import Base
from deerflow.persistence.channel_connections.model import ChannelConnectionRow
from deerflow.persistence.notification_deliveries import NotificationDeliveryRepository
from deerflow.persistence.notification_deliveries.model import NotificationDeliveryRow
from deerflow.persistence.user.im_notifications import ImNotificationPreferencesRepository
from deerflow.persistence.user.model import UserRow
from deerflow.runtime import DisconnectMode, RunRecord, RunStatus
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture
async def env(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/runtime_notices.db")
@event.listens_for(engine.sync_engine, "connect")
def _fk(conn, _):
conn.execute("PRAGMA foreign_keys=ON")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
sessions = async_sessionmaker(engine, expire_on_commit=False)
async with sessions() as session, session.begin():
session.add(UserRow(id="alice", email="alice@example.com"))
for provider in ("feishu", "wecom", "wechat", "qq"):
session.add(
ChannelConnectionRow(
id=f"personal-{provider}",
owner_user_id="alice",
provider=provider,
status="connected",
external_account_id=f"{provider}-user",
workspace_id="app:c2c" if provider == "qq" else "private",
metadata_json={"chat_type": "c2c" if provider == "qq" else "p2p"},
)
)
session.add(
ChannelConnectionRow(
id="qq-group-conn",
owner_user_id="alice",
provider="qq",
status="connected",
external_account_id="qq-group-member",
workspace_id="app:group:grp-1",
metadata_json={"chat_type": "group", "chat_id": "grp-1", "group_openid": "grp-1"},
)
)
preferences = ImNotificationPreferencesRepository(sessions)
deliveries = NotificationDeliveryRepository(sessions)
service = ScheduledTaskService(
task_repo=SimpleNamespace(session_factory=sessions),
task_run_repo=SimpleNamespace(session_factory=sessions),
launch_run=None,
connection_repo=object(),
notification_repo=deliveries,
poll_interval_seconds=5,
lease_seconds=30,
max_concurrent_runs=1,
)
try:
yield sessions, preferences, deliveries, service
finally:
await engine.dispose()
def _make_run(
*,
run_id: str = "run-101",
thread_id: str = "thread-101",
status: RunStatus = RunStatus.success,
metadata: dict | None = None,
goal_verdict: dict | None = None,
human_input_request: dict | None = None,
error: str | None = None,
) -> RunRecord:
base_meta = {"owner": "alice", "title": "Prepare quarterly roadmap"}
if metadata:
base_meta.update(metadata)
return RunRecord(
run_id=run_id,
thread_id=thread_id,
assistant_id="lead_agent",
status=status,
on_disconnect=DisconnectMode.continue_,
user_id="alice",
metadata=base_meta,
error=error,
goal_verdict=goal_verdict,
human_input_request=human_input_request,
)
def test_extract_unanswered_human_input_request_distinguishes_pending_and_answered():
pending_ai = AIMessage(
content="",
tool_calls=[
{
"id": "call-ask-1",
"name": "ask_clarification",
"args": {
"question": "Which region should we deploy to first?",
"clarification_type": "missing_info",
"options": ["us-east", "eu-west"],
},
}
],
)
prompt_tool = ToolMessage(
content="Which region should we deploy to first?",
tool_call_id="call-ask-1",
name="ask_clarification",
artifact={
"human_input": {
"version": 1,
"kind": "human_input_request",
"source": "ask_clarification",
"request_id": "req-ask-1",
"question": "Which region should we deploy to first?",
}
},
)
extracted = extract_unanswered_human_input_request([HumanMessage(content="Deploy it"), pending_ai, prompt_tool])
assert extracted == {
"kind": "human_input_request",
"request_id": "req-ask-1",
"question": "Which region should we deploy to first?",
"source": "ask_clarification",
}
# Answered by a structured human_input_response -> no longer pending.
answered_user = HumanMessage(
content="us-east",
additional_kwargs={
HUMAN_INPUT_RESPONSE_KEY: {
"version": 1,
"kind": "human_input_response",
"source": "ask_clarification",
"request_id": "req-ask-1",
"response_kind": "text",
"value": "us-east",
}
},
)
assert extract_unanswered_human_input_request([HumanMessage(content="Deploy it"), pending_ai, prompt_tool, answered_user]) is None
# Followed by a plain user message in the conversation -> no longer pending.
assert extract_unanswered_human_input_request([HumanMessage(content="Deploy it"), pending_ai, prompt_tool, HumanMessage(content="Use us-east")]) is None
@pytest.mark.anyio
async def test_ordinary_run_notifications_are_opt_in_by_default(env):
sessions, _, _, service = env
await service.handle_run_completion(_make_run(status=RunStatus.success))
await service.handle_run_completion(_make_run(run_id="run-102", status=RunStatus.error, error="boom"))
await service.handle_run_completion(
_make_run(
run_id="run-103",
status=RunStatus.success,
human_input_request={"tool_call_id": "call-1", "question": "Confirm?", "clarification_type": "approach_choice"},
)
)
async with sessions() as session:
assert (await session.scalars(select(NotificationDeliveryRow))).all() == []
@pytest.mark.anyio
async def test_human_input_request_check_precedes_success_and_never_emits_false_completion(env):
sessions, preferences, _, service = env
await preferences.patch(
"alice",
{"run_completed": True, "run_failed": True, "human_input_requested": True},
)
record = _make_run(
run_id="run-clarify",
status=RunStatus.success,
human_input_request={
"tool_call_id": "call-clarify-1",
"question": "Should we overwrite the existing staging table?",
"clarification_type": "risk_confirmation",
},
)
await service.handle_run_completion(record)
async with sessions() as session:
rows = (await session.scalars(select(NotificationDeliveryRow))).all()
assert len(rows) == 4
assert {row.provider for row in rows} == {"feishu", "wecom", "wechat", "qq"}
assert all(row.event == "human_input_requested" for row in rows)
assert all(row.source_kind == "human_input_request" for row in rows)
assert all(row.source_key == "thread-101:call-clarify-1" for row in rows)
assert all(row.task_id is None and row.task_run_id is None for row in rows)
assert all(row.target != "qq-group-member" for row in rows)
assert all(row.payload_json["question"] == "Should we overwrite the existing staging table?" for row in rows)
@pytest.mark.anyio
async def test_ordinary_run_completed_and_explicit_group_target_selection(env):
sessions, preferences, _, service = env
await preferences.patch("alice", {"run_completed": True})
record = _make_run(
run_id="run-goal-ok",
status=RunStatus.success,
metadata={"notification_group_connection_id": "qq-group-conn"},
goal_verdict={"satisfied": True, "relied_on_assumption": True, "reason_code": None},
)
await service.handle_run_completion(record)
async with sessions() as session:
rows = (await session.scalars(select(NotificationDeliveryRow))).all()
assert len(rows) == 5
personal = [r for r in rows if r.payload_json.get("target_type") != "group"]
group = [r for r in rows if r.payload_json.get("target_type") == "group"]
assert {r.provider for r in personal} == {"feishu", "wecom", "wechat", "qq"}
assert [(r.provider, r.target) for r in group] == [("qq", "qq-group-conn")]
assert all(r.event == "run_completed" and r.source_kind == "run" and r.source_key == "run-goal-ok" for r in rows)
assert all(r.payload_json.get("relied_on_assumption") is True for r in rows)
@pytest.mark.anyio
@pytest.mark.parametrize(
"reason_code",
[
"max_continuations_reached",
"no_progress_detected",
"token_capped",
"evaluator_failed",
"blocked:needs_user_input",
],
)
async def test_ordinary_goal_unmet_stand_down_without_human_input_card_does_not_emit_false_notice(env, reason_code):
sessions, preferences, _, service = env
await preferences.patch(
"alice",
{"run_completed": True, "run_failed": True, "human_input_requested": True},
)
record = _make_run(
run_id=f"run-unmet-{reason_code}",
status=RunStatus.success,
goal_verdict={"satisfied": False, "reason_code": reason_code, "relied_on_assumption": False},
human_input_request=None,
)
await service.handle_run_completion(record)
async with sessions() as session:
assert (await session.scalars(select(NotificationDeliveryRow))).all() == []
@pytest.mark.anyio
async def test_interrupted_and_im_originated_runs_do_not_enqueue_runtime_notifications(env):
sessions, preferences, _, service = env
await preferences.patch(
"alice",
{"run_completed": True, "run_failed": True, "human_input_requested": True},
)
await service.handle_run_completion(_make_run(run_id="run-int", status=RunStatus.interrupted))
await service.handle_run_completion(
_make_run(
run_id="run-im",
status=RunStatus.success,
metadata={"deerflow_origin": {"kind": "im_channel", "provider": "feishu"}},
)
)
await service.handle_run_completion(
_make_run(
run_id="run-err",
status=RunStatus.error,
error="model timeout",
)
)
async with sessions() as session:
rows = (await session.scalars(select(NotificationDeliveryRow))).all()
assert {r.run_id for r in rows} == {"run-err"}
assert all(r.event == "run_failed" for r in rows)
@pytest.mark.anyio
async def test_worker_rechecks_human_input_pending_and_drops_resolved_requests(env):
sessions, preferences, deliveries, service = env
await preferences.patch(
"alice",
{"human_input_requested": True, "wecom": False, "wechat": False, "qq": False},
)
await service.handle_run_completion(
_make_run(
run_id="run-stale-ask",
thread_id="thread-stale-ask",
status=RunStatus.success,
human_input_request={"tool_call_id": "call-stale-1", "question": "Still need this?"},
)
)
(claimed,) = await deliveries.claim_due_deliveries(now=datetime.now(UTC), limit=10)
sent_messages = []
class FakeChannel:
is_running = True
async def send_notification_for_owner(self, **kwargs):
sent_messages.append(kwargs)
async def already_resolved(thread_id: str, tool_call_id: str | None) -> bool:
assert (thread_id, tool_call_id) == ("thread-stale-ask", "call-stale-1")
return False
worker = NotificationDeliveryWorker(
delivery_repo=deliveries,
resolve_channel=lambda _: FakeChannel(),
resolve_notification_preferences=preferences.get,
resolve_notification_event_preferences=preferences.get_event_preferences,
resolve_human_input_pending=already_resolved,
)
await worker._deliver(claimed)
assert sent_messages == []
async with sessions() as session:
row = await session.get(NotificationDeliveryRow, claimed["id"])
assert (row.status, row.last_error) == ("failed", "human_input_request_already_resolved")
# Still-pending request delivers normally.
await service.handle_run_completion(
_make_run(
run_id="run-live-ask",
thread_id="thread-live-ask",
status=RunStatus.success,
human_input_request={"tool_call_id": "call-live-1", "question": "Approve production deploy?"},
)
)
(claimed_live,) = await deliveries.claim_due_deliveries(now=datetime.now(UTC), limit=10)
async def still_pending(thread_id: str, tool_call_id: str | None) -> bool:
assert (thread_id, tool_call_id) == ("thread-live-ask", "call-live-1")
return True
worker_live = NotificationDeliveryWorker(
delivery_repo=deliveries,
resolve_channel=lambda _: FakeChannel(),
resolve_notification_preferences=preferences.get,
resolve_notification_event_preferences=preferences.get_event_preferences,
resolve_human_input_pending=still_pending,
)
await worker_live._deliver(claimed_live)
assert len(sent_messages) == 1
assert "Question: Approve production deploy?" in sent_messages[0]["text_markdown"]
async with sessions() as session:
row = await session.get(NotificationDeliveryRow, claimed_live["id"])
assert row.status == "sent"
@pytest.mark.anyio
async def test_unpersisted_terminal_status_does_not_emit_premature_success_notification(env):
from deerflow.persistence.run.model import RunRow
from deerflow.runtime.runs.manager import RunManager
sessions, preferences, _, service = env
await preferences.patch(
"alice",
{"run_completed": True, "human_input_requested": True},
)
# Case 1: in-memory RunRecord explicitly marked terminal_status_persisted=False.
unpersisted_record = _make_run(run_id="run-unpersisted-flag", status=RunStatus.success)
unpersisted_record.terminal_status_persisted = False
await service.handle_run_completion(unpersisted_record)
# Case 2: durable RunRow still says "running" because terminal persistence failed.
async with sessions() as session, session.begin():
session.add(
RunRow(
run_id="run-still-running-in-db",
thread_id="thread-101",
assistant_id="lead_agent",
user_id="alice",
status="running",
)
)
stale_db_record = _make_run(run_id="run-still-running-in-db", status=RunStatus.success)
await service.handle_run_completion(stale_db_record)
async with sessions() as session:
assert (await session.scalars(select(NotificationDeliveryRow))).all() == []
# Case 3: RunManager.set_status marks terminal_status_persisted=False when store write fails.
class FailingRunStore:
async def put(self, run_id, **kwargs):
return None
async def update_status(self, run_id, status, **kwargs):
raise RuntimeError("db write failed")
manager = RunManager(store=FailingRunStore())
created = await manager.create(thread_id="thread-101", assistant_id="lead_agent", user_id="alice")
await manager.set_status(created.run_id, RunStatus.success, persist=True)
assert created.terminal_status_persisted is False
@pytest.mark.anyio
@pytest.mark.parametrize("completion_recovers", [False, True])
@pytest.mark.parametrize("outcome", ["completed", "human_input", "failed"])
async def test_worker_notifies_only_after_terminal_status_is_confirmed(env, completion_recovers, outcome):
from types import SimpleNamespace
from unittest.mock import AsyncMock
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from deerflow.agents.middlewares.clarification_middleware import ClarificationMiddleware
from deerflow.agents.thread_state import ThreadState
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.runs.manager import RunManager
from deerflow.runtime.runs.store.memory import MemoryRunStore
from deerflow.runtime.runs.worker import RunContext, run_agent
sessions, preferences, _, service = env
await preferences.patch(
"alice",
{"run_completed": True, "run_failed": True, "human_input_requested": True, "wecom": False, "wechat": False, "qq": False},
)
class FailingTerminalStore(MemoryRunStore):
async def update_status(self, run_id, status, **kwargs):
if status not in {"pending", "running"}:
raise RuntimeError("terminal status write unavailable")
return await super().update_status(run_id, status, **kwargs)
async def update_run_completion(self, run_id, *, status, **kwargs):
if not completion_recovers:
raise RuntimeError("completion fallback unavailable")
return await super().update_run_completion(run_id, status=status, **kwargs)
store = FailingTerminalStore()
manager = RunManager(store=store)
record = await manager.create(f"thread-{outcome}-{completion_recovers}", user_id="alice")
checkpointer = InMemorySaver()
def fixed_node(state):
if outcome == "failed":
raise RuntimeError("fixed execution failure")
if outcome == "human_input":
call = {"id": "call-confirm", "name": "ask_clarification", "args": {"question": "Which environment?"}, "type": "tool_call"}
request = ClarificationMiddleware()._handle_clarification(SimpleNamespace(tool_call=call))
return {"messages": [AIMessage(content="", tool_calls=[call]), *request.update["messages"]]}
return {"messages": [AIMessage(content="Fixed completed answer")]}
builder = StateGraph(ThreadState)
builder.add_node("fixed", fixed_node)
builder.add_edge(START, "fixed")
builder.add_edge("fixed", END)
graph = builder.compile(checkpointer=checkpointer)
completion_hook = AsyncMock(wraps=service.handle_run_completion)
await run_agent(
SimpleNamespace(publish=AsyncMock(), publish_end=AsyncMock(), cleanup=AsyncMock()),
manager,
record,
ctx=RunContext(checkpointer=checkpointer, event_store=MemoryRunEventStore(), on_run_completed=completion_hook),
agent_factory=lambda *, config: graph,
graph_input={"messages": [HumanMessage(content="Run the fixed task")]},
config={"configurable": {"thread_id": record.thread_id}},
)
stored = await store.get(record.run_id, user_id="alice")
async with sessions() as session:
rows = (await session.scalars(select(NotificationDeliveryRow))).all()
assert record.terminal_status_persisted is completion_recovers
if completion_recovers:
assert stored["status"] == record.status.value
completion_hook.assert_awaited_once_with(record)
expected_event = {"completed": "run_completed", "human_input": "human_input_requested", "failed": "run_failed"}[outcome]
assert [(row.run_id, row.event) for row in rows] == [(record.run_id, expected_event)]
else:
assert stored["status"] == "running"
completion_hook.assert_not_awaited()
assert rows == []
@pytest.mark.anyio
async def test_sqlite_lock_owner_materializes_rows_and_serializes_opt_out(env):
import asyncio
from deerflow.persistence.user.im_notifications import ImNotificationPreferenceRow
sessions, preferences, _, service = env
# Before any patch, get_in_session(lock_owner=True) on SQLite materializes the 4 provider rows
# so SQLite acquires an immediate write lock on the transaction's first statement.
async with sessions() as session, session.begin():
locked = await ImNotificationPreferencesRepository.get_in_session(
session,
"bob",
lock_owner=True,
include_events=True,
)
assert locked["feishu"] is True
assert locked["run_completed"] is False
async with sessions() as session:
bob_rows = (await session.scalars(select(ImNotificationPreferenceRow).where(ImNotificationPreferenceRow.owner_user_id == "bob"))).all()
assert len(bob_rows) == 4
await preferences.patch("alice", {"run_completed": True})
await asyncio.gather(
service.handle_run_completion(_make_run(run_id="run-concurrent-optout", status=RunStatus.success)),
preferences.patch("alice", {"run_completed": False}),
)
# After opt-out completes, re-enabling must never leave a pending row from before opt-out
# if the opt-out ran after enqueue, nor enqueue a row if opt-out ran before enqueue.
final_events = await preferences.get_event_preferences("alice")
assert final_events["run_completed"] is False
async with sessions() as session:
pending = (
await session.scalars(
select(NotificationDeliveryRow).where(
NotificationDeliveryRow.run_id == "run-concurrent-optout",
NotificationDeliveryRow.status == "pending",
)
)
).all()
assert pending == []
@pytest.mark.anyio
@pytest.mark.parametrize("mode", ["full", "delta"])
async def test_gateway_notification_delivery_human_input_closure_works_in_both_checkpoint_modes(
env,
mode,
monkeypatch,
):
from fastapi import FastAPI
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph
from langgraph.store.memory import InMemoryStore
import app.channels.service as channel_service_mod
import app.gateway.app as gateway_app_mod
import app.gateway.deps as gateway_deps
import app.gateway.services as gateway_services
from deerflow.agents.thread_state import get_thread_state_schema
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.runtime.checkpoint_mode import inject_checkpoint_mode
sessions, preferences, deliveries, service = env
await preferences.patch(
"alice",
{"human_input_requested": True, "wecom": False, "wechat": False, "qq": False},
)
checkpointer = InMemorySaver()
store = InMemoryStore()
thread_store = MemoryThreadMetaStore(store)
await thread_store.create("thread-gw-ask", assistant_id="lead_agent", user_id="alice")
state_schema = get_thread_state_schema(mode)
def _noop_node(state):
return {}
builder = StateGraph(state_schema)
builder.add_node("agent", _noop_node)
builder.set_entry_point("agent")
builder.set_finish_point("agent")
graph = builder.compile(checkpointer=checkpointer)
monkeypatch.setattr(
gateway_services,
"resolve_agent_factory",
lambda assistant_id=None: lambda config: graph,
)
monkeypatch.setattr(gateway_deps, "get_config", lambda: SimpleNamespace(models=[]))
pending_ai = AIMessage(
content="",
id="ai-gw-1",
tool_calls=[
{
"id": "call-gw-1",
"name": "ask_clarification",
"args": {"question": "Proceed with migration?", "clarification_type": "confirmation"},
}
],
)
prompt_tool = ToolMessage(
content="Proceed with migration?",
id="tool-gw-1",
tool_call_id="call-gw-1",
name="ask_clarification",
artifact={
"human_input": {
"version": 1,
"kind": "human_input_request",
"source": "ask_clarification",
"request_id": "req-gw-1",
"question": "Proceed with migration?",
}
},
)
config = {"configurable": {"thread_id": "thread-gw-ask", "checkpoint_ns": ""}}
inject_checkpoint_mode(config, mode)
await graph.ainvoke(
{"messages": [HumanMessage(content="Start", id="h-gw-1"), pending_ai, prompt_tool]},
config,
)
sent_messages = []
class FakeChannel:
is_running = True
async def send_notification_for_owner(self, **kwargs):
sent_messages.append(kwargs)
fake_channel_service = SimpleNamespace(
get_channel=lambda provider: FakeChannel() if provider == "feishu" else None,
notification_excluded_providers=lambda: frozenset(),
)
monkeypatch.setattr(channel_service_mod, "get_channel_service", lambda: fake_channel_service)
monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: sessions)
app = FastAPI()
app.state.scheduled_task_service = service
app.state.checkpointer = checkpointer
app.state.store = store
app.state.thread_store = thread_store
app.state.run_event_store = SimpleNamespace()
app.state.checkpoint_channel_mode = mode
app.state.checkpoint_snapshot_frequency = None
startup_config = SimpleNamespace(
scheduler=SimpleNamespace(poll_interval_seconds=60),
channel_connections=SimpleNamespace(notification_locale="en-US"),
)
conn_repo = SimpleNamespace(
list_connections=lambda user_id: [
{
"id": "personal-feishu",
"owner_user_id": user_id,
"provider": "feishu",
"status": "connected",
"external_account_id": "feishu-user",
}
]
)
await gateway_app_mod._start_scheduled_task_notification_delivery(
app,
startup_config,
deliveries,
conn_repo,
)
worker = app.state.notification_delivery_worker
try:
# 1. Unanswered request in checkpoint -> gateway closure returns True and delivers.
await service.handle_run_completion(
_make_run(
run_id=f"run-gw-live-{mode}",
thread_id="thread-gw-ask",
status=RunStatus.success,
human_input_request={"request_id": "req-gw-1", "question": "Proceed with migration?"},
)
)
await worker.run_once(now=datetime.now(UTC))
assert len(sent_messages) == 1
# 2. User answers in checkpoint -> gateway closure returns False and marks terminal failed.
answer_msg = HumanMessage(
content="Yes",
id="h-gw-2",
additional_kwargs={
HUMAN_INPUT_RESPONSE_KEY: {
"version": 1,
"kind": "human_input_response",
"request_id": "req-gw-1",
}
},
)
await graph.ainvoke({"messages": [answer_msg]}, config)
await service.handle_run_completion(
_make_run(
run_id=f"run-gw-answered-{mode}",
thread_id="thread-gw-ask",
status=RunStatus.success,
human_input_request={"request_id": "req-gw-2", "question": "Proceed with migration?"},
)
)
await worker.run_once(now=datetime.now(UTC))
assert len(sent_messages) == 1
async with sessions() as session:
rows = (await session.scalars(select(NotificationDeliveryRow).where(NotificationDeliveryRow.run_id == f"run-gw-answered-{mode}"))).all()
assert len(rows) == 1
assert (rows[0].status, rows[0].last_error) == ("failed", "human_input_request_already_resolved")
finally:
await worker.stop()

View File

@ -183,3 +183,23 @@ async def test_group_selection_requires_enabled_channel_connections(env):
response = await env.client.post("/api/scheduled-tasks", json=_body(**{FIELD: group["id"]}))
assert response.status_code == 422, response.text
assert response.json()["detail"]["code"] == "invalid_notification_group"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("provider", "workspace_id", "metadata", "allowed"),
[
("qq", "app1:group:group-openid", {"chat_type": "group", "group_openid": "group-openid"}, True),
("wecom", "bot1:group:wr_chat_1", {"aibotid": "bot1", "chattype": "group", "chatid": "wr_chat_1"}, True),
("wechat", "group:wx_chat_1", {"chat_type": "group", "chat_id": "group:wx_chat_1"}, False),
],
)
async def test_qq_and_wecom_group_destinations_round_trip_and_wechat_is_rejected(env, provider, workspace_id, metadata, allowed):
group = await _bind(env, provider=provider, workspace_id=workspace_id, metadata=metadata)
response = await env.client.post("/api/scheduled-tasks", json=_body(**{FIELD: group["id"]}))
if allowed:
assert response.status_code == 200, response.text
assert response.json()[FIELD] == group["id"]
else:
assert response.status_code == 422, response.text
assert response.json()["detail"]["code"] == "invalid_notification_group"

View File

@ -0,0 +1,69 @@
"""WeCom group binding names and legacy identity boundaries, without provider I/O."""
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import pytest_asyncio
from app.channels.connection_identity import lookup_thread_id
from app.channels.message_bus import InboundMessage, MessageBus
from app.channels.wecom import WeComChannel
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
@pytest_asyncio.fixture
async def repo(tmp_path):
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'wecom.db'}", sqlite_dir=str(tmp_path))
try:
yield ChannelConnectionRepository(get_session_factory())
finally:
await close_engine()
def inbound(group="wr-group", bot="bot-1"):
return InboundMessage(channel_name="wecom", chat_id="alice", user_id="alice", text="group follow-up", topic_id="alice", metadata={"aibotid": bot, "chattype": "group", "chatid": group})
@pytest.mark.asyncio
@pytest.mark.parametrize("private_metadata", [{"aibotid": "bot-1", "chattype": "single"}, {"aibotid": "bot-1"}, {}])
async def test_private_binding_does_not_authorize_an_unbound_group(repo, private_metadata):
await repo.upsert_connection(owner_user_id="owner", provider="wecom", external_account_id="alice", workspace_id="bot-1", metadata=private_metadata)
channel = WeComChannel(MessageBus(), {"connection_repo": repo})
message = await channel._attach_connection_identity(inbound())
assert message.connection_id is None
assert message.owner_user_id is None
@pytest.mark.asyncio
async def test_legacy_group_binding_retains_owner_and_existing_thread(repo):
legacy = await repo.upsert_connection(owner_user_id="owner", provider="wecom", external_account_id="alice", workspace_id="bot-1", metadata={"aibotid": "bot-1", "chattype": "group"})
await repo.set_thread_id(connection_id=legacy["id"], owner_user_id="owner", provider="wecom", external_conversation_id="alice", external_topic_id="alice", thread_id="existing-thread")
channel = WeComChannel(MessageBus(), {"connection_repo": repo})
message = await channel._attach_connection_identity(inbound())
assert message.connection_id == legacy["id"]
assert message.owner_user_id == "owner"
assert await lookup_thread_id(message, repo=repo, store=None) == "existing-thread"
other_bot = await channel._attach_connection_identity(inbound(bot="bot-2"))
assert other_bot.connection_id is None
await repo.disconnect_connection(connection_id=legacy["id"], owner_user_id="owner")
revoked = await channel._attach_connection_identity(inbound())
assert revoked.connection_id is None
@pytest.mark.asyncio
async def test_named_group_connect_records_a_human_label_without_exposing_chatid(repo):
channel = WeComChannel(MessageBus(), {"connection_repo": repo})
channel._ws_client = SimpleNamespace(reply=AsyncMock(), reply_stream=AsyncMock())
for code, group, label in (("code-1", "wr-group-1", "Release team"), ("code-2", "wr-group-2", "Support team")):
await repo.create_oauth_state(owner_user_id="owner", provider="wecom", state=code, expires_at=datetime.now(UTC) + timedelta(minutes=5), metadata={"notification_group_name": label})
await channel._publish_ws_inbound(
{"body": {"aibotid": "bot-1", "chattype": "group", "chatid": group, "msgid": f"msg-{code}", "from": {"userid": "alice"}, "text": {"content": f"/connect {code}"}}},
f"/connect {code}",
)
rows = await repo.list_connections("owner")
assert len(rows) == 2
assert {row["workspace_name"] for row in rows} == {"Release team", "Support team"}
assert {row["workspace_id"] for row in rows} == {"bot-1:group:wr-group-1", "bot-1:group:wr-group-2"}

View File

@ -1,35 +1,90 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { connectChannelProvider } from "@/core/channels/api";
import { channelConnectionsQueryKey } from "@/core/channels/hooks";
import type { ChannelConnectResponse } from "@/core/channels/types";
import { useI18n } from "@/core/i18n/hooks";
import { fill } from "./shared";
type GroupChannelProvider = "feishu" | "wecom" | "qq";
interface CachedGroupBinding {
data: ChannelConnectResponse;
expiresAt: number;
groupName?: string;
}
/** Group binding needs its own command; a connected private chat is not completion. */
export function BindFeishuGroup() {
const { t, locale } = useI18n();
const labels = t.scheduledTasks.notificationGroup;
const queryClient = useQueryClient();
const [visible, setVisible] = useState(false);
const [provider, setProvider] = useState<GroupChannelProvider>("feishu");
const [namesByProvider, setNamesByProvider] = useState<
Partial<Record<GroupChannelProvider, string>>
>({});
const groupNameId = useId();
const [cachedByProvider, setCachedByProvider] = useState<
Partial<Record<GroupChannelProvider, CachedGroupBinding>>
>({});
const [copied, setCopied] = useState(false);
const [copyFailed, setCopyFailed] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [expiresAt, setExpiresAt] = useState<number | null>(null);
const binding = useMutation({
mutationFn: () => connectChannelProvider("feishu"),
onSuccess: (result) => {
mutationFn: ({
targetProvider,
groupName,
}: {
targetProvider: GroupChannelProvider;
groupName?: string;
}) =>
groupName
? connectChannelProvider(targetProvider, { groupName })
: connectChannelProvider(targetProvider),
onSuccess: (result, { targetProvider, groupName }) => {
setCopied(false);
setCopyFailed(false);
setExpiresAt(Date.now() + result.expires_in * 1000);
setCachedByProvider((prev) => ({
...prev,
[targetProvider]: {
data: result,
expiresAt: Date.now() + result.expires_in * 1000,
groupName,
},
}));
},
});
const command = binding.data ? `/connect ${binding.data.code}` : "";
const currentCached = cachedByProvider[provider];
const needsName = provider !== "feishu";
const groupName = (namesByProvider[provider] ?? "").trim();
const command =
currentCached && (!needsName || currentCached.groupName === groupName)
? `/connect ${currentCached.data.code}`
: "";
const expiresAt = currentCached?.expiresAt ?? null;
const startBind = (targetProvider: GroupChannelProvider) => {
setProvider(targetProvider);
setVisible(true);
setCopied(false);
setCopyFailed(false);
const cached = cachedByProvider[targetProvider];
const name = namesByProvider[targetProvider]?.trim();
if (
(!cached ||
cached.expiresAt <= Date.now() ||
cached.groupName !== name) &&
(targetProvider === "feishu" || name)
) {
binding.mutate({ targetProvider, groupName: name });
}
};
const copy = async () => {
try {
await navigator.clipboard.writeText(command);
@ -58,13 +113,28 @@ export function BindFeishuGroup() {
variant="outline"
size="sm"
disabled={binding.isPending}
onClick={() => {
setVisible(true);
if (!binding.data) binding.mutate();
}}
onClick={() => startBind("feishu")}
>
{labels.bind}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={binding.isPending}
onClick={() => startBind("wecom")}
>
{labels.bindWecom}
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={binding.isPending}
onClick={() => startBind("qq")}
>
{labels.bindQq}
</Button>
<Button
type="button"
variant="ghost"
@ -81,6 +151,27 @@ export function BindFeishuGroup() {
data-testid="feishu-group-binding"
>
<p className="text-sm">{labels.bindInstructions}</p>
{needsName && (
<>
<label htmlFor={groupNameId} className="text-sm font-medium">
{labels.name}
</label>
<Input
id={groupNameId}
value={namesByProvider[provider] ?? ""}
maxLength={256}
onChange={(event) => {
setNamesByProvider((previous) => ({
...previous,
[provider]: event.target.value,
}));
setCopied(false);
setCopyFailed(false);
}}
/>
<p className="text-muted-foreground text-xs">{labels.nameHint}</p>
</>
)}
{binding.isPending && (
<p role="status" className="text-muted-foreground text-xs">
{labels.generating}
@ -125,8 +216,13 @@ export function BindFeishuGroup() {
type="button"
variant="ghost"
size="sm"
disabled={binding.isPending}
onClick={() => binding.mutate()}
disabled={binding.isPending || (needsName && !groupName)}
onClick={() =>
binding.mutate({
targetProvider: provider,
groupName: needsName ? groupName : undefined,
})
}
>
{labels.newCode}
</Button>

View File

@ -12,8 +12,8 @@ import { useChannelConnections } from "@/core/channels/hooks";
import { useScheduledTasksFeature } from "@/core/features/hooks";
import { useI18n } from "@/core/i18n/hooks";
import {
feishuNotificationGroups,
notificationGroupName,
notificationGroups,
} from "@/core/scheduled-tasks/notification-groups";
import { BindFeishuGroup } from "./bind-feishu-group";
@ -69,7 +69,7 @@ function EnabledNotificationGroupSelect({
const { t } = useI18n();
const labels = t.scheduledTasks.notificationGroup;
const { connections, isLoading, error } = useChannelConnections();
const groups = feishuNotificationGroups(connections);
const groups = notificationGroups(connections);
const selected = groups.find((group) => group.id === value);
const selectedMissing = Boolean(value) && !selected;
const unnamed = groups.some((group) => !notificationGroupName(group));
@ -138,7 +138,7 @@ export function NotificationGroupSummary({
const { t } = useI18n();
const labels = t.scheduledTasks.notificationGroup;
const { connections, isLoading, error } = useChannelConnections();
const group = feishuNotificationGroups(connections).find(
const group = notificationGroups(connections).find(
(connection) => connection.id === connectionId,
);
const name = notificationGroupName(group);

View File

@ -10,9 +10,10 @@ import { useAuth } from "@/core/auth/AuthProvider";
import { listChannelProviders } from "@/core/channels/api";
import {
loadChannelNotificationPreferences,
notificationEventIds,
notificationProviderIds,
updateChannelNotificationPreference,
type NotificationProviderId,
type NotificationPreferenceKey,
} from "@/core/channels/notification-preferences";
import { useI18n } from "@/core/i18n/hooks";
import { isStaticWebsiteOnly } from "@/core/static-mode";
@ -51,26 +52,26 @@ function UserChannelNotifications({ userId }: { userId: string }) {
retry: false,
gcTime: 0,
});
const [saving, setSaving] = useState<NotificationProviderId | null>(null);
const [saving, setSaving] = useState<NotificationPreferenceKey | null>(null);
const [saveFailed, setSaveFailed] = useState(false);
const saveRequest = useRef<AbortController | null>(null);
useEffect(() => () => saveRequest.current?.abort(), []);
async function save(provider: NotificationProviderId, enabled: boolean) {
async function save(key: NotificationPreferenceKey, enabled: boolean) {
if (
saveRequest.current ||
!query.data ||
query.data.preferences[provider] === enabled
Boolean(query.data.preferences[key]) === enabled
)
return;
const request = new AbortController();
saveRequest.current = request;
setSaving(provider);
setSaving(key);
setSaveFailed(false);
try {
const preferences = await updateChannelNotificationPreference(
userId,
provider,
key,
enabled,
request.signal,
);
@ -149,6 +150,33 @@ function UserChannelNotifications({ userId }: { userId: string }) {
/>
</div>
))}
{providers.length > 0 && (
<div className="border-border/60 space-y-3 border-t pt-3">
<div className="space-y-1">
<p className="text-sm font-medium">{text.eventsTitle}</p>
<p className="text-muted-foreground text-xs">
{text.eventsDescription}
</p>
</div>
{notificationEventIds.map((event) => (
<div
key={event}
className="flex items-center justify-between gap-4"
>
<label htmlFor={`notify-event-${event}`} className="text-sm">
{text.events[event]}
</label>
<Switch
id={`notify-event-${event}`}
aria-label={text.eventToggleLabel(text.events[event])}
checked={Boolean(preferences[event])}
disabled={saving !== null || query.isFetching}
onCheckedChange={(enabled) => void save(event, enabled)}
/>
</div>
))}
</div>
)}
{saving && (
<p role="status" className="text-muted-foreground text-sm">
{text.saving}

View File

@ -47,10 +47,17 @@ export async function listChannelConnections(): Promise<ChannelConnection[]> {
export async function connectChannelProvider(
provider: ChannelProviderId,
options?: { groupName?: string },
): Promise<ChannelConnectResponse> {
const response = await fetch(
channelsUrl(`/${encodeURIComponent(provider)}/connect`),
{ method: "POST" },
options?.groupName
? {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ group_name: options.groupName }),
}
: { method: "POST" },
);
if (!response.ok) {
await throwGatewayApiError(

View File

@ -12,11 +12,24 @@ export const notificationProviderIds = [
] as const;
export type NotificationProviderId = (typeof notificationProviderIds)[number];
export const notificationEventIds = [
"run_completed",
"run_failed",
"human_input_requested",
] as const;
export type NotificationEventId = (typeof notificationEventIds)[number];
export type NotificationPreferenceKey =
| NotificationProviderId
| NotificationEventId;
const preferencesSchema = z.object({
feishu: z.boolean(),
wecom: z.boolean(),
wechat: z.boolean(),
qq: z.boolean(),
run_completed: z.boolean().optional(),
run_failed: z.boolean().optional(),
human_input_requested: z.boolean().optional(),
});
export type ChannelNotificationPreferences = z.infer<typeof preferencesSchema>;
@ -54,9 +67,9 @@ export function loadChannelNotificationPreferences(
export function updateChannelNotificationPreference(
userId: string,
provider: NotificationProviderId,
key: NotificationPreferenceKey,
enabled: boolean,
signal: AbortSignal,
) {
return requestPreferences(userId, signal, { [provider]: enabled });
return requestPreferences(userId, signal, { [key]: enabled });
}

View File

@ -25,6 +25,7 @@ export interface ChannelProvider {
* is a capability, not a notification preference. Older backends may omit it.
*/
proactive_notifications?: boolean;
proactive_group_notifications?: boolean;
}
export interface ChannelProvidersResponse {

View File

@ -1037,6 +1037,11 @@ export const enUS: Translations = {
"This task has a saved group destination, but group notifications are unavailable on this server.",
clearSaved: "Clear group notification",
bind: "Bind a Feishu group",
bindWecom: "Bind a WeCom group",
bindQq: "Bind a QQ group",
name: "Group name",
nameHint:
"Name this group so you can recognize it when choosing a notification destination. Rebind a group to change its name.",
refresh: "Refresh group list",
bindInstructions:
"Add the bot to the group, then mention the bot and send this command in that group. Refresh the group list after binding.",
@ -1049,19 +1054,19 @@ export const enUS: Translations = {
copyFailed: "Could not copy. Select and copy the command above.",
newCode: "Generate a new code",
close: "Close binding instructions",
label: "Also notify a Feishu group (optional)",
detailLabel: "Additional Feishu group",
label: "Also notify a group (optional)",
detailLabel: "Additional group",
none: "No additional group",
personalHint:
"Your personal notifications stay unchanged. The selected group also receives task updates.",
emptyHint: "Bind a Feishu group below, then refresh the list.",
emptyHint: "Bind a group below, then refresh the list.",
loading: "Loading groups…",
unavailable: "Selected group unavailable",
unavailableHint:
"The selected group is no longer available. Keep it, remove it, or choose another group.",
unnamed: "Group name unavailable",
unnamedHint:
"Check the app's group-information permission, then bind the group again to load its name.",
"Bind the group again with a recognizable name before selecting it.",
loadFailed:
"Could not load groups. Your selection is kept; you can save other changes or remove it.",
},
@ -2414,6 +2419,15 @@ export const enUS: Translations = {
},
toggleLabel: (provider: string) =>
`${provider} scheduled task notifications`,
eventsTitle: "Task & goal run notifications",
eventsDescription:
"Optional IM reminders for ordinary web or background task runs and goals on your enabled apps above (off by default).",
events: {
run_completed: "Task or goal completed",
run_failed: "Task or goal failed",
human_input_requested: "Needs your input",
},
eventToggleLabel: (event: string) => `${event} notifications`,
loading: "Loading notification preferences…",
saving: "Saving…",
loadFailed:

View File

@ -843,6 +843,10 @@ export interface Translations {
savedUnavailable: string;
clearSaved: string;
bind: string;
bindWecom: string;
bindQq: string;
name: string;
nameHint: string;
refresh: string;
bindInstructions: string;
generating: string;
@ -1947,6 +1951,14 @@ export interface Translations {
qq: string;
};
toggleLabel: (provider: string) => string;
eventsTitle: string;
eventsDescription: string;
events: {
run_completed: string;
run_failed: string;
human_input_requested: string;
};
eventToggleLabel: (event: string) => string;
loading: string;
saving: string;
loadFailed: string;

View File

@ -973,6 +973,11 @@ export const zhCN: Translations = {
savedUnavailable: "已有群通知配置,当前服务未启用群通知。",
clearSaved: "清除群通知",
bind: "绑定飞书群",
bindWecom: "绑定企业微信群",
bindQq: "绑定 QQ 群",
name: "群名称",
nameHint:
"为这个群取一个容易识别的名称,便于选择通知目标。重新绑定可修改群名称。",
refresh: "刷新群列表",
bindInstructions:
"先把机器人加入群聊,再在群里 @机器人 并发送这条命令。完成绑定后刷新群列表。",
@ -985,16 +990,16 @@ export const zhCN: Translations = {
copyFailed: "复制失败,请选中上方命令手动复制。",
newCode: "生成新绑定码",
close: "关闭绑定说明",
label: "额外通知到飞书群(可选)",
detailLabel: "额外通知到飞书群",
label: "额外通知到群聊(可选)",
detailLabel: "额外通知到群聊",
none: "仅通知本人",
personalHint: "你现有的个人通知保持不变,所选群也会收到任务更新。",
emptyHint: "先在下方绑定飞书群,再刷新群列表。",
emptyHint: "先在下方绑定群聊,再刷新群列表。",
loading: "正在加载群列表…",
unavailable: "已选群暂不可用",
unavailableHint: "已选群当前不可用。可以保留、移除或选择其他群。",
unnamed: "群名称不可用",
unnamedHint: "请检查应用的群信息读取权限,再重新绑定该群以获取群名称。",
unnamedHint: "请重新绑定该群并设置容易识别的名称,再选择通知目标。",
loadFailed:
"无法加载群列表,已保留当前选择。仍可保存其他修改或移除所选群。",
},
@ -2266,6 +2271,15 @@ export const zhCN: Translations = {
qq: "QQ",
},
toggleLabel: (provider: string) => `${provider}定时任务通知`,
eventsTitle: "普通任务与目标提醒",
eventsDescription:
"可选:通过上方已开启的应用接收普通网页或后台任务、目标的运行提醒(默认关闭)。",
events: {
run_completed: "任务或目标已完成",
run_failed: "任务或目标运行失败",
human_input_requested: "需要你参与确认",
},
eventToggleLabel: (event: string) => `${event}通知`,
loading: "正在加载通知偏好…",
saving: "正在保存…",
loadFailed: "无法加载通知偏好,请重试。",

View File

@ -1,24 +1,130 @@
import type { ChannelConnection } from "@/core/channels/types";
function isNonEmptyTrimmedString(value: unknown): value is string {
return (
typeof value === "string" &&
value.trim().length > 0 &&
value === value.trim()
);
}
export function isFeishuNotificationGroup(
connection: ChannelConnection,
): boolean {
return (
connection.provider === "feishu" &&
connection.status === "connected" &&
connection.metadata?.chat_type === "group" &&
typeof connection.workspace_id === "string" &&
connection.workspace_id.trim().length > 0 &&
connection.metadata?.chat_id === connection.workspace_id
);
}
export function isQqNotificationGroup(connection: ChannelConnection): boolean {
if (
connection.provider !== "qq" ||
connection.status !== "connected" ||
connection.metadata?.chat_type !== "group" ||
typeof connection.workspace_id !== "string"
) {
return false;
}
const sepIndex = connection.workspace_id.indexOf(":group:");
if (sepIndex <= 0) return false;
const appId = connection.workspace_id.slice(0, sepIndex);
const groupOpenid = connection.workspace_id.slice(
sepIndex + ":group:".length,
);
if (
!isNonEmptyTrimmedString(appId) ||
!isNonEmptyTrimmedString(groupOpenid)
) {
return false;
}
const metaOpenid = connection.metadata?.group_openid;
if (
metaOpenid !== undefined &&
metaOpenid !== null &&
metaOpenid !== groupOpenid
) {
return false;
}
return true;
}
export function isWecomNotificationGroup(
connection: ChannelConnection,
): boolean {
if (
connection.provider !== "wecom" ||
connection.status !== "connected" ||
connection.metadata?.chattype !== "group" ||
typeof connection.workspace_id !== "string"
) {
return false;
}
const chatid = connection.metadata?.chatid;
if (!isNonEmptyTrimmedString(chatid)) return false;
const aibotid = connection.metadata?.aibotid;
if (aibotid === undefined || aibotid === null || aibotid === "") {
return connection.workspace_id === `group:${chatid}`;
}
if (!isNonEmptyTrimmedString(aibotid)) return false;
return connection.workspace_id === `${aibotid}:group:${chatid}`;
}
export function isNotificationGroup(connection: ChannelConnection): boolean {
return (
isFeishuNotificationGroup(connection) ||
isQqNotificationGroup(connection) ||
isWecomNotificationGroup(connection)
);
}
/** The Gateway still validates ownership and the binding when the task is saved. */
export function feishuNotificationGroups(
export function notificationGroups(
connections: readonly ChannelConnection[],
): ChannelConnection[] {
return connections.filter(
(connection) =>
connection.provider === "feishu" &&
connection.status === "connected" &&
connection.metadata?.chat_type === "group" &&
typeof connection.workspace_id === "string" &&
connection.workspace_id.trim().length > 0 &&
connection.metadata?.chat_id === connection.workspace_id,
return connections.filter(isNotificationGroup);
}
export const feishuNotificationGroups = notificationGroups;
function qqGroupId(connection: ChannelConnection): string | null {
if (!isQqNotificationGroup(connection)) return null;
const metaOpenid = connection.metadata?.group_openid;
if (isNonEmptyTrimmedString(metaOpenid)) return metaOpenid;
if (typeof connection.workspace_id !== "string") return null;
const sepIndex = connection.workspace_id.indexOf(":group:");
if (sepIndex <= 0) return null;
const groupOpenid = connection.workspace_id.slice(
sepIndex + ":group:".length,
);
return isNonEmptyTrimmedString(groupOpenid) ? groupOpenid : null;
}
function wecomGroupId(connection: ChannelConnection): string | null {
if (!isWecomNotificationGroup(connection)) return null;
const chatid = connection.metadata?.chatid;
return isNonEmptyTrimmedString(chatid) ? chatid : null;
}
export function notificationGroupName(
connection: ChannelConnection | undefined,
): string | null {
const name = connection?.workspace_name?.trim();
if (!connection) return null;
const name = connection.workspace_name?.trim();
if (!name) return null;
// Older bindings used raw platform IDs as generated labels. Require a real
// group name or an owner-chosen label before making them selectable.
if (connection.provider === "qq") {
const groupId = qqGroupId(connection);
if (groupId && name === `QQ group (${groupId})`) return null;
}
if (connection.provider === "wecom") {
const groupId = wecomGroupId(connection);
if (groupId && name === `WeCom group (${groupId})`) return null;
}
return name;
}

View File

@ -87,7 +87,7 @@ test("selecting and clearing an additional group preserves the schedule and disp
await page.getByRole("button", { name: "Edit", exact: true }).click();
const form = page.getByTestId("scheduled-task-form");
const select = form.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
name: "Also notify a group (optional)",
});
await expect(select).toHaveText("No additional group");
await select.click();
@ -112,7 +112,7 @@ test("selecting and clearing an additional group preserves the schedule and disp
).toContainText("Planning team");
await page.getByRole("button", { name: "Edit", exact: true }).click();
await form
.getByRole("combobox", { name: "Also notify a Feishu group (optional)" })
.getByRole("combobox", { name: "Also notify a group (optional)" })
.click();
await page
.getByRole("option", { name: "No additional group", exact: true })
@ -147,7 +147,7 @@ test("a connection-list failure does not clear an existing group when saving ano
).toBeVisible({ timeout: 15_000 });
await expect(
form.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
name: "Also notify a group (optional)",
}),
).toHaveText("Selected group unavailable");
await expect(form).not.toContainText("missing-group-binding");
@ -176,7 +176,7 @@ test("duplicating a task copies its selected group", async ({ page }) => {
const form = page.getByTestId("scheduled-task-form");
await expect(
form.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
name: "Also notify a group (optional)",
}),
).toHaveText("Planning team");
const request = page.waitForRequest(
@ -259,7 +259,7 @@ test("the task form provides a persistent group binding command and refreshes th
groupBound = true;
await form.getByRole("button", { name: "Refresh group list" }).click();
const selector = form.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
name: "Also notify a group (optional)",
});
await expect(selector).toHaveText("No additional group");
await selector.click();
@ -279,7 +279,7 @@ test("a backend without the group capability does not offer a group destination"
).toHaveCount(0);
await expect(
form.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
name: "Also notify a group (optional)",
}),
).toHaveCount(0);
});

View File

@ -161,7 +161,7 @@ describe("TaskFormDialog", () => {
).toBeNull();
expect(
screen.queryByRole("combobox", {
name: "Also notify a Feishu group (optional)",
name: "Also notify a group (optional)",
}),
).toBeNull();
expect(
@ -185,7 +185,7 @@ describe("TaskFormDialog", () => {
await waitFor(() =>
expect(
screen.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
name: "Also notify a group (optional)",
}).textContent,
).toBe("Planning team"),
);
@ -274,7 +274,7 @@ describe("TaskFormDialog", () => {
).toBeNull();
expect(
screen.queryByRole("combobox", {
name: "Also notify a Feishu group (optional)",
name: "Also notify a group (optional)",
}),
).toBeNull();
fireEvent.click(
@ -297,7 +297,7 @@ describe("TaskFormDialog", () => {
});
expect(
screen.queryByRole("combobox", {
name: "Also notify a Feishu group (optional)",
name: "Also notify a group (optional)",
}),
).toBeNull();
expect(
@ -365,17 +365,98 @@ describe("TaskFormDialog", () => {
);
});
test("WeCom and QQ group bind buttons request provider-specific binding codes", async () => {
connectProvider.mockImplementation(async (provider: string) => ({
provider,
mode: "binding_code",
code: `${provider}-group-code`,
instruction: "unused",
expires_in: 600,
}));
renderForm({ mode: "create" });
fireEvent.click(screen.getByRole("button", { name: "Bind a WeCom group" }));
fireEvent.change(
await screen.findByRole("textbox", { name: "Group name" }),
{
target: { value: "Release team" },
},
);
fireEvent.click(
screen.getByRole("button", { name: "Generate a new code" }),
);
const command = await screen.findByRole("textbox", {
name: "Group binding command",
});
await waitFor(() =>
expect(command).toHaveProperty("value", "/connect wecom-group-code"),
);
expect(connectProvider).toHaveBeenCalledWith("wecom", {
groupName: "Release team",
});
fireEvent.click(screen.getByRole("button", { name: "Bind a QQ group" }));
expect(screen.getByRole("textbox", { name: "Group name" })).toHaveProperty(
"value",
"",
);
expect(
screen.queryByRole("textbox", { name: "Group binding command" }),
).toBeNull();
fireEvent.change(screen.getByRole("textbox", { name: "Group name" }), {
target: { value: "Support team" },
});
fireEvent.click(
screen.getByRole("button", { name: "Generate a new code" }),
);
await waitFor(() =>
expect(
screen.getByRole("textbox", { name: "Group binding command" }),
).toHaveProperty("value", "/connect qq-group-code"),
);
expect(connectProvider).toHaveBeenCalledWith("qq", {
groupName: "Support team",
});
fireEvent.click(
screen.getByRole("button", { name: "Bind a Feishu group" }),
);
await waitFor(() =>
expect(
screen.getByRole("textbox", { name: "Group binding command" }),
).toHaveProperty("value", "/connect feishu-group-code"),
);
expect(screen.queryByRole("textbox", { name: "Group name" })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Bind a WeCom group" }));
expect(
screen.getByRole("textbox", { name: "Group binding command" }),
).toHaveProperty("value", "/connect wecom-group-code");
expect(connectProvider).toHaveBeenCalledTimes(3);
fireEvent.change(screen.getByRole("textbox", { name: "Group name" }), {
target: { value: "Operations" },
});
expect(
screen.queryByRole("textbox", { name: "Group binding command" }),
).toBeNull();
fireEvent.click(
screen.getByRole("button", { name: "Generate a new code" }),
);
await waitFor(() =>
expect(connectProvider).toHaveBeenLastCalledWith("wecom", {
groupName: "Operations",
}),
);
await screen.findByRole("textbox", { name: "Group binding command" });
});
test("refreshing after binding discovers the group without changing the task automatically", async () => {
listConnections.mockResolvedValueOnce([]).mockResolvedValue([group()]);
renderForm({ mode: "create" });
await screen.findByText(
"Bind a Feishu group below, then refresh the list.",
);
await screen.findByText("Bind a group below, then refresh the list.");
fireEvent.click(screen.getByRole("button", { name: "Refresh group list" }));
await waitFor(() => expect(listConnections).toHaveBeenCalledTimes(2));
fireEvent.keyDown(
screen.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
name: "Also notify a group (optional)",
}),
{ key: "ArrowDown" },
);
@ -393,7 +474,7 @@ describe("TaskFormDialog", () => {
await waitFor(() => expect(listConnections).toHaveBeenCalled());
fireEvent.keyDown(
screen.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
name: "Also notify a group (optional)",
}),
{ key: "ArrowDown" },
);
@ -419,7 +500,7 @@ describe("TaskFormDialog", () => {
);
expect(
screen.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
name: "Also notify a group (optional)",
}).textContent,
).toBe("Selected group unavailable");
expect(document.body.textContent).not.toContain("saved-group-binding");
@ -445,7 +526,7 @@ describe("TaskFormDialog", () => {
);
fireEvent.keyDown(
screen.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
name: "Also notify a group (optional)",
}),
{ key: "ArrowDown" },
);
@ -463,11 +544,11 @@ describe("TaskFormDialog", () => {
listConnections.mockResolvedValue([group({ workspace_name: null })]);
renderForm({ mode: "create" });
await screen.findByText(
"Check the app's group-information permission, then bind the group again to load its name.",
"Bind the group again with a recognizable name before selecting it.",
);
fireEvent.keyDown(
screen.getByRole("combobox", {
name: "Also notify a Feishu group (optional)",
name: "Also notify a group (optional)",
}),
{ key: "ArrowDown" },
);
@ -483,13 +564,13 @@ describe("TaskFormDialog", () => {
test("the Chinese group field explains that personal notifications stay unchanged", async () => {
renderForm({ mode: "create" }, { locale: "zh-CN" });
expect(
screen.getByRole("combobox", { name: "额外通知到飞书群(可选)" })
screen.getByRole("combobox", { name: "额外通知到群聊(可选)" })
.textContent,
).toBe("仅通知本人");
expect(
screen.getByText("你现有的个人通知保持不变,所选群也会收到任务更新。"),
).toBeTruthy();
await screen.findByText("先在下方绑定飞书群,再刷新群列表。");
await screen.findByText("先在下方绑定群聊,再刷新群列表。");
});
test("every field resolves by its label", () => {

View File

@ -335,3 +335,41 @@ test("a late preference load cannot replace the next viewer's settings", async (
.getAttribute("aria-checked"),
).toBe("true");
});
test("renders default-off task & goal event switches and saves an event toggle", async () => {
const original = rs.mocked(fetcher).getMockImplementation()!;
rs.mocked(fetcher).mockImplementation((url, init) =>
init?.method === "PATCH"
? Promise.resolve(
json({
...defaults,
run_completed: false,
run_failed: false,
human_input_requested: true,
}),
)
: original(url, init),
);
mount();
for (const label of [
"Task or goal completed notifications",
"Task or goal failed notifications",
"Needs your input notifications",
]) {
const toggle = await screen.findByRole("switch", { name: label });
expect(toggle.getAttribute("aria-checked")).toBe("false");
}
const inputToggle = screen.getByRole("switch", {
name: "Needs your input notifications",
});
fireEvent.click(inputToggle);
await waitFor(() =>
expect(inputToggle.getAttribute("aria-checked")).toBe("true"),
);
const [, request] = rs
.mocked(fetcher)
.mock.calls.find(([, init]) => init?.method === "PATCH")!;
expect(JSON.parse(request!.body as string)).toEqual({
human_input_requested: true,
});
});

View File

@ -36,6 +36,21 @@ beforeEach(() => {
});
describe("channels api", () => {
test("sends an owner-chosen group name with the binding-code request", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, { provider: "qq", code: "code", expires_in: 600 }),
);
await connectChannelProvider("qq", { groupName: "Release team" });
expect(mockedFetch).toHaveBeenCalledWith(
"/backend/api/channels/qq/connect",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ group_name: "Release team" }),
},
);
});
test("loads provider catalog", async () => {
mockedFetch.mockResolvedValueOnce(
jsonResponse(200, {

View File

@ -69,3 +69,35 @@ test("keeps an unavailable endpoint distinguishable from valid default values",
loadChannelNotificationPreferences("alice", new AbortController().signal),
).rejects.toBeInstanceOf(GatewayApiError);
});
test("updates a runtime notification event key and parses optional event fields", async () => {
rs.mocked(fetcher).mockResolvedValue(
new Response(
JSON.stringify({
...defaults,
run_completed: true,
run_failed: false,
human_input_requested: true,
}),
),
);
const signal = new AbortController().signal;
const updated = await updateChannelNotificationPreference(
"alice",
"human_input_requested",
true,
signal,
);
expect(updated.human_input_requested).toBe(true);
expect(updated.run_completed).toBe(true);
expect(updated.run_failed).toBe(false);
expect(rs.mocked(fetcher)).toHaveBeenCalledWith(
"/backend/api/channels/notification-preferences",
expect.objectContaining({
method: "PATCH",
signal,
body: '{"human_input_requested":true}',
headers: expect.objectContaining({ "X-Expected-User-Id": "alice" }),
}),
);
});

View File

@ -44,4 +44,86 @@ describe("additional Feishu notification groups", () => {
notificationGroupName({ ...group, workspace_name: " 项目会议 " }),
).toBe("项目会议");
});
test("includes connected QQ and WeCom group bindings while excluding private or unsupported bindings", () => {
const qqGroup: ChannelConnection = {
id: "qq-group",
provider: "qq",
status: "connected",
workspace_id: "app1:group:group-openid",
scopes: [],
metadata: { chat_type: "group", group_openid: "group-openid" },
};
const wecomGroup: ChannelConnection = {
id: "wecom-group",
provider: "wecom",
status: "connected",
workspace_id: "bot1:group:wr_chat_1",
scopes: [],
metadata: { aibotid: "bot1", chattype: "group", chatid: "wr_chat_1" },
};
const qqPrivate: ChannelConnection = {
id: "qq-private",
provider: "qq",
status: "connected",
workspace_id: "app1:c2c",
scopes: [],
metadata: { chat_type: "c2c" },
};
const wechatGroup: ChannelConnection = {
id: "wechat-group",
provider: "wechat",
status: "connected",
workspace_id: "group:wx_1",
scopes: [],
metadata: { chat_type: "group", chat_id: "group:wx_1" },
};
const qqGroup2: ChannelConnection = {
...qqGroup,
id: "qq-group-2",
workspace_id: "app1:group:group-openid-2",
metadata: { chat_type: "group", group_openid: "group-openid-2" },
};
const wecomGroup2: ChannelConnection = {
...wecomGroup,
id: "wecom-group-2",
workspace_id: "bot1:group:wr_chat_2",
metadata: { aibotid: "bot1", chattype: "group", chatid: "wr_chat_2" },
};
expect(
feishuNotificationGroups([
qqGroup,
qqGroup2,
wecomGroup,
wecomGroup2,
qqPrivate,
wechatGroup,
]),
).toEqual([qqGroup, qqGroup2, wecomGroup, wecomGroup2]);
expect(notificationGroupName(qqGroup)).toBeNull();
expect(notificationGroupName(qqGroup2)).toBeNull();
expect(notificationGroupName(wecomGroup)).toBeNull();
expect(notificationGroupName(wecomGroup2)).toBeNull();
expect(
notificationGroupName({ ...qqGroup, workspace_name: "Release team" }),
).toBe("Release team");
expect(
notificationGroupName({ ...qqGroup2, workspace_name: "Support team" }),
).toBe("Support team");
expect(
notificationGroupName({
...qqGroup,
workspace_name: "QQ group (group-openid)",
}),
).toBeNull();
expect(
notificationGroupName({
...wecomGroup,
workspace_name: "WeCom group (wr_chat_1)",
}),
).toBeNull();
expect(
notificationGroupName({ ...wecomGroup, workspace_name: "Ops War Room" }),
).toBe("Ops War Room");
});
});