From 9a97cb94208ae717f6254483a07a3ebd56a8cc5d Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Fri, 2 Oct 2026 23:49:28 +0900 Subject: [PATCH] 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 --- CHANGELOG.md | 9 +++ CHANGELOG_zh.md | 6 ++ backend/app/channels/AGENTS.md | 2 +- backend/app/channels/discord.py | 11 +++- backend/tests/test_channels.py | 8 +-- backend/tests/test_discord_channel.py | 94 +++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b1c2a23a8..c8223722d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 6f4afca18b..695784769d 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -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 diff --git a/backend/app/channels/AGENTS.md b/backend/app/channels/AGENTS.md index 01437f9f01..e09e0afb84 100644 --- a/backend/app/channels/AGENTS.md +++ b/backend/app/channels/AGENTS.md @@ -13,7 +13,7 @@ Bridges external messaging platforms (Feishu, Slack, Telegram, Discord, DingTalk - `base.py` - Abstract `Channel` base class (start/stop/send lifecycle). Provider callbacks that submit coroutines from SDK threads must use `_submit_threadsafe_coroutine()`: it creates and retains the real `asyncio.Task` on the owner loop instead of treating `run_coroutine_threadsafe()`'s proxy Future as a completion signal. Submission is closed atomically with shutdown, and `stop()` must call `_close_and_drain_threadsafe_futures()` before tearing down SDK resources. Blocking SDK teardown in `stop()` (a client `close()`, a thread `join()`) must run off the event loop through `asyncio.to_thread`, since every run and channel on the Gateway shares that loop and `stop()` also serves `POST /api/channels/{name}/restart`; Slack's `SocketModeClient.close()` is a shielded, tracked task so a cancelled stop leaves it running and a retried stop awaits the same close. Anchored by `tests/blocking_io/test_channel_stop_offloop.py`. **Proactive push** (`Channel.send_notification`): used by the scheduled-task notification outbox to push markdown to a bound external identity with no inbound frame. The base default raises `NotImplementedError` (after a `ChannelUnavailable` check when not running) so unsupported providers fail visibly in the outbox. Connectivity failures must raise `ChannelUnavailable` so the delivery worker parks without consuming retries; platform rejections of a well-formed send remain ordinary exceptions. **Only WeCom implements proactive push today** (`wecom.py` via `WSClient.send_message`); the SDK raises `RuntimeError` from that call for a dead socket and for a platform rejection alike, so only the `Reply ack error` prefix counts; other `RuntimeError`s, `ConnectionError`/`TimeoutError`/`OSError` and the `websockets` close exception park. Before sending, the worker re-checks the binding via `resolve_connections` (`ChannelConnectionRepository.list_connections`, owner-scoped, ahead of the channel-liveness check): a target no longer `connected` for the row's owner is finalized with `mark_failed(terminal=True)`; a lookup failure parks. Terminal outbox rows (`sent`/`failed`) ignore later status writes. Scheduler side (`app/scheduler/`): `handle_run_completion` enqueues only after `complete_run` recorded a success or failed outcome; manual triggers, interrupts and recovery-finalized occurrences stay silent. Gateway lifespan wires the enqueue side with the scheduler, then starts `NotificationDeliveryWorker` after the channel service (the scheduler starts first), detaching the enqueue side if delivery cannot start; the worker stops before the channel service. - `wecom.py` - WeCom WebSocket channel; implements `send_notification` for the outbox worker. Tracks `_ws_transport_up` across disconnect/reconnect so transport failures become `ChannelUnavailable` rather than burning the counted retry budget. - `service.py` - Manages lifecycle of all configured channels from `config.yaml`. Shutdown closes manager admission first and keeps transports alive until every manager worker/follow-up watcher has exited. A successful manager stop therefore owns no live handler; if the Gateway's outer timeout cancels shutdown, the service retains its channel objects and global singleton so unfinished resources are not detached and cleanup can be retried. -- `slack.py` / `feishu.py` / `telegram.py` / `discord.py` / `dingtalk.py` - Platform-specific implementations (`feishu.py` tracks the running card `message_id` in memory and patches the same card in place; `telegram.py` accepts inbound text/photos/documents, preserves media captions, hands token-free attachment bytes to the shared upload pipeline, edits the "Working on it..." stream target in place via `editMessageText`, and can optionally send final Markdown replies as Rich Messages through `channels.telegram.rich_messages` — the Rich path only fires when `rich_messages` is on **and** the text contains a rich construct (fenced code, emphasis, a table, a task list, `
`, block math, or a link), so structured command/error replies (none of these) stay plain text and their newlines and `` tokens survive; `discord.py` registers typing loops before inbound handling yields and `_start_typing()` refuses work once `_running` is false; because `stop()` runs on the main loop while typing tasks belong to `_discord_loop`, normal cross-thread cancellation, awaiting, and map cleanup are scheduled there with a bounded wait, while `_run_client()` drains tasks in its `finally` block before an exception/disconnect can make that loop unusable; an already-stopped foreign loop must never have its tasks awaited from the main loop; to that end every outbound cross-loop call (`send` / `send_file` / `_get_channel_or_thread`) goes through `_run_on_discord_loop`, which bounds the await (`DISCORD_OUTBOUND_TIMEOUT_SECONDS` 30s; `DISCORD_UPLOAD_TIMEOUT_SECONDS` 120s for file uploads, whose unbounded-size payload needs room for a slow uplink plus 429 retry-after) and fails fast with a `RuntimeError` when the client loop is missing or not running — a dead client becomes a logged send failure instead of a permanently hung `ChannelManager` worker — and `is_running` reports client-thread aliveness (like `feishu.py`) so `ensure_channel_ready` can restart the channel after `_run_client()` exits on a fatal error; `dingtalk.py` optionally uses AI Card streaming for in-place updates when `card_template_id` is configured, and overrides `receive_file` to download inbound images (`picture`/`richText`) and documents (`file`) by `downloadCode` into the thread uploads bucket, mirroring `feishu.py`) +- `slack.py` / `feishu.py` / `telegram.py` / `discord.py` / `dingtalk.py` - Platform-specific implementations (`feishu.py` tracks the running card `message_id` in memory and patches the same card in place; `telegram.py` accepts inbound text/photos/documents, preserves media captions, hands token-free attachment bytes to the shared upload pipeline, edits the "Working on it..." stream target in place via `editMessageText`, and can optionally send final Markdown replies as Rich Messages through `channels.telegram.rich_messages` — the Rich path only fires when `rich_messages` is on **and** the text contains a rich construct (fenced code, emphasis, a table, a task list, `
`, block math, or a link), so structured command/error replies (none of these) stay plain text and their newlines and `` tokens survive; `discord.py` registers typing loops before inbound handling yields and `_start_typing()` refuses work once `_running` is false; each tick sends the indicator with discord.py 2.x's `await channel.typing()` (`trigger_typing()` was removed in 2.0, and each typing loop logs its first failed tick at WARNING, later ones at DEBUG); because `stop()` runs on the main loop while typing tasks belong to `_discord_loop`, normal cross-thread cancellation, awaiting, and map cleanup are scheduled there with a bounded wait, while `_run_client()` drains tasks in its `finally` block before an exception/disconnect can make that loop unusable; an already-stopped foreign loop must never have its tasks awaited from the main loop; to that end every outbound cross-loop call (`send` / `send_file` / `_get_channel_or_thread`) goes through `_run_on_discord_loop`, which bounds the await (`DISCORD_OUTBOUND_TIMEOUT_SECONDS` 30s; `DISCORD_UPLOAD_TIMEOUT_SECONDS` 120s for file uploads, whose unbounded-size payload needs room for a slow uplink plus 429 retry-after) and fails fast with a `RuntimeError` when the client loop is missing or not running — a dead client becomes a logged send failure instead of a permanently hung `ChannelManager` worker — and `is_running` reports client-thread aliveness (like `feishu.py`) so `ensure_channel_ready` can restart the channel after `_run_client()` exits on a fatal error; `dingtalk.py` optionally uses AI Card streaming for in-place updates when `card_template_id` is configured, and overrides `receive_file` to download inbound images (`picture`/`richText`) and documents (`file`) by `downloadCode` into the thread uploads bucket, mirroring `feishu.py`) - `buzz.py` - Buzz (Nostr relay) implementation: one NIP-42-authenticated websocket, pubkey-allowlist + mention/DM/thread-follow gating, streaming replies via in-place kind-40003 edits; requires the `buzz` dependency extra. Its durable seen-event replay guard uses async `aseen()` / `arecord()` / `aflush()` boundaries: initial JSON reads and coalesced atomic writes run off the Gateway event loop, and `stop()` awaits the final flush before returning. **Subscription model** (operator-facing version in [IM_CHANNEL_CONNECTIONS.md](../../docs/IM_CHANNEL_CONNECTIONS.md#buzz-subscription-model)): the relay fans kind-9 chat events out **only** to channel-scoped subscriptions, proved against a live relay — `REQ {"kinds":[9]}` is accepted and answered with `EOSE` but never receives an event (the connector authenticated and then sat silent forever), `REQ {"kinds":[9],"#h":[uuid]}` works, and a multi-value `#h` matches nothing, so it is strictly one REQ per channel (the same shape as Buzz's own `buzz-acp` harness). Every connection therefore rebuilds three kinds of subscription after NIP-42 auth: `buzz-discovery` (`{"kinds":[39000]}`, a historical query returning exactly the channels this identity belongs to, one stored event each then `EOSE` — adding `#p` returns zero, do not "narrow" it), `buzz-membership` (`{"kinds":[44100,44101],"#p":[us],"since":}`, the relay-signed member-added/member-removed notifications whose `p` tag names the affected member and `h` tag the channel), and one `buzz-chat-` per discovered channel. Chat subscriptions open as each kind-39000 arrives; the discovery `EOSE` is the completeness barrier that retries any that failed and warns when discovery found nothing. A kind-44100 for our pubkey subscribes to the new channel **live** (then re-issues discovery so its name/type reach the DM-detection cache, but only when that channel's metadata is actually missing — an unconditional refresh is how a burst of 44100s multiplied into one discovery pass each); a kind-44101 issues `buzz_nostr.close_frame` for exactly that channel's subscription and drops its metadata. **The membership subscription is scoped to LIVE events** and this is load-bearing: buzz-relay *stores* 44100/44101 and serves history newest-first (default limit 2000), so an unscoped filter replayed the whole membership history on every connect — every stored add read as live, re-running discovery once each (M+1 discovery passes × N stored kind-39000 events per connect, observed live as two `channel discovery complete` lines and one channel logged `` because the 44100 path subscribed before its metadata arrived), re-subscribing channels we have since been removed from, and letting a stored 44101 transiently unsubscribe a channel we are still in. `since` is anchored at the moment the socket opened (`_session_started_at`) minus `MEMBERSHIP_LOOKBACK_SECONDS` (60) of slack, which covers both relay clock skew and a membership change published *during* the connect/auth handshake; the slack can only cost an idempotent replay of the last minute. **A relay `CLOSED` frame is recovered, not merely forgotten** — every subscription on the socket fails silently when dropped, so `_handle_closed` re-issues it, bounded by `MAX_RESUBSCRIBE_ATTEMPTS` (3) per subscription id per connection *and per auth epoch* (`_resubscribe_attempts` is reset on session start, on re-auth, and by `stop()`, so pre-auth rejections — which the auth branch already recovers wholesale — never spend the authenticated session's budget). The first retry is immediate (the common case is a one-off hiccup); later ones back off 1s then 2s, awaited inline in the read loop rather than in a background task that could outlive its own socket. **`auth-required:` before this socket has completed its NIP-42 handshake is the expected bootstrap sequence, not a refusal**, and `_handle_closed` short-circuits it ahead of every recovery path: the connector opens its control REQs immediately in case the relay serves unauthenticated reads, a closed relay answers `auth-required:` plus an `AUTH` challenge, and the auth branch re-opens everything. That case is logged at DEBUG and consumes neither the permanent-refusal branch nor the retry budget (a chat subscription is still dropped from `_chat_subscriptions`, since it genuinely is not subscribed and discovery is what re-opens it). Treating it as permanent produced an operator-facing warning claiming discovery/membership tracking was DOWN in the same run where discovery then completed, every channel was subscribed, and a brand-new channel's kind-44100 was picked up one second later. The boundary is the per-socket `_auth_completed` flag, set once the signed AUTH event has been sent and cleared on session entry, session exit, and `stop()`; the same reason *after* that stays loud, and says the subscription is down until the relay's next AUTH challenge or the next reconnect rather than borrowing the non-auth wording. Then `_is_transient_close` decides whether to retry at all: NIP-01/NIP-42 `auth-required:`/`restricted:`/`blocked:`/`mute:`/`invalid:`/`pow:` prefixes and buzz-relay's own removal/revocation prose are permanent (do not fight the relay over a channel that is no longer ours; a post-auth `auth-required:` is recovered by the AUTH branch, not by re-issuing), `rate-limited:`/`error:`/no-reason-at-all and anything unrecognized are transient — the default resolves toward "keep listening" because going silently deaf is the failure this exists to remove, and the attempt budget bounds a wrong guess. A chat `CLOSED` is only ever recovered for a channel already in `_chat_subscriptions`: a `CLOSED` is relay-supplied, so acting on an unknown one would let a relay induce a subscription just by naming a channel. Every subscription that goes unlistened is logged at WARNING, never INFO. Subscription ids are deterministic per channel precisely so one can be replaced or closed without disturbing the others on the socket, and `_chat_subscriptions` is per-socket state cleared on session end, on re-auth (a pre-auth REQ may have been rejected), and by `stop()`. Three bounds on remote-fed state: `MAX_CACHED_CHANNELS` (512) caps the kind-39000 metadata cache, the watermark map, and the resubscribe-attempt map; `MAX_CHANNEL_SUBSCRIPTIONS` (256, well under buzz-relay's own 1024-per-connection ceiling) caps live chat subscriptions — at the cap new channels are refused and named in a warning rather than evicting a working subscription. **Known bound (documented, not fixed):** the relay caps historical delivery at 2000 events per subscription, newest-first, even with a `since`, so >2000 unread messages in a *single* channel across a disconnect loses the oldest — the relay never sends them and the watermark advances past them. That is the one remaining path that can skip; everything else fails toward replay. **Trust model** (operator-facing version in [IM_CHANNEL_CONNECTIONS.md](../../docs/IM_CHANNEL_CONNECTIONS.md#buzz-trust-model)): every inbound `EVENT` is authenticated at the single `handle_relay_frame` choke point — the NIP-01 id is recomputed from the delivered payload and the BIP-340 Schnorr signature verified against the claimed `pubkey` (`buzz_nostr.verify_event`, pure and total: malformed input returns `False`, never raises) — so `ev["pubkey"]`, the authorization principal for both the allowlist and the `/connect` bind, cannot be forged by a relay the DeerFlow operator does not run. What remains trusted is the *authorship* of kind-39000 channel metadata: any member can sign one, and because per-channel subscriptions are now driven by discovery, a forged kind-39000 has two effects rather than one — it can mark a channel `type: "dm"` (relaxing `require_mention` for that channel) **and** it can induce a chat subscription for a channel of the forger's choosing, since the channels we listen to are exactly the channels we hold metadata for. Neither makes anything be *acted on*: `allowed_users` and per-event signature verification are independent gates, so an induced subscription only means the relay reads its own traffic back to a subscriber that drops it, bounded by `MAX_CHANNEL_SUBSCRIPTIONS` (which refuses rather than evicts, so it cannot displace a real channel). Same for a forged kind-44100, except its `p` tag is re-checked locally so it must at least name us. Closing this needs a configured trusted relay pubkey, which `relay_url` is not. `allowed_users` is deny-by-default (empty = nobody, unlike siblings' empty = everyone), so `start()` logs a WARNING when it is empty and each drop logs at DEBUG. The resubscribe cursor (`since`) is **per channel**, advances only for events that were actually processed, and never past `now + MAX_FUTURE_SKEW_SECONDS`, because it is peer-supplied (`created_at`) and a single future-dated event otherwise made the connector permanently deaf. Per channel rather than global is the safety-critical half: subscriptions are per channel, so one shared cursor is the newest event seen in *any* channel and a busy channel would drag it past a quiet channel's unread messages, skipping them on the next reconnect — measured on a live relay, three channels of one identity sat ~28h apart. Per-channel cursors can only ever cost duplicate delivery (absorbed by the manager's `event_id` dedupe), and an evicted cursor degrades to "no `since`", i.e. the relay's default backlog — both fail toward replay, never toward a miss. Streaming tracks every oversize chunk index (`_stream_targets` for chunk 0, `_stream_tails` for the rest), since the manager republishes cumulative text and reposting `chunks[1:]` per update flooded the channel; all of it is per-connection state cleared by `stop()`, and the remote-fed kind-39000 cache is capped at `MAX_CACHED_CHANNELS`. **`send()` refuses outright to publish text carrying a hidden model-context wrapper** (``, ``, `` — `_HIDDEN_CONTEXT_MARKERS`), logging at ERROR and clearing the stream bookkeeping on a blocked `is_final`. This is defense in depth behind the manager's allowlist, and it lives here rather than in a sibling connector because on Buzz a leak is permanent: every streaming update is an immutable public Nostr event, so a corrective edit only changes what clients render while the original leaked event stays on the relay. Matching is on the literal opening tag, so a reply that merely talks about memory is still published Buzz seen-event shutdown stays bounded and retryable: `aflush()` awaits any in-flight write, attempts at most one final snapshot, leaves a still-changing generation dirty for fail-open replay, and returns without a live persistence timer. A Gateway cancellation does not cancel the underlying worker-thread write; `BuzzChannel.stop()` tracks cleanup completion separately from transport admission so ChannelService can retry the retained channel without racing a second snapshot against that write. The store is quiesced before stop (including the already-stopped guard), so a timed-out relay task that records after stop only marks data dirty and cannot schedule detached file work; a repeated `stop()` still drains that dirty state, while `BuzzChannel.start()` explicitly resumes scheduling and flushes it automatically. - `github.py` - Webhook-driven GitHub channel. Inbound messages come from `POST /api/webhooks/github`; outbound is log-only because GitHub agents post explicitly with `gh` from their sandbox when they choose to comment or create a PR diff --git a/backend/app/channels/discord.py b/backend/app/channels/discord.py index 213f067626..8499a937cb 100644 --- a/backend/app/channels/discord.py +++ b/backend/app/channels/discord.py @@ -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 diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py index 5ffdfe6606..4e9ef1ec5e 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -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): diff --git a/backend/tests/test_discord_channel.py b/backend/tests/test_discord_channel.py index 810f688572..9dd39e77cf 100644 --- a/backend/tests/test_discord_channel.py +++ b/backend/tests/test_discord_channel.py @@ -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,