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