From ba6c4b748f7c8dcb3ea58ff149475d21d28888b2 Mon Sep 17 00:00:00 2001 From: Anunay Aatipamula Date: Mon, 2 Feb 2026 18:41:17 +0530 Subject: [PATCH 1/4] feat(discord): add Discord channel support - Implement Discord channel functionality with websocket integration. - Update configuration schema to include Discord settings. - Enhance README with setup instructions for Discord integration. - Modify channel manager to initialize Discord channel if enabled. - Update CLI status command to display Discord channel status. --- README.md | 48 ++++++- nanobot/channels/discord.py | 252 ++++++++++++++++++++++++++++++++++++ nanobot/channels/manager.py | 11 ++ nanobot/cli/commands.py | 14 ++ nanobot/config/schema.py | 10 ++ 5 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 nanobot/channels/discord.py diff --git a/README.md b/README.md index 167ae22..9ad90b0 100644 --- a/README.md +++ b/README.md @@ -155,11 +155,12 @@ nanobot agent -m "Hello from my local LLM!" ## 💬 Chat Apps -Talk to your nanobot through Telegram or WhatsApp — anytime, anywhere. +Talk to your nanobot through Telegram, Discord, or WhatsApp — anytime, anywhere. | Channel | Setup | |---------|-------| | **Telegram** | Easy (just a token) | +| **Discord** | Easy (bot token + intents) | | **WhatsApp** | Medium (scan QR) |
@@ -194,6 +195,46 @@ nanobot gateway
+
+Discord + +**1. Create a bot** +- Go to https://discord.com/developers/applications +- Create an application → Bot → Add Bot +- Copy the bot token + +**2. Enable intents** +- In the Bot settings, enable **MESSAGE CONTENT INTENT** +- (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data + +**3. Configure** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} +``` + +**4. Invite the bot** +- OAuth2 → URL Generator +- Scopes: `bot` +- Bot Permissions: `Send Messages`, `Read Message History` +- Open the generated invite URL and add the bot to your server + +**5. Run** + +```bash +nanobot gateway +``` + +
+
WhatsApp @@ -254,6 +295,11 @@ nanobot gateway "token": "123456:ABC...", "allowFrom": ["123456789"] }, + "discord": { + "enabled": false, + "token": "YOUR_DISCORD_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + }, "whatsapp": { "enabled": false } diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py new file mode 100644 index 0000000..124e9cf --- /dev/null +++ b/nanobot/channels/discord.py @@ -0,0 +1,252 @@ +"""Discord channel implementation using Discord Gateway websocket.""" + +import asyncio +import json +from pathlib import Path +from typing import Any + +import httpx +import websockets +from loguru import logger + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import DiscordConfig + + +DISCORD_API_BASE = "https://discord.com/api/v10" +DEFAULT_MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB + + +class DiscordChannel(BaseChannel): + """ + Discord channel using Gateway websocket. + + Handles: + - Gateway connection + heartbeat + - MESSAGE_CREATE events + - REST API for outbound messages + """ + + name = "discord" + + def __init__(self, config: DiscordConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: DiscordConfig = config + self._ws: websockets.WebSocketClientProtocol | None = None + self._seq: int | None = None + self._session_id: str | None = None + self._heartbeat_task: asyncio.Task | None = None + self._http: httpx.AsyncClient | None = None + self._max_attachment_bytes = DEFAULT_MAX_ATTACHMENT_BYTES + + async def start(self) -> None: + """Start the Discord gateway connection.""" + if not self.config.token: + logger.error("Discord bot token not configured") + return + + self._running = True + self._http = httpx.AsyncClient(timeout=30.0) + + while self._running: + try: + logger.info("Connecting to Discord gateway...") + async with websockets.connect(self.config.gateway_url) as ws: + self._ws = ws + await self._gateway_loop() + except asyncio.CancelledError: + break + except Exception as e: + logger.warning(f"Discord gateway error: {e}") + if self._running: + logger.info("Reconnecting to Discord gateway in 5 seconds...") + await asyncio.sleep(5) + + async def stop(self) -> None: + """Stop the Discord channel.""" + self._running = False + if self._heartbeat_task: + self._heartbeat_task.cancel() + self._heartbeat_task = None + if self._ws: + await self._ws.close() + self._ws = None + if self._http: + await self._http.aclose() + self._http = None + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through Discord REST API.""" + if not self._http: + logger.warning("Discord HTTP client not initialized") + return + + url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages" + payload: dict[str, Any] = {"content": msg.content} + + if msg.reply_to: + payload["message_reference"] = {"message_id": msg.reply_to} + payload["allowed_mentions"] = {"replied_user": False} + + headers = {"Authorization": f"Bot {self.config.token}"} + + for attempt in range(3): + try: + response = await self._http.post(url, headers=headers, json=payload) + if response.status_code == 429: + data = response.json() + retry_after = float(data.get("retry_after", 1.0)) + logger.warning(f"Discord rate limited, retrying in {retry_after}s") + await asyncio.sleep(retry_after) + continue + response.raise_for_status() + return + except Exception as e: + if attempt == 2: + logger.error(f"Error sending Discord message: {e}") + else: + await asyncio.sleep(1) + + async def _gateway_loop(self) -> None: + """Main gateway loop: identify, heartbeat, dispatch events.""" + if not self._ws: + return + + async for raw in self._ws: + try: + data = json.loads(raw) + except json.JSONDecodeError: + logger.warning(f"Invalid JSON from Discord gateway: {raw[:100]}") + continue + + op = data.get("op") + event_type = data.get("t") + seq = data.get("s") + payload = data.get("d") + + if seq is not None: + self._seq = seq + + if op == 10: + # HELLO: start heartbeat and identify + interval_ms = payload.get("heartbeat_interval", 45000) + await self._start_heartbeat(interval_ms / 1000) + await self._identify() + elif op == 0 and event_type == "READY": + self._session_id = payload.get("session_id") + logger.info("Discord gateway READY") + elif op == 0 and event_type == "MESSAGE_CREATE": + await self._handle_message_create(payload) + elif op == 7: + # RECONNECT: exit loop to reconnect + logger.info("Discord gateway requested reconnect") + break + elif op == 9: + # INVALID_SESSION: reconnect + logger.warning("Discord gateway invalid session") + break + + async def _identify(self) -> None: + """Send IDENTIFY payload.""" + if not self._ws: + return + + identify = { + "op": 2, + "d": { + "token": self.config.token, + "intents": self.config.intents, + "properties": { + "os": "nanobot", + "browser": "nanobot", + "device": "nanobot", + }, + }, + } + await self._ws.send(json.dumps(identify)) + + async def _start_heartbeat(self, interval_s: float) -> None: + """Start or restart the heartbeat loop.""" + if self._heartbeat_task: + self._heartbeat_task.cancel() + + async def heartbeat_loop() -> None: + while self._running and self._ws: + payload = {"op": 1, "d": self._seq} + try: + await self._ws.send(json.dumps(payload)) + except Exception as e: + logger.warning(f"Discord heartbeat failed: {e}") + break + await asyncio.sleep(interval_s) + + self._heartbeat_task = asyncio.create_task(heartbeat_loop()) + + async def _handle_message_create(self, payload: dict[str, Any]) -> None: + """Handle incoming Discord messages.""" + author = payload.get("author") or {} + if author.get("bot"): + return + + sender_id = str(author.get("id", "")) + channel_id = str(payload.get("channel_id", "")) + content = payload.get("content") or "" + + if not sender_id or not channel_id: + return + + if not self.is_allowed(sender_id): + return + + content_parts = [content] if content else [] + media_paths: list[str] = [] + + attachments = payload.get("attachments") or [] + for attachment in attachments: + url = attachment.get("url") + filename = attachment.get("filename") or "attachment" + size = attachment.get("size") or 0 + if not url or not self._http: + continue + if size and size > self._max_attachment_bytes: + content_parts.append(f"[attachment: {filename} - too large]") + continue + try: + media_dir = Path.home() / ".nanobot" / "media" + media_dir.mkdir(parents=True, exist_ok=True) + safe_name = filename.replace("/", "_") + file_path = media_dir / f"{attachment.get('id', 'file')}_{safe_name}" + response = await self._http.get(url) + response.raise_for_status() + file_path.write_bytes(response.content) + media_paths.append(str(file_path)) + content_parts.append(f"[attachment: {file_path}]") + except Exception as e: + logger.warning(f"Failed to download Discord attachment: {e}") + content_parts.append(f"[attachment: {filename} - download failed]") + + message_id = str(payload.get("id", "")) + guild_id = payload.get("guild_id") + referenced = payload.get("referenced_message") or {} + reply_to_id = referenced.get("id") + + await self._handle_message( + sender_id=sender_id, + chat_id=channel_id, + content="\n".join([p for p in content_parts if p]) or "[empty message]", + media=media_paths, + metadata={ + "message_id": message_id, + "guild_id": guild_id, + "channel_id": channel_id, + "author": { + "id": author.get("id"), + "username": author.get("username"), + "discriminator": author.get("discriminator"), + }, + "mentions": payload.get("mentions", []), + "reply_to": reply_to_id, + }, + ) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 04abf5f..c72068b 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -53,6 +53,17 @@ class ChannelManager: logger.info("WhatsApp channel enabled") except ImportError as e: logger.warning(f"WhatsApp channel not available: {e}") + + # Discord channel + if self.config.channels.discord.enabled: + try: + from nanobot.channels.discord import DiscordChannel + self.channels["discord"] = DiscordChannel( + self.config.channels.discord, self.bus + ) + logger.info("Discord channel enabled") + except ImportError as e: + logger.warning(f"Discord channel not available: {e}") async def start_all(self) -> None: """Start WhatsApp channel and the outbound dispatcher.""" diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 6e37aec..943ab0b 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -362,6 +362,20 @@ def channels_status(): "✓" if wa.enabled else "✗", wa.bridge_url ) + + tg = config.channels.telegram + table.add_row( + "Telegram", + "✓" if tg.enabled else "✗", + "polling" + ) + + dc = config.channels.discord + table.add_row( + "Discord", + "✓" if dc.enabled else "✗", + dc.gateway_url + ) console.print(table) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 0db887e..e73e083 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -19,10 +19,20 @@ class TelegramConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames +class DiscordConfig(BaseModel): + """Discord channel configuration.""" + enabled: bool = False + token: str = "" # Bot token from Discord Developer Portal + allow_from: list[str] = Field(default_factory=list) # Allowed user IDs + gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json" + intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT + + class ChannelsConfig(BaseModel): """Configuration for chat channels.""" whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) + discord: DiscordConfig = Field(default_factory=DiscordConfig) class AgentDefaults(BaseModel): From 884690e3c72d8eca1427c520500a33d2df1dccfc Mon Sep 17 00:00:00 2001 From: Anunay Aatipamula Date: Mon, 2 Feb 2026 18:53:47 +0530 Subject: [PATCH 2/4] docs: update README to include limitations of current implementation - Added section outlining current limitations such as global allowlist, lack of per-guild/channel rules, and restrictions on outbound message types. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 9ad90b0..9247f21 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,11 @@ nanobot gateway } ``` +**Limitations (current implementation)** +- Global allowlist only (`allowFrom`); no `groupPolicy`, `dm.policy`, or per-guild/per-channel rules +- No `requireMention` or per-channel enable/disable +- Outbound messages are text only (no file uploads) + **4. Invite the bot** - OAuth2 → URL Generator - Scopes: `bot` From bab464df5fb7808a19618af07ed928dea5ea880f Mon Sep 17 00:00:00 2001 From: Anunay Aatipamula Date: Mon, 2 Feb 2026 19:01:46 +0530 Subject: [PATCH 3/4] feat(discord): implement typing indicator functionality - Add methods to manage typing indicators in Discord channels. - Introduce periodic typing notifications while sending messages. - Ensure proper cleanup of typing tasks on channel closure. --- nanobot/channels/discord.py | 69 ++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 124e9cf..be7ac9e 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -38,6 +38,7 @@ class DiscordChannel(BaseChannel): self._seq: int | None = None self._session_id: str | None = None self._heartbeat_task: asyncio.Task | None = None + self._typing_tasks: dict[str, asyncio.Task] = {} self._http: httpx.AsyncClient | None = None self._max_attachment_bytes = DEFAULT_MAX_ATTACHMENT_BYTES @@ -70,6 +71,9 @@ class DiscordChannel(BaseChannel): if self._heartbeat_task: self._heartbeat_task.cancel() self._heartbeat_task = None + for task in self._typing_tasks.values(): + task.cancel() + self._typing_tasks.clear() if self._ws: await self._ws.close() self._ws = None @@ -92,22 +96,25 @@ class DiscordChannel(BaseChannel): headers = {"Authorization": f"Bot {self.config.token}"} - for attempt in range(3): - try: - response = await self._http.post(url, headers=headers, json=payload) - if response.status_code == 429: - data = response.json() - retry_after = float(data.get("retry_after", 1.0)) - logger.warning(f"Discord rate limited, retrying in {retry_after}s") - await asyncio.sleep(retry_after) - continue - response.raise_for_status() - return - except Exception as e: - if attempt == 2: - logger.error(f"Error sending Discord message: {e}") - else: - await asyncio.sleep(1) + try: + for attempt in range(3): + try: + response = await self._http.post(url, headers=headers, json=payload) + if response.status_code == 429: + data = response.json() + retry_after = float(data.get("retry_after", 1.0)) + logger.warning(f"Discord rate limited, retrying in {retry_after}s") + await asyncio.sleep(retry_after) + continue + response.raise_for_status() + return + except Exception as e: + if attempt == 2: + logger.error(f"Error sending Discord message: {e}") + else: + await asyncio.sleep(1) + finally: + await self._stop_typing(msg.chat_id) async def _gateway_loop(self) -> None: """Main gateway loop: identify, heartbeat, dispatch events.""" @@ -232,6 +239,8 @@ class DiscordChannel(BaseChannel): referenced = payload.get("referenced_message") or {} reply_to_id = referenced.get("id") + await self._start_typing(channel_id) + await self._handle_message( sender_id=sender_id, chat_id=channel_id, @@ -250,3 +259,31 @@ class DiscordChannel(BaseChannel): "reply_to": reply_to_id, }, ) + + async def _send_typing(self, channel_id: str) -> None: + """Send a typing indicator to Discord.""" + if not self._http: + return + url = f"{DISCORD_API_BASE}/channels/{channel_id}/typing" + headers = {"Authorization": f"Bot {self.config.token}"} + try: + await self._http.post(url, headers=headers) + except Exception as e: + logger.debug(f"Discord typing indicator failed: {e}") + + async def _start_typing(self, channel_id: str) -> None: + """Start periodic typing indicator for a channel.""" + await self._stop_typing(channel_id) + + async def typing_loop() -> None: + while self._running: + await self._send_typing(channel_id) + await asyncio.sleep(8) + + self._typing_tasks[channel_id] = asyncio.create_task(typing_loop()) + + async def _stop_typing(self, channel_id: str) -> None: + """Stop typing indicator for a channel.""" + task = self._typing_tasks.pop(channel_id, None) + if task: + task.cancel() From 8a1d7c76d23cb7dd99f54a82fb89c49c25771591 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Feb 2026 07:04:10 +0000 Subject: [PATCH 4/4] refactor: simplify discord channel and improve setup docs --- README.md | 15 ++++---- nanobot/channels/discord.py | 68 +++++++++++-------------------------- 2 files changed, 27 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 6cded80..4444d5b 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,11 @@ nanobot gateway - In the Bot settings, enable **MESSAGE CONTENT INTENT** - (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data -**3. Configure** +**3. Get your User ID** +- Discord Settings → Advanced → enable **Developer Mode** +- Right-click your avatar → **Copy User ID** + +**4. Configure** ```json { @@ -228,18 +232,13 @@ nanobot gateway } ``` -**Limitations (current implementation)** -- Global allowlist only (`allowFrom`); no `groupPolicy`, `dm.policy`, or per-guild/per-channel rules -- No `requireMention` or per-channel enable/disable -- Outbound messages are text only (no file uploads) - -**4. Invite the bot** +**5. Invite the bot** - OAuth2 → URL Generator - Scopes: `bot` - Bot Permissions: `Send Messages`, `Read Message History` - Open the generated invite URL and add the bot to your server -**5. Run** +**6. Run** ```bash nanobot gateway diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index be7ac9e..a76d6ac 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -16,18 +16,11 @@ from nanobot.config.schema import DiscordConfig DISCORD_API_BASE = "https://discord.com/api/v10" -DEFAULT_MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB +MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB class DiscordChannel(BaseChannel): - """ - Discord channel using Gateway websocket. - - Handles: - - Gateway connection + heartbeat - - MESSAGE_CREATE events - - REST API for outbound messages - """ + """Discord channel using Gateway websocket.""" name = "discord" @@ -36,11 +29,9 @@ class DiscordChannel(BaseChannel): self.config: DiscordConfig = config self._ws: websockets.WebSocketClientProtocol | None = None self._seq: int | None = None - self._session_id: str | None = None self._heartbeat_task: asyncio.Task | None = None self._typing_tasks: dict[str, asyncio.Task] = {} self._http: httpx.AsyncClient | None = None - self._max_attachment_bytes = DEFAULT_MAX_ATTACHMENT_BYTES async def start(self) -> None: """Start the Discord gateway connection.""" @@ -142,7 +133,6 @@ class DiscordChannel(BaseChannel): await self._start_heartbeat(interval_ms / 1000) await self._identify() elif op == 0 and event_type == "READY": - self._session_id = payload.get("session_id") logger.info("Discord gateway READY") elif op == 0 and event_type == "MESSAGE_CREATE": await self._handle_message_create(payload) @@ -209,75 +199,57 @@ class DiscordChannel(BaseChannel): content_parts = [content] if content else [] media_paths: list[str] = [] + media_dir = Path.home() / ".nanobot" / "media" - attachments = payload.get("attachments") or [] - for attachment in attachments: + for attachment in payload.get("attachments") or []: url = attachment.get("url") filename = attachment.get("filename") or "attachment" size = attachment.get("size") or 0 if not url or not self._http: continue - if size and size > self._max_attachment_bytes: + if size and size > MAX_ATTACHMENT_BYTES: content_parts.append(f"[attachment: {filename} - too large]") continue try: - media_dir = Path.home() / ".nanobot" / "media" media_dir.mkdir(parents=True, exist_ok=True) - safe_name = filename.replace("/", "_") - file_path = media_dir / f"{attachment.get('id', 'file')}_{safe_name}" - response = await self._http.get(url) - response.raise_for_status() - file_path.write_bytes(response.content) + file_path = media_dir / f"{attachment.get('id', 'file')}_{filename.replace('/', '_')}" + resp = await self._http.get(url) + resp.raise_for_status() + file_path.write_bytes(resp.content) media_paths.append(str(file_path)) content_parts.append(f"[attachment: {file_path}]") except Exception as e: logger.warning(f"Failed to download Discord attachment: {e}") content_parts.append(f"[attachment: {filename} - download failed]") - message_id = str(payload.get("id", "")) - guild_id = payload.get("guild_id") - referenced = payload.get("referenced_message") or {} - reply_to_id = referenced.get("id") + reply_to = (payload.get("referenced_message") or {}).get("id") await self._start_typing(channel_id) await self._handle_message( sender_id=sender_id, chat_id=channel_id, - content="\n".join([p for p in content_parts if p]) or "[empty message]", + content="\n".join(p for p in content_parts if p) or "[empty message]", media=media_paths, metadata={ - "message_id": message_id, - "guild_id": guild_id, - "channel_id": channel_id, - "author": { - "id": author.get("id"), - "username": author.get("username"), - "discriminator": author.get("discriminator"), - }, - "mentions": payload.get("mentions", []), - "reply_to": reply_to_id, + "message_id": str(payload.get("id", "")), + "guild_id": payload.get("guild_id"), + "reply_to": reply_to, }, ) - async def _send_typing(self, channel_id: str) -> None: - """Send a typing indicator to Discord.""" - if not self._http: - return - url = f"{DISCORD_API_BASE}/channels/{channel_id}/typing" - headers = {"Authorization": f"Bot {self.config.token}"} - try: - await self._http.post(url, headers=headers) - except Exception as e: - logger.debug(f"Discord typing indicator failed: {e}") - async def _start_typing(self, channel_id: str) -> None: """Start periodic typing indicator for a channel.""" await self._stop_typing(channel_id) async def typing_loop() -> None: + url = f"{DISCORD_API_BASE}/channels/{channel_id}/typing" + headers = {"Authorization": f"Bot {self.config.token}"} while self._running: - await self._send_typing(channel_id) + try: + await self._http.post(url, headers=headers) + except Exception: + pass await asyncio.sleep(8) self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())