From 98d1dc3b658395f0d3733432f93734c257110ff8 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Sat, 18 Jan 2025 22:01:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=9D=E6=AD=A5=E6=8E=A5=E5=85=A5=20?= =?UTF-8?q?gewechat=20=E6=96=87=E5=AD=97=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/config/default.py | 40 +-- .../core/pipeline/result_decorate/stage.py | 1 - astrbot/core/platform/manager.py | 2 + .../core/platform/sources/gewechat/client.py | 250 ++++++++++++++++++ .../sources/gewechat/gewechat_event.py | 60 +++++ .../gewechat/gewechat_platform_adapter.py | 78 ++++++ astrbot/core/star/star_manager.py | 1 - 7 files changed, 414 insertions(+), 18 deletions(-) create mode 100644 astrbot/core/platform/sources/gewechat/client.py create mode 100644 astrbot/core/platform/sources/gewechat/gewechat_event.py create mode 100644 astrbot/core/platform/sources/gewechat/gewechat_platform_adapter.py diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 44dd2cd5e..1f0319a68 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -36,8 +36,8 @@ DEFAULT_CONFIG = { "prompt_prefix": "", }, "provider_stt_settings": { - "enable": False, - "provider_id": "", + "enable": False, + "provider_id": "", }, "content_safety": { "internal_keywords": {"enable": True, "extra_keywords": []}, @@ -63,9 +63,9 @@ DEFAULT_CONFIG = { "name": "default", "prompt": "如果用户寻求帮助或者打招呼,请告诉他可以用 /help 查看 AstrBot 帮助。", "begin_dialogs": [], - "mood_imitation_dialogs": [] + "mood_imitation_dialogs": [], } - ] + ], } @@ -95,6 +95,15 @@ CONFIG_METADATA_2 = { "ws_reverse_port": 6199, }, "vchat(微信)": {"id": "default", "type": "vchat", "enable": False}, + "gewechat(微信)": { + "id": "gwchat", + "type": "gewechat", + "enable": False, + "base_url": "http://localhost:2531", + "nickname": "soulter", + "host": "localhost", + "port": 11451, + }, }, "items": { "id": { @@ -180,7 +189,7 @@ CONFIG_METADATA_2 = { }, "enable_id_white_list": { "description": "启用 ID 白名单", - "type": "bool" + "type": "bool", }, "id_whitelist": { "description": "ID 白名单", @@ -354,14 +363,14 @@ CONFIG_METADATA_2 = { "id": "whisper", "type": "openai_whisper_selfhost", "model": "tiny", - } + }, }, "items": { "whisper_hint": { "description": "本地部署 Whisper 模型须知", "type": "string", "hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cuda,CPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。", - "obvious_hint": True + "obvious_hint": True, }, "id": { "description": "ID", @@ -451,7 +460,7 @@ CONFIG_METADATA_2 = { "description": "Dify Workflow 输出变量名", "type": "string", "hint": "Dify Workflow 输出变量名。当应用类型为 workflow 时才使用。默认为 astrbot_wf_output。", - } + }, }, }, "provider_settings": { @@ -462,7 +471,7 @@ CONFIG_METADATA_2 = { "description": "启用大语言模型聊天", "type": "bool", "hint": "如需切换大语言模型提供商,请使用 `/provider` 命令。", - "obvious_hint": True + "obvious_hint": True, }, "wake_prefix": { "description": "LLM 聊天额外唤醒前缀", @@ -504,7 +513,7 @@ CONFIG_METADATA_2 = { "name": "", "prompt": "", "begin_dialogs": [], - "mood_imitation_dialogs": [] + "mood_imitation_dialogs": [], } }, "tmpl_display_title": "name", @@ -513,7 +522,7 @@ CONFIG_METADATA_2 = { "description": "人格名称", "type": "string", "hint": "人格名称,用于在多个人格中区分。使用 /persona 指令可切换人格。在 大语言模型设置 处可以设置默认人格。", - "obvious_hint": True + "obvious_hint": True, }, "prompt": { "description": "设定(系统提示词)", @@ -525,18 +534,17 @@ CONFIG_METADATA_2 = { "type": "list", "items": {}, "hint": "可选。在每个对话前会插入这些预设对话。格式要求:第一句为用户,第二句为助手,以此类推。", - "obvious_hint": True + "obvious_hint": True, }, "mood_imitation_dialogs": { "description": "对话风格模仿", "type": "list", "items": {}, "hint": "旨在让模型尽可能模仿学习到所填写的对话的语气风格。格式和 `预设对话` 一样。", - "obvious_hint": True + "obvious_hint": True, }, - } + }, }, - "provider_stt_settings": { "description": "语音转文本(STT)", "type": "object", @@ -545,7 +553,7 @@ CONFIG_METADATA_2 = { "description": "启用语音转文本(STT)", "type": "bool", "hint": "启用前请在 服务提供商配置 处创建支持 语音转文本任务 的提供商。如 whisper。", - "obvious_hint": True + "obvious_hint": True, }, "provider_id": { "description": "提供商 ID,不填则默认第一个STT提供商", diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py index f382fbd4b..f50a858b8 100644 --- a/astrbot/core/pipeline/result_decorate/stage.py +++ b/astrbot/core/pipeline/result_decorate/stage.py @@ -54,7 +54,6 @@ class ResultDecorateStage: result.chain = [Image.fromURL(url)] if self.reply_with_mention and event.get_message_type() != MessageType.FRIEND_MESSAGE: - result.chain.insert(0, Plain("\n")) result.chain.insert(0, At(qq=event.get_sender_id())) if self.reply_with_quote: diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 0308d5746..488f45b94 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -25,6 +25,8 @@ class PlatformManager(): from .sources.qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter # noqa: F401 case "vchat": from .sources.vchat.vchat_platform_adapter import VChatPlatformAdapter # noqa: F401 + case "gewechat": + from .sources.gewechat.gewechat_platform_adapter import GewechatPlatformAdapter # noqa: F401 async def initialize(self): diff --git a/astrbot/core/platform/sources/gewechat/client.py b/astrbot/core/platform/sources/gewechat/client.py new file mode 100644 index 000000000..0355acc08 --- /dev/null +++ b/astrbot/core/platform/sources/gewechat/client.py @@ -0,0 +1,250 @@ +import threading +import asyncio +import aiohttp +import quart + +from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType +from astrbot.api.message_components import Plain, Image, At +from astrbot.api import logger, sp + +class SimpleGewechatClient(): + '''针对 Gewechat 的简单实现。 + + @author: Soulter + @website: https://github.com/Soulter + ''' + def __init__(self, base_url: str, nickname: str, host: str, port: int, event_queue: asyncio.Queue): + self.base_url = base_url + if self.base_url.endswith('/'): + self.base_url = self.base_url[:-1] + + self.base_url += "/v2/api" + + if isinstance(port, str): + port = int(port) + + self.token = None + self.headers = {} + self.nickname = nickname + self.appid = sp.get(f"gewechat-appid-{nickname}", "") + self.callback_url = None + + self.server = quart.Quart(__name__) + self.server.add_url_rule('/astrbot-gewechat/callback', view_func=self.callback, methods=['POST']) + + self.host = host + self.port = port + + self.event_queue = event_queue + + async def get_token_id(self): + async with aiohttp.ClientSession() as session: + async with session.post(f"{self.base_url}/tools/getTokenId") as resp: + json_blob = await resp.json() + self.token = json_blob['data'] + logger.info(f"获取到 Gewechat Token: {self.token}") + self.headers = { + "X-GEWE-TOKEN": self.token + } + + async def _convert(self, data: dict) -> AstrBotMessage: + type_name = data['TypeName'] + if type_name == "Offline": + logger.critical("收到 gewechat 下线通知。") + return + abm = AstrBotMessage() + d = data['Data'] + msg_type = d['MsgType'] + + match msg_type: + case 1: + from_user_name = d['FromUserName']['string'] # 消息来源 + d['to_wxid'] = from_user_name # 用于发信息 + + user_id = "" # 发送人 wxid + content = d['Content']['string'] # 消息内容 + user_real_name = d['PushContent'].split(' : ')[0] # 真实昵称 + user_real_name.replace('在群聊中@了你', '') # trick + abm.self_id = data['Wxid'] # 机器人的 wxid + at_me = False + if "@chatroom" in from_user_name: + abm.type = MessageType.GROUP_MESSAGE + _t = content.split(':\n') + user_id = _t[0] + content = _t[1] + if '\u2005' in content: + # at + content = content.split('\u2005')[1] + + abm.group_id = from_user_name + + # at + msg_source = d['MsgSource'] + if f'' in msg_source: + at_me = True + + else: + abm.type = MessageType.FRIEND_MESSAGE + user_id = from_user_name + abm.session_id = from_user_name + abm.sender = MessageMember(user_id, user_real_name) + abm.message = [Plain(content)] + + if at_me: + abm.message.insert(0, At(qq=abm.self_id)) + + abm.message_id = str(d['MsgId']) + abm.raw_message = d + abm.message_str = content + + logger.info(f"abm: {abm}") + return abm + case _: + logger.error(f"未实现的消息类型: {msg_type}") + + async def callback(self): + data = await quart.request.json + logger.debug(f"收到 gewechat 回调: {data}") + + if data.get('testMsg', None): + return quart.jsonify({"r": "AstrBot ACK"}) + + abm = await self._convert(data) + + if abm: + coro = getattr(self, "on_event_received") + if coro: + await coro(abm) + + return quart.jsonify({"r": "AstrBot ACK"}) + + async def _set_callback_url(self): + logger.info("设置回调,请等待...") + await asyncio.sleep(3) + callback_url = f"http://{self.host}:{self.port}/astrbot-gewechat/callback" + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.base_url}/tools/setCallback", + headers=self.headers, + json={ + "token": self.token, + "callbackUrl": callback_url + } + ) as resp: + json_blob = await resp.json() + logger.info(f"设置回调结果: {json_blob}") + if json_blob['ret'] != 200: + raise Exception(f"设置回调失败: {json_blob}") + logger.info(f"将在 {callback_url} 上接收 gewechat 下发的消息。") + + async def start_polling(self): + + # 设置回调 + threading.Thread(target=asyncio.run, args=(self._set_callback_url(),)).start() + + + await self.server.run_task( + host=self.host, + port=self.port, + shutdown_trigger=self.shutdown_trigger_placeholder + ) + + async def shutdown_trigger_placeholder(self): + while not self.event_queue.closed: + await asyncio.sleep(1) + logger.info("gewechat 适配器已关闭。") + + async def check_online(self, appid: str): + # /login/checkOnline + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.base_url}/login/checkOnline", + headers=self.headers, + json={ + "appId": appid + } + ) as resp: + json_blob = await resp.json() + return json_blob['data'] + + async def login(self): + if self.token is None: + await self.get_token_id() + + if self.appid: + online = await self.check_online(self.appid) + if online: + logger.info(f"APPID: {self.appid} 已在线") + return + + payload = { + "appId": self.appid + } + + if self.appid: + logger.info(f"使用 APPID: {self.appid}, {self.nickname}") + + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.base_url}/login/getLoginQrCode", + headers=self.headers, + json=payload + ) as resp: + json_blob = await resp.json() + if json_blob['ret'] != 200: + raise Exception(f"获取二维码失败: {json_blob}") + qr_data = json_blob['data']['qrData'] + qr_uuid = json_blob['data']['uuid'] + appid = json_blob['data']['appId'] + logger.info(f"APPID: {appid}") + logger.warning(f"请打开该网址,然后使用微信扫描二维码登录: https://api.cl2wm.cn/api/qrcode/code?text={qr_data}") + + # 执行登录 + retry_cnt = 64 + payload.update({ + "uuid": qr_uuid, + "appId": appid + }) + while retry_cnt > 0: + retry_cnt -= 1 + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.base_url}/login/checkLogin", + headers=self.headers, + json=payload + ) as resp: + json_blob = await resp.json() + logger.info(f"检查登录状态: {json_blob}") + status = json_blob['data']['status'] + nickname = json_blob['data'].get('nickName', '') + if status == 1: + logger.info(f"等待确认...{nickname}") + elif status == 2: + logger.info(f"绿泡泡平台登录成功: {nickname}") + break + elif status == 0: + logger.info("等待扫码...") + else: + logger.warning(f"未知状态: {status}") + await asyncio.sleep(5) + + if not self.appid and appid: + sp.put(f"gewechat-appid-{nickname}", appid) + self.appid = appid + logger.info(f"已保存 APPID: {appid}") + + async def post_text(self, to_wxid, content: str): + payload = { + "appId": self.appid, + "toWxid": to_wxid, + "content": content, + } + + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.base_url}/message/postText", + headers=self.headers, + json=payload + ) as resp: + json_blob = await resp.json() + logger.info(f"发送消息结果: {json_blob}") \ No newline at end of file diff --git a/astrbot/core/platform/sources/gewechat/gewechat_event.py b/astrbot/core/platform/sources/gewechat/gewechat_event.py new file mode 100644 index 000000000..e2ed4afb9 --- /dev/null +++ b/astrbot/core/platform/sources/gewechat/gewechat_event.py @@ -0,0 +1,60 @@ +import random +import asyncio +from astrbot.core.utils.io import download_image_by_url +from astrbot.api import logger +from astrbot.api.event import AstrMessageEvent, MessageChain +from astrbot.api.platform import AstrBotMessage, PlatformMetadata +from astrbot.api.message_components import Plain, Image +from .client import SimpleGewechatClient + +class GewechatPlatformEvent(AstrMessageEvent): + def __init__( + self, + message_str: str, + message_obj: AstrBotMessage, + platform_meta: PlatformMetadata, + session_id: str, + client: SimpleGewechatClient + ): + super().__init__(message_str, message_obj, platform_meta, session_id) + self.client = client + + @staticmethod + async def send_with_client(message: MessageChain, user_name: str): + # plain = "" + # for comp in message.chain: + # if isinstance(comp, Plain): + # if message.is_split_: + # await client.send_msg(comp.text, user_name) + # else: + # plain += comp.text + # elif isinstance(comp, Image): + # if comp.file and comp.file.startswith("file:///"): + # file_path = comp.file.replace("file:///", "") + # with open(file_path, "rb") as f: + # await client.send_image(user_name, fd=f) + # elif comp.file and comp.file.startswith("http"): + # image_path = await download_image_by_url(comp.file) + # with open(image_path, "rb") as f: + # await client.send_image(user_name, fd=f) + # else: + # logger.error(f"不支持的 vchat(微信适配器) 消息类型: {comp}") + # await asyncio.sleep(random.uniform(0.5, 1.5)) # 🤓 + + # if plain: + # await client.send_msg(plain, user_name) + pass + + + async def send(self, message: MessageChain): + to_wxid = self.message_obj.raw_message.get('to_wxid', None) + + if not to_wxid: + logger.error("无法获取到 to_wxid。") + return + + for comp in message.chain: + if isinstance(comp, Plain): + await self.client.post_text(to_wxid, comp.text) + + await super().send(message) \ No newline at end of file diff --git a/astrbot/core/platform/sources/gewechat/gewechat_platform_adapter.py b/astrbot/core/platform/sources/gewechat/gewechat_platform_adapter.py new file mode 100644 index 000000000..fe8500c7b --- /dev/null +++ b/astrbot/core/platform/sources/gewechat/gewechat_platform_adapter.py @@ -0,0 +1,78 @@ +import sys +import asyncio +import os + +from astrbot.api.platform import Platform, AstrBotMessage, MessageType, PlatformMetadata +from astrbot.api.event import MessageChain +from astrbot.api import logger +from astrbot.core.platform.astr_message_event import MessageSesion +from ...register import register_platform_adapter +from .gewechat_event import GewechatPlatformEvent +from .client import SimpleGewechatClient + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + + +@register_platform_adapter("gewechat", "基于 gewechat 的 Wechat 适配器") +class GewechatPlatformAdapter(Platform): + + def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None: + super().__init__(event_queue) + self.config = platform_config + self.settingss = platform_settings + self.test_mode = os.environ.get('TEST_MODE', 'off') == 'on' + self.client = None + + @override + async def send_by_session(self, session: MessageSesion, message_chain: MessageChain): + # from_username = session.session_id.split('$$')[0] + # await GewechatPlatformEvent.send_with_client(self.client, message_chain, from_username) + await super().send_by_session(session, message_chain) + + @override + def meta(self) -> PlatformMetadata: + return PlatformMetadata( + "gewechat", + "基于 gewechat 的 Wechat 适配器", + ) + + @override + def run(self): + self.client = SimpleGewechatClient( + self.config['base_url'], + self.config['nickname'], + self.config['host'], + self.config['port'], + self._event_queue, + ) + + async def on_event_received(abm: AstrBotMessage): + await self.handle_msg(abm) + + self.client.on_event_received = on_event_received + + return self._run() + + async def _run(self): + await self.client.login() + + await self.client.start_polling() + + + async def handle_msg(self, message: AstrBotMessage): + if message.type == MessageType.GROUP_MESSAGE: + if self.settingss['unique_session']: + message.session_id = message.sender.user_id + "_" + message.group_id + + message_event = GewechatPlatformEvent( + message_str=message.message_str, + message_obj=message, + platform_meta=self.meta(), + session_id=message.session_id, + client=self.client + ) + + self.commit_event(message_event) \ No newline at end of file diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index d5fc2f3fa..8b6b3ca0c 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -7,7 +7,6 @@ import yaml import logging from types import ModuleType from typing import List -from pip import main as pip_main from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core import logger, sp, pip_installer from .context import Context