Merge branch 'master' into master
This commit is contained in:
@@ -231,9 +231,42 @@ CONFIG_METADATA_2 = {
|
|||||||
"enable": False,
|
"enable": False,
|
||||||
"discord_token": "在此处填入你的Discord Bot Token",
|
"discord_token": "在此处填入你的Discord Bot Token",
|
||||||
"discord_proxy": "",
|
"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": {
|
"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": {
|
"active_send_mode": {
|
||||||
"description": "是否换用主动发送接口",
|
"description": "是否换用主动发送接口",
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ class PlatformManager:
|
|||||||
from .sources.discord.discord_platform_adapter import (
|
from .sources.discord.discord_platform_adapter import (
|
||||||
DiscordPlatformAdapter, # noqa: F401
|
DiscordPlatformAdapter, # noqa: F401
|
||||||
)
|
)
|
||||||
|
case "slack":
|
||||||
|
from .sources.slack.slack_adapter import SlackAdapter # noqa: F401
|
||||||
except (ImportError, ModuleNotFoundError) as e:
|
except (ImportError, ModuleNotFoundError) as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。"
|
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>
|
</v-snackbar>
|
||||||
|
|
||||||
<WaitingForRestart ref="wfr"></WaitingForRestart>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -336,6 +354,10 @@ export default {
|
|||||||
sessionSeparationEnabled: false,
|
sessionSeparationEnabled: false,
|
||||||
sessionSettingLoading: false,
|
sessionSettingLoading: false,
|
||||||
|
|
||||||
|
// Key确认对话框
|
||||||
|
showKeyConfirm: false,
|
||||||
|
keyConfirmResolve: null,
|
||||||
|
|
||||||
newSelectedProviderName: '',
|
newSelectedProviderName: '',
|
||||||
newSelectedProviderConfig: {},
|
newSelectedProviderConfig: {},
|
||||||
updatingMode: false,
|
updatingMode: false,
|
||||||
@@ -383,6 +405,16 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
showKeyConfirm(newValue) {
|
||||||
|
// 当对话框关闭时,如果 Promise 还在等待,则拒绝它以防止内存泄漏
|
||||||
|
if (!newValue && this.keyConfirmResolve) {
|
||||||
|
this.keyConfirmResolve(false);
|
||||||
|
this.keyConfirmResolve = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
// 根据选择的标签过滤提供商列表
|
// 根据选择的标签过滤提供商列表
|
||||||
filteredProviders() {
|
filteredProviders() {
|
||||||
@@ -563,32 +595,40 @@ export default {
|
|||||||
this.updatingMode = true;
|
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;
|
this.loading = true;
|
||||||
if (this.updatingMode) {
|
const wasUpdating = this.updatingMode;
|
||||||
axios.post('/api/config/provider/update', {
|
try {
|
||||||
id: this.newSelectedProviderName,
|
if (wasUpdating) {
|
||||||
config: this.newSelectedProviderConfig
|
const res = await axios.post('/api/config/provider/update', {
|
||||||
}).then((res) => {
|
id: this.newSelectedProviderName,
|
||||||
this.loading = false;
|
config: this.newSelectedProviderConfig
|
||||||
this.showProviderCfg = false;
|
});
|
||||||
this.getConfig();
|
|
||||||
this.showSuccess(res.data.message || "更新成功!");
|
this.showSuccess(res.data.message || "更新成功!");
|
||||||
}).catch((err) => {
|
} else {
|
||||||
this.loading = false;
|
const res = await axios.post('/api/config/provider/new', this.newSelectedProviderConfig);
|
||||||
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();
|
|
||||||
this.showSuccess(res.data.message || "添加成功!");
|
this.showSuccess(res.data.message || "添加成功!");
|
||||||
}).catch((err) => {
|
}
|
||||||
this.loading = false;
|
this.showProviderCfg = false;
|
||||||
this.showError(err.response?.data?.message || err.message);
|
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.loadingStatus = false;
|
||||||
this.showError(err.response?.data?.message || err.message);
|
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>
|
</script>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ dependencies = [
|
|||||||
"quart>=0.20.0",
|
"quart>=0.20.0",
|
||||||
"readability-lxml>=0.8.4.1",
|
"readability-lxml>=0.8.4.1",
|
||||||
"silk-python>=0.2.6",
|
"silk-python>=0.2.6",
|
||||||
|
"slack-sdk>=3.35.0",
|
||||||
"telegramify-markdown>=0.5.1",
|
"telegramify-markdown>=0.5.1",
|
||||||
"watchfiles>=1.0.5",
|
"watchfiles>=1.0.5",
|
||||||
"websockets>=15.0.1",
|
"websockets>=15.0.1",
|
||||||
|
|||||||
+2
-1
@@ -38,4 +38,5 @@ websockets
|
|||||||
faiss-cpu
|
faiss-cpu
|
||||||
aiosqlite
|
aiosqlite
|
||||||
nh3
|
nh3
|
||||||
py-cord[speed]>=2.6.1
|
py-cord[speed]>=2.6.1
|
||||||
|
slack-sdk
|
||||||
Reference in New Issue
Block a user