refactor: simplify discord channel and improve setup docs
This commit is contained in:
parent
7d2bebcfa3
commit
8a1d7c76d2
15
README.md
15
README.md
@ -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
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user