Coverage for agentos/channels/adapters/discord.py: 0%
75 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 21:26 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 21:26 +0800
1"""
2Discord Channel Adapter — Discord.py Gateway.
4Bot Token + Intents → Gateway connection → on_message → ChannelMessage.
5"""
7from __future__ import annotations
9import json
11from agentos.channels.base import BaseChannelAdapter, ChannelConfig, ReplyResult
12from agentos.channels.message import ChannelMessage, ChannelType, MessageType
15class DiscordAdapter(BaseChannelAdapter):
16 """Discord Bot adapter (Gateway Intents).
18 Config fields:
19 bot_token: Discord bot token
20 guild_ids: list of guild/servers to monitor (empty = all)
21 command_prefix: bot command prefix (default "!")
22 dm_enabled: allow DM messages (default True)
23 """
25 CHANNEL_TYPE = ChannelType.DISCORD
27 def __init__(self, config: ChannelConfig):
28 super().__init__(config)
29 self._bot_token = config.extra.get("bot_token", "")
30 self._guild_ids = config.extra.get("guild_ids", [])
31 self._command_prefix = config.extra.get("command_prefix", "!")
32 self._dm_enabled = config.extra.get("dm_enabled", True)
34 # ── Message parsing ──
36 async def parse_incoming(self, payload: dict) -> ChannelMessage | None:
37 """Parse Discord gateway message event into ChannelMessage."""
38 event_type = payload.get("t", "") # Gateway event type
39 data = payload.get("d", {})
41 if event_type == "MESSAGE_CREATE":
42 return self._parse_message(data)
43 elif event_type == "INTERACTION_CREATE":
44 return self._parse_interaction(data)
45 elif event_type == "READY":
46 return ChannelMessage(
47 channel_type=ChannelType.DISCORD,
48 channel_id="system",
49 user_id="system",
50 content=f"Bot ready (guilds: {len(data.get('guilds', []))})",
51 message_type=MessageType.SYSTEM,
52 raw=payload,
53 )
55 return None
57 def _parse_message(self, data: dict) -> ChannelMessage | None:
58 """Parse a Discord Message Create event."""
59 author = data.get("author", {})
60 if author.get("bot", False):
61 return None # Ignore other bots
63 content = data.get("content", "")
64 if not content.strip():
65 return None
67 user_id = author.get("id", "")
68 channel_id = data.get("channel_id", "")
69 guild_id = data.get("guild_id", "")
71 # DM check
72 if not guild_id and not self._dm_enabled:
73 return None
75 # Guild filter
76 if guild_id and self._guild_ids and guild_id not in self._guild_ids:
77 return None
79 # Strip command prefix
80 stripped = content
81 if content.startswith(self._command_prefix):
82 stripped = content[len(self._command_prefix) :]
83 msg_type = MessageType.COMMAND
84 else:
85 msg_type = MessageType.TEXT
87 return ChannelMessage(
88 channel_type=ChannelType.DISCORD,
89 channel_id=channel_id,
90 user_id=user_id,
91 content=stripped.strip(),
92 message_type=msg_type,
93 raw=data,
94 reply_token=data.get("id", ""),
95 metadata={
96 "guild_id": guild_id,
97 "username": author.get("username", ""),
98 "display_name": data.get("member", {}).get("nick", author.get("username", "")),
99 "attachments": [a.get("url") for a in data.get("attachments", [])],
100 },
101 )
103 def _parse_interaction(self, data: dict) -> ChannelMessage | None:
104 """Parse Discord slash command interaction."""
105 interaction_data = data.get("data", {})
106 command_name = interaction_data.get("name", "")
108 user = data.get("user", {}) or data.get("member", {}).get("user", {})
109 user_id = user.get("id", "")
110 channel_id = data.get("channel_id", "")
112 return ChannelMessage(
113 channel_type=ChannelType.DISCORD,
114 channel_id=channel_id,
115 user_id=user_id,
116 content=f"/{command_name} "
117 + " ".join(
118 f"{o.get('name')}:{o.get('value')}" for o in interaction_data.get("options", [])
119 ),
120 message_type=MessageType.COMMAND,
121 raw=data,
122 reply_token=data.get("token", ""),
123 )
125 # ── Reply ──
127 async def reply(self, channel_id: str, content: str, **kwargs) -> ReplyResult:
128 """Send message to Discord channel via REST API."""
129 url = f"https://discord.com/api/v10/channels/{channel_id}/messages"
130 headers = {
131 "Authorization": f"Bot {self._bot_token}",
132 "Content-Type": "application/json",
133 }
134 body = {"content": content[:2000]}
136 if kwargs.get("embed"):
137 body["embeds"] = [kwargs["embed"]]
139 # Interaction follow-up
140 interaction_token = kwargs.get("interaction_token") or kwargs.get("reply_token")
141 if interaction_token:
142 url = f"https://discord.com/api/v10/webhooks/{self._bot_token}/{interaction_token}"
144 try:
145 import aiohttp
147 async with aiohttp.ClientSession() as session:
148 async with session.post(url, headers=headers, json=body) as resp:
149 data = await resp.json()
150 return ReplyResult(success=True, message_id=data.get("id", ""))
151 except ImportError:
152 import urllib.request
154 req = urllib.request.Request(url, data=json.dumps(body).encode(), headers=headers)
155 with urllib.request.urlopen(req) as resp:
156 data = json.loads(resp.read())
157 return ReplyResult(success=True, message_id=data.get("id", ""))
159 async def reply_embed(
160 self,
161 channel_id: str,
162 title: str,
163 description: str,
164 color: int = 0x5865F2,
165 fields: list = None,
166 **kwargs,
167 ) -> ReplyResult:
168 """Send a Discord embed message."""
169 embed = {
170 "title": title,
171 "description": description,
172 "color": color,
173 }
174 if fields:
175 embed["fields"] = fields
176 return await self.reply(channel_id, "", embed=embed, **kwargs)