diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml
index 6c5a0090..4c6304e4 100644
--- a/.github/workflows/dashboard_ci.yml
+++ b/.github/workflows/dashboard_ci.yml
@@ -1,6 +1,10 @@
name: AstrBot Dashboard CI
-on: [push]
+on:
+ push:
+ branches: [ "master" ]
+ pull_request:
+ branches: [ "master" ]
jobs:
build:
diff --git a/README.md b/README.md
index 4e8c1a82..c6799435 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
> 🪧 我们正基于前沿科研成果,设计并实现适用于角色扮演和情感陪伴的长短期记忆模型及情绪控制模型,旨在提升对话的真实性与情感表达能力。敬请期待 `v3.6.0` 版本!
1. **大语言模型对话**。支持各种大语言模型,包括 OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM 等,支持接入本地部署的大模型,通过 Ollama、LLMTuner。具有多轮对话、人格情境、多模态能力,支持图片理解、语音转文字(Whisper)。
-2. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat)、飞书、Telegram。后续将支持钉钉、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核。
+2. **多消息平台接入**。支持接入 QQ(OneBot、QQ 官方机器人平台)、QQ 频道、微信、企业微信、微信公众号、飞书、Telegram、钉钉、Discord、KOOK、VoceChat。支持速率限制、白名单、关键词过滤、百度内容审核。
3. **Agent**。原生支持部分 Agent 能力,如代码执行器、自然语言待办、网页搜索。对接 [Dify 平台](https://dify.ai/),便捷接入 Dify 智能助手、知识库和 Dify 工作流。
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,极简开发。已支持安装多个插件。
5. **可视化管理面板**。支持可视化修改配置、插件管理、日志查看等功能,降低配置难度。集成 WebChat,可在面板上与大模型对话。
@@ -117,20 +117,24 @@ uvx astrbot init
## ⚡ 消息平台支持情况
-| 平台 | 支持性 | 详情 | 消息类型 |
-| -------- | ------- | ------- | ------ |
-| QQ(官方机器人接口) | ✔ | 私聊、群聊,QQ 频道私聊、群聊 | 文字、图片 |
-| QQ(OneBot) | ✔ | 私聊、群聊 | 文字、图片、语音 |
-| 微信个人号 | ✔ | 微信个人号私聊、群聊 | 文字、图片、语音 |
-| Telegram | ✔ | 私聊、群聊 | 文字、图片 |
-| 企业微信 | ✔ | 私聊 | 文字、图片、语音 |
-| 微信客服 | ✔ | 私聊 | 文字、图片 |
-| 飞书 | ✔ | 私聊、群聊 | 文字、图片 |
-| 钉钉 | ✔ | 私聊、群聊 | 文字、图片 |
-| 微信对话开放平台 | 🚧 | 计划内 | - |
-| Discord | 🚧 | 计划内 | - |
-| WhatsApp | 🚧 | 计划内 | - |
-| 小爱音响 | 🚧 | 计划内 | - |
+| 平台 | 支持性 |
+| -------- | ------- |
+| QQ(官方机器人接口) | ✔ |
+| QQ(OneBot) | ✔ |
+| 微信个人号 | ✔ |
+| Telegram | ✔ |
+| 企业微信 | ✔ |
+| 微信客服 | ✔ |
+| 微信公众号 | ✔ |
+| 飞书 | ✔ |
+| 钉钉 | ✔ |
+| Slack | ✔ |
+| Discord | ✔ |
+| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | ✔ |
+| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ |
+| 微信对话开放平台 | 🚧 |
+| WhatsApp | 🚧 |
+| 小爱音响 | 🚧 |
## ⚡ 提供商支持情况
@@ -151,6 +155,7 @@ uvx astrbot init
| SenseVoice | ✔ | 语音转文本 | 本地部署 |
| OpenAI TTS API | ✔ | 文本转语音 | |
| GSVI | ✔ | 文本转语音 | GPT-Sovits-Inference |
+| GPT-SoVITs | ✔ | 文本转语音 | GPT-Sovits-Inference |
| FishAudio | ✔ | 文本转语音 | GPT-Sovits 作者参与的项目 |
| Edge TTS | ✔ | 文本转语音 | Edge 浏览器的免费 TTS |
| 阿里云百炼 TTS | ✔ | 文本转语音 | |
diff --git a/astrbot/core/__init__.py b/astrbot/core/__init__.py
index bce4073c..104a9edb 100644
--- a/astrbot/core/__init__.py
+++ b/astrbot/core/__init__.py
@@ -13,7 +13,6 @@ from .utils.astrbot_path import get_astrbot_data_path
# 初始化数据存储文件夹
os.makedirs(get_astrbot_data_path(), exist_ok=True)
-WEBUI_SK = "Advanced_System_for_Text_Response_and_Bot_Operations_Tool"
DEMO_MODE = os.getenv("DEMO_MODE", False)
astrbot_config = AstrBotConfig()
@@ -31,4 +30,3 @@ pip_installer = PipInstaller(
)
web_chat_queue = asyncio.Queue(maxsize=32)
web_chat_back_queue = asyncio.Queue(maxsize=32)
-
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index 32dd1b45..b2ce1bfe 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -5,13 +5,14 @@
import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
-VERSION = "3.5.15"
+VERSION = "3.5.17"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db")
# 默认配置
DEFAULT_CONFIG = {
"config_version": 2,
"platform_settings": {
+ "plugin_enable":[],
"unique_session": False,
"rate_limit": {
"time": 60,
@@ -102,6 +103,7 @@ DEFAULT_CONFIG = {
"enable": True,
"username": "astrbot",
"password": "77b90590a8945a7d36c963981a307dc9",
+ "jwt_secret": "",
"host": "0.0.0.0",
"port": 6185,
},
@@ -126,7 +128,7 @@ CONFIG_METADATA_2 = {
"description": "消息平台适配器",
"type": "list",
"config_template": {
- "qq_official(QQ)": {
+ "QQ 官方机器人(WebSocket)": {
"id": "default",
"type": "qq_official",
"enable": False,
@@ -135,7 +137,7 @@ CONFIG_METADATA_2 = {
"enable_group_c2c": True,
"enable_guild_direct_message": True,
},
- "qq_official_webhook(QQ)": {
+ "QQ 官方机器人(Webhook)": {
"id": "default",
"type": "qq_official_webhook",
"enable": False,
@@ -144,7 +146,7 @@ CONFIG_METADATA_2 = {
"callback_server_host": "0.0.0.0",
"port": 6196,
},
- "aiocqhttp(OneBotv11)": {
+ "QQ 个人号(aiocqhttp)": {
"id": "default",
"type": "aiocqhttp",
"enable": False,
@@ -152,7 +154,7 @@ CONFIG_METADATA_2 = {
"ws_reverse_port": 6199,
"ws_reverse_token": "",
},
- "gewechat(微信)": {
+ "微信个人号(Gewechat)": {
"id": "gwchat",
"type": "gewechat",
"enable": False,
@@ -161,7 +163,7 @@ CONFIG_METADATA_2 = {
"host": "这里填写你的局域网IP或者公网服务器IP",
"port": 11451,
},
- "wechatpadpro(微信)": {
+ "微信个人号(WeChatPadPro)": {
"id": "wechatpadpro",
"type": "wechatpadpro",
"enable": False,
@@ -171,7 +173,7 @@ CONFIG_METADATA_2 = {
"wpp_active_message_poll": False,
"wpp_active_message_poll_interval": 3,
},
- "weixin_official_account(微信公众平台)": {
+ "微信公众平台": {
"id": "weixin_official_account",
"type": "weixin_official_account",
"enable": False,
@@ -184,7 +186,7 @@ CONFIG_METADATA_2 = {
"port": 6194,
"active_send_mode": False,
},
- "wecom(企业微信)": {
+ "企业微信(含微信客服)": {
"id": "wecom",
"type": "wecom",
"enable": False,
@@ -197,7 +199,7 @@ CONFIG_METADATA_2 = {
"callback_server_host": "0.0.0.0",
"port": 6195,
},
- "lark(飞书)": {
+ "飞书(Lark)": {
"id": "lark",
"type": "lark",
"enable": False,
@@ -206,14 +208,14 @@ CONFIG_METADATA_2 = {
"app_secret": "",
"domain": "https://open.feishu.cn",
},
- "dingtalk(钉钉)": {
+ "钉钉(DingTalk)": {
"id": "dingtalk",
"type": "dingtalk",
"enable": False,
"client_id": "",
"client_secret": "",
},
- "telegram": {
+ "Telegram": {
"id": "telegram",
"type": "telegram",
"enable": False,
@@ -225,8 +227,51 @@ CONFIG_METADATA_2 = {
"telegram_command_auto_refresh": True,
"telegram_command_register_interval": 300,
},
+ "Discord": {
+ "id": "discord",
+ "type": "discord",
+ "enable": False,
+ "discord_token": "",
+ "discord_proxy": "",
+ "discord_command_register": True,
+ "discord_guild_id_for_debug": "",
+ "discord_activity_name": "",
+ },
+ "Slack": {
+ "id": "slack",
+ "type": "slack",
+ "enable": False,
+ "bot_token": "",
+ "app_token": "",
+ "signing_secret": "",
+ "slack_connection_mode": "socket", # webhook, socket
+ "slack_webhook_host": "0.0.0.0",
+ "slack_webhook_port": 6197,
+ "slack_webhook_path": "/astrbot-slack-webhook/callback",
+ },
},
"items": {
+ "slack_connection_mode": {
+ "description": "Slack Connection Mode",
+ "type": "string",
+ "options": ["webhook", "socket"],
+ "hint": "The connection mode for Slack. `webhook` uses a webhook server, `socket` uses Slack's Socket Mode.",
+ },
+ "slack_webhook_host": {
+ "description": "Slack Webhook Host",
+ "type": "string",
+ "hint": "Only valid when Slack connection mode is `webhook`.",
+ },
+ "slack_webhook_port": {
+ "description": "Slack Webhook Port",
+ "type": "int",
+ "hint": "Only valid when Slack connection mode is `webhook`.",
+ },
+ "slack_webhook_path": {
+ "description": "Slack Webhook Path",
+ "type": "string",
+ "hint": "Only valid when Slack connection mode is `webhook`.",
+ },
"active_send_mode": {
"description": "是否换用主动发送接口",
"type": "bool",
@@ -324,6 +369,29 @@ CONFIG_METADATA_2 = {
"hint": "请务必填对,否则 @ 机器人将无法唤醒,只能通过前缀唤醒。",
"obvious_hint": True,
},
+ "discord_token": {
+ "description": "Discord Bot Token",
+ "type": "string",
+ "hint": "在此处填入你的Discord Bot Token",
+ },
+ "discord_proxy": {
+ "description": "Discord 代理地址",
+ "type": "string",
+ "hint": "可选的代理地址:http://ip:port",
+ },
+ "discord_command_register": {
+ "description": "是否自动将插件指令注册为 Discord 斜杠指令",
+ "type": "bool",
+ },
+ "discord_activity_name": {
+ "description": "Discord 活动名称",
+ "type": "string",
+ "hint": "可选的 Discord 活动名称。留空则不设置活动。",
+ },
+ "discord_guild_id_for_debug": {
+ "description": "【开发用】指定一个服务器(Guild)ID。在此服务器注册的指令会立刻生效,便于调试。留空则注册为全局指令。",
+ "type": "string",
+ },
},
},
"platform_settings": {
@@ -800,6 +868,37 @@ CONFIG_METADATA_2 = {
"edge-tts-voice": "zh-CN-XiaoxiaoNeural",
"timeout": 20,
},
+ "GSV TTS(本地加载)": {
+ "id": "gsv_tts",
+ "enable": False,
+ "type": "gsv_tts_selfhost",
+ "provider_type": "text_to_speech",
+ "api_base": "http://127.0.0.1:9880",
+ "gpt_weights_path": "",
+ "sovits_weights_path": "",
+ "timeout": 60,
+ "gsv_default_parms": {
+ "gsv_ref_audio_path": "",
+ "gsv_prompt_text": "",
+ "gsv_prompt_lang": "zh",
+ "gsv_aux_ref_audio_paths": "",
+ "gsv_text_lang": "zh",
+ "gsv_top_k": 5,
+ "gsv_top_p": 1.0,
+ "gsv_temperature": 1.0,
+ "gsv_text_split_method": "cut3",
+ "gsv_batch_size": 1,
+ "gsv_batch_threshold": 0.75,
+ "gsv_split_bucket": True,
+ "gsv_speed_factor": 1,
+ "gsv_fragment_interval": 0.3,
+ "gsv_streaming_mode": False,
+ "gsv_seed": -1,
+ "gsv_parallel_infer": True,
+ "gsv_repetition_penalty": 1.35,
+ "gsv_media_type": "wav",
+ },
+ },
"GSVI TTS(API)": {
"id": "gsvi_tts",
"type": "gsvi_tts_api",
@@ -901,6 +1000,130 @@ CONFIG_METADATA_2 = {
},
},
"items": {
+ "gpt_weights_path": {
+ "description": "GPT模型文件路径",
+ "type": "string",
+ "hint": "即“.ckpt”后缀的文件,请使用绝对路径,路径两端不要带双引号,不填则默认用GPT_SoVITS内置的SoVITS模型(建议直接在GPT_SoVITS中改默认模型)",
+ "obvious_hint": True,
+ },
+ "sovits_weights_path": {
+ "description": "SoVITS模型文件路径",
+ "type": "string",
+ "hint": "即“.pth”后缀的文件,请使用绝对路径,路径两端不要带双引号,不填则默认用GPT_SoVITS内置的SoVITS模型(建议直接在GPT_SoVITS中改默认模型)",
+ "obvious_hint": True,
+ },
+ "gsv_default_parms": {
+ "description": "GPT_SoVITS默认参数",
+ "hint": "参考音频文件路径、参考音频文本必填,其他参数根据个人爱好自行填写",
+ "type": "object",
+ "items": {
+ "gsv_ref_audio_path": {
+ "description": "参考音频文件路径",
+ "type": "string",
+ "hint": "必填!请使用绝对路径!路径两端不要带双引号!",
+ "obvious_hint": True,
+ },
+ "gsv_prompt_text": {
+ "description": "参考音频文本",
+ "type": "string",
+ "hint": "必填!请填写参考音频讲述的文本",
+ "obvious_hint": True,
+ },
+ "gsv_prompt_lang": {
+ "description": "参考音频文本语言",
+ "type": "string",
+ "hint": "请填写参考音频讲述的文本的语言,默认为中文",
+ },
+ "gsv_aux_ref_audio_paths": {
+ "description": "辅助参考音频文件路径",
+ "type": "string",
+ "hint": "辅助参考音频文件,可不填",
+ },
+ "gsv_text_lang": {
+ "description": "文本语言",
+ "type": "string",
+ "hint": "默认为中文",
+ },
+ "gsv_top_k": {
+ "description": "生成语音的多样性",
+ "type": "int",
+ "hint": "",
+ },
+ "gsv_top_p": {
+ "description": "核采样的阈值",
+ "type": "float",
+ "hint": "",
+ },
+ "gsv_temperature": {
+ "description": "生成语音的随机性",
+ "type": "float",
+ "hint": "",
+ },
+ "gsv_text_split_method": {
+ "description": "切分文本的方法",
+ "type": "string",
+ "hint": "可选值: `cut0`:不切分 `cut1`:四句一切 `cut2`:50字一切 `cut3`:按中文句号切 `cut4`:按英文句号切 `cut5`:按标点符号切",
+ "options": [
+ "cut0",
+ "cut1",
+ "cut2",
+ "cut3",
+ "cut4",
+ "cut5",
+ ],
+ },
+ "gsv_batch_size": {
+ "description": "批处理大小",
+ "type": "int",
+ "hint": "",
+ },
+ "gsv_batch_threshold": {
+ "description": "批处理阈值",
+ "type": "float",
+ "hint": "",
+ },
+ "gsv_split_bucket": {
+ "description": "将文本分割成桶以便并行处理",
+ "type": "bool",
+ "hint": "",
+ },
+ "gsv_speed_factor": {
+ "description": "语音播放速度",
+ "type": "float",
+ "hint": "1为原始语速",
+ },
+ "gsv_fragment_interval": {
+ "description": "语音片段之间的间隔时间",
+ "type": "float",
+ "hint": "",
+ },
+ "gsv_streaming_mode": {
+ "description": "启用流模式",
+ "type": "bool",
+ "hint": "",
+ },
+ "gsv_seed": {
+ "description": "随机种子",
+ "type": "int",
+ "hint": "用于结果的可重复性",
+ },
+ "gsv_parallel_infer": {
+ "description": "并行执行推理",
+ "type": "bool",
+ "hint": "",
+ },
+ "gsv_repetition_penalty": {
+ "description": "重复惩罚因子",
+ "type": "float",
+ "hint": "",
+ },
+ "gsv_media_type": {
+ "description": "输出媒体的类型",
+ "type": "string",
+ "hint": "建议用wav",
+ },
+ },
+ },
"embedding_dimensions": {
"description": "嵌入维度",
"type": "int",
diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py
index 49490056..63ec2798 100644
--- a/astrbot/core/platform/manager.py
+++ b/astrbot/core/platform/manager.py
@@ -77,7 +77,15 @@ class PlatformManager:
case "wecom":
from .sources.wecom.wecom_adapter import WecomPlatformAdapter # noqa: F401
case "weixin_official_account":
- from .sources.weixin_official_account.weixin_offacc_adapter import WeixinOfficialAccountPlatformAdapter # noqa
+ from .sources.weixin_official_account.weixin_offacc_adapter import (
+ WeixinOfficialAccountPlatformAdapter, # noqa
+ )
+ case "discord":
+ from .sources.discord.discord_platform_adapter import (
+ DiscordPlatformAdapter, # noqa: F401
+ )
+ case "slack":
+ from .sources.slack.slack_adapter import SlackAdapter # noqa: F401
except (ImportError, ModuleNotFoundError) as e:
logger.error(
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。"
diff --git a/astrbot/core/platform/sources/discord/client.py b/astrbot/core/platform/sources/discord/client.py
new file mode 100644
index 00000000..2e3fd89b
--- /dev/null
+++ b/astrbot/core/platform/sources/discord/client.py
@@ -0,0 +1,126 @@
+import discord
+from astrbot import logger
+import sys
+
+if sys.version_info >= (3, 12):
+ from typing import override
+else:
+ from typing_extensions import override
+
+
+# Discord Bot客户端
+class DiscordBotClient(discord.Bot):
+ """Discord客户端封装"""
+
+ def __init__(self, token: str, proxy: str = None):
+ self.token = token
+ self.proxy = proxy
+
+ # 设置Intent权限,遵循权限最小化原则
+ intents = discord.Intents.default()
+ intents.message_content = True # 订阅消息内容事件 (Privileged)
+ intents.members = True # 订阅成员事件 (Privileged)
+
+ # 初始化Bot
+ super().__init__(intents=intents, proxy=proxy)
+
+ # 回调函数
+ self.on_message_received = None
+ self.on_ready_once_callback = None
+ self._ready_once_fired = False
+
+ @override
+ async def on_ready(self):
+ """当机器人成功连接并准备就绪时触发"""
+ logger.info(f"[Discord] 已作为 {self.user} (ID: {self.user.id}) 登录")
+ logger.info("[Discord] 客户端已准备就绪。")
+
+ if self.on_ready_once_callback and not self._ready_once_fired:
+ self._ready_once_fired = True
+ try:
+ await self.on_ready_once_callback()
+ except Exception as e:
+ logger.error(
+ f"[Discord] on_ready_once_callback 执行失败: {e}", exc_info=True)
+
+ def _create_message_data(self, message: discord.Message) -> dict:
+ """从 discord.Message 创建数据字典"""
+ is_mentioned = self.user in message.mentions
+ return {
+ "message": message,
+ "bot_id": str(self.user.id),
+ "content": message.content,
+ "username": message.author.display_name,
+ "userid": str(message.author.id),
+ "message_id": str(message.id),
+ "channel_id": str(message.channel.id),
+ "guild_id": str(message.guild.id) if message.guild else None,
+ "type": "message",
+ "is_mentioned": is_mentioned,
+ "clean_content": message.clean_content,
+ }
+
+ def _create_interaction_data(self, interaction: discord.Interaction) -> dict:
+ """从 discord.Interaction 创建数据字典"""
+ return {
+ "interaction": interaction,
+ "bot_id": str(self.user.id),
+ "content": self._extract_interaction_content(interaction),
+ "username": interaction.user.display_name,
+ "userid": str(interaction.user.id),
+ "message_id": str(interaction.id),
+ "channel_id": str(interaction.channel_id)
+ if interaction.channel_id
+ else None,
+ "guild_id": str(interaction.guild_id) if interaction.guild_id else None,
+ "type": "interaction",
+ }
+
+ @override
+ async def on_message(self, message: discord.Message):
+ """当接收到消息时触发"""
+ if message.author.bot:
+ return
+
+ logger.debug(
+ f"[Discord] 收到原始消息 from {message.author.name}: {message.content}"
+ )
+
+ if self.on_message_received:
+ message_data = self._create_message_data(message)
+ await self.on_message_received(message_data)
+
+
+ def _extract_interaction_content(self, interaction: discord.Interaction) -> str:
+ """从交互中提取内容"""
+ interaction_type = interaction.type
+ interaction_data = getattr(interaction, "data", {})
+
+ if not interaction_data:
+ return ""
+
+ if interaction_type == discord.InteractionType.application_command:
+ command_name = interaction_data.get("name", "")
+ if options := interaction_data.get("options", []):
+ params = " ".join(
+ [f"{opt['name']}:{opt.get('value', '')}" for opt in options]
+ )
+ return f"/{command_name} {params}"
+ return f"/{command_name}"
+
+ elif interaction_type == discord.InteractionType.component:
+ custom_id = interaction_data.get("custom_id", "")
+ component_type = interaction_data.get("component_type", "")
+ return f"component:{custom_id}:{component_type}"
+
+ return str(interaction_data)
+
+ async def start_polling(self):
+ """开始轮询消息,这是个阻塞方法"""
+ await self.start(self.token)
+
+ @override
+ async def close(self):
+ """关闭客户端"""
+ if not self.is_closed():
+ await super().close()
diff --git a/astrbot/core/platform/sources/discord/components.py b/astrbot/core/platform/sources/discord/components.py
new file mode 100644
index 00000000..dbeda38a
--- /dev/null
+++ b/astrbot/core/platform/sources/discord/components.py
@@ -0,0 +1,133 @@
+import discord
+from typing import List
+from astrbot.api.message_components import BaseMessageComponent
+
+
+# Discord专用组件
+class DiscordEmbed(BaseMessageComponent):
+ """Discord Embed消息组件"""
+
+ type: str = "discord_embed"
+
+ def __init__(
+ self,
+ title: str = None,
+ description: str = None,
+ color: int = None,
+ url: str = None,
+ thumbnail: str = None,
+ image: str = None,
+ footer: str = None,
+ fields: List[dict] = None,
+ ):
+ self.title = title
+ self.description = description
+ self.color = color
+ self.url = url
+ self.thumbnail = thumbnail
+ self.image = image
+ self.footer = footer
+ self.fields = fields or []
+
+ def to_discord_embed(self) -> discord.Embed:
+ """转换为Discord Embed对象"""
+ embed = discord.Embed()
+
+ if self.title:
+ embed.title = self.title
+ if self.description:
+ embed.description = self.description
+ if self.color:
+ embed.color = self.color
+ if self.url:
+ embed.url = self.url
+ if self.thumbnail:
+ embed.set_thumbnail(url=self.thumbnail)
+ if self.image:
+ embed.set_image(url=self.image)
+ if self.footer:
+ embed.set_footer(text=self.footer)
+
+ for field in self.fields:
+ embed.add_field(
+ name=field.get("name", ""),
+ value=field.get("value", ""),
+ inline=field.get("inline", False),
+ )
+
+ return embed
+
+
+class DiscordButton(BaseMessageComponent):
+ """Discord按钮组件"""
+
+ type: str = "discord_button"
+
+ def __init__(
+ self,
+ label: str,
+ custom_id: str = None,
+ style: str = "primary",
+ emoji: str = None,
+ url: str = None,
+ disabled: bool = False,
+ ):
+ self.label = label
+ self.custom_id = custom_id
+ self.style = style
+ self.emoji = emoji
+ self.url = url
+ self.disabled = disabled
+
+class DiscordReference(BaseMessageComponent):
+ """Discord引用组件"""
+ type: str = "discord_reference"
+ def __init__(self, message_id: str, channel_id: str):
+ self.message_id = message_id
+ self.channel_id = channel_id
+
+
+class DiscordView(BaseMessageComponent):
+ """Discord视图组件,包含按钮和选择菜单"""
+
+ type: str = "discord_view"
+
+ def __init__(
+ self, components: List[BaseMessageComponent] = None, timeout: float = None
+ ):
+ self.components = components or []
+ self.timeout = timeout
+
+
+ def to_discord_view(self) -> discord.ui.View:
+ """转换为Discord View对象"""
+ view = discord.ui.View(timeout=self.timeout)
+
+ for component in self.components:
+ if isinstance(component, DiscordButton):
+ button_style = getattr(
+ discord.ButtonStyle, component.style, discord.ButtonStyle.primary
+ )
+
+ if component.url:
+ # URL按钮
+ button = discord.ui.Button(
+ label=component.label,
+ style=discord.ButtonStyle.link,
+ url=component.url,
+ emoji=component.emoji,
+ disabled=component.disabled,
+ )
+ else:
+ # 普通按钮
+ button = discord.ui.Button(
+ label=component.label,
+ style=button_style,
+ custom_id=component.custom_id,
+ emoji=component.emoji,
+ disabled=component.disabled,
+ )
+
+ view.add_item(button)
+
+ return view
diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py
new file mode 100644
index 00000000..df40b5a2
--- /dev/null
+++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py
@@ -0,0 +1,412 @@
+import asyncio
+import discord
+import sys
+import re
+from discord.abc import Messageable
+from discord.channel import DMChannel
+from astrbot.api.platform import (
+ Platform,
+ AstrBotMessage,
+ MessageMember,
+ PlatformMetadata,
+ MessageType,
+)
+from astrbot.api.event import MessageChain
+from astrbot.api.message_components import Plain, Image, File
+from astrbot.core.platform.astr_message_event import MessageSesion
+from astrbot.api.platform import register_platform_adapter
+from astrbot import logger
+from .client import DiscordBotClient
+from .discord_platform_event import DiscordPlatformEvent
+
+from typing import Any, Tuple
+from astrbot.core.star.filter.command import CommandFilter
+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
+
+if sys.version_info >= (3, 12):
+ from typing import override
+else:
+ from typing_extensions import override
+
+
+# 注册平台适配器
+@register_platform_adapter("discord", "Discord 适配器 (基于 Pycord)")
+class DiscordPlatformAdapter(Platform):
+ def __init__(
+ self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
+ ) -> None:
+ super().__init__(event_queue)
+ self.config = platform_config
+ self.settings = platform_settings
+ self.client_self_id = 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)
+
+ @override
+ async def send_by_session(
+ self, session: MessageSesion, message_chain: MessageChain
+ ):
+ """通过会话发送消息"""
+ # 创建一个 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(channel_id_str)
+ channel = self.client.get_channel(channel_id)
+ except (ValueError, TypeError):
+ logger.warning(f"[Discord] Invalid channel ID format: {channel_id_str}")
+
+ 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 {channel_id_str}, will guess message type."
+ )
+ message_obj.type = MessageType.GROUP_MESSAGE
+ message_obj.group_id = session.session_id
+
+ message_obj.message_str = message_chain.get_plain_text()
+ message_obj.sender = MessageMember(
+ user_id=str(self.client_self_id), nickname=self.client.user.display_name
+ )
+ message_obj.self_id = self.client_self_id
+ message_obj.session_id = session.session_id
+ message_obj.message = message_chain
+
+ # 创建临时事件对象来发送消息
+ 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)
+
+ @override
+ def meta(self) -> PlatformMetadata:
+ """返回平台元数据"""
+ return PlatformMetadata(
+ "discord",
+ "Discord 适配器",
+ id=self.config.get("id"),
+ default_config_tmpl=self.config,
+ )
+
+ @override
+ async def run(self):
+ """主要运行逻辑"""
+
+ # 初始化回调函数
+ async def on_received(message_data):
+ logger.debug(f"[Discord] 收到消息: {message_data}")
+ if self.client_self_id is None:
+ self.client_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 未配置。请在配置文件中正确设置 token。")
+ return
+
+ proxy = self.config.get("discord_proxy") or None
+ self.client = DiscordBotClient(token, proxy)
+ self.client.on_message_received = on_received
+
+ async def callback():
+ if self.enable_command_register:
+ await self._collect_and_register_commands()
+ if self.activity_name:
+ await self.client.change_presence(
+ status=discord.Status.online,
+ activity=discord.CustomActivity(name=self.activity_name),
+ )
+
+ self.client.on_ready_once_callback = callback
+
+ try:
+ await self.client.start_polling()
+ except discord.errors.LoginFailure:
+ logger.error("[Discord] 登录失败。请检查你的 Bot Token 是否正确。")
+ except discord.errors.ConnectionClosed:
+ logger.warning("[Discord] 与 Discord 的连接已关闭。")
+ except Exception as e:
+ logger.error(f"[Discord] 适配器运行时发生意外错误: {e}", exc_info=True)
+
+ def _get_message_type(
+ self, channel: Messageable, guild_id: int | None = None
+ ) -> MessageType:
+ """根据 channel 对象和 guild_id 判断消息类型"""
+ if guild_id is not None:
+ return MessageType.GROUP_MESSAGE
+ if isinstance(channel, DMChannel) or getattr(channel, "guild", None) is None:
+ return MessageType.FRIEND_MESSAGE
+ return MessageType.GROUP_MESSAGE
+
+ def _get_channel_id(self, channel: Messageable) -> str:
+ """根据 channel 对象获取ID"""
+ return str(getattr(channel, "id", None))
+
+ def _convert_message_to_abm(self, data: dict) -> AstrBotMessage:
+ """将普通消息转换为 AstrBotMessage"""
+ message: discord.Message = data["message"]
+ is_mentioned = data.get("is_mentioned", False)
+
+ content = message.content
+
+ # 如果机器人被@,移除@部分
+ if (
+ is_mentioned
+ and self.client
+ and self.client.user
+ and self.client.user in message.mentions
+ ):
+ # 构建机器人的@字符串,格式为 <@USER_ID> 或 <@!USER_ID>
+ 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()
+
+ abm = AstrBotMessage()
+
+ abm.type = self._get_message_type(message.channel)
+ abm.group_id = self._get_channel_id(message.channel)
+
+ abm.message_str = content
+ abm.sender = MessageMember(
+ user_id=str(message.author.id), nickname=message.author.display_name
+ )
+
+ message_chain = []
+ if abm.message_str:
+ 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/"
+ ):
+ message_chain.append(
+ Image(file=attachment.url, filename=attachment.filename)
+ )
+ else:
+ message_chain.append(
+ File(name=attachment.filename, url=attachment.url)
+ )
+
+ abm.message = message_chain
+ abm.raw_message = message
+ abm.self_id = self.client_self_id
+ abm.session_id = str(message.channel.id)
+ abm.message_id = str(message.id)
+ return abm
+
+ async def convert_message(self, data: dict) -> AstrBotMessage:
+ """将平台消息转换成 AstrBotMessage"""
+ # 由于 on_interaction 已被禁用,我们只处理普通消息
+ return self._convert_message_to_abm(data)
+
+ async def handle_msg(self, message: AstrBotMessage, followup_webhook=None):
+ """处理消息"""
+ message_event = DiscordPlatformEvent(
+ message_str=message.message_str,
+ message_obj=message,
+ platform_meta=self.meta(),
+ session_id=message.session_id,
+ client=self.client,
+ interaction_followup_webhook=followup_webhook,
+ )
+
+ # 检查是否为斜杠指令
+ is_slash_command = message_event.interaction_followup_webhook is not None
+
+ # 检查是否被@
+ is_mention = (
+ self.client
+ and self.client.user
+ and hasattr(message.raw_message, "mentions")
+ and self.client.user in message.raw_message.mentions
+ )
+
+ # 如果是斜杠指令或被@的消息,设置为唤醒状态
+ if is_slash_command or is_mention:
+ message_event.is_wake = True
+ message_event.is_at_or_wake_command = True
+
+ self.commit_event(message_event)
+
+ @override
+ async def terminate(self):
+ """终止适配器"""
+ logger.info("[Discord] 正在终止适配器...")
+
+ # 清理指令
+ if self.enable_command_register and self.client:
+ logger.info("[Discord] 正在清理已注册的斜杠指令...")
+ try:
+ # 传入空的列表来清除所有全局指令
+ # 如果指定了 guild_id,则只清除该服务器的指令
+ await self.client.sync_commands(
+ commands=[], guild_ids=[self.guild_id] if self.guild_id else None
+ )
+ logger.info("[Discord] 指令清理完成。")
+ except Exception as e:
+ logger.error(f"[Discord] 清理指令时发生错误: {e}", exc_info=True)
+
+ if self.client and hasattr(self.client, "close"):
+ await self.client.close()
+ logger.info("[Discord] 适配器已终止。")
+
+ def register_handler(self, handler_info):
+ """注册处理器信息"""
+ self.registered_handlers.append(handler_info)
+
+ async def _collect_and_register_commands(self):
+ """收集所有指令并注册到Discord"""
+ logger.info("[Discord] 开始收集并注册斜杠指令...")
+ registered_commands = []
+
+ for handler_md in star_handlers_registry:
+ if not star_map[handler_md.handler_module_path].activated:
+ 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
+
+ # 创建动态回调
+ callback = self._create_dynamic_callback(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] 准备同步 {len(registered_commands)} 个指令: {', '.join(registered_commands)}"
+ )
+ else:
+ logger.info("[Discord] 没有发现可注册的指令。")
+
+ # 使用 Pycord 的方法同步指令
+ # 注意:这可能需要一些时间,并且有频率限制
+ await self.client.sync_commands()
+ logger.info("[Discord] 指令同步完成。")
+
+ def _create_dynamic_callback(self, cmd_name: str):
+ """为每个指令动态创建一个异步回调函数"""
+
+ async def dynamic_callback(ctx: discord.ApplicationContext, params: str = None):
+ # 将平台特定的前缀'/'剥离,以适配通用的CommandFilter
+ logger.debug(f"[Discord] 回调函数触发: {cmd_name}")
+ logger.debug(f"[Discord] 回调函数参数: {ctx}")
+ logger.debug(f"[Discord] 回调函数参数: {params}")
+ message_str_for_filter = cmd_name
+ if params:
+ message_str_for_filter += f" {params}"
+
+ logger.debug(
+ f"[Discord] 斜杠指令 '{cmd_name}' 被触发。 "
+ f"原始参数: '{params}'. "
+ f"构建的指令字符串: '{message_str_for_filter}'"
+ )
+
+ # 尝试立即响应,防止超时
+ followup_webhook = None
+ try:
+ await ctx.defer()
+ followup_webhook = ctx.followup
+ except Exception as e:
+ logger.warning(f"[Discord] 指令 '{cmd_name}' defer 失败: {e}")
+
+ # 2. 构建 AstrBotMessage
+ abm = AstrBotMessage()
+ abm.type = self._get_message_type(ctx.channel, ctx.guild_id)
+ abm.group_id = self._get_channel_id(ctx.channel)
+ abm.message_str = message_str_for_filter
+ abm.sender = MessageMember(
+ user_id=str(ctx.author.id), nickname=ctx.author.display_name
+ )
+ abm.message = [Plain(text=message_str_for_filter)]
+ abm.raw_message = ctx.interaction
+ abm.self_id = self.client_self_id
+ abm.session_id = str(ctx.channel_id)
+ abm.message_id = str(ctx.interaction.id)
+
+ # 3. 将消息和 webhook 分别交给 handle_msg 处理
+ await self.handle_msg(abm, followup_webhook)
+
+ return dynamic_callback
+
+ @staticmethod
+ def _extract_command_info(
+ event_filter: Any, handler_metadata: StarHandlerMetadata
+ ) -> Tuple[str, str, CommandFilter] | 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 != [""]
+ ):
+ return None
+ cmd_name = event_filter.command_name
+ cmd_filter_instance = event_filter
+
+ elif isinstance(event_filter, CommandGroupFilter):
+ # 暂不支持指令组直接注册为斜杠指令,因为它们没有 handle 方法
+ return None
+
+ if not cmd_name:
+ return None
+
+ # Discord 斜杠指令名称规范
+ if not re.match(r"^[a-z0-9_-]{1,32}$", cmd_name):
+ logger.debug(f"[Discord] 跳过不符合规范的指令: {cmd_name}")
+ return None
+
+ description = handler_metadata.desc or f"指令: {cmd_name}"
+ if len(description) > 100:
+ description = f"{description[:97]}..."
+
+ return cmd_name, description, cmd_filter_instance
diff --git a/astrbot/core/platform/sources/discord/discord_platform_event.py b/astrbot/core/platform/sources/discord/discord_platform_event.py
new file mode 100644
index 00000000..e61a12fc
--- /dev/null
+++ b/astrbot/core/platform/sources/discord/discord_platform_event.py
@@ -0,0 +1,291 @@
+import asyncio
+import discord
+import base64
+from io import BytesIO
+from pathlib import Path
+from typing import Optional
+import sys
+
+from astrbot.api.event import AstrMessageEvent, MessageChain
+from astrbot.api.platform import AstrBotMessage, PlatformMetadata, At
+from astrbot.api.message_components import (
+ Plain,
+ Image,
+ File,
+ BaseMessageComponent,
+ Reply,
+)
+from astrbot import logger
+from .client import DiscordBotClient
+from .components import DiscordEmbed, DiscordView
+
+if sys.version_info >= (3, 12):
+ from typing import override
+else:
+ from typing_extensions import override
+
+
+# 自定义Discord视图组件(兼容旧版本)
+class DiscordViewComponent(BaseMessageComponent):
+ type: str = "discord_view"
+
+ def __init__(self, view: discord.ui.View):
+ self.view = view
+
+
+class DiscordPlatformEvent(AstrMessageEvent):
+ def __init__(
+ self,
+ message_str: str,
+ message_obj: AstrBotMessage,
+ platform_meta: PlatformMetadata,
+ session_id: str,
+ client: DiscordBotClient,
+ interaction_followup_webhook: Optional[discord.Webhook] = None,
+ ):
+ super().__init__(message_str, message_obj, platform_meta, session_id)
+ self.client = client
+ self.interaction_followup_webhook = interaction_followup_webhook
+
+ @override
+ async def send(self, message: MessageChain):
+ """发送消息到Discord平台"""
+
+ # 解析消息链为 Discord 所需的对象
+ try:
+ content, files, view, embeds, reference_message_id = await self._parse_to_discord(message)
+ except Exception as e:
+ logger.error(f"[Discord] 解析消息链时失败: {e}", exc_info=True)
+ return
+
+ kwargs = {}
+ if content:
+ kwargs["content"] = content
+ if files:
+ kwargs["files"] = files
+ if view:
+ kwargs["view"] = view
+ if embeds:
+ kwargs["embeds"] = embeds
+ if reference_message_id and not self.interaction_followup_webhook:
+ kwargs["reference"] = self.client.get_message(int(reference_message_id))
+ if not kwargs:
+ logger.debug("[Discord] 尝试发送空消息,已忽略。")
+ return
+
+ # 根据上下文执行发送/回复操作
+ try:
+ # -- 斜杠指令/交互上下文 --
+ if self.interaction_followup_webhook:
+ await self.interaction_followup_webhook.send(**kwargs)
+
+ # -- 常规消息上下文 --
+ else:
+ channel = await self._get_channel()
+ if not channel:
+ return
+ else:
+ await channel.send(**kwargs)
+
+ except Exception as e:
+ logger.error(f"[Discord] 发送消息时发生未知错误: {e}", exc_info=True)
+
+ await super().send(message)
+
+ async def _get_channel(self) -> Optional[discord.abc.Messageable]:
+ """获取当前事件对应的频道对象"""
+ try:
+ channel_id = int(self.session_id)
+ return self.client.get_channel(
+ channel_id
+ ) or await self.client.fetch_channel(channel_id)
+ except (ValueError, discord.errors.NotFound, discord.errors.Forbidden):
+ logger.error(f"[Discord] 无法获取频道 {self.session_id}")
+ return None
+
+ async def _parse_to_discord(
+ self,
+ message: MessageChain,
+ ) -> tuple[str, list[discord.File], Optional[discord.ui.View], list[discord.Embed]]:
+ """将 MessageChain 解析为 Discord 发送所需的内容"""
+ content = ""
+ files = []
+ view = None
+ embeds = []
+ reference_message_id = None
+ for i in message.chain: # 遍历消息链
+ if isinstance(i, Plain): # 如果是文字类型的
+ content += i.text
+ elif isinstance(i, Reply):
+ reference_message_id = i.id
+ elif isinstance(i, At):
+ content += f"<@{i.qq}>"
+ elif isinstance(i, Image):
+ logger.debug(f"[Discord] 开始处理 Image 组件: {i}")
+ try:
+ filename = getattr(i, "filename", None)
+ file_content = getattr(i, "file", None)
+
+ if not file_content:
+ logger.warning(f"[Discord] Image 组件没有 file 属性: {i}")
+ continue
+
+ discord_file = None
+
+ # 1. URL
+ if file_content.startswith("http"):
+ logger.debug(f"[Discord] 处理 URL 图片: {file_content}")
+ embed = discord.Embed().set_image(url=file_content)
+ embeds.append(embed)
+ continue
+
+ # 2. File URI
+ elif file_content.startswith("file:///"):
+ logger.debug(f"[Discord] 处理 File URI: {file_content}")
+ path = Path(file_content[8:])
+ if await asyncio.to_thread(path.exists):
+ file_bytes = await asyncio.to_thread(path.read_bytes)
+ discord_file = discord.File(
+ BytesIO(file_bytes), filename=filename or path.name
+ )
+ else:
+ logger.warning(f"[Discord] 图片文件不存在: {path}")
+
+ # 3. Base64 URI
+ elif file_content.startswith("base64://"):
+ logger.debug("[Discord] 处理 Base64 URI")
+ b64_data = file_content.split("base64://", 1)[1]
+ missing_padding = len(b64_data) % 4
+ if missing_padding:
+ b64_data += "=" * (4 - missing_padding)
+ img_bytes = base64.b64decode(b64_data)
+ discord_file = discord.File(
+ BytesIO(img_bytes), filename=filename or "image.png"
+ )
+
+ # 4. 裸 Base64 或本地路径
+ else:
+ try:
+ logger.debug("[Discord] 尝试作为裸 Base64 处理")
+ b64_data = file_content
+ missing_padding = len(b64_data) % 4
+ if missing_padding:
+ b64_data += "=" * (4 - missing_padding)
+ img_bytes = base64.b64decode(b64_data)
+ discord_file = discord.File(
+ BytesIO(img_bytes), filename=filename or "image.png"
+ )
+ except (ValueError, TypeError, base64.binascii.Error):
+ logger.debug(
+ f"[Discord] 裸 Base64 解码失败,作为本地路径处理: {file_content}"
+ )
+ path = Path(file_content)
+ if await asyncio.to_thread(path.exists):
+ file_bytes = await asyncio.to_thread(path.read_bytes)
+ discord_file = discord.File(
+ BytesIO(file_bytes), filename=filename or path.name
+ )
+ else:
+ logger.warning(f"[Discord] 图片文件不存在: {path}")
+
+ if discord_file:
+ files.append(discord_file)
+
+ except Exception:
+ # 使用 getattr 来安全地访问 i.file,以防 i 本身就是问题
+ file_info = getattr(i, "file", "未知")
+ logger.error(
+ f"[Discord] 处理图片时发生未知严重错误: {file_info}",
+ exc_info=True,
+ )
+ elif isinstance(i, File):
+ try:
+ file_path_str = await i.get_file()
+ if file_path_str:
+ path = Path(file_path_str)
+ if await asyncio.to_thread(path.exists):
+ file_bytes = await asyncio.to_thread(path.read_bytes)
+ files.append(
+ discord.File(BytesIO(file_bytes),
+ filename=i.name)
+ )
+ else:
+ logger.warning(
+ f"[Discord] 获取文件失败,路径不存在: {file_path_str}"
+ )
+ else:
+ logger.warning(f"[Discord] 获取文件失败: {i.name}")
+ except Exception as e:
+ logger.warning(f"[Discord] 处理文件失败: {i.name}, 错误: {e}")
+ elif isinstance(i, DiscordEmbed):
+ # Discord Embed消息
+ embeds.append(i.to_discord_embed())
+ elif isinstance(i, DiscordView):
+ # Discord视图组件(按钮、选择菜单等)
+ view = i.to_discord_view()
+ elif isinstance(i, DiscordViewComponent):
+ # 如果消息链中包含Discord视图组件(兼容旧版本)
+ if isinstance(i.view, discord.ui.View):
+ view = i.view
+ else:
+ logger.debug(f"[Discord] 忽略了不支持的消息组件: {i.type}")
+
+ if len(content) > 2000:
+ logger.warning("[Discord] 消息内容超过2000字符,将被截断。")
+ content = content[:2000]
+ return content, files, view, embeds, reference_message_id
+
+ async def react(self, emoji: str):
+ """对原消息添加反应"""
+ try:
+ if hasattr(self.message_obj, "raw_message") and hasattr(
+ self.message_obj.raw_message, "add_reaction"
+ ):
+ await self.message_obj.raw_message.add_reaction(emoji)
+ except Exception as e:
+ logger.error(f"[Discord] 添加反应失败: {e}")
+
+ def is_slash_command(self) -> bool:
+ """判断是否为斜杠命令"""
+ return (
+ hasattr(self.message_obj, "raw_message")
+ and hasattr(self.message_obj.raw_message, "type")
+ and self.message_obj.raw_message.type
+ == discord.InteractionType.application_command
+ )
+
+ def is_button_interaction(self) -> bool:
+ """判断是否为按钮交互"""
+ return (
+ hasattr(self.message_obj, "raw_message")
+ and hasattr(self.message_obj.raw_message, "type")
+ and self.message_obj.raw_message.type == discord.InteractionType.component
+ )
+
+ def get_interaction_custom_id(self) -> str:
+ """获取交互组件的custom_id"""
+ if self.is_button_interaction():
+ try:
+ return self.message_obj.raw_message.data.get("custom_id", "")
+ except Exception:
+ pass
+ return ""
+
+ def is_mentioned(self) -> bool:
+ """判断机器人是否被@"""
+ if hasattr(self.message_obj, "raw_message") and hasattr(
+ self.message_obj.raw_message, "mentions"
+ ):
+ return any(
+ mention.id == int(self.message_obj.self_id)
+ for mention in self.message_obj.raw_message.mentions
+ )
+ return False
+
+ def get_mention_clean_content(self) -> str:
+ """获取去除@后的清洁内容"""
+ if hasattr(self.message_obj, "raw_message") and hasattr(
+ self.message_obj.raw_message, "clean_content"
+ ):
+ return self.message_obj.raw_message.clean_content
+ return self.message_str
diff --git a/astrbot/core/platform/sources/slack/client.py b/astrbot/core/platform/sources/slack/client.py
new file mode 100644
index 00000000..7877e4f5
--- /dev/null
+++ b/astrbot/core/platform/sources/slack/client.py
@@ -0,0 +1,162 @@
+import json
+import hmac
+import hashlib
+import asyncio
+import logging
+from typing import Callable, Optional
+from quart import Quart, request, Response
+from slack_sdk.web.async_client import AsyncWebClient
+from slack_sdk.socket_mode.aiohttp import SocketModeClient
+from slack_sdk.socket_mode.request import SocketModeRequest
+from slack_sdk.socket_mode.response import SocketModeResponse
+from astrbot.api import logger
+
+
+class SlackWebhookClient:
+ """Slack Webhook 模式客户端,使用 Quart 作为 Web 服务器"""
+
+ def __init__(
+ self,
+ web_client: AsyncWebClient,
+ signing_secret: str,
+ host: str = "0.0.0.0",
+ port: int = 3000,
+ path: str = "/slack/events",
+ event_handler: Optional[Callable] = None,
+ ):
+ self.web_client = web_client
+ self.signing_secret = signing_secret
+ self.host = host
+ self.port = port
+ self.path = path
+ self.event_handler = event_handler
+
+ self.app = Quart(__name__)
+ self._setup_routes()
+
+ # 禁用 Quart 的默认日志输出
+ logging.getLogger("quart.app").setLevel(logging.WARNING)
+ logging.getLogger("quart.serving").setLevel(logging.WARNING)
+
+ self.shutdown_event = asyncio.Event()
+
+ def _setup_routes(self):
+ """设置路由"""
+
+ @self.app.route(self.path, methods=["POST"])
+ async def slack_events():
+ """处理 Slack 事件"""
+ try:
+ # 获取请求体和头部
+ body = await request.get_data()
+ event_data = json.loads(body.decode("utf-8"))
+
+ # Verify Slack request signature
+ timestamp = request.headers.get("X-Slack-Request-Timestamp")
+ signature = request.headers.get("X-Slack-Signature")
+ if not timestamp or not signature:
+ return Response("Missing headers", status=400)
+ # Calculate the HMAC signature
+ sig_basestring = f"v0:{timestamp}:{body.decode('utf-8')}"
+ my_signature = (
+ "v0="
+ + hmac.new(
+ self.signing_secret.encode("utf-8"),
+ sig_basestring.encode("utf-8"),
+ hashlib.sha256,
+ ).hexdigest()
+ )
+ # Verify the signature
+ if not hmac.compare_digest(my_signature, signature):
+ logger.warning("Slack request signature verification failed")
+ return Response("Invalid signature", status=400)
+ logger.info(f"Received Slack event: {event_data}")
+
+ # 处理 URL 验证事件
+ if event_data.get("type") == "url_verification":
+ return {"challenge": event_data.get("challenge")}
+ # 处理事件
+ if self.event_handler and event_data.get("type") == "event_callback":
+ await self.event_handler(event_data)
+
+ return Response("", status=200)
+
+ except Exception as e:
+ logger.error(f"处理 Slack 事件时出错: {e}")
+ return Response("Internal Server Error", status=500)
+
+ @self.app.route("/health", methods=["GET"])
+ async def health_check():
+ """健康检查端点"""
+ return {"status": "ok", "service": "slack-webhook"}
+
+ async def start(self):
+ """启动 Webhook 服务器"""
+ logger.info(
+ f"Slack Webhook 服务器启动中,监听 {self.host}:{self.port}{self.path}..."
+ )
+
+ await self.app.run_task(
+ host=self.host,
+ port=self.port,
+ debug=False,
+ shutdown_trigger=self.shutdown_trigger,
+ )
+
+ async def shutdown_trigger(self):
+ await self.shutdown_event.wait()
+
+ async def stop(self):
+ """停止 Webhook 服务器"""
+ self.shutdown_event.set()
+ logger.info("Slack Webhook 服务器已停止")
+
+
+class SlackSocketClient:
+ """Slack Socket 模式客户端"""
+
+ def __init__(
+ self,
+ web_client: AsyncWebClient,
+ app_token: str,
+ event_handler: Optional[Callable] = None,
+ ):
+ self.web_client = web_client
+ self.app_token = app_token
+ self.event_handler = event_handler
+ self.socket_client = None
+
+ async def _handle_events(self, _: SocketModeClient, req: SocketModeRequest):
+ """处理 Socket Mode 事件"""
+ try:
+ # 确认收到事件
+ response = SocketModeResponse(envelope_id=req.envelope_id)
+ await self.socket_client.send_socket_mode_response(response)
+
+ # 处理事件
+ if self.event_handler:
+ await self.event_handler(req)
+
+ except Exception as e:
+ logger.error(f"处理 Socket Mode 事件时出错: {e}")
+
+ async def start(self):
+ """启动 Socket Mode 连接"""
+ self.socket_client = SocketModeClient(
+ app_token=self.app_token,
+ logger=logger,
+ web_client=self.web_client,
+ )
+
+ # 注册事件处理器
+ self.socket_client.socket_mode_request_listeners.append(self._handle_events)
+
+ logger.info("Slack Socket Mode 客户端启动中...")
+ await self.socket_client.connect()
+
+ async def stop(self):
+ """停止 Socket Mode 连接"""
+ if self.socket_client:
+ await self.socket_client.disconnect()
+ await self.socket_client.close()
+ logger.info("Slack Socket Mode 客户端已停止")
diff --git a/astrbot/core/platform/sources/slack/slack_adapter.py b/astrbot/core/platform/sources/slack/slack_adapter.py
new file mode 100644
index 00000000..07dc1011
--- /dev/null
+++ b/astrbot/core/platform/sources/slack/slack_adapter.py
@@ -0,0 +1,396 @@
+import time
+import asyncio
+import uuid
+import aiohttp
+import re
+import base64
+from typing import Awaitable, Any
+from slack_sdk.web.async_client import AsyncWebClient
+from slack_sdk.socket_mode.request import SocketModeRequest
+from astrbot.api.platform import (
+ Platform,
+ AstrBotMessage,
+ MessageMember,
+ MessageType,
+ PlatformMetadata,
+)
+from astrbot.api.event import MessageChain
+from .slack_event import SlackMessageEvent
+from .client import SlackWebhookClient, SlackSocketClient
+from astrbot.api.message_components import * # noqa: F403
+from astrbot.api import logger
+from astrbot.core.platform.astr_message_event import MessageSesion
+from ...register import register_platform_adapter
+
+
+@register_platform_adapter(
+ "slack", "适用于 Slack 的消息平台适配器,支持 Socket Mode 和 Webhook Mode。"
+)
+class SlackAdapter(Platform):
+ def __init__(
+ self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
+ ) -> None:
+ super().__init__(event_queue)
+
+ self.config = platform_config
+ self.settings = platform_settings
+ self.unique_session = platform_settings.get("unique_session", False)
+
+ self.bot_token = platform_config.get("bot_token")
+ self.app_token = platform_config.get("app_token")
+ self.signing_secret = platform_config.get("signing_secret")
+ self.connection_mode = platform_config.get("slack_connection_mode", "socket")
+ self.webhook_host = platform_config.get("slack_webhook_host", "0.0.0.0")
+ self.webhook_port = platform_config.get("slack_webhook_port", 3000)
+ self.webhook_path = platform_config.get(
+ "slack_webhook_path", "/astrbot-slack-webhook/callback"
+ )
+
+ if not self.bot_token:
+ raise ValueError("Slack bot_token 是必需的")
+
+ if self.connection_mode == "socket" and not self.app_token:
+ raise ValueError("Socket Mode 需要 app_token")
+
+ if self.connection_mode == "webhook" and not self.signing_secret:
+ raise ValueError("Webhook Mode 需要 signing_secret")
+
+ self.metadata = PlatformMetadata(
+ name="slack",
+ description="适用于 Slack 的消息平台适配器,支持 Socket Mode 和 Webhook Mode。",
+ id=self.config.get("id"),
+ )
+
+ # 初始化 Slack Web Client
+ self.web_client = AsyncWebClient(token=self.bot_token, logger=logger)
+ self.socket_client = None
+ self.webhook_client = None
+
+ self.bot_self_id = None
+
+ async def send_by_session(
+ self, session: MessageSesion, message_chain: MessageChain
+ ):
+ blocks, text = SlackMessageEvent._parse_slack_blocks(
+ message_chain=message_chain, web_client=self.web_client
+ )
+
+ try:
+ if session.message_type == MessageType.GROUP_MESSAGE:
+ # 发送到频道
+ channel_id = (
+ session.session_id.split("_")[-1]
+ if "_" in session.session_id
+ else session.session_id
+ )
+ await self.web_client.chat_postMessage(
+ channel=channel_id,
+ text=text,
+ blocks=blocks if blocks else None,
+ )
+ else:
+ # 发送私信
+ await self.web_client.chat_postMessage(
+ channel=session.session_id,
+ text=text,
+ blocks=blocks if blocks else None,
+ )
+ except Exception as e:
+ logger.error(f"Slack 发送消息失败: {e}")
+
+ await super().send_by_session(session, message_chain)
+
+ async def convert_message(self, event: dict) -> AstrBotMessage:
+ logger.debug(f"[slack] RawMessage {event}")
+
+ abm = AstrBotMessage()
+ abm.self_id = self.bot_self_id
+
+ # 获取用户信息
+ user_id = event.get("user", "")
+ try:
+ user_info = await self.web_client.users_info(user=user_id)
+ user_data = user_info["user"]
+ user_name = user_data.get("real_name") or user_data.get("name", user_id)
+ except Exception:
+ user_name = user_id
+
+ abm.sender = MessageMember(user_id=user_id, nickname=user_name)
+
+ # 判断消息类型
+ channel_id = event.get("channel", "")
+ try:
+ channel_info = await self.web_client.conversations_info(channel=channel_id)
+ is_im = channel_info["channel"]["is_im"]
+
+ if is_im:
+ abm.type = MessageType.FRIEND_MESSAGE
+ else:
+ abm.type = MessageType.GROUP_MESSAGE
+ abm.group_id = channel_id
+ except Exception:
+ # 默认作为群组消息处理
+ abm.type = MessageType.GROUP_MESSAGE
+ abm.group_id = channel_id
+
+ # 设置会话ID
+ if self.unique_session and abm.type == MessageType.GROUP_MESSAGE:
+ abm.session_id = f"{user_id}_{channel_id}"
+ else:
+ abm.session_id = (
+ channel_id if abm.type == MessageType.GROUP_MESSAGE else user_id
+ )
+
+ abm.message_id = event.get("client_msg_id", uuid.uuid4().hex)
+ abm.timestamp = int(float(event.get("ts", time.time())))
+
+ # 处理消息内容
+ message_text = event.get("text", "")
+ abm.message_str = message_text
+ abm.message = []
+
+ # 优先使用 blocks 字段解析消息
+ if "blocks" in event and event["blocks"]:
+ abm.message = self._parse_blocks(event["blocks"])
+ # 更新 message_str
+ abm.message_str = ""
+ for component in abm.message:
+ if isinstance(component, Plain):
+ abm.message_str += component.text
+ elif message_text:
+ # 处理传统的文本消息
+ if "<@" in message_text:
+ mentions = re.findall(r"<@([^>]+)>", message_text)
+ for mention in mentions:
+ try:
+ mentioned_user = await self.web_client.users_info(user=mention)
+ user_data = mentioned_user["user"]
+ user_name = user_data.get("real_name") or user_data.get(
+ "name", mention
+ )
+ abm.message.append(At(qq=mention, name=user_name))
+ except Exception:
+ abm.message.append(At(qq=mention, name=""))
+
+ # 清理消息文本中的@标记
+ if clean_text := re.sub(r"<@[^>]+>", "", message_text).strip():
+ abm.message.append(Plain(text=clean_text))
+ else:
+ abm.message.append(Plain(text=message_text))
+
+ # 处理文件附件
+ if "files" in event:
+ for file_info in event["files"]:
+ file_name = file_info.get("name", "unknown")
+ file_url = file_info.get("url_private", "")
+ if file_info.get("mimetype", "").startswith("image/"):
+ file_url = await self.get_file_base64(file_url)
+ abm.message.append(Image.fromBase64(base64=file_url))
+ else:
+ # TODO: 下载鉴权
+ abm.message.append(
+ File(name=file_name, file=file_url, url=file_url)
+ )
+
+ abm.raw_message = event
+ return abm
+
+ def _parse_blocks(self, blocks: list) -> list:
+ """解析 Slack blocks 格式的消息内容"""
+ message_components = []
+
+ for block in blocks:
+ block_type = block.get("type", "")
+
+ if block_type == "rich_text":
+ # 处理富文本块
+ elements = block.get("elements", [])
+ for element in elements:
+ if element.get("type") == "rich_text_section":
+ # 处理富文本段落
+ section_elements = element.get("elements", [])
+ text_content = ""
+
+ for section_element in section_elements:
+ element_type = section_element.get("type", "")
+
+ if element_type == "text":
+ # 普通文本
+ text_content += section_element.get("text", "")
+ elif element_type == "user":
+ # @用户提及
+ user_id = section_element.get("user_id", "")
+ if user_id:
+ # 将之前的文本内容先添加到组件中
+ if text_content.strip():
+ message_components.append(
+ Plain(text=text_content)
+ )
+ text_content = ""
+ # 添加@提及组件
+ message_components.append(At(qq=user_id, name=""))
+ elif element_type == "channel":
+ # #频道提及
+ channel_id = section_element.get("channel_id", "")
+ text_content += f"#{channel_id}"
+ elif element_type == "link":
+ # 链接
+ url = section_element.get("url", "")
+ link_text = section_element.get("text", url)
+ text_content += f"[{link_text}]({url})"
+ elif element_type == "emoji":
+ # 表情符号
+ emoji_name = section_element.get("name", "")
+ text_content += f":{emoji_name}:"
+
+ if text_content.strip():
+ message_components.append(Plain(text=text_content))
+
+ elif element.get("type") == "rich_text_list":
+ # 处理列表
+ list_items = element.get("elements", [])
+ list_text = ""
+ for item in list_items:
+ if item.get("type") == "rich_text_section":
+ item_elements = item.get("elements", [])
+ item_text = ""
+ for item_element in item_elements:
+ if item_element.get("type") == "text":
+ item_text += item_element.get("text", "")
+ list_text += f"• {item_text}\n"
+
+ if list_text.strip():
+ message_components.append(Plain(text=list_text.strip()))
+
+ elif block_type == "section":
+ # 处理段落块
+ if "text" in block:
+ text_obj = block["text"]
+ if text_obj.get("type") == "mrkdwn":
+ text_content = text_obj.get("text", "")
+ message_components.append(Plain(text=text_content))
+
+ return message_components
+
+ async def _handle_socket_event(self, req: SocketModeRequest):
+ """处理 Socket Mode 事件"""
+ if req.type == "events_api":
+ # 事件 API
+ event = req.payload.get("event", {})
+
+ # 忽略机器人自己的消息和消息编辑
+ if event.get("subtype") in [
+ "bot_message",
+ "message_changed",
+ "message_deleted",
+ ]:
+ return
+
+ if event.get("bot_id"):
+ return
+
+ if event.get("type") in ["message", "app_mention"]:
+ abm = await self.convert_message(event)
+ if abm:
+ await self.handle_msg(abm)
+
+ async def get_bot_user_id(self):
+ auth_info = await self.web_client.auth_test()
+ return auth_info.get("user_id")
+
+ async def get_file_base64(self, url: str) -> str:
+ """下载 Slack 文件并返回 Base64 编码的内容"""
+ headers = {"Authorization": f"Bearer {self.bot_token}"}
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url, headers=headers) as resp:
+ if resp.status == 200:
+ content = await resp.read()
+ base64_content = base64.b64encode(content).decode("utf-8")
+ return base64_content
+ else:
+ logger.error(f"Failed to download slack file: {resp.status} {await resp.text()}")
+ raise Exception(f"下载文件失败: {resp.status}")
+
+ async def run(self) -> Awaitable[Any]:
+ self.bot_self_id = await self.get_bot_user_id()
+ logger.info(f"Slack auth test OK. Bot ID: {self.bot_self_id}")
+
+ if self.connection_mode == "socket":
+ if not self.app_token:
+ raise ValueError("Socket Mode 需要 app_token")
+
+ # 创建 Socket 客户端
+ self.socket_client = SlackSocketClient(
+ self.web_client, self.app_token, self._handle_socket_event
+ )
+
+ logger.info("Slack 适配器 (Socket Mode) 启动中...")
+ await self.socket_client.start()
+
+ elif self.connection_mode == "webhook":
+ if not self.signing_secret:
+ raise ValueError("Webhook Mode 需要 signing_secret")
+
+ # 创建 Webhook 客户端
+ self.webhook_client = SlackWebhookClient(
+ self.web_client,
+ self.signing_secret,
+ self.webhook_host,
+ self.webhook_port,
+ self.webhook_path,
+ self._handle_webhook_event,
+ )
+
+ logger.info(
+ f"Slack 适配器 (Webhook Mode) 启动中,监听 {self.webhook_host}:{self.webhook_port}{self.webhook_path}..."
+ )
+ await self.webhook_client.start()
+
+ else:
+ raise ValueError(
+ f"不支持的连接模式: {self.connection_mode},请使用 'socket' 或 'webhook'"
+ )
+
+ async def _handle_webhook_event(self, event_data: dict):
+ """处理 Webhook 事件"""
+ event = event_data.get("event", {})
+
+ # 忽略机器人自己的消息和消息编辑
+ if event.get("subtype") in [
+ "bot_message",
+ "message_changed",
+ "message_deleted",
+ ]:
+ return
+
+ if event.get("bot_id"):
+ return
+
+ if event.get("type") in ["message", "app_mention"]:
+ abm = await self.convert_message(event)
+ if abm:
+ await self.handle_msg(abm)
+
+ async def terminate(self):
+ if self.socket_client:
+ await self.socket_client.stop()
+ if self.webhook_client:
+ await self.webhook_client.stop()
+ logger.info("Slack 适配器已被优雅地关闭")
+
+ def meta(self) -> PlatformMetadata:
+ return self.metadata
+
+ async def handle_msg(self, message: AstrBotMessage):
+ message_event = SlackMessageEvent(
+ message_str=message.message_str,
+ message_obj=message,
+ platform_meta=self.meta(),
+ session_id=message.session_id,
+ web_client=self.web_client,
+ )
+
+ self.commit_event(message_event)
+
+ def get_client(self):
+ return self.web_client
diff --git a/astrbot/core/platform/sources/slack/slack_event.py b/astrbot/core/platform/sources/slack/slack_event.py
new file mode 100644
index 00000000..9acd61c8
--- /dev/null
+++ b/astrbot/core/platform/sources/slack/slack_event.py
@@ -0,0 +1,237 @@
+import asyncio
+import re
+from typing import AsyncGenerator
+from slack_sdk.web.async_client import AsyncWebClient
+from astrbot.api.event import AstrMessageEvent, MessageChain
+from astrbot.api.message_components import (
+ Image,
+ Plain,
+ File,
+ BaseMessageComponent,
+)
+from astrbot.api.platform import Group, MessageMember
+from astrbot.api import logger
+
+
+class SlackMessageEvent(AstrMessageEvent):
+ def __init__(
+ self,
+ message_str,
+ message_obj,
+ platform_meta,
+ session_id,
+ web_client: AsyncWebClient,
+ ):
+ super().__init__(message_str, message_obj, platform_meta, session_id)
+ self.web_client = web_client
+
+ @staticmethod
+ async def _from_segment_to_slack_block(
+ segment: BaseMessageComponent, web_client: AsyncWebClient
+ ) -> dict:
+ """将消息段转换为 Slack 块格式"""
+ if isinstance(segment, Plain):
+ return {"type": "section", "text": {"type": "mrkdwn", "text": segment.text}}
+ elif isinstance(segment, Image):
+ # upload file
+ url = segment.url or segment.file
+ if url.startswith("http"):
+ return {
+ "type": "image",
+ "image_url": url,
+ "alt_text": "图片",
+ }
+ path = await segment.convert_to_file_path()
+ response = await web_client.files_upload_v2(
+ file=path,
+ filename="image.jpg",
+ )
+ if not response["ok"]:
+ logger.error(f"Slack file upload failed: {response['error']}")
+ return {
+ "type": "section",
+ "text": {"type": "mrkdwn", "text": "图片上传失败"},
+ }
+ image_url = response["files"][0]["url_private"]
+ logger.debug(f"Slack file upload response: {response}")
+ return {
+ "type": "image",
+ "slack_file": {
+ "url": image_url,
+ },
+ "alt_text": "图片",
+ }
+ elif isinstance(segment, File):
+ # upload file
+ url = segment.url or segment.file
+ response = await web_client.files_upload_v2(
+ file=url,
+ filename=segment.name or "file",
+ )
+ if not response["ok"]:
+ logger.error(f"Slack file upload failed: {response['error']}")
+ return {
+ "type": "section",
+ "text": {"type": "mrkdwn", "text": "文件上传失败"},
+ }
+ file_url = response["files"][0]["permalink"]
+ return {"type": "section", "text": {"type": "mrkdwn", "text": f"文件: <{file_url}|{segment.name or '文件'}>"}}
+ else:
+ return {"type": "section", "text": {"type": "mrkdwn", "text": str(segment)}}
+
+ @staticmethod
+ async def _parse_slack_blocks(
+ message_chain: MessageChain, web_client: AsyncWebClient
+ ):
+ """解析成 Slack 块格式"""
+ blocks = []
+ text_content = ""
+
+ for segment in message_chain.chain:
+ if isinstance(segment, Plain):
+ text_content += segment.text
+ else:
+ # 如果有文本内容,先添加文本块
+ if text_content.strip():
+ blocks.append(
+ {
+ "type": "section",
+ "text": {"type": "mrkdwn", "text": text_content},
+ }
+ )
+ text_content = ""
+
+ # 添加其他类型的块
+ block = await SlackMessageEvent._from_segment_to_slack_block(
+ segment, web_client
+ )
+ blocks.append(block)
+
+ # 如果最后还有文本内容
+ if text_content.strip():
+ blocks.append(
+ {"type": "section", "text": {"type": "mrkdwn", "text": text_content}}
+ )
+
+ return blocks, "" if blocks else text_content
+
+ async def send(self, message: MessageChain):
+ blocks, text = await SlackMessageEvent._parse_slack_blocks(
+ message, self.web_client
+ )
+
+ try:
+ if self.get_group_id():
+ # 发送到频道
+ await self.web_client.chat_postMessage(
+ channel=self.get_group_id(),
+ text=text,
+ blocks=blocks or None,
+ )
+ else:
+ # 发送私信
+ await self.web_client.chat_postMessage(
+ channel=self.get_sender_id(),
+ text=text,
+ blocks=blocks or None,
+ )
+ except Exception:
+ # 如果块发送失败,尝试只发送文本
+ fallback_text = ""
+ for segment in message.chain:
+ if isinstance(segment, Plain):
+ fallback_text += segment.text
+ elif isinstance(segment, File):
+ fallback_text += f" [文件: {segment.name}] "
+ elif isinstance(segment, Image):
+ fallback_text += " [图片] "
+
+ if self.get_group_id():
+ await self.web_client.chat_postMessage(
+ channel=self.get_group_id(), text=fallback_text
+ )
+ else:
+ await self.web_client.chat_postMessage(
+ channel=self.get_sender_id(), text=fallback_text
+ )
+
+ await super().send(message)
+
+ async def send_streaming(
+ self, generator: AsyncGenerator, use_fallback: bool = False
+ ):
+ if not use_fallback:
+ buffer = None
+ async for chain in generator:
+ if not buffer:
+ buffer = chain
+ else:
+ buffer.chain.extend(chain.chain)
+ if not buffer:
+ return
+ buffer.squash_plain()
+ await self.send(buffer)
+ return await super().send_streaming(generator, use_fallback)
+
+ buffer = ""
+ pattern = re.compile(r"[^。?!~…]+[。?!~…]+")
+
+ async for chain in generator:
+ if isinstance(chain, MessageChain):
+ for comp in chain.chain:
+ if isinstance(comp, Plain):
+ buffer += comp.text
+ if any(p in buffer for p in "。?!~…"):
+ buffer = await self.process_buffer(buffer, pattern)
+ else:
+ await self.send(MessageChain(chain=[comp]))
+ await asyncio.sleep(1.5) # 限速
+
+ if buffer.strip():
+ await self.send(MessageChain([Plain(buffer)]))
+ return await super().send_streaming(generator, use_fallback)
+
+ async def get_group(self, group_id=None, **kwargs):
+ if group_id:
+ channel_id = group_id
+ elif self.get_group_id():
+ channel_id = self.get_group_id()
+ else:
+ return None
+
+ try:
+ # 获取频道信息
+ channel_info = await self.web_client.conversations_info(channel=channel_id)
+
+ # 获取频道成员
+ members_response = await self.web_client.conversations_members(
+ channel=channel_id
+ )
+
+ members = []
+ for member_id in members_response["members"]:
+ try:
+ user_info = await self.web_client.users_info(user=member_id)
+ user_data = user_info["user"]
+ members.append(
+ MessageMember(
+ user_id=member_id,
+ nickname=user_data.get("real_name")
+ or user_data.get("name", member_id),
+ )
+ )
+ except Exception:
+ # 如果获取用户信息失败,使用默认信息
+ members.append(MessageMember(user_id=member_id, nickname=member_id))
+
+ channel_data = channel_info["channel"]
+ return Group(
+ group_id=channel_id,
+ group_name=channel_data.get("name", ""),
+ group_avatar="",
+ group_admins=[], # Slack 的管理员信息需要特殊权限获取
+ group_owner=channel_data.get("creator", ""),
+ members=members,
+ )
+ except Exception:
+ return None
diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py
index b11a3361..382f469f 100644
--- a/astrbot/core/provider/manager.py
+++ b/astrbot/core/provider/manager.py
@@ -225,6 +225,10 @@ class ProviderManager:
from .sources.edge_tts_source import (
ProviderEdgeTTS as ProviderEdgeTTS,
)
+ case "gsv_tts_selfhost":
+ from .sources.gsv_selfhosted_source import (
+ ProviderGSVTTS as ProviderGSVTTS,
+ )
case "gsvi_tts_api":
from .sources.gsvi_tts_source import (
ProviderGSVITTS as ProviderGSVITTS,
diff --git a/astrbot/core/provider/sources/gsv_selfhosted_source.py b/astrbot/core/provider/sources/gsv_selfhosted_source.py
new file mode 100644
index 00000000..6c4d872a
--- /dev/null
+++ b/astrbot/core/provider/sources/gsv_selfhosted_source.py
@@ -0,0 +1,148 @@
+import asyncio
+import os
+import uuid
+
+import aiohttp
+from ..provider import TTSProvider
+from ..entities import ProviderType
+from ..register import register_provider_adapter
+from astrbot import logger
+from astrbot.core.utils.astrbot_path import get_astrbot_data_path
+
+
+@register_provider_adapter(
+ provider_type_name="gsv_tts_selfhost",
+ desc="GPT-SoVITS TTS(本地加载)",
+ provider_type=ProviderType.TEXT_TO_SPEECH,
+)
+class ProviderGSVTTS(TTSProvider):
+ def __init__(
+ self,
+ provider_config: dict,
+ provider_settings: dict,
+ ) -> None:
+ super().__init__(provider_config, provider_settings)
+
+ self.api_base = provider_config.get("api_base", "http://127.0.0.1:9880").rstrip(
+ "/"
+ )
+ self.gpt_weights_path: str = provider_config.get("gpt_weights_path", "")
+ self.sovits_weights_path: str = provider_config.get("sovits_weights_path", "")
+
+ # TTS 请求的默认参数,移除前缀gsv_
+ self.default_params: dict = {
+ key.removeprefix("gsv_"): str(value).lower()
+ for key, value in provider_config.get("gsv_default_parms", {}).items()
+ }
+ self.timeout = provider_config.get("timeout", 60)
+ self._session: aiohttp.ClientSession | None = None
+
+ async def initialize(self):
+ """异步初始化:在 ProviderManager 中被调用"""
+ self._session = aiohttp.ClientSession(
+ timeout=aiohttp.ClientTimeout(total=self.timeout)
+ )
+ try:
+ await self._set_model_weights()
+ logger.info("[GSV TTS] 初始化完成")
+ except Exception as e:
+ logger.error(f"[GSV TTS] 初始化失败:{e}")
+ raise
+
+ def get_session(self) -> aiohttp.ClientSession:
+ if not self._session or self._session.closed:
+ raise RuntimeError(
+ "[GSV TTS] Provider HTTP session is not ready or closed."
+ )
+ return self._session
+
+ async def _make_request(
+ self, endpoint: str, params=None, retries: int = 3
+ ) -> bytes | None:
+ """发起请求"""
+ for attempt in range(retries):
+ logger.debug(f"[GSV TTS] 请求地址:{endpoint},参数:{params}")
+ try:
+ async with self.get_session().get(endpoint, params=params) as response:
+ if response.status != 200:
+ error_text = await response.text()
+ raise Exception(
+ f"[GSV TTS] Request to {endpoint} failed with status {response.status}: {error_text}"
+ )
+ return await response.read()
+ except Exception as e:
+ if attempt < retries - 1:
+ logger.warning(
+ f"[GSV TTS] 请求 {endpoint} 第 {attempt + 1} 次失败:{e},重试中..."
+ )
+ await asyncio.sleep(1)
+ else:
+ logger.error(f"[GSV TTS] 请求 {endpoint} 最终失败:{e}")
+ raise
+
+ async def _set_model_weights(self):
+ """设置模型路径"""
+ try:
+ if self.gpt_weights_path:
+ await self._make_request(
+ f"{self.api_base}/set_gpt_weights",
+ {"weights_path": self.gpt_weights_path},
+ )
+ logger.info(f"[GSV TTS] 成功设置 GPT 模型路径:{self.gpt_weights_path}")
+ else:
+ logger.info("[GSV TTS] GPT 模型路径未配置,将使用内置 GPT 模型")
+
+ if self.sovits_weights_path:
+ await self._make_request(
+ f"{self.api_base}/set_sovits_weights",
+ {"weights_path": self.sovits_weights_path},
+ )
+ logger.info(
+ f"[GSV TTS] 成功设置 SoVITS 模型路径:{self.sovits_weights_path}"
+ )
+ else:
+ logger.info("[GSV TTS] SoVITS 模型路径未配置,将使用内置 SoVITS 模型")
+ except aiohttp.ClientError as e:
+ logger.error(f"[GSV TTS] 设置模型路径时发生网络错误:{e}")
+ except Exception as e:
+ logger.error(f"[GSV TTS] 设置模型路径时发生未知错误:{e}")
+
+ async def get_audio(self, text: str) -> str:
+ """实现 TTS 核心方法,根据文本内容自动切换情绪"""
+ if not text.strip():
+ raise ValueError("[GSV TTS] TTS 文本不能为空")
+
+ endpoint = f"{self.api_base}/tts"
+
+ params = self.build_synthesis_params(text)
+
+ temp_dir = os.path.join(get_astrbot_data_path(), "temp")
+ os.makedirs(temp_dir, exist_ok=True)
+ path = os.path.join(temp_dir, f"gsv_tts_{uuid.uuid4().hex}.wav")
+
+ logger.debug(f"[GSV TTS] 正在调用语音合成接口,参数:{params}")
+
+ result = await self._make_request(endpoint, params)
+ if isinstance(result, bytes):
+ with open(path, "wb") as f:
+ f.write(result)
+ return path
+ else:
+ raise Exception(f"[GSV TTS] 合成失败,输入文本:{text},错误信息:{result}")
+
+ def build_synthesis_params(self, text: str) -> dict:
+ """
+ 构建语音合成所需的参数字典。
+
+ 当前仅包含默认参数 + 文本,未来可在此基础上动态添加如情绪、角色等语义控制字段。
+ """
+ params = self.default_params.copy()
+ params["text"] = text
+ # TODO: 在此处添加情绪分析,例如 params["emotion"] = detect_emotion(text)
+ return params
+
+ async def terminate(self):
+ """终止释放资源:在 ProviderManager 中被调用"""
+ if self._session and not self._session.closed:
+ await self._session.close()
+ logger.info("[GSV TTS] Session 已关闭")
diff --git a/astrbot/dashboard/routes/auth.py b/astrbot/dashboard/routes/auth.py
index 2eea1508..87af4b61 100644
--- a/astrbot/dashboard/routes/auth.py
+++ b/astrbot/dashboard/routes/auth.py
@@ -3,7 +3,7 @@ import datetime
import asyncio
from .route import Route, Response, RouteContext
from quart import request
-from astrbot.core import WEBUI_SK, DEMO_MODE
+from astrbot.core import DEMO_MODE
from astrbot import logger
@@ -80,5 +80,8 @@ class AuthRoute(Route):
"username": username,
"exp": datetime.datetime.utcnow() + datetime.timedelta(days=7),
}
- token = jwt.encode(payload, WEBUI_SK, algorithm="HS256")
+ jwt_token = self.config["dashboard"].get("jwt_secret", None)
+ if not jwt_token:
+ raise ValueError("JWT secret is not set in the cmd_config.")
+ token = jwt.encode(payload, jwt_token, algorithm="HS256")
return token
diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/routes/stat.py
index 65b77e06..79397290 100644
--- a/astrbot/dashboard/routes/stat.py
+++ b/astrbot/dashboard/routes/stat.py
@@ -41,10 +41,15 @@ class StatRoute(Route):
await self.core_lifecycle.restart()
return Response().ok().__dict__
- def format_sec(self, sec: int):
- m, s = divmod(sec, 60)
- h, m = divmod(m, 60)
- return f"{h}小时{m}分{s}秒"
+ def _get_running_time_components(self, total_seconds: int):
+ """将总秒数转换为时分秒组件"""
+ minutes, seconds = divmod(total_seconds, 60)
+ hours, minutes = divmod(minutes, 60)
+ return {
+ "hours": hours,
+ "minutes": minutes,
+ "seconds": seconds
+ }
def is_default_cred(self):
username = self.config["dashboard"]["username"]
@@ -107,6 +112,11 @@ class StatRoute(Route):
}
plugin_info.append(info)
+ # 计算运行时长组件
+ running_time = self._get_running_time_components(
+ int(time.time()) - self.core_lifecycle.start_time
+ )
+
stat_dict.update(
{
"platform": self.db_helper.get_grouped_base_stats(
@@ -119,9 +129,7 @@ class StatRoute(Route):
"plugin_count": len(plugins),
"plugins": plugin_info,
"message_time_series": message_time_based_stats,
- "running": self.format_sec(
- int(time.time()) - self.core_lifecycle.start_time
- ),
+ "running": running_time, # 现在返回时间组件而不是格式化的字符串
"memory": {
"process": psutil.Process().memory_info().rss >> 20,
"system": psutil.virtual_memory().total >> 20,
diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py
index 28b80a06..d0847a0b 100644
--- a/astrbot/dashboard/server.py
+++ b/astrbot/dashboard/server.py
@@ -64,6 +64,8 @@ class AstrBotDashboard:
self.shutdown_event = shutdown_event
+ self._init_jwt_secret()
+
async def srv_plug_route(self, subpath, *args, **kwargs):
"""
插件路由
@@ -90,7 +92,7 @@ class AstrBotDashboard:
if token.startswith("Bearer "):
token = token[7:]
try:
- payload = jwt.decode(token, WEBUI_SK, algorithms=["HS256"])
+ payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"])
g.username = payload["username"]
except jwt.ExpiredSignatureError:
r = jsonify(Response().error("Token 过期").__dict__)
@@ -142,6 +144,15 @@ class AstrBotDashboard:
except Exception as e:
return f"获取进程信息失败: {str(e)}"
+ def _init_jwt_secret(self):
+ if not self.config.get("dashboard", {}).get("jwt_secret", None):
+ # 如果没有设置 JWT 密钥,则生成一个新的密钥
+ jwt_secret = os.urandom(32).hex()
+ self.config["dashboard"]["jwt_secret"] = jwt_secret
+ self.config.save_config()
+ logger.info("Initialized random JWT secret for dashboard.")
+ self._jwt_secret = self.config["dashboard"]["jwt_secret"]
+
def run(self):
ip_addr = []
if p := os.environ.get("DASHBOARD_PORT"):
diff --git a/changelogs/v3.5.16.md b/changelogs/v3.5.16.md
new file mode 100644
index 00000000..5249b402
--- /dev/null
+++ b/changelogs/v3.5.16.md
@@ -0,0 +1,18 @@
+# What's Changed
+
+1. 新增:支持接入 Slack
+2. 新增:支持接入 Discord
+3. 新增:支持接入 KOOK
+4. 新增:支持接入 VoceChat
+5. 新增:微信客服支持语音的收发
+6. 新增:实现 WebUI 的 i18n 模型,WebUI 现已支持 English。
+7. 新增:支持接入 GPT SoVITS
+8. 优化:支持通过引用 Bot 消息来唤醒 Bot
+9. 优化:WebUI 滚动条、侧边栏样式优化
+10. 优化:WebUI ChatBox 的样式优化,添加切换夜间模式按钮
+11. 优化:WebUI Chat 页面的 SSE 连接优化及一些其他样式优化
+12. 优化:钉钉发送图片支持使用 AstrBot 自带的文件服务器
+13. 优化:新建服务提供商时,如果没有添加 Key,会弹出警告提示框
+14. 修复:会话隔离模式下,WeChatPadPro 会话 ID 为自身 ID
+15. 修复:会话隔离模式下,WeChatPadPro 无法回复群聊消息
+16. 修复:使用 uvx 启动 AstrBot 时,插件依赖无法正常安装
diff --git a/changelogs/v3.5.17.md b/changelogs/v3.5.17.md
new file mode 100644
index 00000000..162cbf1a
--- /dev/null
+++ b/changelogs/v3.5.17.md
@@ -0,0 +1,20 @@
+# What's Changed
+
+> 对 v3.5.16 的修订版本
+
+1. 新增:支持接入 Slack
+2. 新增:支持接入 Discord
+3. 新增:支持接入 KOOK
+4. 新增:支持接入 VoceChat
+5. 新增:微信客服支持语音的收发
+6. 新增:实现 WebUI 的 i18n 模型,WebUI 现已支持 English。
+7. 新增:支持接入 GPT SoVITS
+8. 优化:支持通过引用 Bot 消息来唤醒 Bot
+9. 优化:WebUI 滚动条、侧边栏样式优化
+10. 优化:WebUI ChatBox 的样式优化,添加切换夜间模式按钮
+11. 优化:WebUI Chat 页面的 SSE 连接优化及一些其他样式优化
+12. 优化:钉钉发送图片支持使用 AstrBot 自带的文件服务器
+13. 优化:新建服务提供商时,如果没有添加 Key,会弹出警告提示框
+14. 修复:会话隔离模式下,WeChatPadPro 会话 ID 为自身 ID
+15. 修复:会话隔离模式下,WeChatPadPro 无法回复群聊消息
+16. 修复:使用 uvx 启动 AstrBot 时,插件依赖无法正常安装
diff --git a/dashboard/package.json b/dashboard/package.json
index cd621b0b..7a5dd44a 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -31,6 +31,7 @@
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "1.0.2",
"vue": "3.3.4",
+ "vue-i18n": "^11.1.5",
"vue-router": "4.2.4",
"vue3-apexcharts": "1.4.4",
"vue3-print-nb": "0.1.4",
@@ -41,11 +42,11 @@
"@mdi/font": "7.2.96",
"@rushstack/eslint-patch": "1.3.3",
"@types/chance": "1.1.3",
- "@types/node": "20.5.7",
+ "@types/node": "^20.5.7",
"@vitejs/plugin-vue": "4.3.3",
"@vue/eslint-config-prettier": "8.0.0",
"@vue/eslint-config-typescript": "11.0.3",
- "@vue/tsconfig": "0.4.0",
+ "@vue/tsconfig": "^0.4.0",
"eslint": "8.48.0",
"eslint-plugin-vue": "9.17.0",
"prettier": "3.0.2",
diff --git a/dashboard/src/assets/images/platform_logos/dingtalk.svg b/dashboard/src/assets/images/platform_logos/dingtalk.svg
new file mode 100644
index 00000000..323aaf14
--- /dev/null
+++ b/dashboard/src/assets/images/platform_logos/dingtalk.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/dashboard/src/assets/images/platform_logos/discord.svg b/dashboard/src/assets/images/platform_logos/discord.svg
new file mode 100644
index 00000000..fb73caa5
--- /dev/null
+++ b/dashboard/src/assets/images/platform_logos/discord.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/dashboard/src/assets/images/platform_logos/kook.png b/dashboard/src/assets/images/platform_logos/kook.png
new file mode 100644
index 00000000..c99421c5
Binary files /dev/null and b/dashboard/src/assets/images/platform_logos/kook.png differ
diff --git a/dashboard/src/assets/images/platform_logos/lark.png b/dashboard/src/assets/images/platform_logos/lark.png
new file mode 100644
index 00000000..d1372f13
Binary files /dev/null and b/dashboard/src/assets/images/platform_logos/lark.png differ
diff --git a/dashboard/src/assets/images/platform_logos/qq.png b/dashboard/src/assets/images/platform_logos/qq.png
new file mode 100644
index 00000000..51227994
Binary files /dev/null and b/dashboard/src/assets/images/platform_logos/qq.png differ
diff --git a/dashboard/src/assets/images/platform_logos/slack.svg b/dashboard/src/assets/images/platform_logos/slack.svg
new file mode 100644
index 00000000..69a4eb6a
--- /dev/null
+++ b/dashboard/src/assets/images/platform_logos/slack.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/dashboard/src/assets/images/platform_logos/telegram.svg b/dashboard/src/assets/images/platform_logos/telegram.svg
new file mode 100644
index 00000000..8e4e9849
--- /dev/null
+++ b/dashboard/src/assets/images/platform_logos/telegram.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/dashboard/src/assets/images/platform_logos/vocechat.png b/dashboard/src/assets/images/platform_logos/vocechat.png
new file mode 100644
index 00000000..97ceb1d0
Binary files /dev/null and b/dashboard/src/assets/images/platform_logos/vocechat.png differ
diff --git a/dashboard/src/assets/images/platform_logos/wechat.png b/dashboard/src/assets/images/platform_logos/wechat.png
new file mode 100644
index 00000000..89f8aed2
Binary files /dev/null and b/dashboard/src/assets/images/platform_logos/wechat.png differ
diff --git a/dashboard/src/assets/images/platform_logos/wecom.png b/dashboard/src/assets/images/platform_logos/wecom.png
new file mode 100644
index 00000000..95bab3e2
Binary files /dev/null and b/dashboard/src/assets/images/platform_logos/wecom.png differ
diff --git a/dashboard/src/components/ConfirmDialog.vue b/dashboard/src/components/ConfirmDialog.vue
index 60380764..9b77a6b6 100644
--- a/dashboard/src/components/ConfirmDialog.vue
+++ b/dashboard/src/components/ConfirmDialog.vue
@@ -5,8 +5,8 @@
正在加载README文档...
+{{ t('core.common.readme.loading') }}
该插件未提供文档链接或GitHub仓库地址。
请查看插件市场或联系插件作者获取更多信息。
{{ t('core.common.readme.empty.title') }}
{{ t('core.common.readme.empty.subtitle') }}
A project out of interests and loves ❤️
+{{ tm('hero.subtitle') }}
- 本项目由众多开源社区成员共同维护。感谢每一位贡献者的付出! + {{ tm('contributors.description') }}
- AstrBot 采用 AGPL v3 协议开源
+{{ tm('stats.license') }}
help
- 获取帮助 😊
+ {{ tm('shortcuts.help') }} 😊
Ctrl
- 录制语音 🎤
+ {{ t('core.common.longPress') }}
+ Ctrl + B
+ {{ tm('shortcuts.voiceRecord') }} 🎤
Ctrl + V
- 粘贴图片 🏞️
+ {{ tm('shortcuts.pasteImage') }} 🏞️
- 管理和查看用户对话历史记录 + {{ tm('subtitle') }}
对话内容为空
+{{ tm('status.emptyContent') }}
确定要删除对话 {{ selectedConversation?.title || '无标题对话' }} 吗?此操作不可恢复。
+{{ tm('dialogs.delete.message', { title: selectedConversation?.title || tm('status.noTitle') }) }}
{{ extension_data.message }}
-详情请检查控制台
+{{ tm('dialogs.error.checkConsole') }}
设置每个插件在不同平台上的可用性,勾选表示启用
+{{ tm('dialogs.platformConfig.description') }}
这个插件没有配置
+{{ tm('dialogs.config.noConfig') }}
- 管理机器人的平台适配器,连接到不同的聊天平台 + {{ tm('subtitle') }}
- 管理模型服务提供商 + {{ tm('subtitle') }}