From 6c3a702f87434c11de6173faa3c7677814d5fa34 Mon Sep 17 00:00:00 2001 From: NLKASHEI Date: Fri, 3 Jul 2026 17:03:57 +0800 Subject: [PATCH 01/11] =?UTF-8?q?Discord=20=E9=80=82=E9=85=8D=E5=99=A8?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 所有命令参数统一显示为 params → 用 inspect.signature() 按函数签名生成独立 Option 2. 命令描述用插件 desc → 改用 handler.__doc__(docstring) 3. star_map 无异常保护 → 加 try/except KeyError 4. 重复 import inspect → 移除 5. Option description 优化 → "请输入 {name}" --- .../discord/discord_platform_adapter.py | 126 +++++++++--------- 1 file changed, 65 insertions(+), 61 deletions(-) diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 2a690c597b..927a939750 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -1,4 +1,5 @@ import asyncio +import inspect import re import sys from typing import Any, cast @@ -9,7 +10,7 @@ from astrbot import logger from astrbot.api.event import MessageChain -from astrbot.api.message_components import File, Image, Plain, Record +from astrbot.api.message_components import File, Image, Plain from astrbot.api.platform import ( AstrBotMessage, MessageMember, @@ -23,7 +24,6 @@ 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 .client import DiscordBotClient from .discord_platform_event import DiscordPlatformEvent @@ -100,7 +100,14 @@ async def send_by_session( 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_chain.get_plain_text(), + 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) @@ -242,12 +249,6 @@ def _convert_message_to_abm(self, data: dict) -> AstrBotMessage: 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), - ) else: message_chain.append( File(name=attachment.filename, url=attachment.url), @@ -262,34 +263,11 @@ def _convert_message_to_abm(self, data: dict) -> AstrBotMessage: async def convert_message(self, data: dict) -> AstrBotMessage: """将平台消息转换成 AstrBotMessage""" # 由于 on_interaction 已被禁用,我们只处理普通消息 - abm = self._convert_message_to_abm(data) - for component in abm.message: - if isinstance(component, Record): - audio_ref = component.url or component.file - if audio_ref: - path_wav = await MediaResolver( - audio_ref, - media_type="audio", - default_suffix=".wav", - ).to_path(target_format="wav") - component.file = path_wav - component.url = path_wav - component.path = path_wav - return abm + return self._convert_message_to_abm(data) - 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. - - Returns: - Created Discord message event. - """ - return DiscordPlatformEvent( + async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> None: + """处理消息""" + message_event = DiscordPlatformEvent( message_str=message.message_str, message_obj=message, platform_meta=self.meta(), @@ -298,10 +276,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" @@ -410,7 +384,10 @@ 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: + try: + if not star_map[handler_md.handler_module_path].activated: + continue + except KeyError: continue if not handler_md.enabled: continue @@ -421,25 +398,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,11 +442,37 @@ 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 + opt_type = discord.SlashCommandOptionType.string + if param.annotation is int: + opt_type = discord.SlashCommandOptionType.integer + required = param.default is inspect.Parameter.empty + options.append( + discord.Option( + name=name, + description=f"请输入 {name}", + type=opt_type, + required=required, + ) + ) + except Exception: + pass + 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 + ctx: discord.ApplicationContext, **kwargs ) -> None: # 1. 嘗試立即响应,防止超时 (移到最前面) followup_webhook = None @@ -495,14 +492,16 @@ 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}") + # 从 kwargs 收集参数值,拼接为命令字符串 + 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}'", ) @@ -565,11 +564,16 @@ 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]}..." From 73b93e00ab18b5e9026d2e5188b46c7fa898bae8 Mon Sep 17 00:00:00 2001 From: NLKASHEI Date: Fri, 3 Jul 2026 17:21:34 +0800 Subject: [PATCH 02/11] Enhance slash command option type mapping and logging Refactor slash command option handling and improve error logging. --- .../discord/discord_platform_adapter.py | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 927a939750..f2d704e935 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -1,7 +1,10 @@ import asyncio import inspect +import logging import re import sys +import typing +import types from typing import Any, cast import discord @@ -384,10 +387,8 @@ async def _collect_and_register_commands(self) -> None: registered_commands = [] for handler_md in star_handlers_registry: - try: - if not star_map[handler_md.handler_module_path].activated: - continue - except KeyError: + plugin = star_map.get(handler_md.handler_module_path) + if not plugin or not plugin.activated: continue if not handler_md.enabled: continue @@ -451,9 +452,25 @@ def _build_slash_options(handler) -> list: 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 param.annotation is int: + 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( @@ -463,8 +480,8 @@ def _build_slash_options(handler) -> list: required=required, ) ) - except Exception: - pass + except Exception as e: + logger.debug(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): @@ -492,8 +509,11 @@ async def dynamic_callback( # 将平台特定的前缀'/'剥离,以适配通用的CommandFilter logger.debug(f"[Discord] Callback triggered: {cmd_name}") logger.debug(f"[Discord] Callback context: {ctx}") - # 从 kwargs 收集参数值,拼接为命令字符串 - params_str = " ".join(str(v) for v in kwargs.values() if v is not None) + # 按 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_str: From 3d3871ad0712cd6b7b1d8234fd2a215d7c03b5b3 Mon Sep 17 00:00:00 2001 From: NLKASHEI Date: Fri, 3 Jul 2026 17:27:21 +0800 Subject: [PATCH 03/11] Change log level from debug to warning for errors --- .../core/platform/sources/discord/discord_platform_adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index f2d704e935..acb9fc4476 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -481,7 +481,7 @@ def _build_slash_options(handler) -> list: ) ) except Exception as e: - logger.debug(f"Failed to build slash options for {handler!r}: {e}", exc_info=True) + 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): From cb4954fabd15f71ca46e9a9a0798998f50917d20 Mon Sep 17 00:00:00 2001 From: NLKASHEI Date: Sat, 4 Jul 2026 20:34:11 +0800 Subject: [PATCH 04/11] Update discord_platform_adapter.py --- .../discord/discord_platform_adapter.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index acb9fc4476..2124f4b24f 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -1,10 +1,9 @@ import asyncio import inspect -import logging import re import sys -import typing import types +import typing from typing import Any, cast import discord @@ -481,16 +480,16 @@ def _build_slash_options(handler) -> list: ) ) except Exception as e: - logger.warning(f"Failed to build slash options for {handler!r}: {e}", exc_info=True) + 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, **kwargs - ) -> None: + async def dynamic_callback(ctx: discord.ApplicationContext, **kwargs) -> None: # 1. 嘗試立即响应,防止超时 (移到最前面) followup_webhook = None try: @@ -511,7 +510,11 @@ async def dynamic_callback( logger.debug(f"[Discord] Callback context: {ctx}") # 按 param_names 顺序提取参数值,保证与函数签名一致 if param_names: - params_str = " ".join(str(kwargs[name]) for name in param_names if kwargs.get(name) is not None) + 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}") @@ -591,7 +594,11 @@ def _extract_command_info( # 优先用 handler 的 docstring 作为命令描述 handler = handler_metadata.handler - description = (handler.__doc__ or "").strip().split("\n")[0] if hasattr(handler, "__doc__") and handler.__doc__ else "" + 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: From 3a74b5a2df3151ab399bc73aec451bd40234b716 Mon Sep 17 00:00:00 2001 From: NLKASHEI Date: Sat, 4 Jul 2026 20:43:31 +0800 Subject: [PATCH 05/11] Update discord_platform_adapter.py --- .../core/platform/sources/discord/discord_platform_adapter.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 2124f4b24f..e136562b3a 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -26,6 +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, # noqa: F401 # 供外部 monkeypatch 使用 +) from .client import DiscordBotClient from .discord_platform_event import DiscordPlatformEvent From 57c230f2d5512d810d015cc71539c56ec85e4424 Mon Sep 17 00:00:00 2001 From: NLKASHEI Date: Sat, 4 Jul 2026 21:13:19 +0800 Subject: [PATCH 06/11] Refactor Discord platform adapter for better handling --- .../discord/discord_platform_adapter.py | 104 +++++++++++------- 1 file changed, 62 insertions(+), 42 deletions(-) diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index e136562b3a..3af2546f3a 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -4,15 +4,15 @@ import sys import types import typing -from typing import Any, cast +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 -from astrbot.api.message_components import File, Image, Plain +from astrbot.api.message_components import File, Image, Plain, Record from astrbot.api.platform import ( AstrBotMessage, MessageMember, @@ -51,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 = [] # 指令注册相关 @@ -60,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( @@ -68,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: @@ -98,16 +99,16 @@ 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 = DiscordPlatformEvent( - message_str=message_chain.get_plain_text(), + message_str=message_obj.message_str, message_obj=message_obj, platform_meta=self.meta(), session_id=session.session_id, @@ -122,7 +123,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, ) @@ -140,12 +141,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")) @@ -170,6 +172,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( @@ -191,9 +194,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 @@ -223,11 +230,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}>" @@ -248,19 +253,20 @@ 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 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 @@ -272,6 +278,12 @@ async def convert_message(self, data: dict) -> AstrBotMessage: 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 + message_event = DiscordPlatformEvent( message_str=message.message_str, message_obj=message, @@ -281,12 +293,6 @@ async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> No interaction_followup_webhook=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 @@ -322,7 +328,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) @@ -345,6 +351,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: @@ -361,16 +380,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: @@ -379,6 +388,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) @@ -553,7 +573,7 @@ async def dynamic_callback(ctx: discord.ApplicationContext, **kwargs) -> None: ) 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) From baf195698dbd15cafe581e3e76ce6523b6da5442 Mon Sep 17 00:00:00 2001 From: NLKASHEI Date: Sat, 4 Jul 2026 21:21:06 +0800 Subject: [PATCH 07/11] Update discord_platform_adapter.py --- .../discord/discord_platform_adapter.py | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 3af2546f3a..e3974dcb52 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -99,10 +99,14 @@ 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) if self.bot_self_id is not None else "unknown", + 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 = str(self.bot_self_id) if self.bot_self_id is not None else "unknown" + 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 @@ -266,7 +270,9 @@ def _convert_message_to_abm(self, data: dict) -> AstrBotMessage: ) abm.message = message_chain abm.raw_message = message - abm.self_id = str(self.bot_self_id) if self.bot_self_id is not None else "unknown" + 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 @@ -274,7 +280,19 @@ def _convert_message_to_abm(self, data: dict) -> AstrBotMessage: async def convert_message(self, data: dict) -> AstrBotMessage: """将平台消息转换成 AstrBotMessage""" # 由于 on_interaction 已被禁用,我们只处理普通消息 - return self._convert_message_to_abm(data) + abm = self._convert_message_to_abm(data) + for component in abm.message: + if isinstance(component, Record): + audio_ref = component.file + if audio_ref: + path_wav = await MediaResolver( + audio_ref, + media_type="audio", + default_suffix=".wav", + ).to_path(target_format="wav") + component.file = path_wav + component.url = path_wav + return abm async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> None: """处理消息""" @@ -573,7 +591,9 @@ async def dynamic_callback(ctx: discord.ApplicationContext, **kwargs) -> None: ) abm.message = [Plain(text=message_str_for_filter)] abm.raw_message = ctx.interaction - abm.self_id = str(self.bot_self_id) if self.bot_self_id is not None else "unknown" + 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) From bc17bb3d8ead66e9bbd192ea1330ef653c885c30 Mon Sep 17 00:00:00 2001 From: NLKASHEI Date: Sat, 4 Jul 2026 21:27:04 +0800 Subject: [PATCH 08/11] Update discord_platform_adapter.py --- .../core/platform/sources/discord/discord_platform_adapter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index e3974dcb52..3162c5f1f5 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -292,6 +292,7 @@ async def convert_message(self, data: dict) -> AstrBotMessage: ).to_path(target_format="wav") component.file = path_wav component.url = path_wav + component.path = path_wav return abm async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> None: From 05e22370a11f4125170ff27af2df777d4925443e Mon Sep 17 00:00:00 2001 From: NLKASHEI Date: Wed, 22 Jul 2026 18:26:43 +0800 Subject: [PATCH 09/11] Update discord_platform_adapter.py --- .../discord/discord_platform_adapter.py | 894 ++++++++++++------ 1 file changed, 584 insertions(+), 310 deletions(-) diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 3162c5f1f5..af4431a4d4 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -4,12 +4,9 @@ import sys import types import typing -from typing import Any +from typing import Any, cast import discord -from discord.abc import GuildChannel, Messageable, PrivateChannel -from discord.channel import DMChannel, GroupChannel - from astrbot import logger from astrbot.api.event import MessageChain from astrbot.api.message_components import File, Image, Plain, Record @@ -26,9 +23,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, # noqa: F401 # 供外部 monkeypatch 使用 -) +from astrbot.core.utils.media_utils import MediaResolver +from discord.abc import GuildChannel, Messageable, PrivateChannel +from discord.channel import DMChannel from .client import DiscordBotClient from .discord_platform_event import DiscordPlatformEvent @@ -38,8 +35,13 @@ else: from typing_extensions import override +# Discord CHAT_INPUT 命令名:小写 + 字母/数字(含 Unicode,中文可用)/_/- ,1-32 +# 官方近似: ^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$ +_DISCORD_CMD_NAME_RE = re.compile(r"^[-_\w]{1,32}$") +_DISCORD_MAX_OPTIONS = 25 +_DISCORD_DESC_MAX = 100 + -# 注册平台适配器 @register_platform_adapter( "discord", "Discord 适配器 (基于 Pycord)", support_streaming_message=False ) @@ -51,15 +53,66 @@ 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 = [] - # 指令注册相关 - self.enable_command_register = self.config.get("discord_command_register", True) - self.guild_id = self.config.get("discord_guild_id_for_debug", None) + self.registered_handlers: list = [] + self.enable_command_register = bool( + self.config.get("discord_command_register", True) + ) + self.guild_id = self._normalize_guild_id( + self.config.get("discord_guild_id_for_debug", None) + ) self.activity_name = self.config.get("discord_activity_name", None) self.shutdown_event = asyncio.Event() - self._polling_task = None - self.client = None + self._polling_task: asyncio.Task | None = None + # 避免 run() 前访问 client 触发 AttributeError + self.client: DiscordBotClient | None = None + + @staticmethod + def _normalize_guild_id(raw: Any) -> int | None: + if raw is None or raw == "": + return None + try: + return int(raw) + except (TypeError, ValueError): + logger.warning(f"[Discord] Invalid discord_guild_id_for_debug: {raw!r}") + return None + + def _client_ready(self) -> bool: + return self.client is not None and self.client.user is not None + + def _ensure_bot_self_id(self) -> None: + if self.bot_self_id is None and self.client is not None and self.client.user: + self.bot_self_id = str(self.client.user.id) + + @staticmethod + def _resolve_channel_id_from_session(session_id: str) -> str: + """从 session_id 解析 channel id,不修改调用方对象。 + + 兼容: + - 纯数字 channel id + - ``platform_channelId`` / ``discord_123`` 等形式(取最后一段) + """ + if not session_id: + return session_id + if "_" in session_id: + return session_id.rsplit("_", 1)[-1] + return session_id + + async def _resolve_channel(self, channel_id: int): + """先读缓存,未命中再 fetch,避免冷启动 get_channel 恒为 None。""" + if self.client is None: + return None + channel = self.client.get_channel(channel_id) + if channel is not None: + return channel + try: + return await self.client.fetch_channel(channel_id) + except Exception as e: + logger.warning( + f"[Discord] fetch_channel({channel_id}) failed: {e}" + ) + return None @override async def send_by_session( @@ -68,57 +121,54 @@ async def send_by_session( message_chain: MessageChain, ) -> None: """通过会话发送消息""" - if self.client is None or self.client.user is None: + if not self._client_ready(): logger.error( - "[Discord] Client is not ready (client is None); message send skipped" + "[Discord] Client is not ready (self.client.user 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)[1] - channel_id_str = session.session_id + assert self.client is not None and self.client.user is not None + self._ensure_bot_self_id() + + session_id = self._resolve_channel_id_from_session(session.session_id) channel = None try: - channel_id = int(channel_id_str) - channel = self.client.get_channel(channel_id) + channel_id = int(session_id) + channel = await self._resolve_channel(channel_id) except (ValueError, TypeError): - logger.warning(f"[Discord] Invalid channel ID format: {channel_id_str}") + logger.warning(f"[Discord] Invalid channel ID format: {session_id}") - if channel: + message_obj = AstrBotMessage() + if channel is not None: message_obj.type = self._get_message_type(channel) message_obj.group_id = self._get_channel_id(channel) else: logger.warning( - f"[Discord] Can't get channel info for {channel_id_str}, will guess message type.", + f"[Discord] Can't get channel info for {session_id}, " + "will guess message type.", ) message_obj.type = MessageType.GROUP_MESSAGE - message_obj.group_id = session.session_id + message_obj.group_id = session_id message_obj.message_str = message_chain.get_plain_text() message_obj.sender = MessageMember( - user_id=str(self.bot_self_id) - if self.bot_self_id is not None - else "unknown", + user_id=str(self.bot_self_id or ""), nickname=self.client.user.display_name, ) - 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.self_id = cast(str, self.bot_self_id) + message_obj.session_id = session_id message_obj.message = message_chain.chain - # 创建临时事件对象来发送消息 - 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) + try: + temp_event = self.create_event(message_obj) + await temp_event.send(message_chain) + except Exception as e: + logger.error( + f"[Discord] send_by_session failed for channel {session_id}: {e}", + exc_info=True, + ) + raise await super().send_by_session(session, message_chain) @override @@ -127,7 +177,7 @@ def meta(self) -> PlatformMetadata: return PlatformMetadata( "discord", "Discord Adapter", - id=str(self.config.get("id") or ""), + id=cast(str, self.config.get("id")), default_config_tmpl=self.config, support_streaming_message=False, ) @@ -136,47 +186,70 @@ def meta(self) -> PlatformMetadata: async def run(self) -> None: """主要运行逻辑""" - # 初始化回调函数 async def on_received(message_data) -> None: - logger.debug(f"[Discord] Message received: {message_data}") - if self.bot_self_id is None: - self.bot_self_id = message_data.get("bot_id") - abm = await self.convert_message(data=message_data) - await self.handle_msg(abm) + try: + logger.debug(f"[Discord] Message received: {message_data}") + if self.bot_self_id is None and isinstance(message_data, dict): + bot_id = message_data.get("bot_id") + if bot_id is not None: + self.bot_self_id = str(bot_id) + self._ensure_bot_self_id() + abm = await self.convert_message(data=message_data) + await self.handle_msg(abm) + except Exception as e: + logger.error( + f"[Discord] Error while handling received message: {e}", + exc_info=True, + ) - # 初始化 Discord 客户端 raw_token = self.config.get("discord_token") - if not raw_token: + token = str(raw_token).strip() if raw_token else "" + if not token: logger.error( - "[Discord] Bot token is not configured. Please set a valid token in the config file." + "[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 + if isinstance(proxy, str): + proxy = proxy.strip() or None allow_bot_messages = bool(self.config.get("discord_allow_bot_messages")) - self.client = DiscordBotClient(token, proxy, allow_bot_messages) - self.client.on_message_received = on_received + client = DiscordBotClient(token, proxy, allow_bot_messages) + self.client = client + client.on_message_received = on_received - async def callback() -> None: + async def on_ready_once() -> None: try: + self._ensure_bot_self_id() if self.enable_command_register: await self._collect_and_register_commands() - if self.activity_name: + if self.activity_name and self.client is not None: await self.client.change_presence( status=discord.Status.online, - activity=discord.CustomActivity(name=self.activity_name), + activity=discord.CustomActivity(name=str(self.activity_name)), ) except Exception as e: logger.error( - f"[Discord] on_ready_once_callback err: {e}", exc_info=True + f"[Discord] on_ready_once_callback err: {e}", + exc_info=True, ) - self.client.on_ready_once_callback = callback + client.on_ready_once_callback = on_ready_once + + def _on_polling_done(task: asyncio.Task) -> None: + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + logger.error( + f"[Discord] Polling task crashed: {exc}", + exc_info=exc, + ) try: - self._polling_task = asyncio.create_task(self.client.start_polling()) - self._polling_task.add_done_callback(self._on_polling_task_done) + self._polling_task = asyncio.create_task(client.start_polling()) + self._polling_task.add_done_callback(_on_polling_done) await self.shutdown_event.wait() except discord.errors.LoginFailure: logger.error( @@ -198,112 +271,136 @@ def _get_message_type( """根据 channel 对象和 guild_id 判断消息类型""" if guild_id is not None: return MessageType.GROUP_MESSAGE - if isinstance(channel, DMChannel): + if isinstance(channel, DMChannel) or getattr(channel, "guild", None) is None: return MessageType.FRIEND_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 + return MessageType.GROUP_MESSAGE def _get_channel_id( self, channel: Messageable | GuildChannel | PrivateChannel ) -> str: - """根据 channel 对象获取ID""" - return str(getattr(channel, "id", None)) + """根据 channel 对象获取 ID;无 id 时返回空串,避免 'None' 污染会话""" + channel_id = getattr(channel, "id", None) + return str(channel_id) if channel_id is not None else "" + + def _strip_bot_leading_mentions(self, content: str, message) -> str: + """只剥离开头属于本 bot 的 user/role mention,避免误删其它 @。 + + 支持开头连续的 bot user mention + bot role mention。 + """ + if not content: + return content + + bot_user = self.client.user if self.client else None + bot_id = bot_user.id if bot_user else None + if bot_id is None: + return content + + changed = True + while changed: + changed = False + for mention_str in (f"<@{bot_id}>", f"<@!{bot_id}>"): + if content.startswith(mention_str): + content = content[len(mention_str) :].lstrip() + changed = True + break + if changed: + continue + + guild = getattr(message, "guild", None) + if not guild: + break + try: + bot_member = guild.get_member(bot_id) + except Exception: + bot_member = None + if not bot_member or not getattr(bot_member, "roles", None): + break + for role in bot_member.roles: + role_mention_str = f"<@&{role.id}>" + if content.startswith(role_mention_str): + content = content[len(role_mention_str) :].lstrip() + changed = True + break + + return content def _convert_message_to_abm(self, data: dict) -> AstrBotMessage: """将普通消息转换为 AstrBotMessage""" message = data["message"] + content = message.content or "" - content = message.content - - # 如果机器人被@,移除@部分 - # 剥离 User Mention (<@id>, <@!id>) - if self.client and self.client.user: - mention_str = f"<@{self.client.user.id}>" - mention_str_nickname = f"<@!{self.client.user.id}>" - if content.startswith(mention_str): - content = content[len(mention_str) :].lstrip() - elif content.startswith(mention_str_nickname): - content = content[len(mention_str_nickname) :].lstrip() - - # 剥离 Role Mention(bot 拥有的任一角色被提及,<@&role_id>) - if ( - hasattr(message, "role_mentions") - and hasattr(message, "guild") - and message.guild - ): - 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}>" - if content.startswith(role_mention_str): - content = content[len(role_mention_str) :].lstrip() - break # 只剥离第一个匹配的角色 mention + content = self._strip_bot_leading_mentions(content, message) abm = AstrBotMessage() abm.type = self._get_message_type(message.channel) abm.group_id = self._get_channel_id(message.channel) abm.message_str = content + + author = message.author abm.sender = MessageMember( - user_id=str(message.author.id), - nickname=message.author.display_name, + user_id=str(getattr(author, "id", "") or ""), + nickname=getattr(author, "display_name", None) + or getattr(author, "name", None) + or "", ) - message_chain = [] + + message_chain: list = [] if abm.message_str: message_chain.append(Plain(text=abm.message_str)) - if message.attachments: - for attachment in message.attachments: - ct = attachment.content_type or "" - if ct.startswith("image/"): - message_chain.append( - Image(file=attachment.url, filename=attachment.filename), - ) - elif ct.startswith("audio/"): - message_chain.append(Record(file=attachment.url)) - else: - message_chain.append( - File(name=attachment.filename, url=attachment.url), - ) + + attachments = getattr(message, "attachments", None) or [] + for attachment in attachments: + ct = (getattr(attachment, "content_type", None) or "").lower() + url = getattr(attachment, "url", None) or "" + filename = getattr(attachment, "filename", None) or "file" + if not url: + continue + if ct.startswith("image/"): + message_chain.append(Image(file=url, filename=filename)) + elif ct.startswith("audio/"): + message_chain.append(Record(file=url, url=url)) + else: + message_chain.append(File(name=filename, url=url)) + abm.message = message_chain abm.raw_message = message - 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) + abm.self_id = cast(str, self.bot_self_id) + channel_id = getattr(message.channel, "id", None) + abm.session_id = str(channel_id) if channel_id is not None else "" + abm.message_id = str(getattr(message, "id", "") or "") return abm async def convert_message(self, data: dict) -> AstrBotMessage: - """将平台消息转换成 AstrBotMessage""" - # 由于 on_interaction 已被禁用,我们只处理普通消息 + """将平台消息转换成 AstrBotMessage,并对音频做本地解析""" abm = self._convert_message_to_abm(data) for component in abm.message: - if isinstance(component, Record): - audio_ref = component.file - if audio_ref: - path_wav = await MediaResolver( - audio_ref, - media_type="audio", - default_suffix=".wav", - ).to_path(target_format="wav") - component.file = path_wav - component.url = path_wav + if not isinstance(component, Record): + continue + audio_ref = component.url or component.file + if not audio_ref: + continue + try: + path_wav = await MediaResolver( + audio_ref, + media_type="audio", + default_suffix=".wav", + ).to_path(target_format="wav") + component.file = path_wav + component.url = path_wav + if hasattr(component, "path"): component.path = path_wav + except Exception as e: + logger.warning( + f"[Discord] Failed to resolve audio attachment: {e}", + exc_info=True, + ) return abm - 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 - - message_event = DiscordPlatformEvent( + def create_event( + self, message: AstrBotMessage, followup_webhook=None + ) -> DiscordPlatformEvent: + """创建 Discord 消息事件""" + return DiscordPlatformEvent( message_str=message.message_str, message_obj=message, platform_meta=self.meta(), @@ -312,18 +409,31 @@ async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> No interaction_followup_webhook=followup_webhook, ) - # 检查是否为斜杠指令 - is_slash_command = message_event.interaction_followup_webhook is not None + async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> None: + """处理消息""" + if not self._client_ready(): + logger.error( + "[Discord] Client is not ready (self.client.user is None); " + "message handling skipped" + ) + return - # 1. 优先处理斜杠指令 - if is_slash_command: + assert self.client is not None and self.client.user is not None + self._ensure_bot_self_id() + + # 补写 self_id,避免上游仍是 None + if not message.self_id and self.bot_self_id: + message.self_id = self.bot_self_id + + message_event = self.create_event(message, followup_webhook) + + # 斜杠指令优先 + if message_event.interaction_followup_webhook is not None: message_event.is_wake = True message_event.is_at_or_wake_command = True self.commit_event(message_event) return - # 2. 处理普通消息(提及检测) - # 确保 raw_message 是 discord.Message 类型,以便静态检查通过 raw_message = message.raw_message if not isinstance(raw_message, discord.Message): logger.warning( @@ -331,35 +441,25 @@ async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> No ) return - # 检查是否被@(User Mention 或 Bot 拥有的 Role Mention) is_mention = False + try: + if self.client.user in (raw_message.mentions or []): + is_mention = True + except Exception: + is_mention = False - # User Mention - # 此时 Pylance 知道 raw_message 是 discord.Message,具有 mentions 属性 - if self.client.user in raw_message.mentions: - is_mention = True - - # Role Mention(Bot 拥有的角色被提及) - if not is_mention and raw_message.role_mentions: + if not is_mention and raw_message.role_mentions and raw_message.guild: bot_member = None - if raw_message.guild: - try: - bot_member = raw_message.guild.get_member( - self.client.user.id, - ) - except discord.DiscordException: - bot_member = None - if bot_member and hasattr(bot_member, "roles"): + try: + bot_member = raw_message.guild.get_member(self.client.user.id) + except Exception: + bot_member = None + if bot_member and getattr(bot_member, "roles", None): bot_roles = set(bot_member.roles) mentioned_roles = set(raw_message.role_mentions) - if ( - bot_roles - and mentioned_roles - and bot_roles.intersection(mentioned_roles) - ): + if bot_roles.intersection(mentioned_roles): is_mention = True - # 如果是被@的消息,设置为唤醒状态 if is_mention: message_event.is_wake = True message_event.is_at_or_wake_command = True @@ -371,26 +471,12 @@ 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: + if self.enable_command_register and self.client is not None: try: + guild_ids = [self.guild_id] if self.guild_id else None await asyncio.wait_for( - self.client.sync_commands( - commands=[], - guild_ids=[self.guild_id] if self.guild_id else None, - ), + self.client.sync_commands(commands=[], guild_ids=guild_ids), timeout=10, ) logger.info("[Discord] Commands cleaned up successfully.") @@ -399,33 +485,41 @@ async def terminate(self) -> None: f"[Discord] Error occurred while cleaning up commands: {e}" ) + if self._polling_task is not None: + self._polling_task.cancel() + try: + await asyncio.wait_for(asyncio.shield(self._polling_task), timeout=10) + except asyncio.CancelledError: + logger.info("[Discord] Polling task cancelled successfully.") + except asyncio.TimeoutError: + logger.warning("[Discord] Polling task cancel timed out.") + except Exception as e: + logger.warning( + f"[Discord] Error occurred while cancelling polling task: {e}" + ) + self._polling_task = None + logger.info("[Discord] Closing client connection...") - if self.client and hasattr(self.client, "close"): + if self.client is not None and hasattr(self.client, "close"): try: await asyncio.wait_for(self.client.close(), timeout=10) except Exception as e: 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) async def _collect_and_register_commands(self) -> None: - """收集所有指令并注册到Discord""" + """收集所有指令并注册到 Discord""" + if self.client is None: + logger.warning("[Discord] Client is None; skip command registration.") + return + logger.info("[Discord] Collecting and registering slash commands...") - registered_commands = [] + registered_commands: list[str] = [] + seen_names: set[str] = set() for handler_md in star_handlers_registry: plugin = star_map.get(handler_md.handler_module_path) @@ -433,42 +527,76 @@ async def _collect_and_register_commands(self) -> None: continue if not handler_md.enabled: continue + for event_filter in handler_md.event_filters: cmd_info = self._extract_command_info(event_filter, handler_md) if not cmd_info: continue - cmd_name, description, cmd_filter_instance = cmd_info + cmd_name, description, _cmd_filter = cmd_info + if cmd_name in seen_names: + logger.warning( + f"[Discord] Duplicate slash command '{cmd_name}' skipped." + ) + continue - # 从 handler 函数签名提取参数生成 Discord Option options = self._build_slash_options(handler_md.handler) + if len(options) > _DISCORD_MAX_OPTIONS: + logger.warning( + f"[Discord] Command '{cmd_name}' has {len(options)} options; " + f"truncating to {_DISCORD_MAX_OPTIONS} (Discord limit)." + ) + options = options[:_DISCORD_MAX_OPTIONS] + + # 无签名参数时保留通用 params,兼容原版自由文本参数 + if not options: + options = [ + discord.Option( + name="params", + description="指令的所有参数", + type=discord.SlashCommandOptionType.string, + required=False, + ), + ] + + # Discord 要求 required options 排在 optional 之前 + options = sorted(options, key=lambda o: (not bool(o.required), o.name)) param_names = [o.name for o in options] - - # 创建动态回调 callback = self._create_dynamic_callback(cmd_name, param_names) - # 创建SlashCommand - slash_command = discord.SlashCommand( - name=cmd_name, - description=description, - func=callback, - options=options if options else None, - guild_ids=[self.guild_id] if self.guild_id else None, - ) - self.client.add_application_command(slash_command) + try: + slash_command = discord.SlashCommand( + name=cmd_name, + description=description, + func=callback, + options=options, + guild_ids=[self.guild_id] if self.guild_id else None, + ) + self.client.add_application_command(slash_command) + except Exception as e: + logger.warning( + f"[Discord] Failed to add command '{cmd_name}': {e}", + exc_info=True, + ) + continue + + seen_names.add(cmd_name) registered_commands.append(cmd_name) if registered_commands: logger.info( - f"[Discord] Ready to sync {len(registered_commands)} commands: {', '.join(registered_commands)}", + f"[Discord] Ready to sync {len(registered_commands)} commands: " + f"{', '.join(registered_commands)}", ) else: logger.info("[Discord] No commands found for registration.") - # 使用 Pycord 的方法同步指令 - # 注意:这可能需要一些时间,并且有频率限制 try: - await self.client.sync_commands() + # guild 调试模式下只同步到该 guild,避免全局配额与延迟 + if self.guild_id: + await self.client.sync_commands(guild_ids=[self.guild_id]) + else: + await self.client.sync_commands() logger.info("[Discord] Command synchronization completed.") except discord.HTTPException as e: if self._is_daily_command_quota_error(e): @@ -479,32 +607,70 @@ async def _collect_and_register_commands(self) -> None: ) return logger.warning(f"[Discord] Sync commands failed: {e}") + except Exception as e: + logger.warning(f"[Discord] Sync commands failed: {e}", exc_info=True) @staticmethod def _is_daily_command_quota_error(error: discord.HTTPException) -> bool: return getattr(error, "code", None) == 30034 @staticmethod - def _build_slash_options(handler) -> list: - """从 handler 函数签名提取参数,映射到 Discord SlashCommandOptionType""" - options = [] + def _unwrap_optional(annotation: Any) -> Any: + origin = typing.get_origin(annotation) + union_types: set[Any] = {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: + return non_none[0] + return annotation + + @staticmethod + def _is_valid_discord_option_name(name: str) -> bool: + return bool(name and _DISCORD_CMD_NAME_RE.match(name) and name == name.lower()) + + @classmethod + def _build_slash_options(cls, handler) -> list: + """从 handler 函数签名提取参数,映射到 Discord Option。 + + - 跳过 self/event、*args/**kwargs + - 校验 option 名 + - required 优先(调用方再统一 sort) + """ + options: list = [] + seen: set[str] = set() try: + try: + type_hints = typing.get_type_hints(handler) + except Exception: + type_hints = {} + sig = inspect.signature(handler) for name, param in sig.parameters.items(): - if name in ("self", "event"): + if name in ("self", "event", "cls"): + continue + if param.kind in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + continue + if not cls._is_valid_discord_option_name(name): + logger.warning( + f"[Discord] Skip invalid option name '{name}' " + f"for handler {handler!r}" + ) + continue + if name in seen: 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] - # 类型映射 + seen.add(name) + + annotation = type_hints.get(name, param.annotation) + if annotation is inspect.Parameter.empty: + annotation = str + annotation = cls._unwrap_optional(annotation) + opt_type = discord.SlashCommandOptionType.string if annotation is int: opt_type = discord.SlashCommandOptionType.integer @@ -512,94 +678,197 @@ def _build_slash_options(handler) -> list: 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}", + description=f"请输入 {name}"[:_DISCORD_DESC_MAX], type=opt_type, required=required, ) ) except Exception as e: logger.warning( - f"Failed to build slash options for {handler!r}: {e}", exc_info=True + f"[Discord] Failed to build slash options for {handler!r}: {e}", + exc_info=True, ) return options + @staticmethod + def _format_slash_param_value(value: Any) -> str: + """将 slash 参数序列化为命令字符串片段。 + + - bool/数字直接转文本 + - 含空格或引号时用双引号包裹,避免简单 split 错位 + """ + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + text = str(value) + if not text: + return '""' + if re.search(r"\s", text) or any(c in text for c in "\"'"): + escaped = text.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + return text + def _create_dynamic_callback(self, cmd_name: str, param_names: list | None = None): - """为每个指令动态创建一个异步回调函数""" - param_names = param_names or [] + """为每个指令动态创建异步回调。 + + 使用显式命名参数签名,兼容 pycord 按参数名注入 option 值; + 同时接受 *args/**kwargs 作兜底。 + """ + param_names = list(param_names or []) - async def dynamic_callback(ctx: discord.ApplicationContext, **kwargs) -> None: - # 1. 嘗試立即响应,防止超时 (移到最前面) + async def _handle( + ctx: discord.ApplicationContext, + bound: dict[str, Any], + ) -> None: followup_webhook = None try: - # 設定 2.5 秒超時,避免卡死整個 event loop - await asyncio.wait_for(ctx.defer(), timeout=2.5) + # 已响应过则不要再 defer + if not ctx.interaction.response.is_done(): + await asyncio.wait_for(ctx.defer(), timeout=2.5) followup_webhook = ctx.followup except asyncio.TimeoutError: logger.warning( - f"[Discord] Defer command '{cmd_name}' timeout. Network might be too slow." + f"[Discord] Defer command '{cmd_name}' timeout. " + "Network might be too slow." ) + try: + if not ctx.interaction.response.is_done(): + await ctx.respond("处理超时,请稍后重试。", ephemeral=True) + except Exception: + pass return except Exception as e: - logger.warning(f"[Discord] Failed to defer command '{cmd_name}': {e}") + logger.warning( + f"[Discord] Failed to defer command '{cmd_name}': {e}" + ) + try: + if not ctx.interaction.response.is_done(): + await ctx.respond("指令响应失败,请稍后重试。", ephemeral=True) + except Exception: + pass return - # 将平台特定的前缀'/'剥离,以适配通用的CommandFilter - logger.debug(f"[Discord] Callback triggered: {cmd_name}") - logger.debug(f"[Discord] Callback context: {ctx}") - # 按 param_names 顺序提取参数值,保证与函数签名一致 - if param_names: - params_str = " ".join( - str(kwargs[name]) - for name in param_names - if kwargs.get(name) is not None + try: + self._ensure_bot_self_id() + logger.debug(f"[Discord] Callback triggered: {cmd_name}, bound={bound}") + + parts: list[str] = [] + if param_names: + for name in param_names: + if name not in bound or bound[name] is None: + continue + parts.append(self._format_slash_param_value(bound[name])) + else: + for v in bound.values(): + if v is not None: + parts.append(self._format_slash_param_value(v)) + params_str = " ".join(parts) + + message_str_for_filter = cmd_name + if params_str: + message_str_for_filter += f" {params_str}" + + logger.debug( + f"[Discord] Slash command '{cmd_name}' triggered. " + f"Raw params: '{params_str}'. " + f"Built command string: '{message_str_for_filter}'", ) - 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_str: - message_str_for_filter += f" {params_str}" - - logger.debug( - f"[Discord] Slash command '{cmd_name}' triggered. " - f"Raw params: '{params_str}'. " - f"Built command string: '{message_str_for_filter}'", - ) - # 2. 构建 AstrBotMessage - channel = ctx.channel - abm = AstrBotMessage() - if channel is not None: - abm.type = self._get_message_type(channel, ctx.guild_id) - abm.group_id = self._get_channel_id(channel) - else: - # 防守式兜底:channel 取不到时,仍能根据 guild_id/channel_id 推断会话信息 - abm.type = ( - MessageType.GROUP_MESSAGE - if ctx.guild_id is not None - else MessageType.FRIEND_MESSAGE + channel = ctx.channel + abm = AstrBotMessage() + if channel is not None: + abm.type = self._get_message_type(channel, ctx.guild_id) + abm.group_id = self._get_channel_id(channel) + else: + abm.type = ( + MessageType.GROUP_MESSAGE + if ctx.guild_id is not None + else MessageType.FRIEND_MESSAGE + ) + abm.group_id = ( + str(ctx.channel_id) if ctx.channel_id is not None else "" + ) + + author = ctx.author + abm.message_str = message_str_for_filter + abm.sender = MessageMember( + user_id=str(getattr(author, "id", "") or ""), + nickname=getattr(author, "display_name", None) + or getattr(author, "name", None) + or "", + ) + abm.message = [Plain(text=message_str_for_filter)] + abm.raw_message = ctx.interaction + abm.self_id = cast(str, self.bot_self_id) + abm.session_id = ( + str(ctx.channel_id) if ctx.channel_id is not None else "" ) - abm.group_id = str(ctx.channel_id) + abm.message_id = str(ctx.interaction.id) - abm.message_str = message_str_for_filter - abm.sender = MessageMember( - user_id=str(ctx.author.id), - nickname=ctx.author.display_name, + await self.handle_msg(abm, followup_webhook) + except Exception as e: + logger.error( + f"[Discord] Slash command '{cmd_name}' handler error: {e}", + exc_info=True, + ) + try: + if followup_webhook is not None: + await followup_webhook.send( + "指令执行出错,请稍后重试。", + ephemeral=True, + ) + except Exception: + pass + + # 为 pycord 构造带显式 option 参数的回调(比纯 **kwargs 更稳) + if param_names: + # 生成: async def dynamic_callback(ctx, a=None, b=None, *args, **kwargs) + args_sig = ", ".join(f"{n}=None" for n in param_names) + # 注意:参数名已通过 Discord 名校验,可安全用于代码生成 + func_src = ( + f"async def dynamic_callback(ctx, {args_sig}, *args, **kwargs):\n" + f" bound = {{}}\n" ) - abm.message = [Plain(text=message_str_for_filter)] - abm.raw_message = ctx.interaction - abm.self_id = ( - str(self.bot_self_id) if self.bot_self_id is not None else "unknown" + for n in param_names: + func_src += f" bound[{n!r}] = {n} if {n} is not None else kwargs.get({n!r})\n" + func_src += ( + " # 位置参兜底\n" + " if args:\n" + f" _names = {param_names!r}\n" + " for _i, _v in enumerate(args):\n" + " if _i < len(_names) and bound.get(_names[_i]) is None:\n" + " bound[_names[_i]] = _v\n" + " for _k, _v in kwargs.items():\n" + " if bound.get(_k) is None:\n" + " bound[_k] = _v\n" + " await _handle(ctx, bound)\n" ) - abm.session_id = str(ctx.channel_id) - abm.message_id = str(ctx.interaction.id) + local_ns: dict[str, Any] = {"_handle": _handle} + try: + exec(func_src, local_ns) # noqa: S102 — 参数名已白名单校验 + return local_ns["dynamic_callback"] + except Exception as e: + logger.warning( + f"[Discord] Failed to build typed callback for '{cmd_name}': {e}; " + "falling back to **kwargs callback.", + exc_info=True, + ) - # 3. 将消息和 webhook 分别交给 handle_msg 处理 - await self.handle_msg(abm, followup_webhook) + async def dynamic_callback( + ctx: discord.ApplicationContext, *args, **kwargs + ) -> None: + bound: dict[str, Any] = dict(kwargs) + if args and param_names: + for idx, value in enumerate(args): + if idx < len(param_names) and bound.get(param_names[idx]) is None: + bound[param_names[idx]] = value + await _handle(ctx, bound) return dynamic_callback @@ -610,42 +879,47 @@ def _extract_command_info( ) -> tuple[str, str, CommandFilter | None] | None: """从事件过滤器中提取指令信息""" cmd_name = None - # is_group = False cmd_filter_instance = None if isinstance(event_filter, CommandFilter): # 暂不支持子指令注册为斜杠指令 - if ( - event_filter.parent_command_names - and event_filter.parent_command_names != [""] - ): + parent_names = getattr(event_filter, "parent_command_names", None) + if parent_names and parent_names != [""]: return None - cmd_name = event_filter.command_name + cmd_name = getattr(event_filter, "command_name", None) cmd_filter_instance = event_filter - elif isinstance(event_filter, CommandGroupFilter): - # 暂不支持指令组直接注册为斜杠指令,因为它们没有 handle 方法 + # 暂不支持指令组直接注册为斜杠指令 + return None + + if not cmd_name or not isinstance(cmd_name, str): return None + cmd_name = cmd_name.strip() if not cmd_name: return None - # Discord 斜杠指令名称规范 - # 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}") + # Discord 允许 Unicode(含中文),须全小写、1-32 + if cmd_name != cmd_name.lower() or not _DISCORD_CMD_NAME_RE.match(cmd_name): + logger.warning( + f"[Discord] 跳过无法注册的斜杠命令名: '{cmd_name}'。" + "须为 1-32 位小写字符(可用中文/字母/数字/_/-)," + "大写或非法字符请改名或通过 alias 触发。" + ) return None - # 优先用 handler 的 docstring 作为命令描述 handler = handler_metadata.handler - description = ( - (handler.__doc__ or "").strip().split("\n")[0] - if hasattr(handler, "__doc__") and handler.__doc__ - else "" - ) + description = "" + doc = getattr(handler, "__doc__", None) + if doc: + description = str(doc).strip().split("\n")[0].strip() + if not description: + description = (getattr(handler_metadata, "desc", None) or "").strip() if not description: - description = handler_metadata.desc or f"Command: {cmd_name}" - if len(description) > 100: - description = f"{description[:97]}..." + description = f"Command: {cmd_name}" + if len(description) > _DISCORD_DESC_MAX: + description = f"{description[: _DISCORD_DESC_MAX - 3]}..." + if not description.strip(): + description = f"Command: {cmd_name}" return cmd_name, description, cmd_filter_instance From 41edab4b71c2ed2081732455d11da5ba4e318165 Mon Sep 17 00:00:00 2001 From: NLKASHEI Date: Wed, 22 Jul 2026 18:29:17 +0800 Subject: [PATCH 10/11] Simplify logger warning statements in Discord adapter --- .../platform/sources/discord/discord_platform_adapter.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index af4431a4d4..781c077932 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -109,9 +109,7 @@ async def _resolve_channel(self, channel_id: int): try: return await self.client.fetch_channel(channel_id) except Exception as e: - logger.warning( - f"[Discord] fetch_channel({channel_id}) failed: {e}" - ) + logger.warning(f"[Discord] fetch_channel({channel_id}) failed: {e}") return None @override @@ -744,9 +742,7 @@ async def _handle( pass return except Exception as e: - logger.warning( - f"[Discord] Failed to defer command '{cmd_name}': {e}" - ) + logger.warning(f"[Discord] Failed to defer command '{cmd_name}': {e}") try: if not ctx.interaction.response.is_done(): await ctx.respond("指令响应失败,请稍后重试。", ephemeral=True) From 9a066588c2d44982a66a33ff3fc7eadc3e4ea0ba Mon Sep 17 00:00:00 2001 From: NLKASHEI Date: Wed, 22 Jul 2026 18:35:09 +0800 Subject: [PATCH 11/11] Update discord_platform_adapter.py --- .../discord/discord_platform_adapter.py | 839 ++++++------------ 1 file changed, 247 insertions(+), 592 deletions(-) diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 781c077932..2a690c597b 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -1,12 +1,12 @@ import asyncio -import inspect import re import sys -import types -import typing from typing import Any, cast import discord +from discord.abc import GuildChannel, Messageable, PrivateChannel +from discord.channel import DMChannel + from astrbot import logger from astrbot.api.event import MessageChain from astrbot.api.message_components import File, Image, Plain, Record @@ -24,8 +24,6 @@ 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 discord.abc import GuildChannel, Messageable, PrivateChannel -from discord.channel import DMChannel from .client import DiscordBotClient from .discord_platform_event import DiscordPlatformEvent @@ -35,13 +33,8 @@ else: from typing_extensions import override -# Discord CHAT_INPUT 命令名:小写 + 字母/数字(含 Unicode,中文可用)/_/- ,1-32 -# 官方近似: ^[-_\p{L}\p{N}\p{sc=Deva}\p{sc=Thai}]{1,32}$ -_DISCORD_CMD_NAME_RE = re.compile(r"^[-_\w]{1,32}$") -_DISCORD_MAX_OPTIONS = 25 -_DISCORD_DESC_MAX = 100 - +# 注册平台适配器 @register_platform_adapter( "discord", "Discord 适配器 (基于 Pycord)", support_streaming_message=False ) @@ -55,62 +48,13 @@ def __init__( super().__init__(platform_config, event_queue) self.settings = platform_settings self.bot_self_id: str | None = None - self.registered_handlers: list = [] - self.enable_command_register = bool( - self.config.get("discord_command_register", True) - ) - self.guild_id = self._normalize_guild_id( - self.config.get("discord_guild_id_for_debug", None) - ) + self.registered_handlers = [] + # 指令注册相关 + self.enable_command_register = self.config.get("discord_command_register", True) + self.guild_id = self.config.get("discord_guild_id_for_debug", None) self.activity_name = self.config.get("discord_activity_name", None) self.shutdown_event = asyncio.Event() - self._polling_task: asyncio.Task | None = None - # 避免 run() 前访问 client 触发 AttributeError - self.client: DiscordBotClient | None = None - - @staticmethod - def _normalize_guild_id(raw: Any) -> int | None: - if raw is None or raw == "": - return None - try: - return int(raw) - except (TypeError, ValueError): - logger.warning(f"[Discord] Invalid discord_guild_id_for_debug: {raw!r}") - return None - - def _client_ready(self) -> bool: - return self.client is not None and self.client.user is not None - - def _ensure_bot_self_id(self) -> None: - if self.bot_self_id is None and self.client is not None and self.client.user: - self.bot_self_id = str(self.client.user.id) - - @staticmethod - def _resolve_channel_id_from_session(session_id: str) -> str: - """从 session_id 解析 channel id,不修改调用方对象。 - - 兼容: - - 纯数字 channel id - - ``platform_channelId`` / ``discord_123`` 等形式(取最后一段) - """ - if not session_id: - return session_id - if "_" in session_id: - return session_id.rsplit("_", 1)[-1] - return session_id - - async def _resolve_channel(self, channel_id: int): - """先读缓存,未命中再 fetch,避免冷启动 get_channel 恒为 None。""" - if self.client is None: - return None - channel = self.client.get_channel(channel_id) - if channel is not None: - return channel - try: - return await self.client.fetch_channel(channel_id) - except Exception as e: - logger.warning(f"[Discord] fetch_channel({channel_id}) failed: {e}") - return None + self._polling_task = None @override async def send_by_session( @@ -119,54 +63,45 @@ async def send_by_session( message_chain: MessageChain, ) -> None: """通过会话发送消息""" - if not self._client_ready(): + if 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 (self.client.user is None); message send skipped" ) return - assert self.client is not None and self.client.user is not None - self._ensure_bot_self_id() - - session_id = self._resolve_channel_id_from_session(session.session_id) + # 创建一个 message_obj 以便在 event 中使用 + message_obj = AstrBotMessage() + if "_" in session.session_id: + session.session_id = session.session_id.split("_")[1] + channel_id_str = session.session_id channel = None try: - channel_id = int(session_id) - channel = await self._resolve_channel(channel_id) + channel_id = int(channel_id_str) + channel = self.client.get_channel(channel_id) except (ValueError, TypeError): - logger.warning(f"[Discord] Invalid channel ID format: {session_id}") + logger.warning(f"[Discord] Invalid channel ID format: {channel_id_str}") - message_obj = AstrBotMessage() - if channel is not None: + if channel: message_obj.type = self._get_message_type(channel) message_obj.group_id = self._get_channel_id(channel) else: logger.warning( - f"[Discord] Can't get channel info for {session_id}, " - "will guess message type.", + f"[Discord] Can't get channel info for {channel_id_str}, will guess message type.", ) message_obj.type = MessageType.GROUP_MESSAGE - message_obj.group_id = session_id + message_obj.group_id = session.session_id message_obj.message_str = message_chain.get_plain_text() message_obj.sender = MessageMember( - user_id=str(self.bot_self_id or ""), + user_id=str(self.bot_self_id), nickname=self.client.user.display_name, ) message_obj.self_id = cast(str, self.bot_self_id) - message_obj.session_id = session_id + message_obj.session_id = session.session_id message_obj.message = message_chain.chain - try: - temp_event = self.create_event(message_obj) - await temp_event.send(message_chain) - except Exception as e: - logger.error( - f"[Discord] send_by_session failed for channel {session_id}: {e}", - exc_info=True, - ) - raise + temp_event = self.create_event(message_obj) + await temp_event.send(message_chain) await super().send_by_session(session, message_chain) @override @@ -184,70 +119,45 @@ def meta(self) -> PlatformMetadata: async def run(self) -> None: """主要运行逻辑""" + # 初始化回调函数 async def on_received(message_data) -> None: - try: - logger.debug(f"[Discord] Message received: {message_data}") - if self.bot_self_id is None and isinstance(message_data, dict): - bot_id = message_data.get("bot_id") - if bot_id is not None: - self.bot_self_id = str(bot_id) - self._ensure_bot_self_id() - abm = await self.convert_message(data=message_data) - await self.handle_msg(abm) - except Exception as e: - logger.error( - f"[Discord] Error while handling received message: {e}", - exc_info=True, - ) - - raw_token = self.config.get("discord_token") - token = str(raw_token).strip() if raw_token else "" + logger.debug(f"[Discord] Message received: {message_data}") + if self.bot_self_id is None: + self.bot_self_id = message_data.get("bot_id") + abm = await self.convert_message(data=message_data) + await self.handle_msg(abm) + + # 初始化 Discord 客户端 + token = str(self.config.get("discord_token")) if not token: logger.error( - "[Discord] Bot token is not configured. " - "Please set a valid token in the config file." + "[Discord] Bot token is not configured. Please set a valid token in the config file." ) return proxy = self.config.get("discord_proxy") or None - if isinstance(proxy, str): - proxy = proxy.strip() or None allow_bot_messages = bool(self.config.get("discord_allow_bot_messages")) - client = DiscordBotClient(token, proxy, allow_bot_messages) - self.client = client - client.on_message_received = on_received + self.client = DiscordBotClient(token, proxy, allow_bot_messages) + self.client.on_message_received = on_received - async def on_ready_once() -> None: + async def callback() -> None: try: - self._ensure_bot_self_id() if self.enable_command_register: await self._collect_and_register_commands() - if self.activity_name and self.client is not None: + if self.activity_name: await self.client.change_presence( status=discord.Status.online, - activity=discord.CustomActivity(name=str(self.activity_name)), + activity=discord.CustomActivity(name=self.activity_name), ) except Exception as e: logger.error( - f"[Discord] on_ready_once_callback err: {e}", - exc_info=True, + f"[Discord] on_ready_once_callback err: {e}", exc_info=True ) - client.on_ready_once_callback = on_ready_once - - def _on_polling_done(task: asyncio.Task) -> None: - if task.cancelled(): - return - exc = task.exception() - if exc is not None: - logger.error( - f"[Discord] Polling task crashed: {exc}", - exc_info=exc, - ) + self.client.on_ready_once_callback = callback try: - self._polling_task = asyncio.create_task(client.start_polling()) - self._polling_task.add_done_callback(_on_polling_done) + self._polling_task = asyncio.create_task(self.client.start_polling()) await self.shutdown_event.wait() except discord.errors.LoginFailure: logger.error( @@ -276,128 +186,109 @@ def _get_message_type( def _get_channel_id( self, channel: Messageable | GuildChannel | PrivateChannel ) -> str: - """根据 channel 对象获取 ID;无 id 时返回空串,避免 'None' 污染会话""" - channel_id = getattr(channel, "id", None) - return str(channel_id) if channel_id is not None else "" - - def _strip_bot_leading_mentions(self, content: str, message) -> str: - """只剥离开头属于本 bot 的 user/role mention,避免误删其它 @。 - - 支持开头连续的 bot user mention + bot role mention。 - """ - if not content: - return content - - bot_user = self.client.user if self.client else None - bot_id = bot_user.id if bot_user else None - if bot_id is None: - return content - - changed = True - while changed: - changed = False - for mention_str in (f"<@{bot_id}>", f"<@!{bot_id}>"): - if content.startswith(mention_str): - content = content[len(mention_str) :].lstrip() - changed = True - break - if changed: - continue - - guild = getattr(message, "guild", None) - if not guild: - break - try: - bot_member = guild.get_member(bot_id) - except Exception: - bot_member = None - if not bot_member or not getattr(bot_member, "roles", None): - break - for role in bot_member.roles: - role_mention_str = f"<@&{role.id}>" - if content.startswith(role_mention_str): - content = content[len(role_mention_str) :].lstrip() - changed = True - break - - return content + """根据 channel 对象获取ID""" + return str(getattr(channel, "id", None)) def _convert_message_to_abm(self, data: dict) -> AstrBotMessage: """将普通消息转换为 AstrBotMessage""" message = data["message"] - content = message.content or "" - content = self._strip_bot_leading_mentions(content, message) + content = message.content + + # 如果机器人被@,移除@部分 + # 剥离 User Mention (<@id>, <@!id>) + if self.client and self.client.user: + mention_str = f"<@{self.client.user.id}>" + mention_str_nickname = f"<@!{self.client.user.id}>" + if content.startswith(mention_str): + content = content[len(mention_str) :].lstrip() + elif content.startswith(mention_str_nickname): + content = content[len(mention_str_nickname) :].lstrip() + + # 剥离 Role Mention(bot 拥有的任一角色被提及,<@&role_id>) + if ( + hasattr(message, "role_mentions") + 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 + ) + if bot_member and hasattr(bot_member, "roles"): + for role in bot_member.roles: + role_mention_str = f"<@&{role.id}>" + if content.startswith(role_mention_str): + content = content[len(role_mention_str) :].lstrip() + break # 只剥离第一个匹配的角色 mention abm = AstrBotMessage() abm.type = self._get_message_type(message.channel) abm.group_id = self._get_channel_id(message.channel) abm.message_str = content - - author = message.author abm.sender = MessageMember( - user_id=str(getattr(author, "id", "") or ""), - nickname=getattr(author, "display_name", None) - or getattr(author, "name", None) - or "", + user_id=str(message.author.id), + nickname=message.author.display_name, ) - - message_chain: list = [] + message_chain = [] if abm.message_str: message_chain.append(Plain(text=abm.message_str)) - - attachments = getattr(message, "attachments", None) or [] - for attachment in attachments: - ct = (getattr(attachment, "content_type", None) or "").lower() - url = getattr(attachment, "url", None) or "" - filename = getattr(attachment, "filename", None) or "file" - if not url: - continue - if ct.startswith("image/"): - message_chain.append(Image(file=url, filename=filename)) - elif ct.startswith("audio/"): - message_chain.append(Record(file=url, url=url)) - else: - message_chain.append(File(name=filename, url=url)) - + if message.attachments: + for attachment in message.attachments: + if attachment.content_type and attachment.content_type.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), + ) + 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) - channel_id = getattr(message.channel, "id", None) - abm.session_id = str(channel_id) if channel_id is not None else "" - abm.message_id = str(getattr(message, "id", "") or "") + abm.session_id = str(message.channel.id) + abm.message_id = str(message.id) return abm async def convert_message(self, data: dict) -> AstrBotMessage: - """将平台消息转换成 AstrBotMessage,并对音频做本地解析""" + """将平台消息转换成 AstrBotMessage""" + # 由于 on_interaction 已被禁用,我们只处理普通消息 abm = self._convert_message_to_abm(data) for component in abm.message: - if not isinstance(component, Record): - continue - audio_ref = component.url or component.file - if not audio_ref: - continue - try: - path_wav = await MediaResolver( - audio_ref, - media_type="audio", - default_suffix=".wav", - ).to_path(target_format="wav") - component.file = path_wav - component.url = path_wav - if hasattr(component, "path"): + if isinstance(component, Record): + audio_ref = component.url or component.file + if audio_ref: + path_wav = await MediaResolver( + audio_ref, + media_type="audio", + default_suffix=".wav", + ).to_path(target_format="wav") + component.file = path_wav + component.url = path_wav component.path = path_wav - except Exception as e: - logger.warning( - f"[Discord] Failed to resolve audio attachment: {e}", - exc_info=True, - ) return abm def create_event( self, message: AstrBotMessage, followup_webhook=None ) -> DiscordPlatformEvent: - """创建 Discord 消息事件""" + """Creates a Discord message event. + + Args: + message: AstrBot message object to wrap. + followup_webhook: Optional slash-command follow-up webhook. + + Returns: + Created Discord message event. + """ return DiscordPlatformEvent( message_str=message.message_str, message_obj=message, @@ -409,29 +300,26 @@ def create_event( async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> None: """处理消息""" - if not self._client_ready(): + 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" + "[Discord] Client is not ready (self.client.user is None); message handling skipped" ) return - assert self.client is not None and self.client.user is not None - self._ensure_bot_self_id() + # 检查是否为斜杠指令 + is_slash_command = message_event.interaction_followup_webhook is not None - # 补写 self_id,避免上游仍是 None - if not message.self_id and self.bot_self_id: - message.self_id = self.bot_self_id - - message_event = self.create_event(message, followup_webhook) - - # 斜杠指令优先 - if message_event.interaction_followup_webhook is not None: + # 1. 优先处理斜杠指令 + if is_slash_command: message_event.is_wake = True message_event.is_at_or_wake_command = True self.commit_event(message_event) return + # 2. 处理普通消息(提及检测) + # 确保 raw_message 是 discord.Message 类型,以便静态检查通过 raw_message = message.raw_message if not isinstance(raw_message, discord.Message): logger.warning( @@ -439,25 +327,35 @@ async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> No ) return + # 检查是否被@(User Mention 或 Bot 拥有的 Role Mention) is_mention = False - try: - if self.client.user in (raw_message.mentions or []): - is_mention = True - except Exception: - is_mention = False - if not is_mention and raw_message.role_mentions and raw_message.guild: + # User Mention + # 此时 Pylance 知道 raw_message 是 discord.Message,具有 mentions 属性 + if self.client.user in raw_message.mentions: + is_mention = True + + # Role Mention(Bot 拥有的角色被提及) + if not is_mention and raw_message.role_mentions: bot_member = None - try: - bot_member = raw_message.guild.get_member(self.client.user.id) - except Exception: - bot_member = None - if bot_member and getattr(bot_member, "roles", None): + if raw_message.guild: + try: + bot_member = raw_message.guild.get_member( + self.client.user.id, + ) + except Exception: + bot_member = None + if bot_member and hasattr(bot_member, "roles"): bot_roles = set(bot_member.roles) mentioned_roles = set(raw_message.role_mentions) - if bot_roles.intersection(mentioned_roles): + if ( + bot_roles + and mentioned_roles + and bot_roles.intersection(mentioned_roles) + ): is_mention = True + # 如果是被@的消息,设置为唤醒状态 if is_mention: message_event.is_wake = True message_event.is_at_or_wake_command = True @@ -468,13 +366,14 @@ 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() - logger.info("[Discord] Cleaning up commands...") - if self.enable_command_register and self.client is not None: + if self.enable_command_register and self.client: try: - guild_ids = [self.guild_id] if self.guild_id else None await asyncio.wait_for( - self.client.sync_commands(commands=[], guild_ids=guild_ids), + self.client.sync_commands( + commands=[], + guild_ids=[self.guild_id] if self.guild_id else None, + ), timeout=10, ) logger.info("[Discord] Commands cleaned up successfully.") @@ -483,22 +382,18 @@ async def terminate(self) -> None: f"[Discord] Error occurred while cleaning up commands: {e}" ) - if self._polling_task is not None: + if self._polling_task: self._polling_task.cancel() try: - await asyncio.wait_for(asyncio.shield(self._polling_task), timeout=10) + await asyncio.wait_for(self._polling_task, timeout=10) except asyncio.CancelledError: logger.info("[Discord] Polling task cancelled successfully.") - except asyncio.TimeoutError: - logger.warning("[Discord] Polling task cancel timed out.") except Exception as e: logger.warning( f"[Discord] Error occurred while cancelling polling task: {e}" ) - self._polling_task = None - logger.info("[Discord] Closing client connection...") - if self.client is not None and hasattr(self.client, "close"): + if self.client and hasattr(self.client, "close"): try: await asyncio.wait_for(self.client.close(), timeout=10) except Exception as e: @@ -510,91 +405,57 @@ def register_handler(self, handler_info) -> None: self.registered_handlers.append(handler_info) async def _collect_and_register_commands(self) -> None: - """收集所有指令并注册到 Discord""" - if self.client is None: - logger.warning("[Discord] Client is None; skip command registration.") - return - + """收集所有指令并注册到Discord""" logger.info("[Discord] Collecting and registering slash commands...") - registered_commands: list[str] = [] - seen_names: set[str] = set() + registered_commands = [] for handler_md in star_handlers_registry: - plugin = star_map.get(handler_md.handler_module_path) - if not plugin or not plugin.activated: + if not star_map[handler_md.handler_module_path].activated: continue if not handler_md.enabled: continue - for event_filter in handler_md.event_filters: cmd_info = self._extract_command_info(event_filter, handler_md) if not cmd_info: continue - cmd_name, description, _cmd_filter = cmd_info - if cmd_name in seen_names: - logger.warning( - f"[Discord] Duplicate slash command '{cmd_name}' skipped." - ) - continue - - options = self._build_slash_options(handler_md.handler) - if len(options) > _DISCORD_MAX_OPTIONS: - logger.warning( - f"[Discord] Command '{cmd_name}' has {len(options)} options; " - f"truncating to {_DISCORD_MAX_OPTIONS} (Discord limit)." - ) - options = options[:_DISCORD_MAX_OPTIONS] - - # 无签名参数时保留通用 params,兼容原版自由文本参数 - if not options: - options = [ - discord.Option( - name="params", - description="指令的所有参数", - type=discord.SlashCommandOptionType.string, - required=False, - ), - ] - - # Discord 要求 required options 排在 optional 之前 - options = sorted(options, key=lambda o: (not bool(o.required), o.name)) - param_names = [o.name for o in options] - callback = self._create_dynamic_callback(cmd_name, param_names) + cmd_name, description, cmd_filter_instance = cmd_info - try: - slash_command = discord.SlashCommand( - name=cmd_name, - description=description, - func=callback, - options=options, - guild_ids=[self.guild_id] if self.guild_id else None, - ) - self.client.add_application_command(slash_command) - except Exception as e: - logger.warning( - f"[Discord] Failed to add command '{cmd_name}': {e}", - exc_info=True, - ) - continue + # 创建动态回调 + callback = self._create_dynamic_callback(cmd_name) - seen_names.add(cmd_name) + # 创建一个通用的参数选项来接收所有文本输入 + options = [ + discord.Option( + name="params", + description="指令的所有参数", + type=discord.SlashCommandOptionType.string, + required=False, + ), + ] + + # 创建SlashCommand + slash_command = discord.SlashCommand( + name=cmd_name, + description=description, + func=callback, + options=options, + guild_ids=[self.guild_id] if self.guild_id else None, + ) + self.client.add_application_command(slash_command) registered_commands.append(cmd_name) if registered_commands: logger.info( - f"[Discord] Ready to sync {len(registered_commands)} commands: " - f"{', '.join(registered_commands)}", + f"[Discord] Ready to sync {len(registered_commands)} commands: {', '.join(registered_commands)}", ) else: logger.info("[Discord] No commands found for registration.") + # 使用 Pycord 的方法同步指令 + # 注意:这可能需要一些时间,并且有频率限制 try: - # guild 调试模式下只同步到该 guild,避免全局配额与延迟 - if self.guild_id: - await self.client.sync_commands(guild_ids=[self.guild_id]) - else: - await self.client.sync_commands() + await self.client.sync_commands() logger.info("[Discord] Command synchronization completed.") except discord.HTTPException as e: if self._is_daily_command_quota_error(e): @@ -605,266 +466,74 @@ async def _collect_and_register_commands(self) -> None: ) return logger.warning(f"[Discord] Sync commands failed: {e}") - except Exception as e: - logger.warning(f"[Discord] Sync commands failed: {e}", exc_info=True) @staticmethod def _is_daily_command_quota_error(error: discord.HTTPException) -> bool: return getattr(error, "code", None) == 30034 - @staticmethod - def _unwrap_optional(annotation: Any) -> Any: - origin = typing.get_origin(annotation) - union_types: set[Any] = {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: - return non_none[0] - return annotation - - @staticmethod - def _is_valid_discord_option_name(name: str) -> bool: - return bool(name and _DISCORD_CMD_NAME_RE.match(name) and name == name.lower()) - - @classmethod - def _build_slash_options(cls, handler) -> list: - """从 handler 函数签名提取参数,映射到 Discord Option。 - - - 跳过 self/event、*args/**kwargs - - 校验 option 名 - - required 优先(调用方再统一 sort) - """ - options: list = [] - seen: set[str] = set() - try: - try: - type_hints = typing.get_type_hints(handler) - except Exception: - type_hints = {} + def _create_dynamic_callback(self, cmd_name: str): + """为每个指令动态创建一个异步回调函数""" - sig = inspect.signature(handler) - for name, param in sig.parameters.items(): - if name in ("self", "event", "cls"): - continue - if param.kind in ( - inspect.Parameter.VAR_POSITIONAL, - inspect.Parameter.VAR_KEYWORD, - ): - continue - if not cls._is_valid_discord_option_name(name): - logger.warning( - f"[Discord] Skip invalid option name '{name}' " - f"for handler {handler!r}" - ) - continue - if name in seen: - continue - seen.add(name) - - annotation = type_hints.get(name, param.annotation) - if annotation is inspect.Parameter.empty: - annotation = str - annotation = cls._unwrap_optional(annotation) - - 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}"[:_DISCORD_DESC_MAX], - type=opt_type, - required=required, - ) - ) - except Exception as e: - logger.warning( - f"[Discord] Failed to build slash options for {handler!r}: {e}", - exc_info=True, - ) - return options - - @staticmethod - def _format_slash_param_value(value: Any) -> str: - """将 slash 参数序列化为命令字符串片段。 - - - bool/数字直接转文本 - - 含空格或引号时用双引号包裹,避免简单 split 错位 - """ - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, (int, float)): - return str(value) - text = str(value) - if not text: - return '""' - if re.search(r"\s", text) or any(c in text for c in "\"'"): - escaped = text.replace("\\", "\\\\").replace('"', '\\"') - return f'"{escaped}"' - return text - - def _create_dynamic_callback(self, cmd_name: str, param_names: list | None = None): - """为每个指令动态创建异步回调。 - - 使用显式命名参数签名,兼容 pycord 按参数名注入 option 值; - 同时接受 *args/**kwargs 作兜底。 - """ - param_names = list(param_names or []) - - async def _handle( - ctx: discord.ApplicationContext, - bound: dict[str, Any], + async def dynamic_callback( + ctx: discord.ApplicationContext, params: str | None = None ) -> None: + # 1. 嘗試立即响应,防止超时 (移到最前面) followup_webhook = None try: - # 已响应过则不要再 defer - if not ctx.interaction.response.is_done(): - await asyncio.wait_for(ctx.defer(), timeout=2.5) + # 設定 2.5 秒超時,避免卡死整個 event loop + await asyncio.wait_for(ctx.defer(), timeout=2.5) followup_webhook = ctx.followup except asyncio.TimeoutError: logger.warning( - f"[Discord] Defer command '{cmd_name}' timeout. " - "Network might be too slow." + f"[Discord] Defer command '{cmd_name}' timeout. Network might be too slow." ) - try: - if not ctx.interaction.response.is_done(): - await ctx.respond("处理超时,请稍后重试。", ephemeral=True) - except Exception: - pass return except Exception as e: logger.warning(f"[Discord] Failed to defer command '{cmd_name}': {e}") - try: - if not ctx.interaction.response.is_done(): - await ctx.respond("指令响应失败,请稍后重试。", ephemeral=True) - except Exception: - pass return - try: - self._ensure_bot_self_id() - logger.debug(f"[Discord] Callback triggered: {cmd_name}, bound={bound}") - - parts: list[str] = [] - if param_names: - for name in param_names: - if name not in bound or bound[name] is None: - continue - parts.append(self._format_slash_param_value(bound[name])) - else: - for v in bound.values(): - if v is not None: - parts.append(self._format_slash_param_value(v)) - params_str = " ".join(parts) - - message_str_for_filter = cmd_name - if params_str: - message_str_for_filter += f" {params_str}" - - logger.debug( - f"[Discord] Slash command '{cmd_name}' triggered. " - f"Raw params: '{params_str}'. " - f"Built command string: '{message_str_for_filter}'", - ) - - channel = ctx.channel - abm = AstrBotMessage() - if channel is not None: - abm.type = self._get_message_type(channel, ctx.guild_id) - abm.group_id = self._get_channel_id(channel) - else: - abm.type = ( - MessageType.GROUP_MESSAGE - if ctx.guild_id is not None - else MessageType.FRIEND_MESSAGE - ) - abm.group_id = ( - str(ctx.channel_id) if ctx.channel_id is not None else "" - ) + # 将平台特定的前缀'/'剥离,以适配通用的CommandFilter + logger.debug(f"[Discord] Callback triggered: {cmd_name}") + logger.debug(f"[Discord] Callback context: {ctx}") + logger.debug(f"[Discord] Callback params: {params}") + message_str_for_filter = cmd_name + if params: + message_str_for_filter += f" {params}" + + logger.debug( + f"[Discord] Slash command '{cmd_name}' triggered. " + f"Raw params: '{params}'. " + f"Built command string: '{message_str_for_filter}'", + ) - author = ctx.author - abm.message_str = message_str_for_filter - abm.sender = MessageMember( - user_id=str(getattr(author, "id", "") or ""), - nickname=getattr(author, "display_name", None) - or getattr(author, "name", None) - or "", - ) - abm.message = [Plain(text=message_str_for_filter)] - abm.raw_message = ctx.interaction - abm.self_id = cast(str, self.bot_self_id) - abm.session_id = ( - str(ctx.channel_id) if ctx.channel_id is not None else "" + # 2. 构建 AstrBotMessage + channel = ctx.channel + abm = AstrBotMessage() + if channel is not None: + abm.type = self._get_message_type(channel, ctx.guild_id) + abm.group_id = self._get_channel_id(channel) + else: + # 防守式兜底:channel 取不到时,仍能根据 guild_id/channel_id 推断会话信息 + abm.type = ( + MessageType.GROUP_MESSAGE + if ctx.guild_id is not None + else MessageType.FRIEND_MESSAGE ) - abm.message_id = str(ctx.interaction.id) + abm.group_id = str(ctx.channel_id) - await self.handle_msg(abm, followup_webhook) - except Exception as e: - logger.error( - f"[Discord] Slash command '{cmd_name}' handler error: {e}", - exc_info=True, - ) - try: - if followup_webhook is not None: - await followup_webhook.send( - "指令执行出错,请稍后重试。", - ephemeral=True, - ) - except Exception: - pass - - # 为 pycord 构造带显式 option 参数的回调(比纯 **kwargs 更稳) - if param_names: - # 生成: async def dynamic_callback(ctx, a=None, b=None, *args, **kwargs) - args_sig = ", ".join(f"{n}=None" for n in param_names) - # 注意:参数名已通过 Discord 名校验,可安全用于代码生成 - func_src = ( - f"async def dynamic_callback(ctx, {args_sig}, *args, **kwargs):\n" - f" bound = {{}}\n" - ) - for n in param_names: - func_src += f" bound[{n!r}] = {n} if {n} is not None else kwargs.get({n!r})\n" - func_src += ( - " # 位置参兜底\n" - " if args:\n" - f" _names = {param_names!r}\n" - " for _i, _v in enumerate(args):\n" - " if _i < len(_names) and bound.get(_names[_i]) is None:\n" - " bound[_names[_i]] = _v\n" - " for _k, _v in kwargs.items():\n" - " if bound.get(_k) is None:\n" - " bound[_k] = _v\n" - " await _handle(ctx, bound)\n" + abm.message_str = message_str_for_filter + abm.sender = MessageMember( + user_id=str(ctx.author.id), + nickname=ctx.author.display_name, ) - local_ns: dict[str, Any] = {"_handle": _handle} - try: - exec(func_src, local_ns) # noqa: S102 — 参数名已白名单校验 - return local_ns["dynamic_callback"] - except Exception as e: - logger.warning( - f"[Discord] Failed to build typed callback for '{cmd_name}': {e}; " - "falling back to **kwargs callback.", - exc_info=True, - ) + abm.message = [Plain(text=message_str_for_filter)] + abm.raw_message = ctx.interaction + abm.self_id = cast(str, self.bot_self_id) + abm.session_id = str(ctx.channel_id) + abm.message_id = str(ctx.interaction.id) - async def dynamic_callback( - ctx: discord.ApplicationContext, *args, **kwargs - ) -> None: - bound: dict[str, Any] = dict(kwargs) - if args and param_names: - for idx, value in enumerate(args): - if idx < len(param_names) and bound.get(param_names[idx]) is None: - bound[param_names[idx]] = value - await _handle(ctx, bound) + # 3. 将消息和 webhook 分别交给 handle_msg 处理 + await self.handle_msg(abm, followup_webhook) return dynamic_callback @@ -875,47 +544,33 @@ def _extract_command_info( ) -> tuple[str, str, CommandFilter | None] | None: """从事件过滤器中提取指令信息""" cmd_name = None + # is_group = False cmd_filter_instance = None if isinstance(event_filter, CommandFilter): # 暂不支持子指令注册为斜杠指令 - parent_names = getattr(event_filter, "parent_command_names", None) - if parent_names and parent_names != [""]: + if ( + event_filter.parent_command_names + and event_filter.parent_command_names != [""] + ): return None - cmd_name = getattr(event_filter, "command_name", None) + cmd_name = event_filter.command_name cmd_filter_instance = event_filter - elif isinstance(event_filter, CommandGroupFilter): - # 暂不支持指令组直接注册为斜杠指令 - return None - if not cmd_name or not isinstance(cmd_name, str): + elif isinstance(event_filter, CommandGroupFilter): + # 暂不支持指令组直接注册为斜杠指令,因为它们没有 handle 方法 return None - cmd_name = cmd_name.strip() if not cmd_name: return None - # Discord 允许 Unicode(含中文),须全小写、1-32 - if cmd_name != cmd_name.lower() or not _DISCORD_CMD_NAME_RE.match(cmd_name): - logger.warning( - f"[Discord] 跳过无法注册的斜杠命令名: '{cmd_name}'。" - "须为 1-32 位小写字符(可用中文/字母/数字/_/-)," - "大写或非法字符请改名或通过 alias 触发。" - ) + # Discord 斜杠指令名称规范 + 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 - handler = handler_metadata.handler - description = "" - doc = getattr(handler, "__doc__", None) - if doc: - description = str(doc).strip().split("\n")[0].strip() - if not description: - description = (getattr(handler_metadata, "desc", None) or "").strip() - if not description: - description = f"Command: {cmd_name}" - if len(description) > _DISCORD_DESC_MAX: - description = f"{description[: _DISCORD_DESC_MAX - 3]}..." - if not description.strip(): - description = f"Command: {cmd_name}" + description = handler_metadata.desc or f"Command: {cmd_name}" + if len(description) > 100: + description = f"{description[:97]}..." return cmd_name, description, cmd_filter_instance