From 852254eaef4e50b6602606e087066122f32ea8fc Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Tue, 4 Feb 2025 19:37:40 +0800 Subject: [PATCH] feat: add `gewechat` adapter --- README.md | 4 +- pkg/core/bootutils/deps.py | 3 +- pkg/core/migrations/m025_gewechat_config.py | 31 +++ pkg/core/stages/migrate.py | 2 +- pkg/platform/manager.py | 31 ++- pkg/platform/sources/gewechat.py | 285 ++++++++++++++++++++ requirements.txt | 1 + templates/platform.json | 9 + templates/schema/platform.json | 46 ++++ 9 files changed, 407 insertions(+), 5 deletions(-) create mode 100644 pkg/core/migrations/m025_gewechat_config.py create mode 100644 pkg/platform/sources/gewechat.py diff --git a/README.md b/README.md index 306f855c..7a5ed108 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ ## ✨ Features -- 💬 大模型对话、Agent:支持多种大模型,适配群聊和私聊;具有多轮对话、工具调用、多模态能力,并深度适配 [Dify](https://dify.ai)。目前支持 QQ、QQ频道、企业微信、飞书、Discord,后续还将支持个人微信、WhatsApp、Telegram 等平台。 +- 💬 大模型对话、Agent:支持多种大模型,适配群聊和私聊;具有多轮对话、工具调用、多模态能力,并深度适配 [Dify](https://dify.ai)。目前支持 QQ、QQ频道、企业微信、飞书、Discord、个人微信,后续还将支持 WhatsApp、Telegram 等平台。 - 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。 - 🧩 插件扩展、活跃社区:支持事件驱动、组件扩展等插件机制;丰富生态,目前已有数十个[插件](https://docs.langbot.app/plugin/plugin-intro.html) - 😻 [New] Web 管理面板:支持通过浏览器管理 LangBot 实例,具体支持功能,查看[文档](https://docs.langbot.app/webui/intro.html) @@ -84,7 +84,7 @@ | 企业微信 | ✅ | | | 飞书 | ✅ | | | Discord | ✅ | | -| 个人微信 | 🚧 | | +| 个人微信 | ✅ | 使用 [Gewechat](https://github.com/Devo919/Gewechat) 接入 | | WhatsApp | 🚧 | | | 钉钉 | 🚧 | | diff --git a/pkg/core/bootutils/deps.py b/pkg/core/bootutils/deps.py index 755c1211..44b9e60a 100644 --- a/pkg/core/bootutils/deps.py +++ b/pkg/core/bootutils/deps.py @@ -27,7 +27,8 @@ required_deps = { "jwt": "pyjwt", "Crypto": "pycryptodome", "lark_oapi": "lark-oapi", - "discord": "discord.py" + "discord": "discord.py", + "gewechat_client": "gewechat-client" } diff --git a/pkg/core/migrations/m025_gewechat_config.py b/pkg/core/migrations/m025_gewechat_config.py new file mode 100644 index 00000000..c5002b43 --- /dev/null +++ b/pkg/core/migrations/m025_gewechat_config.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from .. import migration + + +@migration.migration_class("gewechat-config", 25) +class GewechatConfigMigration(migration.Migration): + """迁移""" + + async def need_migrate(self) -> bool: + """判断当前环境是否需要运行此迁移""" + + for adapter in self.ap.platform_cfg.data['platform-adapters']: + if adapter['adapter'] == 'gewechat': + return False + + return True + + async def run(self): + """执行迁移""" + self.ap.platform_cfg.data['platform-adapters'].append({ + "adapter": "gewechat", + "enable": False, + "gewechat_url": "http://your-gewechat-server:2531", + "port": 2286, + "callback_url": "http://your-callback-url:2286/gewechat/callback", + "app_id": "", + "token": "" + }) + + await self.ap.platform_cfg.dump_config() diff --git a/pkg/core/stages/migrate.py b/pkg/core/stages/migrate.py index 1639a736..22a8ae0f 100644 --- a/pkg/core/stages/migrate.py +++ b/pkg/core/stages/migrate.py @@ -8,7 +8,7 @@ from ..migrations import m001_sensitive_word_migration, m002_openai_config_migra from ..migrations import m005_deepseek_cfg_completion, m006_vision_config, m007_qcg_center_url, m008_ad_fixwin_config_migrate, m009_msg_truncator_cfg from ..migrations import m010_ollama_requester_config, m011_command_prefix_config, m012_runner_config, m013_http_api_config, m014_force_delay_config from ..migrations import m015_gitee_ai_config, m016_dify_service_api, m017_dify_api_timeout_params, m018_xai_config, m019_zhipuai_config -from ..migrations import m020_wecom_config, m021_lark_config, m022_lmstudio_config, m023_siliconflow_config, m024_discord_config +from ..migrations import m020_wecom_config, m021_lark_config, m022_lmstudio_config, m023_siliconflow_config, m024_discord_config, m025_gewechat_config @stage.stage_class("MigrationStage") diff --git a/pkg/platform/manager.py b/pkg/platform/manager.py index 22dfe17d..b45bc488 100644 --- a/pkg/platform/manager.py +++ b/pkg/platform/manager.py @@ -37,7 +37,7 @@ class PlatformManager: async def initialize(self): - from .sources import nakuru, aiocqhttp, qqbotpy, wecom, lark, discord + from .sources import nakuru, aiocqhttp, qqbotpy, wecom, lark, discord, gewechat async def on_friend_message(event: platform_events.FriendMessage, adapter: msadapter.MessageSourceAdapter): @@ -100,6 +100,35 @@ class PlatformManager: if len(self.adapters) == 0: self.ap.logger.warning('未运行平台适配器,请根据文档配置并启用平台适配器。') + async def write_back_config(self, adapter_inst: msadapter.MessageSourceAdapter, config: dict): + index = -2 + + for i, adapter in enumerate(self.adapters): + if adapter == adapter_inst: + index = i + break + + if index == -2: + raise Exception('平台适配器未找到') + + # 只修改启用的适配器 + real_index = -1 + + for i, adapter in enumerate(self.ap.platform_cfg.data['platform-adapters']): + if adapter['enable']: + index -= 1 + if index == -1: + real_index = i + break + + new_cfg = { + 'adapter': adapter_inst.name, + 'enable': True, + **config + } + self.ap.platform_cfg.data['platform-adapters'][real_index] = new_cfg + await self.ap.platform_cfg.dump_config() + async def send(self, event: platform_events.MessageEvent, msg: platform_message.MessageChain, adapter: msadapter.MessageSourceAdapter): if self.ap.platform_cfg.data['at-sender'] and isinstance(event, platform_events.GroupMessage): diff --git a/pkg/platform/sources/gewechat.py b/pkg/platform/sources/gewechat.py new file mode 100644 index 00000000..c75b1add --- /dev/null +++ b/pkg/platform/sources/gewechat.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import gewechat_client + +import typing +import asyncio +import traceback +import time +import re +import base64 +import uuid +import json +import os +import copy +import datetime +import threading + +import quart +import aiohttp + +from .. import adapter +from ...pipeline.longtext.strategies import forward +from ...core import app +from ..types import message as platform_message +from ..types import events as platform_events +from ..types import entities as platform_entities +from ...utils import image + + +class GewechatMessageConverter(adapter.MessageConverter): + + @staticmethod + async def yiri2target( + message_chain: platform_message.MessageChain + ) -> list[dict]: + content_list = [] + for component in message_chain: + if isinstance(component, platform_message.At): + content_list.append({"type": "at", "target": component.target}) + elif isinstance(component, platform_message.Plain): + content_list.append({"type": "text", "content": component.text}) + elif isinstance(component, platform_message.Image): + # content_list.append({"type": "image", "image_id": component.image_id}) + pass + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + content_list.extend(await GewechatMessageConverter.yiri2target(node.message_chain)) + + return content_list + + @staticmethod + async def target2yiri( + message: dict, + bot_account_id: str + ) -> platform_message.MessageChain: + + if message["Data"]["MsgType"] == 1: + # 检查消息开头,如果有 wxid_sbitaz0mt65n22:\n 则删掉 + regex = re.compile(r"^wxid_.*:") + + line_split = message["Data"]["Content"]["string"].split("\n") + + if len(line_split) > 0 and regex.match(line_split[0]): + message["Data"]["Content"]["string"] = "\n".join(line_split[1:]) + + at_string = f'@{bot_account_id}' + content_list = [] + if at_string in message["Data"]["Content"]["string"]: + content_list.append(platform_message.At(target=bot_account_id)) + content_list.append(platform_message.Plain(message["Data"]["Content"]["string"].replace(at_string, "", 1))) + else: + content_list = [platform_message.Plain(message["Data"]["Content"]["string"])] + + return platform_message.MessageChain(content_list) + + elif message["Data"]["MsgType"] == 3: + image_base64 = message["Data"]["ImgBuf"]["buffer"] + return platform_message.MessageChain( + [platform_message.Image(base64=f"data:image/jpeg;base64,{image_base64}")] + ) + +class GewechatEventConverter(adapter.EventConverter): + + @staticmethod + async def yiri2target( + event: platform_events.MessageEvent + ) -> dict: + pass + + @staticmethod + async def target2yiri( + event: dict, + bot_account_id: str + ) -> platform_events.MessageEvent: + message_chain = await GewechatMessageConverter.target2yiri(copy.deepcopy(event), bot_account_id) + + if not message_chain: + return None + + if '@chatroom' in event["Data"]["FromUserName"]["string"]: + # 找出开头的 wxid_ 字符串,以:结尾 + sender_wxid = event["Data"]["Content"]["string"].split(":")[0] + + return platform_events.GroupMessage( + sender=platform_entities.GroupMember( + id=sender_wxid, + member_name=event["Data"]["FromUserName"]["string"], + permission=platform_entities.Permission.Member, + group=platform_entities.Group( + id=event["Data"]["FromUserName"]["string"], + name=event["Data"]["FromUserName"]["string"], + permission=platform_entities.Permission.Member, + ), + special_title="", + join_timestamp=0, + last_speak_timestamp=0, + mute_time_remaining=0, + ), + message_chain=message_chain, + time=event["Data"]["CreateTime"], + source_platform_object=event, + ) + elif 'wxid_' in event["Data"]["FromUserName"]["string"]: + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event["Data"]["FromUserName"]["string"], + nickname=event["Data"]["FromUserName"]["string"], + remark='', + ), + message_chain=message_chain, + time=event["Data"]["CreateTime"], + source_platform_object=event, + ) + + +@adapter.adapter_class("gewechat") +class GewechatMessageSourceAdapter(adapter.MessageSourceAdapter): + + bot: gewechat_client.GewechatClient + quart_app: quart.Quart + + bot_account_id: str + + config: dict + + ap: app.Application + + message_converter: GewechatMessageConverter = GewechatMessageConverter() + event_converter: GewechatEventConverter = GewechatEventConverter() + + listeners: typing.Dict[ + typing.Type[platform_events.Event], + typing.Callable[[platform_events.Event, adapter.MessageSourceAdapter], None], + ] = {} + + def __init__(self, config: dict, ap: app.Application): + self.config = config + self.ap = ap + + self.quart_app = quart.Quart(__name__) + + @self.quart_app.route('/gewechat/callback', methods=['POST']) + async def gewechat_callback(): + data = await quart.request.json + # print(json.dumps(data, indent=4, ensure_ascii=False)) + + if 'testMsg' in data: + return 'ok' + elif 'TypeName' in data and data['TypeName'] == 'AddMsg': + try: + + event = await self.event_converter.target2yiri(data.copy(), self.bot_account_id) + except Exception as e: + traceback.print_exc() + + if event.__class__ in self.listeners: + await self.listeners[event.__class__](event, self) + + return 'ok' + + async def send_message( + self, + target_type: str, + target_id: str, + message: platform_message.MessageChain + ): + pass + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False + ): + content_list = await self.message_converter.yiri2target(message) + + ats = [item["target"] for item in content_list if item["type"] == "at"] + + for msg in content_list: + if msg["type"] == "text": + + if ats: + member_info = self.bot.get_chatroom_member_detail( + self.config["app_id"], + message_source.source_platform_object["Data"]["FromUserName"]["string"], + ats[::-1] + )["data"] + + for member in member_info: + msg['content'] = f'@{member["nickName"]} {msg["content"]}' + + self.bot.post_text( + app_id=self.config["app_id"], + to_wxid=message_source.source_platform_object["Data"]["FromUserName"]["string"], + content=msg["content"], + ats=','.join(ats) + ) + + async def is_muted(self, group_id: int) -> bool: + pass + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[[platform_events.Event, adapter.MessageSourceAdapter], None] + ): + self.listeners[event_type] = callback + + def unregister_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[[platform_events.Event, adapter.MessageSourceAdapter], None] + ): + pass + + async def run_async(self): + + if not self.config["token"]: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{self.config['gewechat_url']}/v2/api/tools/getTokenId", + json={"app_id": self.config["app_id"]} + ) as response: + if response.status != 200: + raise Exception(f"获取gewechat token失败: {await response.text()}") + self.config["token"] = (await response.json())["data"] + + self.bot = gewechat_client.GewechatClient( + f"{self.config['gewechat_url']}/v2/api", + self.config["token"] + ) + + app_id, error_msg = self.bot.login(self.config["app_id"]) + if error_msg: + raise Exception(f"Gewechat 登录失败: {error_msg}") + + self.config["app_id"] = app_id + + self.ap.logger.info(f"Gewechat 登录成功,app_id: {app_id}") + + await self.ap.platform_mgr.write_back_config(self, self.config) + + # 获取 nickname + profile = self.bot.get_profile(self.config["app_id"]) + self.bot_account_id = profile["data"]["nickName"] + + def thread_set_callback(): + time.sleep(3) + ret = self.bot.set_callback(self.config["token"], self.config["callback_url"]) + print('设置 Gewechat 回调:', ret) + + threading.Thread(target=thread_set_callback).start() + + async def shutdown_trigger_placeholder(): + while True: + await asyncio.sleep(1) + + await self.quart_app.run_task( + host='0.0.0.0', + port=self.config["port"], + shutdown_trigger=shutdown_trigger_placeholder, + ) + + async def kill(self) -> bool: + pass \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b002deb1..b19d6b6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,6 +27,7 @@ pyjwt pycryptodome lark-oapi discord.py +gewechat-client # indirect taskgroup==0.0.0a4 \ No newline at end of file diff --git a/templates/platform.json b/templates/platform.json index 58489d4b..97a865cf 100644 --- a/templates/platform.json +++ b/templates/platform.json @@ -48,6 +48,15 @@ "enable": false, "client_id": "1234567890", "token": "XXXXXXXXXX" + }, + { + "adapter": "gewechat", + "enable": false, + "gewechat_url": "http://your-gewechat-server:2531", + "port": 2286, + "callback_url": "http://your-callback-url:2286/gewechat/callback", + "app_id": "", + "token": "" } ], "track-function-calls": true, diff --git a/templates/schema/platform.json b/templates/schema/platform.json index 2c05eab6..36e7219b 100644 --- a/templates/schema/platform.json +++ b/templates/schema/platform.json @@ -244,6 +244,52 @@ "description": "Discord 的 token" } } + }, + { + "title": "gewechat 适配器", + "description": "用于接入个人微信", + "properties": { + "adapter": { + "type": "string", + "const": "gewechat" + }, + "enable": { + "type": "boolean", + "default": false, + "description": "是否启用此适配器", + "layout": { + "comp": "switch", + "props": { + "color": "primary" + } + } + }, + "gewechat_url": { + "type": "string", + "default": "", + "description": "gewechat 的 url" + }, + "port": { + "type": "integer", + "default": 2286, + "description": "gewechat 的端口" + }, + "callback_url": { + "type": "string", + "default": "", + "description": "回调地址(LangBot主机相对于gewechat服务器的地址)" + }, + "app_id": { + "type": "string", + "default": "", + "description": "gewechat 的 app_id" + }, + "token": { + "type": "string", + "default": "", + "description": "gewechat 的 token" + } + } } ] }