refactor(discord-adapter): 整理代码格式并修复Discord交互相关问题

本次提交包含多项优化与修复:
1.  清理导入语句并调整导入顺序,修复datetime导入顺序
2.  重构并发配置函数的换行格式,提升可读性
3.  修复poll模块的API调用方式,改用timedelta设置时长并调整答案添加逻辑
4.  重构REST调度器的协程执行逻辑,支持工厂模式创建协程
5.  新增discord webhook签名验证与交互类型定义
6.  新增线程管理相关的消息动作配置
7.  扩展安全策略的禁用策略支持,优化日志与函数参数格式
8.  重构事件队列的初始化参数格式,优化日志输出
9.  修复组件模块的类型错误:替换弃用的StringSelect为Select,新增提及选择器,修复按钮样式解析,添加视图超时处理
10. 新增丰富的Discord slash命令:模型管理组与用户信息查询命令
11. 新增多账号配置相关的数据类与工具方法
12. 新增Discord消息与交互事件的规范化处理逻辑
This commit is contained in:
Kris 2026-05-13 16:07:32 +08:00
parent 9c01d480cc
commit 9ab904c5bd
14 changed files with 1195 additions and 113 deletions

View File

@ -0,0 +1,106 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class DiscordAccountConfig:
account_id: str
token: str = ""
token_file: str = ""
label: str = ""
enabled: bool = True
weight: int = 1
priority: int = 0
config: dict[str, Any] = field(default_factory=dict)
@property
def resolved_token(self) -> str | None:
if self.token:
return self.token.strip().strip('"').strip("'")
if self.token_file:
import os
if os.path.isfile(self.token_file):
try:
with open(self.token_file, encoding="utf-8") as f:
return f.read().strip()
except (OSError, UnicodeDecodeError):
pass
return None
@property
def configured(self) -> bool:
return bool(self.resolved_token)
DEFAULT_ACCOUNT_ID = "default"
@dataclass
class DiscordMultiAccountConfig:
accounts: dict[str, DiscordAccountConfig] = field(default_factory=dict)
default_account_id: str = DEFAULT_ACCOUNT_ID
@classmethod
def from_config(cls, config: dict | None) -> DiscordMultiAccountConfig:
if not config:
return cls()
accounts_data = config.get("accounts", {})
if not accounts_data:
token = config.get("token", "")
token_file = config.get("tokenFile", "")
if token or token_file:
account = DiscordAccountConfig(
account_id=DEFAULT_ACCOUNT_ID,
token=token,
token_file=token_file,
label="Default",
config=config,
)
return cls(
accounts={DEFAULT_ACCOUNT_ID: account},
default_account_id=DEFAULT_ACCOUNT_ID,
)
accounts: dict[str, DiscordAccountConfig] = {}
for aid, entry in accounts_data.items():
if not isinstance(entry, dict):
continue
accounts[aid] = DiscordAccountConfig(
account_id=aid,
token=entry.get("token", ""),
token_file=entry.get("tokenFile", entry.get("token_file", "")),
label=entry.get("label", aid),
enabled=entry.get("enabled", True),
weight=entry.get("weight", 1),
priority=entry.get("priority", 0),
config=entry,
)
default_id = config.get("default_account_id", DEFAULT_ACCOUNT_ID)
if default_id not in accounts and accounts:
default_id = next(iter(accounts))
return cls(accounts=accounts, default_account_id=default_id)
def resolve_account(self, account_id: str | None = None) -> DiscordAccountConfig | None:
if account_id and account_id in self.accounts:
acc = self.accounts[account_id]
return acc if acc.enabled else None
if self.default_account_id in self.accounts:
acc = self.accounts[self.default_account_id]
return acc if acc.enabled else None
return None
def list_enabled(self) -> list[DiscordAccountConfig]:
return [a for a in self.accounts.values() if a.enabled and a.configured]
def list_account_ids(self) -> list[str]:
return list(self.accounts.keys())
@property
def is_multi(self) -> bool:
return len(self.list_enabled()) > 1

File diff suppressed because it is too large Load Diff

View File

@ -1,10 +1,8 @@
from __future__ import annotations
import re
from typing import Literal
from collections.abc import Iterator
from typing import Literal
ChunkMode = Literal["newline", "length"]

View File

@ -174,6 +174,79 @@ async def register_slash_commands(
else:
await interaction.followup.send(f"切换失败:模型 `{model_name}` 不可用")
model_group = app_commands.Group(name="ai-model", description="AI 模型管理")
@model_group.command(name="list", description="查看当前可用的 AI 模型列表")
async def model_list(interaction: discord.Interaction):
await interaction.response.defer(ephemeral=True)
if get_models_fn is None:
await interaction.followup.send("模型管理功能未启用")
return
models = await get_models_fn()
if models:
model_list = "\n".join(f"• `{m}`" for m in models)
await interaction.followup.send(f"**可用模型:**\n{model_list}")
else:
await interaction.followup.send("当前无可用模型")
async def model_autocomplete(
_interaction: discord.Interaction,
current: str,
) -> list[app_commands.Choice[str]]:
if get_models_fn is None:
return []
try:
models = await get_models_fn()
except Exception:
return []
return [app_commands.Choice(name=m, value=m) for m in models if current.lower() in m.lower()][:25]
@model_group.command(name="switch", description="切换当前使用的 AI 模型")
@app_commands.describe(model_name="要切换到的模型名称")
@app_commands.autocomplete(model_name=model_autocomplete)
async def model_switch(interaction: discord.Interaction, model_name: str):
await interaction.response.defer(ephemeral=True)
if set_model_fn is None:
await interaction.followup.send("模型管理功能未启用")
return
ok = await set_model_fn(str(interaction.user.id), model_name)
if ok:
await interaction.followup.send(f"已切换模型到 `{model_name}`")
else:
await interaction.followup.send(f"切换失败:模型 `{model_name}` 不可用")
tree.add_command(model_group)
@tree.command(name="user-info", description="查看指定用户的 Discord 信息")
@app_commands.describe(user="要查询的用户")
async def user_info(interaction: discord.Interaction, user: discord.User):
await interaction.response.defer(ephemeral=True)
lines = [
"**用户信息:**",
f"• 用户名: `{user.name}`",
f"• 显示名: `{user.display_name}`",
f"• ID: `{user.id}`",
f"• Bot: {'' if user.bot else ''}",
f"• 创建时间: {user.created_at.strftime('%Y-%m-%d %H:%M:%S')}",
]
if interaction.guild:
member = interaction.guild.get_member(user.id)
if member:
lines.append(f"• 昵称: `{member.nick or ''}`")
joined_str = member.joined_at.strftime("%Y-%m-%d %H:%M:%S") if member.joined_at else "未知"
lines.append(f"• 加入时间: {joined_str}")
role_names = [r.name for r in member.roles if r.name != "@everyone"]
if role_names:
lines.append(f"• 角色: {', '.join(f'`{r}`' for r in role_names[:10])}")
await interaction.followup.send("\n".join(lines))
try:
synced = await tree.sync()
logger.info(f"Discord slash commands synced: {len(synced)} commands")

View File

@ -5,6 +5,8 @@ from typing import Any
import discord
from yuxi.utils.logging_config import logger
class ButtonStyle(Enum):
PRIMARY = discord.ButtonStyle.primary
@ -27,7 +29,8 @@ def create_button(
emoji: str | None = None,
disabled: bool = False,
) -> discord.ui.Button:
kwargs: dict[str, Any] = {"label": label, "style": style, "disabled": disabled}
resolved_style = style.value if isinstance(style, ButtonStyle) else style
kwargs: dict[str, Any] = {"label": label, "style": resolved_style, "disabled": disabled}
if custom_id is not None:
kwargs["custom_id"] = custom_id
if url is not None and style in (ButtonStyle.LINK, discord.ButtonStyle.link):
@ -44,7 +47,7 @@ def create_string_select(
min_values: int = 1,
max_values: int = 1,
disabled: bool = False,
) -> discord.ui.StringSelect:
) -> discord.ui.Select:
select_options = [
discord.SelectOption(
label=opt.get("label", ""),
@ -55,7 +58,7 @@ def create_string_select(
)
for opt in options
]
return discord.ui.StringSelect(
return discord.ui.Select(
custom_id=custom_id,
options=select_options,
placeholder=placeholder,
@ -113,6 +116,22 @@ def create_channel_select(
)
def create_mentionable_select(
custom_id: str,
placeholder: str | None = None,
min_values: int = 1,
max_values: int = 1,
disabled: bool = False,
) -> discord.ui.MentionableSelect:
return discord.ui.MentionableSelect(
custom_id=custom_id,
placeholder=placeholder,
min_values=min_values,
max_values=max_values,
disabled=disabled,
)
def create_text_input(
label: str,
custom_id: str,
@ -123,10 +142,11 @@ def create_text_input(
max_length: int | None = None,
default: str | None = None,
) -> discord.ui.TextInput:
resolved_style = style.value if isinstance(style, TextInputStyle) else style
kwargs: dict[str, Any] = {
"label": label,
"custom_id": custom_id,
"style": style,
"style": resolved_style,
"required": required,
}
if placeholder is not None:
@ -157,6 +177,15 @@ def build_view(
timeout: float | None = None,
) -> discord.ui.View:
view = discord.ui.View(timeout=timeout)
async def _on_timeout():
logger.debug(f"Discord View timeout: custom_id={view.id}")
for child in view.children:
child.disabled = True
view.stop()
view.on_timeout = _on_timeout
for row in action_rows:
for item in row:
view.add_item(item)

View File

@ -12,9 +12,13 @@ class EventQueue:
DEFAULT_FORCE_FLUSH_THRESHOLD = 5
DEFAULT_FORCE_FLUSH_MIN_AGE_MS = 200
def __init__(self, max_queue_size: int = 1000, ordering_window_ms: int = 500,
force_flush_threshold: int = DEFAULT_FORCE_FLUSH_THRESHOLD,
force_flush_min_age_ms: int = DEFAULT_FORCE_FLUSH_MIN_AGE_MS):
def __init__(
self,
max_queue_size: int = 1000,
ordering_window_ms: int = 500,
force_flush_threshold: int = DEFAULT_FORCE_FLUSH_THRESHOLD,
force_flush_min_age_ms: int = DEFAULT_FORCE_FLUSH_MIN_AGE_MS,
):
self._max_queue_size = max_queue_size
self._ordering_window_ms = ordering_window_ms
self._force_flush_threshold = force_flush_threshold
@ -44,8 +48,7 @@ class EventQueue:
if len(self._pending) >= self._max_queue_size:
self._pending.popitem(last=False)
self._dropped_count += 1
logger.warning(f"[EventQueue] Queue full, dropping oldest event "
f"(total dropped: {self._dropped_count})")
logger.warning(f"[EventQueue] Queue full, dropping oldest event (total dropped: {self._dropped_count})")
key = f"{sequence:020d}_{event_id}"
self._pending[key] = {
@ -92,8 +95,10 @@ class EventQueue:
if item["enqueued_at"] <= min_age_cutoff:
ready_keys.append(key)
if ready_keys:
logger.debug(f"[EventQueue] Force flush: {len(ready_keys)} events "
f"(pending={len(self._pending)}, threshold={self._force_flush_threshold})")
logger.debug(
f"[EventQueue] Force flush: {len(ready_keys)} events "
f"(pending={len(self._pending)}, threshold={self._force_flush_threshold})"
)
for key in ready_keys:
item = self._pending.pop(key)

View File

@ -1,13 +1,12 @@
from __future__ import annotations
import re
from datetime import datetime, UTC
from datetime import UTC, datetime
from typing import Any
import discord
from yuxi.channels.models import ChannelResponse
from yuxi.utils.logging_config import logger
_SANITIZE_PATTERNS = [
re.compile(r"<system-reminder>[\s\S]*?</system-reminder>", re.IGNORECASE),

View File

@ -103,6 +103,41 @@ DISCORD_MESSAGE_ACTIONS: dict[str, dict[str, Any]] = {
"description": "Lock a thread to prevent further replies",
"api": "modify_thread",
},
"unarchiveThread": {
"status": "implemented",
"description": "Unarchive a thread to make it active again",
"api": "modify_thread",
},
"unlockThread": {
"status": "implemented",
"description": "Unlock a thread to allow replies again",
"api": "modify_thread",
},
"joinThread": {
"status": "implemented",
"description": "Join a thread as the bot user",
"api": "join_thread",
},
"leaveThread": {
"status": "implemented",
"description": "Leave a thread as the bot user",
"api": "leave_thread",
},
"addThreadMember": {
"status": "implemented",
"description": "Add a user to a private thread",
"api": "add_thread_member",
},
"removeThreadMember": {
"status": "implemented",
"description": "Remove a user from a private thread",
"api": "remove_thread_member",
},
"listActiveThreads": {
"status": "implemented",
"description": "List all active threads in a guild",
"api": "list_active_threads",
},
"fetchMessage": {
"status": "implemented",
"description": "Fetch a single message by ID from a channel",

View File

@ -11,6 +11,7 @@ from yuxi.channels.models import (
ChannelIdentity,
ChannelMessage,
ChannelType,
ChatType,
EventType,
MentionsInfo,
MessageType,
@ -335,3 +336,188 @@ class DiscordMessageNormalizer:
if changes:
msg.metadata["changes"] = changes
return msg
@staticmethod
def normalize_interaction(interaction: discord.Interaction) -> ChannelMessage:
guild = interaction.guild
channel = interaction.channel
if guild and channel:
if isinstance(channel, discord.Thread):
chat_id = f"thread_{channel.id}"
chat_type = ChatType.THREAD
else:
chat_id = f"guild_{guild.id}_channel_{channel.id}"
chat_type = ChatType.GUILD_CHANNEL
else:
chat_id = f"dm_{interaction.user.id}"
chat_type = ChatType.DIRECT
interaction_data = getattr(interaction, "data", {}) or {}
metadata: dict[str, Any] = {
"interaction_type": str(interaction.type),
"interaction_id": str(interaction.id),
"token": interaction.token,
"custom_id": interaction_data.get("custom_id"),
"command_name": interaction_data.get("name"),
}
if guild:
metadata["guild_id"] = str(guild.id)
metadata["guild_name"] = guild.name
if channel:
metadata["channel_id"] = str(channel.id)
metadata["channel_name"] = getattr(channel, "name", None)
if interaction.type == discord.InteractionType.component:
metadata["component_type"] = str(interaction_data.get("component_type", "unknown"))
elif interaction.type == discord.InteractionType.modal_submit:
components = interaction_data.get("components", [])
input_values: dict[str, str] = {}
for comp in components:
for sub in comp.get("components", []):
input_values[sub.get("custom_id", "")] = sub.get("value", "")
metadata["modal_values"] = input_values
content = ""
if interaction.type == discord.InteractionType.component:
content = f"component:{interaction_data.get('custom_id', '')}"
elif interaction.type == discord.InteractionType.modal_submit:
content = f"modal:{interaction_data.get('custom_id', '')}"
elif interaction.type == discord.InteractionType.application_command:
content = f"/{interaction_data.get('name', '')}"
return ChannelMessage(
identity=ChannelIdentity(
channel_id="discord",
channel_type=ChannelType.DISCORD,
channel_user_id=str(interaction.user.id),
channel_chat_id=chat_id,
channel_message_id=str(interaction.id),
),
event_type=EventType.INTERACTION,
chat_type=chat_type,
content=content,
metadata=metadata,
)
@staticmethod
def normalize_channel_event(channel: discord.abc.GuildChannel, event_type: EventType) -> ChannelMessage:
guild = channel.guild
guild_id = str(guild.id) if guild else ""
channel_id = str(channel.id)
metadata: dict[str, Any] = {
"guild_id": guild_id,
"channel_id": channel_id,
"channel_name": channel.name,
"channel_type": str(channel.type),
}
if guild:
metadata["guild_name"] = guild.name
if hasattr(channel, "position"):
metadata["position"] = channel.position
if hasattr(channel, "parent_id") and channel.parent_id:
metadata["parent_id"] = str(channel.parent_id)
return ChannelMessage(
identity=ChannelIdentity(
channel_id="discord",
channel_type=ChannelType.DISCORD,
channel_user_id="",
channel_chat_id=guild_id,
channel_message_id=channel_id,
),
event_type=event_type,
content=channel.name,
metadata=metadata,
)
@staticmethod
def normalize_channel_update(before: discord.abc.GuildChannel, after: discord.abc.GuildChannel) -> ChannelMessage:
msg = DiscordMessageNormalizer.normalize_channel_event(after, EventType.CHANNEL_UPDATED)
changes: dict[str, Any] = {}
if before.name != after.name:
changes["name_before"] = before.name
changes["name_after"] = after.name
if hasattr(before, "position") and hasattr(after, "position") and before.position != after.position:
changes["position_before"] = before.position
changes["position_after"] = after.position
if hasattr(before, "topic") and hasattr(after, "topic") and before.topic != after.topic:
changes["topic_before"] = before.topic
changes["topic_after"] = after.topic
if hasattr(before, "nsfw") and hasattr(after, "nsfw") and before.nsfw != after.nsfw:
changes["nsfw_before"] = before.nsfw
changes["nsfw_after"] = after.nsfw
if changes:
msg.metadata["changes"] = changes
return msg
@staticmethod
def normalize_interaction_raw(data: dict) -> ChannelMessage:
interaction_type = data.get("type", 0)
user_data = data.get("user", {}) or data.get("member", {}).get("user", {})
user_id = str(user_data.get("id", ""))
interaction_id = str(data.get("id", ""))
interaction_token = data.get("token", "")
guild_id = data.get("guild_id")
channel_id = data.get("channel_id")
if guild_id and channel_id:
chat_id = f"guild_{guild_id}_channel_{channel_id}"
chat_type = ChatType.GUILD_CHANNEL
elif channel_id:
chat_id = f"dm_{user_id}"
chat_type = ChatType.DIRECT
else:
chat_id = f"dm_{user_id}"
chat_type = ChatType.DIRECT
interaction_data = data.get("data", {}) or {}
metadata: dict[str, Any] = {
"interaction_type": str(interaction_type),
"interaction_id": interaction_id,
"token": interaction_token,
"custom_id": interaction_data.get("custom_id"),
"command_name": interaction_data.get("name"),
}
if guild_id:
metadata["guild_id"] = str(guild_id)
if channel_id:
metadata["channel_id"] = str(channel_id)
if interaction_type == 3:
metadata["component_type"] = str(interaction_data.get("component_type", "unknown"))
elif interaction_type == 5:
components = interaction_data.get("components", [])
input_values: dict[str, str] = {}
for comp in components:
for sub in comp.get("components", []):
input_values[sub.get("custom_id", "")] = sub.get("value", "")
metadata["modal_values"] = input_values
content = ""
if interaction_type == 3:
content = f"component:{interaction_data.get('custom_id', '')}"
elif interaction_type == 5:
content = f"modal:{interaction_data.get('custom_id', '')}"
elif interaction_type == 2:
content = f"/{interaction_data.get('name', '')}"
return ChannelMessage(
identity=ChannelIdentity(
channel_id="discord",
channel_type=ChannelType.DISCORD,
channel_user_id=user_id,
channel_chat_id=chat_id,
channel_message_id=interaction_id,
),
event_type=EventType.INTERACTION,
chat_type=chat_type,
content=content,
metadata=metadata,
)

View File

@ -1,5 +1,7 @@
from __future__ import annotations
from datetime import timedelta
import discord
from yuxi.channels.models import DeliveryResult
@ -20,13 +22,13 @@ async def create_poll(
return DeliveryResult(success=False, error="Question too long (max 300 characters)")
try:
poll_answers = [discord.PollAnswer(text=opt[:55]) for opt in options]
poll = discord.Poll(
question=discord.PollMedia(text=question[:300]),
answers=poll_answers,
duration=duration_hours,
allow_multiselect=allow_multiselect,
question=question[:300],
duration=timedelta(hours=duration_hours),
multiple=allow_multiselect,
)
for opt in options:
poll.add_answer(text=opt[:55])
msg = await channel.send(poll=poll)
return DeliveryResult(
success=True,

View File

@ -60,11 +60,17 @@ def _get_global_limiter() -> _GlobalRateLimit:
class RESTRequest:
def __init__(self, route: str, coro, priority: int = 0):
self.route = route
self.coro = coro
self._coro_factory = coro if callable(coro) else None
self._coro = None if callable(coro) else coro
self.priority = priority
self.created_at = time.monotonic()
self.future: asyncio.Future = asyncio.Future()
async def execute_coro(self):
if self._coro_factory is not None:
self._coro = self._coro_factory()
return await self._coro
def __lt__(self, other: RESTRequest) -> bool:
if self.priority != other.priority:
return self.priority > other.priority
@ -122,7 +128,7 @@ class RESTScheduler:
last_error = None
for attempt in range(self._retry_attempts):
try:
result = await request.coro
result = await request.execute_coro()
if not request.future.done():
request.future.set_result(result)
return

View File

@ -9,7 +9,7 @@ _PolicyPairing = "pairing"
_PolicyAllowlist = "allowlist"
_PolicyDisabled = "disabled"
DM_POLICIES = {_PolicyOpen, _PolicyPairing, _PolicyAllowlist}
DM_POLICIES = {_PolicyOpen, _PolicyPairing, _PolicyAllowlist, _PolicyDisabled}
GROUP_POLICIES = {_PolicyOpen, _PolicyAllowlist, _PolicyDisabled}
@ -37,7 +37,9 @@ class DiscordSecurityPolicy:
self._guild_configs = guilds_config
if self._dangerously_allow_name_matching:
logger.warning("[Discord/Security] Name-based guild matching is active — use numeric guild IDs in production")
logger.warning(
"[Discord/Security] Name-based guild matching is active — use numeric guild IDs in production"
)
def is_dm_allowed(self, user_id: str) -> bool:
if self.dm_policy == _PolicyOpen:
@ -87,7 +89,9 @@ class DiscordSecurityPolicy:
return True, ""
allowed_channels = guild_config.get("allowChannels", [])
if self._match_channel(channel_id, channel_name, allowed_channels, allow_name_match=self._dangerously_allow_name_matching):
if self._match_channel(
channel_id, channel_name, allowed_channels, allow_name_match=self._dangerously_allow_name_matching
):
return True, ""
allowed_roles = guild_config.get("allowRoles", [])
@ -125,7 +129,9 @@ class DiscordSecurityPolicy:
return {}
@staticmethod
def _match_channel(channel_id: str, channel_name: str | None, allowed_channels: list[str], *, allow_name_match: bool = False) -> bool:
def _match_channel(
channel_id: str, channel_name: str | None, allowed_channels: list[str], *, allow_name_match: bool = False
) -> bool:
if channel_id in allowed_channels:
return True
if allow_name_match and channel_name:

View File

@ -24,7 +24,9 @@ _global_semaphore: asyncio.Semaphore | None = None
_PRE_ROUTE_SEMAPHORES: dict[str, asyncio.Semaphore] = {}
def configure_concurrency(global_limit: int = _DEFAULT_GLOBAL_CONCURRENCY, route_limit: int = _DEFAULT_ROUTE_CONCURRENCY) -> None:
def configure_concurrency(
global_limit: int = _DEFAULT_GLOBAL_CONCURRENCY, route_limit: int = _DEFAULT_ROUTE_CONCURRENCY
) -> None:
global _global_semaphore
_global_semaphore = asyncio.Semaphore(global_limit)
_PRE_ROUTE_SEMAPHORES.clear()
@ -37,6 +39,7 @@ def _get_global_semaphore() -> asyncio.Semaphore:
_global_semaphore = asyncio.Semaphore(_DEFAULT_GLOBAL_CONCURRENCY)
return _global_semaphore
ChunkMode = Literal["newline", "length"]

View File

@ -0,0 +1,55 @@
from __future__ import annotations
from enum import IntEnum
try:
import nacl.bindings
import nacl.exceptions
except ImportError:
nacl = None
class InteractionType(IntEnum):
PING = 1
APPLICATION_COMMAND = 2
MESSAGE_COMPONENT = 3
APPLICATION_COMMAND_AUTOCOMPLETE = 4
MODAL_SUBMIT = 5
class InteractionCallbackType(IntEnum):
PONG = 1
CHANNEL_MESSAGE_WITH_SOURCE = 4
DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE = 5
DEFERRED_UPDATE_MESSAGE = 6
UPDATE_MESSAGE = 7
APPLICATION_COMMAND_AUTOCOMPLETE_RESULT = 8
MODAL = 9
def verify_ed25519_signature(
public_key_hex: str,
signature_hex: str,
timestamp: str,
body: str,
) -> bool:
if nacl is None:
raise ImportError("PyNaCl is required for Ed25519 signature verification. Install it with: pip install pynacl")
try:
public_key_bytes = bytes.fromhex(public_key_hex)
signature_bytes = bytes.fromhex(signature_hex)
except ValueError:
return False
message = timestamp.encode() + body.encode()
try:
nacl.bindings.crypto_sign_verify(signature_bytes, message, public_key_bytes)
return True
except nacl.exceptions.BadSignatureError:
return False
def handle_ping_interaction() -> dict[str, int]:
return {"type": InteractionCallbackType.PONG}