mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
463b120923
Preserve the platform CDN URL in Image.url alongside base64 data, enabling plugins to use ContentElement.from_image_url() for direct vision API access without redundant local download. - aiocqhttp: use msg_data["data"]["url"] and msg.data["url"] - discord: use attachment.url - telegram: use file.file_path - slack: use pic_url - wecom: use picurl - qqofficial: use pic_url Satori adapter already follows this pattern (satori.py:168). The change is purely additive — base64 is preserved for backward compatibility, and get_bytes() priority (url → base64 → path) ensures plugins can choose the optimal path. Closes #2355 Co-authored-by: douxt <8429023+douxt@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
701 lines
29 KiB
Python
701 lines
29 KiB
Python
from __future__ import annotations
|
|
import typing
|
|
import asyncio
|
|
import traceback
|
|
import datetime
|
|
import json
|
|
import time
|
|
|
|
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
|
|
|
|
|
|
_GROUP_NAME_CACHE_TTL_SECONDS = 3600
|
|
_GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS = 60
|
|
_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS = 2
|
|
_GROUP_MEMBER_INFO_CACHE_TTL_SECONDS = 86400
|
|
_GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS = 600
|
|
_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS = 2
|
|
|
|
|
|
def _normalize_base64_payload(value: str) -> str:
|
|
if value.startswith('base64://'):
|
|
return value.removeprefix('base64://')
|
|
if value.startswith('data:') and ';base64,' in value:
|
|
return value.split(';base64,', 1)[1]
|
|
return value
|
|
|
|
|
|
def _get_field(data: dict, key: str, default: str = '') -> str:
|
|
value = data.get(key)
|
|
if value is None:
|
|
return default
|
|
return str(value)
|
|
|
|
|
|
def _get_group_member_name(sender: dict) -> str:
|
|
return _get_field(sender, 'card') or _get_field(sender, 'nickname') or _get_field(sender, 'user_id')
|
|
|
|
|
|
def _get_group_name_placeholder(group_id: typing.Union[int, str]) -> str:
|
|
return f'Group {group_id}'
|
|
|
|
|
|
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 = _normalize_base64_payload(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 = _normalize_base64_payload(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):
|
|
file = msg.url or msg.path
|
|
if not file and msg.base64:
|
|
file = f'base64://{_normalize_base64_payload(msg.base64)}'
|
|
msg_list.append({'type': 'file', 'data': {'file': file, '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(
|
|
url=msg_data['data']['url'], 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': # 这里来应该传入转发消息组,暂时传入Quote
|
|
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(
|
|
url=msg.data['url'], 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': # 此处处理引用消息传入Quote
|
|
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='骰子'))
|
|
elif msg.type == 'json':
|
|
try:
|
|
raw = msg.data.get('data', {})
|
|
if isinstance(raw, str):
|
|
raw = json.loads(raw)
|
|
if isinstance(raw, dict):
|
|
_meta = raw.get('meta', {}) or {}
|
|
if isinstance(_meta, dict):
|
|
_detail = _meta.get('detail_1') or _meta.get('music') or _meta.get('news') or {}
|
|
else:
|
|
_detail = {}
|
|
if isinstance(_detail, dict):
|
|
preview = _detail.get('preview', '')
|
|
title = _detail.get('desc', '') or _detail.get('title', '')
|
|
url = _detail.get('qqdocurl', '') or _detail.get('jumpUrl', '')
|
|
else:
|
|
preview = title = url = ''
|
|
text = ' '.join([f'[{raw.get("app", "")}]', preview, title, url]).strip()
|
|
yiri_msg_list.append(platform_message.Plain(text=text or '[收到一张JSON卡片]'))
|
|
else:
|
|
yiri_msg_list.append(platform_message.Plain(text=str(raw)))
|
|
except Exception:
|
|
yiri_msg_list.append(platform_message.Plain(text='[收到一张JSON卡片]'))
|
|
|
|
chain = platform_message.MessageChain(yiri_msg_list)
|
|
|
|
return chain
|
|
|
|
|
|
class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|
def __init__(self):
|
|
self._group_name_cache: dict[typing.Union[int, str], tuple[str, float]] = {}
|
|
self._group_name_negative_cache: dict[typing.Union[int, str], float] = {}
|
|
self._group_member_info_cache: dict[
|
|
tuple[typing.Union[int, str], typing.Union[int, str]], tuple[dict, float]
|
|
] = {}
|
|
self._group_member_info_negative_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], float] = {}
|
|
|
|
@staticmethod
|
|
async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int):
|
|
return event.source_platform_object
|
|
|
|
async def _get_group_name(self, group_id: typing.Union[int, str], bot=None) -> str:
|
|
now = time.monotonic()
|
|
if group_id in self._group_name_cache:
|
|
group_name, expires_at = self._group_name_cache[group_id]
|
|
if expires_at > now:
|
|
return group_name
|
|
del self._group_name_cache[group_id]
|
|
if group_id in self._group_name_negative_cache:
|
|
expires_at = self._group_name_negative_cache[group_id]
|
|
if expires_at > now:
|
|
return ''
|
|
del self._group_name_negative_cache[group_id]
|
|
if bot is None:
|
|
return ''
|
|
try:
|
|
group_info = await asyncio.wait_for(
|
|
bot.get_group_info(group_id=group_id),
|
|
timeout=_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS,
|
|
)
|
|
except Exception:
|
|
self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS
|
|
return ''
|
|
group_name = _get_field(group_info, 'group_name') if isinstance(group_info, dict) else ''
|
|
if group_name:
|
|
self._group_name_cache[group_id] = (group_name, now + _GROUP_NAME_CACHE_TTL_SECONDS)
|
|
self._group_name_negative_cache.pop(group_id, None)
|
|
else:
|
|
self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS
|
|
return group_name
|
|
|
|
async def _get_group_member_info(
|
|
self,
|
|
group_id: typing.Union[int, str],
|
|
user_id: typing.Union[int, str],
|
|
bot=None,
|
|
) -> dict:
|
|
now = time.monotonic()
|
|
cache_key = (group_id, user_id)
|
|
if cache_key in self._group_member_info_cache:
|
|
member_info, expires_at = self._group_member_info_cache[cache_key]
|
|
if expires_at > now:
|
|
return member_info
|
|
del self._group_member_info_cache[cache_key]
|
|
if cache_key in self._group_member_info_negative_cache:
|
|
expires_at = self._group_member_info_negative_cache[cache_key]
|
|
if expires_at > now:
|
|
return {}
|
|
del self._group_member_info_negative_cache[cache_key]
|
|
if bot is None:
|
|
return {}
|
|
try:
|
|
member_info = await asyncio.wait_for(
|
|
bot.get_group_member_info(group_id=group_id, user_id=user_id),
|
|
timeout=_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS,
|
|
)
|
|
except Exception:
|
|
self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS
|
|
return {}
|
|
if isinstance(member_info, dict) and member_info:
|
|
self._group_member_info_cache[cache_key] = (
|
|
member_info,
|
|
now + _GROUP_MEMBER_INFO_CACHE_TTL_SECONDS,
|
|
)
|
|
self._group_member_info_negative_cache.pop(cache_key, None)
|
|
return member_info
|
|
self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS
|
|
return {}
|
|
|
|
async def target2yiri(self, event: aiocqhttp.Event, bot=None):
|
|
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot)
|
|
|
|
if event.message_type == 'group':
|
|
permission = 'MEMBER'
|
|
group_name = await self._get_group_name(event.group_id, bot) or _get_group_name_placeholder(event.group_id)
|
|
special_title = _get_field(event.sender, 'title')
|
|
if not special_title:
|
|
member_info = await self._get_group_member_info(event.group_id, event.sender['user_id'], bot)
|
|
special_title = _get_field(member_info, 'title')
|
|
|
|
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=_get_group_member_name(event.sender),
|
|
permission=permission,
|
|
group=platform_entities.Group(
|
|
id=event.group_id,
|
|
name=group_name,
|
|
permission=platform_entities.Permission.Member,
|
|
),
|
|
special_title=special_title,
|
|
),
|
|
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 = pydantic.Field(default_factory=AiocqhttpEventConverter)
|
|
|
|
on_websocket_connection_event_cache: list[aiocqhttp.Event] = []
|
|
_listener_wrappers: dict[
|
|
tuple[typing.Type[platform_events.Event], typing.Callable],
|
|
tuple[str, typing.Callable],
|
|
] = {}
|
|
|
|
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 = []
|
|
self._listener_wrappers = {}
|
|
|
|
if 'access-token' in config:
|
|
self.bot = aiocqhttp.CQHttp(access_token=config['access-token'])
|
|
del self.config['access-token']
|
|
else:
|
|
self.bot = aiocqhttp.CQHttp()
|
|
|
|
self.bot.on_websocket_connection(self._on_websocket_connection)
|
|
|
|
async def _on_websocket_connection(self, event: aiocqhttp.Event):
|
|
for cached_event in self.on_websocket_connection_event_cache:
|
|
if cached_event.self_id == event.self_id and cached_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}')
|
|
|
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
|
# Check if message contains a Forward component
|
|
forward_msg = message.get_first(platform_message.Forward)
|
|
if forward_msg:
|
|
if target_type == 'group':
|
|
# Send as merged forward message via OneBot API
|
|
await self._send_forward_message(int(target_id), forward_msg)
|
|
return
|
|
else:
|
|
await self.logger.warning(
|
|
f'Forward message is only supported for group targets, got target_type={target_type}. Falling through to normal send.'
|
|
)
|
|
|
|
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 _send_forward_message(self, group_id: int, forward: platform_message.Forward):
|
|
"""Send a merged forward message to a group using NapCat extended API."""
|
|
messages = []
|
|
|
|
for node in forward.node_list:
|
|
# Build content for each node
|
|
content = []
|
|
if node.message_chain:
|
|
for component in node.message_chain:
|
|
if isinstance(component, platform_message.Plain):
|
|
if component.text:
|
|
content.append({'type': 'text', 'data': {'text': component.text}})
|
|
elif isinstance(component, platform_message.Image):
|
|
img_data = {}
|
|
if component.base64:
|
|
b64 = _normalize_base64_payload(component.base64)
|
|
img_data['file'] = f'base64://{b64}'
|
|
elif component.url:
|
|
img_data['file'] = component.url
|
|
elif component.path:
|
|
img_data['file'] = str(component.path)
|
|
|
|
if img_data:
|
|
content.append({'type': 'image', 'data': img_data})
|
|
|
|
if not content:
|
|
continue
|
|
|
|
# Build node data - use user_id and nickname format for NapCat
|
|
user_id = str(node.sender_id) if node.sender_id else str(self.bot_account_id or '10000')
|
|
node_data = {
|
|
'type': 'node',
|
|
'data': {
|
|
'user_id': user_id,
|
|
'nickname': node.sender_name or '未知',
|
|
'content': content,
|
|
},
|
|
}
|
|
|
|
messages.append(node_data)
|
|
|
|
if not messages:
|
|
return
|
|
|
|
# Build the full message payload for NapCat's send_forward_msg API
|
|
# This matches the format used by GiveMeSetuPlugin
|
|
bot_id = str(self.bot_account_id) if self.bot_account_id else '10000'
|
|
payload = {
|
|
'group_id': group_id,
|
|
'user_id': bot_id, # Required by NapCat for display
|
|
'messages': messages,
|
|
}
|
|
|
|
# Add display settings if available
|
|
if forward.display:
|
|
if forward.display.title:
|
|
payload['news'] = [{'text': forward.display.title}]
|
|
if forward.display.brief:
|
|
payload['prompt'] = forward.display.brief
|
|
if forward.display.summary:
|
|
payload['summary'] = forward.display.summary
|
|
if forward.display.source:
|
|
payload['source'] = forward.display.source
|
|
|
|
try:
|
|
# Use send_forward_msg (NapCat extended API) instead of send_group_forward_msg
|
|
await self.logger.info(
|
|
f'Sending forward message to group {group_id} with {len(messages)} nodes, payload keys: {list(payload.keys())}'
|
|
)
|
|
result = await self.bot.call_action('send_forward_msg', **payload)
|
|
await self.logger.info(f'Forward message sent to group {group_id}, result: {result}')
|
|
except Exception as e:
|
|
await self.logger.error(f'Failed to send forward message to group {group_id}: {e}')
|
|
# Fallback: try standard OneBot API with integer group_id
|
|
try:
|
|
await self.logger.info('Trying fallback API send_group_forward_msg')
|
|
await self.bot.call_action('send_group_forward_msg', group_id=group_id, messages=messages)
|
|
await self.logger.info(f'Forward message sent via fallback API to group {group_id}')
|
|
except Exception as e2:
|
|
await self.logger.error(f'Fallback also failed: {e2}')
|
|
raise
|
|
|
|
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._listener_wrappers[(event_type, callback)] = ('message.group', on_message)
|
|
# self.bot.on_notice()(on_message)
|
|
elif event_type == platform_events.FriendMessage:
|
|
self.bot.on_message('private')(on_message)
|
|
self._listener_wrappers[(event_type, callback)] = ('message.private', on_message)
|
|
# self.bot.on_notice()(on_message)
|
|
# print(event_type)
|
|
|
|
def unregister_listener(
|
|
self,
|
|
event_type: typing.Type[platform_events.Event],
|
|
callback: typing.Callable[
|
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
|
],
|
|
):
|
|
listener = self._listener_wrappers.pop((event_type, callback), None)
|
|
if listener is None:
|
|
return
|
|
|
|
event_name, wrapper = listener
|
|
self.bot._bus.unsubscribe(event_name, wrapper)
|
|
|
|
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
|