mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-13 19:06:01 +00:00
1783da42f4
send_file opened the attachment with a bare open() and never closed it, leaking a file descriptor on every Discord file delivery. The handle was also leaked on the failure path: when target.send raised, the except branch logged and returned without closing fp. The "# noqa: SIM115" suppressed the lint warning instead of fixing it. Wrap open() in a with statement that stays open for the full upload — the discord client reads fp while target.send runs on _discord_loop, and once that future resolves the bytes are consumed, so closing here is safe. This closes the handle on both the success and exception paths and matches how telegram and feishu already handle their file uploads. Adds regression tests asserting the handle is closed after send_file on both the success and failure paths. Refs #3544
214 lines
6.7 KiB
Python
214 lines
6.7 KiB
Python
"""Tests for Discord channel integration wiring."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import builtins
|
|
import threading
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from app.channels.discord import DiscordChannel
|
|
from app.channels.manager import CHANNEL_CAPABILITIES
|
|
from app.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment
|
|
from app.channels.service import _CHANNEL_REGISTRY
|
|
|
|
|
|
def test_discord_channel_registered() -> None:
|
|
assert "discord" in _CHANNEL_REGISTRY
|
|
|
|
|
|
def test_discord_channel_capabilities() -> None:
|
|
assert "discord" in CHANNEL_CAPABILITIES
|
|
|
|
|
|
def test_discord_channel_init() -> None:
|
|
bus = MessageBus()
|
|
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
|
|
|
|
assert channel.name == "discord"
|
|
|
|
|
|
def _make_discord_message(text: str):
|
|
return SimpleNamespace(
|
|
id=111,
|
|
content=text,
|
|
author=SimpleNamespace(id=123, bot=False, display_name="alice"),
|
|
guild=SimpleNamespace(id=321),
|
|
channel=SimpleNamespace(id=456),
|
|
add_reaction=lambda _emoji: None,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discord_bot_mention_slash_skill_routes_as_chat() -> None:
|
|
bus = MessageBus()
|
|
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
|
|
captured = []
|
|
channel._running = True
|
|
channel._client = SimpleNamespace(user=SimpleNamespace(id=999, mention="<@999>"))
|
|
channel._discord_module = SimpleNamespace(Thread=type("FakeThread", (), {}))
|
|
channel._publish = captured.append
|
|
|
|
async def noop(*_args, **_kwargs):
|
|
return None
|
|
|
|
channel._start_typing = noop
|
|
channel._add_reaction = noop
|
|
|
|
await channel._on_message(_make_discord_message("<@999> /data-analysis analyze uploads/foo.csv"))
|
|
|
|
assert len(captured) == 1
|
|
inbound = captured[0]
|
|
assert inbound.text == "/data-analysis analyze uploads/foo.csv"
|
|
assert inbound.msg_type == InboundMessageType.CHAT
|
|
assert inbound.topic_id == "456"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_discord_bot_mention_known_command_routes_as_command() -> None:
|
|
bus = MessageBus()
|
|
channel = DiscordChannel(bus=bus, config={"bot_token": "token"})
|
|
captured = []
|
|
channel._running = True
|
|
channel._client = SimpleNamespace(user=SimpleNamespace(id=999, mention="<@999>"))
|
|
channel._discord_module = SimpleNamespace(Thread=type("FakeThread", (), {}))
|
|
channel._publish = captured.append
|
|
|
|
async def noop(*_args, **_kwargs):
|
|
return None
|
|
|
|
channel._start_typing = noop
|
|
channel._add_reaction = noop
|
|
|
|
await channel._on_message(_make_discord_message("<@999> /help"))
|
|
|
|
assert len(captured) == 1
|
|
inbound = captured[0]
|
|
assert inbound.text == "/help"
|
|
assert inbound.msg_type == InboundMessageType.COMMAND
|
|
assert inbound.topic_id == "456"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# send_file file-handle lifecycle
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _start_bg_loop() -> tuple[asyncio.AbstractEventLoop, threading.Thread]:
|
|
"""Spin up a real background event loop, mirroring ``DiscordChannel._discord_loop``.
|
|
|
|
``send_file`` schedules work onto ``_discord_loop`` via
|
|
``run_coroutine_threadsafe`` and awaits the result with ``wrap_future``, so a
|
|
real running loop is the most faithful way to exercise that path.
|
|
"""
|
|
loop = asyncio.new_event_loop()
|
|
ready = threading.Event()
|
|
|
|
def _runner() -> None:
|
|
loop.call_soon(ready.set)
|
|
loop.run_forever()
|
|
|
|
thread = threading.Thread(target=_runner, daemon=True)
|
|
thread.start()
|
|
ready.wait()
|
|
return loop, thread
|
|
|
|
|
|
def _stop_bg_loop(loop: asyncio.AbstractEventLoop, thread: threading.Thread) -> None:
|
|
loop.call_soon_threadsafe(loop.stop)
|
|
thread.join(timeout=5)
|
|
loop.close()
|
|
|
|
|
|
def _build_send_file_channel(bg_loop: asyncio.AbstractEventLoop) -> DiscordChannel:
|
|
channel = DiscordChannel(bus=MessageBus(), config={"bot_token": "token"})
|
|
channel._discord_loop = bg_loop
|
|
channel._discord_module = SimpleNamespace(File=lambda fp, filename=None: fp)
|
|
|
|
async def _noop(*_args, **_kwargs):
|
|
return None
|
|
|
|
channel._stop_typing = _noop
|
|
return channel
|
|
|
|
|
|
def _tracking_open():
|
|
"""Wrap ``builtins.open`` to record every handle it returns."""
|
|
handles: list = []
|
|
real_open = builtins.open
|
|
|
|
def _open(path, *args, **kwargs):
|
|
handle = real_open(path, *args, **kwargs)
|
|
handles.append(handle)
|
|
return handle
|
|
|
|
return handles, _open
|
|
|
|
|
|
async def _noop_coro(*_args, **_kwargs):
|
|
return None
|
|
|
|
|
|
def _resolve_to(target):
|
|
async def _resolve_target(_msg):
|
|
return target
|
|
|
|
return _resolve_target
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_file_closes_file_handle(tmp_path) -> None:
|
|
"""The file handle opened for upload is closed once send_file returns (success path)."""
|
|
bg_loop, bg_thread = _start_bg_loop()
|
|
try:
|
|
channel = _build_send_file_channel(bg_loop)
|
|
target = SimpleNamespace(send=_noop_coro)
|
|
channel._resolve_target = _resolve_to(target)
|
|
|
|
path = tmp_path / "upload.txt"
|
|
path.write_bytes(b"hello")
|
|
att = ResolvedAttachment("/mnt/user-data/outputs/upload.txt", path, "upload.txt", "text/plain", 5, False)
|
|
msg = OutboundMessage(channel_name="discord", chat_id="c1", thread_id="t1", text="t")
|
|
|
|
handles, tracking_open = _tracking_open()
|
|
with patch("builtins.open", tracking_open):
|
|
result = await channel.send_file(msg, att)
|
|
|
|
assert result is True
|
|
assert len(handles) == 1
|
|
assert handles[0].closed is True
|
|
finally:
|
|
_stop_bg_loop(bg_loop, bg_thread)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_file_closes_handle_when_send_fails(tmp_path) -> None:
|
|
"""The file handle is still closed when target.send raises (failure path)."""
|
|
bg_loop, bg_thread = _start_bg_loop()
|
|
try:
|
|
channel = _build_send_file_channel(bg_loop)
|
|
|
|
async def _failing_send(*, file=None):
|
|
raise RuntimeError("upload failed")
|
|
|
|
target = SimpleNamespace(send=_failing_send)
|
|
channel._resolve_target = _resolve_to(target)
|
|
|
|
path = tmp_path / "upload.txt"
|
|
path.write_bytes(b"hello")
|
|
att = ResolvedAttachment("/mnt/user-data/outputs/upload.txt", path, "upload.txt", "text/plain", 5, False)
|
|
msg = OutboundMessage(channel_name="discord", chat_id="c1", thread_id="t1", text="t")
|
|
|
|
handles, tracking_open = _tracking_open()
|
|
with patch("builtins.open", tracking_open):
|
|
result = await channel.send_file(msg, att)
|
|
|
|
assert result is False
|
|
assert len(handles) == 1
|
|
assert handles[0].closed is True
|
|
finally:
|
|
_stop_bg_loop(bg_loop, bg_thread)
|