From 8956df3668de0e0b009275aa38d88049535b3cd6 Mon Sep 17 00:00:00 2001 From: Jesse <74103710+95256155o@users.noreply.github.com> Date: Mon, 30 Mar 2026 02:02:43 -0400 Subject: [PATCH] feat(discord): configurable read receipt + subagent working indicator (#2330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(discord): channel-side read receipt and subagent indicator - Add 👀 reaction on message receipt, removed after bot reply - Add 🔧 reaction on first progress message, removed on final reply - Both managed purely in discord.py channel layer, no subagent.py changes - Config: read_receipt_emoji, subagent_emoji with sensible defaults Addresses maintainer feedback on HKUDS/nanobot#2330 Co-Authored-By: Claude Sonnet 4.6 * fix(discord): add both reactions on inbound, not on progress _progress flag is for streaming chunks, not subagent lifecycle. Add 👀 + 🔧 immediately on message receipt, clear both on final reply. * fix: remove stale _subagent_active reference in _clear_reactions * fix(discord): clean up reactions on message handling failure Previously, if _handle_message raised an exception, pending reactions (read receipt + subagent indicator) would remain on the user's message indefinitely since send() — which handles normal cleanup — would never be called. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(discord): replace subagent_emoji with delayed working indicator - Rename subagent_emoji → working_emoji (honest naming: not tied to subagent lifecycle) - Add working_emoji_delay (default 2s) — cosmetic delay so 🔧 appears after 👀, cancelled if bot replies before delay fires - Clean up: cancel pending task + remove both reactions on reply/error Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Sonnet 4.6 --- nanobot/channels/discord.py | 44 +++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index ef7d41d77..9bf4d919c 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -42,6 +42,9 @@ class DiscordConfig(Base): allow_from: list[str] = Field(default_factory=list) intents: int = 37377 group_policy: Literal["mention", "open"] = "mention" + read_receipt_emoji: str = "👀" + working_emoji: str = "🔧" + working_emoji_delay: float = 2.0 if DISCORD_AVAILABLE: @@ -258,6 +261,8 @@ class DiscordChannel(BaseChannel): self._client: DiscordBotClient | None = None self._typing_tasks: dict[str, asyncio.Task[None]] = {} self._bot_user_id: str | None = None + self._pending_reactions: dict[str, Any] = {} # chat_id -> message object + self._working_emoji_tasks: dict[str, asyncio.Task[None]] = {} async def start(self) -> None: """Start the Discord client.""" @@ -305,6 +310,7 @@ class DiscordChannel(BaseChannel): return is_progress = bool((msg.metadata or {}).get("_progress")) + try: await client.send_outbound(msg) except Exception as e: @@ -312,6 +318,7 @@ class DiscordChannel(BaseChannel): finally: if not is_progress: await self._stop_typing(msg.chat_id) + await self._clear_reactions(msg.chat_id) async def _handle_discord_message(self, message: discord.Message) -> None: """Handle incoming Discord messages from discord.py.""" @@ -331,6 +338,24 @@ class DiscordChannel(BaseChannel): await self._start_typing(message.channel) + # Add read receipt reaction immediately, working emoji after delay + channel_id = self._channel_key(message.channel) + try: + await message.add_reaction(self.config.read_receipt_emoji) + self._pending_reactions[channel_id] = message + except Exception as e: + logger.debug("Failed to add read receipt reaction: {}", e) + + # Delayed working indicator (cosmetic — not tied to subagent lifecycle) + async def _delayed_working_emoji() -> None: + await asyncio.sleep(self.config.working_emoji_delay) + try: + await message.add_reaction(self.config.working_emoji) + except Exception: + pass + + self._working_emoji_tasks[channel_id] = asyncio.create_task(_delayed_working_emoji()) + try: await self._handle_message( sender_id=sender_id, @@ -340,6 +365,7 @@ class DiscordChannel(BaseChannel): metadata=metadata, ) except Exception: + await self._clear_reactions(channel_id) await self._stop_typing(channel_id) raise @@ -454,6 +480,24 @@ class DiscordChannel(BaseChannel): except asyncio.CancelledError: pass + + async def _clear_reactions(self, chat_id: str) -> None: + """Remove all pending reactions after bot replies.""" + # Cancel delayed working emoji if it hasn't fired yet + task = self._working_emoji_tasks.pop(chat_id, None) + if task and not task.done(): + task.cancel() + + msg_obj = self._pending_reactions.pop(chat_id, None) + if msg_obj is None: + return + bot_user = self._client.user if self._client else None + for emoji in (self.config.read_receipt_emoji, self.config.working_emoji): + try: + await msg_obj.remove_reaction(emoji, bot_user) + except Exception: + pass + async def _cancel_all_typing(self) -> None: """Stop all typing tasks.""" channel_ids = list(self._typing_tasks)