mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* feat(channels): control IM notifications and add WeChat/QQ delivery * fix(scheduler): skip notification preference locks without recipients --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
261 lines
10 KiB
Python
261 lines
10 KiB
Python
"""QQ proactive C2C notifications: serialized HTTP, no real provider calls."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import inspect
|
|
import json
|
|
import time
|
|
import traceback
|
|
from contextlib import asynccontextmanager
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from app.channels.base import ChannelUnavailable
|
|
from app.channels.message_bus import MessageBus
|
|
from app.channels.qq import QQAPIError, QQChannel, _ReplyContext
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
def binding(**overrides):
|
|
return {
|
|
"owner_user_id": "owner",
|
|
"provider": "qq",
|
|
"status": "connected",
|
|
"external_account_id": "user-openid",
|
|
"workspace_id": "app1:c2c",
|
|
"metadata": {"chat_type": "c2c"},
|
|
**overrides,
|
|
}
|
|
|
|
|
|
@asynccontextmanager
|
|
async def connected(handler=None, *, bindings=None):
|
|
requests = []
|
|
|
|
async def respond(request):
|
|
requests.append(request)
|
|
if handler is not None:
|
|
response = handler(request)
|
|
if inspect.isawaitable(response):
|
|
response = await response
|
|
return response
|
|
if request.url.host == "bots.qq.com":
|
|
return httpx.Response(200, json={"access_token": "fixture-token", "expires_in": 7200})
|
|
return httpx.Response(200, json={"id": "notification-id"})
|
|
|
|
repo = SimpleNamespace(list_connections=AsyncMock(return_value=[binding()] if bindings is None else bindings))
|
|
channel = QQChannel(MessageBus(), {"app_id": "app1", "client_secret": "fixture-secret", "connection_repo": repo})
|
|
channel._http = httpx.AsyncClient(transport=httpx.MockTransport(respond), timeout=15, trust_env=False)
|
|
channel._running = True
|
|
channel._ready.set()
|
|
channel._listener = asyncio.create_task(asyncio.Event().wait())
|
|
try:
|
|
yield channel, requests
|
|
finally:
|
|
await channel.stop()
|
|
|
|
|
|
async def notify(channel, *, owner="owner", target="user-openid", text="任务已完成。\nOpen DeerFlow for details."):
|
|
await channel.send_notification_for_owner(owner_user_id=owner, target=target, text_markdown=text)
|
|
|
|
|
|
async def test_private_notification_serializes_without_passive_message_context():
|
|
async with connected() as (channel, requests):
|
|
context = _ReplyContext("/unused/passive/path", time.monotonic() - 1, 4, sequence=4)
|
|
channel._replies[("c2c:user-openid", "old-message")] = context
|
|
await notify(channel)
|
|
channel._connection_repo.list_connections.assert_awaited_once_with("owner")
|
|
assert len(requests) == 2
|
|
token, message = requests
|
|
assert json.loads(token.content) == {"appId": "app1", "clientSecret": "fixture-secret"}
|
|
assert str(message.url) == "https://api.sgroup.qq.com/v2/users/user-openid/messages"
|
|
assert message.headers["Authorization"] == "QQBot fixture-token"
|
|
assert message.headers["X-Union-Appid"] == "app1"
|
|
assert message.extensions["timeout"] == {"connect": 15, "read": 15, "write": 15, "pool": 15}
|
|
assert json.loads(message.content) == {"msg_type": 0, "content": "任务已完成。\nOpen DeerFlow for details."}
|
|
assert context.sequence == 4
|
|
assert len(channel._replies) == 1
|
|
|
|
|
|
async def test_notification_waits_for_message_ack():
|
|
sending, release = asyncio.Event(), asyncio.Event()
|
|
|
|
async def handler(request):
|
|
sending.set()
|
|
await release.wait()
|
|
return httpx.Response(200, json={"id": "notification-id"})
|
|
|
|
async with connected(handler) as (channel, requests):
|
|
channel._access_token = "fixture-token"
|
|
channel._token_expires_at = time.monotonic() + 60
|
|
task = asyncio.create_task(notify(channel))
|
|
try:
|
|
await asyncio.wait_for(sending.wait(), 1)
|
|
assert not task.done()
|
|
release.set()
|
|
await task
|
|
assert len(requests) == 1
|
|
finally:
|
|
release.set()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"override",
|
|
[
|
|
{"owner_user_id": "other-owner"},
|
|
{"provider": "feishu"},
|
|
{"status": "revoked"},
|
|
{"external_account_id": "other-user"},
|
|
{"workspace_id": "another-app:c2c"},
|
|
{"workspace_id": "app1:group:group-id", "metadata": {"chat_type": "group"}},
|
|
{"metadata": {"chat_type": "group"}},
|
|
{"metadata": {}},
|
|
],
|
|
)
|
|
async def test_notification_refuses_non_private_or_wrong_owner_binding(override):
|
|
async with connected(bindings=[binding(**override)]) as (channel, requests):
|
|
with pytest.raises(QQAPIError, match="private.*connected"):
|
|
await notify(channel)
|
|
assert requests == []
|
|
|
|
|
|
async def test_notification_rechecks_binding_on_every_attempt():
|
|
async with connected() as (channel, requests):
|
|
await notify(channel)
|
|
channel._connection_repo.list_connections.return_value = []
|
|
with pytest.raises(QQAPIError, match="private.*connected"):
|
|
await notify(channel)
|
|
assert len(requests) == 2
|
|
assert channel._connection_repo.list_connections.await_count == 2
|
|
|
|
|
|
@pytest.mark.parametrize("case", ["lookup-error", "missing-owner", "missing-repo"])
|
|
async def test_missing_binding_verification_parks_without_send(case, caplog):
|
|
async with connected() as (channel, requests):
|
|
owner = "owner"
|
|
if case == "lookup-error":
|
|
channel._connection_repo.list_connections.side_effect = OSError("sensitive-owner-detail")
|
|
elif case == "missing-owner":
|
|
owner = None
|
|
else:
|
|
channel._connection_repo = None
|
|
with pytest.raises(ChannelUnavailable) as caught:
|
|
await notify(channel, owner=owner)
|
|
assert requests == []
|
|
assert "sensitive-owner-detail" not in "".join(traceback.format_exception(caught.value)) + caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize("condition", ["stopped", "not-ready", "listener-ended", "http-closed", "http-missing"])
|
|
async def test_notification_parks_without_live_transport(condition):
|
|
async with connected() as (channel, requests):
|
|
if condition == "stopped":
|
|
channel._running = False
|
|
elif condition == "not-ready":
|
|
channel._ready.clear()
|
|
elif condition == "listener-ended":
|
|
channel._listener.cancel()
|
|
await asyncio.gather(channel._listener, return_exceptions=True)
|
|
elif condition == "http-closed":
|
|
await channel._http.aclose()
|
|
else:
|
|
await channel._http.aclose()
|
|
channel._http = None
|
|
with pytest.raises(ChannelUnavailable):
|
|
await notify(channel)
|
|
assert requests == []
|
|
|
|
|
|
@pytest.mark.parametrize("stage", ["token", "message"])
|
|
@pytest.mark.parametrize("error_type", [httpx.ConnectError, httpx.ReadTimeout])
|
|
async def test_transport_error_parks_without_retry_or_sensitive_details(stage, error_type, caplog):
|
|
def handler(request):
|
|
raise error_type("sensitive-provider-detail", request=request)
|
|
|
|
async with connected(handler) as (channel, requests):
|
|
if stage == "message":
|
|
channel._access_token = "fixture-token"
|
|
channel._token_expires_at = time.monotonic() + 60
|
|
with pytest.raises(ChannelUnavailable) as caught:
|
|
await notify(channel)
|
|
assert len(requests) == 1
|
|
rendered = "".join(traceback.format_exception(caught.value)) + caplog.text
|
|
assert "sensitive-provider-detail" not in rendered
|
|
assert "fixture-secret" not in rendered
|
|
assert "fixture-token" not in rendered
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status", "payload"),
|
|
[
|
|
(403, {"err_code": 40054013, "message": "sensitive-rejection-body"}),
|
|
(429, {"err_code": 40034100, "message": "sensitive-rejection-body"}),
|
|
(200, {"code": 40034105, "id": "not-an-ack", "message": "sensitive-rejection-body"}),
|
|
(200, {"err_code": 40054013, "id": "not-an-ack", "message": "sensitive-rejection-body"}),
|
|
(200, {}),
|
|
(200, {"id": ""}),
|
|
(200, {"id": 12}),
|
|
(200, {"id": " "}),
|
|
],
|
|
)
|
|
async def test_rejection_or_missing_ack_counts_as_failure(status, payload, caplog):
|
|
async with connected(lambda request: httpx.Response(status, json=payload)) as (channel, requests):
|
|
channel._access_token = "fixture-token"
|
|
channel._token_expires_at = time.monotonic() + 60
|
|
with pytest.raises(QQAPIError) as caught:
|
|
await notify(channel)
|
|
assert not isinstance(caught.value, ChannelUnavailable)
|
|
assert len(requests) == 1
|
|
assert "sensitive-rejection-body" not in "".join(traceback.format_exception(caught.value)) + caplog.text
|
|
|
|
|
|
async def test_auth_refresh_keeps_proactive_payload_and_no_passive_budget():
|
|
token_requests, message_requests = [], []
|
|
|
|
def handler(request):
|
|
if request.url.host == "bots.qq.com":
|
|
token_requests.append(request)
|
|
return httpx.Response(200, json={"access_token": f"token-{len(token_requests)}", "expires_in": 7200})
|
|
message_requests.append(request)
|
|
return httpx.Response(401 if len(message_requests) == 1 else 200, json={"id": "notification-id"})
|
|
|
|
async with connected(handler) as (channel, requests):
|
|
await notify(channel)
|
|
assert len(token_requests) == 2
|
|
assert len(message_requests) == 2
|
|
assert message_requests[0].content == message_requests[1].content
|
|
assert message_requests[1].headers["Authorization"] == "QQBot token-2"
|
|
assert not channel._replies
|
|
|
|
|
|
async def test_group_notification_remains_unsupported():
|
|
async with connected() as (channel, requests):
|
|
with pytest.raises(NotImplementedError):
|
|
await channel.send_group_notification_for_owner(owner_user_id="owner", target="group-binding", text_markdown="Done")
|
|
assert requests == []
|
|
|
|
|
|
async def test_notification_cancellation_propagates_without_retry():
|
|
sending = asyncio.Event()
|
|
|
|
async def handler(request):
|
|
sending.set()
|
|
await asyncio.Event().wait()
|
|
|
|
async with connected(handler) as (channel, requests):
|
|
channel._access_token = "fixture-token"
|
|
channel._token_expires_at = time.monotonic() + 60
|
|
task = asyncio.create_task(notify(channel))
|
|
try:
|
|
await asyncio.wait_for(sending.wait(), 1)
|
|
finally:
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
assert len(requests) == 1
|