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 8493a278..b4a67f35 100644 --- a/pkg/core/bootutils/deps.py +++ b/pkg/core/bootutils/deps.py @@ -28,7 +28,8 @@ required_deps = { "Crypto": "pycryptodome", "lark_oapi": "lark-oapi", "discord": "discord.py", - "cryptography": "cryptography" + "cryptography": "cryptography", + "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/pipeline/longtext/strategies/forward.py b/pkg/pipeline/longtext/strategies/forward.py index c001b6d8..7abb9c6e 100644 --- a/pkg/pipeline/longtext/strategies/forward.py +++ b/pkg/pipeline/longtext/strategies/forward.py @@ -9,30 +9,8 @@ from ....core import entities as core_entities from ....platform.types import message as platform_message -class ForwardMessageDiaplay(pydantic.BaseModel): - title: str = "群聊的聊天记录" - brief: str = "[聊天记录]" - source: str = "聊天记录" - preview: typing.List[str] = [] - summary: str = "查看x条转发消息" - - -class Forward(platform_message.MessageComponent): - """合并转发。""" - type: str = "Forward" - """消息组件类型。""" - display: ForwardMessageDiaplay - """显示信息""" - node_list: typing.List[platform_message.ForwardMessageNode] - """转发消息节点列表。""" - def __init__(self, *args, **kwargs): - if len(args) == 1: - self.node_list = args[0] - super().__init__(**kwargs) - super().__init__(*args, **kwargs) - - def __str__(self): - return '[聊天记录]' +ForwardMessageDiaplay = platform_message.ForwardMessageDiaplay +Forward = platform_message.Forward @strategy_model.strategy_class("forward") diff --git a/pkg/platform/manager.py b/pkg/platform/manager.py index 5701bd1f..85302ca4 100644 --- a/pkg/platform/manager.py +++ b/pkg/platform/manager.py @@ -39,7 +39,7 @@ class PlatformManager: async def initialize(self): - from .sources import nakuru, aiocqhttp, qqofficial, wecom, lark, discord + from .sources import nakuru, aiocqhttp, qqbotpy, qqofficial, wecom, lark, discord, gewechat async def on_friend_message(event: platform_events.FriendMessage, adapter: msadapter.MessageSourceAdapter): @@ -102,6 +102,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/discord.py b/pkg/platform/sources/discord.py index f8f0179d..577272a3 100644 --- a/pkg/platform/sources/discord.py +++ b/pkg/platform/sources/discord.py @@ -55,6 +55,11 @@ class DiscordMessageConverter(adapter.MessageConverter): image_files.append(discord.File(fp=image_bytes, filename=f"{uuid.uuid4()}.png")) 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 = await DiscordMessageConverter.yiri2target(node.message_chain) + text_string += text_string + image_files.extend(image_files) return text_string, image_files 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/pkg/platform/sources/lark.py b/pkg/platform/sources/lark.py index 4d630ae9..b4b346db 100644 --- a/pkg/platform/sources/lark.py +++ b/pkg/platform/sources/lark.py @@ -90,6 +90,9 @@ class LarkMessageConverter(adapter.MessageConverter): ] ) pending_paragraph = [] + elif isinstance(msg, platform_message.Forward): + for node in msg.node_list: + message_elements.extend(await LarkMessageConverter.yiri2target(node.message_chain, api_client)) if pending_paragraph: message_elements.append(pending_paragraph) diff --git a/pkg/platform/types/message.py b/pkg/platform/types/message.py index aad239d8..f53193d0 100644 --- a/pkg/platform/types/message.py +++ b/pkg/platform/types/message.py @@ -785,10 +785,20 @@ class ForwardMessageNode(pydantic.BaseModel): ) +class ForwardMessageDiaplay(pydantic.BaseModel): + title: str = "群聊的聊天记录" + brief: str = "[聊天记录]" + source: str = "聊天记录" + preview: typing.List[str] = [] + summary: str = "查看x条转发消息" + + class Forward(MessageComponent): """合并转发。""" type: str = "Forward" """消息组件类型。""" + display: ForwardMessageDiaplay + """显示信息""" node_list: typing.List[ForwardMessageNode] """转发消息节点列表。""" def __init__(self, *args, **kwargs): diff --git a/pkg/utils/constants.py b/pkg/utils/constants.py index 2b6979ad..6b862322 100644 --- a/pkg/utils/constants.py +++ b/pkg/utils/constants.py @@ -1,4 +1,4 @@ -semantic_version = "v3.4.5" +semantic_version = "v3.4.5.2" debug_mode = False diff --git a/requirements.txt b/requirements.txt index 72100385..27349f64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,5 +28,7 @@ pycryptodome lark-oapi discord.py cryptography +gewechat-client + # indirect taskgroup==0.0.0a4 \ No newline at end of file diff --git a/templates/platform.json b/templates/platform.json index 0eb13feb..97a865cf 100644 --- a/templates/platform.json +++ b/templates/platform.json @@ -45,9 +45,18 @@ }, { "adapter": "discord", - "enable": true, + "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, @@ -58,7 +67,7 @@ "max": 0 }, "long-text-process": { - "threshold": 256, + "threshold": 2560, "strategy": "forward", "font-path": "" }, 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" + } + } } ] } diff --git a/web/src/components/MarketPluginCard.vue b/web/src/components/MarketPluginCard.vue new file mode 100644 index 00000000..ad1379f8 --- /dev/null +++ b/web/src/components/MarketPluginCard.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/web/src/components/Marketplace.vue b/web/src/components/Marketplace.vue new file mode 100644 index 00000000..89c0ad29 --- /dev/null +++ b/web/src/components/Marketplace.vue @@ -0,0 +1,228 @@ + + + + + \ No newline at end of file diff --git a/web/src/pages/Plugins.vue b/web/src/pages/Plugins.vue index dbb7de99..fcdade39 100644 --- a/web/src/pages/Plugins.vue +++ b/web/src/pages/Plugins.vue @@ -2,6 +2,10 @@
+ + 已安装 + 插件市场 +
@@ -78,17 +82,21 @@
-
+
+
+ +