chore: Add PyPI package support for uvx/pip installation (#1764)

* Initial plan

* Add package structure and resource path utilities

- Created langbot/ package with __init__.py and __main__.py entry point
- Added paths utility to find frontend and resource files from package installation
- Updated config loading to use resource paths
- Updated frontend serving to use resource paths
- Added MANIFEST.in for package data inclusion
- Updated pyproject.toml with build system and entry points

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Add PyPI publishing workflow and update license

- Created GitHub Actions workflow to build frontend and publish to PyPI
- Added license field to pyproject.toml to fix deprecation warning
- Updated .gitignore to exclude build artifacts
- Tested package building successfully

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Add PyPI installation documentation

- Created PYPI_INSTALLATION.md with detailed installation and usage instructions
- Updated README.md to feature uvx/pip installation as recommended method
- Updated README_EN.md with same changes for English documentation

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Address code review feedback

- Made package-data configuration more specific to langbot package only
- Improved path detection with caching to avoid repeated file I/O
- Removed sys.path searching which was incorrect for package data
- Removed interactive input() call for non-interactive environment compatibility
- Simplified error messages for version check

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Fix code review issues

- Use specific exception types instead of bare except
- Fix misleading comments about directory levels
- Remove redundant existence check before makedirs with exist_ok=True
- Use context manager for file opening to ensure proper cleanup

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* Simplify package configuration and document behavioral differences

- Removed redundant package-data configuration, relying on MANIFEST.in
- Added documentation about behavioral differences between package and source installation
- Clarified that include-package-data=true uses MANIFEST.in for data files

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>

* chore: update pyproject.toml

* chore: try pack templates in langbot/

* chore: update

* chore: update

* chore: update

* chore: update

* chore: update

* chore: adjust dir structure

* chore: fix imports

* fix: read default-pipeline-config.json

* fix: read default-pipeline-config.json

* fix: tests

* ci: publish pypi

* chore: bump version 4.6.0-beta.1 for testing

* chore: add templates/**

* fix: send adapters and requesters icons

* chore: bump version 4.6.0b2 for testing

* chore: add platform field for docker-compose.yaml

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
Copilot
2025-11-16 19:53:01 +08:00
committed by GitHub
parent 6a24c951e0
commit e642ffa5b3
477 changed files with 1001 additions and 1002 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -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
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

@@ -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
@@ -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
@@ -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
@@ -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

@@ -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