mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* 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>
108 lines
4.8 KiB
Python
108 lines
4.8 KiB
Python
"""The static channel capability table stays in sync with the channel code.
|
|
|
|
The scheduler and the Settings page read ``proactive_notifications`` from
|
|
``app.channels.capabilities`` without importing channel SDKs, so these tests
|
|
pin that table to the channel registry and to the channel classes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from app.channels.base import Channel
|
|
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
|
|
|
|
_BACKEND_DIR = Path(__file__).resolve().parents[1]
|
|
# Third-party modules the channel implementations import at module level.
|
|
_CHANNEL_SDK_MODULES = ("lark_oapi", "slack_sdk", "telegram", "discord", "dingtalk_stream", "aibot", "websockets", "cryptography", "markdown_to_mrkdwn")
|
|
|
|
|
|
def test_capabilities_cover_channel_registry():
|
|
# A provider registered without a capability entry (as qq once was) fails here.
|
|
assert set(CHANNEL_CAPABILITIES) == set(_CHANNEL_REGISTRY)
|
|
assert CHANNEL_CAPABILITIES["qq"]["proactive_notifications"] is True
|
|
|
|
|
|
def test_every_entry_declares_both_flags_as_bools():
|
|
for provider, entry in CHANNEL_CAPABILITIES.items():
|
|
assert set(entry) == {"supports_streaming", "proactive_notifications", "proactive_group_notifications"}, provider
|
|
assert all(isinstance(value, bool) for value in entry.values()), provider
|
|
|
|
|
|
def test_supported_notification_channels_push_proactively():
|
|
assert {provider for provider, entry in CHANNEL_CAPABILITIES.items() if entry["proactive_notifications"]} == {"wecom", "feishu", "wechat", "qq"}
|
|
for provider in ("wecom", "feishu", "wechat", "qq"):
|
|
assert supports_proactive_notifications(provider) is True
|
|
|
|
|
|
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:
|
|
"""The third-party module a resolution failure is missing, or None for any other failure."""
|
|
current: BaseException | None = exc
|
|
while current is not None:
|
|
if isinstance(current, ModuleNotFoundError):
|
|
name = current.name or ""
|
|
return name if name and name != "app" and not name.startswith("app.") else None
|
|
current = current.__cause__ or current.__context__
|
|
return None
|
|
|
|
|
|
@pytest.mark.parametrize("provider", sorted(CHANNEL_CAPABILITIES))
|
|
def test_proactive_flag_matches_send_notification_override(provider):
|
|
# Resolved exactly as the channel service starts a channel.
|
|
try:
|
|
cls = resolve_class(_CHANNEL_REGISTRY[provider], base_class=None)
|
|
except ImportError as exc:
|
|
missing = _missing_optional_module(exc)
|
|
if missing is None or provider == "wecom":
|
|
# wecom imports no SDK at module level, so it must always resolve.
|
|
raise
|
|
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():
|
|
code = (
|
|
"import sys\n"
|
|
"import app.channels.capabilities\n"
|
|
f"sdk = {_CHANNEL_SDK_MODULES!r}\n"
|
|
f"providers = {tuple(sorted(_CHANNEL_REGISTRY))!r}\n"
|
|
"loaded = sorted(name for name in sys.modules if name.split('.')[0] in sdk or name in {'app.channels.' + p for p in providers})\n"
|
|
"print(','.join(loaded))\n"
|
|
)
|
|
result = subprocess.run([sys.executable, "-c", code], cwd=_BACKEND_DIR, capture_output=True, text=True, check=True)
|
|
assert result.stdout.strip() == ""
|
|
|
|
|
|
def test_qq_streaming_is_unchanged_by_its_new_entry():
|
|
from app.channels.manager import ChannelManager
|
|
|
|
# No running channel service: the manager falls back to the static table,
|
|
# which says False for qq, as the missing entry did before.
|
|
assert ChannelManager._channel_supports_streaming("qq") is False
|