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:
Hyeonsang Cho 2026-10-02 23:49:28 +09:00 committed by GitHub
parent 2625424024
commit 9a97cb9420
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 123 additions and 7 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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,