refactor: simplify discord channel and improve setup docs

This commit is contained in:
Re-bin 2026-02-06 07:04:10 +00:00
parent 7d2bebcfa3
commit 8a1d7c76d2
2 changed files with 27 additions and 56 deletions

View File

@ -214,7 +214,11 @@ nanobot gateway
- In the Bot settings, enable **MESSAGE CONTENT INTENT** - In the Bot settings, enable **MESSAGE CONTENT INTENT**
- (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data - (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 ```json
{ {
@ -228,18 +232,13 @@ nanobot gateway
} }
``` ```
**Limitations (current implementation)** **5. Invite the bot**
- 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 - OAuth2 → URL Generator
- Scopes: `bot` - Scopes: `bot`
- Bot Permissions: `Send Messages`, `Read Message History` - Bot Permissions: `Send Messages`, `Read Message History`
- Open the generated invite URL and add the bot to your server - Open the generated invite URL and add the bot to your server
**5. Run** **6. Run**
```bash ```bash
nanobot gateway nanobot gateway

View File

@ -16,18 +16,11 @@ from nanobot.config.schema import DiscordConfig
DISCORD_API_BASE = "https://discord.com/api/v10" 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): class DiscordChannel(BaseChannel):
""" """Discord channel using Gateway websocket."""
Discord channel using Gateway websocket.
Handles:
- Gateway connection + heartbeat
- MESSAGE_CREATE events
- REST API for outbound messages
"""
name = "discord" name = "discord"
@ -36,11 +29,9 @@ class DiscordChannel(BaseChannel):
self.config: DiscordConfig = config self.config: DiscordConfig = config
self._ws: websockets.WebSocketClientProtocol | None = None self._ws: websockets.WebSocketClientProtocol | None = None
self._seq: int | None = None self._seq: int | None = None
self._session_id: str | None = None
self._heartbeat_task: asyncio.Task | None = None self._heartbeat_task: asyncio.Task | None = None
self._typing_tasks: dict[str, asyncio.Task] = {} self._typing_tasks: dict[str, asyncio.Task] = {}
self._http: httpx.AsyncClient | None = None self._http: httpx.AsyncClient | None = None
self._max_attachment_bytes = DEFAULT_MAX_ATTACHMENT_BYTES
async def start(self) -> None: async def start(self) -> None:
"""Start the Discord gateway connection.""" """Start the Discord gateway connection."""
@ -142,7 +133,6 @@ class DiscordChannel(BaseChannel):
await self._start_heartbeat(interval_ms / 1000) await self._start_heartbeat(interval_ms / 1000)
await self._identify() await self._identify()
elif op == 0 and event_type == "READY": elif op == 0 and event_type == "READY":
self._session_id = payload.get("session_id")
logger.info("Discord gateway READY") logger.info("Discord gateway READY")
elif op == 0 and event_type == "MESSAGE_CREATE": elif op == 0 and event_type == "MESSAGE_CREATE":
await self._handle_message_create(payload) await self._handle_message_create(payload)
@ -209,75 +199,57 @@ class DiscordChannel(BaseChannel):
content_parts = [content] if content else [] content_parts = [content] if content else []
media_paths: list[str] = [] media_paths: list[str] = []
media_dir = Path.home() / ".nanobot" / "media"
attachments = payload.get("attachments") or [] for attachment in payload.get("attachments") or []:
for attachment in attachments:
url = attachment.get("url") url = attachment.get("url")
filename = attachment.get("filename") or "attachment" filename = attachment.get("filename") or "attachment"
size = attachment.get("size") or 0 size = attachment.get("size") or 0
if not url or not self._http: if not url or not self._http:
continue continue
if size and size > self._max_attachment_bytes: if size and size > MAX_ATTACHMENT_BYTES:
content_parts.append(f"[attachment: {filename} - too large]") content_parts.append(f"[attachment: {filename} - too large]")
continue continue
try: try:
media_dir = Path.home() / ".nanobot" / "media"
media_dir.mkdir(parents=True, exist_ok=True) media_dir.mkdir(parents=True, exist_ok=True)
safe_name = filename.replace("/", "_") file_path = media_dir / f"{attachment.get('id', 'file')}_{filename.replace('/', '_')}"
file_path = media_dir / f"{attachment.get('id', 'file')}_{safe_name}" resp = await self._http.get(url)
response = await self._http.get(url) resp.raise_for_status()
response.raise_for_status() file_path.write_bytes(resp.content)
file_path.write_bytes(response.content)
media_paths.append(str(file_path)) media_paths.append(str(file_path))
content_parts.append(f"[attachment: {file_path}]") content_parts.append(f"[attachment: {file_path}]")
except Exception as e: except Exception as e:
logger.warning(f"Failed to download Discord attachment: {e}") logger.warning(f"Failed to download Discord attachment: {e}")
content_parts.append(f"[attachment: {filename} - download failed]") content_parts.append(f"[attachment: {filename} - download failed]")
message_id = str(payload.get("id", "")) reply_to = (payload.get("referenced_message") or {}).get("id")
guild_id = payload.get("guild_id")
referenced = payload.get("referenced_message") or {}
reply_to_id = referenced.get("id")
await self._start_typing(channel_id) await self._start_typing(channel_id)
await self._handle_message( await self._handle_message(
sender_id=sender_id, sender_id=sender_id,
chat_id=channel_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, media=media_paths,
metadata={ metadata={
"message_id": message_id, "message_id": str(payload.get("id", "")),
"guild_id": guild_id, "guild_id": payload.get("guild_id"),
"channel_id": channel_id, "reply_to": reply_to,
"author": {
"id": author.get("id"),
"username": author.get("username"),
"discriminator": author.get("discriminator"),
},
"mentions": payload.get("mentions", []),
"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: async def _start_typing(self, channel_id: str) -> None:
"""Start periodic typing indicator for a channel.""" """Start periodic typing indicator for a channel."""
await self._stop_typing(channel_id) await self._stop_typing(channel_id)
async def typing_loop() -> None: async def typing_loop() -> None:
url = f"{DISCORD_API_BASE}/channels/{channel_id}/typing"
headers = {"Authorization": f"Bot {self.config.token}"}
while self._running: while self._running:
await self._send_typing(channel_id) try:
await self._http.post(url, headers=headers)
except Exception:
pass
await asyncio.sleep(8) await asyncio.sleep(8)
self._typing_tasks[channel_id] = asyncio.create_task(typing_loop()) self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())