feat: add Discord channel adapter for channel_hub (#335)#497
Conversation
Add DiscordConnector in channel_hub/adapters/discord.py following the existing adapter pattern. Uses httpx for Discord HTTP API polling with after-id pagination, per-channel rate limiting (5/5s), and graceful error handling. Emits channel-hub messages with source=discord, channel_id, guild_id, and author metadata.
|
Warning Review limit reached
More reviews will be available in 20 minutes and 55 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThis PR adds a complete Discord channel adapter that polls Discord channels for messages, routes them through an agent router, and sends responses back to Discord with formatted content, embeds, and interactive buttons. The implementation handles rate limiting, tracks message pagination per channel, and filters out bot-originated messages. ChangesDiscord Channel Adapter
Sequence Diagram(s)sequenceDiagram
participant Client
participant DiscordConnector
participant DiscordAPI as Discord API
Client->>DiscordConnector: start()
DiscordConnector->>DiscordAPI: GET /users/@me
DiscordAPI-->>DiscordConnector: bot user ID
DiscordConnector->>DiscordConnector: spawn _poll() task
DiscordConnector-->>Client: running
Client->>DiscordConnector: stop()
DiscordConnector->>DiscordConnector: cancel _poll() task
DiscordConnector-->>Client: stopped
flowchart
Poll["_poll() loop running"]
Channels["Iterate channels"]
Fetch["_check_channel(channel_id)"]
Paginate["Fetch with after message_id"]
RateLimit["HTTP 429?"]
Wait["Sleep retry_after"]
Update["Update last_message_id"]
Filter["Skip bot messages"]
Sleep["Sleep poll_interval"]
Poll --> Channels
Channels --> Fetch
Fetch --> Paginate
Paginate --> RateLimit
RateLimit -->|Yes| Wait
Wait --> Paginate
RateLimit -->|No| Update
Update --> Filter
Filter --> Sleep
Sleep --> Channels
sequenceDiagram
participant Discord as Discord API
participant Connector as DiscordConnector
participant Router as Agent Router
Discord->>Connector: message event
Connector->>Connector: _handle_message()
Connector->>Connector: build IncomingMessage
Connector->>Router: route_message()
Router-->>Connector: OutgoingMessage
Connector->>Connector: _send_response()
Connector->>Discord: POST /channels/{id}/messages
Discord-->>Connector: response posted
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
My apologies for the mess here. Some fixes escaped my digital friends :) |
Code Review SummaryStatus: 2 Issues Found | Recommendation: Address before merge Overview
Issue Details (click to expand)WARNING
Other Observations (not in diff)No issues found in unchanged code. Files Reviewed (1 files)
Reviewed by nemotron-3-super-120b-a12b-20230311:free · 75,710 tokens |
- Remove ineffective per-channel semaphore (_channel_sem); channels are polled sequentially so the Semaphore(5) never had concurrent callers. Rate limiting is already handled reactively via the 429/retry_after path. - Raise RuntimeError in start() when bot user ID cannot be resolved (bad token or network error) instead of silently continuing, which would cause the connector to respond to its own messages and loop indefinitely. - Add TestDiscordAdapter tests covering both new start() failure modes and the absence of _channel_sem.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tinyagentos/channel_hub/adapters/discord.py`:
- Around line 228-235: The passthrough POST branch (checking
response.passthrough and response.passthrough_platform == "discord") is missing
the try/except around client.post; wrap the client.post call in a try block and
catch httpx.RequestError, logging the same descriptive error used for normal
sends (e.g., "Failed to send Discord response" plus the exception) and returning
after handling the error; mirror the error handling pattern used in the regular
POST branch (the try/except around client.post) so you use httpx.RequestError
and the same logger (e.g., self.logger or logger) and ensure the function still
returns on success or after logging the error.
- Around line 64-85: The code currently sets self._running = True before
validating the bot ID, which can leave the connector in a running state if
resolving the bot user ID (the HTTP call using httpx and resp.json()) raises a
RuntimeError; move the self._running = True assignment to after successful
resolution of self._bot_user_id and after creating the background task with
asyncio.create_task(self._poll_loop()), so that _running is only True when
_bot_user_id is populated and _task exists (ensure any exceptions raised during
the GET to f"{DISCORD_API_BASE}/users/@me" still propagate and do not leave
_running True).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b2d6148c-2d13-4f6b-9a9b-bd556978c3d3
📒 Files selected for processing (2)
tests/test_channel_hub_new.pytinyagentos/channel_hub/adapters/discord.py
| self._running = True | ||
| # Resolve bot user ID — required to skip the bot's own messages. | ||
| try: | ||
| async with httpx.AsyncClient(timeout=10) as client: | ||
| resp = await client.get( | ||
| f"{DISCORD_API_BASE}/users/@me", headers=self.headers, | ||
| ) | ||
| if resp.status_code == 200: | ||
| self._bot_user_id = resp.json().get("id") | ||
| except Exception as exc: | ||
| raise RuntimeError( | ||
| f"Discord connector for '{self.agent_name}': could not resolve " | ||
| f"bot user ID — check your token. ({exc})" | ||
| ) from exc | ||
|
|
||
| if not self._bot_user_id: | ||
| raise RuntimeError( | ||
| f"Discord connector for '{self.agent_name}': /users/@me returned " | ||
| f"an empty user ID — token may be invalid." | ||
| ) | ||
|
|
||
| self._task = asyncio.create_task(self._poll_loop()) |
There was a problem hiding this comment.
_running set before validation can leave inconsistent state on failure.
If RuntimeError is raised (lines 74 or 80), _running remains True but no task exists. Move the assignment after successful bot ID resolution.
Proposed fix
async def start(self) -> None:
- self._running = True
# Resolve bot user ID — required to skip the bot's own messages.
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{DISCORD_API_BASE}/users/@me", headers=self.headers,
)
if resp.status_code == 200:
self._bot_user_id = resp.json().get("id")
except Exception as exc:
raise RuntimeError(
f"Discord connector for '{self.agent_name}': could not resolve "
f"bot user ID — check your token. ({exc})"
) from exc
if not self._bot_user_id:
raise RuntimeError(
f"Discord connector for '{self.agent_name}': /users/@me returned "
f"an empty user ID — token may be invalid."
)
+ self._running = True
self._task = asyncio.create_task(self._poll_loop())📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| self._running = True | |
| # Resolve bot user ID — required to skip the bot's own messages. | |
| try: | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| resp = await client.get( | |
| f"{DISCORD_API_BASE}/users/@me", headers=self.headers, | |
| ) | |
| if resp.status_code == 200: | |
| self._bot_user_id = resp.json().get("id") | |
| except Exception as exc: | |
| raise RuntimeError( | |
| f"Discord connector for '{self.agent_name}': could not resolve " | |
| f"bot user ID — check your token. ({exc})" | |
| ) from exc | |
| if not self._bot_user_id: | |
| raise RuntimeError( | |
| f"Discord connector for '{self.agent_name}': /users/@me returned " | |
| f"an empty user ID — token may be invalid." | |
| ) | |
| self._task = asyncio.create_task(self._poll_loop()) | |
| # Resolve bot user ID — required to skip the bot's own messages. | |
| try: | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| resp = await client.get( | |
| f"{DISCORD_API_BASE}/users/@me", headers=self.headers, | |
| ) | |
| if resp.status_code == 200: | |
| self._bot_user_id = resp.json().get("id") | |
| except Exception as exc: | |
| raise RuntimeError( | |
| f"Discord connector for '{self.agent_name}': could not resolve " | |
| f"bot user ID — check your token. ({exc})" | |
| ) from exc | |
| if not self._bot_user_id: | |
| raise RuntimeError( | |
| f"Discord connector for '{self.agent_name}': /users/@me returned " | |
| f"an empty user ID — token may be invalid." | |
| ) | |
| self._running = True | |
| self._task = asyncio.create_task(self._poll_loop()) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tinyagentos/channel_hub/adapters/discord.py` around lines 64 - 85, The code
currently sets self._running = True before validating the bot ID, which can
leave the connector in a running state if resolving the bot user ID (the HTTP
call using httpx and resp.json()) raises a RuntimeError; move the self._running
= True assignment to after successful resolution of self._bot_user_id and after
creating the background task with asyncio.create_task(self._poll_loop()), so
that _running is only True when _bot_user_id is populated and _task exists
(ensure any exceptions raised during the GET to f"{DISCORD_API_BASE}/users/@me"
still propagate and do not leave _running True).
| if response.passthrough and response.passthrough_platform == "discord": | ||
| payload = response.passthrough_payload | ||
| await client.post( | ||
| f"{DISCORD_API_BASE}/channels/{channel_id}/messages", | ||
| headers=self.headers, | ||
| json=payload, | ||
| ) | ||
| return |
There was a problem hiding this comment.
Missing error handling for passthrough POST.
The passthrough POST (lines 230-234) lacks the try/except httpx.RequestError that the regular POST has (lines 266-275). A network failure here will bubble up with a generic poll error instead of the descriptive "Failed to send Discord response" log.
Proposed fix
if response.passthrough and response.passthrough_platform == "discord":
payload = response.passthrough_payload
- await client.post(
- f"{DISCORD_API_BASE}/channels/{channel_id}/messages",
- headers=self.headers,
- json=payload,
- )
+ try:
+ await client.post(
+ f"{DISCORD_API_BASE}/channels/{channel_id}/messages",
+ headers=self.headers,
+ json=payload,
+ )
+ except httpx.RequestError as exc:
+ logger.error(
+ "Failed to send Discord passthrough response to %s: %s",
+ channel_id, exc,
+ )
return🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@tinyagentos/channel_hub/adapters/discord.py` around lines 228 - 235, The
passthrough POST branch (checking response.passthrough and
response.passthrough_platform == "discord") is missing the try/except around
client.post; wrap the client.post call in a try block and catch
httpx.RequestError, logging the same descriptive error used for normal sends
(e.g., "Failed to send Discord response" plus the exception) and returning after
handling the error; mirror the error handling pattern used in the regular POST
branch (the try/except around client.post) so you use httpx.RequestError and the
same logger (e.g., self.logger or logger) and ensure the function still returns
on success or after logging the error.
Two test-implementation mismatches from the auto-generated test suite: 1. test_stores_per_channel_semaphores → test_initial_bot_user_id_none Adapter has no _channel_sem (rate limiting is reactive, not semaphore-based). Replaced with test for actual init state attribute. 2. test_start_resolves_bot_user_id_failure Adapter raises RuntimeError on empty _bot_user_id (fail-fast design is correct — can't poll without filtering own messages). Test now expects the RuntimeError instead of asserting graceful degradation.
Summary
Adds a Discord channel adapter that integrates with the channel_hub system, enabling agents to communicate through Discord channels.
Closes #335
Summary by CodeRabbit