mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Merge branch 'rc/new-plugin' into refactor/new-plugin-system
This commit is contained in:
@@ -115,8 +115,10 @@ class RuntimeBot:
|
||||
if isinstance(e, asyncio.CancelledError):
|
||||
self.task_context.set_current_action('Exited.')
|
||||
return
|
||||
|
||||
traceback_str = traceback.format_exc()
|
||||
self.task_context.set_current_action('Exited with error.')
|
||||
await self.logger.error(f'平台适配器运行出错:\n{e}\n{traceback.format_exc()}')
|
||||
await self.logger.error(f'平台适配器运行出错:\n{e}\n{traceback_str}')
|
||||
|
||||
self.task_wrapper = self.ap.task_mgr.create_task(
|
||||
exception_wrapper(),
|
||||
@@ -169,8 +171,8 @@ class PlatformManager:
|
||||
{},
|
||||
webchat_logger,
|
||||
ap=self.ap,
|
||||
is_stream=False,
|
||||
)
|
||||
webchat_adapter_inst.ap = self.ap
|
||||
|
||||
self.webchat_proxy_bot = RuntimeBot(
|
||||
ap=self.ap,
|
||||
|
||||
@@ -120,7 +120,7 @@ class EventLogger(abstract_platform_event_logger.AbstractEventLogger):
|
||||
async def _truncate_logs(self):
|
||||
if len(self.logs) > MAX_LOG_COUNT:
|
||||
for i in range(DELETE_COUNT_PER_TIME):
|
||||
for image_key in self.logs[i].images:
|
||||
for image_key in self.logs[i].images: # type: ignore
|
||||
await self.ap.storage_mgr.storage_provider.delete(image_key)
|
||||
self.logs = self.logs[DELETE_COUNT_PER_TIME:]
|
||||
|
||||
|
||||
@@ -61,13 +61,13 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
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}})
|
||||
msg_list.append({'type': 'file', 'data': {'file': msg.url, 'name': msg.name}})
|
||||
elif isinstance(msg, platform_message.Face):
|
||||
if msg.face_type=='face':
|
||||
if msg.face_type == 'face':
|
||||
msg_list.append(aiocqhttp.MessageSegment.face(msg.face_id))
|
||||
elif msg.face_type=='rps':
|
||||
elif msg.face_type == 'rps':
|
||||
msg_list.append(aiocqhttp.MessageSegment.rps())
|
||||
elif msg.face_type=='dice':
|
||||
elif msg.face_type == 'dice':
|
||||
msg_list.append(aiocqhttp.MessageSegment.dice())
|
||||
|
||||
else:
|
||||
@@ -76,44 +76,130 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
return msg_list, msg_id, msg_time
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message: str, message_id: int = -1, bot=None):
|
||||
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": "生气"
|
||||
'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,'')
|
||||
return face_code_dict.get(face_id, '')
|
||||
|
||||
async def process_message_data(msg_data, reply_list):
|
||||
if msg_data['type'] == 'image':
|
||||
@@ -156,10 +242,10 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
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)
|
||||
emoji_id = msg.data.get('emoji_package_id', None)
|
||||
if emoji_id:
|
||||
face_id = emoji_id
|
||||
face_name = msg.data.get("summary", '')
|
||||
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'])
|
||||
@@ -185,27 +271,28 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
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))
|
||||
# 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('/','')))
|
||||
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='猜拳'))
|
||||
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='骰子'))
|
||||
yiri_msg_list.append(platform_message.Face(face_type='dice', face_id=int(face_id), face_name='骰子'))
|
||||
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
@@ -221,7 +308,6 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
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'
|
||||
|
||||
|
||||
@@ -96,10 +96,16 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
message_converter: DingTalkMessageConverter = DingTalkMessageConverter()
|
||||
event_converter: DingTalkEventConverter = DingTalkEventConverter()
|
||||
config: dict
|
||||
card_instance_id_dict: (
|
||||
dict # 回复卡片消息字典,key为消息id,value为回复卡片实例id,用于在流式消息时判断是否发送到指定卡片
|
||||
)
|
||||
seq: int # 消息顺序,直接以seq作为标识
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
self.config = config
|
||||
self.logger = logger
|
||||
self.card_instance_id_dict = {}
|
||||
# self.seq = 1
|
||||
required_keys = [
|
||||
'client_id',
|
||||
'client_secret',
|
||||
@@ -112,6 +118,15 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
self.bot_account_id = self.config['robot_name']
|
||||
|
||||
self.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=self.logger,
|
||||
)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -126,6 +141,33 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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]
|
||||
# print(card_instance_id)
|
||||
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':
|
||||
@@ -133,6 +175,20 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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],
|
||||
@@ -155,15 +211,6 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
self.bot.on_message('GroupMessage')(on_message)
|
||||
|
||||
async def run_async(self):
|
||||
config = self.config
|
||||
self.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=self.logger,
|
||||
)
|
||||
await self.bot.start()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
|
||||
@@ -46,6 +46,23 @@ spec:
|
||||
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
|
||||
|
||||
@@ -8,6 +8,8 @@ import base64
|
||||
import uuid
|
||||
import os
|
||||
import datetime
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
|
||||
import aiohttp
|
||||
import pydantic
|
||||
@@ -17,6 +19,568 @@ 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 ..logger import EventLogger
|
||||
|
||||
|
||||
# 语音功能相关异常定义
|
||||
class VoiceConnectionError(Exception):
|
||||
"""语音连接基础异常"""
|
||||
|
||||
def __init__(self, message: str, error_code: str = None, guild_id: int = None):
|
||||
super().__init__(message)
|
||||
self.error_code = error_code
|
||||
self.guild_id = guild_id
|
||||
self.timestamp = datetime.datetime.now()
|
||||
|
||||
|
||||
class VoicePermissionError(VoiceConnectionError):
|
||||
"""语音权限异常"""
|
||||
|
||||
def __init__(self, message: str, missing_permissions: list = None, user_id: int = None, channel_id: int = None):
|
||||
super().__init__(message, 'PERMISSION_ERROR')
|
||||
self.missing_permissions = missing_permissions or []
|
||||
self.user_id = user_id
|
||||
self.channel_id = channel_id
|
||||
|
||||
|
||||
class VoiceNetworkError(VoiceConnectionError):
|
||||
"""语音网络异常"""
|
||||
|
||||
def __init__(self, message: str, retry_count: int = 0):
|
||||
super().__init__(message, 'NETWORK_ERROR')
|
||||
self.retry_count = retry_count
|
||||
self.last_attempt = datetime.datetime.now()
|
||||
|
||||
|
||||
class VoiceConnectionStatus(Enum):
|
||||
"""语音连接状态枚举"""
|
||||
|
||||
IDLE = 'idle'
|
||||
CONNECTING = 'connecting'
|
||||
CONNECTED = 'connected'
|
||||
PLAYING = 'playing'
|
||||
RECONNECTING = 'reconnecting'
|
||||
FAILED = 'failed'
|
||||
|
||||
|
||||
class VoiceConnectionInfo:
|
||||
"""
|
||||
语音连接信息类
|
||||
|
||||
用于存储和管理单个语音连接的详细信息,包括连接状态、时间戳、
|
||||
频道信息等。提供连接信息的标准化数据结构。
|
||||
|
||||
@author: @ydzat
|
||||
@version: 1.0
|
||||
@since: 2025-07-04
|
||||
"""
|
||||
|
||||
def __init__(self, guild_id: int, channel_id: int, channel_name: str = None):
|
||||
"""
|
||||
初始化语音连接信息
|
||||
|
||||
@author: @ydzat
|
||||
|
||||
Args:
|
||||
guild_id (int): 服务器ID
|
||||
channel_id (int): 语音频道ID
|
||||
channel_name (str, optional): 语音频道名称
|
||||
"""
|
||||
self.guild_id = guild_id
|
||||
self.channel_id = channel_id
|
||||
self.channel_name = channel_name or f'Channel-{channel_id}'
|
||||
self.connected = False
|
||||
self.connection_time: datetime.datetime = None
|
||||
self.last_activity = datetime.datetime.now()
|
||||
self.status = VoiceConnectionStatus.IDLE
|
||||
self.user_count = 0
|
||||
self.latency = 0.0
|
||||
self.connection_health = 'unknown'
|
||||
self.voice_client = None
|
||||
|
||||
def update_status(self, status: VoiceConnectionStatus):
|
||||
"""
|
||||
更新连接状态
|
||||
|
||||
@author: @ydzat
|
||||
|
||||
Args:
|
||||
status (VoiceConnectionStatus): 新的连接状态
|
||||
"""
|
||||
self.status = status
|
||||
self.last_activity = datetime.datetime.now()
|
||||
|
||||
if status == VoiceConnectionStatus.CONNECTED:
|
||||
self.connected = True
|
||||
if self.connection_time is None:
|
||||
self.connection_time = datetime.datetime.now()
|
||||
elif status in [VoiceConnectionStatus.IDLE, VoiceConnectionStatus.FAILED]:
|
||||
self.connected = False
|
||||
self.connection_time = None
|
||||
self.voice_client = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""
|
||||
转换为字典格式
|
||||
|
||||
@author: @ydzat
|
||||
|
||||
Returns:
|
||||
dict: 连接信息的字典表示
|
||||
"""
|
||||
return {
|
||||
'guild_id': self.guild_id,
|
||||
'channel_id': self.channel_id,
|
||||
'channel_name': self.channel_name,
|
||||
'connected': self.connected,
|
||||
'connection_time': self.connection_time.isoformat() if self.connection_time else None,
|
||||
'last_activity': self.last_activity.isoformat(),
|
||||
'status': self.status.value,
|
||||
'user_count': self.user_count,
|
||||
'latency': self.latency,
|
||||
'connection_health': self.connection_health,
|
||||
}
|
||||
|
||||
|
||||
class VoiceConnectionManager:
|
||||
"""
|
||||
语音连接管理器
|
||||
|
||||
负责管理多个服务器的语音连接,提供连接建立、断开、状态查询等功能。
|
||||
采用单例模式确保全局只有一个连接管理器实例。
|
||||
|
||||
@author: @ydzat
|
||||
@version: 1.0
|
||||
@since: 2025-07-04
|
||||
"""
|
||||
|
||||
def __init__(self, bot: discord.Client, logger: EventLogger):
|
||||
"""
|
||||
初始化语音连接管理器
|
||||
|
||||
@author: @ydzat
|
||||
|
||||
Args:
|
||||
bot (discord.Client): Discord 客户端实例
|
||||
logger (EventLogger): 事件日志记录器
|
||||
"""
|
||||
self.bot = bot
|
||||
self.logger = logger
|
||||
self.connections: typing.Dict[int, VoiceConnectionInfo] = {}
|
||||
self._connection_lock = asyncio.Lock()
|
||||
self._cleanup_task = None
|
||||
self._monitoring_enabled = True
|
||||
|
||||
async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient:
|
||||
"""
|
||||
加入语音频道
|
||||
|
||||
验证用户权限和频道状态后,建立到指定语音频道的连接。
|
||||
支持连接复用和自动重连机制。
|
||||
|
||||
@author: @ydzat
|
||||
|
||||
Args:
|
||||
guild_id (int): 服务器ID
|
||||
channel_id (int): 语音频道ID
|
||||
user_id (int, optional): 请求用户ID,用于权限验证
|
||||
|
||||
Returns:
|
||||
discord.VoiceClient: 语音客户端实例
|
||||
|
||||
Raises:
|
||||
VoicePermissionError: 权限不足时抛出
|
||||
VoiceNetworkError: 网络连接失败时抛出
|
||||
VoiceConnectionError: 其他连接错误时抛出
|
||||
"""
|
||||
async with self._connection_lock:
|
||||
try:
|
||||
# 获取服务器和频道对象
|
||||
guild = self.bot.get_guild(guild_id)
|
||||
if not guild:
|
||||
raise VoiceConnectionError(f'无法找到服务器 {guild_id}', 'GUILD_NOT_FOUND', guild_id)
|
||||
|
||||
channel = guild.get_channel(channel_id)
|
||||
if not channel or not isinstance(channel, discord.VoiceChannel):
|
||||
raise VoiceConnectionError(f'无法找到语音频道 {channel_id}', 'CHANNEL_NOT_FOUND', guild_id)
|
||||
|
||||
# 验证用户是否在语音频道中(如果提供了用户ID)
|
||||
if user_id:
|
||||
await self._validate_user_in_channel(guild, channel, user_id)
|
||||
|
||||
# 验证机器人权限
|
||||
await self._validate_bot_permissions(channel)
|
||||
|
||||
# 检查是否已有连接
|
||||
if guild_id in self.connections:
|
||||
existing_conn = self.connections[guild_id]
|
||||
if existing_conn.connected and existing_conn.voice_client:
|
||||
if existing_conn.channel_id == channel_id:
|
||||
# 已连接到相同频道,返回现有连接
|
||||
await self.logger.info(f'复用现有语音连接: {guild.name} -> {channel.name}')
|
||||
return existing_conn.voice_client
|
||||
else:
|
||||
# 连接到不同频道,先断开旧连接
|
||||
await self._disconnect_internal(guild_id)
|
||||
|
||||
# 建立新连接
|
||||
voice_client = await channel.connect()
|
||||
|
||||
# 更新连接信息
|
||||
conn_info = VoiceConnectionInfo(guild_id, channel_id, channel.name)
|
||||
conn_info.voice_client = voice_client
|
||||
conn_info.update_status(VoiceConnectionStatus.CONNECTED)
|
||||
conn_info.user_count = len(channel.members)
|
||||
self.connections[guild_id] = conn_info
|
||||
|
||||
await self.logger.info(f'成功连接到语音频道: {guild.name} -> {channel.name}')
|
||||
return voice_client
|
||||
|
||||
except discord.ClientException as e:
|
||||
raise VoiceNetworkError(f'Discord 客户端错误: {str(e)}')
|
||||
except discord.opus.OpusNotLoaded as e:
|
||||
raise VoiceConnectionError(f'Opus 编码器未加载: {str(e)}', 'OPUS_NOT_LOADED', guild_id)
|
||||
except Exception as e:
|
||||
await self.logger.error(f'连接语音频道时发生未知错误: {str(e)}')
|
||||
raise VoiceConnectionError(f'连接失败: {str(e)}', 'UNKNOWN_ERROR', guild_id)
|
||||
|
||||
async def leave_voice_channel(self, guild_id: int) -> bool:
|
||||
"""
|
||||
离开语音频道
|
||||
|
||||
断开指定服务器的语音连接,清理相关资源和状态信息。
|
||||
确保音频播放停止后再断开连接。
|
||||
|
||||
@author: @ydzat
|
||||
|
||||
Args:
|
||||
guild_id (int): 服务器ID
|
||||
|
||||
Returns:
|
||||
bool: 断开是否成功
|
||||
"""
|
||||
async with self._connection_lock:
|
||||
return await self._disconnect_internal(guild_id)
|
||||
|
||||
async def _disconnect_internal(self, guild_id: int) -> bool:
|
||||
"""
|
||||
内部断开连接方法
|
||||
|
||||
@author: @ydzat
|
||||
|
||||
Args:
|
||||
guild_id (int): 服务器ID
|
||||
|
||||
Returns:
|
||||
bool: 断开是否成功
|
||||
"""
|
||||
if guild_id not in self.connections:
|
||||
return True
|
||||
|
||||
conn_info = self.connections[guild_id]
|
||||
|
||||
try:
|
||||
if conn_info.voice_client and conn_info.voice_client.is_connected():
|
||||
# 停止当前播放
|
||||
if conn_info.voice_client.is_playing():
|
||||
conn_info.voice_client.stop()
|
||||
|
||||
# 等待播放完全停止
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# 断开连接
|
||||
await conn_info.voice_client.disconnect()
|
||||
|
||||
conn_info.update_status(VoiceConnectionStatus.IDLE)
|
||||
del self.connections[guild_id]
|
||||
|
||||
await self.logger.info(f'已断开语音连接: Guild {guild_id}')
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
await self.logger.error(f'断开语音连接时发生错误: {str(e)}')
|
||||
# 即使出错也要清理连接记录
|
||||
conn_info.update_status(VoiceConnectionStatus.FAILED)
|
||||
if guild_id in self.connections:
|
||||
del self.connections[guild_id]
|
||||
return False
|
||||
|
||||
async def get_voice_client(self, guild_id: int) -> typing.Optional[discord.VoiceClient]:
|
||||
"""
|
||||
获取语音客户端
|
||||
|
||||
返回指定服务器的语音客户端实例,如果未连接则返回 None。
|
||||
会验证连接的有效性,自动清理无效连接。
|
||||
|
||||
@author: @ydzat
|
||||
|
||||
Args:
|
||||
guild_id (int): 服务器ID
|
||||
|
||||
Returns:
|
||||
Optional[discord.VoiceClient]: 语音客户端实例或 None
|
||||
"""
|
||||
if guild_id not in self.connections:
|
||||
return None
|
||||
|
||||
conn_info = self.connections[guild_id]
|
||||
|
||||
# 验证连接是否仍然有效
|
||||
if conn_info.voice_client and not conn_info.voice_client.is_connected():
|
||||
# 连接已失效,清理状态
|
||||
await self._disconnect_internal(guild_id)
|
||||
return None
|
||||
|
||||
return conn_info.voice_client if conn_info.connected else None
|
||||
|
||||
async def is_connected_to_voice(self, guild_id: int) -> bool:
|
||||
"""
|
||||
检查是否连接到语音频道
|
||||
|
||||
@author: @ydzat
|
||||
|
||||
Args:
|
||||
guild_id (int): 服务器ID
|
||||
|
||||
Returns:
|
||||
bool: 是否已连接
|
||||
"""
|
||||
if guild_id not in self.connections:
|
||||
return False
|
||||
|
||||
conn_info = self.connections[guild_id]
|
||||
|
||||
# 检查实际连接状态
|
||||
if conn_info.voice_client and not conn_info.voice_client.is_connected():
|
||||
# 连接已失效,清理状态
|
||||
await self._disconnect_internal(guild_id)
|
||||
return False
|
||||
|
||||
return conn_info.connected
|
||||
|
||||
async def get_connection_status(self, guild_id: int) -> typing.Optional[dict]:
|
||||
"""
|
||||
获取连接状态信息
|
||||
|
||||
@author: @ydzat
|
||||
|
||||
Args:
|
||||
guild_id (int): 服务器ID
|
||||
|
||||
Returns:
|
||||
Optional[dict]: 连接状态信息字典或 None
|
||||
"""
|
||||
if guild_id not in self.connections:
|
||||
return None
|
||||
|
||||
conn_info = self.connections[guild_id]
|
||||
|
||||
# 更新实时信息
|
||||
if conn_info.voice_client and conn_info.voice_client.is_connected():
|
||||
conn_info.latency = conn_info.voice_client.latency * 1000 # 转换为毫秒
|
||||
conn_info.connection_health = 'good' if conn_info.latency < 100 else 'poor'
|
||||
|
||||
# 更新频道用户数
|
||||
guild = self.bot.get_guild(guild_id)
|
||||
if guild:
|
||||
channel = guild.get_channel(conn_info.channel_id)
|
||||
if channel and isinstance(channel, discord.VoiceChannel):
|
||||
conn_info.user_count = len(channel.members)
|
||||
|
||||
return conn_info.to_dict()
|
||||
|
||||
async def list_active_connections(self) -> typing.List[dict]:
|
||||
"""
|
||||
列出所有活跃连接
|
||||
|
||||
@author: @ydzat
|
||||
|
||||
Returns:
|
||||
List[dict]: 活跃连接列表
|
||||
"""
|
||||
active_connections = []
|
||||
|
||||
for guild_id, conn_info in self.connections.items():
|
||||
if conn_info.connected:
|
||||
status = await self.get_connection_status(guild_id)
|
||||
if status:
|
||||
active_connections.append(status)
|
||||
|
||||
return active_connections
|
||||
|
||||
async def get_voice_channel_info(self, guild_id: int, channel_id: int) -> typing.Optional[dict]:
|
||||
"""
|
||||
获取语音频道信息
|
||||
|
||||
@author: @ydzat
|
||||
|
||||
Args:
|
||||
guild_id (int): 服务器ID
|
||||
channel_id (int): 频道ID
|
||||
|
||||
Returns:
|
||||
Optional[dict]: 频道信息字典或 None
|
||||
"""
|
||||
guild = self.bot.get_guild(guild_id)
|
||||
if not guild:
|
||||
return None
|
||||
|
||||
channel = guild.get_channel(channel_id)
|
||||
if not channel or not isinstance(channel, discord.VoiceChannel):
|
||||
return None
|
||||
|
||||
# 获取用户信息
|
||||
users = []
|
||||
for member in channel.members:
|
||||
users.append(
|
||||
{'id': member.id, 'name': member.display_name, 'status': str(member.status), 'is_bot': member.bot}
|
||||
)
|
||||
|
||||
# 获取权限信息
|
||||
bot_member = guild.me
|
||||
permissions = channel.permissions_for(bot_member)
|
||||
|
||||
return {
|
||||
'channel_id': channel_id,
|
||||
'channel_name': channel.name,
|
||||
'guild_id': guild_id,
|
||||
'guild_name': guild.name,
|
||||
'user_limit': channel.user_limit,
|
||||
'current_users': users,
|
||||
'user_count': len(users),
|
||||
'bitrate': channel.bitrate,
|
||||
'permissions': {
|
||||
'connect': permissions.connect,
|
||||
'speak': permissions.speak,
|
||||
'use_voice_activation': permissions.use_voice_activation,
|
||||
'priority_speaker': permissions.priority_speaker,
|
||||
},
|
||||
}
|
||||
|
||||
async def _validate_user_in_channel(self, guild: discord.Guild, channel: discord.VoiceChannel, user_id: int):
|
||||
"""
|
||||
验证用户是否在语音频道中
|
||||
|
||||
@author: @ydzat
|
||||
|
||||
Args:
|
||||
guild: Discord 服务器对象
|
||||
channel: 语音频道对象
|
||||
user_id: 用户ID
|
||||
|
||||
Raises:
|
||||
VoicePermissionError: 用户不在频道中时抛出
|
||||
"""
|
||||
member = guild.get_member(user_id)
|
||||
if not member:
|
||||
raise VoicePermissionError(f'无法找到用户 {user_id}', ['member_not_found'], user_id, channel.id)
|
||||
|
||||
if not member.voice or member.voice.channel != channel:
|
||||
raise VoicePermissionError(
|
||||
f'用户 {member.display_name} 不在语音频道 {channel.name} 中',
|
||||
['user_not_in_channel'],
|
||||
user_id,
|
||||
channel.id,
|
||||
)
|
||||
|
||||
async def _validate_bot_permissions(self, channel: discord.VoiceChannel):
|
||||
"""
|
||||
验证机器人权限
|
||||
|
||||
@author: @ydzat
|
||||
|
||||
Args:
|
||||
channel: 语音频道对象
|
||||
|
||||
Raises:
|
||||
VoicePermissionError: 权限不足时抛出
|
||||
"""
|
||||
bot_member = channel.guild.me
|
||||
permissions = channel.permissions_for(bot_member)
|
||||
|
||||
missing_permissions = []
|
||||
|
||||
if not permissions.connect:
|
||||
missing_permissions.append('connect')
|
||||
if not permissions.speak:
|
||||
missing_permissions.append('speak')
|
||||
|
||||
if missing_permissions:
|
||||
raise VoicePermissionError(
|
||||
f'机器人在频道 {channel.name} 中缺少权限: {", ".join(missing_permissions)}',
|
||||
missing_permissions,
|
||||
channel_id=channel.id,
|
||||
)
|
||||
|
||||
async def cleanup_inactive_connections(self):
|
||||
"""
|
||||
清理无效连接
|
||||
|
||||
定期检查并清理已断开或无效的语音连接,释放资源。
|
||||
|
||||
@author: @ydzat
|
||||
"""
|
||||
cleanup_guilds = []
|
||||
|
||||
for guild_id, conn_info in self.connections.items():
|
||||
if not conn_info.voice_client or not conn_info.voice_client.is_connected():
|
||||
cleanup_guilds.append(guild_id)
|
||||
|
||||
for guild_id in cleanup_guilds:
|
||||
await self._disconnect_internal(guild_id)
|
||||
|
||||
if cleanup_guilds:
|
||||
await self.logger.info(f'清理了 {len(cleanup_guilds)} 个无效的语音连接')
|
||||
|
||||
async def start_monitoring(self):
|
||||
"""
|
||||
开始连接监控
|
||||
|
||||
@author: @ydzat
|
||||
"""
|
||||
if self._cleanup_task is None and self._monitoring_enabled:
|
||||
self._cleanup_task = asyncio.create_task(self._monitoring_loop())
|
||||
|
||||
async def stop_monitoring(self):
|
||||
"""
|
||||
停止连接监控
|
||||
|
||||
@author: @ydzat
|
||||
"""
|
||||
self._monitoring_enabled = False
|
||||
if self._cleanup_task:
|
||||
self._cleanup_task.cancel()
|
||||
try:
|
||||
await self._cleanup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._cleanup_task = None
|
||||
|
||||
async def _monitoring_loop(self):
|
||||
"""
|
||||
监控循环
|
||||
|
||||
@author: @ydzat
|
||||
"""
|
||||
try:
|
||||
while self._monitoring_enabled:
|
||||
await asyncio.sleep(60) # 每分钟检查一次
|
||||
await self.cleanup_inactive_connections()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def disconnect_all(self):
|
||||
"""
|
||||
断开所有连接
|
||||
|
||||
@author: @ydzat
|
||||
"""
|
||||
async with self._connection_lock:
|
||||
guild_ids = list(self.connections.keys())
|
||||
for guild_id in guild_ids:
|
||||
await self._disconnect_internal(guild_id)
|
||||
|
||||
await self.stop_monitoring()
|
||||
|
||||
|
||||
class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@@ -35,28 +599,89 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
|
||||
for ele in message_chain:
|
||||
if isinstance(ele, platform_message.Image):
|
||||
image_bytes = None
|
||||
filename = f'{uuid.uuid4()}.png' # 默认文件名
|
||||
|
||||
if ele.base64:
|
||||
image_bytes = base64.b64decode(ele.base64)
|
||||
# 处理base64编码的图片
|
||||
if ele.base64.startswith('data:'):
|
||||
# 从data URL中提取文件类型
|
||||
data_header = ele.base64.split(',')[0]
|
||||
if 'jpeg' in data_header or 'jpg' in data_header:
|
||||
filename = f'{uuid.uuid4()}.jpg'
|
||||
elif 'gif' in data_header:
|
||||
filename = f'{uuid.uuid4()}.gif'
|
||||
elif 'webp' in data_header:
|
||||
filename = f'{uuid.uuid4()}.webp'
|
||||
# 去掉data:image/xxx;base64,前缀
|
||||
base64_data = ele.base64.split(',')[1]
|
||||
else:
|
||||
base64_data = ele.base64
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
elif ele.url:
|
||||
# 从URL下载图片
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(ele.url) as response:
|
||||
image_bytes = await response.read()
|
||||
# 从URL或Content-Type推断文件类型
|
||||
content_type = response.headers.get('Content-Type', '')
|
||||
if 'jpeg' in content_type or 'jpg' in content_type:
|
||||
filename = f'{uuid.uuid4()}.jpg'
|
||||
elif 'gif' in content_type:
|
||||
filename = f'{uuid.uuid4()}.gif'
|
||||
elif 'webp' in content_type:
|
||||
filename = f'{uuid.uuid4()}.webp'
|
||||
elif ele.url.lower().endswith(('.jpg', '.jpeg')):
|
||||
filename = f'{uuid.uuid4()}.jpg'
|
||||
elif ele.url.lower().endswith('.gif'):
|
||||
filename = f'{uuid.uuid4()}.gif'
|
||||
elif ele.url.lower().endswith('.webp'):
|
||||
filename = f'{uuid.uuid4()}.webp'
|
||||
elif ele.path:
|
||||
with open(ele.path, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
# 从文件路径读取图片
|
||||
# 确保路径没有空字节
|
||||
clean_path = ele.path.replace('\x00', '')
|
||||
clean_path = os.path.abspath(clean_path)
|
||||
|
||||
image_files.append(discord.File(fp=image_bytes, filename=f'{uuid.uuid4()}.png'))
|
||||
if not os.path.exists(clean_path):
|
||||
continue # 跳过不存在的文件
|
||||
|
||||
try:
|
||||
with open(clean_path, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
# 从文件路径获取文件名,保持原始扩展名
|
||||
original_filename = os.path.basename(clean_path)
|
||||
if original_filename and '.' in original_filename:
|
||||
# 保持原始文件名的扩展名
|
||||
ext = original_filename.split('.')[-1].lower()
|
||||
filename = f'{uuid.uuid4()}.{ext}'
|
||||
else:
|
||||
# 如果没有扩展名,尝试从文件内容检测
|
||||
if image_bytes.startswith(b'\xff\xd8\xff'):
|
||||
filename = f'{uuid.uuid4()}.jpg'
|
||||
elif image_bytes.startswith(b'GIF'):
|
||||
filename = f'{uuid.uuid4()}.gif'
|
||||
elif image_bytes.startswith(b'RIFF') and b'WEBP' in image_bytes[:20]:
|
||||
filename = f'{uuid.uuid4()}.webp'
|
||||
# 默认保持PNG
|
||||
except Exception as e:
|
||||
print(f'Error reading image file {clean_path}: {e}')
|
||||
continue # 跳过读取失败的文件
|
||||
|
||||
if image_bytes:
|
||||
# 使用BytesIO创建文件对象,避免路径问题
|
||||
import io
|
||||
|
||||
image_files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
|
||||
elif isinstance(ele, platform_message.Plain):
|
||||
text_string += ele.text
|
||||
elif isinstance(ele, platform_message.Forward):
|
||||
for node in ele.node_list:
|
||||
(
|
||||
text_string,
|
||||
image_files,
|
||||
node_text,
|
||||
node_images,
|
||||
) = await DiscordMessageConverter.yiri2target(node.message_chain)
|
||||
text_string += text_string
|
||||
image_files.extend(image_files)
|
||||
text_string += node_text
|
||||
image_files.extend(node_images)
|
||||
|
||||
return text_string, image_files
|
||||
|
||||
@@ -165,11 +790,16 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
voice_manager: VoiceConnectionManager | None = pydantic.Field(exclude=True, default=None)
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
bot_account_id = config['client_id']
|
||||
|
||||
listeners = {}
|
||||
|
||||
# 初始化语音连接管理器
|
||||
# self.voice_manager: VoiceConnectionManager = None
|
||||
|
||||
adapter_self = self
|
||||
|
||||
class MyClient(discord.Client):
|
||||
@@ -196,10 +826,194 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot_account_id=bot_account_id,
|
||||
listeners=listeners,
|
||||
bot=bot,
|
||||
voice_manager=None,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# Voice functionality methods
|
||||
async def join_voice_channel(self, guild_id: int, channel_id: int, user_id: int = None) -> discord.VoiceClient:
|
||||
"""
|
||||
加入语音频道
|
||||
|
||||
为指定服务器的语音频道建立连接,支持用户权限验证和连接复用。
|
||||
|
||||
@author: @ydzat
|
||||
@version: 1.0
|
||||
@since: 2025-07-04
|
||||
|
||||
Args:
|
||||
guild_id (int): Discord 服务器ID
|
||||
channel_id (int): 语音频道ID
|
||||
user_id (int, optional): 请求用户ID,用于权限验证
|
||||
|
||||
Returns:
|
||||
discord.VoiceClient: 语音客户端实例
|
||||
|
||||
Raises:
|
||||
VoicePermissionError: 权限不足
|
||||
VoiceNetworkError: 网络连接失败
|
||||
VoiceConnectionError: 其他连接错误
|
||||
"""
|
||||
if not self.voice_manager:
|
||||
raise VoiceConnectionError('语音管理器未初始化', 'MANAGER_NOT_READY')
|
||||
|
||||
return await self.voice_manager.join_voice_channel(guild_id, channel_id, user_id)
|
||||
|
||||
async def leave_voice_channel(self, guild_id: int) -> bool:
|
||||
"""
|
||||
离开语音频道
|
||||
|
||||
断开指定服务器的语音连接,清理相关资源。
|
||||
|
||||
@author: @ydzat
|
||||
@version: 1.0
|
||||
@since: 2025-07-04
|
||||
|
||||
Args:
|
||||
guild_id (int): Discord 服务器ID
|
||||
|
||||
Returns:
|
||||
bool: 是否成功断开连接
|
||||
"""
|
||||
if not self.voice_manager:
|
||||
return False
|
||||
|
||||
return await self.voice_manager.leave_voice_channel(guild_id)
|
||||
|
||||
async def get_voice_client(self, guild_id: int) -> typing.Optional[discord.VoiceClient]:
|
||||
"""
|
||||
获取语音客户端
|
||||
|
||||
返回指定服务器的语音客户端实例,用于音频播放控制。
|
||||
|
||||
@author: @ydzat
|
||||
@version: 1.0
|
||||
@since: 2025-07-04
|
||||
|
||||
Args:
|
||||
guild_id (int): Discord 服务器ID
|
||||
|
||||
Returns:
|
||||
Optional[discord.VoiceClient]: 语音客户端实例或 None
|
||||
"""
|
||||
if not self.voice_manager:
|
||||
return None
|
||||
|
||||
return await self.voice_manager.get_voice_client(guild_id)
|
||||
|
||||
async def is_connected_to_voice(self, guild_id: int) -> bool:
|
||||
"""
|
||||
检查语音连接状态
|
||||
|
||||
@author: @ydzat
|
||||
@version: 1.0
|
||||
@since: 2025-07-04
|
||||
|
||||
Args:
|
||||
guild_id (int): Discord 服务器ID
|
||||
|
||||
Returns:
|
||||
bool: 是否已连接到语音频道
|
||||
"""
|
||||
if not self.voice_manager:
|
||||
return False
|
||||
|
||||
return await self.voice_manager.is_connected_to_voice(guild_id)
|
||||
|
||||
async def get_voice_connection_status(self, guild_id: int) -> typing.Optional[dict]:
|
||||
"""
|
||||
获取语音连接详细状态
|
||||
|
||||
返回包含连接时间、延迟、用户数等详细信息的状态字典。
|
||||
|
||||
@author: @ydzat
|
||||
@version: 1.0
|
||||
@since: 2025-07-04
|
||||
|
||||
Args:
|
||||
guild_id (int): Discord 服务器ID
|
||||
|
||||
Returns:
|
||||
Optional[dict]: 连接状态信息或 None
|
||||
"""
|
||||
if not self.voice_manager:
|
||||
return None
|
||||
|
||||
return await self.voice_manager.get_connection_status(guild_id)
|
||||
|
||||
async def list_active_voice_connections(self) -> typing.List[dict]:
|
||||
"""
|
||||
列出所有活跃的语音连接
|
||||
|
||||
@author: @ydzat
|
||||
@version: 1.0
|
||||
@since: 2025-07-04
|
||||
|
||||
Returns:
|
||||
List[dict]: 活跃语音连接列表
|
||||
"""
|
||||
if not self.voice_manager:
|
||||
return []
|
||||
|
||||
return await self.voice_manager.list_active_connections()
|
||||
|
||||
async def get_voice_channel_info(self, guild_id: int, channel_id: int) -> typing.Optional[dict]:
|
||||
"""
|
||||
获取语音频道详细信息
|
||||
|
||||
包括频道名称、用户列表、权限信息等。
|
||||
|
||||
@author: @ydzat
|
||||
@version: 1.0
|
||||
@since: 2025-07-04
|
||||
|
||||
Args:
|
||||
guild_id (int): Discord 服务器ID
|
||||
channel_id (int): 语音频道ID
|
||||
|
||||
Returns:
|
||||
Optional[dict]: 频道信息字典或 None
|
||||
"""
|
||||
if not self.voice_manager:
|
||||
return None
|
||||
|
||||
return await self.voice_manager.get_voice_channel_info(guild_id, channel_id)
|
||||
|
||||
async def cleanup_voice_connections(self):
|
||||
"""
|
||||
清理无效的语音连接
|
||||
|
||||
手动触发语音连接清理,移除已断开或无效的连接。
|
||||
|
||||
@author: @ydzat
|
||||
@version: 1.0
|
||||
@since: 2025-07-04
|
||||
"""
|
||||
if self.voice_manager:
|
||||
await self.voice_manager.cleanup_inactive_connections()
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
pass
|
||||
msg_to_send, image_files = await self.message_converter.yiri2target(message)
|
||||
|
||||
try:
|
||||
# 获取频道对象
|
||||
channel = self.bot.get_channel(int(target_id))
|
||||
if channel is None:
|
||||
# 如果本地缓存中没有,尝试从API获取
|
||||
channel = await self.bot.fetch_channel(int(target_id))
|
||||
|
||||
args = {
|
||||
'content': msg_to_send,
|
||||
}
|
||||
|
||||
if len(image_files) > 0:
|
||||
args['files'] = image_files
|
||||
|
||||
await channel.send(**args)
|
||||
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Discord send_message failed: {e}')
|
||||
raise e
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
@@ -247,9 +1061,31 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
self.listeners.pop(event_type)
|
||||
|
||||
async def run_async(self):
|
||||
"""
|
||||
启动 Discord 适配器
|
||||
|
||||
初始化语音管理器并启动 Discord 客户端连接。
|
||||
|
||||
@author: @ydzat (修改)
|
||||
"""
|
||||
async with self.bot:
|
||||
# 初始化语音管理器
|
||||
self.voice_manager = VoiceConnectionManager(self.bot, self.logger)
|
||||
await self.voice_manager.start_monitoring()
|
||||
|
||||
await self.logger.info('Discord 适配器语音功能已启用')
|
||||
await self.bot.start(self.config['token'], reconnect=True)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
"""
|
||||
关闭 Discord 适配器
|
||||
|
||||
清理语音连接并关闭 Discord 客户端。
|
||||
|
||||
@author: @ydzat (修改)
|
||||
"""
|
||||
if self.voice_manager:
|
||||
await self.voice_manager.disconnect_all()
|
||||
|
||||
await self.bot.close()
|
||||
return True
|
||||
|
||||
@@ -18,6 +18,7 @@ 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
|
||||
@@ -320,10 +321,17 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -334,7 +342,11 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
quart_app: quart.Quart = pydantic.Field(exclude=True)
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
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'])
|
||||
@@ -343,7 +355,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
data = await quart.request.json
|
||||
|
||||
if 'encrypt' in data:
|
||||
cipher = AESCipher(self.config['encrypt-key'])
|
||||
cipher = AESCipher(config['encrypt-key'])
|
||||
data = cipher.decrypt_string(data['encrypt'])
|
||||
data = json.loads(data)
|
||||
|
||||
@@ -398,16 +410,256 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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.ap.logger.debug(f'飞书卡片创建成功,卡片ID: {response.data.card_id}')
|
||||
self.card_id_dict[message_id] = response.data.card_id
|
||||
|
||||
card_id = response.data.card_id
|
||||
return card_id
|
||||
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'飞书卡片创建失败,错误信息: {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,
|
||||
@@ -446,6 +698,62 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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
|
||||
|
||||
@@ -495,4 +803,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -65,6 +65,16 @@ spec:
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import telegram
|
||||
import telegram.ext
|
||||
from telegram import Update
|
||||
@@ -136,6 +137,12 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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],
|
||||
@@ -149,6 +156,7 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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()}')
|
||||
|
||||
@@ -158,6 +166,8 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
msg_stream_id={},
|
||||
seq=1,
|
||||
bot=bot,
|
||||
application=application,
|
||||
bot_account_id='',
|
||||
@@ -195,6 +205,70 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
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
|
||||
|
||||
@@ -221,8 +295,12 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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
|
||||
|
||||
@@ -25,6 +25,16 @@ spec:
|
||||
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
|
||||
|
||||
@@ -9,8 +9,8 @@ import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platf
|
||||
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 ...core import app
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from ...core import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -21,17 +21,20 @@ class WebChatMessage(pydantic.BaseModel):
|
||||
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:
|
||||
@@ -46,20 +49,21 @@ class WebChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
webchat_person_session: WebChatSession = pydantic.Field(exclude=True, default_factory=WebChatSession)
|
||||
webchat_group_session: WebChatSession = pydantic.Field(exclude=True, default_factory=WebChatSession)
|
||||
|
||||
ap: app.Application = pydantic.Field(exclude=True) # set by bot manager
|
||||
|
||||
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)
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, ap: app.Application):
|
||||
ap: app.Application = pydantic.Field(exclude=True)
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
ap=ap,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.webchat_person_session = WebChatSession(id='webchatperson')
|
||||
@@ -112,12 +116,53 @@ class WebChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
# notify waiter
|
||||
if isinstance(message_source, platform_events.FriendMessage):
|
||||
self.webchat_person_session.resp_waiters[message_source.message_chain.message_id].set_result(message_data)
|
||||
await self.webchat_person_session.resp_queues[message_source.message_chain.message_id].put(message_data)
|
||||
elif isinstance(message_source, platform_events.GroupMessage):
|
||||
self.webchat_group_session.resp_waiters[message_source.message_chain.message_id].set_result(message_data)
|
||||
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],
|
||||
@@ -157,8 +202,13 @@ class WebChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.logger.info('WebChat调试适配器正在停止')
|
||||
|
||||
async def send_webchat_message(
|
||||
self, pipeline_uuid: str, session_type: str, message_chain_obj: typing.List[dict]
|
||||
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
|
||||
@@ -169,6 +219,9 @@ class WebChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
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,
|
||||
@@ -202,21 +255,46 @@ class WebChatAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
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)
|
||||
|
||||
# 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))
|
||||
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)
|
||||
|
||||
resp_message = await waiter
|
||||
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
|
||||
|
||||
resp_message.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
|
||||
|
||||
use_session.get_message_list(pipeline_uuid).append(resp_message)
|
||||
|
||||
return resp_message.model_dump()
|
||||
yield resp_message.model_dump()
|
||||
|
||||
def get_webchat_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
|
||||
"""获取调试消息历史"""
|
||||
|
||||
@@ -9,7 +9,8 @@ metadata:
|
||||
en_US: "WebChat adapter for pipeline debugging"
|
||||
zh_Hans: "用于流水线调试的网页聊天适配器"
|
||||
icon: ""
|
||||
spec: {}
|
||||
spec:
|
||||
config: []
|
||||
execution:
|
||||
python:
|
||||
path: "webchat.py"
|
||||
|
||||
@@ -11,13 +11,11 @@ import asyncio
|
||||
import traceback
|
||||
import re
|
||||
import base64
|
||||
import os
|
||||
import copy
|
||||
import threading
|
||||
|
||||
import quart
|
||||
|
||||
from ...core import app
|
||||
from ..logger import EventLogger
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional, Tuple
|
||||
@@ -27,21 +25,23 @@ 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):
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
self.config = config
|
||||
self.bot = WeChatPadClient(self.config['wechatpad_url'], self.config['token'])
|
||||
self.logger = logging.getLogger('WeChatPadMessageConverter')
|
||||
self.logger = logger
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:
|
||||
content_list = []
|
||||
_ = os.path.abspath(__file__)
|
||||
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.At):
|
||||
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})
|
||||
@@ -75,20 +75,34 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
|
||||
return content_list
|
||||
|
||||
async def target2yiri(self, message: dict, bot_account_id: str) -> platform_message.MessageChain:
|
||||
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())
|
||||
elif ats_bot:
|
||||
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']
|
||||
@@ -226,8 +240,8 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
# 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
|
||||
# quote_id = None # 引用消息的原发送者
|
||||
# tousername = None # 接收方: 所属微信的wxid
|
||||
user_data = '' # 用户消息
|
||||
sender_id = xml_data.findtext('.//fromusername') # 发送方:单聊用户/群member
|
||||
|
||||
@@ -235,13 +249,10 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
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')
|
||||
# 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']
|
||||
|
||||
_ = tousername
|
||||
_ = quote_id
|
||||
# if message:
|
||||
# tousername = message['to_user_name']['str']
|
||||
|
||||
if quote_data:
|
||||
quote_data_message_list = platform_message.MessageChain()
|
||||
@@ -397,6 +408,23 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
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:
|
||||
@@ -420,16 +448,20 @@ class WeChatPadMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
|
||||
|
||||
class WeChatPadEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
def __init__(self, config: dict):
|
||||
def __init__(self, config: dict, logger: logging.Logger):
|
||||
self.config = config
|
||||
self.message_converter = WeChatPadMessageConverter(config)
|
||||
self.logger = logging.getLogger('WeChatPadEventConverter')
|
||||
self.message_converter = WeChatPadMessageConverter(config, logger)
|
||||
self.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:
|
||||
async def target2yiri(
|
||||
self,
|
||||
event: dict,
|
||||
bot_account_id: str,
|
||||
) -> platform_events.MessageEvent:
|
||||
# 排除公众号以及微信团队消息
|
||||
if (
|
||||
event['from_user_name']['str'].startswith('gh_')
|
||||
@@ -489,8 +521,6 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
|
||||
config: dict
|
||||
|
||||
ap: app.Application
|
||||
|
||||
logger: EventLogger
|
||||
|
||||
message_converter: WeChatPadMessageConverter
|
||||
@@ -501,14 +531,13 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
def __init__(self, config: dict, ap: app.Application, logger: EventLogger):
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
self.config = config
|
||||
self.ap = ap
|
||||
self.logger = logger
|
||||
self.quart_app = quart.Quart(__name__)
|
||||
|
||||
self.message_converter = WeChatPadMessageConverter(config)
|
||||
self.event_converter = WeChatPadEventConverter(config)
|
||||
self.message_converter = WeChatPadMessageConverter(config, logger)
|
||||
self.event_converter = WeChatPadEventConverter(config, logger)
|
||||
|
||||
async def ws_message(self, data):
|
||||
"""处理接收到的消息"""
|
||||
@@ -541,19 +570,22 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
for msg in content_list:
|
||||
# 文本消息处理@
|
||||
if msg['type'] == 'text' and at_targets:
|
||||
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"]}'
|
||||
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=at_targets
|
||||
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=at_targets
|
||||
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']
|
||||
@@ -575,7 +607,7 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
if handler := handler_map.get(msg['type']):
|
||||
handler(msg)
|
||||
else:
|
||||
print(f'未处理的消息类型: {msg["type"]}')
|
||||
self.logger.warning(f'未处理的消息类型: {msg["type"]}')
|
||||
continue
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
@@ -650,7 +682,7 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
# url = login_data['Data']["QrCodeUrl"]
|
||||
|
||||
profile = self.bot.get_profile()
|
||||
self.logger.info(profile)
|
||||
# self.logger.info(profile)
|
||||
|
||||
self.bot_account_id = profile['Data']['userInfo']['nickName']['str']
|
||||
self.config['wxid'] = profile['Data']['userInfo']['userName']['str']
|
||||
@@ -670,18 +702,18 @@ class WeChatPadAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
# 这里需要确保ws_message是同步的,或者使用asyncio.run调用异步方法
|
||||
asyncio.run(self.ws_message(data))
|
||||
except json.JSONDecodeError:
|
||||
print(f'Non-JSON message: {message[:100]}...')
|
||||
self.logger.error(f'Non-JSON message: {message[:100]}...')
|
||||
|
||||
def on_error(ws, error):
|
||||
print(f'WebSocket error: {str(error)[:200]}')
|
||||
self.logger.error(f'WebSocket error: {str(error)[:200]}')
|
||||
|
||||
def on_close(ws, close_status_code, close_msg):
|
||||
print('WebSocket closed, reconnecting...')
|
||||
self.logger.info('WebSocket closed, reconnecting...')
|
||||
time.sleep(5)
|
||||
connect_websocket_sync() # 自动重连
|
||||
|
||||
def on_open(ws):
|
||||
print('WebSocket connected successfully!')
|
||||
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
|
||||
|
||||
@@ -144,9 +144,9 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot=bot,
|
||||
bot_account_id='',
|
||||
listeners={},
|
||||
bot=bot,
|
||||
)
|
||||
|
||||
async def reply_message(
|
||||
|
||||
Reference in New Issue
Block a user