Merge branch 'master' into master
This commit is contained in:
@@ -231,9 +231,42 @@ CONFIG_METADATA_2 = {
|
||||
"enable": False,
|
||||
"discord_token": "在此处填入你的Discord Bot Token",
|
||||
"discord_proxy": "",
|
||||
}
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -84,6 +84,8 @@ class PlatformManager:
|
||||
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库 中安装依赖库。"
|
||||
|
||||
@@ -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 客户端已停止")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -306,6 +306,24 @@
|
||||
</v-snackbar>
|
||||
|
||||
<WaitingForRestart ref="wfr"></WaitingForRestart>
|
||||
|
||||
<!-- Key为空的确认对话框 -->
|
||||
<v-dialog v-model="showKeyConfirm" max-width="450" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 bg-error d-flex align-center">
|
||||
<v-icon start class="me-2">mdi-alert-circle-outline</v-icon>
|
||||
确认保存
|
||||
</v-card-title>
|
||||
<v-card-text class="py-4 text-body-1 text-medium-emphasis">
|
||||
您没有填写 API Key,确定要保存吗?这可能会导致该服务提供商无法正常工作。
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey" variant="text" @click="handleKeyConfirm(false)">取消</v-btn>
|
||||
<v-btn color="error" variant="flat" @click="handleKeyConfirm(true)">确定</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -336,6 +354,10 @@ export default {
|
||||
sessionSeparationEnabled: false,
|
||||
sessionSettingLoading: false,
|
||||
|
||||
// Key确认对话框
|
||||
showKeyConfirm: false,
|
||||
keyConfirmResolve: null,
|
||||
|
||||
newSelectedProviderName: '',
|
||||
newSelectedProviderConfig: {},
|
||||
updatingMode: false,
|
||||
@@ -383,6 +405,16 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
showKeyConfirm(newValue) {
|
||||
// 当对话框关闭时,如果 Promise 还在等待,则拒绝它以防止内存泄漏
|
||||
if (!newValue && this.keyConfirmResolve) {
|
||||
this.keyConfirmResolve(false);
|
||||
this.keyConfirmResolve = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
// 根据选择的标签过滤提供商列表
|
||||
filteredProviders() {
|
||||
@@ -563,32 +595,40 @@ export default {
|
||||
this.updatingMode = true;
|
||||
},
|
||||
|
||||
newProvider() {
|
||||
async newProvider() {
|
||||
// 检查 key 是否为空
|
||||
if (
|
||||
'key' in this.newSelectedProviderConfig &&
|
||||
(!this.newSelectedProviderConfig.key || this.newSelectedProviderConfig.key.length === 0)
|
||||
) {
|
||||
const confirmed = await this.confirmEmptyKey();
|
||||
if (!confirmed) {
|
||||
return; // 如果用户取消,则中止保存
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
if (this.updatingMode) {
|
||||
axios.post('/api/config/provider/update', {
|
||||
id: this.newSelectedProviderName,
|
||||
config: this.newSelectedProviderConfig
|
||||
}).then((res) => {
|
||||
this.loading = false;
|
||||
this.showProviderCfg = false;
|
||||
this.getConfig();
|
||||
const wasUpdating = this.updatingMode;
|
||||
try {
|
||||
if (wasUpdating) {
|
||||
const res = await axios.post('/api/config/provider/update', {
|
||||
id: this.newSelectedProviderName,
|
||||
config: this.newSelectedProviderConfig
|
||||
});
|
||||
this.showSuccess(res.data.message || "更新成功!");
|
||||
}).catch((err) => {
|
||||
this.loading = false;
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
this.updatingMode = false;
|
||||
} else {
|
||||
axios.post('/api/config/provider/new', this.newSelectedProviderConfig).then((res) => {
|
||||
this.loading = false;
|
||||
this.showProviderCfg = false;
|
||||
this.getConfig();
|
||||
} else {
|
||||
const res = await axios.post('/api/config/provider/new', this.newSelectedProviderConfig);
|
||||
this.showSuccess(res.data.message || "添加成功!");
|
||||
}).catch((err) => {
|
||||
this.loading = false;
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
}
|
||||
this.showProviderCfg = false;
|
||||
this.getConfig();
|
||||
} catch (err) {
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
if (wasUpdating) {
|
||||
this.updatingMode = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -670,7 +710,21 @@ export default {
|
||||
this.loadingStatus = false;
|
||||
this.showError(err.response?.data?.message || err.message);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
confirmEmptyKey() {
|
||||
this.showKeyConfirm = true;
|
||||
return new Promise((resolve) => {
|
||||
this.keyConfirmResolve = resolve;
|
||||
});
|
||||
},
|
||||
|
||||
handleKeyConfirm(confirmed) {
|
||||
if (this.keyConfirmResolve) {
|
||||
this.keyConfirmResolve(confirmed);
|
||||
}
|
||||
this.showKeyConfirm = false;
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -42,6 +42,7 @@ dependencies = [
|
||||
"quart>=0.20.0",
|
||||
"readability-lxml>=0.8.4.1",
|
||||
"silk-python>=0.2.6",
|
||||
"slack-sdk>=3.35.0",
|
||||
"telegramify-markdown>=0.5.1",
|
||||
"watchfiles>=1.0.5",
|
||||
"websockets>=15.0.1",
|
||||
|
||||
+2
-1
@@ -38,4 +38,5 @@ websockets
|
||||
faiss-cpu
|
||||
aiosqlite
|
||||
nh3
|
||||
py-cord[speed]>=2.6.1
|
||||
py-cord[speed]>=2.6.1
|
||||
slack-sdk
|
||||
Reference in New Issue
Block a user