chore: adjust dir structure
0
src/langbot/pkg/platform/sources/__init__.py
Normal file
452
src/langbot/pkg/platform/sources/aiocqhttp.py
Normal file
@@ -0,0 +1,452 @@
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
import asyncio
|
||||
import traceback
|
||||
import datetime
|
||||
|
||||
import aiocqhttp
|
||||
import pydantic
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
from ...utils import image
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
|
||||
|
||||
class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(
|
||||
message_chain: platform_message.MessageChain,
|
||||
) -> typing.Tuple[list, int, datetime.datetime]:
|
||||
msg_list = aiocqhttp.Message()
|
||||
|
||||
msg_id = 0
|
||||
msg_time = None
|
||||
|
||||
for msg in message_chain:
|
||||
if type(msg) is platform_message.Plain:
|
||||
msg_list.append(aiocqhttp.MessageSegment.text(msg.text))
|
||||
elif type(msg) is platform_message.Source:
|
||||
msg_id = msg.id
|
||||
msg_time = msg.time
|
||||
elif type(msg) is platform_message.Image:
|
||||
arg = ''
|
||||
if msg.base64:
|
||||
arg = msg.base64
|
||||
msg_list.append(aiocqhttp.MessageSegment.image(f'base64://{arg}'))
|
||||
elif msg.url:
|
||||
arg = msg.url
|
||||
msg_list.append(aiocqhttp.MessageSegment.image(arg))
|
||||
elif msg.path:
|
||||
arg = msg.path
|
||||
msg_list.append(aiocqhttp.MessageSegment.image(arg))
|
||||
elif type(msg) is platform_message.At:
|
||||
msg_list.append(aiocqhttp.MessageSegment.at(msg.target))
|
||||
elif type(msg) is platform_message.AtAll:
|
||||
msg_list.append(aiocqhttp.MessageSegment.at('all'))
|
||||
elif type(msg) is platform_message.Voice:
|
||||
arg = ''
|
||||
if msg.base64:
|
||||
arg = msg.base64
|
||||
msg_list.append(aiocqhttp.MessageSegment.record(f'base64://{arg}'))
|
||||
elif msg.url:
|
||||
arg = msg.url
|
||||
msg_list.append(aiocqhttp.MessageSegment.record(arg))
|
||||
elif msg.path:
|
||||
arg = msg.path
|
||||
msg_list.append(aiocqhttp.MessageSegment.record(msg.path))
|
||||
elif type(msg) is platform_message.Forward:
|
||||
for node in msg.node_list:
|
||||
msg_list.extend((await AiocqhttpMessageConverter.yiri2target(node.message_chain))[0])
|
||||
elif isinstance(msg, platform_message.File):
|
||||
msg_list.append({'type': 'file', 'data': {'file': msg.url, 'name': msg.name}})
|
||||
elif isinstance(msg, platform_message.Face):
|
||||
if msg.face_type == 'face':
|
||||
msg_list.append(aiocqhttp.MessageSegment.face(msg.face_id))
|
||||
elif msg.face_type == 'rps':
|
||||
msg_list.append(aiocqhttp.MessageSegment.rps())
|
||||
elif msg.face_type == 'dice':
|
||||
msg_list.append(aiocqhttp.MessageSegment.dice())
|
||||
|
||||
else:
|
||||
msg_list.append(aiocqhttp.MessageSegment.text(str(msg)))
|
||||
|
||||
return msg_list, msg_id, msg_time
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message: str, message_id: int = -1, bot: aiocqhttp.CQHttp = None):
|
||||
message = aiocqhttp.Message(message)
|
||||
|
||||
def get_face_name(face_id):
|
||||
face_code_dict = {
|
||||
'2': '好色',
|
||||
'4': '得意',
|
||||
'5': '流泪',
|
||||
'8': '睡',
|
||||
'9': '大哭',
|
||||
'10': '尴尬',
|
||||
'12': '调皮',
|
||||
'14': '微笑',
|
||||
'16': '酷',
|
||||
'21': '可爱',
|
||||
'23': '傲慢',
|
||||
'24': '饥饿',
|
||||
'25': '困',
|
||||
'26': '惊恐',
|
||||
'27': '流汗',
|
||||
'28': '憨笑',
|
||||
'29': '悠闲',
|
||||
'30': '奋斗',
|
||||
'32': '疑问',
|
||||
'33': '嘘',
|
||||
'34': '晕',
|
||||
'38': '敲打',
|
||||
'39': '再见',
|
||||
'41': '发抖',
|
||||
'42': '爱情',
|
||||
'43': '跳跳',
|
||||
'49': '拥抱',
|
||||
'53': '蛋糕',
|
||||
'60': '咖啡',
|
||||
'63': '玫瑰',
|
||||
'66': '爱心',
|
||||
'74': '太阳',
|
||||
'75': '月亮',
|
||||
'76': '赞',
|
||||
'78': '握手',
|
||||
'79': '胜利',
|
||||
'85': '飞吻',
|
||||
'89': '西瓜',
|
||||
'96': '冷汗',
|
||||
'97': '擦汗',
|
||||
'98': '抠鼻',
|
||||
'99': '鼓掌',
|
||||
'100': '糗大了',
|
||||
'101': '坏笑',
|
||||
'102': '左哼哼',
|
||||
'103': '右哼哼',
|
||||
'104': '哈欠',
|
||||
'106': '委屈',
|
||||
'109': '左亲亲',
|
||||
'111': '可怜',
|
||||
'116': '示爱',
|
||||
'118': '抱拳',
|
||||
'120': '拳头',
|
||||
'122': '爱你',
|
||||
'123': 'NO',
|
||||
'124': 'OK',
|
||||
'125': '转圈',
|
||||
'129': '挥手',
|
||||
'144': '喝彩',
|
||||
'147': '棒棒糖',
|
||||
'171': '茶',
|
||||
'173': '泪奔',
|
||||
'174': '无奈',
|
||||
'175': '卖萌',
|
||||
'176': '小纠结',
|
||||
'179': 'doge',
|
||||
'180': '惊喜',
|
||||
'181': '骚扰',
|
||||
'182': '笑哭',
|
||||
'183': '我最美',
|
||||
'201': '点赞',
|
||||
'203': '托脸',
|
||||
'212': '托腮',
|
||||
'214': '啵啵',
|
||||
'219': '蹭一蹭',
|
||||
'222': '抱抱',
|
||||
'227': '拍手',
|
||||
'232': '佛系',
|
||||
'240': '喷脸',
|
||||
'243': '甩头',
|
||||
'246': '加油抱抱',
|
||||
'262': '脑阔疼',
|
||||
'264': '捂脸',
|
||||
'265': '辣眼睛',
|
||||
'266': '哦哟',
|
||||
'267': '头秃',
|
||||
'268': '问号脸',
|
||||
'269': '暗中观察',
|
||||
'270': 'emm',
|
||||
'271': '吃瓜',
|
||||
'272': '呵呵哒',
|
||||
'273': '我酸了',
|
||||
'277': '汪汪',
|
||||
'278': '汗',
|
||||
'281': '无眼笑',
|
||||
'282': '敬礼',
|
||||
'284': '面无表情',
|
||||
'285': '摸鱼',
|
||||
'287': '哦',
|
||||
'289': '睁眼',
|
||||
'290': '敲开心',
|
||||
'293': '摸锦鲤',
|
||||
'294': '期待',
|
||||
'297': '拜谢',
|
||||
'298': '元宝',
|
||||
'299': '牛啊',
|
||||
'305': '右亲亲',
|
||||
'306': '牛气冲天',
|
||||
'307': '喵喵',
|
||||
'314': '仔细分析',
|
||||
'315': '加油',
|
||||
'318': '崇拜',
|
||||
'319': '比心',
|
||||
'320': '庆祝',
|
||||
'322': '拒绝',
|
||||
'324': '吃糖',
|
||||
'326': '生气',
|
||||
}
|
||||
return face_code_dict.get(face_id, '')
|
||||
|
||||
async def process_message_data(msg_data, reply_list):
|
||||
if msg_data['type'] == 'image':
|
||||
image_base64, image_format = await image.qq_image_url_to_base64(msg_data['data']['url'])
|
||||
reply_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
|
||||
|
||||
elif msg_data['type'] == 'text':
|
||||
reply_list.append(platform_message.Plain(text=msg_data['data']['text']))
|
||||
|
||||
elif msg_data['type'] == 'forward': # 这里来应该传入转发消息组,暂时传入qoute
|
||||
for forward_msg_datas in msg_data['data']['content']:
|
||||
for forward_msg_data in forward_msg_datas['message']:
|
||||
await process_message_data(forward_msg_data, reply_list)
|
||||
|
||||
elif msg_data['type'] == 'at':
|
||||
if msg_data['data']['qq'] == 'all':
|
||||
reply_list.append(platform_message.AtAll())
|
||||
else:
|
||||
reply_list.append(
|
||||
platform_message.At(
|
||||
target=msg_data['data']['qq'],
|
||||
)
|
||||
)
|
||||
|
||||
yiri_msg_list = []
|
||||
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
|
||||
for msg in message:
|
||||
reply_list = []
|
||||
if msg.type == 'at':
|
||||
if msg.data['qq'] == 'all':
|
||||
yiri_msg_list.append(platform_message.AtAll())
|
||||
else:
|
||||
yiri_msg_list.append(
|
||||
platform_message.At(
|
||||
target=msg.data['qq'],
|
||||
)
|
||||
)
|
||||
elif msg.type == 'text':
|
||||
yiri_msg_list.append(platform_message.Plain(text=msg.data['text']))
|
||||
elif msg.type == 'image':
|
||||
emoji_id = msg.data.get('emoji_package_id', None)
|
||||
if emoji_id:
|
||||
face_id = emoji_id
|
||||
face_name = msg.data.get('summary', '')
|
||||
image_msg = platform_message.Face(face_id=face_id, face_name=face_name)
|
||||
else:
|
||||
image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url'])
|
||||
image_msg = platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')
|
||||
yiri_msg_list.append(image_msg)
|
||||
elif msg.type == 'forward':
|
||||
# 暂时不太合理
|
||||
# msg_datas = await bot.get_msg(message_id=message_id)
|
||||
# print(msg_datas)
|
||||
# for msg_data in msg_datas["message"]:
|
||||
# await process_message_data(msg_data, yiri_msg_list)
|
||||
pass
|
||||
|
||||
elif msg.type == 'reply': # 此处处理引用消息传入Qoute
|
||||
msg_datas = await bot.get_msg(message_id=msg.data['id'])
|
||||
|
||||
for msg_data in msg_datas['message']:
|
||||
await process_message_data(msg_data, reply_list)
|
||||
|
||||
reply_msg = platform_message.Quote(
|
||||
message_id=msg.data['id'], sender_id=msg_datas['user_id'], origin=reply_list
|
||||
)
|
||||
yiri_msg_list.append(reply_msg)
|
||||
|
||||
elif msg.type == 'file':
|
||||
pass
|
||||
# file_name = msg.data['file']
|
||||
# file_id = msg.data['file_id']
|
||||
# file_data = await bot.get_file(file_id=file_id)
|
||||
# file_name = file_data.get('file_name')
|
||||
# file_path = file_data.get('file')
|
||||
# _ = file_path
|
||||
# file_url = file_data.get('file_url')
|
||||
# file_size = file_data.get('file_size')
|
||||
# yiri_msg_list.append(platform_message.File(id=file_id, name=file_name,url=file_url,size=file_size))
|
||||
elif msg.type == 'face':
|
||||
face_id = msg.data['id']
|
||||
face_name = msg.data['raw']['faceText']
|
||||
if not face_name:
|
||||
face_name = get_face_name(face_id)
|
||||
yiri_msg_list.append(platform_message.Face(face_id=int(face_id), face_name=face_name.replace('/', '')))
|
||||
elif msg.type == 'rps':
|
||||
face_id = msg.data['result']
|
||||
yiri_msg_list.append(platform_message.Face(face_type='rps', face_id=int(face_id), face_name='猜拳'))
|
||||
elif msg.type == 'dice':
|
||||
face_id = msg.data['result']
|
||||
yiri_msg_list.append(platform_message.Face(face_type='dice', face_id=int(face_id), face_name='骰子'))
|
||||
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
|
||||
|
||||
class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int):
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: aiocqhttp.Event, bot=None):
|
||||
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot)
|
||||
|
||||
if event.message_type == 'group':
|
||||
permission = 'MEMBER'
|
||||
|
||||
if 'role' in event.sender:
|
||||
if event.sender['role'] == 'admin':
|
||||
permission = 'ADMINISTRATOR'
|
||||
elif event.sender['role'] == 'owner':
|
||||
permission = 'OWNER'
|
||||
converted_event = platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=event.sender['user_id'], # message_seq 放哪?
|
||||
member_name=event.sender['nickname'],
|
||||
permission=permission,
|
||||
group=platform_entities.Group(
|
||||
id=event.group_id,
|
||||
name=event.sender['nickname'],
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title=event.sender['title'] if 'title' in event.sender else '',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=yiri_chain,
|
||||
time=event.time,
|
||||
source_platform_object=event,
|
||||
)
|
||||
return converted_event
|
||||
elif event.message_type == 'private':
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=event.sender['user_id'],
|
||||
nickname=event.sender['nickname'],
|
||||
remark='',
|
||||
),
|
||||
message_chain=yiri_chain,
|
||||
time=event.time,
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
|
||||
class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True, default_factory=aiocqhttp.CQHttp)
|
||||
|
||||
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
|
||||
event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter()
|
||||
|
||||
on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = []
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
async def shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
self.config['shutdown_trigger'] = shutdown_trigger_placeholder
|
||||
|
||||
self.on_websocket_connection_event_cache = []
|
||||
|
||||
if 'access-token' in config:
|
||||
self.bot = aiocqhttp.CQHttp(access_token=config['access-token'])
|
||||
del self.config['access-token']
|
||||
else:
|
||||
self.bot = aiocqhttp.CQHttp()
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
aiocq_msg = (await AiocqhttpMessageConverter.yiri2target(message))[0]
|
||||
|
||||
if target_type == 'group':
|
||||
await self.bot.send_group_msg(group_id=int(target_id), message=aiocq_msg)
|
||||
elif target_type == 'person':
|
||||
await self.bot.send_private_msg(user_id=int(target_id), message=aiocq_msg)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
aiocq_event = await AiocqhttpEventConverter.yiri2target(message_source, self.bot_account_id)
|
||||
aiocq_msg = (await AiocqhttpMessageConverter.yiri2target(message))[0]
|
||||
if quote_origin:
|
||||
aiocq_msg = aiocqhttp.MessageSegment.reply(aiocq_event.message_id) + aiocq_msg
|
||||
|
||||
return await self.bot.send(aiocq_event, aiocq_msg)
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
return False
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
async def on_message(event: aiocqhttp.Event):
|
||||
self.bot_account_id = event.self_id
|
||||
try:
|
||||
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in on_message: {traceback.format_exc()}')
|
||||
traceback.print_exc()
|
||||
|
||||
if event_type == platform_events.GroupMessage:
|
||||
self.bot.on_message('group')(on_message)
|
||||
# self.bot.on_notice()(on_message)
|
||||
elif event_type == platform_events.FriendMessage:
|
||||
self.bot.on_message('private')(on_message)
|
||||
# self.bot.on_notice()(on_message)
|
||||
# print(event_type)
|
||||
|
||||
async def on_websocket_connection(event: aiocqhttp.Event):
|
||||
for event in self.on_websocket_connection_event_cache:
|
||||
if event.self_id == event.self_id and event.time == event.time:
|
||||
return
|
||||
|
||||
self.on_websocket_connection_event_cache.append(event)
|
||||
await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}')
|
||||
|
||||
self.bot.on_websocket_connection(on_websocket_connection)
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
return super().unregister_listener(event_type, callback)
|
||||
|
||||
async def run_async(self):
|
||||
await self.bot._server_app.run_task(**self.config)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
# Current issue: existing connection will not be closed
|
||||
# self.should_shutdown = True
|
||||
return False
|
||||
47
src/langbot/pkg/platform/sources/aiocqhttp.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: aiocqhttp
|
||||
label:
|
||||
en_US: OneBot v11
|
||||
zh_Hans: OneBot v11
|
||||
description:
|
||||
en_US: OneBot v11 Adapter
|
||||
zh_Hans: OneBot v11 适配器,请查看文档了解使用方式
|
||||
icon: onebot.png
|
||||
spec:
|
||||
config:
|
||||
- name: host
|
||||
label:
|
||||
en_US: Host
|
||||
zh_Hans: 主机
|
||||
description:
|
||||
en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0
|
||||
zh_Hans: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
||||
type: string
|
||||
required: true
|
||||
default: 0.0.0.0
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 端口
|
||||
description:
|
||||
en_US: Port
|
||||
zh_Hans: 监听的端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2280
|
||||
- name: access-token
|
||||
label:
|
||||
en_US: Access Token
|
||||
zh_Hans: 访问令牌
|
||||
description:
|
||||
en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it
|
||||
zh_Hans: 自定义的与协议端的连接令牌,若协议端未设置,则不填
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./aiocqhttp.py
|
||||
attr: AiocqhttpAdapter
|
||||
238
src/langbot/pkg/platform/sources/dingtalk.py
Normal file
@@ -0,0 +1,238 @@
|
||||
import traceback
|
||||
import typing
|
||||
from libs.dingtalk_api.dingtalkevent import DingTalkEvent
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
from libs.dingtalk_api.api import DingTalkClient
|
||||
import datetime
|
||||
from ..logger import EventLogger
|
||||
|
||||
|
||||
class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain):
|
||||
content = ''
|
||||
at = False
|
||||
for msg in message_chain:
|
||||
if type(msg) is platform_message.At:
|
||||
at = True
|
||||
if type(msg) is platform_message.Plain:
|
||||
content += msg.text
|
||||
if type(msg) is platform_message.Forward:
|
||||
for node in msg.node_list:
|
||||
content += (await DingTalkMessageConverter.yiri2target(node.message_chain))[0]
|
||||
return content, at
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: DingTalkEvent, bot_name: str):
|
||||
yiri_msg_list = []
|
||||
yiri_msg_list.append(
|
||||
platform_message.Source(id=event.incoming_message.message_id, time=datetime.datetime.now())
|
||||
)
|
||||
|
||||
for atUser in event.incoming_message.at_users:
|
||||
if atUser.dingtalk_id == event.incoming_message.chatbot_user_id:
|
||||
yiri_msg_list.append(platform_message.At(target=bot_name))
|
||||
|
||||
if event.content:
|
||||
text_content = event.content.replace('@' + bot_name, '')
|
||||
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
||||
if event.picture:
|
||||
yiri_msg_list.append(platform_message.Image(base64=event.picture))
|
||||
if event.file:
|
||||
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
||||
if event.audio:
|
||||
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
|
||||
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
|
||||
|
||||
class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent):
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: DingTalkEvent, bot_name: str):
|
||||
message_chain = await DingTalkMessageConverter.target2yiri(event, bot_name)
|
||||
|
||||
if event.conversation == 'FriendMessage':
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=event.incoming_message.sender_staff_id,
|
||||
nickname=event.incoming_message.sender_nick,
|
||||
remark='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event.incoming_message.create_at,
|
||||
source_platform_object=event,
|
||||
)
|
||||
elif event.conversation == 'GroupMessage':
|
||||
sender = platform_entities.GroupMember(
|
||||
id=event.incoming_message.sender_staff_id,
|
||||
member_name=event.incoming_message.sender_nick,
|
||||
permission='MEMBER',
|
||||
group=platform_entities.Group(
|
||||
id=event.incoming_message.conversation_id,
|
||||
name=event.incoming_message.conversation_title,
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
)
|
||||
time = event.incoming_message.create_at
|
||||
return platform_events.GroupMessage(
|
||||
sender=sender,
|
||||
message_chain=message_chain,
|
||||
time=time,
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
|
||||
class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: DingTalkClient
|
||||
bot_account_id: str
|
||||
message_converter: DingTalkMessageConverter = DingTalkMessageConverter()
|
||||
event_converter: DingTalkEventConverter = DingTalkEventConverter()
|
||||
config: dict
|
||||
card_instance_id_dict: (
|
||||
dict # 回复卡片消息字典,key为消息id,value为回复卡片实例id,用于在流式消息时判断是否发送到指定卡片
|
||||
)
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
required_keys = [
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'robot_name',
|
||||
'robot_code',
|
||||
]
|
||||
missing_keys = [key for key in required_keys if key not in config]
|
||||
if missing_keys:
|
||||
raise Exception('钉钉缺少相关配置项,请查看文档或联系管理员')
|
||||
bot = DingTalkClient(
|
||||
client_id=config['client_id'],
|
||||
client_secret=config['client_secret'],
|
||||
robot_name=config['robot_name'],
|
||||
robot_code=config['robot_code'],
|
||||
markdown_card=config['markdown_card'],
|
||||
logger=logger,
|
||||
)
|
||||
bot_account_id = config['robot_name']
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
card_instance_id_dict={},
|
||||
bot_account_id=bot_account_id,
|
||||
bot=bot,
|
||||
listeners={},
|
||||
)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
event = await DingTalkEventConverter.yiri2target(
|
||||
message_source,
|
||||
)
|
||||
incoming_message = event.incoming_message
|
||||
|
||||
content, at = await DingTalkMessageConverter.yiri2target(message)
|
||||
await self.bot.send_message(content, incoming_message, at)
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
# event = await DingTalkEventConverter.yiri2target(
|
||||
# message_source,
|
||||
# )
|
||||
# incoming_message = event.incoming_message
|
||||
|
||||
# msg_id = incoming_message.message_id
|
||||
message_id = bot_message.resp_message_id
|
||||
msg_seq = bot_message.msg_sequence
|
||||
|
||||
if (msg_seq - 1) % 8 == 0 or is_final:
|
||||
content, at = await DingTalkMessageConverter.yiri2target(message)
|
||||
|
||||
card_instance, card_instance_id = self.card_instance_id_dict[message_id]
|
||||
if not content and bot_message.content:
|
||||
content = bot_message.content # 兼容直接传入content的情况
|
||||
# print(card_instance_id)
|
||||
if content:
|
||||
await self.bot.send_card_message(card_instance, card_instance_id, content, is_final)
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
# self.seq = 1 # 消息回复结束之后重置seq
|
||||
self.card_instance_id_dict.pop(message_id) # 消息回复结束之后删除卡片实例id
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
content = await DingTalkMessageConverter.yiri2target(message)
|
||||
if target_type == 'person':
|
||||
await self.bot.send_proactive_message_to_one(target_id, content)
|
||||
if target_type == 'group':
|
||||
await self.bot.send_proactive_message_to_group(target_id, content)
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
is_stream = False
|
||||
if self.config.get('enable-stream-reply', None):
|
||||
is_stream = True
|
||||
return is_stream
|
||||
|
||||
async def create_message_card(self, message_id, event):
|
||||
card_template_id = self.config['card_template_id']
|
||||
incoming_message = event.source_platform_object.incoming_message
|
||||
# message_id = incoming_message.message_id
|
||||
card_instance, card_instance_id = await self.bot.create_and_card(card_template_id, incoming_message)
|
||||
self.card_instance_id_dict[message_id] = (card_instance, card_instance_id)
|
||||
return True
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
async def on_message(event: DingTalkEvent):
|
||||
try:
|
||||
return await callback(
|
||||
await self.event_converter.target2yiri(event, self.config['robot_name']),
|
||||
self,
|
||||
)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in dingtalk callback: {traceback.format_exc()}')
|
||||
|
||||
if event_type == platform_events.FriendMessage:
|
||||
self.bot.on_message('FriendMessage')(on_message)
|
||||
elif event_type == platform_events.GroupMessage:
|
||||
self.bot.on_message('GroupMessage')(on_message)
|
||||
|
||||
async def run_async(self):
|
||||
await self.bot.start()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
return False
|
||||
|
||||
async def is_muted(self) -> bool:
|
||||
return False
|
||||
|
||||
async def unregister_listener(
|
||||
self,
|
||||
event_type: type,
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
return super().unregister_listener(event_type, callback)
|
||||
7
src/langbot/pkg/platform/sources/dingtalk.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg fill="#4aa4f8" width="800px" height="800px" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" class="icon" stroke="#4aa4f8">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
69
src/langbot/pkg/platform/sources/dingtalk.yaml
Normal file
@@ -0,0 +1,69 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: dingtalk
|
||||
label:
|
||||
en_US: DingTalk
|
||||
zh_Hans: 钉钉
|
||||
description:
|
||||
en_US: DingTalk Adapter
|
||||
zh_Hans: 钉钉适配器,请查看文档了解使用方式
|
||||
icon: dingtalk.svg
|
||||
spec:
|
||||
config:
|
||||
- name: client_id
|
||||
label:
|
||||
en_US: Client ID
|
||||
zh_Hans: 客户端ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: client_secret
|
||||
label:
|
||||
en_US: Client Secret
|
||||
zh_Hans: 客户端密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: robot_code
|
||||
label:
|
||||
en_US: Robot Code
|
||||
zh_Hans: 机器人代码
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: robot_name
|
||||
label:
|
||||
en_US: Robot Name
|
||||
zh_Hans: 机器人名称
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: markdown_card
|
||||
label:
|
||||
en_US: Markdown Card
|
||||
zh_Hans: 是否使用 Markdown 卡片
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
- name: enable-stream-reply
|
||||
label:
|
||||
en_US: Enable Stream Reply Mode
|
||||
zh_Hans: 启用钉钉卡片流式回复模式
|
||||
description:
|
||||
en_US: If enabled, the bot will use the stream of lark reply mode
|
||||
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
- name: card_template_id
|
||||
label:
|
||||
en_US: card template id
|
||||
zh_Hans: 卡片模板ID
|
||||
type: string
|
||||
required: true
|
||||
default: "填写你的卡片template_id"
|
||||
execution:
|
||||
python:
|
||||
path: ./dingtalk.py
|
||||
attr: DingTalkAdapter
|
||||
1098
src/langbot/pkg/platform/sources/discord.py
Normal file
7
src/langbot/pkg/platform/sources/discord.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg width="80px" height="80px" viewBox="0 -28.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid" fill="#000000">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
31
src/langbot/pkg/platform/sources/discord.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: discord
|
||||
label:
|
||||
en_US: Discord
|
||||
zh_Hans: Discord
|
||||
description:
|
||||
en_US: Discord Adapter
|
||||
zh_Hans: Discord 适配器,请查看文档了解使用方式
|
||||
icon: discord.svg
|
||||
spec:
|
||||
config:
|
||||
- name: client_id
|
||||
label:
|
||||
en_US: Client ID
|
||||
zh_Hans: 客户端ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./discord.py
|
||||
attr: DiscordAdapter
|
||||
810
src/langbot/pkg/platform/sources/lark.py
Normal file
@@ -0,0 +1,810 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import lark_oapi
|
||||
from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody
|
||||
import traceback
|
||||
import typing
|
||||
import asyncio
|
||||
import re
|
||||
import base64
|
||||
import uuid
|
||||
import json
|
||||
import datetime
|
||||
import hashlib
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
import aiohttp
|
||||
import lark_oapi.ws.exception
|
||||
import quart
|
||||
from lark_oapi.api.im.v1 import *
|
||||
import pydantic
|
||||
from lark_oapi.api.cardkit.v1 import *
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
|
||||
|
||||
class AESCipher(object):
|
||||
def __init__(self, key):
|
||||
self.bs = AES.block_size
|
||||
self.key = hashlib.sha256(AESCipher.str_to_bytes(key)).digest()
|
||||
|
||||
@staticmethod
|
||||
def str_to_bytes(data):
|
||||
u_type = type(b''.decode('utf8'))
|
||||
if isinstance(data, u_type):
|
||||
return data.encode('utf8')
|
||||
return data
|
||||
|
||||
@staticmethod
|
||||
def _unpad(s):
|
||||
return s[: -ord(s[len(s) - 1 :])]
|
||||
|
||||
def decrypt(self, enc):
|
||||
iv = enc[: AES.block_size]
|
||||
cipher = AES.new(self.key, AES.MODE_CBC, iv)
|
||||
return self._unpad(cipher.decrypt(enc[AES.block_size :]))
|
||||
|
||||
def decrypt_string(self, enc):
|
||||
enc = base64.b64decode(enc)
|
||||
return self.decrypt(enc).decode('utf8')
|
||||
|
||||
|
||||
class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(
|
||||
message_chain: platform_message.MessageChain, api_client: lark_oapi.Client
|
||||
) -> typing.Tuple[list]:
|
||||
message_elements = []
|
||||
pending_paragraph = []
|
||||
for msg in message_chain:
|
||||
if isinstance(msg, platform_message.Plain):
|
||||
# Ensure text is valid UTF-8
|
||||
try:
|
||||
text = msg.text.encode('utf-8').decode('utf-8')
|
||||
pending_paragraph.append({'tag': 'md', 'text': text})
|
||||
except UnicodeError:
|
||||
# If text is not valid UTF-8, try to decode with other encodings
|
||||
try:
|
||||
text = msg.text.encode('latin1').decode('utf-8')
|
||||
pending_paragraph.append({'tag': 'md', 'text': text})
|
||||
except UnicodeError:
|
||||
# If still fails, replace invalid characters
|
||||
text = msg.text.encode('utf-8', errors='replace').decode('utf-8')
|
||||
pending_paragraph.append({'tag': 'md', 'text': text})
|
||||
elif isinstance(msg, platform_message.At):
|
||||
pending_paragraph.append({'tag': 'at', 'user_id': msg.target, 'style': []})
|
||||
elif isinstance(msg, platform_message.AtAll):
|
||||
pending_paragraph.append({'tag': 'at', 'user_id': 'all', 'style': []})
|
||||
elif isinstance(msg, platform_message.Image):
|
||||
image_bytes = None
|
||||
|
||||
if msg.base64:
|
||||
try:
|
||||
# Remove data URL prefix if present
|
||||
if msg.base64.startswith('data:'):
|
||||
msg.base64 = msg.base64.split(',', 1)[1]
|
||||
image_bytes = base64.b64decode(msg.base64)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
elif msg.url:
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(msg.url) as response:
|
||||
if response.status == 200:
|
||||
image_bytes = await response.read()
|
||||
else:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
elif msg.path:
|
||||
try:
|
||||
with open(msg.path, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
|
||||
if image_bytes is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Create a temporary file to store the image bytes
|
||||
import tempfile
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False) as temp_file:
|
||||
temp_file.write(image_bytes)
|
||||
temp_file.flush()
|
||||
|
||||
# Create image request using the temporary file
|
||||
request = (
|
||||
CreateImageRequest.builder()
|
||||
.request_body(
|
||||
CreateImageRequestBody.builder()
|
||||
.image_type('message')
|
||||
.image(open(temp_file.name, 'rb'))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
response = await api_client.im.v1.image.acreate(request)
|
||||
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.im.v1.image.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
|
||||
image_key = response.data.image_key
|
||||
|
||||
message_elements.append(pending_paragraph)
|
||||
message_elements.append(
|
||||
[
|
||||
{
|
||||
'tag': 'img',
|
||||
'image_key': image_key,
|
||||
}
|
||||
]
|
||||
)
|
||||
pending_paragraph = []
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
finally:
|
||||
# Clean up the temporary file
|
||||
import os
|
||||
|
||||
if 'temp_file' in locals():
|
||||
os.unlink(temp_file.name)
|
||||
elif isinstance(msg, platform_message.Forward):
|
||||
for node in msg.node_list:
|
||||
message_elements.extend(await LarkMessageConverter.yiri2target(node.message_chain, api_client))
|
||||
|
||||
if pending_paragraph:
|
||||
message_elements.append(pending_paragraph)
|
||||
|
||||
return message_elements
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(
|
||||
message: lark_oapi.api.im.v1.model.event_message.EventMessage,
|
||||
api_client: lark_oapi.Client,
|
||||
) -> platform_message.MessageChain:
|
||||
message_content = json.loads(message.content)
|
||||
|
||||
lb_msg_list = []
|
||||
|
||||
msg_create_time = datetime.datetime.fromtimestamp(int(message.create_time) / 1000)
|
||||
|
||||
lb_msg_list.append(platform_message.Source(id=message.message_id, time=msg_create_time))
|
||||
|
||||
if message.message_type == 'text':
|
||||
element_list = []
|
||||
|
||||
def text_element_recur(text_ele: dict) -> list[dict]:
|
||||
if text_ele['text'] == '':
|
||||
return []
|
||||
|
||||
at_pattern = re.compile(r'@_user_[\d]+')
|
||||
at_matches = at_pattern.findall(text_ele['text'])
|
||||
|
||||
name_mapping = {}
|
||||
for mathc in at_matches:
|
||||
for mention in message.mentions:
|
||||
if mention.key == mathc:
|
||||
name_mapping[mathc] = mention.name
|
||||
break
|
||||
|
||||
if len(name_mapping.keys()) == 0:
|
||||
return [text_ele]
|
||||
|
||||
# 只处理第一个,剩下的递归处理
|
||||
text_split = text_ele['text'].split(list(name_mapping.keys())[0])
|
||||
|
||||
new_list = []
|
||||
|
||||
left_text = text_split[0]
|
||||
right_text = text_split[1]
|
||||
|
||||
new_list.extend(text_element_recur({'tag': 'text', 'text': left_text, 'style': []}))
|
||||
|
||||
new_list.append(
|
||||
{
|
||||
'tag': 'at',
|
||||
'user_id': list(name_mapping.keys())[0],
|
||||
'user_name': name_mapping[list(name_mapping.keys())[0]],
|
||||
'style': [],
|
||||
}
|
||||
)
|
||||
|
||||
new_list.extend(text_element_recur({'tag': 'text', 'text': right_text, 'style': []}))
|
||||
|
||||
return new_list
|
||||
|
||||
element_list = text_element_recur({'tag': 'text', 'text': message_content['text'], 'style': []})
|
||||
|
||||
message_content = {'title': '', 'content': element_list}
|
||||
|
||||
elif message.message_type == 'post':
|
||||
new_list = []
|
||||
|
||||
for ele in message_content['content']:
|
||||
if type(ele) is dict:
|
||||
new_list.append(ele)
|
||||
elif type(ele) is list:
|
||||
new_list.extend(ele)
|
||||
|
||||
message_content['content'] = new_list
|
||||
elif message.message_type == 'image':
|
||||
message_content['content'] = [{'tag': 'img', 'image_key': message_content['image_key'], 'style': []}]
|
||||
|
||||
for ele in message_content['content']:
|
||||
if ele['tag'] == 'text':
|
||||
lb_msg_list.append(platform_message.Plain(text=ele['text']))
|
||||
elif ele['tag'] == 'at':
|
||||
lb_msg_list.append(platform_message.At(target=ele['user_name']))
|
||||
elif ele['tag'] == 'img':
|
||||
image_key = ele['image_key']
|
||||
|
||||
request: GetMessageResourceRequest = (
|
||||
GetMessageResourceRequest.builder()
|
||||
.message_id(message.message_id)
|
||||
.file_key(image_key)
|
||||
.type('image')
|
||||
.build()
|
||||
)
|
||||
|
||||
response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)
|
||||
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
|
||||
image_bytes = response.file.read()
|
||||
image_base64 = base64.b64encode(image_bytes).decode()
|
||||
|
||||
image_format = response.raw.headers['content-type']
|
||||
|
||||
lb_msg_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
|
||||
|
||||
return platform_message.MessageChain(lb_msg_list)
|
||||
|
||||
|
||||
class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(
|
||||
event: platform_events.MessageEvent,
|
||||
) -> lark_oapi.im.v1.P2ImMessageReceiveV1:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(
|
||||
event: lark_oapi.im.v1.P2ImMessageReceiveV1, api_client: lark_oapi.Client
|
||||
) -> platform_events.Event:
|
||||
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
|
||||
|
||||
if event.event.message.chat_type == 'p2p':
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=event.event.sender.sender_id.open_id,
|
||||
nickname=event.event.sender.sender_id.union_id,
|
||||
remark='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event.event.message.create_time,
|
||||
)
|
||||
elif event.event.message.chat_type == 'group':
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=event.event.sender.sender_id.open_id,
|
||||
member_name=event.event.sender.sender_id.union_id,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=event.event.message.chat_id,
|
||||
name='',
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event.event.message.create_time,
|
||||
)
|
||||
|
||||
|
||||
CARD_ID_CACHE_SIZE = 500
|
||||
CARD_ID_CACHE_MAX_LIFETIME = 20 * 60 # 20分钟
|
||||
|
||||
|
||||
class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: lark_oapi.ws.Client = pydantic.Field(exclude=True)
|
||||
api_client: lark_oapi.Client = pydantic.Field(exclude=True)
|
||||
|
||||
bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识
|
||||
lark_tenant_key: str = pydantic.Field(exclude=True, default='') # 飞书企业key
|
||||
|
||||
message_converter: LarkMessageConverter = LarkMessageConverter()
|
||||
event_converter: LarkEventConverter = LarkEventConverter()
|
||||
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
]
|
||||
|
||||
quart_app: quart.Quart = pydantic.Field(exclude=True)
|
||||
|
||||
card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片
|
||||
|
||||
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
quart_app = quart.Quart(__name__)
|
||||
|
||||
@quart_app.route('/lark/callback', methods=['POST'])
|
||||
async def lark_callback():
|
||||
try:
|
||||
data = await quart.request.json
|
||||
|
||||
if 'encrypt' in data:
|
||||
cipher = AESCipher(config['encrypt-key'])
|
||||
data = cipher.decrypt_string(data['encrypt'])
|
||||
data = json.loads(data)
|
||||
|
||||
type = data.get('type')
|
||||
if type is None:
|
||||
context = EventContext(data)
|
||||
type = context.header.event_type
|
||||
|
||||
if 'url_verification' == type:
|
||||
# todo 验证verification token
|
||||
return {'challenge': data.get('challenge')}
|
||||
context = EventContext(data)
|
||||
type = context.header.event_type
|
||||
p2v1 = P2ImMessageReceiveV1()
|
||||
p2v1.header = context.header
|
||||
event = P2ImMessageReceiveV1Data()
|
||||
event.message = EventMessage(context.event['message'])
|
||||
event.sender = EventSender(context.event['sender'])
|
||||
p2v1.event = event
|
||||
p2v1.schema = context.schema
|
||||
if 'im.message.receive_v1' == type:
|
||||
try:
|
||||
event = await self.event_converter.target2yiri(p2v1, self.api_client)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
|
||||
|
||||
if event.__class__ in self.listeners:
|
||||
await self.listeners[event.__class__](event, self)
|
||||
|
||||
return {'code': 200, 'message': 'ok'}
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
|
||||
return {'code': 500, 'message': 'error'}
|
||||
|
||||
async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||
lb_event = await self.event_converter.target2yiri(event, self.api_client)
|
||||
|
||||
await self.listeners[type(lb_event)](lb_event, self)
|
||||
|
||||
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||
asyncio.create_task(on_message(event))
|
||||
|
||||
event_handler = (
|
||||
lark_oapi.EventDispatcherHandler.builder('', '').register_p2_im_message_receive_v1(sync_on_message).build()
|
||||
)
|
||||
|
||||
bot_account_id = config['bot_name']
|
||||
|
||||
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler)
|
||||
api_client = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret']).build()
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
lark_tenant_key=config.get('lark_tenant_key', ''),
|
||||
card_id_dict={},
|
||||
seq=1,
|
||||
listeners={},
|
||||
quart_app=quart_app,
|
||||
bot=bot,
|
||||
api_client=api_client,
|
||||
bot_account_id=bot_account_id,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
pass
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
is_stream = False
|
||||
if self.config.get('enable-stream-reply', None):
|
||||
is_stream = True
|
||||
return is_stream
|
||||
|
||||
async def create_card_id(self, message_id):
|
||||
try:
|
||||
# self.logger.debug('飞书支持stream输出,创建卡片......')
|
||||
|
||||
card_data = {
|
||||
'schema': '2.0',
|
||||
'config': {
|
||||
'update_multi': True,
|
||||
'streaming_mode': True,
|
||||
'streaming_config': {
|
||||
'print_step': {'default': 1},
|
||||
'print_frequency_ms': {'default': 70},
|
||||
'print_strategy': 'fast',
|
||||
},
|
||||
},
|
||||
'body': {
|
||||
'direction': 'vertical',
|
||||
'padding': '12px 12px 12px 12px',
|
||||
'elements': [
|
||||
{
|
||||
'tag': 'div',
|
||||
'text': {
|
||||
'tag': 'plain_text',
|
||||
'content': 'LangBot',
|
||||
'text_size': 'normal',
|
||||
'text_align': 'left',
|
||||
'text_color': 'default',
|
||||
},
|
||||
'icon': {
|
||||
'tag': 'custom_icon',
|
||||
'img_key': 'img_v3_02p3_05c65d5d-9bad-440a-a2fb-c89571bfd5bg',
|
||||
},
|
||||
},
|
||||
{
|
||||
'tag': 'markdown',
|
||||
'content': '',
|
||||
'text_align': 'left',
|
||||
'text_size': 'normal',
|
||||
'margin': '0px 0px 0px 0px',
|
||||
'element_id': 'streaming_txt',
|
||||
},
|
||||
{
|
||||
'tag': 'markdown',
|
||||
'content': '',
|
||||
'text_align': 'left',
|
||||
'text_size': 'normal',
|
||||
'margin': '0px 0px 0px 0px',
|
||||
},
|
||||
{
|
||||
'tag': 'column_set',
|
||||
'horizontal_spacing': '8px',
|
||||
'horizontal_align': 'left',
|
||||
'columns': [
|
||||
{
|
||||
'tag': 'column',
|
||||
'width': 'weighted',
|
||||
'elements': [
|
||||
{
|
||||
'tag': 'markdown',
|
||||
'content': '',
|
||||
'text_align': 'left',
|
||||
'text_size': 'normal',
|
||||
'margin': '0px 0px 0px 0px',
|
||||
},
|
||||
{
|
||||
'tag': 'markdown',
|
||||
'content': '',
|
||||
'text_align': 'left',
|
||||
'text_size': 'normal',
|
||||
'margin': '0px 0px 0px 0px',
|
||||
},
|
||||
{
|
||||
'tag': 'markdown',
|
||||
'content': '',
|
||||
'text_align': 'left',
|
||||
'text_size': 'normal',
|
||||
'margin': '0px 0px 0px 0px',
|
||||
},
|
||||
],
|
||||
'padding': '0px 0px 0px 0px',
|
||||
'direction': 'vertical',
|
||||
'horizontal_spacing': '8px',
|
||||
'vertical_spacing': '2px',
|
||||
'horizontal_align': 'left',
|
||||
'vertical_align': 'top',
|
||||
'margin': '0px 0px 0px 0px',
|
||||
'weight': 1,
|
||||
}
|
||||
],
|
||||
'margin': '0px 0px 0px 0px',
|
||||
},
|
||||
{'tag': 'hr', 'margin': '0px 0px 0px 0px'},
|
||||
{
|
||||
'tag': 'column_set',
|
||||
'horizontal_spacing': '12px',
|
||||
'horizontal_align': 'right',
|
||||
'columns': [
|
||||
{
|
||||
'tag': 'column',
|
||||
'width': 'weighted',
|
||||
'elements': [
|
||||
{
|
||||
'tag': 'markdown',
|
||||
'content': '<font color="grey-600">以上内容由 AI 生成,仅供参考。更多详细、准确信息可点击引用链接查看</font>',
|
||||
'text_align': 'left',
|
||||
'text_size': 'notation',
|
||||
'margin': '4px 0px 0px 0px',
|
||||
'icon': {
|
||||
'tag': 'standard_icon',
|
||||
'token': 'robot_outlined',
|
||||
'color': 'grey',
|
||||
},
|
||||
}
|
||||
],
|
||||
'padding': '0px 0px 0px 0px',
|
||||
'direction': 'vertical',
|
||||
'horizontal_spacing': '8px',
|
||||
'vertical_spacing': '8px',
|
||||
'horizontal_align': 'left',
|
||||
'vertical_align': 'top',
|
||||
'margin': '0px 0px 0px 0px',
|
||||
'weight': 1,
|
||||
},
|
||||
{
|
||||
'tag': 'column',
|
||||
'width': '20px',
|
||||
'elements': [
|
||||
{
|
||||
'tag': 'button',
|
||||
'text': {'tag': 'plain_text', 'content': ''},
|
||||
'type': 'text',
|
||||
'width': 'fill',
|
||||
'size': 'medium',
|
||||
'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'},
|
||||
'hover_tips': {'tag': 'plain_text', 'content': '有帮助'},
|
||||
'margin': '0px 0px 0px 0px',
|
||||
}
|
||||
],
|
||||
'padding': '0px 0px 0px 0px',
|
||||
'direction': 'vertical',
|
||||
'horizontal_spacing': '8px',
|
||||
'vertical_spacing': '8px',
|
||||
'horizontal_align': 'left',
|
||||
'vertical_align': 'top',
|
||||
'margin': '0px 0px 0px 0px',
|
||||
},
|
||||
{
|
||||
'tag': 'column',
|
||||
'width': '30px',
|
||||
'elements': [
|
||||
{
|
||||
'tag': 'button',
|
||||
'text': {'tag': 'plain_text', 'content': ''},
|
||||
'type': 'text',
|
||||
'width': 'default',
|
||||
'size': 'medium',
|
||||
'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'},
|
||||
'hover_tips': {'tag': 'plain_text', 'content': '无帮助'},
|
||||
'margin': '0px 0px 0px 0px',
|
||||
}
|
||||
],
|
||||
'padding': '0px 0px 0px 0px',
|
||||
'vertical_spacing': '8px',
|
||||
'horizontal_align': 'left',
|
||||
'vertical_align': 'top',
|
||||
'margin': '0px 0px 0px 0px',
|
||||
},
|
||||
],
|
||||
'margin': '0px 0px 4px 0px',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
# delay / fast 创建卡片模板,delay 延迟打印,fast 实时打印,可以自定义更好看的消息模板
|
||||
|
||||
request: CreateCardRequest = (
|
||||
CreateCardRequest.builder()
|
||||
.request_body(CreateCardRequestBody.builder().type('card_json').data(json.dumps(card_data)).build())
|
||||
.build()
|
||||
)
|
||||
|
||||
# 发起请求
|
||||
response: CreateCardResponse = self.api_client.cardkit.v1.card.create(request)
|
||||
|
||||
# 处理失败返回
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.cardkit.v1.card.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
|
||||
self.card_id_dict[message_id] = response.data.card_id
|
||||
|
||||
card_id = response.data.card_id
|
||||
return card_id
|
||||
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
async def create_message_card(self, message_id, event) -> str:
|
||||
"""
|
||||
创建卡片消息。
|
||||
使用卡片消息是因为普通消息更新次数有限制,而大模型流式返回结果可能很多而超过限制,而飞书卡片没有这个限制(api免费次数有限)
|
||||
"""
|
||||
# message_id = event.message_chain.message_id
|
||||
|
||||
card_id = await self.create_card_id(message_id)
|
||||
content = {
|
||||
'type': 'card',
|
||||
'data': {'card_id': card_id, 'template_variable': {'content': 'Thinking...'}},
|
||||
} # 当收到消息时发送消息模板,可添加模板变量,详情查看飞书中接口文档
|
||||
request: ReplyMessageRequest = (
|
||||
ReplyMessageRequest.builder()
|
||||
.message_id(event.message_chain.message_id)
|
||||
.request_body(
|
||||
ReplyMessageRequestBody.builder().content(json.dumps(content)).msg_type('interactive').build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
# 发起请求
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
|
||||
|
||||
# 处理失败返回
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
return True
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
# 不再需要了,因为message_id已经被包含到message_chain中
|
||||
# lark_event = await self.event_converter.yiri2target(message_source)
|
||||
lark_message = await self.message_converter.yiri2target(message, self.api_client)
|
||||
|
||||
final_content = {
|
||||
'zh_Hans': {
|
||||
'title': '',
|
||||
'content': lark_message,
|
||||
},
|
||||
}
|
||||
|
||||
request: ReplyMessageRequest = (
|
||||
ReplyMessageRequest.builder()
|
||||
.message_id(message_source.message_chain.message_id)
|
||||
.request_body(
|
||||
ReplyMessageRequestBody.builder()
|
||||
.content(json.dumps(final_content))
|
||||
.msg_type('post')
|
||||
.reply_in_thread(False)
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
|
||||
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.im.v1.message.reply failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
"""
|
||||
回复消息变成更新卡片消息
|
||||
"""
|
||||
# self.seq += 1
|
||||
message_id = bot_message.resp_message_id
|
||||
msg_seq = bot_message.msg_sequence
|
||||
if msg_seq % 8 == 0 or is_final:
|
||||
lark_message = await self.message_converter.yiri2target(message, self.api_client)
|
||||
|
||||
text_message = ''
|
||||
for ele in lark_message[0]:
|
||||
if ele['tag'] == 'text':
|
||||
text_message += ele['text']
|
||||
elif ele['tag'] == 'md':
|
||||
text_message += ele['text']
|
||||
|
||||
# content = {
|
||||
# 'type': 'card_json',
|
||||
# 'data': {'card_id': self.card_id_dict[message_id], 'elements': {'content': text_message}},
|
||||
# }
|
||||
|
||||
request: ContentCardElementRequest = (
|
||||
ContentCardElementRequest.builder()
|
||||
.card_id(self.card_id_dict[message_id])
|
||||
.element_id('streaming_txt')
|
||||
.request_body(
|
||||
ContentCardElementRequestBody.builder()
|
||||
# .uuid("a0d69e20-1dd1-458b-k525-dfeca4015204")
|
||||
.content(text_message)
|
||||
.sequence(msg_seq)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
# self.seq = 1 # 消息回复结束之后重置seq
|
||||
self.card_id_dict.pop(message_id) # 清理已经使用过的卡片
|
||||
# 发起请求
|
||||
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request)
|
||||
|
||||
# 处理失败返回
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.im.v1.message.patch failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
return
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
return False
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners.pop(event_type)
|
||||
|
||||
async def run_async(self):
|
||||
port = self.config['port']
|
||||
enable_webhook = self.config['enable-webhook']
|
||||
|
||||
if not enable_webhook:
|
||||
try:
|
||||
await self.bot._connect()
|
||||
except lark_oapi.ws.exception.ClientException as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
await self.bot._disconnect()
|
||||
if self.bot._auto_reconnect:
|
||||
await self.bot._reconnect()
|
||||
else:
|
||||
raise e
|
||||
else:
|
||||
|
||||
async def shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self.quart_app.run_task(
|
||||
host='0.0.0.0',
|
||||
port=port,
|
||||
shutdown_trigger=shutdown_trigger_placeholder,
|
||||
)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
# 需要断开连接,不然旧的连接会继续运行,导致飞书消息来时会随机选择一个连接
|
||||
# 断开时lark.ws.Client的_receive_message_loop会打印error日志: receive message loop exit。然后进行重连,
|
||||
# 所以要设置_auto_reconnect=False,让其不重连。
|
||||
self.bot._auto_reconnect = False
|
||||
await self.bot._disconnect()
|
||||
return False
|
||||
1
src/langbot/pkg/platform/sources/lark.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1711946937387" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5208" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M262.339048 243.809524h326.070857s91.672381 84.504381 91.672381 200.655238l-152.81981 105.569524S445.781333 359.960381 262.339048 243.809524z" fill="#00DAB8" p-id="5209"></path><path d="M853.333333 423.350857s-112.103619-42.276571-183.393523-10.581333c-71.338667 31.695238-101.912381 73.923048-132.486096 105.618286-40.71619 42.22781-112.054857 116.150857-173.202285 73.923047-61.147429-42.276571 244.540952 147.846095 244.540952 147.846095s127.463619-71.631238 173.202286-190.122666C822.759619 444.464762 853.333333 423.350857 853.333333 423.350857z" fill="#0C3AA0" p-id="5210"></path><path d="M170.666667 402.236952v316.757334s112.298667 138.142476 376.978285 63.390476c112.103619-31.695238 203.824762-179.541333 203.824762-179.541333S618.934857 824.612571 170.666667 402.285714z" fill="#296DFF" p-id="5211"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
81
src/langbot/pkg/platform/sources/lark.yaml
Normal file
@@ -0,0 +1,81 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: lark
|
||||
label:
|
||||
en_US: Lark
|
||||
zh_Hans: 飞书
|
||||
description:
|
||||
en_US: Lark Adapter
|
||||
zh_Hans: 飞书适配器,请查看文档了解使用方式
|
||||
icon: lark.svg
|
||||
spec:
|
||||
config:
|
||||
- name: app_id
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: app_secret
|
||||
label:
|
||||
en_US: App Secret
|
||||
zh_Hans: 应用密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: bot_name
|
||||
label:
|
||||
en_US: Bot Name
|
||||
zh_Hans: 机器人名称
|
||||
description:
|
||||
en_US: Must be the same as the name of the bot in Lark, otherwise the bot will not be able to receive messages in the group
|
||||
zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: enable-webhook
|
||||
label:
|
||||
en_US: Enable Webhook Mode
|
||||
zh_Hans: 启用Webhook模式
|
||||
description:
|
||||
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
|
||||
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
- name: port
|
||||
label:
|
||||
en_US: Webhook Port
|
||||
zh_Hans: Webhook端口
|
||||
description:
|
||||
en_US: Only valid when webhook mode is enabled, please fill in the webhook port
|
||||
zh_Hans: 仅在启用 Webhook 模式时有效,请填写 Webhook 端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2285
|
||||
- name: encrypt-key
|
||||
label:
|
||||
en_US: Encrypt Key
|
||||
zh_Hans: 加密密钥
|
||||
description:
|
||||
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
||||
zh_Hans: 仅在启用 Webhook 模式时有效,请填写加密密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: enable-stream-reply
|
||||
label:
|
||||
en_US: Enable Stream Reply Mode
|
||||
zh_Hans: 启用飞书流式回复模式
|
||||
description:
|
||||
en_US: If enabled, the bot will use the stream of lark reply mode
|
||||
zh_Hans: 如果启用,将使用飞书流式方式来回复内容
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
execution:
|
||||
python:
|
||||
path: ./lark.py
|
||||
attr: LarkAdapter
|
||||
BIN
src/langbot/pkg/platform/sources/legacy/gewechat.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
691
src/langbot/pkg/platform/sources/legacy/gewechat.py
Normal file
@@ -0,0 +1,691 @@
|
||||
import gewechat_client
|
||||
|
||||
import typing
|
||||
import asyncio
|
||||
import traceback
|
||||
import time
|
||||
import re
|
||||
import copy
|
||||
import threading
|
||||
|
||||
import quart
|
||||
import aiohttp
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from ....core import app
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
from ....utils import image
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional, Tuple
|
||||
from functools import partial
|
||||
from ...logger import EventLogger
|
||||
|
||||
|
||||
class GewechatMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:
|
||||
content_list = []
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.At):
|
||||
content_list.append({'type': 'at', 'target': component.target})
|
||||
elif isinstance(component, platform_message.Plain):
|
||||
content_list.append({'type': 'text', 'content': component.text})
|
||||
elif isinstance(component, platform_message.Image):
|
||||
if not component.url:
|
||||
pass
|
||||
content_list.append({'type': 'image', 'image': component.url})
|
||||
|
||||
elif isinstance(component, platform_message.Voice):
|
||||
content_list.append({'type': 'voice', 'url': component.url, 'length': component.length})
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain))
|
||||
content_list.append({'type': 'image', 'image': component.url})
|
||||
elif isinstance(component, platform_message.WeChatMiniPrograms):
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'WeChatMiniPrograms',
|
||||
'mini_app_id': component.mini_app_id,
|
||||
'display_name': component.display_name,
|
||||
'page_path': component.page_path,
|
||||
'cover_img_url': component.image_url,
|
||||
'title': component.title,
|
||||
'user_name': component.user_name,
|
||||
}
|
||||
)
|
||||
elif isinstance(component, platform_message.WeChatForwardMiniPrograms):
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'WeChatForwardMiniPrograms',
|
||||
'xml_data': component.xml_data,
|
||||
'image_url': component.image_url,
|
||||
}
|
||||
)
|
||||
elif isinstance(component, platform_message.WeChatEmoji):
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'WeChatEmoji',
|
||||
'emoji_md5': component.emoji_md5,
|
||||
'emoji_size': component.emoji_size,
|
||||
}
|
||||
)
|
||||
elif isinstance(component, platform_message.WeChatLink):
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'WeChatLink',
|
||||
'link_title': component.link_title,
|
||||
'link_desc': component.link_desc,
|
||||
'link_thumb_url': component.link_thumb_url,
|
||||
'link_url': component.link_url,
|
||||
}
|
||||
)
|
||||
elif isinstance(component, platform_message.WeChatForwardLink):
|
||||
content_list.append({'type': 'WeChatForwardLink', 'xml_data': component.xml_data})
|
||||
elif isinstance(component, platform_message.Voice):
|
||||
content_list.append({'type': 'voice', 'url': component.url, 'length': component.length})
|
||||
elif isinstance(component, platform_message.WeChatForwardImage):
|
||||
content_list.append({'type': 'WeChatForwardImage', 'xml_data': component.xml_data})
|
||||
elif isinstance(component, platform_message.WeChatForwardFile):
|
||||
content_list.append({'type': 'WeChatForwardFile', 'xml_data': component.xml_data})
|
||||
elif isinstance(component, platform_message.WeChatAppMsg):
|
||||
content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg})
|
||||
# 引用消息转发
|
||||
elif isinstance(component, platform_message.WeChatForwardQuote):
|
||||
content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg})
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
if node.message_chain:
|
||||
content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain))
|
||||
|
||||
return content_list
|
||||
|
||||
async def target2yiri(self, message: dict, bot_account_id: str) -> platform_message.MessageChain:
|
||||
"""外部消息转平台消息"""
|
||||
# 数据预处理
|
||||
message_list = []
|
||||
ats_bot = False # 是否被@
|
||||
content = message['Data']['Content']['string']
|
||||
content_no_preifx = content # 群消息则去掉前缀
|
||||
is_group_message = self._is_group_message(message)
|
||||
if is_group_message:
|
||||
ats_bot = self._ats_bot(message, bot_account_id)
|
||||
if '@所有人' in content:
|
||||
message_list.append(platform_message.AtAll())
|
||||
elif ats_bot:
|
||||
message_list.append(platform_message.At(target=bot_account_id))
|
||||
content_no_preifx, _ = self._extract_content_and_sender(content)
|
||||
|
||||
msg_type = message['Data']['MsgType']
|
||||
|
||||
# 映射消息类型到处理器方法
|
||||
handler_map = {
|
||||
1: self._handler_text,
|
||||
3: self._handler_image,
|
||||
34: self._handler_voice,
|
||||
49: self._handler_compound, # 复合类型
|
||||
}
|
||||
|
||||
# 分派处理
|
||||
handler = handler_map.get(msg_type, self._handler_default)
|
||||
handler_result = await handler(
|
||||
message=message, # 原始的message
|
||||
content_no_preifx=content_no_preifx, # 处理后的content
|
||||
)
|
||||
|
||||
if handler_result and len(handler_result) > 0:
|
||||
message_list.extend(handler_result)
|
||||
|
||||
return platform_message.MessageChain(message_list)
|
||||
|
||||
async def _handler_text(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
|
||||
"""处理文本消息 (msg_type=1)"""
|
||||
if message and self._is_group_message(message):
|
||||
pattern = r'@\S{1,20}'
|
||||
content_no_preifx = re.sub(pattern, '', content_no_preifx)
|
||||
|
||||
return platform_message.MessageChain([platform_message.Plain(content_no_preifx)])
|
||||
|
||||
async def _handler_image(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
|
||||
"""处理图像消息 (msg_type=3)"""
|
||||
try:
|
||||
image_xml = content_no_preifx
|
||||
if not image_xml:
|
||||
return platform_message.MessageChain([platform_message.Unknown('[图片内容为空]')])
|
||||
|
||||
base64_str, image_format = await image.get_gewechat_image_base64(
|
||||
gewechat_url=self.config['gewechat_url'],
|
||||
gewechat_file_url=self.config['gewechat_file_url'],
|
||||
app_id=self.config['app_id'],
|
||||
xml_content=image_xml,
|
||||
token=self.config['token'],
|
||||
image_type=2,
|
||||
)
|
||||
|
||||
elements = [
|
||||
platform_message.Image(base64=f'data:image/{image_format};base64,{base64_str}'),
|
||||
platform_message.WeChatForwardImage(xml_data=image_xml), # 微信消息转发
|
||||
]
|
||||
return platform_message.MessageChain(elements)
|
||||
except Exception as e:
|
||||
print(f'处理图片失败: {str(e)}')
|
||||
return platform_message.MessageChain([platform_message.Unknown('[图片处理失败]')])
|
||||
|
||||
async def _handler_voice(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
|
||||
"""处理语音消息 (msg_type=34)"""
|
||||
message_List = []
|
||||
try:
|
||||
# 从消息中提取语音数据(需根据实际数据结构调整字段名)
|
||||
audio_base64 = message['Data']['ImgBuf']['buffer']
|
||||
|
||||
# 验证语音数据有效性
|
||||
if not audio_base64:
|
||||
message_List.append(platform_message.Unknown(text='[语音内容为空]'))
|
||||
return platform_message.MessageChain(message_List)
|
||||
|
||||
# 转换为平台支持的语音格式(如 Silk 格式)
|
||||
voice_element = platform_message.Voice(base64=f'data:audio/silk;base64,{audio_base64}')
|
||||
message_List.append(voice_element)
|
||||
|
||||
except KeyError as e:
|
||||
print(f'语音数据字段缺失: {str(e)}')
|
||||
message_List.append(platform_message.Unknown(text='[语音数据解析失败]'))
|
||||
except Exception as e:
|
||||
print(f'处理语音消息异常: {str(e)}')
|
||||
message_List.append(platform_message.Unknown(text='[语音处理失败]'))
|
||||
|
||||
return platform_message.MessageChain(message_List)
|
||||
|
||||
async def _handler_compound(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
|
||||
"""处理复合消息 (msg_type=49),根据子类型分派"""
|
||||
try:
|
||||
xml_data = ET.fromstring(content_no_preifx)
|
||||
appmsg_data = xml_data.find('.//appmsg')
|
||||
if appmsg_data:
|
||||
data_type = appmsg_data.findtext('.//type', '')
|
||||
|
||||
# 二次分派处理器
|
||||
sub_handler_map = {
|
||||
'57': self._handler_compound_quote,
|
||||
'5': self._handler_compound_link,
|
||||
'6': self._handler_compound_file,
|
||||
'33': self._handler_compound_mini_program,
|
||||
'36': self._handler_compound_mini_program,
|
||||
'2000': partial(self._handler_compound_unsupported, text='[转账消息]'),
|
||||
'2001': partial(self._handler_compound_unsupported, text='[红包消息]'),
|
||||
'51': partial(self._handler_compound_unsupported, text='[视频号消息]'),
|
||||
}
|
||||
|
||||
handler = sub_handler_map.get(data_type, self._handler_compound_unsupported)
|
||||
return await handler(
|
||||
message=message, # 原始msg
|
||||
xml_data=xml_data, # xml数据
|
||||
)
|
||||
else:
|
||||
return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)])
|
||||
except Exception as e:
|
||||
print(f'解析复合消息失败: {str(e)}')
|
||||
return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)])
|
||||
|
||||
async def _handler_compound_quote(
|
||||
self, message: Optional[dict], xml_data: ET.Element
|
||||
) -> platform_message.MessageChain:
|
||||
"""处理引用消息 (data_type=57)"""
|
||||
message_list = []
|
||||
# print("_handler_compound_quote", ET.tostring(xml_data, encoding='unicode'))
|
||||
appmsg_data = xml_data.find('.//appmsg')
|
||||
quote_data = '' # 引用原文
|
||||
user_data = '' # 用户消息
|
||||
sender_id = xml_data.findtext('.//fromusername') # 发送方:单聊用户/群member
|
||||
if appmsg_data:
|
||||
user_data = appmsg_data.findtext('.//title') or ''
|
||||
quote_data = appmsg_data.find('.//refermsg').findtext('.//content')
|
||||
message_list.append(
|
||||
platform_message.WeChatForwardQuote(app_msg=ET.tostring(appmsg_data, encoding='unicode'))
|
||||
)
|
||||
# quote_data原始的消息
|
||||
if quote_data:
|
||||
quote_data_message_list = platform_message.MessageChain()
|
||||
# 文本消息
|
||||
try:
|
||||
if '<msg>' not in quote_data:
|
||||
quote_data_message_list.append(platform_message.Plain(quote_data))
|
||||
else:
|
||||
# 引用消息展开
|
||||
quote_data_xml = ET.fromstring(quote_data)
|
||||
if quote_data_xml.find('img'):
|
||||
quote_data_message_list.extend(await self._handler_image(None, quote_data))
|
||||
elif quote_data_xml.find('voicemsg'):
|
||||
quote_data_message_list.extend(await self._handler_voice(None, quote_data))
|
||||
elif quote_data_xml.find('videomsg'):
|
||||
quote_data_message_list.extend(await self._handler_default(None, quote_data)) # 先不处理
|
||||
else:
|
||||
# appmsg
|
||||
quote_data_message_list.extend(await self._handler_compound(None, quote_data))
|
||||
except Exception as e:
|
||||
print(f'处理引用消息异常 expcetion:{e}')
|
||||
quote_data_message_list.append(platform_message.Plain(quote_data))
|
||||
message_list.append(
|
||||
platform_message.Quote(
|
||||
sender_id=sender_id,
|
||||
origin=quote_data_message_list,
|
||||
)
|
||||
)
|
||||
if len(user_data) > 0:
|
||||
pattern = r'@\S{1,20}'
|
||||
user_data = re.sub(pattern, '', user_data)
|
||||
message_list.append(platform_message.Plain(user_data))
|
||||
|
||||
# for comp in message_list:
|
||||
# if isinstance(comp, platform_message.Quote):
|
||||
# print(f"quote_message_chain len={len(message_list)}")
|
||||
# print(f"quote_message_chain send_id={comp.sender_id}" )
|
||||
# for quote_item in comp.origin:
|
||||
# print(f"--quote_message_component [msg_type={quote_item.type}][message={quote_item}]" )
|
||||
# else:
|
||||
# print(f"quote_message_chain plain [msg_type={comp.type}][message={comp.text}]")
|
||||
return platform_message.MessageChain(message_list)
|
||||
|
||||
async def _handler_compound_file(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain:
|
||||
"""处理文件消息 (data_type=6)"""
|
||||
xml_data_str = ET.tostring(xml_data, encoding='unicode')
|
||||
return platform_message.MessageChain([platform_message.WeChatForwardFile(xml_data=xml_data_str)])
|
||||
|
||||
async def _handler_compound_link(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain:
|
||||
"""处理链接消息(如公众号文章、外部网页)"""
|
||||
message_list = []
|
||||
try:
|
||||
# 解析 XML 中的链接参数
|
||||
appmsg = xml_data.find('.//appmsg')
|
||||
if appmsg is None:
|
||||
return platform_message.MessageChain()
|
||||
message_list.append(
|
||||
platform_message.WeChatLink(
|
||||
link_title=appmsg.findtext('title', ''),
|
||||
link_desc=appmsg.findtext('des', ''),
|
||||
link_url=appmsg.findtext('url', ''),
|
||||
link_thumb_url=appmsg.findtext('thumburl', ''), # 这个字段拿不到
|
||||
)
|
||||
)
|
||||
# 转发消息
|
||||
xml_data_str = ET.tostring(xml_data, encoding='unicode')
|
||||
# print(xml_data_str)
|
||||
message_list.append(platform_message.WeChatForwardLink(xml_data=xml_data_str))
|
||||
except Exception as e:
|
||||
print(f'解析链接消息失败: {str(e)}')
|
||||
return platform_message.MessageChain(message_list)
|
||||
|
||||
async def _handler_compound_mini_program(
|
||||
self, message: dict, xml_data: ET.Element
|
||||
) -> platform_message.MessageChain:
|
||||
"""处理小程序消息(如小程序卡片、服务通知)"""
|
||||
xml_data_str = ET.tostring(xml_data, encoding='unicode')
|
||||
return platform_message.MessageChain([platform_message.WeChatForwardMiniPrograms(xml_data=xml_data_str)])
|
||||
|
||||
async def _handler_default(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
|
||||
"""处理未知消息类型"""
|
||||
if message:
|
||||
msg_type = message['Data']['MsgType']
|
||||
else:
|
||||
msg_type = ''
|
||||
return platform_message.MessageChain([platform_message.Unknown(text=f'[未知消息类型 msg_type:{msg_type}]')])
|
||||
|
||||
def _handler_compound_unsupported(
|
||||
self, message: dict, xml_data: str, text: Optional[str] = None
|
||||
) -> platform_message.MessageChain:
|
||||
"""处理未支持复合消息类型(msg_type=49)子类型"""
|
||||
if not text:
|
||||
text = f'[xml_data={xml_data}]'
|
||||
content_list = []
|
||||
content_list.append(platform_message.Unknown(text=f'[处理未支持复合消息类型[msg_type=49]|{text}'))
|
||||
|
||||
return platform_message.MessageChain(content_list)
|
||||
|
||||
# 返回是否被艾特
|
||||
def _ats_bot(self, message: dict, bot_account_id: str) -> bool:
|
||||
ats_bot = False
|
||||
try:
|
||||
to_user_name = message['Wxid'] # 接收方: 所属微信的wxid
|
||||
raw_content = message['Data']['Content']['string'] # 原始消息内容
|
||||
content_no_prefix, _ = self._extract_content_and_sender(raw_content)
|
||||
# 直接艾特机器人(这个有bug,当被引用的消息里面有@bot,会套娃
|
||||
# ats_bot = ats_bot or (f"@{bot_account_id}" in content_no_prefix)
|
||||
# 文本类@bot
|
||||
push_content = message.get('Data', {}).get('PushContent', '')
|
||||
ats_bot = ats_bot or ('在群聊中@了你' in push_content)
|
||||
# 引用别人时@bot
|
||||
msg_source = message.get('Data', {}).get('MsgSource', '') or ''
|
||||
if len(msg_source) > 0:
|
||||
msg_source_data = ET.fromstring(msg_source)
|
||||
at_user_list = msg_source_data.findtext('atuserlist') or ''
|
||||
ats_bot = ats_bot or (to_user_name in at_user_list)
|
||||
# 引用bot
|
||||
if message.get('Data', {}).get('MsgType', 0) == 49:
|
||||
xml_data = ET.fromstring(content_no_prefix)
|
||||
appmsg_data = xml_data.find('.//appmsg')
|
||||
tousername = message['Wxid']
|
||||
if appmsg_data: # 接收方: 所属微信的wxid
|
||||
quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr') # 引用消息的原发送者
|
||||
ats_bot = ats_bot or (quote_id == tousername)
|
||||
except Exception as e:
|
||||
print(f'Error in gewechat _ats_bot: {e}')
|
||||
finally:
|
||||
return ats_bot
|
||||
|
||||
# 提取一下content前面的sender_id, 和去掉前缀的内容
|
||||
def _extract_content_and_sender(self, raw_content: str) -> Tuple[str, Optional[str]]:
|
||||
try:
|
||||
# 检查消息开头,如果有 wxid_sbitaz0mt65n22:\n 则删掉
|
||||
# add: 有些用户的wxid不是上述格式。换成user_name:
|
||||
regex = re.compile(r'^[a-zA-Z0-9_\-]{5,20}:')
|
||||
line_split = raw_content.split('\n')
|
||||
if len(line_split) > 0 and regex.match(line_split[0]):
|
||||
raw_content = '\n'.join(line_split[1:])
|
||||
sender_id = line_split[0].strip(':')
|
||||
return raw_content, sender_id
|
||||
except Exception as e:
|
||||
print(f'_extract_content_and_sender got except: {e}')
|
||||
finally:
|
||||
return raw_content, None
|
||||
|
||||
# 是否是群消息
|
||||
def _is_group_message(self, message: dict) -> bool:
|
||||
from_user_name = message['Data']['FromUserName']['string']
|
||||
return from_user_name.endswith('@chatroom')
|
||||
|
||||
|
||||
class GewechatEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
self.message_converter = GewechatMessageConverter(config)
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent) -> dict:
|
||||
pass
|
||||
|
||||
async def target2yiri(self, event: dict, bot_account_id: str) -> platform_events.MessageEvent:
|
||||
# print(event)
|
||||
# 排除自己发消息回调回答问题
|
||||
if event['Wxid'] == event['Data']['FromUserName']['string']:
|
||||
return None
|
||||
# 排除公众号以及微信团队消息
|
||||
if event['Data']['FromUserName']['string'].startswith('gh_') or event['Data']['FromUserName'][
|
||||
'string'
|
||||
].startswith('weixin'):
|
||||
return None
|
||||
message_chain = await self.message_converter.target2yiri(copy.deepcopy(event), bot_account_id)
|
||||
|
||||
if not message_chain:
|
||||
return None
|
||||
|
||||
if '@chatroom' in event['Data']['FromUserName']['string']:
|
||||
# 找出开头的 wxid_ 字符串,以:结尾
|
||||
sender_wxid = event['Data']['Content']['string'].split(':')[0]
|
||||
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=sender_wxid,
|
||||
member_name=event['Data']['FromUserName']['string'],
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=event['Data']['FromUserName']['string'],
|
||||
name=event['Data']['FromUserName']['string'],
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event['Data']['CreateTime'],
|
||||
source_platform_object=event,
|
||||
)
|
||||
else:
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=event['Data']['FromUserName']['string'],
|
||||
nickname=event['Data']['FromUserName']['string'],
|
||||
remark='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event['Data']['CreateTime'],
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
|
||||
class GeWeChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
name: str = 'gewechat' # 定义适配器名称
|
||||
|
||||
bot: gewechat_client.GewechatClient
|
||||
quart_app: quart.Quart
|
||||
|
||||
bot_account_id: str
|
||||
|
||||
config: dict
|
||||
|
||||
ap: app.Application
|
||||
|
||||
message_converter: GewechatMessageConverter
|
||||
event_converter: GewechatEventConverter
|
||||
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
|
||||
self.config = config
|
||||
self.ap = ap
|
||||
self.logger = logger
|
||||
self.quart_app = quart.Quart(__name__)
|
||||
|
||||
self.message_converter = GewechatMessageConverter(config)
|
||||
self.event_converter = GewechatEventConverter(config)
|
||||
|
||||
@self.quart_app.route('/gewechat/callback', methods=['POST'])
|
||||
async def gewechat_callback():
|
||||
data = await quart.request.json
|
||||
# print(json.dumps(data, indent=4, ensure_ascii=False))
|
||||
await self.logger.debug(f'Gewechat callback event: {data}')
|
||||
|
||||
if 'data' in data:
|
||||
data['Data'] = data['data']
|
||||
if 'type_name' in data:
|
||||
data['TypeName'] = data['type_name']
|
||||
# print(json.dumps(data, indent=4, ensure_ascii=False))
|
||||
|
||||
if 'testMsg' in data:
|
||||
return 'ok'
|
||||
elif 'TypeName' in data and data['TypeName'] == 'AddMsg':
|
||||
try:
|
||||
event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in gewechat callback: {traceback.format_exc()}')
|
||||
|
||||
if event.__class__ in self.listeners:
|
||||
await self.listeners[event.__class__](event, self)
|
||||
|
||||
return 'ok'
|
||||
|
||||
async def _handle_message(self, message: platform_message.MessageChain, target_id: str):
|
||||
"""统一消息处理核心逻辑"""
|
||||
content_list = await self.message_converter.yiri2target(message)
|
||||
at_targets = [item['target'] for item in content_list if item['type'] == 'at']
|
||||
|
||||
# 处理@逻辑
|
||||
at_targets = at_targets or []
|
||||
member_info = []
|
||||
if at_targets:
|
||||
member_info = self.bot.get_chatroom_member_detail(self.config['app_id'], target_id, at_targets[::-1])[
|
||||
'data'
|
||||
]
|
||||
|
||||
# 处理消息组件
|
||||
for msg in content_list:
|
||||
# 文本消息处理@
|
||||
if msg['type'] == 'text' and at_targets:
|
||||
for member in member_info:
|
||||
msg['content'] = f'@{member["nickName"]} {msg["content"]}'
|
||||
|
||||
# 统一消息派发
|
||||
handler_map = {
|
||||
'text': lambda msg: self.bot.post_text(
|
||||
app_id=self.config['app_id'],
|
||||
to_wxid=target_id,
|
||||
content=msg['content'],
|
||||
ats=','.join(at_targets),
|
||||
),
|
||||
'image': lambda msg: self.bot.post_image(
|
||||
app_id=self.config['app_id'],
|
||||
to_wxid=target_id,
|
||||
img_url=msg['image'],
|
||||
),
|
||||
'WeChatForwardMiniPrograms': lambda msg: self.bot.forward_mini_app(
|
||||
app_id=self.config['app_id'],
|
||||
to_wxid=target_id,
|
||||
xml=msg['xml_data'],
|
||||
cover_img_url=msg.get('image_url'),
|
||||
),
|
||||
'WeChatEmoji': lambda msg: self.bot.post_emoji(
|
||||
app_id=self.config['app_id'],
|
||||
to_wxid=target_id,
|
||||
emoji_md5=msg['emoji_md5'],
|
||||
emoji_size=msg['emoji_size'],
|
||||
),
|
||||
'WeChatLink': lambda msg: self.bot.post_link(
|
||||
app_id=self.config['app_id'],
|
||||
to_wxid=target_id,
|
||||
title=msg['link_title'],
|
||||
desc=msg['link_desc'],
|
||||
link_url=msg['link_url'],
|
||||
thumb_url=msg['link_thumb_url'],
|
||||
),
|
||||
'WeChatMiniPrograms': lambda msg: self.bot.post_mini_app(
|
||||
app_id=self.config['app_id'],
|
||||
to_wxid=target_id,
|
||||
mini_app_id=msg['mini_app_id'],
|
||||
display_name=msg['display_name'],
|
||||
page_path=msg['page_path'],
|
||||
cover_img_url=msg['cover_img_url'],
|
||||
title=msg['title'],
|
||||
user_name=msg['user_name'],
|
||||
),
|
||||
'WeChatForwardLink': lambda msg: self.bot.forward_url(
|
||||
app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data']
|
||||
),
|
||||
'WeChatForwardImage': lambda msg: self.bot.forward_image(
|
||||
app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data']
|
||||
),
|
||||
'WeChatForwardFile': lambda msg: self.bot.forward_file(
|
||||
app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data']
|
||||
),
|
||||
'voice': lambda msg: self.bot.post_voice(
|
||||
app_id=self.config['app_id'],
|
||||
to_wxid=target_id,
|
||||
voice_url=msg['url'],
|
||||
voice_duration=msg['length'],
|
||||
),
|
||||
'WeChatAppMsg': lambda msg: self.bot.post_app_msg(
|
||||
app_id=self.config['app_id'],
|
||||
to_wxid=target_id,
|
||||
appmsg=msg['app_msg'],
|
||||
),
|
||||
'at': lambda msg: None,
|
||||
}
|
||||
|
||||
if handler := handler_map.get(msg['type']):
|
||||
handler(msg)
|
||||
else:
|
||||
await self.logger.warning(f'未处理的消息类型: {msg["type"]}')
|
||||
continue
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
"""主动发送消息"""
|
||||
return await self._handle_message(message, target_id)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
"""回复消息"""
|
||||
if message_source.source_platform_object:
|
||||
target_id = message_source.source_platform_object['Data']['FromUserName']['string']
|
||||
return await self._handle_message(message, target_id)
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
pass
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
pass
|
||||
|
||||
async def run_async(self):
|
||||
if not self.config['token']:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f'{self.config["gewechat_url"]}/v2/api/tools/getTokenId',
|
||||
json={'app_id': self.config['app_id']},
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise Exception(f'获取gewechat token失败: {await response.text()}')
|
||||
self.config['token'] = (await response.json())['data']
|
||||
|
||||
self.bot = gewechat_client.GewechatClient(f'{self.config["gewechat_url"]}/v2/api', self.config['token'])
|
||||
|
||||
def gewechat_login_process():
|
||||
app_id, error_msg = self.bot.login(self.config['app_id'])
|
||||
if error_msg:
|
||||
raise Exception(f'Gewechat 登录失败: {error_msg}')
|
||||
|
||||
self.config['app_id'] = app_id
|
||||
|
||||
print(f'Gewechat 登录成功,app_id: {app_id}')
|
||||
|
||||
# 获取 nickname
|
||||
profile = self.bot.get_profile(self.config['app_id'])
|
||||
self.bot_account_id = profile['data']['nickName']
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
try:
|
||||
# gewechat-server容器重启, token会变,但是还会登录成功
|
||||
# 换新token也会收不到回调,要重新登陆下。
|
||||
self.bot.set_callback(self.config['token'], self.config['callback_url'])
|
||||
except Exception as e:
|
||||
raise Exception(f'设置 Gewechat 回调失败, token失效: {e}')
|
||||
|
||||
threading.Thread(target=gewechat_login_process).start()
|
||||
|
||||
async def shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self.quart_app.run_task(
|
||||
host='0.0.0.0',
|
||||
port=self.config['port'],
|
||||
shutdown_trigger=shutdown_trigger_placeholder,
|
||||
)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
pass
|
||||
59
src/langbot/pkg/platform/sources/legacy/gewechat.yaml
Normal file
@@ -0,0 +1,59 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: gewechat
|
||||
label:
|
||||
en_US: GeWeChat
|
||||
zh_Hans: GeWeChat(个人微信)
|
||||
description:
|
||||
en_US: GeWeChat Adapter
|
||||
zh_Hans: GeWeChat 适配器,请查看文档了解使用方式
|
||||
icon: gewechat.png
|
||||
spec:
|
||||
config:
|
||||
- name: gewechat_url
|
||||
label:
|
||||
en_US: GeWeChat URL
|
||||
zh_Hans: GeWeChat URL
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: gewechat_file_url
|
||||
label:
|
||||
en_US: GeWeChat file download URL
|
||||
zh_Hans: GeWeChat 文件下载URL
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2286
|
||||
- name: callback_url
|
||||
label:
|
||||
en_US: Callback URL
|
||||
zh_Hans: 回调URL
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: app_id
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./gewechat.py
|
||||
attr: GeWeChatAdapter
|
||||
BIN
src/langbot/pkg/platform/sources/legacy/nakuru.png
Normal file
|
After Width: | Height: | Size: 274 KiB |
333
src/langbot/pkg/platform/sources/legacy/nakuru.py
Normal file
@@ -0,0 +1,333 @@
|
||||
# 加了之后会导致:https://github.com/Lxns-Network/nakuru-project/issues/25
|
||||
# from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import typing
|
||||
import traceback
|
||||
|
||||
|
||||
import nakuru
|
||||
import nakuru.entities.components as nkc
|
||||
|
||||
from ....pipeline.longtext.strategies import forward
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from ...logger import EventLogger
|
||||
|
||||
|
||||
class NakuruProjectMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""消息转换器"""
|
||||
|
||||
@staticmethod
|
||||
def yiri2target(message_chain: platform_message.MessageChain) -> list:
|
||||
msg_list = []
|
||||
if type(message_chain) is platform_message.MessageChain:
|
||||
msg_list = message_chain.__root__
|
||||
elif type(message_chain) is list:
|
||||
msg_list = message_chain
|
||||
elif type(message_chain) is str:
|
||||
msg_list = [platform_message.Plain(message_chain)]
|
||||
else:
|
||||
raise Exception('Unknown message type: ' + str(message_chain) + str(type(message_chain)))
|
||||
|
||||
nakuru_msg_list = []
|
||||
|
||||
# 遍历并转换
|
||||
for component in msg_list:
|
||||
if type(component) is platform_message.Plain:
|
||||
nakuru_msg_list.append(nkc.Plain(component.text, False))
|
||||
elif type(component) is platform_message.Image:
|
||||
if component.url is not None:
|
||||
nakuru_msg_list.append(nkc.Image.fromURL(component.url))
|
||||
elif component.base64 is not None:
|
||||
nakuru_msg_list.append(nkc.Image.fromBase64(component.base64))
|
||||
elif component.path is not None:
|
||||
nakuru_msg_list.append(nkc.Image.fromFileSystem(component.path))
|
||||
elif type(component) is platform_message.At:
|
||||
nakuru_msg_list.append(nkc.At(qq=component.target))
|
||||
elif type(component) is platform_message.AtAll:
|
||||
nakuru_msg_list.append(nkc.AtAll())
|
||||
elif type(component) is platform_message.Voice:
|
||||
if component.url is not None:
|
||||
nakuru_msg_list.append(nkc.Record.fromURL(component.url))
|
||||
elif component.path is not None:
|
||||
nakuru_msg_list.append(nkc.Record.fromFileSystem(component.path))
|
||||
elif type(component) is forward.Forward:
|
||||
# 转发消息
|
||||
yiri_forward_node_list = component.node_list
|
||||
nakuru_forward_node_list = []
|
||||
|
||||
# 遍历并转换
|
||||
for yiri_forward_node in yiri_forward_node_list:
|
||||
try:
|
||||
content_list = NakuruProjectMessageConverter.yiri2target(yiri_forward_node.message_chain)
|
||||
nakuru_forward_node = nkc.Node(
|
||||
name=yiri_forward_node.sender_name,
|
||||
uin=yiri_forward_node.sender_id,
|
||||
time=int(yiri_forward_node.time.timestamp())
|
||||
if yiri_forward_node.time is not None
|
||||
else None,
|
||||
content=content_list,
|
||||
)
|
||||
nakuru_forward_node_list.append(nakuru_forward_node)
|
||||
except Exception:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
nakuru_msg_list.append(nakuru_forward_node_list)
|
||||
else:
|
||||
nakuru_msg_list.append(nkc.Plain(str(component)))
|
||||
|
||||
return nakuru_msg_list
|
||||
|
||||
@staticmethod
|
||||
def target2yiri(message_chain: typing.Any, message_id: int = -1) -> platform_message.MessageChain:
|
||||
"""将Yiri的消息链转换为YiriMirai的消息链"""
|
||||
assert type(message_chain) is list
|
||||
|
||||
yiri_msg_list = []
|
||||
import datetime
|
||||
|
||||
# 添加Source组件以标记message_id等信息
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
for component in message_chain:
|
||||
if type(component) is nkc.Plain:
|
||||
yiri_msg_list.append(platform_message.Plain(text=component.text))
|
||||
elif type(component) is nkc.Image:
|
||||
yiri_msg_list.append(platform_message.Image(url=component.url))
|
||||
elif type(component) is nkc.At:
|
||||
yiri_msg_list.append(platform_message.At(target=component.qq))
|
||||
elif type(component) is nkc.AtAll:
|
||||
yiri_msg_list.append(platform_message.AtAll())
|
||||
else:
|
||||
pass
|
||||
# logging.debug("转换后的消息链: " + str(yiri_msg_list))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
return chain
|
||||
|
||||
|
||||
class NakuruProjectEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
"""事件转换器"""
|
||||
|
||||
@staticmethod
|
||||
def yiri2target(event: typing.Type[platform_events.Event]):
|
||||
if event is platform_events.GroupMessage:
|
||||
return nakuru.GroupMessage
|
||||
elif event is platform_events.FriendMessage:
|
||||
return nakuru.FriendMessage
|
||||
else:
|
||||
raise Exception('未支持转换的事件类型: ' + str(event))
|
||||
|
||||
@staticmethod
|
||||
def target2yiri(event: typing.Any) -> platform_events.Event:
|
||||
yiri_chain = NakuruProjectMessageConverter.target2yiri(event.message, event.message_id)
|
||||
if type(event) is nakuru.FriendMessage: # 私聊消息事件
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=event.sender.user_id,
|
||||
nickname=event.sender.nickname,
|
||||
remark=event.sender.nickname,
|
||||
),
|
||||
message_chain=yiri_chain,
|
||||
time=event.time,
|
||||
)
|
||||
elif type(event) is nakuru.GroupMessage: # 群聊消息事件
|
||||
permission = 'MEMBER'
|
||||
|
||||
if event.sender.role == 'admin':
|
||||
permission = 'ADMINISTRATOR'
|
||||
elif event.sender.role == 'owner':
|
||||
permission = 'OWNER'
|
||||
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=event.sender.user_id,
|
||||
member_name=event.sender.nickname,
|
||||
permission=permission,
|
||||
group=platform_entities.Group(
|
||||
id=event.group_id,
|
||||
name=event.sender.nickname,
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title=event.sender.title,
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=yiri_chain,
|
||||
time=event.time,
|
||||
)
|
||||
else:
|
||||
raise Exception('未支持转换的事件类型: ' + str(event))
|
||||
|
||||
|
||||
class NakuruAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""nakuru-project适配器"""
|
||||
|
||||
bot: nakuru.CQHTTP
|
||||
bot_account_id: int
|
||||
|
||||
message_converter: NakuruProjectMessageConverter = NakuruProjectMessageConverter()
|
||||
event_converter: NakuruProjectEventConverter = NakuruProjectEventConverter()
|
||||
|
||||
listener_list: list[dict]
|
||||
|
||||
# ap: app.Application
|
||||
|
||||
cfg: dict
|
||||
|
||||
def __init__(self, cfg: dict, ap, logger: EventLogger):
|
||||
"""初始化nakuru-project的对象"""
|
||||
cfg['port'] = cfg['ws_port']
|
||||
del cfg['ws_port']
|
||||
self.cfg = cfg
|
||||
self.ap = ap
|
||||
self.logger = logger
|
||||
self.listener_list = []
|
||||
self.bot = nakuru.CQHTTP(**self.cfg)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message: typing.Union[platform_message.MessageChain, list],
|
||||
converted: bool = False,
|
||||
):
|
||||
task = None
|
||||
|
||||
converted_msg = self.message_converter.yiri2target(message) if not converted else message
|
||||
|
||||
# 检查是否有转发消息
|
||||
has_forward = False
|
||||
for msg in converted_msg:
|
||||
if type(msg) is list: # 转发消息,仅回复此消息组件
|
||||
has_forward = True
|
||||
converted_msg = msg
|
||||
break
|
||||
if has_forward:
|
||||
if target_type == 'group':
|
||||
task = self.bot.sendGroupForwardMessage(int(target_id), converted_msg)
|
||||
elif target_type == 'person':
|
||||
task = self.bot.sendPrivateForwardMessage(int(target_id), converted_msg)
|
||||
else:
|
||||
raise Exception('Unknown target type: ' + target_type)
|
||||
else:
|
||||
if target_type == 'group':
|
||||
task = self.bot.sendGroupMessage(int(target_id), converted_msg)
|
||||
elif target_type == 'person':
|
||||
task = self.bot.sendFriendMessage(int(target_id), converted_msg)
|
||||
else:
|
||||
raise Exception('Unknown target type: ' + target_type)
|
||||
|
||||
await task
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
message = self.message_converter.yiri2target(message)
|
||||
if quote_origin:
|
||||
# 在前方添加引用组件
|
||||
message.insert(
|
||||
0,
|
||||
nkc.Reply(
|
||||
id=message_source.message_chain.message_id,
|
||||
),
|
||||
)
|
||||
if type(message_source) is platform_events.GroupMessage:
|
||||
await self.send_message('group', message_source.sender.group.id, message, converted=True)
|
||||
elif type(message_source) is platform_events.FriendMessage:
|
||||
await self.send_message('person', message_source.sender.id, message, converted=True)
|
||||
else:
|
||||
raise Exception('Unknown message source type: ' + str(type(message_source)))
|
||||
|
||||
def is_muted(self, group_id: int) -> bool:
|
||||
import time
|
||||
|
||||
# 检查是否被禁言
|
||||
group_member_info = asyncio.run(self.bot.getGroupMemberInfo(group_id, self.bot_account_id))
|
||||
return group_member_info.shut_up_timestamp > int(time.time())
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
try:
|
||||
source_cls = NakuruProjectEventConverter.yiri2target(event_type)
|
||||
|
||||
# 包装函数
|
||||
async def listener_wrapper(app: nakuru.CQHTTP, source: source_cls): # type: ignore
|
||||
await callback(self.event_converter.target2yiri(source), self)
|
||||
|
||||
# 将包装函数和原函数的对应关系存入列表
|
||||
self.listener_list.append(
|
||||
{
|
||||
'event_type': event_type,
|
||||
'callable': callback,
|
||||
'wrapper': listener_wrapper,
|
||||
}
|
||||
)
|
||||
|
||||
# 注册监听器
|
||||
self.bot.receiver(source_cls.__name__)(listener_wrapper)
|
||||
except Exception as e:
|
||||
self.logger.error(f'Error in nakuru register_listener: {traceback.format_exc()}')
|
||||
raise e
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
nakuru_event_name = self.event_converter.yiri2target(event_type).__name__
|
||||
|
||||
new_event_list = []
|
||||
|
||||
# 从本对象的监听器列表中查找并删除
|
||||
target_wrapper = None
|
||||
for listener in self.listener_list:
|
||||
if listener['event_type'] == event_type and listener['callable'] == callback:
|
||||
target_wrapper = listener['wrapper']
|
||||
self.listener_list.remove(listener)
|
||||
break
|
||||
|
||||
if target_wrapper is None:
|
||||
raise Exception('未找到对应的监听器')
|
||||
|
||||
for func in self.bot.event[nakuru_event_name]:
|
||||
if func.callable != target_wrapper:
|
||||
new_event_list.append(func)
|
||||
|
||||
self.bot.event[nakuru_event_name] = new_event_list
|
||||
|
||||
async def run_async(self):
|
||||
try:
|
||||
import requests
|
||||
|
||||
resp = requests.get(
|
||||
url='http://{}:{}/get_login_info'.format(self.cfg['host'], self.cfg['http_port']),
|
||||
headers={'Authorization': 'Bearer ' + self.cfg['token'] if 'token' in self.cfg else ''},
|
||||
timeout=5,
|
||||
proxies=None,
|
||||
)
|
||||
if resp.status_code == 403:
|
||||
raise Exception('go-cqhttp拒绝访问,请检查配置文件中nakuru适配器的配置')
|
||||
self.bot_account_id = int(resp.json()['data']['user_id'])
|
||||
except Exception:
|
||||
raise Exception('获取go-cqhttp账号信息失败, 请检查是否已启动go-cqhttp并配置正确')
|
||||
await self.bot._run()
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
return False
|
||||
45
src/langbot/pkg/platform/sources/legacy/nakuru.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: nakuru
|
||||
label:
|
||||
en_US: Nakuru
|
||||
zh_Hans: Nakuru
|
||||
description:
|
||||
en_US: Nakuru Adapter
|
||||
zh_Hans: Nakuru 适配器(go-cqhttp),请查看文档了解使用方式
|
||||
icon: nakuru.png
|
||||
spec:
|
||||
config:
|
||||
- name: host
|
||||
label:
|
||||
en_US: Host
|
||||
zh_Hans: 主机
|
||||
type: string
|
||||
required: true
|
||||
default: "127.0.0.1"
|
||||
- name: http_port
|
||||
label:
|
||||
en_US: HTTP Port
|
||||
zh_Hans: HTTP端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 5700
|
||||
- name: ws_port
|
||||
label:
|
||||
en_US: WebSocket Port
|
||||
zh_Hans: WebSocket端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 8080
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./nakuru.py
|
||||
attr: NakuruAdapter
|
||||
532
src/langbot/pkg/platform/sources/legacy/qqbotpy.py
Normal file
@@ -0,0 +1,532 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import typing
|
||||
import datetime
|
||||
import re
|
||||
import traceback
|
||||
|
||||
import botpy
|
||||
import botpy.message as botpy_message
|
||||
import botpy.types.message as botpy_message_type
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from ....pipeline.longtext.strategies import forward
|
||||
from ....core import app
|
||||
from ....config import manager as cfg_mgr
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
from ...logger import EventLogger
|
||||
|
||||
|
||||
class OfficialGroupMessage(platform_events.GroupMessage):
|
||||
pass
|
||||
|
||||
|
||||
class OfficialFriendMessage(platform_events.FriendMessage):
|
||||
pass
|
||||
|
||||
|
||||
event_handler_mapping = {
|
||||
platform_events.GroupMessage: [
|
||||
'on_at_message_create',
|
||||
'on_group_at_message_create',
|
||||
],
|
||||
platform_events.FriendMessage: [
|
||||
'on_direct_message_create',
|
||||
'on_c2c_message_create',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
cached_message_ids = {}
|
||||
"""由于QQ官方的消息id是字符串,而YiriMirai的消息id是整数,所以需要一个索引来进行转换"""
|
||||
|
||||
id_index = 0
|
||||
|
||||
|
||||
def save_msg_id(message_id: str) -> int:
|
||||
"""保存消息id"""
|
||||
global id_index, cached_message_ids
|
||||
|
||||
crt_index = id_index
|
||||
id_index += 1
|
||||
cached_message_ids[str(crt_index)] = message_id
|
||||
return crt_index
|
||||
|
||||
|
||||
def char_to_value(char):
|
||||
"""将单个字符转换为相应的数值。"""
|
||||
if '0' <= char <= '9':
|
||||
return ord(char) - ord('0')
|
||||
elif 'A' <= char <= 'Z':
|
||||
return ord(char) - ord('A') + 10
|
||||
|
||||
return ord(char) - ord('a') + 36
|
||||
|
||||
|
||||
def digest(s: str) -> int:
|
||||
"""计算字符串的hash值。"""
|
||||
# 取末尾的8位
|
||||
sub_s = s[-10:]
|
||||
|
||||
number = 0
|
||||
base = 36
|
||||
|
||||
for i in range(len(sub_s)):
|
||||
number = number * base + char_to_value(sub_s[i])
|
||||
|
||||
return number
|
||||
|
||||
|
||||
K = typing.TypeVar('K')
|
||||
V = typing.TypeVar('V')
|
||||
|
||||
|
||||
class OpenIDMapping(typing.Generic[K, V]):
|
||||
map: dict[K, V]
|
||||
|
||||
dump_func: typing.Callable
|
||||
|
||||
digest_func: typing.Callable[[K], V]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
map: dict[K, V],
|
||||
dump_func: typing.Callable,
|
||||
digest_func: typing.Callable[[K], V] = digest,
|
||||
):
|
||||
self.map = map
|
||||
|
||||
self.dump_func = dump_func
|
||||
|
||||
self.digest_func = digest_func
|
||||
|
||||
def __getitem__(self, key: K) -> V:
|
||||
return self.map[key]
|
||||
|
||||
def __setitem__(self, key: K, value: V):
|
||||
self.map[key] = value
|
||||
self.dump_func()
|
||||
|
||||
def __contains__(self, key: K) -> bool:
|
||||
return key in self.map
|
||||
|
||||
def __delitem__(self, key: K):
|
||||
del self.map[key]
|
||||
self.dump_func()
|
||||
|
||||
def getkey(self, value: V) -> K:
|
||||
return list(self.map.keys())[list(self.map.values()).index(value)]
|
||||
|
||||
def save_openid(self, key: K) -> V:
|
||||
if key in self.map:
|
||||
return self.map[key]
|
||||
|
||||
value = self.digest_func(key)
|
||||
|
||||
self.map[key] = value
|
||||
|
||||
self.dump_func()
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class OfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""QQ 官方消息转换器"""
|
||||
|
||||
@staticmethod
|
||||
def yiri2target(message_chain: platform_message.MessageChain):
|
||||
"""将 YiriMirai 的消息链转换为 QQ 官方消息"""
|
||||
|
||||
msg_list = []
|
||||
if type(message_chain) is platform_message.MessageChain:
|
||||
msg_list = message_chain.__root__
|
||||
elif type(message_chain) is list:
|
||||
msg_list = message_chain
|
||||
elif type(message_chain) is str:
|
||||
msg_list = [platform_message.Plain(text=message_chain)]
|
||||
else:
|
||||
raise Exception('Unknown message type: ' + str(message_chain) + str(type(message_chain)))
|
||||
|
||||
offcial_messages: list[dict] = []
|
||||
"""
|
||||
{
|
||||
"type": "text",
|
||||
"content": "Hello World!"
|
||||
}
|
||||
|
||||
{
|
||||
"type": "image",
|
||||
"content": "https://example.com/example.jpg"
|
||||
}
|
||||
"""
|
||||
|
||||
# 遍历并转换
|
||||
for component in msg_list:
|
||||
if type(component) is platform_message.Plain:
|
||||
offcial_messages.append({'type': 'text', 'content': component.text})
|
||||
elif type(component) is platform_message.Image:
|
||||
if component.url is not None:
|
||||
offcial_messages.append({'type': 'image', 'content': component.url})
|
||||
elif component.path is not None:
|
||||
offcial_messages.append({'type': 'file_image', 'content': component.path})
|
||||
elif type(component) is platform_message.At:
|
||||
offcial_messages.append({'type': 'at', 'content': ''})
|
||||
elif type(component) is platform_message.AtAll:
|
||||
print('上层组件要求发送 AtAll 消息,但 QQ 官方 API 不支持此消息类型,忽略此消息。')
|
||||
elif type(component) is platform_message.Voice:
|
||||
print('上层组件要求发送 Voice 消息,但 QQ 官方 API 不支持此消息类型,忽略此消息。')
|
||||
elif type(component) is forward.Forward:
|
||||
# 转发消息
|
||||
yiri_forward_node_list = component.node_list
|
||||
|
||||
# 遍历并转换
|
||||
for yiri_forward_node in yiri_forward_node_list:
|
||||
try:
|
||||
message_chain = yiri_forward_node.message_chain
|
||||
|
||||
# 平铺
|
||||
offcial_messages.extend(OfficialMessageConverter.yiri2target(message_chain))
|
||||
except Exception:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
return offcial_messages
|
||||
|
||||
@staticmethod
|
||||
def extract_message_chain_from_obj(
|
||||
message: typing.Union[
|
||||
botpy_message.Message,
|
||||
botpy_message.DirectMessage,
|
||||
botpy_message.GroupMessage,
|
||||
botpy_message.C2CMessage,
|
||||
],
|
||||
message_id: str = None,
|
||||
bot_account_id: int = 0,
|
||||
) -> platform_message.MessageChain:
|
||||
yiri_msg_list = []
|
||||
# 存id
|
||||
|
||||
yiri_msg_list.append(platform_message.Source(id=save_msg_id(message_id), time=datetime.datetime.now()))
|
||||
|
||||
if type(message) not in [botpy_message.DirectMessage, botpy_message.C2CMessage]:
|
||||
yiri_msg_list.append(platform_message.At(target=bot_account_id))
|
||||
|
||||
if hasattr(message, 'mentions'):
|
||||
for mention in message.mentions:
|
||||
if mention.bot:
|
||||
continue
|
||||
|
||||
yiri_msg_list.append(platform_message.At(target=mention.id))
|
||||
|
||||
for attachment in message.attachments:
|
||||
if attachment.content_type.startswith('image'):
|
||||
yiri_msg_list.append(platform_message.Image(url=attachment.url))
|
||||
else:
|
||||
logging.warning('不支持的附件类型:' + attachment.content_type + ',忽略此附件。')
|
||||
|
||||
content = re.sub(r'<@!\d+>', '', str(message.content))
|
||||
if content.strip() != '':
|
||||
yiri_msg_list.append(platform_message.Plain(text=content))
|
||||
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
|
||||
|
||||
class OfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
"""事件转换器"""
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def yiri2target(self, event: typing.Type[platform_events.Event]):
|
||||
if event == platform_events.GroupMessage:
|
||||
return botpy_message.Message
|
||||
elif event == platform_events.FriendMessage:
|
||||
return botpy_message.DirectMessage
|
||||
else:
|
||||
raise Exception('未支持转换的事件类型(YiriMirai -> Official): ' + str(event))
|
||||
|
||||
def target2yiri(
|
||||
self,
|
||||
event: typing.Union[
|
||||
botpy_message.Message,
|
||||
botpy_message.DirectMessage,
|
||||
botpy_message.GroupMessage,
|
||||
botpy_message.C2CMessage,
|
||||
],
|
||||
) -> platform_events.Event:
|
||||
if isinstance(event, botpy_message.Message): # 频道内,转群聊事件
|
||||
permission = 'MEMBER'
|
||||
|
||||
if '2' in event.member.roles:
|
||||
permission = 'ADMINISTRATOR'
|
||||
elif '4' in event.member.roles:
|
||||
permission = 'OWNER'
|
||||
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=event.author.id,
|
||||
member_name=event.author.username,
|
||||
permission=permission,
|
||||
group=platform_entities.Group(
|
||||
id=event.channel_id,
|
||||
name=event.author.username,
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=int(
|
||||
datetime.datetime.strptime(event.member.joined_at, '%Y-%m-%dT%H:%M:%S%z').timestamp()
|
||||
),
|
||||
last_speak_timestamp=datetime.datetime.now().timestamp(),
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),
|
||||
time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),
|
||||
)
|
||||
elif isinstance(event, botpy_message.DirectMessage): # 频道私聊,转私聊事件
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=event.guild_id,
|
||||
nickname=event.author.username,
|
||||
remark=event.author.username,
|
||||
),
|
||||
message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),
|
||||
time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),
|
||||
)
|
||||
elif isinstance(event, botpy_message.GroupMessage): # 群聊,转群聊事件
|
||||
author_member_id = event.author.member_openid
|
||||
|
||||
return OfficialGroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=author_member_id,
|
||||
member_name=author_member_id,
|
||||
permission='MEMBER',
|
||||
group=platform_entities.Group(
|
||||
id=event.group_openid,
|
||||
name=author_member_id,
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=int(0),
|
||||
last_speak_timestamp=datetime.datetime.now().timestamp(),
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),
|
||||
time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),
|
||||
)
|
||||
elif isinstance(event, botpy_message.C2CMessage): # 私聊,转私聊事件
|
||||
user_id_alter = event.author.user_openid
|
||||
|
||||
return OfficialFriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=user_id_alter,
|
||||
nickname=user_id_alter,
|
||||
remark=user_id_alter,
|
||||
),
|
||||
message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),
|
||||
time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),
|
||||
)
|
||||
|
||||
|
||||
class OfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""QQ 官方消息适配器"""
|
||||
|
||||
bot: botpy.Client = None
|
||||
|
||||
bot_account_id: int = 0
|
||||
|
||||
message_converter: OfficialMessageConverter
|
||||
event_converter: OfficialEventConverter
|
||||
|
||||
cfg: dict = None
|
||||
|
||||
cached_official_messages: dict = {}
|
||||
"""缓存的 qq-botpy 框架消息对象
|
||||
|
||||
message_id: botpy_message.Message | botpy_message.DirectMessage
|
||||
"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
metadata: cfg_mgr.ConfigManager = None
|
||||
|
||||
group_msg_seq = None
|
||||
c2c_msg_seq = None
|
||||
|
||||
def __init__(self, cfg: dict, ap: app.Application, logger: EventLogger):
|
||||
"""初始化适配器"""
|
||||
self.cfg = cfg
|
||||
self.ap = ap
|
||||
self.logger = logger
|
||||
|
||||
self.group_msg_seq = 1
|
||||
self.c2c_msg_seq = 1
|
||||
|
||||
switchs = {}
|
||||
|
||||
for intent in cfg['intents']:
|
||||
switchs[intent] = True
|
||||
|
||||
del cfg['intents']
|
||||
|
||||
intents = botpy.Intents(**switchs)
|
||||
|
||||
self.bot = botpy.Client(intents=intents)
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
message_list = self.message_converter.yiri2target(message)
|
||||
|
||||
for msg in message_list:
|
||||
args = {}
|
||||
|
||||
if msg['type'] == 'text':
|
||||
args['content'] = msg['content']
|
||||
elif msg['type'] == 'image':
|
||||
args['image'] = msg['content']
|
||||
elif msg['type'] == 'file_image':
|
||||
args['file_image'] = msg['content']
|
||||
else:
|
||||
continue
|
||||
|
||||
if target_type == 'group':
|
||||
args['channel_id'] = str(target_id)
|
||||
|
||||
await self.bot.api.post_message(**args)
|
||||
elif target_type == 'person':
|
||||
args['guild_id'] = str(target_id)
|
||||
|
||||
await self.bot.api.post_dms(**args)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
message_list = self.message_converter.yiri2target(message)
|
||||
|
||||
for msg in message_list:
|
||||
args = {}
|
||||
|
||||
if msg['type'] == 'text':
|
||||
args['content'] = msg['content']
|
||||
elif msg['type'] == 'image':
|
||||
args['image'] = msg['content']
|
||||
elif msg['type'] == 'file_image':
|
||||
args['file_image'] = msg['content']
|
||||
else:
|
||||
continue
|
||||
|
||||
if quote_origin:
|
||||
args['message_reference'] = botpy_message_type.Reference(
|
||||
message_id=cached_message_ids[str(message_source.message_chain.message_id)]
|
||||
)
|
||||
|
||||
if isinstance(message_source, platform_events.GroupMessage):
|
||||
args['channel_id'] = str(message_source.sender.group.id)
|
||||
args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)]
|
||||
await self.bot.api.post_message(**args)
|
||||
elif isinstance(message_source, platform_events.FriendMessage):
|
||||
args['guild_id'] = str(message_source.sender.id)
|
||||
args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)]
|
||||
await self.bot.api.post_dms(**args)
|
||||
elif isinstance(message_source, OfficialGroupMessage):
|
||||
if 'file_image' in args: # 暂不支持发送文件图片
|
||||
continue
|
||||
|
||||
args['group_openid'] = message_source.sender.group.id
|
||||
|
||||
if 'image' in args:
|
||||
uploadMedia = await self.bot.api.post_group_file(
|
||||
group_openid=args['group_openid'],
|
||||
file_type=1,
|
||||
url=str(args['image']),
|
||||
)
|
||||
|
||||
del args['image']
|
||||
args['media'] = uploadMedia
|
||||
args['msg_type'] = 7
|
||||
|
||||
args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)]
|
||||
args['msg_seq'] = self.group_msg_seq
|
||||
self.group_msg_seq += 1
|
||||
|
||||
await self.bot.api.post_group_message(**args)
|
||||
elif isinstance(message_source, OfficialFriendMessage):
|
||||
if 'file_image' in args:
|
||||
continue
|
||||
args['openid'] = message_source.sender.id
|
||||
|
||||
if 'image' in args:
|
||||
uploadMedia = await self.bot.api.post_c2c_file(
|
||||
openid=args['openid'], file_type=1, url=str(args['image'])
|
||||
)
|
||||
|
||||
del args['image']
|
||||
args['media'] = uploadMedia
|
||||
args['msg_type'] = 7
|
||||
|
||||
args['msg_id'] = cached_message_ids[str(message_source.message_chain.message_id)]
|
||||
|
||||
args['msg_seq'] = self.c2c_msg_seq
|
||||
self.c2c_msg_seq += 1
|
||||
|
||||
await self.bot.api.post_c2c_message(**args)
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
return False
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
try:
|
||||
|
||||
async def wrapper(
|
||||
message: typing.Union[
|
||||
botpy_message.Message,
|
||||
botpy_message.DirectMessage,
|
||||
botpy_message.GroupMessage,
|
||||
],
|
||||
):
|
||||
self.cached_official_messages[str(message.id)] = message
|
||||
await callback(self.event_converter.target2yiri(message), self)
|
||||
|
||||
for event_handler in event_handler_mapping[event_type]:
|
||||
setattr(self.bot, event_handler, wrapper)
|
||||
except Exception as e:
|
||||
self.logger.error(f'Error in qqbotpy callback: {traceback.format_exc()}')
|
||||
raise e
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
delattr(self.bot, event_handler_mapping[event_type])
|
||||
|
||||
async def run_async(self):
|
||||
self.metadata = self.ap.adapter_qq_botpy_meta
|
||||
|
||||
self.message_converter = OfficialMessageConverter()
|
||||
self.event_converter = OfficialEventConverter()
|
||||
|
||||
self.cfg['ret_coro'] = True
|
||||
|
||||
await self.logger.info('运行 QQ 官方适配器')
|
||||
await (await self.bot.start(**self.cfg))
|
||||
|
||||
async def kill(self) -> bool:
|
||||
if not self.bot.is_closed():
|
||||
await self.bot.close()
|
||||
return True
|
||||
1
src/langbot/pkg/platform/sources/legacy/qqbotpy.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><path fill="#FFC107" d="M17.5,44c-3.6,0-6.5-1.6-6.5-3.5s2.9-3.5,6.5-3.5s6.5,1.6,6.5,3.5S21.1,44,17.5,44z M37,40.5c0-1.9-2.9-3.5-6.5-3.5S24,38.6,24,40.5s2.9,3.5,6.5,3.5S37,42.4,37,40.5z"/><path fill="#37474F" d="M37.2,22.2c-0.1-0.3-0.2-0.6-0.3-1c0.1-0.5,0.1-1,0.1-1.5c0-1.4-0.1-2.6-0.1-3.6C36.9,9.4,31.1,4,24,4S11,9.4,11,16.1c0,0.9,0,2.2,0,3.6c0,0.5,0,1,0.1,1.5c-0.1,0.3-0.2,0.6-0.3,1c-1.9,2.7-3.8,6-3.8,8.5C7,35.5,8.4,35,8.4,35c0.6,0,1.6-1,2.5-2.1C13,38.8,18,43,24,43s11-4.2,13.1-10.1C38,34,39,35,39.6,35c0,0,1.4,0.5,1.4-4.3C41,28.2,39.1,24.8,37.2,22.2z"/><path fill="#ECEFF1" d="M14.7,23c-0.5,1.5-0.7,3.1-0.7,4.8C14,35.1,18.5,41,24,41s10-5.9,10-13.2c0-1.7-0.3-3.3-0.7-4.8H14.7z"/><path fill="#FFF" d="M23,13.5c0,1.9-1.1,3.5-2.5,3.5S18,15.4,18,13.5s1.1-3.5,2.5-3.5S23,11.6,23,13.5z M27.5,10c-1.4,0-2.5,1.6-2.5,3.5s1.1,3.5,2.5,3.5s2.5-1.6,2.5-3.5S28.9,10,27.5,10z"/><path fill="#37474F" d="M22,13.5c0,0.8-0.4,1.5-1,1.5s-1-0.7-1-1.5s0.4-1.5,1-1.5S22,12.7,22,13.5z M27,12c-0.6,0-1,0.7-1,1.5s0.4-0.5,1-0.5s1,1.3,1,0.5S27.6,12,27,12z"/><path fill="#FFC107" d="M32,19.5c0,0.8-3.6,2.5-8,2.5s-8-1.7-8-2.5s3.6-1.5,8-1.5S32,18.7,32,19.5z"/><path fill="#FF3D00" d="M38.7,21.2c-0.4-1.5-1-2.2-2.1-1.3c0,0-5.9,3.1-12.5,3.1v0.1l0-0.1c-6.6,0-12.5-3.1-12.5-3.1c-1.1-0.8-1.7-0.2-2.1,1.3c-0.4,1.5-0.7,2,0.7,2.8c0.1,0.1,1.4,0.8,3.4,1.7c-0.6,3.5-0.5,6.8-0.5,7c0.1,1.5,1.3,1.3,2.9,1.3c1.6-0.1,2.9,0,2.9-1.6c0-0.9,0-2.9,0.3-5c1.6,0.3,3.2,0.6,5,0.6l0,0v0c7.3,0,13.7-3.9,13.9-4C39.3,23.3,39,22.8,38.7,21.2z"/><path fill="#DD2C00" d="M13.2,27.7c1.6,0.6,3.5,1.3,5.6,1.7c0-0.6,0.1-1.3,0.2-2c-2.1-0.5-4-1.1-5.5-1.7C13.4,26.4,13.3,27.1,13.2,27.7z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
40
src/langbot/pkg/platform/sources/legacy/qqbotpy.yaml
Normal file
@@ -0,0 +1,40 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: qq-botpy
|
||||
label:
|
||||
en_US: QQBotPy
|
||||
zh_Hans: QQBotPy
|
||||
description:
|
||||
en_US: QQ Official API (WebSocket)
|
||||
zh_Hans: QQ 官方 API (WebSocket),请查看文档了解使用方式
|
||||
icon: qqbotpy.svg
|
||||
spec:
|
||||
config:
|
||||
- name: appid
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: secret
|
||||
label:
|
||||
en_US: Secret
|
||||
zh_Hans: 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: intents
|
||||
label:
|
||||
en_US: Intents
|
||||
zh_Hans: 权限
|
||||
type: array
|
||||
required: true
|
||||
default: []
|
||||
items:
|
||||
type: string
|
||||
execution:
|
||||
python:
|
||||
path: ./qqbotpy.py
|
||||
attr: OfficialAdapter
|
||||
BIN
src/langbot/pkg/platform/sources/line.png
Normal file
|
After Width: | Height: | Size: 970 KiB |
252
src/langbot/pkg/platform/sources/line.py
Normal file
@@ -0,0 +1,252 @@
|
||||
import typing
|
||||
import quart
|
||||
|
||||
|
||||
import traceback
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
from ..logger import EventLogger
|
||||
|
||||
|
||||
from linebot.v3 import WebhookHandler
|
||||
from linebot.v3.exceptions import InvalidSignatureError
|
||||
from linebot.v3.messaging import Configuration, ApiClient, MessagingApi, ReplyMessageRequest, TextMessage, ImageMessage
|
||||
from linebot.v3.webhooks import (
|
||||
MessageEvent,
|
||||
TextMessageContent,
|
||||
ImageMessageContent,
|
||||
VideoMessageContent,
|
||||
AudioMessageContent,
|
||||
)
|
||||
|
||||
# from linebot import WebhookParser
|
||||
from linebot.v3.webhook import WebhookParser
|
||||
from linebot.v3.messaging import MessagingApiBlob
|
||||
|
||||
|
||||
class LINEMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain, api_client: ApiClient) -> typing.Tuple[list]:
|
||||
content_list = []
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.At):
|
||||
content_list.append({'type': 'at', 'target': component.target})
|
||||
elif isinstance(component, platform_message.Plain):
|
||||
content_list.append({'type': 'text', 'content': component.text})
|
||||
elif isinstance(component, platform_message.Image):
|
||||
if not component.url:
|
||||
pass
|
||||
content_list.append({'type': 'image', 'image': component.url})
|
||||
|
||||
elif isinstance(component, platform_message.Voice):
|
||||
content_list.append({'type': 'voice', 'url': component.url, 'length': component.length})
|
||||
|
||||
return content_list
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message, bot_client) -> platform_message.MessageChain:
|
||||
lb_msg_list = []
|
||||
msg_create_time = datetime.datetime.fromtimestamp(int(message.timestamp) / 1000)
|
||||
|
||||
lb_msg_list.append(platform_message.Source(id=message.webhook_event_id, time=msg_create_time))
|
||||
|
||||
if isinstance(message.message, TextMessageContent):
|
||||
lb_msg_list.append(platform_message.Plain(text=message.message.text))
|
||||
elif isinstance(message.message, AudioMessageContent):
|
||||
pass
|
||||
elif isinstance(message.message, VideoMessageContent):
|
||||
pass
|
||||
elif isinstance(message.message, ImageMessageContent):
|
||||
message_content = MessagingApiBlob(bot_client).get_message_content(message.message.id)
|
||||
|
||||
base64_string = base64.b64encode(message_content).decode('utf-8')
|
||||
|
||||
# 如果需要Data URI格式(用于直接嵌入HTML等)
|
||||
# 首先需要知道图片类型,LINE图片通常是JPEG
|
||||
data_uri = f'data:image/jpeg;base64,{base64_string}'
|
||||
lb_msg_list.append(platform_message.Image(base64=data_uri))
|
||||
return platform_message.MessageChain(lb_msg_list)
|
||||
|
||||
|
||||
class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(
|
||||
event: platform_events.MessageEvent,
|
||||
) -> MessageEvent:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event, bot_client) -> platform_events.Event:
|
||||
message_chain = await LINEMessageConverter.target2yiri(event, bot_client)
|
||||
|
||||
if event.source.type == 'user':
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=event.message.id,
|
||||
nickname=event.source.user_id,
|
||||
remark='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event.timestamp,
|
||||
source_platform_object=event,
|
||||
)
|
||||
else:
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=event.event.sender.sender_id.open_id,
|
||||
member_name=event.event.sender.sender_id.union_id,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=event.message.id,
|
||||
name='',
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event.timestamp,
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
|
||||
class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: MessagingApi
|
||||
api_client: ApiClient
|
||||
|
||||
bot_account_id: str # 用于在流水线中识别at是否是本bot,直接以bot_name作为标识
|
||||
message_converter: LINEMessageConverter
|
||||
event_converter: LINEEventConverter
|
||||
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
]
|
||||
|
||||
config: dict
|
||||
quart_app: quart.Quart
|
||||
|
||||
card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片
|
||||
|
||||
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
configuration = Configuration(access_token=config['channel_access_token'])
|
||||
line_webhook = WebhookHandler(config['channel_secret'])
|
||||
parser = WebhookParser(config['channel_secret'])
|
||||
api_client = ApiClient(configuration)
|
||||
|
||||
bot_account_id = config.get('bot_account_id', 'langbot')
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
quart_app=quart.Quart(__name__),
|
||||
listeners={},
|
||||
card_id_dict={},
|
||||
seq=1,
|
||||
event_converter=LINEEventConverter(),
|
||||
message_converter=LINEMessageConverter(),
|
||||
line_webhook=line_webhook,
|
||||
parser=parser,
|
||||
configuration=configuration,
|
||||
api_client=api_client,
|
||||
bot=MessagingApi(api_client),
|
||||
bot_account_id=bot_account_id,
|
||||
)
|
||||
|
||||
@self.quart_app.route('/line/callback', methods=['POST'])
|
||||
async def line_callback():
|
||||
try:
|
||||
signature = quart.request.headers.get('X-Line-Signature')
|
||||
body = await quart.request.get_data(as_text=True)
|
||||
events = parser.parse(body, signature) # 解密解析消息
|
||||
|
||||
try:
|
||||
# print(events)
|
||||
lb_event = await self.event_converter.target2yiri(events[0], self.api_client)
|
||||
if lb_event.__class__ in self.listeners:
|
||||
await self.listeners[lb_event.__class__](lb_event, self)
|
||||
except InvalidSignatureError:
|
||||
self.logger.info(
|
||||
f'Invalid signature. Please check your channel access token/channel secret.{traceback.format_exc()}'
|
||||
)
|
||||
return quart.Response('Invalid signature', status=400)
|
||||
|
||||
return {'code': 200, 'message': 'ok'}
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in LINE callback: {traceback.format_exc()}')
|
||||
return {'code': 500, 'message': 'error'}
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
pass
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
content_list = await self.message_converter.yiri2target(message, self.api_client)
|
||||
|
||||
for content in content_list:
|
||||
if content['type'] == 'text':
|
||||
self.bot.reply_message_with_http_info(
|
||||
ReplyMessageRequest(
|
||||
reply_token=message_source.source_platform_object.reply_token,
|
||||
messages=[TextMessage(text=content['content'])],
|
||||
)
|
||||
)
|
||||
elif content['type'] == 'image':
|
||||
self.bot.reply_message_with_http_info(
|
||||
ReplyMessageRequest(
|
||||
reply_token=message_source.source_platform_object.reply_token,
|
||||
messages=[ImageMessage(text=content['content'])],
|
||||
)
|
||||
)
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
return False
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners.pop(event_type)
|
||||
|
||||
async def run_async(self):
|
||||
port = self.config['port']
|
||||
|
||||
async def shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self.quart_app.run_task(
|
||||
host='0.0.0.0',
|
||||
port=port,
|
||||
shutdown_trigger=shutdown_trigger_placeholder,
|
||||
)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
pass
|
||||
54
src/langbot/pkg/platform/sources/line.yaml
Normal file
@@ -0,0 +1,54 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: LINE
|
||||
label:
|
||||
en_US: LINE
|
||||
zh_Hans: LINE
|
||||
description:
|
||||
en_US: LINE Adapter
|
||||
zh_Hans: LINE适配器,请查看文档了解使用方式
|
||||
ja_JP: LINEアダプター、ドキュメントを参照してください
|
||||
zh_Hant: LINE適配器,請查看文檔了解使用方式
|
||||
icon: line.png
|
||||
spec:
|
||||
config:
|
||||
- name: channel_access_token
|
||||
label:
|
||||
en_US: Channel access token
|
||||
zh_Hans: 频道访问令牌
|
||||
ja_JP: チャンネルアクセストークン
|
||||
zh_Hant: 頻道訪問令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: port
|
||||
label:
|
||||
en_US: Webhook Port
|
||||
zh_Hans: Webhook端口
|
||||
description:
|
||||
en_US: Only valid when webhook mode is enabled, please fill in the webhook port
|
||||
zh_Hans: 请填写 Webhook 端口
|
||||
ja_JP: Webhookポートを入力してください
|
||||
zh_Hant: 請填寫 Webhook 端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2287
|
||||
- name: channel_secret
|
||||
label:
|
||||
en_US: Channel secret
|
||||
zh_Hans: 消息密钥
|
||||
ja_JP: チャンネルシークレット
|
||||
zh_Hant: 消息密钥
|
||||
description:
|
||||
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
||||
zh_Hans: 请填写加密密钥
|
||||
ja_JP: Webhookモードが有効な場合にのみ、暗号化キーを入力してください
|
||||
zh_Hant: 請填寫加密密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./line.py
|
||||
attr: LINEAdapter
|
||||
BIN
src/langbot/pkg/platform/sources/officialaccount.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
159
src/langbot/pkg/platform/sources/officialaccount.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
import asyncio
|
||||
import traceback
|
||||
import pydantic
|
||||
import datetime
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from libs.official_account_api.oaevent import OAEvent
|
||||
from libs.official_account_api.api import OAClient
|
||||
from libs.official_account_api.api import OAClientForLongerResponse
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
from ..logger import EventLogger
|
||||
|
||||
|
||||
class OAMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain):
|
||||
for msg in message_chain:
|
||||
if type(msg) is platform_message.Plain:
|
||||
return msg.text
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message: str, message_id=-1):
|
||||
yiri_msg_list = []
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
|
||||
yiri_msg_list.append(platform_message.Plain(text=message))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
|
||||
|
||||
class OAEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def target2yiri(event: OAEvent):
|
||||
if event.type == 'text':
|
||||
yiri_chain = await OAMessageConverter.target2yiri(event.message, event.message_id)
|
||||
|
||||
friend = platform_entities.Friend(
|
||||
id=event.user_id,
|
||||
nickname=str(event.user_id),
|
||||
remark='',
|
||||
)
|
||||
|
||||
return platform_events.FriendMessage(
|
||||
sender=friend,
|
||||
message_chain=yiri_chain,
|
||||
time=event.timestamp,
|
||||
source_platform_object=event,
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
message_converter: OAMessageConverter = OAMessageConverter()
|
||||
event_converter: OAEventConverter = OAEventConverter()
|
||||
bot: typing.Union[OAClient, OAClientForLongerResponse] = pydantic.Field(exclude=True)
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
required_keys = ['token', 'EncodingAESKey', 'AppSecret', 'AppID', 'Mode']
|
||||
missing_keys = [k for k in required_keys if k not in config]
|
||||
if missing_keys:
|
||||
raise Exception(f'OfficialAccount 缺少配置项: {missing_keys}')
|
||||
|
||||
if config['Mode'] == 'drop':
|
||||
bot = OAClient(
|
||||
token=config['token'],
|
||||
EncodingAESKey=config['EncodingAESKey'],
|
||||
Appsecret=config['AppSecret'],
|
||||
AppID=config['AppID'],
|
||||
logger=logger,
|
||||
)
|
||||
elif config['Mode'] == 'passive':
|
||||
bot = OAClientForLongerResponse(
|
||||
token=config['token'],
|
||||
EncodingAESKey=config['EncodingAESKey'],
|
||||
Appsecret=config['AppSecret'],
|
||||
AppID=config['AppID'],
|
||||
LoadingMessage=config.get('LoadingMessage', ''),
|
||||
logger=logger,
|
||||
)
|
||||
else:
|
||||
raise KeyError('请设置微信公众号通信模式')
|
||||
|
||||
bot_account_id = config.get('AppID', '')
|
||||
|
||||
super().__init__(
|
||||
bot=bot,
|
||||
bot_account_id=bot_account_id,
|
||||
config=config,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.FriendMessage,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
content = await OAMessageConverter.yiri2target(message)
|
||||
if isinstance(self.bot, OAClient):
|
||||
await self.bot.set_message(message_source.message_chain.message_id, content)
|
||||
elif isinstance(self.bot, OAClientForLongerResponse):
|
||||
from_user = message_source.sender.id
|
||||
await self.bot.set_message(from_user, message_source.message_chain.message_id, content)
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
pass
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: type,
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
async def on_message(event: OAEvent):
|
||||
self.bot_account_id = event.receiver_id
|
||||
try:
|
||||
return await callback(await self.event_converter.target2yiri(event), self)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in officialaccount callback: {traceback.format_exc()}')
|
||||
|
||||
if event_type == platform_events.FriendMessage:
|
||||
self.bot.on_message('text')(on_message)
|
||||
elif event_type == platform_events.GroupMessage:
|
||||
pass
|
||||
|
||||
async def run_async(self):
|
||||
async def shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self.bot.run_task(
|
||||
host=self.config['host'],
|
||||
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, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
return super().unregister_listener(event_type, callback)
|
||||
|
||||
async def is_muted(
|
||||
self,
|
||||
group_id: str,
|
||||
) -> bool:
|
||||
pass
|
||||
76
src/langbot/pkg/platform/sources/officialaccount.yaml
Normal file
@@ -0,0 +1,76 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: officialaccount
|
||||
label:
|
||||
en_US: Official Account
|
||||
zh_Hans: 微信公众号
|
||||
description:
|
||||
en_US: Official Account Adapter
|
||||
zh_Hans: 微信公众号适配器,请查看文档了解使用方式
|
||||
icon: officialaccount.png
|
||||
spec:
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: EncodingAESKey
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: AppID
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: AppSecret
|
||||
label:
|
||||
en_US: App Secret
|
||||
zh_Hans: 应用密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: Mode
|
||||
label:
|
||||
en_US: Mode
|
||||
zh_Hans: 接入模式
|
||||
type: string
|
||||
required: true
|
||||
default: "drop"
|
||||
- name: LoadingMessage
|
||||
label:
|
||||
en_US: Loading Message
|
||||
zh_Hans: 加载消息
|
||||
type: string
|
||||
required: true
|
||||
default: "AI正在思考中,请发送任意内容获取回复。"
|
||||
- name: host
|
||||
label:
|
||||
en_US: Host
|
||||
zh_Hans: 监听主机
|
||||
description:
|
||||
en_US: The host that Official Account listens on for Webhook connections.
|
||||
zh_Hans: 微信公众号监听的主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
||||
type: string
|
||||
required: true
|
||||
default: 0.0.0.0
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 监听端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2287
|
||||
execution:
|
||||
python:
|
||||
path: ./officialaccount.py
|
||||
attr: OfficialAccountAdapter
|
||||
BIN
src/langbot/pkg/platform/sources/onebot.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
247
src/langbot/pkg/platform/sources/qqofficial.py
Normal file
@@ -0,0 +1,247 @@
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
import asyncio
|
||||
import traceback
|
||||
|
||||
import datetime
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
from libs.qq_official_api.api import QQOfficialClient
|
||||
from libs.qq_official_api.qqofficialevent import QQOfficialEvent
|
||||
from ...utils import image
|
||||
from ..logger import EventLogger
|
||||
|
||||
|
||||
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@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(
|
||||
{
|
||||
'type': 'text',
|
||||
'content': msg.text,
|
||||
}
|
||||
)
|
||||
|
||||
return content_list
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message: str, message_id: str, pic_url: str, content_type):
|
||||
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_qq_official_image_base64(pic_url=pic_url, content_type=content_type)
|
||||
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 QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent) -> QQOfficialEvent:
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: QQOfficialEvent):
|
||||
"""
|
||||
QQ官方消息转换为LB对象
|
||||
"""
|
||||
yiri_chain = await QQOfficialMessageConverter.target2yiri(
|
||||
message=event.content,
|
||||
message_id=event.d_id,
|
||||
pic_url=event.attachments,
|
||||
content_type=event.content_type,
|
||||
)
|
||||
|
||||
if event.t == 'C2C_MESSAGE_CREATE':
|
||||
friend = platform_entities.Friend(
|
||||
id=event.user_openid,
|
||||
nickname=event.t,
|
||||
remark='',
|
||||
)
|
||||
return platform_events.FriendMessage(
|
||||
sender=friend,
|
||||
message_chain=yiri_chain,
|
||||
time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
if event.t == 'DIRECT_MESSAGE_CREATE':
|
||||
friend = platform_entities.Friend(
|
||||
id=event.guild_id,
|
||||
nickname=event.t,
|
||||
remark='',
|
||||
)
|
||||
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, source_platform_object=event)
|
||||
if event.t == 'GROUP_AT_MESSAGE_CREATE':
|
||||
yiri_chain.insert(0, platform_message.At(target='justbot'))
|
||||
|
||||
sender = platform_entities.GroupMember(
|
||||
id=event.group_openid,
|
||||
member_name=event.t,
|
||||
permission='MEMBER',
|
||||
group=platform_entities.Group(
|
||||
id=event.group_openid,
|
||||
name='MEMBER',
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
)
|
||||
time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp())
|
||||
return platform_events.GroupMessage(
|
||||
sender=sender,
|
||||
message_chain=yiri_chain,
|
||||
time=time,
|
||||
source_platform_object=event,
|
||||
)
|
||||
if event.t == 'AT_MESSAGE_CREATE':
|
||||
yiri_chain.insert(0, platform_message.At(target='justbot'))
|
||||
sender = platform_entities.GroupMember(
|
||||
id=event.channel_id,
|
||||
member_name=event.t,
|
||||
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.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp())
|
||||
return platform_events.GroupMessage(
|
||||
sender=sender,
|
||||
message_chain=yiri_chain,
|
||||
time=time,
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
|
||||
class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: QQOfficialClient
|
||||
config: dict
|
||||
bot_account_id: str
|
||||
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
||||
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
bot = QQOfficialClient(app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger)
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot=bot,
|
||||
bot_account_id=config['appid'],
|
||||
)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
qq_official_event = await QQOfficialEventConverter.yiri2target(
|
||||
message_source,
|
||||
)
|
||||
|
||||
content_list = await QQOfficialMessageConverter.yiri2target(message)
|
||||
|
||||
# 私聊消息
|
||||
if qq_official_event.t == 'C2C_MESSAGE_CREATE':
|
||||
for content in content_list:
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_private_text_msg(
|
||||
qq_official_event.user_openid,
|
||||
content['content'],
|
||||
qq_official_event.d_id,
|
||||
)
|
||||
|
||||
# 群聊消息
|
||||
if qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':
|
||||
for content in content_list:
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_group_text_msg(
|
||||
qq_official_event.group_openid,
|
||||
content['content'],
|
||||
qq_official_event.d_id,
|
||||
)
|
||||
|
||||
# 频道群聊
|
||||
if qq_official_event.t == 'AT_MESSAGE_CREATE':
|
||||
for content in content_list:
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_channle_group_text_msg(
|
||||
qq_official_event.channel_id,
|
||||
content['content'],
|
||||
qq_official_event.d_id,
|
||||
)
|
||||
|
||||
# 频道私聊
|
||||
if qq_official_event.t == 'DIRECT_MESSAGE_CREATE':
|
||||
for content in content_list:
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_channle_private_text_msg(
|
||||
qq_official_event.guild_id,
|
||||
content['content'],
|
||||
qq_official_event.d_id,
|
||||
)
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
pass
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
async def on_message(event: QQOfficialEvent):
|
||||
self.bot_account_id = 'justbot'
|
||||
try:
|
||||
return await callback(await self.event_converter.target2yiri(event), self)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in qqofficial callback: {traceback.format_exc()}')
|
||||
|
||||
if event_type == platform_events.FriendMessage:
|
||||
self.bot.on_message('DIRECT_MESSAGE_CREATE')(on_message)
|
||||
self.bot.on_message('C2C_MESSAGE_CREATE')(on_message)
|
||||
elif event_type == platform_events.GroupMessage:
|
||||
self.bot.on_message('GROUP_AT_MESSAGE_CREATE')(on_message)
|
||||
self.bot.on_message('AT_MESSAGE_CREATE')(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
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: type,
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
return super().unregister_listener(event_type, callback)
|
||||
1
src/langbot/pkg/platform/sources/qqofficial.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><path fill="#FFC107" d="M17.5,44c-3.6,0-6.5-1.6-6.5-3.5s2.9-3.5,6.5-3.5s6.5,1.6,6.5,3.5S21.1,44,17.5,44z M37,40.5c0-1.9-2.9-3.5-6.5-3.5S24,38.6,24,40.5s2.9,3.5,6.5,3.5S37,42.4,37,40.5z"/><path fill="#37474F" d="M37.2,22.2c-0.1-0.3-0.2-0.6-0.3-1c0.1-0.5,0.1-1,0.1-1.5c0-1.4-0.1-2.6-0.1-3.6C36.9,9.4,31.1,4,24,4S11,9.4,11,16.1c0,0.9,0,2.2,0,3.6c0,0.5,0,1,0.1,1.5c-0.1,0.3-0.2,0.6-0.3,1c-1.9,2.7-3.8,6-3.8,8.5C7,35.5,8.4,35,8.4,35c0.6,0,1.6-1,2.5-2.1C13,38.8,18,43,24,43s11-4.2,13.1-10.1C38,34,39,35,39.6,35c0,0,1.4,0.5,1.4-4.3C41,28.2,39.1,24.8,37.2,22.2z"/><path fill="#ECEFF1" d="M14.7,23c-0.5,1.5-0.7,3.1-0.7,4.8C14,35.1,18.5,41,24,41s10-5.9,10-13.2c0-1.7-0.3-3.3-0.7-4.8H14.7z"/><path fill="#FFF" d="M23,13.5c0,1.9-1.1,3.5-2.5,3.5S18,15.4,18,13.5s1.1-3.5,2.5-3.5S23,11.6,23,13.5z M27.5,10c-1.4,0-2.5,1.6-2.5,3.5s1.1,3.5,2.5,3.5s2.5-1.6,2.5-3.5S28.9,10,27.5,10z"/><path fill="#37474F" d="M22,13.5c0,0.8-0.4,1.5-1,1.5s-1-0.7-1-1.5s0.4-1.5,1-1.5S22,12.7,22,13.5z M27,12c-0.6,0-1,0.7-1,1.5s0.4-0.5,1-0.5s1,1.3,1,0.5S27.6,12,27,12z"/><path fill="#FFC107" d="M32,19.5c0,0.8-3.6,2.5-8,2.5s-8-1.7-8-2.5s3.6-1.5,8-1.5S32,18.7,32,19.5z"/><path fill="#FF3D00" d="M38.7,21.2c-0.4-1.5-1-2.2-2.1-1.3c0,0-5.9,3.1-12.5,3.1v0.1l0-0.1c-6.6,0-12.5-3.1-12.5-3.1c-1.1-0.8-1.7-0.2-2.1,1.3c-0.4,1.5-0.7,2,0.7,2.8c0.1,0.1,1.4,0.8,3.4,1.7c-0.6,3.5-0.5,6.8-0.5,7c0.1,1.5,1.3,1.3,2.9,1.3c1.6-0.1,2.9,0,2.9-1.6c0-0.9,0-2.9,0.3-5c1.6,0.3,3.2,0.6,5,0.6l0,0v0c7.3,0,13.7-3.9,13.9-4C39.3,23.3,39,22.8,38.7,21.2z"/><path fill="#DD2C00" d="M13.2,27.7c1.6,0.6,3.5,1.3,5.6,1.7c0-0.6,0.1-1.3,0.2-2c-2.1-0.5-4-1.1-5.5-1.7C13.4,26.4,13.3,27.1,13.2,27.7z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
45
src/langbot/pkg/platform/sources/qqofficial.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: qqofficial
|
||||
label:
|
||||
en_US: QQ Official API
|
||||
zh_Hans: QQ 官方 API
|
||||
description:
|
||||
en_US: QQ Official API (Webhook)
|
||||
zh_Hans: QQ 官方 API (Webhook),请查看文档了解使用方式
|
||||
icon: qqofficial.svg
|
||||
spec:
|
||||
config:
|
||||
- name: appid
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: secret
|
||||
label:
|
||||
en_US: Secret
|
||||
zh_Hans: 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 监听端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2284
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./qqofficial.py
|
||||
attr: QQOfficialAdapter
|
||||
BIN
src/langbot/pkg/platform/sources/slack.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
172
src/langbot/pkg/platform/sources/slack.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
import asyncio
|
||||
import traceback
|
||||
|
||||
import datetime
|
||||
|
||||
from libs.slack_api.api import SlackClient
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from libs.slack_api.slackevent import SlackEvent
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
from langbot_plugin.api.entities.builtin.command import errors as command_errors
|
||||
from ...utils import image
|
||||
from ..logger import EventLogger
|
||||
|
||||
|
||||
class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@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(abstract_platform_adapter.AbstractEventConverter):
|
||||
@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(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: SlackClient
|
||||
bot_account_id: str
|
||||
message_converter: SlackMessageConverter = SlackMessageConverter()
|
||||
event_converter: SlackEventConverter = SlackEventConverter()
|
||||
config: dict
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
self.config = config
|
||||
self.logger = logger
|
||||
required_keys = [
|
||||
'bot_token',
|
||||
'signing_secret',
|
||||
]
|
||||
missing_keys = [key for key in required_keys if key not in config]
|
||||
if missing_keys:
|
||||
raise command_errors.ParamNotEnoughError('Slack机器人缺少相关配置项,请查看文档或联系管理员')
|
||||
|
||||
self.bot = SlackClient(
|
||||
bot_token=self.config['bot_token'], signing_secret=self.config['signing_secret'], logger=self.logger
|
||||
)
|
||||
|
||||
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, abstract_platform_adapter.AbstractMessagePlatformAdapter], 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 Exception:
|
||||
await self.logger.error(f'Error in slack callback: {traceback.format_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, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
return super().unregister_listener(event_type, callback)
|
||||
38
src/langbot/pkg/platform/sources/slack.yaml
Normal file
@@ -0,0 +1,38 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: slack
|
||||
label:
|
||||
en_US: Slack
|
||||
zh_Hans: Slack
|
||||
description:
|
||||
en_US: Slack Adapter
|
||||
zh_Hans: Slack 适配器,请查看文档了解使用方式
|
||||
icon: slack.png
|
||||
spec:
|
||||
config:
|
||||
- name: bot_token
|
||||
label:
|
||||
en_US: Bot Token
|
||||
zh_Hans: 机器人令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: signing_secret
|
||||
label:
|
||||
en_US: signing_secret
|
||||
zh_Hans: 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 监听端口
|
||||
type: int
|
||||
required: true
|
||||
default: 2288
|
||||
execution:
|
||||
python:
|
||||
path: ./slack.py
|
||||
attr: SlackAdapter
|
||||
306
src/langbot/pkg/platform/sources/telegram.py
Normal file
@@ -0,0 +1,306 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import telegram
|
||||
import telegram.ext
|
||||
from telegram import Update
|
||||
from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters
|
||||
import telegramify_markdown
|
||||
import typing
|
||||
import traceback
|
||||
import base64
|
||||
import aiohttp
|
||||
import pydantic
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
|
||||
|
||||
class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain, bot: telegram.Bot) -> list[dict]:
|
||||
components = []
|
||||
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
components.append({'type': 'text', 'text': component.text})
|
||||
elif isinstance(component, platform_message.Image):
|
||||
photo_bytes = None
|
||||
|
||||
if component.base64:
|
||||
photo_bytes = base64.b64decode(component.base64)
|
||||
elif component.url:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(component.url) as response:
|
||||
photo_bytes = await response.read()
|
||||
elif component.path:
|
||||
with open(component.path, 'rb') as f:
|
||||
photo_bytes = f.read()
|
||||
|
||||
components.append({'type': 'photo', 'photo': photo_bytes})
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
components.extend(await TelegramMessageConverter.yiri2target(node.message_chain, bot))
|
||||
|
||||
return components
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message: telegram.Message, bot: telegram.Bot, bot_account_id: str):
|
||||
message_components = []
|
||||
|
||||
def parse_message_text(text: str) -> list[platform_message.MessageComponent]:
|
||||
msg_components = []
|
||||
|
||||
if f'@{bot_account_id}' in text:
|
||||
msg_components.append(platform_message.At(target=bot_account_id))
|
||||
text = text.replace(f'@{bot_account_id}', '')
|
||||
msg_components.append(platform_message.Plain(text=text))
|
||||
|
||||
return msg_components
|
||||
|
||||
if message.text:
|
||||
message_text = message.text
|
||||
message_components.extend(parse_message_text(message_text))
|
||||
|
||||
if message.photo:
|
||||
if message.caption:
|
||||
message_components.extend(parse_message_text(message.caption))
|
||||
|
||||
file = await message.photo[-1].get_file()
|
||||
|
||||
file_bytes = None
|
||||
file_format = ''
|
||||
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.get(file.file_path) as response:
|
||||
file_bytes = await response.read()
|
||||
file_format = 'image/jpeg'
|
||||
|
||||
message_components.append(
|
||||
platform_message.Image(
|
||||
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}'
|
||||
)
|
||||
)
|
||||
|
||||
return platform_message.MessageChain(message_components)
|
||||
|
||||
|
||||
class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent, bot: telegram.Bot):
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: Update, bot: telegram.Bot, bot_account_id: str):
|
||||
lb_message = await TelegramMessageConverter.target2yiri(event.message, bot, bot_account_id)
|
||||
|
||||
if event.effective_chat.type == 'private':
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=event.effective_chat.id,
|
||||
nickname=event.effective_chat.first_name,
|
||||
remark=str(event.effective_chat.id),
|
||||
),
|
||||
message_chain=lb_message,
|
||||
time=event.message.date.timestamp(),
|
||||
source_platform_object=event,
|
||||
)
|
||||
elif event.effective_chat.type == 'group' or 'supergroup':
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=event.effective_chat.id,
|
||||
member_name=event.effective_chat.title,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=event.effective_chat.id,
|
||||
name=event.effective_chat.title,
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=lb_message,
|
||||
time=event.message.date.timestamp(),
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
|
||||
class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: telegram.Bot = pydantic.Field(exclude=True)
|
||||
application: telegram.ext.Application = pydantic.Field(exclude=True)
|
||||
|
||||
message_converter: TelegramMessageConverter = TelegramMessageConverter()
|
||||
event_converter: TelegramEventConverter = TelegramEventConverter()
|
||||
|
||||
config: dict
|
||||
|
||||
msg_stream_id: dict # 流式消息id字典,key为流式消息id,value为首次消息源id,用于在流式消息时判断编辑那条消息
|
||||
|
||||
seq: int # 消息中识别消息顺序,直接以seq作为标识
|
||||
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
async def telegram_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
if update.message.from_user.is_bot:
|
||||
return
|
||||
|
||||
try:
|
||||
lb_event = await self.event_converter.target2yiri(update, self.bot, self.bot_account_id)
|
||||
await self.listeners[type(lb_event)](lb_event, self)
|
||||
await self.is_stream_output_supported()
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in telegram callback: {traceback.format_exc()}')
|
||||
|
||||
application = ApplicationBuilder().token(config['token']).build()
|
||||
bot = application.bot
|
||||
application.add_handler(MessageHandler(filters.TEXT | (filters.COMMAND) | filters.PHOTO, telegram_callback))
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
msg_stream_id={},
|
||||
seq=1,
|
||||
bot=bot,
|
||||
application=application,
|
||||
bot_account_id='',
|
||||
listeners={},
|
||||
)
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
pass
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
assert isinstance(message_source.source_platform_object, Update)
|
||||
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
||||
|
||||
for component in components:
|
||||
if component['type'] == 'text':
|
||||
if self.config['markdown_card'] is True:
|
||||
content = telegramify_markdown.markdownify(
|
||||
content=component['text'],
|
||||
)
|
||||
else:
|
||||
content = component['text']
|
||||
args = {
|
||||
'chat_id': message_source.source_platform_object.effective_chat.id,
|
||||
'text': content,
|
||||
}
|
||||
if self.config['markdown_card'] is True:
|
||||
args['parse_mode'] = 'MarkdownV2'
|
||||
if quote_origin:
|
||||
args['reply_to_message_id'] = message_source.source_platform_object.message.id
|
||||
|
||||
await self.bot.send_message(**args)
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
msg_seq = bot_message.msg_sequence
|
||||
if (msg_seq - 1) % 8 == 0 or is_final:
|
||||
assert isinstance(message_source.source_platform_object, Update)
|
||||
components = await TelegramMessageConverter.yiri2target(message, self.bot)
|
||||
args = {}
|
||||
message_id = message_source.source_platform_object.message.id
|
||||
if quote_origin:
|
||||
args['reply_to_message_id'] = message_source.source_platform_object.message.id
|
||||
|
||||
component = components[0]
|
||||
if message_id not in self.msg_stream_id: # 当消息回复第一次时,发送新消息
|
||||
# time.sleep(0.6)
|
||||
if component['type'] == 'text':
|
||||
if self.config['markdown_card'] is True:
|
||||
content = telegramify_markdown.markdownify(
|
||||
content=component['text'],
|
||||
)
|
||||
else:
|
||||
content = component['text']
|
||||
args = {
|
||||
'chat_id': message_source.source_platform_object.effective_chat.id,
|
||||
'text': content,
|
||||
}
|
||||
if self.config['markdown_card'] is True:
|
||||
args['parse_mode'] = 'MarkdownV2'
|
||||
|
||||
send_msg = await self.bot.send_message(**args)
|
||||
send_msg_id = send_msg.message_id
|
||||
self.msg_stream_id[message_id] = send_msg_id
|
||||
else: # 存在消息的时候直接编辑消息1
|
||||
if component['type'] == 'text':
|
||||
if self.config['markdown_card'] is True:
|
||||
content = telegramify_markdown.markdownify(
|
||||
content=component['text'],
|
||||
)
|
||||
else:
|
||||
content = component['text']
|
||||
args = {
|
||||
'message_id': self.msg_stream_id[message_id],
|
||||
'chat_id': message_source.source_platform_object.effective_chat.id,
|
||||
'text': content,
|
||||
}
|
||||
if self.config['markdown_card'] is True:
|
||||
args['parse_mode'] = 'MarkdownV2'
|
||||
|
||||
await self.bot.edit_message_text(**args)
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
# self.seq = 1 # 消息回复结束之后重置seq
|
||||
self.msg_stream_id.pop(message_id) # 消息回复结束之后删除流式消息id
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
is_stream = False
|
||||
if self.config.get('enable-stream-reply', None):
|
||||
is_stream = True
|
||||
return is_stream
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
return False
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners.pop(event_type)
|
||||
|
||||
async def run_async(self):
|
||||
await self.application.initialize()
|
||||
self.bot_account_id = (await self.bot.get_me()).username
|
||||
await self.application.updater.start_polling(allowed_updates=Update.ALL_TYPES)
|
||||
await self.application.start()
|
||||
await self.logger.info('Telegram adapter running')
|
||||
|
||||
async def kill(self) -> bool:
|
||||
if self.application.running:
|
||||
await self.application.stop()
|
||||
if self.application.updater:
|
||||
await self.application.updater.stop()
|
||||
await self.logger.info('Telegram adapter stopped')
|
||||
return True
|
||||
1
src/langbot/pkg/platform/sources/telegram.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><path fill="#29b6f6" d="M24,4C13,4,4,13,4,24s9,20,20,20s20-9,20-20S35,4,24,4z"/><path fill="#fff" d="M34,15l-3.7,19.1c0,0-0.2,0.9-1.2,0.9c-0.6,0-0.9-0.3-0.9-0.3L20,28l-4-2l-5.1-1.4c0,0-0.9-0.3-0.9-1 c0-0.6,0.9-0.9,0.9-0.9l21.3-8.5c0,0,0.7-0.2,1.1-0.2c0.3,0,0.6,0.1,0.6,0.5C34,14.8,34,15,34,15z"/><path fill="#b0bec5" d="M23,30.5l-3.4,3.4c0,0-0.1,0.1-0.3,0.1c-0.1,0-0.1,0-0.2,0l1-6L23,30.5z"/><path fill="#cfd8dc" d="M29.9,18.2c-0.2-0.2-0.5-0.3-0.7-0.1L16,26c0,0,2.1,5.9,2.4,6.9c0.3,1,0.6,1,0.6,1l1-6l9.8-9.1 C30,18.7,30.1,18.4,29.9,18.2z"/></svg>
|
||||
|
After Width: | Height: | Size: 634 B |
41
src/langbot/pkg/platform/sources/telegram.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: telegram
|
||||
label:
|
||||
en_US: Telegram
|
||||
zh_Hans: 电报
|
||||
description:
|
||||
en_US: Telegram Adapter
|
||||
zh_Hans: 电报适配器,请查看文档了解使用方式
|
||||
icon: telegram.svg
|
||||
spec:
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: markdown_card
|
||||
label:
|
||||
en_US: Markdown Card
|
||||
zh_Hans: 是否使用 Markdown 卡片
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
- name: enable-stream-reply
|
||||
label:
|
||||
en_US: Enable Stream Reply Mode
|
||||
zh_Hans: 启用电报流式回复模式
|
||||
description:
|
||||
en_US: If enabled, the bot will use the stream of telegram reply mode
|
||||
zh_Hans: 如果启用,将使用电报流式方式来回复内容
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
execution:
|
||||
python:
|
||||
path: ./telegram.py
|
||||
attr: TelegramAdapter
|
||||
304
src/langbot/pkg/platform/sources/webchat.py
Normal file
@@ -0,0 +1,304 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import typing
|
||||
from datetime import datetime
|
||||
|
||||
import pydantic
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from ...core import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebChatMessage(pydantic.BaseModel):
|
||||
id: int
|
||||
role: str
|
||||
content: str
|
||||
message_chain: list[dict]
|
||||
timestamp: str
|
||||
is_final: bool = False
|
||||
|
||||
|
||||
class WebChatSession:
|
||||
id: str
|
||||
message_lists: dict[str, list[WebChatMessage]] = {}
|
||||
resp_waiters: dict[int, asyncio.Future[WebChatMessage]]
|
||||
resp_queues: dict[int, asyncio.Queue[WebChatMessage]]
|
||||
|
||||
def __init__(self, id: str):
|
||||
self.id = id
|
||||
self.message_lists = {}
|
||||
self.resp_waiters = {}
|
||||
self.resp_queues = {}
|
||||
|
||||
def get_message_list(self, pipeline_uuid: str) -> list[WebChatMessage]:
|
||||
if pipeline_uuid not in self.message_lists:
|
||||
self.message_lists[pipeline_uuid] = []
|
||||
|
||||
return self.message_lists[pipeline_uuid]
|
||||
|
||||
|
||||
class WebChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""WebChat调试适配器,用于流水线调试"""
|
||||
|
||||
webchat_person_session: WebChatSession = pydantic.Field(exclude=True, default_factory=WebChatSession)
|
||||
webchat_group_session: WebChatSession = pydantic.Field(exclude=True, default_factory=WebChatSession)
|
||||
|
||||
listeners: dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = pydantic.Field(default_factory=dict, exclude=True)
|
||||
|
||||
is_stream: bool = pydantic.Field(exclude=True)
|
||||
debug_messages: dict[str, list[dict]] = pydantic.Field(default_factory=dict, exclude=True)
|
||||
|
||||
ap: app.Application = pydantic.Field(exclude=True)
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.webchat_person_session = WebChatSession(id='webchatperson')
|
||||
self.webchat_group_session = WebChatSession(id='webchatgroup')
|
||||
|
||||
self.bot_account_id = 'webchatbot'
|
||||
|
||||
self.debug_messages = {}
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message: platform_message.MessageChain,
|
||||
) -> dict:
|
||||
"""发送消息到调试会话"""
|
||||
session_key = target_id
|
||||
|
||||
if session_key not in self.debug_messages:
|
||||
self.debug_messages[session_key] = []
|
||||
|
||||
message_data = {
|
||||
'id': len(self.debug_messages[session_key]) + 1,
|
||||
'type': 'bot',
|
||||
'content': str(message),
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'message_chain': [component.__dict__ for component in message],
|
||||
}
|
||||
|
||||
self.debug_messages[session_key].append(message_data)
|
||||
|
||||
await self.logger.info(f'Send message to {session_key}: {message}')
|
||||
|
||||
return message_data
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
) -> dict:
|
||||
"""回复消息"""
|
||||
message_data = WebChatMessage(
|
||||
id=-1,
|
||||
role='assistant',
|
||||
content=str(message),
|
||||
message_chain=[component.__dict__ for component in message],
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
# notify waiter
|
||||
if isinstance(message_source, platform_events.FriendMessage):
|
||||
await self.webchat_person_session.resp_queues[message_source.message_chain.message_id].put(message_data)
|
||||
elif isinstance(message_source, platform_events.GroupMessage):
|
||||
await self.webchat_group_session.resp_queues[message_source.message_chain.message_id].put(message_data)
|
||||
|
||||
return message_data.model_dump()
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
) -> dict:
|
||||
"""回复消息"""
|
||||
message_data = WebChatMessage(
|
||||
id=-1,
|
||||
role='assistant',
|
||||
content=str(message),
|
||||
message_chain=[component.__dict__ for component in message],
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
|
||||
# notify waiter
|
||||
session = (
|
||||
self.webchat_group_session
|
||||
if isinstance(message_source, platform_events.GroupMessage)
|
||||
else self.webchat_person_session
|
||||
)
|
||||
if message_source.message_chain.message_id not in session.resp_waiters:
|
||||
# session.resp_waiters[message_source.message_chain.message_id] = asyncio.Queue()
|
||||
queue = session.resp_queues[message_source.message_chain.message_id]
|
||||
|
||||
# if isinstance(message_source, platform_events.FriendMessage):
|
||||
# queue = self.webchat_person_session.resp_queues[message_source.message_chain.message_id]
|
||||
# elif isinstance(message_source, platform_events.GroupMessage):
|
||||
# queue = self.webchat_group_session.resp_queues[message_source.message_chain.message_id]
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
message_data.is_final = True
|
||||
# print(message_data)
|
||||
await queue.put(message_data)
|
||||
|
||||
return message_data.model_dump()
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return self.is_stream
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
func: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
||||
],
|
||||
):
|
||||
"""注册事件监听器"""
|
||||
self.listeners[event_type] = func
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
func: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], typing.Awaitable[None]
|
||||
],
|
||||
):
|
||||
"""取消注册事件监听器"""
|
||||
del self.listeners[event_type]
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
return False
|
||||
|
||||
async def run_async(self):
|
||||
"""运行适配器"""
|
||||
await self.logger.info('WebChat调试适配器已启动')
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
await self.logger.info('WebChat调试适配器已停止')
|
||||
raise
|
||||
|
||||
async def kill(self):
|
||||
"""停止适配器"""
|
||||
await self.logger.info('WebChat调试适配器正在停止')
|
||||
|
||||
async def send_webchat_message(
|
||||
self,
|
||||
pipeline_uuid: str,
|
||||
session_type: str,
|
||||
message_chain_obj: typing.List[dict],
|
||||
is_stream: bool = False,
|
||||
) -> dict:
|
||||
self.is_stream = is_stream
|
||||
"""发送调试消息到流水线"""
|
||||
if session_type == 'person':
|
||||
use_session = self.webchat_person_session
|
||||
else:
|
||||
use_session = self.webchat_group_session
|
||||
|
||||
message_chain = platform_message.MessageChain.parse_obj(message_chain_obj)
|
||||
|
||||
message_id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
||||
|
||||
use_session.resp_queues[message_id] = asyncio.Queue()
|
||||
logger.debug(f'Initialized queue for message_id: {message_id}')
|
||||
|
||||
use_session.get_message_list(pipeline_uuid).append(
|
||||
WebChatMessage(
|
||||
id=message_id,
|
||||
role='user',
|
||||
content=str(message_chain),
|
||||
message_chain=message_chain_obj,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
)
|
||||
)
|
||||
|
||||
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))
|
||||
|
||||
if session_type == 'person':
|
||||
sender = platform_entities.Friend(id='webchatperson', nickname='User', remark='User')
|
||||
event = platform_events.FriendMessage(
|
||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||
)
|
||||
else:
|
||||
group = platform_entities.Group(
|
||||
id='webchatgroup', name='Group', permission=platform_entities.Permission.Member
|
||||
)
|
||||
sender = platform_entities.GroupMember(
|
||||
id='webchatperson',
|
||||
member_name='User',
|
||||
group=group,
|
||||
permission=platform_entities.Permission.Member,
|
||||
)
|
||||
event = platform_events.GroupMessage(
|
||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||
)
|
||||
|
||||
self.ap.platform_mgr.webchat_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
||||
|
||||
# trigger pipeline
|
||||
if event.__class__ in self.listeners:
|
||||
await self.listeners[event.__class__](event, self)
|
||||
|
||||
if is_stream:
|
||||
queue = use_session.resp_queues[message_id]
|
||||
msg_id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
||||
while True:
|
||||
resp_message = await queue.get()
|
||||
resp_message.id = msg_id
|
||||
if resp_message.is_final:
|
||||
resp_message.id = msg_id
|
||||
use_session.get_message_list(pipeline_uuid).append(resp_message)
|
||||
yield resp_message.model_dump()
|
||||
break
|
||||
yield resp_message.model_dump()
|
||||
use_session.resp_queues.pop(message_id)
|
||||
|
||||
else: # non-stream
|
||||
# set waiter
|
||||
# waiter = asyncio.Future[WebChatMessage]()
|
||||
# use_session.resp_waiters[message_id] = waiter
|
||||
# # waiter.add_done_callback(lambda future: use_session.resp_waiters.pop(message_id))
|
||||
#
|
||||
# resp_message = await waiter
|
||||
#
|
||||
# resp_message.id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
||||
#
|
||||
# use_session.get_message_list(pipeline_uuid).append(resp_message)
|
||||
#
|
||||
# yield resp_message.model_dump()
|
||||
msg_id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
||||
|
||||
queue = use_session.resp_queues[message_id]
|
||||
resp_message = await queue.get()
|
||||
use_session.get_message_list(pipeline_uuid).append(resp_message)
|
||||
resp_message.id = msg_id
|
||||
resp_message.is_final = True
|
||||
|
||||
yield resp_message.model_dump()
|
||||
|
||||
def get_webchat_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
|
||||
"""获取调试消息历史"""
|
||||
if session_type == 'person':
|
||||
return [message.model_dump() for message in self.webchat_person_session.get_message_list(pipeline_uuid)]
|
||||
else:
|
||||
return [message.model_dump() for message in self.webchat_group_session.get_message_list(pipeline_uuid)]
|
||||
17
src/langbot/pkg/platform/sources/webchat.yaml
Normal file
@@ -0,0 +1,17 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: webchat
|
||||
label:
|
||||
en_US: "WebChat Debug"
|
||||
zh_Hans: "网页聊天调试"
|
||||
description:
|
||||
en_US: "WebChat adapter for pipeline debugging"
|
||||
zh_Hans: "用于流水线调试的网页聊天适配器"
|
||||
icon: ""
|
||||
spec:
|
||||
config: []
|
||||
execution:
|
||||
python:
|
||||
path: "webchat.py"
|
||||
attr: "WebChatAdapter"
|
||||
BIN
src/langbot/pkg/platform/sources/wechatpad.png
Normal file
|
After Width: | Height: | Size: 466 KiB |
753
src/langbot/pkg/platform/sources/wechatpad.py
Normal file
@@ -0,0 +1,753 @@
|
||||
import requests
|
||||
import websocket
|
||||
import json
|
||||
import time
|
||||
import httpx
|
||||
|
||||
from libs.wechatpad_api.client import WeChatPadClient
|
||||
|
||||
import typing
|
||||
import asyncio
|
||||
import traceback
|
||||
import re
|
||||
import base64
|
||||
import copy
|
||||
import threading
|
||||
|
||||
import quart
|
||||
|
||||
from ..logger import EventLogger
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional, Tuple
|
||||
from functools import partial
|
||||
import logging
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
|
||||
|
||||
class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
self.bot = WeChatPadClient(config['wechatpad_url'], config['token'])
|
||||
self.config = config
|
||||
self.logger = logger
|
||||
|
||||
# super().__init__(
|
||||
# config = config,
|
||||
# bot = bot,
|
||||
# logger = logger,
|
||||
# )
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:
|
||||
content_list = []
|
||||
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.AtAll):
|
||||
content_list.append({'type': 'at', 'target': 'all'})
|
||||
elif isinstance(component, platform_message.At):
|
||||
content_list.append({'type': 'at', 'target': component.target})
|
||||
elif isinstance(component, platform_message.Plain):
|
||||
content_list.append({'type': 'text', 'content': component.text})
|
||||
elif isinstance(component, platform_message.Image):
|
||||
if component.url:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(component.url)
|
||||
|
||||
if response.status_code == 200:
|
||||
file_bytes = response.content
|
||||
base64_str = base64.b64encode(file_bytes).decode('utf-8') # 返回字符串格式
|
||||
else:
|
||||
raise Exception('获取文件失败')
|
||||
# pass
|
||||
content_list.append({'type': 'image', 'image': base64_str})
|
||||
elif component.base64:
|
||||
content_list.append({'type': 'image', 'image': component.base64})
|
||||
|
||||
elif isinstance(component, platform_message.WeChatEmoji):
|
||||
content_list.append(
|
||||
{'type': 'WeChatEmoji', 'emoji_md5': component.emoji_md5, 'emoji_size': component.emoji_size}
|
||||
)
|
||||
elif isinstance(component, platform_message.Voice):
|
||||
content_list.append({'type': 'voice', 'data': component.url, 'duration': component.length, 'forma': 0})
|
||||
elif isinstance(component, platform_message.WeChatAppMsg):
|
||||
content_list.append({'type': 'WeChatAppMsg', 'app_msg': component.app_msg})
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
if node.message_chain:
|
||||
content_list.extend(await WeChatPadMessageConverter.yiri2target(node.message_chain))
|
||||
|
||||
return content_list
|
||||
|
||||
async def target2yiri(
|
||||
self,
|
||||
message: dict,
|
||||
bot_account_id: str,
|
||||
) -> platform_message.MessageChain:
|
||||
"""外部消息转平台消息"""
|
||||
# 数据预处理
|
||||
message_list = []
|
||||
bot_wxid = self.config['wxid']
|
||||
ats_bot = False # 是否被@
|
||||
content = message['content']['str']
|
||||
content_no_preifx = content # 群消息则去掉前缀
|
||||
is_group_message = self._is_group_message(message)
|
||||
if is_group_message:
|
||||
ats_bot = self._ats_bot(message, bot_account_id)
|
||||
|
||||
self.logger.info(f'ats_bot: {ats_bot}; bot_account_id: {bot_account_id}; bot_wxid: {bot_wxid}')
|
||||
if '@所有人' in content:
|
||||
message_list.append(platform_message.AtAll())
|
||||
if ats_bot:
|
||||
message_list.append(platform_message.At(target=bot_account_id))
|
||||
|
||||
# 解析@信息并生成At组件
|
||||
at_targets = self._extract_at_targets(message)
|
||||
for target_id in at_targets:
|
||||
if target_id != bot_wxid: # 避免重复添加机器人的At
|
||||
message_list.append(platform_message.At(target=target_id))
|
||||
|
||||
content_no_preifx, _ = self._extract_content_and_sender(content)
|
||||
|
||||
msg_type = message['msg_type']
|
||||
|
||||
# 映射消息类型到处理器方法
|
||||
handler_map = {
|
||||
1: self._handler_text,
|
||||
3: self._handler_image,
|
||||
34: self._handler_voice,
|
||||
49: self._handler_compound, # 复合类型
|
||||
}
|
||||
|
||||
# 分派处理
|
||||
handler = handler_map.get(msg_type, self._handler_default)
|
||||
handler_result = await handler(
|
||||
message=message, # 原始的message
|
||||
content_no_preifx=content_no_preifx, # 处理后的content
|
||||
)
|
||||
|
||||
if handler_result and len(handler_result) > 0:
|
||||
message_list.extend(handler_result)
|
||||
|
||||
return platform_message.MessageChain(message_list)
|
||||
|
||||
async def _handler_text(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
|
||||
"""处理文本消息 (msg_type=1)"""
|
||||
if message and self._is_group_message(message):
|
||||
pattern = r'@\S{1,20}'
|
||||
content_no_preifx = re.sub(pattern, '', content_no_preifx)
|
||||
|
||||
return platform_message.MessageChain([platform_message.Plain(text=content_no_preifx)])
|
||||
|
||||
async def _handler_image(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
|
||||
"""处理图像消息 (msg_type=3)"""
|
||||
try:
|
||||
image_xml = content_no_preifx
|
||||
if not image_xml:
|
||||
return platform_message.MessageChain([platform_message.Unknown('[图片内容为空]')])
|
||||
root = ET.fromstring(image_xml)
|
||||
|
||||
# 提取img标签的属性
|
||||
img_tag = root.find('img')
|
||||
if img_tag is not None:
|
||||
aeskey = img_tag.get('aeskey')
|
||||
cdnthumburl = img_tag.get('cdnthumburl')
|
||||
# cdnmidimgurl = img_tag.get('cdnmidimgurl')
|
||||
|
||||
image_data = self.bot.cdn_download(aeskey=aeskey, file_type=1, file_url=cdnthumburl)
|
||||
if image_data['Data']['FileData'] == '':
|
||||
image_data = self.bot.cdn_download(aeskey=aeskey, file_type=2, file_url=cdnthumburl)
|
||||
base64_str = image_data['Data']['FileData']
|
||||
# self.logger.info(f"data:image/png;base64,{base64_str}")
|
||||
|
||||
elements = [
|
||||
platform_message.Image(base64=f'data:image/png;base64,{base64_str}'),
|
||||
# platform_message.WeChatForwardImage(xml_data=image_xml) # 微信消息转发
|
||||
]
|
||||
return platform_message.MessageChain(elements)
|
||||
except Exception as e:
|
||||
self.logger.error(f'处理图片失败: {str(e)}')
|
||||
return platform_message.MessageChain([platform_message.Unknown('[图片处理失败]')])
|
||||
|
||||
async def _handler_voice(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
|
||||
"""处理语音消息 (msg_type=34)"""
|
||||
message_List = []
|
||||
try:
|
||||
# 从消息中提取语音数据(需根据实际数据结构调整字段名)
|
||||
# audio_base64 = message["img_buf"]["buffer"]
|
||||
voice_xml = content_no_preifx
|
||||
new_msg_id = message['new_msg_id']
|
||||
root = ET.fromstring(voice_xml)
|
||||
|
||||
# 提取voicemsg标签的属性
|
||||
voicemsg = root.find('voicemsg')
|
||||
if voicemsg is not None:
|
||||
bufid = voicemsg.get('bufid')
|
||||
length = voicemsg.get('voicelength')
|
||||
voice_data = self.bot.get_msg_voice(buf_id=str(bufid), length=int(length), msgid=str(new_msg_id))
|
||||
audio_base64 = voice_data['Data']['Base64']
|
||||
|
||||
# 验证语音数据有效性
|
||||
if not audio_base64:
|
||||
message_List.append(platform_message.Unknown(text='[语音内容为空]'))
|
||||
return platform_message.MessageChain(message_List)
|
||||
|
||||
# 转换为平台支持的语音格式(如 Silk 格式)
|
||||
voice_element = platform_message.Voice(base64=f'data:audio/silk;base64,{audio_base64}')
|
||||
message_List.append(voice_element)
|
||||
|
||||
except KeyError as e:
|
||||
self.logger.error(f'语音数据字段缺失: {str(e)}')
|
||||
message_List.append(platform_message.Unknown(text='[语音数据解析失败]'))
|
||||
except Exception as e:
|
||||
self.logger.error(f'处理语音消息异常: {str(e)}')
|
||||
message_List.append(platform_message.Unknown(text='[语音处理失败]'))
|
||||
|
||||
return platform_message.MessageChain(message_List)
|
||||
|
||||
async def _handler_compound(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
|
||||
"""处理复合消息 (msg_type=49),根据子类型分派"""
|
||||
try:
|
||||
xml_data = ET.fromstring(content_no_preifx)
|
||||
appmsg_data = xml_data.find('.//appmsg')
|
||||
if appmsg_data:
|
||||
data_type = appmsg_data.findtext('.//type', '')
|
||||
# 二次分派处理器
|
||||
sub_handler_map = {
|
||||
'57': self._handler_compound_quote,
|
||||
'5': self._handler_compound_link,
|
||||
'6': self._handler_compound_file,
|
||||
'74': self._handler_compound_file,
|
||||
'33': self._handler_compound_mini_program,
|
||||
'36': self._handler_compound_mini_program,
|
||||
'2000': partial(self._handler_compound_unsupported, text='[转账消息]'),
|
||||
'2001': partial(self._handler_compound_unsupported, text='[红包消息]'),
|
||||
'51': partial(self._handler_compound_unsupported, text='[视频号消息]'),
|
||||
}
|
||||
|
||||
handler = sub_handler_map.get(data_type, self._handler_compound_unsupported)
|
||||
return await handler(
|
||||
message=message, # 原始msg
|
||||
xml_data=xml_data, # xml数据
|
||||
)
|
||||
else:
|
||||
return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)])
|
||||
except Exception as e:
|
||||
self.logger.error(f'解析复合消息失败: {str(e)}')
|
||||
return platform_message.MessageChain([platform_message.Unknown(text=content_no_preifx)])
|
||||
|
||||
async def _handler_compound_quote(
|
||||
self, message: Optional[dict], xml_data: ET.Element
|
||||
) -> platform_message.MessageChain:
|
||||
"""处理引用消息 (data_type=57)"""
|
||||
message_list = []
|
||||
# self.logger.info("_handler_compound_quote", ET.tostring(xml_data, encoding='unicode'))
|
||||
appmsg_data = xml_data.find('.//appmsg')
|
||||
quote_data = '' # 引用原文
|
||||
# quote_id = None # 引用消息的原发送者
|
||||
# tousername = None # 接收方: 所属微信的wxid
|
||||
user_data = '' # 用户消息
|
||||
sender_id = xml_data.findtext('.//fromusername') # 发送方:单聊用户/群member
|
||||
|
||||
# 引用消息转发
|
||||
if appmsg_data:
|
||||
user_data = appmsg_data.findtext('.//title') or ''
|
||||
quote_data = appmsg_data.find('.//refermsg').findtext('.//content')
|
||||
# quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr')
|
||||
message_list.append(platform_message.WeChatAppMsg(app_msg=ET.tostring(appmsg_data, encoding='unicode')))
|
||||
# if message:
|
||||
# tousername = message['to_user_name']['str']
|
||||
|
||||
if quote_data:
|
||||
quote_data_message_list = platform_message.MessageChain()
|
||||
# 文本消息
|
||||
try:
|
||||
if '<msg>' not in quote_data:
|
||||
quote_data_message_list.append(platform_message.Plain(text=quote_data))
|
||||
else:
|
||||
# 引用消息展开
|
||||
quote_data_xml = ET.fromstring(quote_data)
|
||||
if quote_data_xml.find('img'):
|
||||
quote_data_message_list.extend(await self._handler_image(None, quote_data))
|
||||
elif quote_data_xml.find('voicemsg'):
|
||||
quote_data_message_list.extend(await self._handler_voice(None, quote_data))
|
||||
elif quote_data_xml.find('videomsg'):
|
||||
quote_data_message_list.extend(await self._handler_default(None, quote_data)) # 先不处理
|
||||
else:
|
||||
# appmsg
|
||||
quote_data_message_list.extend(await self._handler_compound(None, quote_data))
|
||||
except Exception as e:
|
||||
self.logger.error(f'处理引用消息异常 expcetion:{e}')
|
||||
quote_data_message_list.append(platform_message.Plain(text=quote_data))
|
||||
message_list.append(
|
||||
platform_message.Quote(
|
||||
sender_id=sender_id,
|
||||
origin=quote_data_message_list,
|
||||
)
|
||||
)
|
||||
if len(user_data) > 0:
|
||||
pattern = r'@\S{1,20}'
|
||||
user_data = re.sub(pattern, '', user_data)
|
||||
message_list.append(platform_message.Plain(text=user_data))
|
||||
|
||||
return platform_message.MessageChain(message_list)
|
||||
|
||||
async def _handler_compound_file(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain:
|
||||
"""处理文件消息 (data_type=6)"""
|
||||
file_data = xml_data.find('.//appmsg')
|
||||
|
||||
if file_data.findtext('.//type', '') == '74':
|
||||
return None
|
||||
|
||||
else:
|
||||
xml_data_str = ET.tostring(xml_data, encoding='unicode')
|
||||
# print(xml_data_str)
|
||||
|
||||
# 提取img标签的属性
|
||||
# print(xml_data)
|
||||
file_name = file_data.find('title').text
|
||||
file_id = file_data.find('md5').text
|
||||
# file_szie = file_data.find('totallen')
|
||||
|
||||
# print(file_data)
|
||||
if file_data is not None:
|
||||
aeskey = xml_data.findtext('.//appattach/aeskey')
|
||||
cdnthumburl = xml_data.findtext('.//appattach/cdnattachurl')
|
||||
# cdnmidimgurl = img_tag.get('cdnmidimgurl')
|
||||
|
||||
# print(aeskey,cdnthumburl)
|
||||
|
||||
file_data = self.bot.cdn_download(aeskey=aeskey, file_type=5, file_url=cdnthumburl)
|
||||
|
||||
file_base64 = file_data['Data']['FileData']
|
||||
# print(file_data)
|
||||
file_size = file_data['Data']['TotalSize']
|
||||
|
||||
# print(file_base64)
|
||||
return platform_message.MessageChain(
|
||||
[
|
||||
platform_message.WeChatFile(
|
||||
file_id=file_id, file_name=file_name, file_size=file_size, file_base64=file_base64
|
||||
),
|
||||
platform_message.WeChatForwardFile(xml_data=xml_data_str),
|
||||
]
|
||||
)
|
||||
|
||||
async def _handler_compound_link(self, message: dict, xml_data: ET.Element) -> platform_message.MessageChain:
|
||||
"""处理链接消息(如公众号文章、外部网页)"""
|
||||
message_list = []
|
||||
try:
|
||||
# 解析 XML 中的链接参数
|
||||
appmsg = xml_data.find('.//appmsg')
|
||||
if appmsg is None:
|
||||
return platform_message.MessageChain()
|
||||
message_list.append(
|
||||
platform_message.WeChatLink(
|
||||
link_title=appmsg.findtext('title', ''),
|
||||
link_desc=appmsg.findtext('des', ''),
|
||||
link_url=appmsg.findtext('url', ''),
|
||||
link_thumb_url=appmsg.findtext('thumburl', ''), # 这个字段拿不到
|
||||
)
|
||||
)
|
||||
# 还没有发链接的接口, 暂时还需要自己构造appmsg, 先用WeChatAppMsg。
|
||||
message_list.append(platform_message.WeChatAppMsg(app_msg=ET.tostring(appmsg, encoding='unicode')))
|
||||
except Exception as e:
|
||||
self.logger.error(f'解析链接消息失败: {str(e)}')
|
||||
return platform_message.MessageChain(message_list)
|
||||
|
||||
async def _handler_compound_mini_program(
|
||||
self, message: dict, xml_data: ET.Element
|
||||
) -> platform_message.MessageChain:
|
||||
"""处理小程序消息(如小程序卡片、服务通知)"""
|
||||
xml_data_str = ET.tostring(xml_data, encoding='unicode')
|
||||
return platform_message.MessageChain([platform_message.WeChatForwardMiniPrograms(xml_data=xml_data_str)])
|
||||
|
||||
async def _handler_default(self, message: Optional[dict], content_no_preifx: str) -> platform_message.MessageChain:
|
||||
"""处理未知消息类型"""
|
||||
if message:
|
||||
msg_type = message['msg_type']
|
||||
else:
|
||||
msg_type = ''
|
||||
return platform_message.MessageChain([platform_message.Unknown(text=f'[未知消息类型 msg_type:{msg_type}]')])
|
||||
|
||||
def _handler_compound_unsupported(
|
||||
self, message: dict, xml_data: str, text: Optional[str] = None
|
||||
) -> platform_message.MessageChain:
|
||||
"""处理未支持复合消息类型(msg_type=49)子类型"""
|
||||
if not text:
|
||||
text = f'[xml_data={xml_data}]'
|
||||
content_list = []
|
||||
content_list.append(platform_message.Unknown(text=f'[处理未支持复合消息类型[msg_type=49]|{text}'))
|
||||
|
||||
return platform_message.MessageChain(content_list)
|
||||
|
||||
# 返回是否被艾特
|
||||
def _ats_bot(self, message: dict, bot_account_id: str) -> bool:
|
||||
ats_bot = False
|
||||
try:
|
||||
to_user_name = message['to_user_name']['str'] # 接收方: 所属微信的wxid
|
||||
raw_content = message['content']['str'] # 原始消息内容
|
||||
content_no_prefix, _ = self._extract_content_and_sender(raw_content)
|
||||
# 直接艾特机器人(这个有bug,当被引用的消息里面有@bot,会套娃
|
||||
# ats_bot = ats_bot or (f"@{bot_account_id}" in content_no_prefix)
|
||||
# 文本类@bot
|
||||
push_content = message.get('push_content', '')
|
||||
ats_bot = ats_bot or ('在群聊中@了你' in push_content)
|
||||
# 引用别人时@bot
|
||||
msg_source = message.get('msg_source', '') or ''
|
||||
if len(msg_source) > 0:
|
||||
msg_source_data = ET.fromstring(msg_source)
|
||||
at_user_list = msg_source_data.findtext('atuserlist') or ''
|
||||
ats_bot = ats_bot or (to_user_name in at_user_list)
|
||||
# 引用bot
|
||||
if message.get('msg_type', 0) == 49:
|
||||
xml_data = ET.fromstring(content_no_prefix)
|
||||
appmsg_data = xml_data.find('.//appmsg')
|
||||
tousername = message['to_user_name']['str']
|
||||
if appmsg_data: # 接收方: 所属微信的wxid
|
||||
quote_id = appmsg_data.find('.//refermsg').findtext('.//chatusr') # 引用消息的原发送者
|
||||
ats_bot = ats_bot or (quote_id == tousername)
|
||||
except Exception as e:
|
||||
self.logger.error(f'_ats_bot got except: {e}')
|
||||
finally:
|
||||
return ats_bot
|
||||
|
||||
# 提取一下at的wxid列表
|
||||
def _extract_at_targets(self, message: dict) -> list[str]:
|
||||
"""从消息中提取被@用户的ID列表"""
|
||||
at_targets = []
|
||||
try:
|
||||
# 从msg_source中解析atuserlist
|
||||
msg_source = message.get('msg_source', '') or ''
|
||||
if len(msg_source) > 0:
|
||||
msg_source_data = ET.fromstring(msg_source)
|
||||
at_user_list = msg_source_data.findtext('atuserlist') or ''
|
||||
if at_user_list:
|
||||
# atuserlist格式通常是逗号分隔的用户ID列表
|
||||
at_targets = [user_id.strip() for user_id in at_user_list.split(',') if user_id.strip()]
|
||||
except Exception as e:
|
||||
self.logger.error(f'_extract_at_targets got except: {e}')
|
||||
return at_targets
|
||||
|
||||
# 提取一下content前面的sender_id, 和去掉前缀的内容
|
||||
def _extract_content_and_sender(self, raw_content: str) -> Tuple[str, Optional[str]]:
|
||||
try:
|
||||
# 检查消息开头,如果有 wxid_sbitaz0mt65n22:\n 则删掉
|
||||
# add: 有些用户的wxid不是上述格式。换成user_name:
|
||||
regex = re.compile(r'^[a-zA-Z0-9_\-]{5,20}:')
|
||||
line_split = raw_content.split('\n')
|
||||
if len(line_split) > 0 and regex.match(line_split[0]):
|
||||
raw_content = '\n'.join(line_split[1:])
|
||||
sender_id = line_split[0].strip(':')
|
||||
return raw_content, sender_id
|
||||
except Exception as e:
|
||||
self.logger.error(f'_extract_content_and_sender got except: {e}')
|
||||
finally:
|
||||
return raw_content, None
|
||||
|
||||
# 是否是群消息
|
||||
def _is_group_message(self, message: dict) -> bool:
|
||||
from_user_name = message['from_user_name']['str']
|
||||
return from_user_name.endswith('@chatroom')
|
||||
|
||||
|
||||
class WeChatPadEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
def __init__(self, config: dict, logger: logging.Logger):
|
||||
self.config = config
|
||||
self.logger = logger
|
||||
self.message_converter = WeChatPadMessageConverter(self.config, self.logger)
|
||||
# super().__init__(
|
||||
# config=config,
|
||||
# message_converter=message_converter,
|
||||
# logger = logger,
|
||||
# )
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent) -> dict:
|
||||
pass
|
||||
|
||||
async def target2yiri(
|
||||
self,
|
||||
event: dict,
|
||||
bot_account_id: str,
|
||||
) -> platform_events.MessageEvent:
|
||||
# 排除公众号以及微信团队消息
|
||||
if (
|
||||
event['from_user_name']['str'].startswith('gh_')
|
||||
or event['from_user_name']['str'] == 'weixin'
|
||||
or event['from_user_name']['str'] == 'newsapp'
|
||||
or event['from_user_name']['str'] == self.config['wxid']
|
||||
):
|
||||
return None
|
||||
message_chain = await self.message_converter.target2yiri(copy.deepcopy(event), bot_account_id)
|
||||
|
||||
if not message_chain:
|
||||
return None
|
||||
|
||||
if '@chatroom' in event['from_user_name']['str']:
|
||||
# 找出开头的 wxid_ 字符串,以:结尾
|
||||
sender_wxid = event['content']['str'].split(':')[0]
|
||||
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=sender_wxid,
|
||||
member_name=event['from_user_name']['str'],
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=event['from_user_name']['str'],
|
||||
name=event['from_user_name']['str'],
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event['create_time'],
|
||||
source_platform_object=event,
|
||||
)
|
||||
else:
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=event['from_user_name']['str'],
|
||||
nickname=event['from_user_name']['str'],
|
||||
remark='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event['create_time'],
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
|
||||
class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
name: str = 'WeChatPad' # 定义适配器名称
|
||||
|
||||
bot: WeChatPadClient
|
||||
quart_app: quart.Quart
|
||||
|
||||
bot_account_id: str
|
||||
|
||||
config: dict
|
||||
|
||||
logger: EventLogger
|
||||
|
||||
message_converter: WeChatPadMessageConverter
|
||||
event_converter: WeChatPadEventConverter
|
||||
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
quart_app = quart.Quart(__name__)
|
||||
|
||||
message_converter = WeChatPadMessageConverter(config, logger)
|
||||
event_converter = WeChatPadEventConverter(config, logger)
|
||||
bot = WeChatPadClient(config['wechatpad_url'], config['token'])
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
quart_app=quart_app,
|
||||
message_converter=message_converter,
|
||||
event_converter=event_converter,
|
||||
listeners={},
|
||||
bot_account_id='',
|
||||
name='WeChatPad',
|
||||
bot=bot,
|
||||
)
|
||||
|
||||
async def ws_message(self, data):
|
||||
"""处理接收到的消息"""
|
||||
|
||||
try:
|
||||
event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in wechatpad callback: {traceback.format_exc()}')
|
||||
|
||||
if event.__class__ in self.listeners:
|
||||
await self.listeners[event.__class__](event, self)
|
||||
|
||||
return 'ok'
|
||||
|
||||
async def _handle_message(self, message: platform_message.MessageChain, target_id: str):
|
||||
"""统一消息处理核心逻辑"""
|
||||
content_list = await self.message_converter.yiri2target(message)
|
||||
# print(content_list)
|
||||
at_targets = [item['target'] for item in content_list if item['type'] == 'at']
|
||||
# print(at_targets)
|
||||
# 处理@逻辑
|
||||
at_targets = at_targets or []
|
||||
member_info = []
|
||||
if at_targets:
|
||||
member_info = self.bot.get_chatroom_member_detail(
|
||||
target_id,
|
||||
)['Data']['member_data']['chatroom_member_list']
|
||||
|
||||
# 处理消息组件
|
||||
for msg in content_list:
|
||||
# 文本消息处理@
|
||||
if msg['type'] == 'text' and at_targets:
|
||||
if 'all' in at_targets:
|
||||
msg['content'] = f'@所有人 {msg["content"]}'
|
||||
else:
|
||||
at_nick_name_list = []
|
||||
for member in member_info:
|
||||
if member['user_name'] in at_targets:
|
||||
at_nick_name_list.append(f'@{member["nick_name"]}')
|
||||
msg['content'] = f'{" ".join(at_nick_name_list)} {msg["content"]}'
|
||||
|
||||
# 统一消息派发
|
||||
handler_map = {
|
||||
'text': lambda msg: self.bot.send_text_message(
|
||||
to_wxid=target_id, message=msg['content'], ats=['notify@all'] if 'all' in at_targets else at_targets
|
||||
),
|
||||
'image': lambda msg: self.bot.send_image_message(
|
||||
to_wxid=target_id, img_url=msg['image'], ats=['notify@all'] if 'all' in at_targets else at_targets
|
||||
),
|
||||
'WeChatEmoji': lambda msg: self.bot.send_emoji_message(
|
||||
to_wxid=target_id, emoji_md5=msg['emoji_md5'], emoji_size=msg['emoji_size']
|
||||
),
|
||||
'voice': lambda msg: self.bot.send_voice_message(
|
||||
to_wxid=target_id,
|
||||
voice_data=msg['data'],
|
||||
voice_duration=msg['duration'],
|
||||
voice_forma=msg['forma'],
|
||||
),
|
||||
'WeChatAppMsg': lambda msg: self.bot.send_app_message(
|
||||
to_wxid=target_id,
|
||||
app_message=msg['app_msg'],
|
||||
type=0,
|
||||
),
|
||||
'at': lambda msg: None,
|
||||
}
|
||||
|
||||
if handler := handler_map.get(msg['type']):
|
||||
handler(msg)
|
||||
else:
|
||||
self.logger.warning(f'未处理的消息类型: {msg["type"]}')
|
||||
continue
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
"""主动发送消息"""
|
||||
return await self._handle_message(message, target_id)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
"""回复消息"""
|
||||
if message_source.source_platform_object:
|
||||
target_id = message_source.source_platform_object['from_user_name']['str']
|
||||
return await self._handle_message(message, target_id)
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
pass
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
pass
|
||||
|
||||
async def run_async(self):
|
||||
if not self.config['admin_key'] and not self.config['token']:
|
||||
raise RuntimeError('无wechatpad管理密匙,请填入配置文件后重启')
|
||||
else:
|
||||
if self.config['token']:
|
||||
self.bot = WeChatPadClient(self.config['wechatpad_url'], self.config['token'])
|
||||
data = self.bot.get_login_status()
|
||||
if data['Code'] == 300 and data['Text'] == '你已退出微信':
|
||||
response = requests.post(
|
||||
f'{self.config["wechatpad_url"]}/admin/GenAuthKey1?key={self.config["admin_key"]}',
|
||||
json={'Count': 1, 'Days': 365},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f'获取token失败: {response.text}')
|
||||
self.config['token'] = response.json()['Data'][0]
|
||||
|
||||
elif not self.config['token']:
|
||||
response = requests.post(
|
||||
f'{self.config["wechatpad_url"]}/admin/GenAuthKey1?key={self.config["admin_key"]}',
|
||||
json={'Count': 1, 'Days': 365},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f'获取token失败: {response.text}')
|
||||
self.config['token'] = response.json()['Data'][0]
|
||||
|
||||
self.bot = WeChatPadClient(self.config['wechatpad_url'], self.config['token'], logger=self.logger)
|
||||
await self.logger.info(self.config['token'])
|
||||
thread_1 = threading.Event()
|
||||
|
||||
def wechat_login_process():
|
||||
# 不登录,这些先注释掉,避免登陆态尝试拉qrcode。
|
||||
# login_data =self.bot.get_login_qr()
|
||||
|
||||
# url = login_data['Data']["QrCodeUrl"]
|
||||
|
||||
profile = self.bot.get_profile()
|
||||
# self.logger.info(profile)
|
||||
|
||||
self.bot_account_id = profile['Data']['userInfo']['nickName']['str']
|
||||
self.config['wxid'] = profile['Data']['userInfo']['userName']['str']
|
||||
thread_1.set()
|
||||
|
||||
# asyncio.create_task(wechat_login_process)
|
||||
threading.Thread(target=wechat_login_process).start()
|
||||
|
||||
def connect_websocket_sync() -> None:
|
||||
thread_1.wait()
|
||||
uri = f'{self.config["wechatpad_ws"]}/GetSyncMsg?key={self.config["token"]}'
|
||||
print(f'Connecting to WebSocket: {uri}')
|
||||
|
||||
def on_message(ws, message):
|
||||
try:
|
||||
data = json.loads(message)
|
||||
# 这里需要确保ws_message是同步的,或者使用asyncio.run调用异步方法
|
||||
asyncio.run(self.ws_message(data))
|
||||
except json.JSONDecodeError:
|
||||
self.logger.error(f'Non-JSON message: {message[:100]}...')
|
||||
|
||||
def on_error(ws, error):
|
||||
self.logger.error(f'WebSocket error: {str(error)[:200]}')
|
||||
|
||||
def on_close(ws, close_status_code, close_msg):
|
||||
self.logger.info('WebSocket closed, reconnecting...')
|
||||
time.sleep(5)
|
||||
connect_websocket_sync() # 自动重连
|
||||
|
||||
def on_open(ws):
|
||||
self.logger.info('WebSocket connected successfully!')
|
||||
|
||||
ws = websocket.WebSocketApp(
|
||||
uri, on_message=on_message, on_error=on_error, on_close=on_close, on_open=on_open
|
||||
)
|
||||
ws.run_forever(ping_interval=60, ping_timeout=20)
|
||||
|
||||
# 直接调用同步版本(会阻塞)
|
||||
# connect_websocket_sync()
|
||||
|
||||
# 这行代码会在WebSocket连接断开后才会执行
|
||||
thread = threading.Thread(target=connect_websocket_sync, name='WebSocketClientThread', daemon=True)
|
||||
thread.start()
|
||||
self.logger.info('WebSocket client thread started')
|
||||
|
||||
async def kill(self) -> bool:
|
||||
pass
|
||||
52
src/langbot/pkg/platform/sources/wechatpad.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: wechatpad
|
||||
label:
|
||||
en_US: WeChatPad
|
||||
zh_CN: WeChatPad(个人微信ipad)
|
||||
description:
|
||||
en_US: WeChatPad Adapter
|
||||
zh_CN: WeChatPad 适配器
|
||||
icon: wechatpad.png
|
||||
spec:
|
||||
config:
|
||||
- name: wechatpad_url
|
||||
label:
|
||||
en_US: WeChatPad ERL
|
||||
zh_CN: WeChatPad URL
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: wechatpad_ws
|
||||
label:
|
||||
en_US: WeChatPad_Ws
|
||||
zh_CN: WeChatPad_Ws
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: admin_key
|
||||
label:
|
||||
en_US: Admin_Key
|
||||
zh_CN: 管理员密匙
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_CN: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: wxid
|
||||
label:
|
||||
en_US: wxid
|
||||
zh_CN: wxid
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./wechatpad.py
|
||||
attr: WeChatPadAdapter
|
||||
BIN
src/langbot/pkg/platform/sources/wecom.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
241
src/langbot/pkg/platform/sources/wecom.py
Normal file
@@ -0,0 +1,241 @@
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
import asyncio
|
||||
import traceback
|
||||
|
||||
import datetime
|
||||
|
||||
from libs.wecom_api.api import WecomClient
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from libs.wecom_api.wecomevent import WecomEvent
|
||||
from ...utils import image
|
||||
from ..logger import EventLogger
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
|
||||
|
||||
class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain, bot: WecomClient):
|
||||
content_list = []
|
||||
|
||||
for msg in message_chain:
|
||||
if type(msg) is platform_message.Plain:
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'text',
|
||||
'content': msg.text,
|
||||
}
|
||||
)
|
||||
elif type(msg) is platform_message.Image:
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'image',
|
||||
'media_id': await bot.get_media_id(msg),
|
||||
}
|
||||
)
|
||||
elif type(msg) is platform_message.Forward:
|
||||
for node in msg.node_list:
|
||||
content_list.extend((await WecomMessageConverter.yiri2target(node.message_chain, bot)))
|
||||
else:
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'text',
|
||||
'content': str(msg),
|
||||
}
|
||||
)
|
||||
|
||||
return content_list
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message: str, message_id: int = -1):
|
||||
yiri_msg_list = []
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
|
||||
yiri_msg_list.append(platform_message.Plain(text=message))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri_image(picurl: str, message_id: int = -1):
|
||||
yiri_msg_list = []
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl)
|
||||
yiri_msg_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
|
||||
|
||||
class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.Event, bot_account_id: int, bot: WecomClient) -> WecomEvent:
|
||||
# only for extracting user information
|
||||
|
||||
if type(event) is platform_events.GroupMessage:
|
||||
pass
|
||||
|
||||
if type(event) is platform_events.FriendMessage:
|
||||
payload = {
|
||||
'MsgType': 'text',
|
||||
'Content': '',
|
||||
'FromUserName': event.sender.id,
|
||||
'ToUserName': bot_account_id,
|
||||
'CreateTime': int(datetime.datetime.now().timestamp()),
|
||||
'AgentID': event.sender.nickname,
|
||||
}
|
||||
wecom_event = WecomEvent.from_payload(payload=payload)
|
||||
if not wecom_event:
|
||||
raise ValueError('无法从 message_data 构造 WecomEvent 对象')
|
||||
|
||||
return wecom_event
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: WecomEvent):
|
||||
"""
|
||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||
|
||||
Args:
|
||||
event (WecomEvent): 企业微信事件。
|
||||
|
||||
Returns:
|
||||
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
||||
"""
|
||||
# 转换消息链
|
||||
if event.type == 'text':
|
||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
nickname=str(event.agent_id),
|
||||
remark='',
|
||||
)
|
||||
|
||||
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
|
||||
elif event.type == 'image':
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
nickname=str(event.agent_id),
|
||||
remark='',
|
||||
)
|
||||
|
||||
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
|
||||
|
||||
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
|
||||
|
||||
|
||||
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: WecomClient
|
||||
bot_account_id: str
|
||||
message_converter: WecomMessageConverter = WecomMessageConverter()
|
||||
event_converter: WecomEventConverter = WecomEventConverter()
|
||||
config: dict
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
# 校验必填项
|
||||
required_keys = [
|
||||
'corpid',
|
||||
'secret',
|
||||
'token',
|
||||
'EncodingAESKey',
|
||||
'contacts_secret',
|
||||
]
|
||||
missing_keys = [key for key in required_keys if key not in config]
|
||||
if missing_keys:
|
||||
raise Exception(f'Wecom 缺少配置项: {missing_keys}')
|
||||
|
||||
# 创建运行时 bot 对象
|
||||
bot = WecomClient(
|
||||
corpid=config['corpid'],
|
||||
secret=config['secret'],
|
||||
token=config['token'],
|
||||
EncodingAESKey=config['EncodingAESKey'],
|
||||
contacts_secret=config['contacts_secret'],
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot=bot,
|
||||
bot_account_id='',
|
||||
)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
|
||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||
fixed_user_id = Wecom_event.user_id
|
||||
# 删掉开头的u
|
||||
fixed_user_id = fixed_user_id[1:]
|
||||
for content in content_list:
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content'])
|
||||
elif content['type'] == 'image':
|
||||
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
"""企业微信目前只有发送给个人的方法,
|
||||
构造target_id的方式为前半部分为账户id,后半部分为agent_id,中间使用“|”符号隔开。
|
||||
"""
|
||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||
parts = target_id.split('|')
|
||||
user_id = parts[0]
|
||||
agent_id = int(parts[1])
|
||||
if target_type == 'person':
|
||||
for content in content_list:
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_private_msg(user_id, agent_id, content['content'])
|
||||
if content['type'] == 'image':
|
||||
await self.bot.send_image(user_id, agent_id, content['media'])
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
async def on_message(event: WecomEvent):
|
||||
self.bot_account_id = event.receiver_id
|
||||
try:
|
||||
return await callback(await self.event_converter.target2yiri(event), self)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')
|
||||
|
||||
if event_type == platform_events.FriendMessage:
|
||||
self.bot.on_message('text')(on_message)
|
||||
self.bot.on_message('image')(on_message)
|
||||
elif event_type == platform_events.GroupMessage:
|
||||
pass
|
||||
|
||||
async def run_async(self):
|
||||
async def shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self.bot.run_task(
|
||||
host=self.config['host'],
|
||||
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, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
return super().unregister_listener(event_type, callback)
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
pass
|
||||
69
src/langbot/pkg/platform/sources/wecom.yaml
Normal file
@@ -0,0 +1,69 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: wecom
|
||||
label:
|
||||
en_US: WeCom
|
||||
zh_Hans: 企业微信
|
||||
description:
|
||||
en_US: WeCom Adapter
|
||||
zh_Hans: 企业微信适配器,请查看文档了解使用方式
|
||||
icon: wecom.png
|
||||
spec:
|
||||
config:
|
||||
- name: host
|
||||
label:
|
||||
en_US: Host
|
||||
zh_Hans: 监听主机
|
||||
description:
|
||||
en_US: Webhook host, unless you know what you're doing, please write 0.0.0.0
|
||||
zh_Hans: Webhook 监听主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
||||
type: string
|
||||
required: true
|
||||
default: "0.0.0.0"
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 监听端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2290
|
||||
- name: corpid
|
||||
label:
|
||||
en_US: Corpid
|
||||
zh_Hans: 企业ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: secret
|
||||
label:
|
||||
en_US: Secret
|
||||
zh_Hans: 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: EncodingAESKey
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: contacts_secret
|
||||
label:
|
||||
en_US: Contacts Secret
|
||||
zh_Hans: 通讯录密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./wecom.py
|
||||
attr: WecomAdapter
|
||||
BIN
src/langbot/pkg/platform/sources/wecombot.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
216
src/langbot/pkg/platform/sources/wecombot.py
Normal file
@@ -0,0 +1,216 @@
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
import asyncio
|
||||
import traceback
|
||||
|
||||
import datetime
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
from ..logger import EventLogger
|
||||
from libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
||||
from libs.wecom_ai_bot_api.api import WecomBotClient
|
||||
|
||||
|
||||
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain):
|
||||
content = ''
|
||||
for msg in message_chain:
|
||||
if type(msg) is platform_message.Plain:
|
||||
content += msg.text
|
||||
return content
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: WecomBotEvent):
|
||||
yiri_msg_list = []
|
||||
if event.type == 'group':
|
||||
yiri_msg_list.append(platform_message.At(target=event.ai_bot_id))
|
||||
yiri_msg_list.append(platform_message.Source(id=event.message_id, time=datetime.datetime.now()))
|
||||
yiri_msg_list.append(platform_message.Plain(text=event.content))
|
||||
if event.picurl != '':
|
||||
yiri_msg_list.append(platform_message.Image(base64=event.picurl))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
|
||||
|
||||
class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent):
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: WecomBotEvent):
|
||||
message_chain = await WecomBotMessageConverter.target2yiri(event)
|
||||
if event.type == 'single':
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=event.userid,
|
||||
nickname=event.username,
|
||||
remark='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=datetime.datetime.now().timestamp(),
|
||||
source_platform_object=event,
|
||||
)
|
||||
elif event.type == 'group':
|
||||
try:
|
||||
sender = platform_entities.GroupMember(
|
||||
id=event.userid,
|
||||
permission='MEMBER',
|
||||
member_name=event.username,
|
||||
group=platform_entities.Group(
|
||||
id=str(event.chatid),
|
||||
name=event.chatname,
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
)
|
||||
time = datetime.datetime.now().timestamp()
|
||||
return platform_events.GroupMessage(
|
||||
sender=sender,
|
||||
message_chain=message_chain,
|
||||
time=time,
|
||||
source_platform_object=event,
|
||||
)
|
||||
except Exception:
|
||||
print(traceback.format_exc())
|
||||
|
||||
|
||||
class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: WecomBotClient
|
||||
bot_account_id: str
|
||||
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
|
||||
event_converter: WecomBotEventConverter = WecomBotEventConverter()
|
||||
config: dict
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId', 'port']
|
||||
missing_keys = [key for key in required_keys if key not in config]
|
||||
if missing_keys:
|
||||
raise Exception(f'WecomBot 缺少配置项: {missing_keys}')
|
||||
|
||||
# 创建运行时 bot 对象
|
||||
bot = WecomBotClient(
|
||||
Token=config['Token'],
|
||||
EnCodingAESKey=config['EncodingAESKey'],
|
||||
Corpid=config['Corpid'],
|
||||
logger=logger,
|
||||
)
|
||||
bot_account_id = config['BotId']
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot=bot,
|
||||
bot_account_id=bot_account_id,
|
||||
)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
content = await self.message_converter.yiri2target(message)
|
||||
await self.bot.set_message(message_source.source_platform_object.message_id, content)
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
"""将流水线增量输出写入企业微信 stream 会话。
|
||||
|
||||
Args:
|
||||
message_source: 流水线提供的原始消息事件。
|
||||
bot_message: 当前片段对应的模型元信息(未使用)。
|
||||
message: 需要回复的消息链。
|
||||
quote_origin: 是否引用原消息(企业微信暂不支持)。
|
||||
is_final: 标记当前片段是否为最终回复。
|
||||
|
||||
Returns:
|
||||
dict: 包含 `stream` 键,标识写入是否成功。
|
||||
|
||||
Example:
|
||||
在流水线 `reply_message_chunk` 调用中自动触发,无需手动调用。
|
||||
"""
|
||||
# 转换为纯文本(智能机器人当前协议仅支持文本流)
|
||||
content = await self.message_converter.yiri2target(message)
|
||||
msg_id = message_source.source_platform_object.message_id
|
||||
|
||||
# 将片段推送到 WecomBotClient 中的队列,返回值用于判断是否走降级逻辑
|
||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||||
if not success and is_final:
|
||||
# 未命中流式队列时使用旧有 set_message 兜底
|
||||
await self.bot.set_message(msg_id, content)
|
||||
return {'stream': success}
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
"""智能机器人侧默认开启流式能力。
|
||||
|
||||
Returns:
|
||||
bool: 恒定返回 True。
|
||||
|
||||
Example:
|
||||
流水线执行阶段会调用此方法以确认是否启用流式。"""
|
||||
return True
|
||||
|
||||
async def send_message(self, target_type, target_id, message):
|
||||
pass
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
async def on_message(event: WecomBotEvent):
|
||||
try:
|
||||
return await callback(await self.event_converter.target2yiri(event), self)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in wecombot callback: {traceback.format_exc()}')
|
||||
print(traceback.format_exc())
|
||||
|
||||
try:
|
||||
if event_type == platform_events.FriendMessage:
|
||||
self.bot.on_message('single')(on_message)
|
||||
elif event_type == platform_events.GroupMessage:
|
||||
self.bot.on_message('group')(on_message)
|
||||
except Exception:
|
||||
print(traceback.format_exc())
|
||||
|
||||
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, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
return super().unregister_listener(event_type, callback)
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
pass
|
||||
52
src/langbot/pkg/platform/sources/wecombot.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: wecombot
|
||||
label:
|
||||
en_US: WeComBot
|
||||
zh_Hans: 企业微信智能机器人
|
||||
description:
|
||||
en_US: WeComBot Adapter
|
||||
zh_Hans: 企业微信智能机器人适配器,请查看文档了解使用方式
|
||||
icon: wecombot.png
|
||||
spec:
|
||||
config:
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 监听端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2291
|
||||
- name: Corpid
|
||||
label:
|
||||
en_US: Corpid
|
||||
zh_Hans: 企业ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: Token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: EncodingAESKey
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: BotId
|
||||
label:
|
||||
en_US: BotId
|
||||
zh_Hans: 机器人ID
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./wecombot.py
|
||||
attr: WecomBotAdapter
|
||||
217
src/langbot/pkg/platform/sources/wecomcs.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
import asyncio
|
||||
import traceback
|
||||
|
||||
import datetime
|
||||
import pydantic
|
||||
|
||||
from libs.wecom_customer_service_api.api import WecomCSClient
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
from libs.wecom_customer_service_api.wecomcsevent import WecomCSEvent
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
from langbot_plugin.api.entities.builtin.command import errors as command_errors
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
|
||||
|
||||
class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain, bot: WecomCSClient):
|
||||
content_list = []
|
||||
|
||||
for msg in message_chain:
|
||||
if type(msg) is platform_message.Plain:
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'text',
|
||||
'content': msg.text,
|
||||
}
|
||||
)
|
||||
elif type(msg) is platform_message.Image:
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'image',
|
||||
'media_id': await bot.get_media_id(msg),
|
||||
}
|
||||
)
|
||||
elif type(msg) is platform_message.Forward:
|
||||
for node in msg.node_list:
|
||||
content_list.extend((await WecomMessageConverter.yiri2target(node.message_chain, bot)))
|
||||
else:
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'text',
|
||||
'content': str(msg),
|
||||
}
|
||||
)
|
||||
|
||||
return content_list
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message: str, message_id: int = -1):
|
||||
yiri_msg_list = []
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
|
||||
yiri_msg_list.append(platform_message.Plain(text=message))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri_image(picurl: str, message_id: int = -1):
|
||||
yiri_msg_list = []
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
yiri_msg_list.append(platform_message.Image(base64=picurl))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
|
||||
|
||||
class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.Event, bot_account_id: int, bot: WecomCSClient) -> WecomCSEvent:
|
||||
# only for extracting user information
|
||||
|
||||
if type(event) is platform_events.GroupMessage:
|
||||
pass
|
||||
|
||||
if type(event) is platform_events.FriendMessage:
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: WecomCSEvent):
|
||||
"""
|
||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||
|
||||
Args:
|
||||
event (WecomEvent): 企业微信客服事件。
|
||||
|
||||
Returns:
|
||||
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
||||
"""
|
||||
# 转换消息链
|
||||
if event.type == 'text':
|
||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
nickname=str(event.user_id),
|
||||
remark='',
|
||||
)
|
||||
|
||||
return platform_events.FriendMessage(
|
||||
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
|
||||
)
|
||||
elif event.type == 'image':
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
nickname=str(event.user_id),
|
||||
remark='',
|
||||
)
|
||||
|
||||
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
|
||||
|
||||
return platform_events.FriendMessage(
|
||||
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
|
||||
)
|
||||
|
||||
|
||||
class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: WecomCSClient = pydantic.Field(exclude=True)
|
||||
message_converter: WecomMessageConverter = WecomMessageConverter()
|
||||
event_converter: WecomEventConverter = WecomEventConverter()
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
required_keys = [
|
||||
'corpid',
|
||||
'secret',
|
||||
'token',
|
||||
'EncodingAESKey',
|
||||
]
|
||||
missing_keys = [key for key in required_keys if key not in config]
|
||||
if missing_keys:
|
||||
raise command_errors.ParamNotEnoughError('企业微信客服缺少相关配置项,请查看文档或联系管理员')
|
||||
|
||||
bot = WecomCSClient(
|
||||
corpid=config['corpid'],
|
||||
secret=config['secret'],
|
||||
token=config['token'],
|
||||
EncodingAESKey=config['EncodingAESKey'],
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot_account_id='',
|
||||
listeners={},
|
||||
bot=bot,
|
||||
)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
|
||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||
|
||||
for content in content_list:
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_text_msg(
|
||||
open_kfid=Wecom_event.receiver_id,
|
||||
external_userid=Wecom_event.user_id,
|
||||
msgid=Wecom_event.message_id,
|
||||
content=content['content'],
|
||||
)
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
pass
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
async def on_message(event: WecomCSEvent):
|
||||
self.bot_account_id = event.receiver_id
|
||||
try:
|
||||
return await callback(await self.event_converter.target2yiri(event), self)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}')
|
||||
|
||||
if event_type == platform_events.FriendMessage:
|
||||
self.bot.on_message('text')(on_message)
|
||||
self.bot.on_message('image')(on_message)
|
||||
elif event_type == platform_events.GroupMessage:
|
||||
pass
|
||||
|
||||
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 is_muted(self, group_id: int) -> bool:
|
||||
return False
|
||||
|
||||
async def unregister_listener(
|
||||
self,
|
||||
event_type: type,
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
return super().unregister_listener(event_type, callback)
|
||||
52
src/langbot/pkg/platform/sources/wecomcs.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: wecomcs
|
||||
label:
|
||||
en_US: WeComCustomerService
|
||||
zh_Hans: 企业微信客服
|
||||
description:
|
||||
en_US: WeComCSAdapter
|
||||
zh_Hans: 企业微信客服适配器
|
||||
icon: wecom.png
|
||||
spec:
|
||||
config:
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 监听端口
|
||||
type: int
|
||||
required: true
|
||||
default: 2289
|
||||
- name: corpid
|
||||
label:
|
||||
en_US: Corpid
|
||||
zh_Hans: 企业ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: secret
|
||||
label:
|
||||
en_US: Secret
|
||||
zh_Hans: 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: EncodingAESKey
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./wecomcs.py
|
||||
attr: WecomCSAdapter
|
||||