Coverage for integrations / channels / discord_adapter.py: 43.4%
274 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Discord Channel Adapter
4Implements Discord messaging using discord.py library.
5Supports text channels, DMs, threads, embeds, and reactions.
7Features:
8- Text messages
9- Embeds (rich content)
10- Reactions
11- Slash commands
12- DM/Server detection
13- Thread support
14- File attachments
15"""
17from __future__ import annotations
19import asyncio
20import logging
21import os
22from typing import Optional, List, Dict, Any
23from datetime import datetime
25try:
26 import discord
27 from discord import Intents, Message as DiscordMessage, Embed, File
28 from discord.ext import commands
29 HAS_DISCORD = True
30except ImportError:
31 HAS_DISCORD = False
33from .base import (
34 ChannelAdapter,
35 ChannelConfig,
36 ChannelStatus,
37 Message,
38 MessageType,
39 MediaAttachment,
40 SendResult,
41 ChannelConnectionError,
42 ChannelSendError,
43 ChannelRateLimitError,
44)
45from .room_capable import RoomCapableAdapter, UnsupportedRoomError
47logger = logging.getLogger(__name__)
50class DiscordAdapter(ChannelAdapter, RoomCapableAdapter):
51 """
52 Discord messaging adapter.
54 Usage:
55 config = ChannelConfig(token="BOT_TOKEN")
56 adapter = DiscordAdapter(config)
57 adapter.on_message(my_handler)
58 await adapter.start()
60 Room semantics (UNIF-G2 ``RoomCapableAdapter``):
61 Discord text channels — bot is auto-member of every channel
62 in the guilds it has been invited to; ``join_room`` validates
63 access + caches the room id for downstream filtering.
65 Discord voice channels — ``join_room`` opens a ``VoiceClient``
66 via ``voice_channel.connect()`` so the bot is visibly present.
67 The audio-receive path (frames → STT) is wired in
68 ``agent_voice_bridge._tick()`` (UNIF-G3 / W1.2).
70 DMs — raises ``UnsupportedRoomError`` (DMs aren't rooms).
71 """
73 def __init__(self, config: ChannelConfig):
74 if not HAS_DISCORD:
75 raise ImportError(
76 "discord.py not installed. "
77 "Install with: pip install discord.py"
78 )
80 super().__init__(config)
82 # Set up intents
83 intents = Intents.default()
84 intents.message_content = True
85 intents.members = True
86 intents.dm_messages = True
87 intents.guild_messages = True
88 intents.voice_states = True # UNIF-G2: voice room presence + member listing
90 self._bot = commands.Bot(
91 command_prefix=config.extra.get("prefix", "!"),
92 intents=intents,
93 )
94 self._bot_user_id: Optional[int] = None
95 # UNIF-G2: per-room voice-client registry. Keyed by Discord
96 # voice-channel id (int). Populated by ``join_room`` for voice
97 # channels, drained by ``leave_room``. Only voice channels live
98 # here — text channels are presence-by-default.
99 self._voice_clients: Dict[int, Any] = {}
100 # UNIF-G2: text-room "presence" registry. Discord doesn't have
101 # an explicit join handshake for text channels (the bot is a
102 # member of every guild text channel it can see), so we track
103 # the bot's deliberate-presence intent locally for downstream
104 # callers (e.g. message-filtering, leave_room semantics).
105 self._joined_text_rooms: set = set()
106 self._setup_events()
108 @property
109 def name(self) -> str:
110 return "discord"
112 def _setup_events(self) -> None:
113 """Set up Discord event handlers."""
115 @self._bot.event
116 async def on_ready():
117 self._bot_user_id = self._bot.user.id
118 self.status = ChannelStatus.CONNECTED
119 logger.info(f"Discord connected as {self._bot.user.name}#{self._bot.user.discriminator}")
121 @self._bot.event
122 async def on_message(discord_msg: DiscordMessage):
123 # Ignore own messages
124 if discord_msg.author.id == self._bot_user_id:
125 return
127 # Convert and dispatch
128 message = self._convert_message(discord_msg)
129 await self._dispatch_message(message)
131 @self._bot.event
132 async def on_disconnect():
133 self.status = ChannelStatus.DISCONNECTED
134 logger.warning("Discord disconnected")
136 @self._bot.event
137 async def on_error(event, *args, **kwargs):
138 logger.error(f"Discord error in {event}: {args}")
139 self.status = ChannelStatus.ERROR
141 async def connect(self) -> bool:
142 """Connect to Discord using bot token."""
143 if not self.config.token:
144 logger.error("Discord bot token not provided")
145 return False
147 try:
148 # Start bot in background
149 self.status = ChannelStatus.CONNECTING
150 asyncio.create_task(self._bot.start(self.config.token))
152 # Wait for ready
153 for _ in range(30): # 30 second timeout
154 if self.status == ChannelStatus.CONNECTED:
155 return True
156 await asyncio.sleep(1)
158 logger.error("Discord connection timeout")
159 self.status = ChannelStatus.ERROR
160 return False
162 except discord.LoginFailure as e:
163 logger.error(f"Discord login failed: {e}")
164 self.status = ChannelStatus.ERROR
165 return False
166 except Exception as e:
167 logger.error(f"Discord connection error: {e}")
168 self.status = ChannelStatus.ERROR
169 return False
171 async def disconnect(self) -> None:
172 """Disconnect from Discord."""
173 try:
174 await self._bot.close()
175 except Exception as e:
176 logger.error(f"Error disconnecting from Discord: {e}")
177 finally:
178 self.status = ChannelStatus.DISCONNECTED
180 def _convert_message(self, discord_msg: DiscordMessage) -> Message:
181 """Convert Discord message to unified Message format."""
182 # Check if bot is mentioned
183 is_mentioned = self._bot.user in discord_msg.mentions if self._bot.user else False
185 # Process attachments
186 media = []
187 for attachment in discord_msg.attachments:
188 # Determine media type from content type
189 content_type = attachment.content_type or ""
190 if content_type.startswith("image/"):
191 media_type = MessageType.IMAGE
192 elif content_type.startswith("video/"):
193 media_type = MessageType.VIDEO
194 elif content_type.startswith("audio/"):
195 media_type = MessageType.AUDIO
196 else:
197 media_type = MessageType.DOCUMENT
199 media.append(MediaAttachment(
200 type=media_type,
201 url=attachment.url,
202 file_name=attachment.filename,
203 file_size=attachment.size,
204 mime_type=content_type,
205 ))
207 # Determine if group (guild) or DM
208 is_group = discord_msg.guild is not None
210 return Message(
211 id=str(discord_msg.id),
212 channel=self.name,
213 sender_id=str(discord_msg.author.id),
214 sender_name=discord_msg.author.display_name,
215 chat_id=str(discord_msg.channel.id),
216 text=discord_msg.content,
217 media=media,
218 reply_to_id=str(discord_msg.reference.message_id) if discord_msg.reference else None,
219 timestamp=discord_msg.created_at,
220 is_group=is_group,
221 is_bot_mentioned=is_mentioned,
222 raw={
223 "guild_id": str(discord_msg.guild.id) if discord_msg.guild else None,
224 "guild_name": discord_msg.guild.name if discord_msg.guild else None,
225 "channel_name": discord_msg.channel.name if hasattr(discord_msg.channel, 'name') else "DM",
226 },
227 )
229 async def send_message(
230 self,
231 chat_id: str,
232 text: str,
233 reply_to: Optional[str] = None,
234 media: Optional[List[MediaAttachment]] = None,
235 buttons: Optional[List[Dict]] = None,
236 ) -> SendResult:
237 """Send a message to a Discord channel."""
238 try:
239 channel = self._bot.get_channel(int(chat_id))
240 if not channel:
241 # Try fetching if not in cache
242 channel = await self._bot.fetch_channel(int(chat_id))
244 if not channel:
245 return SendResult(success=False, error="Channel not found")
247 # Build embed if buttons provided (Discord uses embeds for rich content)
248 embed = None
249 view = None
250 if buttons:
251 embed, view = self._build_embed_with_buttons(text, buttons)
252 text = None # Text goes in embed
254 # Handle media attachments
255 files = []
256 if media:
257 for m in media:
258 if m.file_path:
259 files.append(File(m.file_path, filename=m.file_name))
261 # Get reference message for reply
262 reference = None
263 if reply_to:
264 try:
265 ref_msg = await channel.fetch_message(int(reply_to))
266 reference = ref_msg
267 except Exception:
268 pass
270 # Send message
271 msg = await channel.send(
272 content=text,
273 embed=embed,
274 files=files if files else None,
275 reference=reference,
276 view=view,
277 )
279 return SendResult(
280 success=True,
281 message_id=str(msg.id),
282 raw={"jump_url": msg.jump_url},
283 )
285 except discord.Forbidden:
286 logger.error(f"Permission denied to send to channel {chat_id}")
287 return SendResult(success=False, error="Permission denied")
288 except discord.HTTPException as e:
289 if e.status == 429: # Rate limited
290 raise ChannelRateLimitError(retry_after=e.retry_after)
291 logger.error(f"Discord HTTP error: {e}")
292 return SendResult(success=False, error=str(e))
293 except Exception as e:
294 logger.error(f"Failed to send Discord message: {e}")
295 return SendResult(success=False, error=str(e))
297 def _build_embed_with_buttons(self, text: str, buttons: List[Dict]) -> tuple:
298 """Build Discord embed and view with buttons."""
299 embed = Embed(description=text)
301 # Create view with buttons
302 view = discord.ui.View()
303 for btn in buttons:
304 if btn.get("url"):
305 # Link button
306 view.add_item(discord.ui.Button(
307 label=btn["text"],
308 url=btn["url"],
309 style=discord.ButtonStyle.link,
310 ))
311 else:
312 # Callback button (would need custom handling)
313 view.add_item(discord.ui.Button(
314 label=btn["text"],
315 custom_id=btn.get("callback_data", btn["text"]),
316 style=discord.ButtonStyle.primary,
317 ))
319 return embed, view
321 async def edit_message(
322 self,
323 chat_id: str,
324 message_id: str,
325 text: str,
326 buttons: Optional[List[Dict]] = None,
327 ) -> SendResult:
328 """Edit an existing Discord message."""
329 try:
330 channel = self._bot.get_channel(int(chat_id))
331 if not channel:
332 channel = await self._bot.fetch_channel(int(chat_id))
334 message = await channel.fetch_message(int(message_id))
336 embed = None
337 view = None
338 if buttons:
339 embed, view = self._build_embed_with_buttons(text, buttons)
340 text = None
342 await message.edit(content=text, embed=embed, view=view)
344 return SendResult(success=True, message_id=message_id)
346 except discord.NotFound:
347 return SendResult(success=False, error="Message not found")
348 except discord.Forbidden:
349 return SendResult(success=False, error="Permission denied")
350 except Exception as e:
351 logger.error(f"Failed to edit Discord message: {e}")
352 return SendResult(success=False, error=str(e))
354 async def delete_message(self, chat_id: str, message_id: str) -> bool:
355 """Delete a Discord message."""
356 try:
357 channel = self._bot.get_channel(int(chat_id))
358 if not channel:
359 channel = await self._bot.fetch_channel(int(chat_id))
361 message = await channel.fetch_message(int(message_id))
362 await message.delete()
363 return True
365 except Exception as e:
366 logger.error(f"Failed to delete Discord message: {e}")
367 return False
369 async def send_typing(self, chat_id: str) -> None:
370 """Send typing indicator."""
371 try:
372 channel = self._bot.get_channel(int(chat_id))
373 if channel:
374 await channel.typing()
375 except Exception:
376 pass
378 async def get_chat_info(self, chat_id: str) -> Optional[Dict[str, Any]]:
379 """Get information about a Discord channel."""
380 try:
381 channel = self._bot.get_channel(int(chat_id))
382 if not channel:
383 channel = await self._bot.fetch_channel(int(chat_id))
385 info = {
386 "id": channel.id,
387 "type": str(channel.type),
388 }
390 if hasattr(channel, 'name'):
391 info["name"] = channel.name
392 if hasattr(channel, 'guild'):
393 info["guild_id"] = channel.guild.id
394 info["guild_name"] = channel.guild.name
396 return info
398 except Exception as e:
399 logger.error(f"Failed to get Discord channel info: {e}")
400 return None
402 async def add_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
403 """Add a reaction to a message."""
404 try:
405 channel = self._bot.get_channel(int(chat_id))
406 if not channel:
407 channel = await self._bot.fetch_channel(int(chat_id))
409 message = await channel.fetch_message(int(message_id))
410 await message.add_reaction(emoji)
411 return True
413 except Exception as e:
414 logger.error(f"Failed to add reaction: {e}")
415 return False
417 async def create_thread(
418 self,
419 chat_id: str,
420 message_id: str,
421 name: str,
422 ) -> Optional[str]:
423 """Create a thread from a message."""
424 try:
425 channel = self._bot.get_channel(int(chat_id))
426 if not channel:
427 channel = await self._bot.fetch_channel(int(chat_id))
429 message = await channel.fetch_message(int(message_id))
430 thread = await message.create_thread(name=name)
431 return str(thread.id)
433 except Exception as e:
434 logger.error(f"Failed to create thread: {e}")
435 return None
437 # ─── UNIF-G2: RoomCapableAdapter implementation ──────────────────
439 async def _resolve_channel(self, room_id: str):
440 """Best-effort channel resolve — cache → REST fallback.
442 Returns the discord channel object or ``None`` if it can't be
443 found (deleted / wrong id / no access). Centralized so
444 ``join_room``/``leave_room``/``list_room_members`` share the
445 same lookup path instead of duplicating ``get_channel``+fallback
446 ladder three times.
447 """
448 try:
449 cid = int(room_id)
450 except (TypeError, ValueError):
451 return None
452 channel = self._bot.get_channel(cid)
453 if channel is not None:
454 return channel
455 try:
456 return await self._bot.fetch_channel(cid)
457 except discord.NotFound:
458 return None
459 except discord.Forbidden:
460 return None
461 except Exception as e:
462 logger.error(f"Discord _resolve_channel({cid}) failed: {e}")
463 return None
465 async def join_room(self, room_id: str,
466 role: str = 'participant') -> bool:
467 """Join a Discord channel/room as the agent's presence.
469 - Text channel → validate access, cache presence intent, return True.
470 - Voice channel → connect via ``VoiceClient`` so the bot is
471 visibly in the call. Audio frames are picked up later by
472 ``agent_voice_bridge._tick()`` (UNIF-G3 / W1.2 wiring).
473 - DM (no guild) → raise ``UnsupportedRoomError``.
474 - Channel not found / forbidden → return False.
476 Idempotent on (room_id) — calling twice does NOT double-join
477 a voice channel; the cached client is returned.
478 """
479 channel = await self._resolve_channel(room_id)
480 if channel is None:
481 logger.warning(
482 "Discord.join_room: channel %s not found / forbidden",
483 room_id)
484 return False
486 if isinstance(channel, discord.DMChannel):
487 raise UnsupportedRoomError(
488 "Discord DMs are not rooms — use send_message for 1:1.")
490 if isinstance(channel, discord.VoiceChannel):
491 cid = int(room_id)
492 existing = self._voice_clients.get(cid)
493 if existing is not None and existing.is_connected():
494 return True
495 try:
496 # UNIF-G7 Producer A: prefer VoiceRecvClient when the
497 # discord-ext-voice-recv lib is installed, so the
498 # HevolveStreamingSink can pipe per-speaker PCM through
499 # the canonical streaming-STT WS server. When the lib
500 # is absent, fall back to the bare connect() call —
501 # voice room PRESENCE behavior is unchanged.
502 connect_kwargs = {'reconnect': True, 'self_deaf': False}
503 try:
504 from .discord_voice_recv_sink import (
505 HAS_VOICE_RECV, VoiceRecvClient,
506 )
507 if HAS_VOICE_RECV and VoiceRecvClient is not None:
508 connect_kwargs['cls'] = VoiceRecvClient
509 except Exception:
510 pass
511 voice_client = await channel.connect(**connect_kwargs)
512 self._voice_clients[cid] = voice_client
513 # Best-effort attach the streaming sink. Returns
514 # False (silent) when the recv lib isn't installed or
515 # the connected client doesn't support listen().
516 try:
517 from .discord_voice_recv_sink import (
518 maybe_attach_recv_sink,
519 )
520 maybe_attach_recv_sink(
521 voice_client, call_id=str(cid),
522 bot_user_id=self._bot_user_id)
523 except Exception as e:
524 logger.debug(
525 "Discord.join_room: sink attach skipped (%s)", e)
526 logger.info(
527 "Discord.join_room: voice channel %s connected (role=%s)",
528 cid, role)
529 return True
530 except discord.ClientException as e:
531 # Already connected from another path — treat as success.
532 logger.info(
533 "Discord.join_room: voice %s already-connected (%s)",
534 cid, e)
535 return True
536 except (discord.opus.OpusNotLoaded, AttributeError) as e:
537 logger.warning(
538 "Discord.join_room: opus library unavailable for "
539 "voice %s (%s); voice presence requires libopus on "
540 "the host", cid, e)
541 return False
542 except Exception as e:
543 logger.error(
544 "Discord.join_room: voice connect failed for %s: %s",
545 cid, e)
546 return False
548 # Text channel / thread / news / forum / stage etc. — Discord
549 # doesn't expose an explicit "join" for non-voice channels, but
550 # we record presence-intent so leave_room is symmetric.
551 self._joined_text_rooms.add(int(room_id))
552 logger.info(
553 "Discord.join_room: text room %s presence cached (role=%s)",
554 room_id, role)
555 return True
557 async def leave_room(self, room_id: str) -> bool:
558 """Leave a Discord channel/room.
560 - Voice channel → disconnect the cached ``VoiceClient``.
561 - Text channel → drop the cached presence intent.
562 - Unknown / never-joined → return False (caller decides whether
563 that's a problem).
564 """
565 try:
566 cid = int(room_id)
567 except (TypeError, ValueError):
568 return False
570 # Voice path
571 voice_client = self._voice_clients.pop(cid, None)
572 if voice_client is not None:
573 try:
574 if voice_client.is_connected():
575 await voice_client.disconnect(force=False)
576 logger.info("Discord.leave_room: voice %s disconnected", cid)
577 return True
578 except Exception as e:
579 logger.error(
580 "Discord.leave_room: voice disconnect %s failed: %s",
581 cid, e)
582 return False
584 # Text path
585 if cid in self._joined_text_rooms:
586 self._joined_text_rooms.discard(cid)
587 logger.info("Discord.leave_room: text %s presence cleared", cid)
588 return True
590 logger.debug("Discord.leave_room: %s not in joined registry", cid)
591 return False
593 async def list_room_members(self, room_id: str) -> List[Dict[str, Any]]:
594 """List members of a Discord channel/voice channel.
596 Text channels — uses ``channel.members`` (requires GUILD_MEMBERS
597 intent, which we declare). Voice channels — uses
598 ``channel.members`` populated from voice-state cache.
600 Returns ``[]`` on any error / unknown channel; callers shouldn't
601 rely on member listing being authoritative.
602 """
603 channel = await self._resolve_channel(room_id)
604 if channel is None:
605 return []
606 members_attr = getattr(channel, 'members', None)
607 if not members_attr:
608 return []
609 result: List[Dict[str, Any]] = []
610 try:
611 for m in members_attr:
612 # Skip the bot itself in member listings — caller asked
613 # who else is here.
614 if m.id == self._bot_user_id:
615 continue
616 result.append({
617 'id': str(m.id),
618 'display_name': getattr(m, 'display_name', None) or m.name,
619 'is_bot': bool(getattr(m, 'bot', False)),
620 })
621 except Exception as e:
622 logger.error(
623 "Discord.list_room_members: iterating %s failed: %s",
624 room_id, e)
625 return []
626 return result
629def create_discord_adapter(token: str = None, **kwargs) -> DiscordAdapter:
630 """
631 Factory function to create Discord adapter.
633 Args:
634 token: Bot token (or set DISCORD_BOT_TOKEN env var)
635 **kwargs: Additional config options
637 Returns:
638 Configured DiscordAdapter
639 """
640 token = token or os.getenv("DISCORD_BOT_TOKEN")
641 if not token:
642 raise ValueError("Discord bot token required")
644 config = ChannelConfig(token=token, **kwargs)
645 return DiscordAdapter(config)