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

1""" 

2Discord Channel Adapter 

3 

4Implements Discord messaging using discord.py library. 

5Supports text channels, DMs, threads, embeds, and reactions. 

6 

7Features: 

8- Text messages 

9- Embeds (rich content) 

10- Reactions 

11- Slash commands 

12- DM/Server detection 

13- Thread support 

14- File attachments 

15""" 

16 

17from __future__ import annotations 

18 

19import asyncio 

20import logging 

21import os 

22from typing import Optional, List, Dict, Any 

23from datetime import datetime 

24 

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 

32 

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 

46 

47logger = logging.getLogger(__name__) 

48 

49 

50class DiscordAdapter(ChannelAdapter, RoomCapableAdapter): 

51 """ 

52 Discord messaging adapter. 

53 

54 Usage: 

55 config = ChannelConfig(token="BOT_TOKEN") 

56 adapter = DiscordAdapter(config) 

57 adapter.on_message(my_handler) 

58 await adapter.start() 

59 

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. 

64 

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

69 

70 DMs — raises ``UnsupportedRoomError`` (DMs aren't rooms). 

71 """ 

72 

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 ) 

79 

80 super().__init__(config) 

81 

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 

89 

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

107 

108 @property 

109 def name(self) -> str: 

110 return "discord" 

111 

112 def _setup_events(self) -> None: 

113 """Set up Discord event handlers.""" 

114 

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}") 

120 

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 

126 

127 # Convert and dispatch 

128 message = self._convert_message(discord_msg) 

129 await self._dispatch_message(message) 

130 

131 @self._bot.event 

132 async def on_disconnect(): 

133 self.status = ChannelStatus.DISCONNECTED 

134 logger.warning("Discord disconnected") 

135 

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 

140 

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 

146 

147 try: 

148 # Start bot in background 

149 self.status = ChannelStatus.CONNECTING 

150 asyncio.create_task(self._bot.start(self.config.token)) 

151 

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) 

157 

158 logger.error("Discord connection timeout") 

159 self.status = ChannelStatus.ERROR 

160 return False 

161 

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 

170 

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 

179 

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 

184 

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 

198 

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

206 

207 # Determine if group (guild) or DM 

208 is_group = discord_msg.guild is not None 

209 

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 ) 

228 

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

243 

244 if not channel: 

245 return SendResult(success=False, error="Channel not found") 

246 

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 

253 

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

260 

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 

269 

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 ) 

278 

279 return SendResult( 

280 success=True, 

281 message_id=str(msg.id), 

282 raw={"jump_url": msg.jump_url}, 

283 ) 

284 

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

296 

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) 

300 

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

318 

319 return embed, view 

320 

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

333 

334 message = await channel.fetch_message(int(message_id)) 

335 

336 embed = None 

337 view = None 

338 if buttons: 

339 embed, view = self._build_embed_with_buttons(text, buttons) 

340 text = None 

341 

342 await message.edit(content=text, embed=embed, view=view) 

343 

344 return SendResult(success=True, message_id=message_id) 

345 

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

353 

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

360 

361 message = await channel.fetch_message(int(message_id)) 

362 await message.delete() 

363 return True 

364 

365 except Exception as e: 

366 logger.error(f"Failed to delete Discord message: {e}") 

367 return False 

368 

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 

377 

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

384 

385 info = { 

386 "id": channel.id, 

387 "type": str(channel.type), 

388 } 

389 

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 

395 

396 return info 

397 

398 except Exception as e: 

399 logger.error(f"Failed to get Discord channel info: {e}") 

400 return None 

401 

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

408 

409 message = await channel.fetch_message(int(message_id)) 

410 await message.add_reaction(emoji) 

411 return True 

412 

413 except Exception as e: 

414 logger.error(f"Failed to add reaction: {e}") 

415 return False 

416 

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

428 

429 message = await channel.fetch_message(int(message_id)) 

430 thread = await message.create_thread(name=name) 

431 return str(thread.id) 

432 

433 except Exception as e: 

434 logger.error(f"Failed to create thread: {e}") 

435 return None 

436 

437 # ─── UNIF-G2: RoomCapableAdapter implementation ────────────────── 

438 

439 async def _resolve_channel(self, room_id: str): 

440 """Best-effort channel resolve — cache → REST fallback. 

441 

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 

464 

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. 

468 

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. 

475 

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 

485 

486 if isinstance(channel, discord.DMChannel): 

487 raise UnsupportedRoomError( 

488 "Discord DMs are not rooms — use send_message for 1:1.") 

489 

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 

547 

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 

556 

557 async def leave_room(self, room_id: str) -> bool: 

558 """Leave a Discord channel/room. 

559 

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 

569 

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 

583 

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 

589 

590 logger.debug("Discord.leave_room: %s not in joined registry", cid) 

591 return False 

592 

593 async def list_room_members(self, room_id: str) -> List[Dict[str, Any]]: 

594 """List members of a Discord channel/voice channel. 

595 

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. 

599 

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 

627 

628 

629def create_discord_adapter(token: str = None, **kwargs) -> DiscordAdapter: 

630 """ 

631 Factory function to create Discord adapter. 

632 

633 Args: 

634 token: Bot token (or set DISCORD_BOT_TOKEN env var) 

635 **kwargs: Additional config options 

636 

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

643 

644 config = ChannelConfig(token=token, **kwargs) 

645 return DiscordAdapter(config)