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:
parent
9c01d480cc
commit
9ab904c5bd
106
backend/package/yuxi/channels/adapters/discord/accounts.py
Normal file
106
backend/package/yuxi/channels/adapters/discord/accounts.py
Normal 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
@ -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"]
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
|
||||
55
backend/package/yuxi/channels/adapters/discord/webhook.py
Normal file
55
backend/package/yuxi/channels/adapters/discord/webhook.py
Normal 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}
|
||||
Loading…
Reference in New Issue
Block a user