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