mirror of
https://github.com/langbot-app/LangBot.git
synced 2025-11-25 19:37:36 +08:00
Merge pull request #1258 from RockChinQ/feat/slack
feat: add slack adapter
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,3 +39,4 @@ botpy.log*
|
|||||||
/libs/wecom_api/test.py
|
/libs/wecom_api/test.py
|
||||||
/venv
|
/venv
|
||||||
/jp-tyo-churros-05.rockchin.top
|
/jp-tyo-churros-05.rockchin.top
|
||||||
|
test.py
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
| 钉钉 | ✅ | |
|
| 钉钉 | ✅ | |
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | |
|
||||||
| Slack | 🚧 | |
|
| Slack | ✅ | |
|
||||||
| LINE | 🚧 | |
|
| LINE | 🚧 | |
|
||||||
| WhatsApp | 🚧 | |
|
| WhatsApp | 🚧 | |
|
||||||
|
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ Directly use the released version to run, see the [Manual Deployment](https://do
|
|||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | |
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | |
|
||||||
| Slack | 🚧 | |
|
| Slack | ✅ | |
|
||||||
| LINE | 🚧 | |
|
| LINE | 🚧 | |
|
||||||
| WhatsApp | 🚧 | |
|
| WhatsApp | 🚧 | |
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
|
|||||||
| DingTalk | ✅ | |
|
| DingTalk | ✅ | |
|
||||||
| Discord | ✅ | |
|
| Discord | ✅ | |
|
||||||
| Telegram | ✅ | |
|
| Telegram | ✅ | |
|
||||||
| Slack | 🚧 | |
|
| Slack | ✅ | |
|
||||||
| LINE | 🚧 | |
|
| LINE | 🚧 | |
|
||||||
| WhatsApp | 🚧 | |
|
| WhatsApp | 🚧 | |
|
||||||
|
|
||||||
|
|||||||
0
libs/slack_api/__init__.py
Normal file
0
libs/slack_api/__init__.py
Normal file
111
libs/slack_api/api.py
Normal file
111
libs/slack_api/api.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import json
|
||||||
|
from quart import Quart, jsonify,request
|
||||||
|
from slack_sdk.web.async_client import AsyncWebClient
|
||||||
|
from .slackevent import SlackEvent
|
||||||
|
from typing import Callable, Dict, Any
|
||||||
|
from pkg.platform.types import events as platform_events, message as platform_message
|
||||||
|
|
||||||
|
class SlackClient():
|
||||||
|
|
||||||
|
def __init__(self,bot_token:str,signing_secret:str):
|
||||||
|
|
||||||
|
self.bot_token = bot_token
|
||||||
|
self.signing_secret = signing_secret
|
||||||
|
self.app = Quart(__name__)
|
||||||
|
self.client = AsyncWebClient(self.bot_token)
|
||||||
|
self.app.add_url_rule('/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST'])
|
||||||
|
self._message_handlers = {
|
||||||
|
"example":[],
|
||||||
|
}
|
||||||
|
self.bot_user_id = None # 避免机器人回复自己的消息
|
||||||
|
|
||||||
|
async def handle_callback_request(self):
|
||||||
|
try:
|
||||||
|
body = await request.get_data()
|
||||||
|
data = json.loads(body)
|
||||||
|
if 'type' in data:
|
||||||
|
if data['type'] == 'url_verification':
|
||||||
|
return data['challenge']
|
||||||
|
|
||||||
|
bot_user_id = data.get("event",{}).get("bot_id","")
|
||||||
|
|
||||||
|
if self.bot_user_id and bot_user_id == self.bot_user_id:
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
|
||||||
|
# 处理私信
|
||||||
|
if data and data.get("event", {}).get("channel_type") in ["im"]:
|
||||||
|
event = SlackEvent.from_payload(data)
|
||||||
|
await self._handle_message(event)
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
#处理群聊
|
||||||
|
if data.get("event",{}).get("type") == 'app_mention':
|
||||||
|
data.setdefault("event", {})["channel_type"] = "channel"
|
||||||
|
event = SlackEvent.from_payload(data)
|
||||||
|
await self._handle_message(event)
|
||||||
|
return jsonify({'status':'ok'})
|
||||||
|
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise(e)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_message(self, event: SlackEvent):
|
||||||
|
"""
|
||||||
|
处理消息事件。
|
||||||
|
"""
|
||||||
|
msg_type = event.type
|
||||||
|
if msg_type in self._message_handlers:
|
||||||
|
for handler in self._message_handlers[msg_type]:
|
||||||
|
await handler(event)
|
||||||
|
|
||||||
|
def on_message(self, msg_type: str):
|
||||||
|
"""注册消息类型处理器"""
|
||||||
|
def decorator(func: Callable[[platform_events.Event], None]):
|
||||||
|
if msg_type not in self._message_handlers:
|
||||||
|
self._message_handlers[msg_type] = []
|
||||||
|
self._message_handlers[msg_type].append(func)
|
||||||
|
return func
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
async def send_message_to_channel(self,text:str,channel_id:str):
|
||||||
|
try:
|
||||||
|
response = await self.client.chat_postMessage(
|
||||||
|
channel=channel_id,
|
||||||
|
text=text
|
||||||
|
)
|
||||||
|
if self.bot_user_id is None and response.get("ok"):
|
||||||
|
self.bot_user_id = response["message"]["bot_id"]
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
async def send_message_to_one(self,text:str,user_id:str):
|
||||||
|
try:
|
||||||
|
response = await self.client.chat_postMessage(
|
||||||
|
channel = '@'+user_id,
|
||||||
|
text= text
|
||||||
|
)
|
||||||
|
if self.bot_user_id is None and response.get("ok"):
|
||||||
|
self.bot_user_id = response["message"]["bot_id"]
|
||||||
|
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
async def run_task(self, host: str, port: int, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
启动 Quart 应用。
|
||||||
|
"""
|
||||||
|
await self.app.run_task(host=host, port=port, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
91
libs/slack_api/slackevent.py
Normal file
91
libs/slack_api/slackevent.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
class SlackEvent(dict):
|
||||||
|
@staticmethod
|
||||||
|
def from_payload(payload: Dict[str, Any]) -> Optional["SlackEvent"]:
|
||||||
|
try:
|
||||||
|
event = SlackEvent(payload)
|
||||||
|
return event
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
|
||||||
|
if self.get("event", {}).get("channel_type") == "im":
|
||||||
|
blocks = self.get("event", {}).get("blocks", [])
|
||||||
|
if not blocks:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
elements = blocks[0].get("elements", [])
|
||||||
|
if not elements:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
elements = elements[0].get("elements", [])
|
||||||
|
text = ""
|
||||||
|
|
||||||
|
for el in elements:
|
||||||
|
if el.get("type") == "text":
|
||||||
|
text += el.get("text", "")
|
||||||
|
elif el.get("type") == "link":
|
||||||
|
text += el.get("url", "")
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
if self.get("event",{}).get("channel_type") == 'channel':
|
||||||
|
message_text = ""
|
||||||
|
for block in self.get("event", {}).get("blocks", []):
|
||||||
|
if block.get("type") == "rich_text":
|
||||||
|
for element in block.get("elements", []):
|
||||||
|
if element.get("type") == "rich_text_section":
|
||||||
|
parts = []
|
||||||
|
for el in element.get("elements", []):
|
||||||
|
if el.get("type") == "text":
|
||||||
|
parts.append(el["text"])
|
||||||
|
elif el.get("type") == "link":
|
||||||
|
parts.append(el["url"])
|
||||||
|
message_text = "".join(parts)
|
||||||
|
|
||||||
|
return message_text
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_id(self) -> Optional[str]:
|
||||||
|
return self.get("event", {}).get("user","")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channel_id(self) -> Optional[str]:
|
||||||
|
return self.get("event", {}).get("channel","")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self) -> str:
|
||||||
|
""" message对应私聊,app_mention对应频道at """
|
||||||
|
return self.get("event", {}).get("channel_type", "")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message_id(self) -> str:
|
||||||
|
return self.get("event_id","")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pic_url(self) -> str:
|
||||||
|
"""提取 Slack 事件中的图片 URL"""
|
||||||
|
files = self.get("event", {}).get("files", [])
|
||||||
|
if files:
|
||||||
|
return files[0].get("url_private", "")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sender_name(self) -> str:
|
||||||
|
return self.get("event", {}).get("user","")
|
||||||
|
|
||||||
|
def __getattr__(self, key: str) -> Optional[Any]:
|
||||||
|
return self.get(key)
|
||||||
|
|
||||||
|
def __setattr__(self, key: str, value: Any) -> None:
|
||||||
|
self[key] = value
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<SlackEvent {super().__repr__()}>"
|
||||||
204
pkg/platform/sources/slack.py
Normal file
204
pkg/platform/sources/slack.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
import typing
|
||||||
|
import asyncio
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from libs.slack_api.api import SlackClient
|
||||||
|
from pkg.platform.adapter import MessagePlatformAdapter
|
||||||
|
from pkg.platform.types import events as platform_events, message as platform_message
|
||||||
|
from libs.slack_api.slackevent import SlackEvent
|
||||||
|
from pkg.core import app
|
||||||
|
from .. import adapter
|
||||||
|
from ...core import app
|
||||||
|
from ..types import message as platform_message
|
||||||
|
from ..types import events as platform_events
|
||||||
|
from ..types import entities as platform_entities
|
||||||
|
from ...command.errors import ParamNotEnoughError
|
||||||
|
from ...utils import image
|
||||||
|
|
||||||
|
class SlackMessageConverter(adapter.MessageConverter):
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def yiri2target(message_chain:platform_message.MessageChain):
|
||||||
|
content_list = []
|
||||||
|
for msg in message_chain:
|
||||||
|
if type(msg) is platform_message.Plain:
|
||||||
|
content_list.append({
|
||||||
|
"content":msg.text,
|
||||||
|
})
|
||||||
|
|
||||||
|
return content_list
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def target2yiri(message:str,message_id:str,pic_url:str,bot:SlackClient):
|
||||||
|
yiri_msg_list = []
|
||||||
|
yiri_msg_list.append(
|
||||||
|
platform_message.Source(id=message_id,time=datetime.datetime.now())
|
||||||
|
)
|
||||||
|
if pic_url is not None:
|
||||||
|
base64_url = await image.get_slack_image_to_base64(pic_url=pic_url,bot_token=bot.bot_token)
|
||||||
|
yiri_msg_list.append(
|
||||||
|
platform_message.Image(base64=base64_url)
|
||||||
|
)
|
||||||
|
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=message))
|
||||||
|
chain = platform_message.MessageChain(yiri_msg_list)
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
class SlackEventConverter(adapter.EventConverter):
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def yiri2target(event:platform_events.MessageEvent) -> SlackEvent:
|
||||||
|
return event.source_platform_object
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def target2yiri(event:SlackEvent,bot:SlackClient):
|
||||||
|
yiri_chain = await SlackMessageConverter.target2yiri(
|
||||||
|
message=event.text,message_id=event.message_id,pic_url=event.pic_url,bot=bot
|
||||||
|
)
|
||||||
|
|
||||||
|
if event.type == 'channel':
|
||||||
|
yiri_chain.insert(0, platform_message.At(target="SlackBot"))
|
||||||
|
|
||||||
|
sender = platform_entities.GroupMember(
|
||||||
|
id = event.user_id,
|
||||||
|
member_name= str(event.sender_name),
|
||||||
|
permission= 'MEMBER',
|
||||||
|
group = platform_entities.Group(
|
||||||
|
id = event.channel_id,
|
||||||
|
name = 'MEMBER',
|
||||||
|
permission= platform_entities.Permission.Member
|
||||||
|
),
|
||||||
|
special_title='',
|
||||||
|
join_timestamp=0,
|
||||||
|
last_speak_timestamp=0,
|
||||||
|
mute_time_remaining=0
|
||||||
|
)
|
||||||
|
time = int(datetime.datetime.utcnow().timestamp())
|
||||||
|
return platform_events.GroupMessage(
|
||||||
|
sender = sender,
|
||||||
|
message_chain=yiri_chain,
|
||||||
|
time = time,
|
||||||
|
source_platform_object=event
|
||||||
|
)
|
||||||
|
|
||||||
|
if event.type == 'im':
|
||||||
|
return platform_events.FriendMessage(
|
||||||
|
sender=platform_entities.Friend(
|
||||||
|
id=event.user_id,
|
||||||
|
nickname = event.sender_name,
|
||||||
|
remark=""
|
||||||
|
),
|
||||||
|
message_chain = yiri_chain,
|
||||||
|
time = float(datetime.datetime.now().timestamp()),
|
||||||
|
source_platform_object=event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class SlackAdapter(adapter.MessagePlatformAdapter):
|
||||||
|
bot: SlackClient
|
||||||
|
ap: app.Application
|
||||||
|
bot_account_id: str
|
||||||
|
message_converter: SlackMessageConverter = SlackMessageConverter()
|
||||||
|
event_converter: SlackEventConverter = SlackEventConverter()
|
||||||
|
config: dict
|
||||||
|
|
||||||
|
def __init__(self,config:dict,ap:app.Application):
|
||||||
|
self.config = config
|
||||||
|
self.ap = ap
|
||||||
|
required_keys = [
|
||||||
|
"bot_token",
|
||||||
|
"signing_secret",
|
||||||
|
]
|
||||||
|
missing_keys = [key for key in required_keys if key not in config]
|
||||||
|
if missing_keys:
|
||||||
|
raise ParamNotEnoughError("Slack机器人缺少相关配置项,请查看文档或联系管理员")
|
||||||
|
|
||||||
|
self.bot = SlackClient(
|
||||||
|
bot_token=self.config["bot_token"],
|
||||||
|
signing_secret=self.config["signing_secret"]
|
||||||
|
)
|
||||||
|
|
||||||
|
async def reply_message(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
):
|
||||||
|
slack_event = await SlackEventConverter.yiri2target(
|
||||||
|
message_source
|
||||||
|
)
|
||||||
|
|
||||||
|
content_list = await SlackMessageConverter.yiri2target(message)
|
||||||
|
|
||||||
|
for content in content_list:
|
||||||
|
if slack_event.type == 'channel':
|
||||||
|
await self.bot.send_message_to_channel(
|
||||||
|
content['content'],slack_event.channel_id
|
||||||
|
)
|
||||||
|
if slack_event.type == 'im':
|
||||||
|
await self.bot.send_message_to_one(
|
||||||
|
content['content'],slack_event.user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
|
content_list = await SlackMessageConverter.yiri2target(message)
|
||||||
|
for content in content_list:
|
||||||
|
if target_type == 'person':
|
||||||
|
await self.bot.send_message_to_one(content['content'],target_id)
|
||||||
|
if target_type == 'group':
|
||||||
|
await self.bot.send_message_to_channel(content['content'],target_id)
|
||||||
|
|
||||||
|
|
||||||
|
def register_listener(
|
||||||
|
self,
|
||||||
|
event_type: typing.Type[platform_events.Event],
|
||||||
|
callback: typing.Callable[
|
||||||
|
[platform_events.Event, adapter.MessagePlatformAdapter], None
|
||||||
|
],
|
||||||
|
):
|
||||||
|
async def on_message(event:SlackEvent):
|
||||||
|
self.bot_account_id = 'SlackBot'
|
||||||
|
try:
|
||||||
|
return await callback(
|
||||||
|
await self.event_converter.target2yiri(event,self.bot),self
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
if event_type == platform_events.FriendMessage:
|
||||||
|
self.bot.on_message("im")(on_message)
|
||||||
|
elif event_type == platform_events.GroupMessage:
|
||||||
|
self.bot.on_message("channel")(on_message)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async(self):
|
||||||
|
async def shutdown_trigger_placeholder():
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
await self.bot.run_task(
|
||||||
|
host="0.0.0.0",
|
||||||
|
port=self.config["port"],
|
||||||
|
shutdown_trigger=shutdown_trigger_placeholder,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def kill(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def unregister_listener(
|
||||||
|
self,
|
||||||
|
event_type: type,
|
||||||
|
callback: typing.Callable[[platform_events.Event, MessagePlatformAdapter], None],
|
||||||
|
):
|
||||||
|
return super().unregister_listener(event_type, callback)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
44
pkg/platform/sources/slack.yaml
Normal file
44
pkg/platform/sources/slack.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: MessagePlatformAdapter
|
||||||
|
metadata:
|
||||||
|
name: slack
|
||||||
|
label:
|
||||||
|
en_US: Slack API
|
||||||
|
zh_CN: Slack API
|
||||||
|
description:
|
||||||
|
en_US: Slack API
|
||||||
|
zh_CN: Slack API
|
||||||
|
spec:
|
||||||
|
config:
|
||||||
|
- name: bot_token
|
||||||
|
label:
|
||||||
|
en_US: Bot Token
|
||||||
|
zh_CN: 机器人令牌
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ""
|
||||||
|
- name: signing_secret
|
||||||
|
label:
|
||||||
|
en_US: signing_secret
|
||||||
|
zh_CN: 密钥
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ""
|
||||||
|
- name: port
|
||||||
|
label:
|
||||||
|
en_US: Port
|
||||||
|
zh_CN: 监听端口
|
||||||
|
type: int
|
||||||
|
required: true
|
||||||
|
default: 2288
|
||||||
|
- name: host
|
||||||
|
label:
|
||||||
|
en_US: Host
|
||||||
|
zh_CN: 监听主机
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: 0.0.0.0
|
||||||
|
execution:
|
||||||
|
python:
|
||||||
|
path: ./slack.py
|
||||||
|
attr: SlackAdapter
|
||||||
@@ -213,3 +213,18 @@ async def extract_b64_and_format(image_base64_data: str) -> typing.Tuple[str, st
|
|||||||
base64_str = image_base64_data.split(',')[-1]
|
base64_str = image_base64_data.split(',')[-1]
|
||||||
image_format = image_base64_data.split(':')[-1].split(';')[0].split('/')[-1]
|
image_format = image_base64_data.split(':')[-1].split(';')[0].split('/')[-1]
|
||||||
return base64_str, image_format
|
return base64_str, image_format
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def get_slack_image_to_base64(pic_url:str, bot_token:str):
|
||||||
|
headers = {"Authorization": f"Bearer {bot_token}"}
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(pic_url, headers=headers) as resp:
|
||||||
|
image_data = await resp.read()
|
||||||
|
return base64.b64encode(image_data).decode('utf-8')
|
||||||
|
except Exception as e:
|
||||||
|
raise(e)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,6 @@ dashscope
|
|||||||
python-telegram-bot
|
python-telegram-bot
|
||||||
certifi
|
certifi
|
||||||
mcp
|
mcp
|
||||||
|
slack_sdk
|
||||||
# indirect
|
# indirect
|
||||||
taskgroup==0.0.0a4
|
taskgroup==0.0.0a4
|
||||||
@@ -94,6 +94,13 @@
|
|||||||
"adapter":"telegram",
|
"adapter":"telegram",
|
||||||
"enable": false,
|
"enable": false,
|
||||||
"token":""
|
"token":""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"adapter":"slack",
|
||||||
|
"enable":true,
|
||||||
|
"bot_token":"",
|
||||||
|
"signing_secret":"",
|
||||||
|
"port":2288
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"track-function-calls": true,
|
"track-function-calls": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user