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 @@
+
+