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 from __future__ import annotations
import re import re
from typing import Literal
from collections.abc import Iterator from collections.abc import Iterator
from typing import Literal
ChunkMode = Literal["newline", "length"] ChunkMode = Literal["newline", "length"]

View File

@ -174,6 +174,79 @@ async def register_slash_commands(
else: else:
await interaction.followup.send(f"切换失败:模型 `{model_name}` 不可用") 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: try:
synced = await tree.sync() synced = await tree.sync()
logger.info(f"Discord slash commands synced: {len(synced)} commands") logger.info(f"Discord slash commands synced: {len(synced)} commands")

View File

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

View File

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

View File

@ -1,13 +1,12 @@
from __future__ import annotations from __future__ import annotations
import re import re
from datetime import datetime, UTC from datetime import UTC, datetime
from typing import Any from typing import Any
import discord import discord
from yuxi.channels.models import ChannelResponse from yuxi.channels.models import ChannelResponse
from yuxi.utils.logging_config import logger
_SANITIZE_PATTERNS = [ _SANITIZE_PATTERNS = [
re.compile(r"<system-reminder>[\s\S]*?</system-reminder>", re.IGNORECASE), 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", "description": "Lock a thread to prevent further replies",
"api": "modify_thread", "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": { "fetchMessage": {
"status": "implemented", "status": "implemented",
"description": "Fetch a single message by ID from a channel", "description": "Fetch a single message by ID from a channel",

View File

@ -11,6 +11,7 @@ from yuxi.channels.models import (
ChannelIdentity, ChannelIdentity,
ChannelMessage, ChannelMessage,
ChannelType, ChannelType,
ChatType,
EventType, EventType,
MentionsInfo, MentionsInfo,
MessageType, MessageType,
@ -335,3 +336,188 @@ class DiscordMessageNormalizer:
if changes: if changes:
msg.metadata["changes"] = changes msg.metadata["changes"] = changes
return msg 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 __future__ import annotations
from datetime import timedelta
import discord import discord
from yuxi.channels.models import DeliveryResult 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)") return DeliveryResult(success=False, error="Question too long (max 300 characters)")
try: try:
poll_answers = [discord.PollAnswer(text=opt[:55]) for opt in options]
poll = discord.Poll( poll = discord.Poll(
question=discord.PollMedia(text=question[:300]), question=question[:300],
answers=poll_answers, duration=timedelta(hours=duration_hours),
duration=duration_hours, multiple=allow_multiselect,
allow_multiselect=allow_multiselect,
) )
for opt in options:
poll.add_answer(text=opt[:55])
msg = await channel.send(poll=poll) msg = await channel.send(poll=poll)
return DeliveryResult( return DeliveryResult(
success=True, success=True,

View File

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

View File

@ -9,7 +9,7 @@ _PolicyPairing = "pairing"
_PolicyAllowlist = "allowlist" _PolicyAllowlist = "allowlist"
_PolicyDisabled = "disabled" _PolicyDisabled = "disabled"
DM_POLICIES = {_PolicyOpen, _PolicyPairing, _PolicyAllowlist} DM_POLICIES = {_PolicyOpen, _PolicyPairing, _PolicyAllowlist, _PolicyDisabled}
GROUP_POLICIES = {_PolicyOpen, _PolicyAllowlist, _PolicyDisabled} GROUP_POLICIES = {_PolicyOpen, _PolicyAllowlist, _PolicyDisabled}
@ -37,7 +37,9 @@ class DiscordSecurityPolicy:
self._guild_configs = guilds_config self._guild_configs = guilds_config
if self._dangerously_allow_name_matching: 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: def is_dm_allowed(self, user_id: str) -> bool:
if self.dm_policy == _PolicyOpen: if self.dm_policy == _PolicyOpen:
@ -87,7 +89,9 @@ class DiscordSecurityPolicy:
return True, "" return True, ""
allowed_channels = guild_config.get("allowChannels", []) 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, "" return True, ""
allowed_roles = guild_config.get("allowRoles", []) allowed_roles = guild_config.get("allowRoles", [])
@ -125,7 +129,9 @@ class DiscordSecurityPolicy:
return {} return {}
@staticmethod @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: if channel_id in allowed_channels:
return True return True
if allow_name_match and channel_name: 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] = {} _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 _global_semaphore
_global_semaphore = asyncio.Semaphore(global_limit) _global_semaphore = asyncio.Semaphore(global_limit)
_PRE_ROUTE_SEMAPHORES.clear() _PRE_ROUTE_SEMAPHORES.clear()
@ -37,6 +39,7 @@ def _get_global_semaphore() -> asyncio.Semaphore:
_global_semaphore = asyncio.Semaphore(_DEFAULT_GLOBAL_CONCURRENCY) _global_semaphore = asyncio.Semaphore(_DEFAULT_GLOBAL_CONCURRENCY)
return _global_semaphore return _global_semaphore
ChunkMode = Literal["newline", "length"] 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}