diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 2a690c597b..3162c5f1f5 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -1,11 +1,14 @@ import asyncio +import inspect import re import sys -from typing import Any, cast +import types +import typing +from typing import Any import discord from discord.abc import GuildChannel, Messageable, PrivateChannel -from discord.channel import DMChannel +from discord.channel import DMChannel, GroupChannel from astrbot import logger from astrbot.api.event import MessageChain @@ -23,7 +26,9 @@ from astrbot.core.star.filter.command_group import CommandGroupFilter from astrbot.core.star.star import star_map from astrbot.core.star.star_handler import StarHandlerMetadata, star_handlers_registry -from astrbot.core.utils.media_utils import MediaResolver +from astrbot.core.utils.media_utils import ( + MediaResolver, # noqa: F401 # 供外部 monkeypatch 使用 +) from .client import DiscordBotClient from .discord_platform_event import DiscordPlatformEvent @@ -46,7 +51,6 @@ def __init__( event_queue: asyncio.Queue, ) -> None: super().__init__(platform_config, event_queue) - self.settings = platform_settings self.bot_self_id: str | None = None self.registered_handlers = [] # 指令注册相关 @@ -55,6 +59,7 @@ def __init__( self.activity_name = self.config.get("discord_activity_name", None) self.shutdown_event = asyncio.Event() self._polling_task = None + self.client = None @override async def send_by_session( @@ -63,16 +68,17 @@ async def send_by_session( message_chain: MessageChain, ) -> None: """通过会话发送消息""" - if self.client.user is None: + if self.client is None or self.client.user is None: logger.error( - "[Discord] Client is not ready (self.client.user is None); message send skipped" + "[Discord] Client is not ready (client is None); message send skipped" ) return # 创建一个 message_obj 以便在 event 中使用 message_obj = AstrBotMessage() + # 剥离平台前缀 (如 "discord_123456" → "123456") if "_" in session.session_id: - session.session_id = session.session_id.split("_")[1] + session.session_id = session.session_id.split("_", 1)[1] channel_id_str = session.session_id channel = None try: @@ -93,14 +99,25 @@ async def send_by_session( message_obj.message_str = message_chain.get_plain_text() message_obj.sender = MessageMember( - user_id=str(self.bot_self_id), + user_id=str(self.bot_self_id) + if self.bot_self_id is not None + else "unknown", nickname=self.client.user.display_name, ) - message_obj.self_id = cast(str, self.bot_self_id) + message_obj.self_id = ( + str(self.bot_self_id) if self.bot_self_id is not None else "unknown" + ) message_obj.session_id = session.session_id message_obj.message = message_chain.chain - temp_event = self.create_event(message_obj) + # 创建临时事件对象来发送消息 + temp_event = DiscordPlatformEvent( + message_str=message_obj.message_str, + message_obj=message_obj, + platform_meta=self.meta(), + session_id=session.session_id, + client=self.client, + ) await temp_event.send(message_chain) await super().send_by_session(session, message_chain) @@ -110,7 +127,7 @@ def meta(self) -> PlatformMetadata: return PlatformMetadata( "discord", "Discord Adapter", - id=cast(str, self.config.get("id")), + id=str(self.config.get("id") or ""), default_config_tmpl=self.config, support_streaming_message=False, ) @@ -128,12 +145,13 @@ async def on_received(message_data) -> None: await self.handle_msg(abm) # 初始化 Discord 客户端 - token = str(self.config.get("discord_token")) - if not token: + raw_token = self.config.get("discord_token") + if not raw_token: logger.error( "[Discord] Bot token is not configured. Please set a valid token in the config file." ) return + token = str(raw_token) proxy = self.config.get("discord_proxy") or None allow_bot_messages = bool(self.config.get("discord_allow_bot_messages")) @@ -158,6 +176,7 @@ async def callback() -> None: try: self._polling_task = asyncio.create_task(self.client.start_polling()) + self._polling_task.add_done_callback(self._on_polling_task_done) await self.shutdown_event.wait() except discord.errors.LoginFailure: logger.error( @@ -179,9 +198,13 @@ def _get_message_type( """根据 channel 对象和 guild_id 判断消息类型""" if guild_id is not None: return MessageType.GROUP_MESSAGE - if isinstance(channel, DMChannel) or getattr(channel, "guild", None) is None: + if isinstance(channel, DMChannel): return MessageType.FRIEND_MESSAGE - return MessageType.GROUP_MESSAGE + if isinstance(channel, GroupChannel): + return MessageType.GROUP_MESSAGE + if getattr(channel, "guild", None) is not None: + return MessageType.GROUP_MESSAGE + return MessageType.FRIEND_MESSAGE def _get_channel_id( self, channel: Messageable | GuildChannel | PrivateChannel @@ -211,11 +234,9 @@ def _convert_message_to_abm(self, data: dict) -> AstrBotMessage: and hasattr(message, "guild") and message.guild ): - bot_member = ( - message.guild.get_member(self.client.user.id) - if self.client and self.client.user - else None - ) + bot_member = None + if self.client is not None and self.client.user is not None: + bot_member = message.guild.get_member(self.client.user.id) if bot_member and hasattr(bot_member, "roles"): for role in bot_member.roles: role_mention_str = f"<@&{role.id}>" @@ -236,25 +257,22 @@ def _convert_message_to_abm(self, data: dict) -> AstrBotMessage: message_chain.append(Plain(text=abm.message_str)) if message.attachments: for attachment in message.attachments: - if attachment.content_type and attachment.content_type.startswith( - "image/", - ): + ct = attachment.content_type or "" + if ct.startswith("image/"): message_chain.append( Image(file=attachment.url, filename=attachment.filename), ) - elif attachment.content_type and attachment.content_type.startswith( - "audio/", - ): - message_chain.append( - Record(file=attachment.url, url=attachment.url), - ) + elif ct.startswith("audio/"): + message_chain.append(Record(file=attachment.url)) else: message_chain.append( File(name=attachment.filename, url=attachment.url), ) abm.message = message_chain abm.raw_message = message - abm.self_id = cast(str, self.bot_self_id) + abm.self_id = ( + str(self.bot_self_id) if self.bot_self_id is not None else "unknown" + ) abm.session_id = str(message.channel.id) abm.message_id = str(message.id) return abm @@ -265,7 +283,7 @@ async def convert_message(self, data: dict) -> AstrBotMessage: abm = self._convert_message_to_abm(data) for component in abm.message: if isinstance(component, Record): - audio_ref = component.url or component.file + audio_ref = component.file if audio_ref: path_wav = await MediaResolver( audio_ref, @@ -277,19 +295,15 @@ async def convert_message(self, data: dict) -> AstrBotMessage: component.path = path_wav return abm - def create_event( - self, message: AstrBotMessage, followup_webhook=None - ) -> DiscordPlatformEvent: - """Creates a Discord message event. - - Args: - message: AstrBot message object to wrap. - followup_webhook: Optional slash-command follow-up webhook. + async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> None: + """处理消息""" + if self.client is None or self.client.user is None: + logger.error( + "[Discord] Client is not ready (client is None or user is None); message handling skipped" + ) + return - Returns: - Created Discord message event. - """ - return DiscordPlatformEvent( + message_event = DiscordPlatformEvent( message_str=message.message_str, message_obj=message, platform_meta=self.meta(), @@ -298,16 +312,6 @@ def create_event( interaction_followup_webhook=followup_webhook, ) - async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> None: - """处理消息""" - message_event = self.create_event(message, followup_webhook) - - if self.client.user is None: - logger.error( - "[Discord] Client is not ready (self.client.user is None); message handling skipped" - ) - return - # 检查是否为斜杠指令 is_slash_command = message_event.interaction_followup_webhook is not None @@ -343,7 +347,7 @@ async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> No bot_member = raw_message.guild.get_member( self.client.user.id, ) - except Exception: + except discord.DiscordException: bot_member = None if bot_member and hasattr(bot_member, "roles"): bot_roles = set(bot_member.roles) @@ -366,6 +370,19 @@ async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> No async def terminate(self) -> None: logger.info("[Discord] Shutting down adapter...") self.shutdown_event.set() + + # 先取消 polling,避免与命令清理竞争 + if self._polling_task: + self._polling_task.cancel() + try: + await asyncio.wait_for(self._polling_task, timeout=10) + except asyncio.CancelledError: + logger.info("[Discord] Polling task cancelled successfully.") + except Exception as e: + logger.warning( + f"[Discord] Error occurred while cancelling polling task: {e}" + ) + logger.info("[Discord] Cleaning up commands...") if self.enable_command_register and self.client: try: @@ -382,16 +399,6 @@ async def terminate(self) -> None: f"[Discord] Error occurred while cleaning up commands: {e}" ) - if self._polling_task: - self._polling_task.cancel() - try: - await asyncio.wait_for(self._polling_task, timeout=10) - except asyncio.CancelledError: - logger.info("[Discord] Polling task cancelled successfully.") - except Exception as e: - logger.warning( - f"[Discord] Error occurred while cancelling polling task: {e}" - ) logger.info("[Discord] Closing client connection...") if self.client and hasattr(self.client, "close"): try: @@ -400,6 +407,17 @@ async def terminate(self) -> None: logger.warning(f"[Discord] Error occurred while closing client: {e}") logger.info("[Discord] Adapter shutdown complete.") + def _on_polling_task_done(self, task: asyncio.Task) -> None: + """polling task 完成回调,记录异常并唤醒 run() 避免僵尸状态""" + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.error( + f"[Discord] Polling task terminated with error: {exc}", exc_info=exc + ) + self.shutdown_event.set() + def register_handler(self, handler_info) -> None: """注册处理器信息""" self.registered_handlers.append(handler_info) @@ -410,7 +428,8 @@ async def _collect_and_register_commands(self) -> None: registered_commands = [] for handler_md in star_handlers_registry: - if not star_map[handler_md.handler_module_path].activated: + plugin = star_map.get(handler_md.handler_module_path) + if not plugin or not plugin.activated: continue if not handler_md.enabled: continue @@ -421,25 +440,19 @@ async def _collect_and_register_commands(self) -> None: cmd_name, description, cmd_filter_instance = cmd_info - # 创建动态回调 - callback = self._create_dynamic_callback(cmd_name) + # 从 handler 函数签名提取参数生成 Discord Option + options = self._build_slash_options(handler_md.handler) + param_names = [o.name for o in options] - # 创建一个通用的参数选项来接收所有文本输入 - options = [ - discord.Option( - name="params", - description="指令的所有参数", - type=discord.SlashCommandOptionType.string, - required=False, - ), - ] + # 创建动态回调 + callback = self._create_dynamic_callback(cmd_name, param_names) # 创建SlashCommand slash_command = discord.SlashCommand( name=cmd_name, description=description, func=callback, - options=options, + options=options if options else None, guild_ids=[self.guild_id] if self.guild_id else None, ) self.client.add_application_command(slash_command) @@ -471,12 +484,54 @@ async def _collect_and_register_commands(self) -> None: def _is_daily_command_quota_error(error: discord.HTTPException) -> bool: return getattr(error, "code", None) == 30034 - def _create_dynamic_callback(self, cmd_name: str): + @staticmethod + def _build_slash_options(handler) -> list: + """从 handler 函数签名提取参数,映射到 Discord SlashCommandOptionType""" + options = [] + try: + sig = inspect.signature(handler) + for name, param in sig.parameters.items(): + if name in ("self", "event"): + continue + annotation = param.annotation + # 处理 Optional[T] 或 T | None + origin = typing.get_origin(annotation) + union_types = {typing.Union} + if hasattr(types, "UnionType"): + union_types.add(types.UnionType) + if origin in union_types: + args = typing.get_args(annotation) + non_none = [a for a in args if a is not type(None)] + if len(non_none) == 1: + annotation = non_none[0] + # 类型映射 + opt_type = discord.SlashCommandOptionType.string + if annotation is int: + opt_type = discord.SlashCommandOptionType.integer + elif annotation is float: + opt_type = discord.SlashCommandOptionType.number + elif annotation is bool: + opt_type = discord.SlashCommandOptionType.boolean + required = param.default is inspect.Parameter.empty + options.append( + discord.Option( + name=name, + description=f"请输入 {name}", + type=opt_type, + required=required, + ) + ) + except Exception as e: + logger.warning( + f"Failed to build slash options for {handler!r}: {e}", exc_info=True + ) + return options + + def _create_dynamic_callback(self, cmd_name: str, param_names: list | None = None): """为每个指令动态创建一个异步回调函数""" + param_names = param_names or [] - async def dynamic_callback( - ctx: discord.ApplicationContext, params: str | None = None - ) -> None: + async def dynamic_callback(ctx: discord.ApplicationContext, **kwargs) -> None: # 1. 嘗試立即响应,防止超时 (移到最前面) followup_webhook = None try: @@ -495,14 +550,23 @@ async def dynamic_callback( # 将平台特定的前缀'/'剥离,以适配通用的CommandFilter logger.debug(f"[Discord] Callback triggered: {cmd_name}") logger.debug(f"[Discord] Callback context: {ctx}") - logger.debug(f"[Discord] Callback params: {params}") + # 按 param_names 顺序提取参数值,保证与函数签名一致 + if param_names: + params_str = " ".join( + str(kwargs[name]) + for name in param_names + if kwargs.get(name) is not None + ) + else: + params_str = " ".join(str(v) for v in kwargs.values() if v is not None) + logger.debug(f"[Discord] Callback params: {params_str}, kwargs: {kwargs}") message_str_for_filter = cmd_name - if params: - message_str_for_filter += f" {params}" + if params_str: + message_str_for_filter += f" {params_str}" logger.debug( f"[Discord] Slash command '{cmd_name}' triggered. " - f"Raw params: '{params}'. " + f"Raw params: '{params_str}'. " f"Built command string: '{message_str_for_filter}'", ) @@ -528,7 +592,9 @@ async def dynamic_callback( ) abm.message = [Plain(text=message_str_for_filter)] abm.raw_message = ctx.interaction - abm.self_id = cast(str, self.bot_self_id) + abm.self_id = ( + str(self.bot_self_id) if self.bot_self_id is not None else "unknown" + ) abm.session_id = str(ctx.channel_id) abm.message_id = str(ctx.interaction.id) @@ -565,11 +631,20 @@ def _extract_command_info( return None # Discord 斜杠指令名称规范 - if cmd_name != cmd_name.lower() or not re.match(r"^[-_'\\w]{1,32}$", cmd_name): + # Discord 支持 Unicode 命令名(如中文),放宽匹配并确保全小写 + if cmd_name != cmd_name.lower() or not re.match(r"^[-_'\w]{1,32}$", cmd_name): logger.debug(f"[Discord] Skipping invalid slash command format: {cmd_name}") return None - description = handler_metadata.desc or f"Command: {cmd_name}" + # 优先用 handler 的 docstring 作为命令描述 + handler = handler_metadata.handler + description = ( + (handler.__doc__ or "").strip().split("\n")[0] + if hasattr(handler, "__doc__") and handler.__doc__ + else "" + ) + if not description: + description = handler_metadata.desc or f"Command: {cmd_name}" if len(description) > 100: description = f"{description[:97]}..."