返回 JoyAI-Echo
discord.py
1 """Discord channel implementation using discord.py."""
2
3 from __future__ import annotations
4
5 import asyncio
6 import importlib.util
7 import time
8 from dataclasses import dataclass
9 from pathlib import Path
10 from typing import TYPE_CHECKING, Any, Literal
11
12 from loguru import logger
13 from pydantic import Field
14
15 from nanobot.bus.events import OutboundMessage
16 from nanobot.bus.queue import MessageBus
17 from nanobot.channels.base import BaseChannel
18 from nanobot.command.builtin import build_help_text
19 from nanobot.config.paths import get_media_dir
20 from nanobot.config.schema import Base
21 from nanobot.utils.helpers import safe_filename, split_message
22
23 DISCORD_AVAILABLE = importlib.util.find_spec("discord") is not None
24 if TYPE_CHECKING:
25 import aiohttp
26 import discord
27 from discord import app_commands
28 from discord.abc import Messageable
29
30 if DISCORD_AVAILABLE:
31 import discord
32 from discord import app_commands
33 from discord.abc import Messageable
34
35 MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB
36 MAX_MESSAGE_LEN = 2000 # Discord message character limit
37 TYPING_INTERVAL_S = 8
38
39
40 @dataclass
41 class _StreamBuf:
42 """Per-chat streaming accumulator for progressive Discord message edits."""
43
44 text: str = ""
45 message: Any | None = None
46 last_edit: float = 0.0
47 stream_id: str | None = None
48
49
50 class DiscordConfig(Base):
51 """Discord channel configuration."""
52
53 enabled: bool = False
54 token: str = ""
55 allow_from: list[str] = Field(default_factory=list)
56 allow_channels: list[str] = Field(default_factory=list) # Allowed channel IDs (empty = all)
57 intents: int = 37377
58 group_policy: Literal["mention", "open"] = "mention"
59 read_receipt_emoji: str = "👀"
60 working_emoji: str = "🔧"
61 working_emoji_delay: float = 2.0
62 streaming: bool = True
63 proxy: str | None = None
64 proxy_username: str | None = None
65 proxy_password: str | None = None
66
67
68 if DISCORD_AVAILABLE:
69
70 class DiscordBotClient(discord.Client):
71 """discord.py client that forwards events to the channel."""
72
73 def __init__(
74 self,
75 channel: DiscordChannel,
76 *,
77 intents: discord.Intents,
78 proxy: str | None = None,
79 proxy_auth: aiohttp.BasicAuth | None = None,
80 ) -> None:
81 super().__init__(intents=intents, proxy=proxy, proxy_auth=proxy_auth)
82 self._channel = channel
83 self.tree = app_commands.CommandTree(self)
84 self._register_app_commands()
85
86 async def on_ready(self) -> None:
87 self._channel._bot_user_id = str(self.user.id) if self.user else None
88 logger.info("Discord bot connected as user {}", self._channel._bot_user_id)
89 try:
90 synced = await self.tree.sync()
91 logger.info("Discord app commands synced: {}", len(synced))
92 except Exception as e:
93 logger.warning("Discord app command sync failed: {}", e)
94
95 async def on_message(self, message: discord.Message) -> None:
96 await self._channel._handle_discord_message(message)
97
98 async def _reply_ephemeral(self, interaction: discord.Interaction, text: str) -> bool:
99 """Send an ephemeral interaction response and report success."""
100 try:
101 await interaction.response.send_message(text, ephemeral=True)
102 return True
103 except Exception as e:
104 logger.warning("Discord interaction response failed: {}", e)
105 return False
106
107 async def _forward_slash_command(
108 self,
109 interaction: discord.Interaction,
110 command_text: str,
111 ) -> None:
112 sender_id = str(interaction.user.id)
113 channel_id = interaction.channel_id
114
115 if channel_id is None:
116 logger.warning("Discord slash command missing channel_id: {}", command_text)
117 return
118
119 if not self._channel.is_allowed(sender_id):
120 await self._reply_ephemeral(interaction, "You are not allowed to use this bot.")
121 return
122
123 await self._reply_ephemeral(interaction, f"Processing {command_text}...")
124
125 await self._channel._handle_message(
126 sender_id=sender_id,
127 chat_id=str(channel_id),
128 content=command_text,
129 metadata={
130 "interaction_id": str(interaction.id),
131 "guild_id": str(interaction.guild_id) if interaction.guild_id else None,
132 "is_slash_command": True,
133 },
134 )
135
136 def _register_app_commands(self) -> None:
137 commands = (
138 ("new", "Stop current task and start a new conversation", "/new"),
139 ("stop", "Stop the current task", "/stop"),
140 ("restart", "Restart the bot", "/restart"),
141 ("status", "Show bot status", "/status"),
142 )
143
144 for name, description, command_text in commands:
145
146 @self.tree.command(name=name, description=description)
147 async def command_handler(
148 interaction: discord.Interaction,
149 _command_text: str = command_text,
150 ) -> None:
151 await self._forward_slash_command(interaction, _command_text)
152
153 @self.tree.command(name="help", description="Show available commands")
154 async def help_command(interaction: discord.Interaction) -> None:
155 sender_id = str(interaction.user.id)
156 if not self._channel.is_allowed(sender_id):
157 await self._reply_ephemeral(interaction, "You are not allowed to use this bot.")
158 return
159 await self._reply_ephemeral(interaction, build_help_text())
160
161 @self.tree.error
162 async def on_app_command_error(
163 interaction: discord.Interaction,
164 error: app_commands.AppCommandError,
165 ) -> None:
166 command_name = interaction.command.qualified_name if interaction.command else "?"
167 logger.warning(
168 "Discord app command failed user={} channel={} cmd={} error={}",
169 interaction.user.id,
170 interaction.channel_id,
171 command_name,
172 error,
173 )
174
175 async def send_outbound(self, msg: OutboundMessage) -> None:
176 """Send a nanobot outbound message using Discord transport rules."""
177 channel_id = int(msg.chat_id)
178
179 channel = self.get_channel(channel_id)
180 if channel is None:
181 try:
182 channel = await self.fetch_channel(channel_id)
183 except Exception as e:
184 logger.warning("Discord channel {} unavailable: {}", msg.chat_id, e)
185 return
186
187 reference, mention_settings = self._build_reply_context(channel, msg.reply_to)
188 sent_media = False
189 failed_media: list[str] = []
190
191 for index, media_path in enumerate(msg.media or []):
192 if await self._send_file(
193 channel,
194 media_path,
195 reference=reference if index == 0 else None,
196 mention_settings=mention_settings,
197 ):
198 sent_media = True
199 else:
200 failed_media.append(Path(media_path).name)
201
202 for index, chunk in enumerate(
203 self._build_chunks(msg.content or "", failed_media, sent_media)
204 ):
205 kwargs: dict[str, Any] = {"content": chunk}
206 if index == 0 and reference is not None and not sent_media:
207 kwargs["reference"] = reference
208 kwargs["allowed_mentions"] = mention_settings
209 await channel.send(**kwargs)
210
211 async def _send_file(
212 self,
213 channel: Messageable,
214 file_path: str,
215 *,
216 reference: discord.PartialMessage | None,
217 mention_settings: discord.AllowedMentions,
218 ) -> bool:
219 """Send a file attachment via discord.py."""
220 path = Path(file_path)
221 if not path.is_file():
222 logger.warning("Discord file not found, skipping: {}", file_path)
223 return False
224
225 if path.stat().st_size > MAX_ATTACHMENT_BYTES:
226 logger.warning("Discord file too large (>20MB), skipping: {}", path.name)
227 return False
228
229 try:
230 kwargs: dict[str, Any] = {"file": discord.File(path)}
231 if reference is not None:
232 kwargs["reference"] = reference
233 kwargs["allowed_mentions"] = mention_settings
234 await channel.send(**kwargs)
235 logger.info("Discord file sent: {}", path.name)
236 return True
237 except Exception as e:
238 logger.error("Error sending Discord file {}: {}", path.name, e)
239 return False
240
241 @staticmethod
242 def _build_chunks(content: str, failed_media: list[str], sent_media: bool) -> list[str]:
243 """Build outbound text chunks, including attachment-failure fallback text."""
244 chunks = split_message(content, MAX_MESSAGE_LEN)
245 if chunks or not failed_media or sent_media:
246 return chunks
247 fallback = "\n".join(f"[attachment: {name} - send failed]" for name in failed_media)
248 return split_message(fallback, MAX_MESSAGE_LEN)
249
250 @staticmethod
251 def _build_reply_context(
252 channel: Messageable,
253 reply_to: str | None,
254 ) -> tuple[discord.PartialMessage | None, discord.AllowedMentions]:
255 """Build reply context for outbound messages."""
256 mention_settings = discord.AllowedMentions(replied_user=False)
257 if not reply_to:
258 return None, mention_settings
259 try:
260 message_id = int(reply_to)
261 except ValueError:
262 logger.warning("Invalid Discord reply target: {}", reply_to)
263 return None, mention_settings
264
265 return channel.get_partial_message(message_id), mention_settings
266
267
268 class DiscordChannel(BaseChannel):
269 """Discord channel using discord.py."""
270
271 name = "discord"
272 display_name = "Discord"
273 _STREAM_EDIT_INTERVAL = 0.8
274
275 @classmethod
276 def default_config(cls) -> dict[str, Any]:
277 return DiscordConfig().model_dump(by_alias=True)
278
279 @staticmethod
280 def _channel_key(channel_or_id: Any) -> str:
281 """Normalize channel-like objects and ids to a stable string key."""
282 channel_id = getattr(channel_or_id, "id", channel_or_id)
283 return str(channel_id)
284
285 def __init__(self, config: Any, bus: MessageBus):
286 if isinstance(config, dict):
287 config = DiscordConfig.model_validate(config)
288 super().__init__(config, bus)
289 self.config: DiscordConfig = config
290 self._client: DiscordBotClient | None = None
291 self._typing_tasks: dict[str, asyncio.Task[None]] = {}
292 self._bot_user_id: str | None = None
293 self._pending_reactions: dict[str, Any] = {} # chat_id -> message object
294 self._working_emoji_tasks: dict[str, asyncio.Task[None]] = {}
295 self._stream_bufs: dict[str, _StreamBuf] = {}
296
297 async def start(self) -> None:
298 """Start the Discord client."""
299 if not DISCORD_AVAILABLE:
300 logger.error("discord.py not installed. Run: pip install echo-director-agent[discord]")
301 return
302
303 if not self.config.token:
304 logger.error("Discord bot token not configured")
305 return
306
307 try:
308 intents = discord.Intents.none()
309 intents.value = self.config.intents
310
311 proxy_auth = None
312 has_user = bool(self.config.proxy_username)
313 has_pass = bool(self.config.proxy_password)
314 if has_user and has_pass:
315 import aiohttp
316
317 proxy_auth = aiohttp.BasicAuth(
318 login=self.config.proxy_username,
319 password=self.config.proxy_password,
320 )
321 elif has_user != has_pass:
322 logger.warning(
323 "Discord proxy auth incomplete: both proxy_username and "
324 "proxy_password must be set; ignoring partial credentials",
325 )
326
327 self._client = DiscordBotClient(
328 self,
329 intents=intents,
330 proxy=self.config.proxy,
331 proxy_auth=proxy_auth,
332 )
333 except Exception as e:
334 logger.error("Failed to initialize Discord client: {}", e)
335 self._client = None
336 self._running = False
337 return
338
339 self._running = True
340 logger.info("Starting Discord client via discord.py...")
341
342 try:
343 await self._client.start(self.config.token)
344 except asyncio.CancelledError:
345 raise
346 except Exception as e:
347 logger.error("Discord client startup failed: {}", e)
348 finally:
349 self._running = False
350 await self._reset_runtime_state(close_client=True)
351
352 async def stop(self) -> None:
353 """Stop the Discord channel."""
354 self._running = False
355 await self._reset_runtime_state(close_client=True)
356
357 async def send(self, msg: OutboundMessage) -> None:
358 """Send a message through Discord using discord.py."""
359 client = self._client
360 if client is None or not client.is_ready():
361 logger.warning("Discord client not ready; dropping outbound message")
362 return
363
364 is_progress = bool((msg.metadata or {}).get("_progress"))
365
366 try:
367 await client.send_outbound(msg)
368 except Exception as e:
369 logger.error("Error sending Discord message: {}", e)
370 raise
371 finally:
372 if not is_progress:
373 await self._stop_typing(msg.chat_id)
374 await self._clear_reactions(msg.chat_id)
375
376 async def send_delta(
377 self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
378 ) -> None:
379 """Progressive Discord delivery: send once, then edit until the stream ends."""
380 client = self._client
381 if client is None or not client.is_ready():
382 logger.warning("Discord client not ready; dropping stream delta")
383 return
384
385 meta = metadata or {}
386 stream_id = meta.get("_stream_id")
387
388 if meta.get("_stream_end"):
389 buf = self._stream_bufs.get(chat_id)
390 if not buf or buf.message is None or not buf.text:
391 return
392 if stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id:
393 return
394 await self._finalize_stream(chat_id, buf)
395 return
396
397 buf = self._stream_bufs.get(chat_id)
398 if buf is None or (
399 stream_id is not None and buf.stream_id is not None and buf.stream_id != stream_id
400 ):
401 buf = _StreamBuf(stream_id=stream_id)
402 self._stream_bufs[chat_id] = buf
403 elif buf.stream_id is None:
404 buf.stream_id = stream_id
405
406 buf.text += delta
407 if not buf.text.strip():
408 return
409
410 target = await self._resolve_channel(chat_id)
411 if target is None:
412 logger.warning("Discord stream target {} unavailable", chat_id)
413 return
414
415 now = time.monotonic()
416 if buf.message is None:
417 try:
418 buf.message = await target.send(content=buf.text)
419 buf.last_edit = now
420 except Exception as e:
421 logger.warning("Discord stream initial send failed: {}", e)
422 raise
423 return
424
425 if (now - buf.last_edit) < self._STREAM_EDIT_INTERVAL:
426 return
427
428 try:
429 await buf.message.edit(content=DiscordBotClient._build_chunks(buf.text, [], False)[0])
430 buf.last_edit = now
431 except Exception as e:
432 logger.warning("Discord stream edit failed: {}", e)
433 raise
434
435 async def _handle_discord_message(self, message: discord.Message) -> None:
436 """Handle incoming Discord messages from discord.py.
437
438 Self-loop guard: only drop messages from this bot's own account. Messages
439 from other bots are allowed through so multi-agent setups (one bot asking
440 another for help, a bot mentioning another by @name, etc.) can work.
441 Bot-from-bot loops are still prevented per-instance because each bot
442 still ignores its own outbound messages. (#3217)
443 """
444 if self._bot_user_id is not None and str(message.author.id) == self._bot_user_id:
445 return
446
447 sender_id = str(message.author.id)
448 channel_id = self._channel_key(message.channel)
449 content = message.content or ""
450
451 if not self._should_accept_inbound(message, sender_id, content):
452 return
453
454 media_paths, attachment_markers = await self._download_attachments(message.attachments)
455 full_content = self._compose_inbound_content(content, attachment_markers)
456 metadata = self._build_inbound_metadata(message)
457
458 await self._start_typing(message.channel)
459
460 # Add read receipt reaction immediately, working emoji after delay
461 try:
462 await message.add_reaction(self.config.read_receipt_emoji)
463 self._pending_reactions[channel_id] = message
464 except Exception as e:
465 logger.debug("Failed to add read receipt reaction: {}", e)
466
467 # Delayed working indicator (cosmetic — not tied to subagent lifecycle)
468 async def _delayed_working_emoji() -> None:
469 await asyncio.sleep(self.config.working_emoji_delay)
470 try:
471 await message.add_reaction(self.config.working_emoji)
472 except Exception:
473 pass
474
475 self._working_emoji_tasks[channel_id] = asyncio.create_task(_delayed_working_emoji())
476
477 try:
478 await self._handle_message(
479 sender_id=sender_id,
480 chat_id=channel_id,
481 content=full_content,
482 media=media_paths,
483 metadata=metadata,
484 )
485 except Exception:
486 await self._clear_reactions(channel_id)
487 await self._stop_typing(channel_id)
488 raise
489
490 async def _on_message(self, message: discord.Message) -> None:
491 """Backward-compatible alias for legacy tests/callers."""
492 await self._handle_discord_message(message)
493
494 async def _resolve_channel(self, chat_id: str) -> Any | None:
495 """Resolve a Discord channel from cache first, then network fetch."""
496 client = self._client
497 if client is None or not client.is_ready():
498 return None
499 channel_id = int(chat_id)
500 channel = client.get_channel(channel_id)
501 if channel is not None:
502 return channel
503 try:
504 return await client.fetch_channel(channel_id)
505 except Exception as e:
506 logger.warning("Discord channel {} unavailable: {}", chat_id, e)
507 return None
508
509 async def _finalize_stream(self, chat_id: str, buf: _StreamBuf) -> None:
510 """Commit the final streamed content and flush overflow chunks."""
511 chunks = DiscordBotClient._build_chunks(buf.text, [], False)
512 if not chunks:
513 self._stream_bufs.pop(chat_id, None)
514 return
515
516 try:
517 await buf.message.edit(content=chunks[0])
518 except Exception as e:
519 logger.warning("Discord final stream edit failed: {}", e)
520 raise
521
522 target = getattr(buf.message, "channel", None) or await self._resolve_channel(chat_id)
523 if target is None:
524 logger.warning("Discord stream follow-up target {} unavailable", chat_id)
525 self._stream_bufs.pop(chat_id, None)
526 return
527
528 for extra_chunk in chunks[1:]:
529 await target.send(content=extra_chunk)
530
531 self._stream_bufs.pop(chat_id, None)
532 await self._stop_typing(chat_id)
533 await self._clear_reactions(chat_id)
534
535 def _should_accept_inbound(
536 self,
537 message: discord.Message,
538 sender_id: str,
539 content: str,
540 ) -> bool:
541 """Check if inbound Discord message should be processed."""
542 if not self.is_allowed(sender_id):
543 return False
544 # Channel-based filtering: only respond in allowed channels
545 allow_channels = self.config.allow_channels
546 if allow_channels:
547 channel_id = self._channel_key(message.channel)
548 if channel_id not in allow_channels:
549 return False
550 if message.guild is not None and not self._should_respond_in_group(message, content):
551 return False
552 return True
553
554 async def _download_attachments(
555 self,
556 attachments: list[discord.Attachment],
557 ) -> tuple[list[str], list[str]]:
558 """Download supported attachments and return paths + display markers."""
559 media_paths: list[str] = []
560 markers: list[str] = []
561 media_dir = get_media_dir("discord")
562
563 for attachment in attachments:
564 filename = attachment.filename or "attachment"
565 if attachment.size and attachment.size > MAX_ATTACHMENT_BYTES:
566 markers.append(f"[attachment: {filename} - too large]")
567 continue
568 try:
569 media_dir.mkdir(parents=True, exist_ok=True)
570 safe_name = safe_filename(filename)
571 file_path = media_dir / f"{attachment.id}_{safe_name}"
572 await attachment.save(file_path)
573 media_paths.append(str(file_path))
574 markers.append(f"[attachment: {file_path.name}]")
575 except Exception as e:
576 logger.warning("Failed to download Discord attachment: {}", e)
577 markers.append(f"[attachment: {filename} - download failed]")
578
579 return media_paths, markers
580
581 @staticmethod
582 def _compose_inbound_content(content: str, attachment_markers: list[str]) -> str:
583 """Combine message text with attachment markers."""
584 content_parts = [content] if content else []
585 content_parts.extend(attachment_markers)
586 return "\n".join(part for part in content_parts if part) or "[empty message]"
587
588 @staticmethod
589 def _build_inbound_metadata(message: discord.Message) -> dict[str, str | None]:
590 """Build metadata for inbound Discord messages."""
591 reply_to = (
592 str(message.reference.message_id)
593 if message.reference and message.reference.message_id
594 else None
595 )
596 return {
597 "message_id": str(message.id),
598 "guild_id": str(message.guild.id) if message.guild else None,
599 "reply_to": reply_to,
600 }
601
602 def _should_respond_in_group(self, message: discord.Message, content: str) -> bool:
603 """Check if the bot should respond in a guild channel based on policy."""
604 if self.config.group_policy == "open":
605 return True
606
607 if self.config.group_policy == "mention":
608 bot_user_id = self._bot_user_id
609 if bot_user_id is None:
610 logger.debug(
611 "Discord message in {} ignored (bot identity unavailable)", message.channel.id
612 )
613 return False
614
615 if any(str(user.id) == bot_user_id for user in message.mentions):
616 return True
617 if f"<@{bot_user_id}>" in content or f"<@!{bot_user_id}>" in content:
618 return True
619
620 logger.debug("Discord message in {} ignored (bot not mentioned)", message.channel.id)
621 return False
622
623 return True
624
625 async def _start_typing(self, channel: Messageable) -> None:
626 """Start periodic typing indicator for a channel."""
627 channel_id = self._channel_key(channel)
628 await self._stop_typing(channel_id)
629
630 async def typing_loop() -> None:
631 while self._running:
632 try:
633 async with channel.typing():
634 await asyncio.sleep(TYPING_INTERVAL_S)
635 except asyncio.CancelledError:
636 return
637 except Exception as e:
638 logger.debug("Discord typing indicator failed for {}: {}", channel_id, e)
639 return
640
641 self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())
642
643 async def _stop_typing(self, channel_id: str) -> None:
644 """Stop typing indicator for a channel."""
645 task = self._typing_tasks.pop(self._channel_key(channel_id), None)
646 if task is None:
647 return
648 task.cancel()
649 try:
650 await task
651 except asyncio.CancelledError:
652 pass
653
654 async def _clear_reactions(self, chat_id: str) -> None:
655 """Remove all pending reactions after bot replies."""
656 # Cancel delayed working emoji if it hasn't fired yet
657 task = self._working_emoji_tasks.pop(chat_id, None)
658 if task and not task.done():
659 task.cancel()
660
661 msg_obj = self._pending_reactions.pop(chat_id, None)
662 if msg_obj is None:
663 return
664 bot_user = self._client.user if self._client else None
665 for emoji in (self.config.read_receipt_emoji, self.config.working_emoji):
666 try:
667 await msg_obj.remove_reaction(emoji, bot_user)
668 except Exception:
669 pass
670
671 async def _cancel_all_typing(self) -> None:
672 """Stop all typing tasks."""
673 channel_ids = list(self._typing_tasks)
674 for channel_id in channel_ids:
675 await self._stop_typing(channel_id)
676
677 async def _reset_runtime_state(self, close_client: bool) -> None:
678 """Reset client and typing state."""
679 await self._cancel_all_typing()
680 self._stream_bufs.clear()
681 if close_client and self._client is not None and not self._client.is_closed():
682 try:
683 await self._client.close()
684 except Exception as e:
685 logger.warning("Discord client close failed: {}", e)
686 self._client = None
687 self._bot_user_id = None
688
688 lines PYTHON