mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
fix(channels): send Discord typing indicators through the discord.py 2.x API (#6138)
* fix(channels): send Discord typing indicators through the discord.py 2.x API _start_typing() called channel.trigger_typing(), which discord.py removed in 2.0; the project requires discord.py>=2.7.0. The typing loop swallowed every exception, so each tick raised AttributeError and no indicator was ever sent, and the tests never noticed because they mocked the removed method on a SimpleNamespace. Await channel.typing() instead (awaiting the returned Typing sends one indicator), and log a failed tick at DEBUG rather than discarding it. The existing typing-lifecycle tests now mock typing(), and new tests drive _start_typing() against a fake shaped like the 2.x Messageable, which has no trigger_typing. * docs(changelog): reference #6138 * fix(channels): warn on a Discord typing loop's first failed indicator Logging every failed typing tick at DEBUG left a permanently dead indicator invisible at the default INFO level, the same silence that hid the trigger_typing AttributeError. A bot that lost Send Messages in a channel (403 on every tick) or is being rate limited could not be told apart from a working one without enabling DEBUG. Log the first failure of each typing loop at WARNING with the traceback, and later ticks of that loop at DEBUG so a retrying loop cannot flood the log. The level is per loop, so the next reply's loop warns again. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
2625424024
commit
9a97cb9420
@ -460,6 +460,14 @@ This release closes that milestone with **301 merged pull requests**.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **channels:** The Discord typing indicator is now actually sent while the
|
||||
agent works on a reply. `_start_typing()` called `channel.trigger_typing()`, which
|
||||
discord.py removed in 2.0 (the project requires `>=2.7.0`), and its loop
|
||||
swallowed every exception, so each tick raised `AttributeError` and nothing
|
||||
was ever sent. It now awaits `channel.typing()`, the 2.x API that sends one
|
||||
indicator. A typing loop's first failed tick is logged at WARNING (a missing
|
||||
permission or sustained rate limiting is visible at the default level) and
|
||||
later ticks at DEBUG, instead of being dropped. ([#6138])
|
||||
- **community:** The SSRF URL guard no longer stalls the Gateway event loop
|
||||
while it resolves a hostname. `validate_public_http_url` resolves with
|
||||
blocking `socket.getaddrinfo`, and the crawl4ai and Browserless `web_fetch`,
|
||||
@ -7575,5 +7583,6 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
||||
[#6132]: https://github.com/bytedance/deer-flow/pull/6132
|
||||
[#6134]: https://github.com/bytedance/deer-flow/pull/6134
|
||||
[#6135]: https://github.com/bytedance/deer-flow/pull/6135
|
||||
[#6138]: https://github.com/bytedance/deer-flow/pull/6138
|
||||
[#6140]: https://github.com/bytedance/deer-flow/pull/6140
|
||||
|
||||
|
||||
@ -419,6 +419,11 @@
|
||||
|
||||
### 修复
|
||||
|
||||
- **渠道:** Discord 现在会在智能体生成回复期间真正显示"正在输入"提示。`_start_typing()` 调用的
|
||||
`channel.trigger_typing()` 已在 discord.py 2.0 中移除(项目要求 `>=2.7.0`),而其循环吞掉了
|
||||
所有异常,因此每次都抛出 `AttributeError`,提示从未发送。现在改为 await 2.x 的
|
||||
`channel.typing()` 发送一次提示。每个输入提示循环的首次失败以 WARNING 级别记录(缺少权限或持续限流
|
||||
在默认日志级别下即可见),之后的失败以 DEBUG 级别记录,而不是直接丢弃。([#6138])
|
||||
- **社区工具:** SSRF URL 校验在解析主机名时不再阻塞 Gateway 事件循环。
|
||||
`validate_public_http_url` 通过阻塞的 `socket.getaddrinfo` 解析主机名,而
|
||||
crawl4ai 与 Browserless 的 `web_fetch`、`web_capture`、`browser_navigate`、
|
||||
@ -6338,4 +6343,5 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
||||
[#6132]: https://github.com/bytedance/deer-flow/pull/6132
|
||||
[#6134]: https://github.com/bytedance/deer-flow/pull/6134
|
||||
[#6135]: https://github.com/bytedance/deer-flow/pull/6135
|
||||
[#6138]: https://github.com/bytedance/deer-flow/pull/6138
|
||||
[#6140]: https://github.com/bytedance/deer-flow/pull/6140
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -367,12 +367,19 @@ class DiscordChannel(Channel):
|
||||
return # Already typing for this target
|
||||
|
||||
async def _typing_loop():
|
||||
# The loop's first failure logs at WARNING, so an indicator that
|
||||
# never works (missing permission, sustained 429s) shows at the
|
||||
# default level; later ticks drop to DEBUG so retries cannot flood.
|
||||
failure_level = logging.WARNING
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
await channel.trigger_typing()
|
||||
# discord.py 2.x removed ``trigger_typing()``; awaiting
|
||||
# ``typing()`` sends one indicator (~10s on screen).
|
||||
await channel.typing()
|
||||
except Exception:
|
||||
pass
|
||||
logger.log(failure_level, "[Discord] failed to send typing indicator to %s", target_id, exc_info=True)
|
||||
failure_level = logging.DEBUG
|
||||
await asyncio.sleep(10)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@ -6161,7 +6161,7 @@ class TestDiscordChannel:
|
||||
async def go():
|
||||
channel = DiscordChannel(MessageBus(), config={})
|
||||
channel._running = True
|
||||
typing_target = SimpleNamespace(trigger_typing=AsyncMock())
|
||||
typing_target = SimpleNamespace(typing=AsyncMock())
|
||||
|
||||
# Queue the starter without yielding to it. stop() therefore runs
|
||||
# first and must form a boundary that the delayed starter cannot
|
||||
@ -6203,7 +6203,7 @@ class TestDiscordChannel:
|
||||
async def go():
|
||||
channel = DiscordChannel(MessageBus(), config={})
|
||||
channel._running = True
|
||||
typing_target = SimpleNamespace(trigger_typing=AsyncMock())
|
||||
typing_target = SimpleNamespace(typing=AsyncMock())
|
||||
|
||||
discord_loop = asyncio.new_event_loop()
|
||||
loop_ready = threading.Event()
|
||||
@ -6278,7 +6278,7 @@ class TestDiscordChannel:
|
||||
async def go():
|
||||
channel = DiscordChannel(MessageBus(), config={})
|
||||
channel._running = True
|
||||
typing_target = SimpleNamespace(trigger_typing=AsyncMock())
|
||||
typing_target = SimpleNamespace(typing=AsyncMock())
|
||||
|
||||
discord_loop = asyncio.new_event_loop()
|
||||
loop_ready = threading.Event()
|
||||
@ -6331,7 +6331,7 @@ class TestDiscordChannel:
|
||||
|
||||
channel = DiscordChannel(MessageBus(), config={})
|
||||
channel._running = True
|
||||
typing_target = SimpleNamespace(trigger_typing=AsyncMock())
|
||||
typing_target = SimpleNamespace(typing=AsyncMock())
|
||||
|
||||
class FailingClient:
|
||||
def __init__(self):
|
||||
|
||||
@ -6,6 +6,7 @@ import asyncio
|
||||
import builtins
|
||||
import gc
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import weakref
|
||||
@ -160,6 +161,99 @@ async def test_discord_stop_joins_client_thread_off_the_event_loop() -> None:
|
||||
assert channel._thread is None
|
||||
|
||||
|
||||
class _TypingTarget:
|
||||
"""Messageable stand-in shaped like discord.py 2.x.
|
||||
|
||||
discord.py 2.0 removed ``Messageable.trigger_typing()``: ``typing()``
|
||||
returns a ``Typing`` object, and awaiting it sends one indicator. This
|
||||
fake deliberately has no ``trigger_typing``, so code still calling the
|
||||
1.x API fails here the way it fails against the real library.
|
||||
"""
|
||||
|
||||
def __init__(self, error: Exception | None = None) -> None:
|
||||
self.sent = asyncio.Event()
|
||||
self.calls = 0
|
||||
self._error = error
|
||||
|
||||
def typing(self):
|
||||
return self._send()
|
||||
|
||||
async def _send(self) -> None:
|
||||
self.calls += 1
|
||||
self.sent.set()
|
||||
if self._error is not None:
|
||||
raise self._error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_typing_sends_indicator_through_discord_typing_api() -> None:
|
||||
channel = DiscordChannel(bus=MessageBus(), config={"bot_token": "token"})
|
||||
channel._running = True
|
||||
target = _TypingTarget()
|
||||
|
||||
await channel._start_typing(target, "chat-1")
|
||||
try:
|
||||
await asyncio.wait_for(target.sent.wait(), timeout=1)
|
||||
finally:
|
||||
await channel._stop_typing("chat-1")
|
||||
|
||||
assert target.calls == 1
|
||||
|
||||
|
||||
async def _wait_for_typing_calls(target: _TypingTarget, count: int, sleep) -> None:
|
||||
for _ in range(1000):
|
||||
if target.calls >= count:
|
||||
return
|
||||
await sleep(0)
|
||||
raise AssertionError(f"typing loop made {target.calls} calls, expected {count}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_typing_warns_on_first_failure_then_logs_at_debug(caplog, monkeypatch) -> None:
|
||||
"""A loop's first failed indicator logs at WARNING so a permanently dead
|
||||
indicator is visible at the default level; later ticks of the same loop
|
||||
log at DEBUG, and the loop keeps retrying. A new loop warns again."""
|
||||
import app.channels.discord as discord_module
|
||||
|
||||
real_sleep = asyncio.sleep
|
||||
|
||||
async def fast_sleep(_delay):
|
||||
await real_sleep(0)
|
||||
|
||||
# Collapse the 10s tick so several ticks run inside the test.
|
||||
monkeypatch.setattr(discord_module.asyncio, "sleep", fast_sleep)
|
||||
channel = DiscordChannel(bus=MessageBus(), config={"bot_token": "token"})
|
||||
channel._running = True
|
||||
target = _TypingTarget(error=RuntimeError("missing permissions"))
|
||||
|
||||
def failure_records():
|
||||
return [record for record in caplog.records if "failed to send typing indicator" in record.getMessage()]
|
||||
|
||||
with caplog.at_level("DEBUG", logger="app.channels.discord"):
|
||||
await channel._start_typing(target, "chat-1")
|
||||
typing_task = channel._typing_tasks["chat-1"]
|
||||
try:
|
||||
await _wait_for_typing_calls(target, 3, real_sleep)
|
||||
assert not typing_task.done()
|
||||
finally:
|
||||
await channel._stop_typing("chat-1")
|
||||
|
||||
first_loop = failure_records()
|
||||
assert [record.levelno for record in first_loop[:3]] == [logging.WARNING, logging.DEBUG, logging.DEBUG]
|
||||
assert [record.levelno for record in first_loop].count(logging.WARNING) == 1
|
||||
assert first_loop[0].exc_info is not None
|
||||
|
||||
caplog.clear()
|
||||
target.calls = 0
|
||||
await channel._start_typing(target, "chat-1")
|
||||
try:
|
||||
await _wait_for_typing_calls(target, 1, real_sleep)
|
||||
finally:
|
||||
await channel._stop_typing("chat-1")
|
||||
|
||||
assert failure_records()[0].levelno == logging.WARNING
|
||||
|
||||
|
||||
def _make_discord_message(text: str):
|
||||
return SimpleNamespace(
|
||||
id=111,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user