diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 7181f918..546e10be 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -3,22 +3,6 @@ description: 报错或漏洞请使用这个模板创建,不使用此模板创 title: "[Bug]: " labels: ["bug?"] body: - - type: dropdown - attributes: - label: 消息平台适配器 - description: "接入的消息平台类型" - options: - - 其他(或暂未使用) - - Nakuru(go-cqhttp) - - aiocqhttp(使用 OneBot 协议接入的) - - qq-botpy(QQ官方API WebSocket) - - qqofficial(QQ官方API Webhook) - - lark(飞书) - - wecom(企业微信) - - gewechat(个人微信) - - discord - validations: - required: true - type: input attributes: label: 运行环境 diff --git a/.gitignore b/.gitignore index 0a14ca5b..48303861 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ botpy.log* /poc /libs/wecom_api/test.py /venv -/jp-tyo-churros-05.rockchin.top +test.py +/web_ui \ No newline at end of file diff --git a/README.md b/README.md index d0e9696c..951b1396 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ RockChinQ%2FLangBot | Trendshift -项目主页 | +项目主页功能介绍部署文档常见问题 | @@ -87,13 +87,14 @@ | QQ 个人号 | ✅ | QQ 个人号私聊、群聊 | | QQ 官方机器人 | ✅ | QQ 官方机器人,支持频道、私聊、群聊 | | 企业微信 | ✅ | | +| 企微对外客服 | ✅ | | | 个人微信 | ✅ | 使用 [Gewechat](https://github.com/Devo919/Gewechat) 接入 | | 微信公众号 | ✅ | | | 飞书 | ✅ | | | 钉钉 | ✅ | | | Discord | ✅ | | | Telegram | ✅ | | -| Slack | 🚧 | | +| Slack | ✅ | | | LINE | 🚧 | | | WhatsApp | 🚧 | | @@ -109,6 +110,7 @@ | [Anthropic](https://www.anthropic.com/) | ✅ | | | [xAI](https://x.ai/) | ✅ | | | [智谱AI](https://open.bigmodel.cn/) | ✅ | | +| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型和 GPU 资源平台 | | [Dify](https://dify.ai) | ✅ | LLMOps 平台 | | [Ollama](https://ollama.com/) | ✅ | 本地大模型运行平台 | | [LMStudio](https://lmstudio.ai/) | ✅ | 本地大模型运行平台 | @@ -116,6 +118,7 @@ | [SiliconFlow](https://siliconflow.cn/) | ✅ | 大模型聚合平台 | | [阿里云百炼](https://bailian.console.aliyun.com/) | ✅ | 大模型聚合平台, LLMOps 平台 | | [火山方舟](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | 大模型聚合平台, LLMOps 平台 | +| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | 大模型聚合平台 | | [MCP](https://modelcontextprotocol.io/) | ✅ | 支持通过 MCP 协议获取工具 | ### TTS diff --git a/README_EN.md b/README_EN.md index a5755f83..c9c449a1 100644 --- a/README_EN.md +++ b/README_EN.md @@ -7,7 +7,7 @@ RockChinQ%2FLangBot | Trendshift -Home | +HomeFeaturesDeploymentFAQ | @@ -45,6 +45,7 @@ > > - Before you start deploying in any way, please read the [New User Guide](https://docs.langbot.app/insight/guide.html). > - All documentation is in Chinese, we will provide i18n version in the near future. +> - Read [the auto-generated wiki on DeepWiki](https://deepwiki.com/RockChinQ/LangBot). #### Docker Compose Deployment @@ -85,12 +86,13 @@ Directly use the released version to run, see the [Manual Deployment](https://do | Personal QQ | ✅ | | | QQ Official API | ✅ | | | WeCom | ✅ | | +| WeComCS | ✅ | | | Personal WeChat | ✅ | Use [Gewechat](https://github.com/Devo919/Gewechat) to access | | Lark | ✅ | | | DingTalk | ✅ | | | Discord | ✅ | | | Telegram | ✅ | | -| Slack | 🚧 | | +| Slack | ✅ | | | LINE | 🚧 | | | WhatsApp | 🚧 | | @@ -107,12 +109,14 @@ Directly use the released version to run, see the [Manual Deployment](https://do | [xAI](https://x.ai/) | ✅ | | | [Zhipu AI](https://open.bigmodel.cn/) | ✅ | | | [Dify](https://dify.ai) | ✅ | LLMOps platform | +| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | LLM and GPU resource platform | | [Ollama](https://ollama.com/) | ✅ | Local LLM running platform | | [LMStudio](https://lmstudio.ai/) | ✅ | Local LLM running platform | | [GiteeAI](https://ai.gitee.com/) | ✅ | LLM interface gateway(MaaS) | | [SiliconFlow](https://siliconflow.cn/) | ✅ | LLM gateway(MaaS) | | [Aliyun Bailian](https://bailian.console.aliyun.com/) | ✅ | LLM gateway(MaaS), LLMOps platform | | [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | LLM gateway(MaaS), LLMOps platform | +| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | LLM gateway(MaaS) | | [MCP](https://modelcontextprotocol.io/) | ✅ | Support tool access through MCP protocol | ## 🤝 Community Contribution diff --git a/README_JP.md b/README_JP.md index 6ddb6655..c4ca5621 100644 --- a/README_JP.md +++ b/README_JP.md @@ -7,7 +7,7 @@ RockChinQ%2FLangBot | Trendshift -ホーム | +ホーム機能デプロイFAQ | @@ -44,6 +44,7 @@ > > - どのデプロイ方法を始める前に、必ず[新規ユーザーガイド](https://docs.langbot.app/insight/guide.html)をお読みください。 > - すべてのドキュメントは中国語で提供されています。近い将来、i18nバージョンを提供する予定です。 +> - Read [the auto-generated wiki on DeepWiki](https://deepwiki.com/RockChinQ/LangBot)。 #### Docker Compose デプロイ @@ -84,12 +85,13 @@ LangBotはBTPanelにリストされています。BTPanelをインストール | 個人QQ | ✅ | | | QQ公式API | ✅ | | | WeCom | ✅ | | +| WeComCS | ✅ | | | 個人WeChat | ✅ | [Gewechat](https://github.com/Devo919/Gewechat)を使用して接続 | | Lark | ✅ | | | DingTalk | ✅ | | | Discord | ✅ | | | Telegram | ✅ | | -| Slack | 🚧 | | +| Slack | ✅ | | | LINE | 🚧 | | | WhatsApp | 🚧 | | @@ -105,6 +107,7 @@ LangBotはBTPanelにリストされています。BTPanelをインストール | [Anthropic](https://www.anthropic.com/) | ✅ | | | [xAI](https://x.ai/) | ✅ | | | [Zhipu AI](https://open.bigmodel.cn/) | ✅ | | +| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | ✅ | 大模型とGPUリソースプラットフォーム | | [Dify](https://dify.ai) | ✅ | LLMOpsプラットフォーム | | [Ollama](https://ollama.com/) | ✅ | ローカルLLM実行プラットフォーム | | [LMStudio](https://lmstudio.ai/) | ✅ | ローカルLLM実行プラットフォーム | @@ -112,6 +115,7 @@ LangBotはBTPanelにリストされています。BTPanelをインストール | [SiliconFlow](https://siliconflow.cn/) | ✅ | LLMゲートウェイ(MaaS) | | [Aliyun Bailian](https://bailian.console.aliyun.com/) | ✅ | LLMゲートウェイ(MaaS), LLMOpsプラットフォーム | | [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | ✅ | LLMゲートウェイ(MaaS), LLMOpsプラットフォーム | +| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | ✅ | LLMゲートウェイ(MaaS) | | [MCP](https://modelcontextprotocol.io/) | ✅ | MCPプロトコルをサポート | ## 🤝 コミュニティ貢献 diff --git a/docker-compose.yaml b/docker-compose.yaml index 78548de5..6f75e85d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,6 +8,8 @@ services: - ./data:/app/data - ./plugins:/app/plugins restart: on-failure + environment: + - TZ=Asia/Shanghai ports: - 5300:5300 # 供 WebUI 使用 - 2280-2290:2280-2290 # 供消息平台适配器方向连接 diff --git a/libs/dingtalk_api/api.py b/libs/dingtalk_api/api.py index b908fd4f..883b7455 100644 --- a/libs/dingtalk_api/api.py +++ b/libs/dingtalk_api/api.py @@ -11,7 +11,12 @@ import traceback class DingTalkClient: def __init__( - self, client_id: str, client_secret: str, robot_name: str, robot_code: str + self, + client_id: str, + client_secret: str, + robot_name: str, + robot_code: str, + markdown_card: bool, ): """初始化 WebSocket 连接并自动启动""" self.credential = dingtalk_stream.Credential(client_id, client_secret) @@ -30,6 +35,7 @@ class DingTalkClient: self.robot_name = robot_name self.robot_code = robot_code self.access_token_expiry_time = '' + self.markdown_card = markdown_card async def get_access_token(self): url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken' @@ -114,7 +120,14 @@ class DingTalkClient: await self._handle_message(event) async def send_message(self, content: str, incoming_message): - self.EchoTextHandler.reply_text(content, incoming_message) + if self.markdown_card: + self.EchoTextHandler.reply_markdown( + title=self.robot_name + '的回答', + text=content, + incoming_message=incoming_message, + ) + else: + self.EchoTextHandler.reply_text(content, incoming_message) async def get_incoming_message(self): """获取收到的消息""" diff --git a/libs/slack_api/__init__.py b/libs/slack_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/slack_api/api.py b/libs/slack_api/api.py new file mode 100644 index 00000000..86239ce9 --- /dev/null +++ b/libs/slack_api/api.py @@ -0,0 +1,111 @@ +import json +from quart import Quart, jsonify,request +from slack_sdk.web.async_client import AsyncWebClient +from .slackevent import SlackEvent +from typing import Callable, Dict, Any +from pkg.platform.types import events as platform_events, message as platform_message + +class SlackClient(): + + def __init__(self,bot_token:str,signing_secret:str): + + self.bot_token = bot_token + self.signing_secret = signing_secret + self.app = Quart(__name__) + self.client = AsyncWebClient(self.bot_token) + self.app.add_url_rule('/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']) + self._message_handlers = { + "example":[], + } + self.bot_user_id = None # 避免机器人回复自己的消息 + + async def handle_callback_request(self): + try: + body = await request.get_data() + data = json.loads(body) + if 'type' in data: + if data['type'] == 'url_verification': + return data['challenge'] + + bot_user_id = data.get("event",{}).get("bot_id","") + + if self.bot_user_id and bot_user_id == self.bot_user_id: + return jsonify({'status': 'ok'}) + + + # 处理私信 + if data and data.get("event", {}).get("channel_type") in ["im"]: + event = SlackEvent.from_payload(data) + await self._handle_message(event) + return jsonify({'status': 'ok'}) + + #处理群聊 + if data.get("event",{}).get("type") == 'app_mention': + data.setdefault("event", {})["channel_type"] = "channel" + event = SlackEvent.from_payload(data) + await self._handle_message(event) + return jsonify({'status':'ok'}) + + return jsonify({'status': 'ok'}) + + except Exception as e: + raise(e) + + + + async def _handle_message(self, event: SlackEvent): + """ + 处理消息事件。 + """ + msg_type = event.type + if msg_type in self._message_handlers: + for handler in self._message_handlers[msg_type]: + await handler(event) + + def on_message(self, msg_type: str): + """注册消息类型处理器""" + def decorator(func: Callable[[platform_events.Event], None]): + if msg_type not in self._message_handlers: + self._message_handlers[msg_type] = [] + self._message_handlers[msg_type].append(func) + return func + return decorator + + async def send_message_to_channel(self,text:str,channel_id:str): + try: + response = await self.client.chat_postMessage( + channel=channel_id, + text=text + ) + if self.bot_user_id is None and response.get("ok"): + self.bot_user_id = response["message"]["bot_id"] + return + except Exception as e: + raise e + + async def send_message_to_one(self,text:str,user_id:str): + try: + response = await self.client.chat_postMessage( + channel = '@'+user_id, + text= text + ) + if self.bot_user_id is None and response.get("ok"): + self.bot_user_id = response["message"]["bot_id"] + + return + except Exception as e: + raise e + + async def run_task(self, host: str, port: int, *args, **kwargs): + """ + 启动 Quart 应用。 + """ + await self.app.run_task(host=host, port=port, *args, **kwargs) + + + + + + + + diff --git a/libs/slack_api/slackevent.py b/libs/slack_api/slackevent.py new file mode 100644 index 00000000..5a6e9f90 --- /dev/null +++ b/libs/slack_api/slackevent.py @@ -0,0 +1,91 @@ +from typing import Dict, Any, Optional + +class SlackEvent(dict): + @staticmethod + def from_payload(payload: Dict[str, Any]) -> Optional["SlackEvent"]: + try: + event = SlackEvent(payload) + return event + except KeyError: + return None + + @property + def text(self) -> str: + + if self.get("event", {}).get("channel_type") == "im": + blocks = self.get("event", {}).get("blocks", []) + if not blocks: + return "" + + elements = blocks[0].get("elements", []) + if not elements: + return "" + + elements = elements[0].get("elements", []) + text = "" + + for el in elements: + if el.get("type") == "text": + text += el.get("text", "") + elif el.get("type") == "link": + text += el.get("url", "") + + return text + + + if self.get("event",{}).get("channel_type") == 'channel': + message_text = "" + for block in self.get("event", {}).get("blocks", []): + if block.get("type") == "rich_text": + for element in block.get("elements", []): + if element.get("type") == "rich_text_section": + parts = [] + for el in element.get("elements", []): + if el.get("type") == "text": + parts.append(el["text"]) + elif el.get("type") == "link": + parts.append(el["url"]) + message_text = "".join(parts) + + return message_text + + + + @property + def user_id(self) -> Optional[str]: + return self.get("event", {}).get("user","") + + @property + def channel_id(self) -> Optional[str]: + return self.get("event", {}).get("channel","") + + @property + def type(self) -> str: + """ message对应私聊,app_mention对应频道at """ + return self.get("event", {}).get("channel_type", "") + + @property + def message_id(self) -> str: + return self.get("event_id","") + + @property + def pic_url(self) -> str: + """提取 Slack 事件中的图片 URL""" + files = self.get("event", {}).get("files", []) + if files: + return files[0].get("url_private", "") + return None + + + @property + def sender_name(self) -> str: + return self.get("event", {}).get("user","") + + def __getattr__(self, key: str) -> Optional[Any]: + return self.get(key) + + def __setattr__(self, key: str, value: Any) -> None: + self[key] = value + + def __repr__(self) -> str: + return f"" diff --git a/libs/wecom_customer_service_api/__init__.py b/libs/wecom_customer_service_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/wecom_customer_service_api/api.py b/libs/wecom_customer_service_api/api.py new file mode 100644 index 00000000..04e5398b --- /dev/null +++ b/libs/wecom_customer_service_api/api.py @@ -0,0 +1,343 @@ +from quart import request +from ..wecom_api.WXBizMsgCrypt3 import WXBizMsgCrypt +import base64 +import binascii +import httpx +import traceback +from quart import Quart +import xml.etree.ElementTree as ET +from typing import Callable, Dict, Any +from .wecomcsevent import WecomCSEvent +from pkg.platform.types import events as platform_events, message as platform_message +import aiofiles + + +class WecomCSClient(): + def __init__(self,corpid:str,secret:str,token:str,EncodingAESKey:str): + self.corpid = corpid + self.secret = secret + self.access_token_for_contacts ='' + self.token = token + self.aes = EncodingAESKey + self.base_url = 'https://qyapi.weixin.qq.com/cgi-bin' + self.access_token = '' + self.app = Quart(__name__) + self.wxcpt = WXBizMsgCrypt(self.token, self.aes, self.corpid) + self.app.add_url_rule('/callback/command', 'handle_callback', self.handle_callback_request, methods=['GET', 'POST']) + self._message_handlers = { + "example":[], + } + + async def get_pic_url(self, media_id: str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + + url = f"{self.base_url}/media/get?access_token={self.access_token}&media_id={media_id}" + + async with httpx.AsyncClient() as client: + response = await client.get(url) + if response.headers.get("Content-Type", "").startswith("application/json"): + data = response.json() + if data.get('errcode') in [40014, 42001]: + self.access_token = await self.get_access_token(self.secret) + return await self.get_pic_url(media_id) + else: + raise Exception("Failed to get image: " + str(data)) + + # 否则是图片,转成 base64 + image_bytes = response.content + content_type = response.headers.get("Content-Type", "") + base64_str = base64.b64encode(image_bytes).decode("utf-8") + base64_str = f"data:{content_type};base64,{base64_str}" + return base64_str + + + #access——token操作 + async def check_access_token(self): + return bool(self.access_token and self.access_token.strip()) + + async def check_access_token_for_contacts(self): + return bool(self.access_token_for_contacts and self.access_token_for_contacts.strip()) + + async def get_access_token(self,secret): + url = f'https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid={self.corpid}&corpsecret={secret}' + async with httpx.AsyncClient() as client: + response = await client.get(url) + data = response.json() + if 'access_token' in data: + return data['access_token'] + else: + raise Exception(f"未获取access token: {data}") + + async def get_detailed_message_list(self,xml_msg:str): + # 在本方法中解析消息,并且获得消息的具体内容 + if isinstance(xml_msg, bytes): + xml_msg = xml_msg.decode('utf-8') + root = ET.fromstring(xml_msg) + token = root.find("Token").text + open_kfid = root.find("OpenKfId").text + + # if open_kfid in self.openkfid_list: + # return None + # else: + # self.openkfid_list.append(open_kfid) + + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + + url = self.base_url+'/kf/sync_msg?access_token='+ self.access_token + async with httpx.AsyncClient() as client: + params = { + "token": token, + "voice_format": 0, + "open_kfid": open_kfid, + } + response = await client.post(url,json=params) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.get_detailed_message_list(xml_msg) + if data['errcode'] != 0: + raise Exception("Failed to get message") + + last_msg_data = data['msg_list'][-1] + open_kfid = last_msg_data.get("open_kfid") + # 进行获取图片操作 + if last_msg_data.get("msgtype") == "image": + media_id = last_msg_data.get("image").get("media_id") + picurl = await self.get_pic_url(media_id) + last_msg_data["picurl"] = picurl + # await self.change_service_status(userid=external_userid,openkfid=open_kfid,servicer=servicer) + return last_msg_data + + + async def change_service_status(self,userid:str,openkfid:str,servicer:str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + url = self.base_url+"/kf/service_state/get?access_token="+self.access_token + async with httpx.AsyncClient() as client: + params = { + "open_kfid" : openkfid, + "external_userid" : userid, + "service_state" : 1, + "servicer_userid" : servicer, + } + response = await client.post(url,json=params) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.change_service_status(userid,openkfid) + if data['errcode'] != 0: + raise Exception("Failed to change service status: "+str(data)) + + + async def send_image(self,user_id:str,agent_id:int,media_id:str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + url = self.base_url+'/media/upload?access_token='+self.access_token + async with httpx.AsyncClient() as client: + params = { + "touser" : user_id, + "toparty" : "", + "totag":"", + "agentid" : agent_id, + "msgtype" : "image", + "image" : { + "media_id" : media_id, + }, + "safe":0, + "enable_id_trans": 0, + "enable_duplicate_check": 0, + "duplicate_check_interval": 1800 + } + try: + response = await client.post(url,json=params) + data = response.json() + except Exception as e: + raise Exception("Failed to send image: "+str(e)) + + # 企业微信错误码40014和42001,代表accesstoken问题 + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.send_image(user_id,agent_id,media_id) + + if data['errcode'] != 0: + raise Exception("Failed to send image: "+str(data)) + + + async def send_text_msg(self, open_kfid: str, external_userid: str, msgid: str,content:str): + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + + url = f"https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token={self.access_token}" + + payload = { + "touser": external_userid, + "open_kfid": open_kfid, + "msgid": msgid, + "msgtype": "text", + "text": { + "content": content, + } + } + + async with httpx.AsyncClient() as client: + response = await client.post(url, json=payload) + + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + return await self.send_text_msg(open_kfid,external_userid,msgid,content) + if data['errcode'] != 0: + raise Exception("Failed to send message") + return data + + + async def handle_callback_request(self): + """ + 处理回调请求,包括 GET 验证和 POST 消息接收。 + """ + try: + + msg_signature = request.args.get("msg_signature") + timestamp = request.args.get("timestamp") + nonce = request.args.get("nonce") + + if request.method == "GET": + echostr = request.args.get("echostr") + ret, reply_echo_str = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) + if ret != 0: + raise Exception(f"验证失败,错误码: {ret}") + return reply_echo_str + + elif request.method == "POST": + encrypt_msg = await request.data + ret, xml_msg = self.wxcpt.DecryptMsg(encrypt_msg, msg_signature, timestamp, nonce) + if ret != 0: + raise Exception(f"消息解密失败,错误码: {ret}") + + # 解析消息并处理 + message_data = await self.get_detailed_message_list(xml_msg) + if message_data is not None: + event = WecomCSEvent.from_payload(message_data) + if event: + await self._handle_message(event) + + return "success" + except Exception as e: + traceback.print_exc() + return f"Error processing request: {str(e)}", 400 + + async def run_task(self, host: str, port: int, *args, **kwargs): + """ + 启动 Quart 应用。 + """ + await self.app.run_task(host=host, port=port, *args, **kwargs) + + def on_message(self, msg_type: str): + """ + 注册消息类型处理器。 + """ + def decorator(func: Callable[[WecomCSEvent], None]): + if msg_type not in self._message_handlers: + self._message_handlers[msg_type] = [] + self._message_handlers[msg_type].append(func) + return func + return decorator + + async def _handle_message(self, event: WecomCSEvent): + """ + 处理消息事件。 + """ + msg_type = event.type + if msg_type in self._message_handlers: + for handler in self._message_handlers[msg_type]: + await handler(event) + + + @staticmethod + async def get_image_type(image_bytes: bytes) -> str: + """ + 通过图片的magic numbers判断图片类型 + """ + magic_numbers = { + b'\xFF\xD8\xFF': 'jpg', + b'\x89\x50\x4E\x47': 'png', + b'\x47\x49\x46': 'gif', + b'\x42\x4D': 'bmp', + b'\x00\x00\x01\x00': 'ico' + } + + for magic, ext in magic_numbers.items(): + if image_bytes.startswith(magic): + return ext + return 'jpg' # 默认返回jpg + + + async def upload_to_work(self, image: platform_message.Image): + """ + 获取 media_id + """ + if not await self.check_access_token(): + self.access_token = await self.get_access_token(self.secret) + + url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file' + file_bytes = None + file_name = "uploaded_file.txt" + + # 获取文件的二进制数据 + if image.path: + async with aiofiles.open(image.path, 'rb') as f: + file_bytes = await f.read() + file_name = image.path.split('/')[-1] + elif image.url: + file_bytes = await self.download_image_to_bytes(image.url) + file_name = image.url.split('/')[-1] + elif image.base64: + try: + base64_data = image.base64 + if ',' in base64_data: + base64_data = base64_data.split(',', 1)[1] + padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0 + padded_base64 = base64_data + '=' * padding + file_bytes = base64.b64decode(padded_base64) + except binascii.Error as e: + raise ValueError(f"Invalid base64 string: {str(e)}") + else: + raise ValueError("image对象出错") + + # 设置 multipart/form-data 格式的文件 + boundary = "-------------------------acebdf13572468" + headers = { + 'Content-Type': f'multipart/form-data; boundary={boundary}' + } + body = ( + f"--{boundary}\r\n" + f"Content-Disposition: form-data; name=\"media\"; filename=\"{file_name}\"; filelength={len(file_bytes)}\r\n" + f"Content-Type: application/octet-stream\r\n\r\n" + ).encode('utf-8') + file_bytes + f"\r\n--{boundary}--\r\n".encode('utf-8') + + # 上传文件 + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, content=body) + data = response.json() + if data['errcode'] == 40014 or data['errcode'] == 42001: + self.access_token = await self.get_access_token(self.secret) + media_id = await self.upload_to_work(image) + if data.get('errcode', 0) != 0: + raise Exception("failed to upload file") + + media_id = data.get('media_id') + return media_id + + async def download_image_to_bytes(self,url:str) -> bytes: + async with httpx.AsyncClient() as client: + response = await client.get(url) + response.raise_for_status() + return response.content + + #进行media_id的获取 + async def get_media_id(self, image: platform_message.Image): + + media_id = await self.upload_to_work(image=image) + return media_id diff --git a/libs/wecom_customer_service_api/wecomcsevent.py b/libs/wecom_customer_service_api/wecomcsevent.py new file mode 100644 index 00000000..8dc0e30d --- /dev/null +++ b/libs/wecom_customer_service_api/wecomcsevent.py @@ -0,0 +1,134 @@ +from typing import Dict, Any, Optional + + +class WecomCSEvent(dict): + """ + 封装从企业微信收到的事件数据对象(字典),提供属性以获取其中的字段。 + + 除 `type` 和 `detail_type` 属性对于任何事件都有效外,其它属性是否存在(若不存在则返回 `None`)依事件类型不同而不同。 + """ + + @staticmethod + def from_payload(payload: Dict[str, Any]) -> Optional["WecomCSEvent"]: + """ + 从企业微信(客服会话)事件数据构造 `WecomEvent` 对象。 + + Args: + payload (Dict[str, Any]): 解密后的企业微信事件数据。 + + Returns: + Optional[WecomEvent]: 如果事件数据合法,则返回 WecomEvent 对象;否则返回 None。 + """ + try: + event = WecomCSEvent(payload) + _ = event.type, + return event + except KeyError: + return None + + @property + def type(self) -> str: + """ + 事件类型,例如 "message"、"event"、"text" 等。 + + Returns: + str: 事件类型。 + """ + return self.get("msgtype", "") + + @property + def user_id(self) -> Optional[str]: + """ + 用户 ID,例如消息的发送者或事件的触发者。 + + Returns: + Optional[str]: 用户 ID。 + """ + return self.get("external_userid") + + @property + def receiver_id(self) -> Optional[str]: + """ + 接收者 ID,例如机器人自身的企业微信 ID。 + + Returns: + Optional[str]: 接收者 ID。 + """ + return self.get("open_kfid","") + + @property + def picurl(self) -> Optional[str]: + """ + 图片 URL,仅在图片消息中存在。 + base64格式 + Returns: + Optional[str]: 图片 URL。 + """ + + return self.get("picurl","") + + @property + def message_id(self) -> Optional[str]: + """ + 消息 ID,仅在消息类型事件中存在。 + + Returns: + Optional[str]: 消息 ID。 + """ + return self.get("msgid") + + @property + def message(self) -> Optional[str]: + """ + 消息内容,仅在消息类型事件中存在。 + + Returns: + Optional[str]: 消息内容。 + """ + if self.get("msgtype") == 'text': + return self.get("text").get("content","") + else: + return None + + + @property + def timestamp(self) -> Optional[int]: + """ + 事件发生的时间戳。 + + Returns: + Optional[int]: 时间戳。 + """ + return self.get("send_time") + + + def __getattr__(self, key: str) -> Optional[Any]: + """ + 允许通过属性访问数据中的任意字段。 + + Args: + key (str): 字段名。 + + Returns: + Optional[Any]: 字段值。 + """ + return self.get(key) + + def __setattr__(self, key: str, value: Any) -> None: + """ + 允许通过属性设置数据中的任意字段。 + + Args: + key (str): 字段名。 + value (Any): 字段值。 + """ + self[key] = value + + def __repr__(self) -> str: + """ + 生成事件对象的字符串表示。 + + Returns: + str: 字符串表示。 + """ + return f"" diff --git a/pkg/core/bootutils/deps.py b/pkg/core/bootutils/deps.py index b0ba7983..db3c12a0 100644 --- a/pkg/core/bootutils/deps.py +++ b/pkg/core/bootutils/deps.py @@ -38,6 +38,7 @@ required_deps = { 'certifi': 'certifi', 'mcp': 'mcp', 'sqlmodel': 'sqlmodel', + 'telegramify_markdown': 'telegramify-markdown', } diff --git a/pkg/core/migrations/m038_tg_dingtalk_markdown.py b/pkg/core/migrations/m038_tg_dingtalk_markdown.py new file mode 100644 index 00000000..1123c6b2 --- /dev/null +++ b/pkg/core/migrations/m038_tg_dingtalk_markdown.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from .. import migration + + +@migration.migration_class("tg-dingtalk-markdown", 38) +class TgDingtalkMarkdownMigration(migration.Migration): + """迁移""" + + async def need_migrate(self) -> bool: + """判断当前环境是否需要运行此迁移""" + + for adapter in self.ap.platform_cfg.data['platform-adapters']: + if adapter['adapter'] in ['dingtalk','telegram']: + if 'markdown_card' not in adapter: + return True + return False + + async def run(self): + """执行迁移""" + for adapter in self.ap.platform_cfg.data['platform-adapters']: + if adapter['adapter'] in ['dingtalk','telegram']: + if 'markdown_card' not in adapter: + adapter['markdown_card'] = False + await self.ap.platform_cfg.dump_config() + \ No newline at end of file diff --git a/pkg/core/migrations/m039_modelscope_cfg_completion.py b/pkg/core/migrations/m039_modelscope_cfg_completion.py new file mode 100644 index 00000000..8e574911 --- /dev/null +++ b/pkg/core/migrations/m039_modelscope_cfg_completion.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from .. import migration + + +@migration.migration_class("modelscope-config-completion", 39) +class ModelScopeConfigCompletionMigration(migration.Migration): + """ModelScope配置迁移 + """ + + async def need_migrate(self) -> bool: + """判断当前环境是否需要运行此迁移 + """ + return 'modelscope-chat-completions' not in self.ap.provider_cfg.data['requester'] \ + or 'modelscope' not in self.ap.provider_cfg.data['keys'] + + async def run(self): + """执行迁移 + """ + if 'modelscope-chat-completions' not in self.ap.provider_cfg.data['requester']: + self.ap.provider_cfg.data['requester']['modelscope-chat-completions'] = { + 'base-url': 'https://api-inference.modelscope.cn/v1', + 'args': {}, + 'timeout': 120, + } + + if 'modelscope' not in self.ap.provider_cfg.data['keys']: + self.ap.provider_cfg.data['keys']['modelscope'] = [] + + await self.ap.provider_cfg.dump_config() diff --git a/pkg/core/migrations/m040_ppio_config.py b/pkg/core/migrations/m040_ppio_config.py new file mode 100644 index 00000000..cd218d87 --- /dev/null +++ b/pkg/core/migrations/m040_ppio_config.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from .. import migration + + +@migration.migration_class("ppio-config", 40) +class PPIOConfigMigration(migration.Migration): + """PPIO配置迁移 + """ + + async def need_migrate(self) -> bool: + """判断当前环境是否需要运行此迁移 + """ + return 'ppio-chat-completions' not in self.ap.provider_cfg.data['requester'] \ + or 'ppio' not in self.ap.provider_cfg.data['keys'] + + async def run(self): + """执行迁移 + """ + if 'ppio-chat-completions' not in self.ap.provider_cfg.data['requester']: + self.ap.provider_cfg.data['requester']['ppio-chat-completions'] = { + 'base-url': 'https://api.ppinfra.com/v3/openai', + 'args': {}, + 'timeout': 120, + } + + if 'ppio' not in self.ap.provider_cfg.data['keys']: + self.ap.provider_cfg.data['keys']['ppio'] = [] + + await self.ap.provider_cfg.dump_config() diff --git a/pkg/platform/sources/dingtalk.py b/pkg/platform/sources/dingtalk.py index 52f4a832..ff9173ef 100644 --- a/pkg/platform/sources/dingtalk.py +++ b/pkg/platform/sources/dingtalk.py @@ -116,6 +116,7 @@ class DingTalkAdapter(adapter.MessagePlatformAdapter): client_secret=config['client_secret'], robot_name=config['robot_name'], robot_code=config['robot_code'], + markdown_card=config['markdown_card'], ) async def reply_message( diff --git a/pkg/platform/sources/gewechat.py b/pkg/platform/sources/gewechat.py index 2c9e5733..0555f050 100644 --- a/pkg/platform/sources/gewechat.py +++ b/pkg/platform/sources/gewechat.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import gewechat_client import typing @@ -7,7 +5,6 @@ import asyncio import traceback import time import re -import base64 import copy import threading @@ -21,6 +18,8 @@ from ..types import events as platform_events from ..types import entities as platform_entities from ...utils import image import xml.etree.ElementTree as ET +from typing import Optional, Tuple +from functools import partial class GewechatMessageConverter(adapter.MessageConverter): @@ -49,182 +48,437 @@ class GewechatMessageConverter(adapter.MessageConverter): content_list.extend( await GewechatMessageConverter.yiri2target(node.message_chain) ) + content_list.append({'type': 'image', 'image': component.url}) + elif isinstance(component, platform_message.WeChatMiniPrograms): + content_list.append( + { + 'type': 'WeChatMiniPrograms', + 'mini_app_id': component.mini_app_id, + 'display_name': component.display_name, + 'page_path': component.page_path, + 'cover_img_url': component.image_url, + 'title': component.title, + 'user_name': component.user_name, + } + ) + elif isinstance(component, platform_message.WeChatForwardMiniPrograms): + content_list.append( + { + 'type': 'WeChatForwardMiniPrograms', + 'xml_data': component.xml_data, + 'image_url': component.image_url, + } + ) + elif isinstance(component, platform_message.WeChatEmoji): + content_list.append( + { + 'type': 'WeChatEmoji', + 'emoji_md5': component.emoji_md5, + 'emoji_size': component.emoji_size, + } + ) + elif isinstance(component, platform_message.WeChatLink): + content_list.append( + { + 'type': 'WeChatLink', + 'link_title': component.link_title, + 'link_desc': component.link_desc, + 'link_thumb_url': component.link_thumb_url, + 'link_url': component.link_url, + } + ) + elif isinstance(component, platform_message.WeChatForwardLink): + content_list.append( + {'type': 'WeChatForwardLink', 'xml_data': component.xml_data} + ) + elif isinstance(component, platform_message.Voice): + content_list.append( + {'type': 'voice', 'url': component.url, 'length': component.length} + ) + elif isinstance(component, platform_message.WeChatForwardImage): + content_list.append( + {'type': 'WeChatForwardImage', 'xml_data': component.xml_data} + ) + elif isinstance(component, platform_message.WeChatForwardFile): + content_list.append( + {'type': 'WeChatForwardFile', 'xml_data': component.xml_data} + ) + elif isinstance(component, platform_message.WeChatAppMsg): + content_list.append( + {'type': 'WeChatAppMsg', 'app_msg': component.app_msg} + ) + # 引用消息转发 + elif isinstance(component, platform_message.WeChatForwardQuote): + content_list.append( + {'type': 'WeChatAppMsg', 'app_msg': component.app_msg} + ) + elif isinstance(component, platform_message.Forward): + for node in component.node_list: + if node.message_chain: + content_list.extend( + await GewechatMessageConverter.yiri2target( + node.message_chain + ) + ) return content_list async def target2yiri( self, message: dict, bot_account_id: str ) -> platform_message.MessageChain: - if message['Data']['MsgType'] == 1: - # 检查消息开头,如果有 wxid_sbitaz0mt65n22:\n 则删掉 - regex = re.compile(r'^wxid_.*:') - # print(message) + """外部消息转平台消息""" + # 数据预处理 + message_list = [] + ats_bot = False # 是否被@ + content = message['Data']['Content']['string'] + 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) + if '@所有人' in content: + message_list.append(platform_message.AtAll()) + elif ats_bot: + message_list.append(platform_message.At(target=bot_account_id)) + content_no_preifx, _ = self._extract_content_and_sender(content) - line_split = message['Data']['Content']['string'].split('\n') + msg_type = message['Data']['MsgType'] - if len(line_split) > 0 and regex.match(line_split[0]): - message['Data']['Content']['string'] = '\n'.join(line_split[1:]) + # 映射消息类型到处理器方法 + handler_map = { + 1: self._handler_text, + 3: self._handler_image, + 34: self._handler_voice, + 49: self._handler_compound, # 复合类型 + } - # 正则表达式模式,匹配'@'后跟任意数量的非空白字符 - pattern = r'@\S+' - 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) - ) - ) - # 更优雅的替换改名后@机器人,仅仅限于单独AT的情况 - elif ( - 'PushContent' in message['Data'] - and '在群聊中@了你' in message['Data']['PushContent'] - ): - if ( - '@所有人' in message['Data']['Content']['string'] - ): # at全员时候传入atll不当作at自己 - content_list.append(platform_message.AtAll()) - else: - content_list.append(platform_message.At(target=bot_account_id)) - content_list.append( - platform_message.Plain( - re.sub(pattern, '', message['Data']['Content']['string']) - ) - ) - else: - content_list = [ - platform_message.Plain(message['Data']['Content']['string']) - ] + # 分派处理 + handler = handler_map.get(msg_type, self._handler_default) + handler_result = await handler( + message=message, # 原始的message + content_no_preifx=content_no_preifx, # 处理后的content + ) - return platform_message.MessageChain(content_list) + if handler_result and len(handler_result) > 0: + message_list.extend(handler_result) - elif message['Data']['MsgType'] == 3: - image_xml = message['Data']['Content']['string'] + return platform_message.MessageChain(message_list) + + async def _handler_text( + self, message: Optional[dict], content_no_preifx: str + ) -> platform_message.MessageChain: + """处理文本消息 (msg_type=1)""" + if message and self._is_group_message(message): + pattern = r'@\S{1,20}' + content_no_preifx = re.sub(pattern, '', content_no_preifx) + + return platform_message.MessageChain( + [platform_message.Plain(content_no_preifx)] + ) + + async def _handler_image( + self, message: Optional[dict], content_no_preifx: str + ) -> platform_message.MessageChain: + """处理图像消息 (msg_type=3)""" + try: + image_xml = content_no_preifx if not image_xml: return platform_message.MessageChain( - [platform_message.Plain(text='[图片内容为空]')] + [platform_message.Unknown('[图片内容为空]')] ) - try: - base64_str, image_format = await image.get_gewechat_image_base64( - gewechat_url=self.config['gewechat_url'], - gewechat_file_url=self.config['gewechat_file_url'], - app_id=self.config['app_id'], - xml_content=image_xml, - token=self.config['token'], - image_type=2, - ) - - return platform_message.MessageChain( - [ - platform_message.Image( - base64=f'data:image/{image_format};base64,{base64_str}' - ) - ] - ) - except Exception as e: - print(f'处理图片消息失败: {str(e)}') - return platform_message.MessageChain( - [platform_message.Plain(text='[图片处理失败]')] - ) - elif message['Data']['MsgType'] == 34: - audio_base64 = message['Data']['ImgBuf']['buffer'] - return platform_message.MessageChain( - [ - platform_message.Voice( - base64=f'data:audio/silk;base64,{audio_base64}' - ) - ] + base64_str, image_format = await image.get_gewechat_image_base64( + gewechat_url=self.config['gewechat_url'], + gewechat_file_url=self.config['gewechat_file_url'], + app_id=self.config['app_id'], + xml_content=image_xml, + token=self.config['token'], + image_type=2, ) - elif message['Data']['MsgType'] == 49: - # 支持微信聊天记录的消息类型,将 XML 内容转换为 MessageChain 传递 - try: - content = message['Data']['Content']['string'] - # 有三种可能的消息结构weid开头,私聊直接和直接 - if content.startswith('wxid'): - xml_list = content.split('\n')[2:] - xml_data = '\n'.join(xml_list) - elif content.startswith(' platform_message.MessageChain: + """处理语音消息 (msg_type=34)""" + message_List = [] + try: + # 从消息中提取语音数据(需根据实际数据结构调整字段名) + audio_base64 = message['Data']['ImgBuf']['buffer'] + + # 验证语音数据有效性 + if not audio_base64: + message_List.append(platform_message.Unknown(text='[语音内容为空]')) + return platform_message.MessageChain(message_List) + + # 转换为平台支持的语音格式(如 Silk 格式) + voice_element = platform_message.Voice( + base64=f'data:audio/silk;base64,{audio_base64}' + ) + message_List.append(voice_element) + + except KeyError as e: + print(f'语音数据字段缺失: {str(e)}') + message_List.append(platform_message.Unknown(text='[语音数据解析失败]')) + except Exception as e: + print(f'处理语音消息异常: {str(e)}') + message_List.append(platform_message.Unknown(text='[语音处理失败]')) + + return platform_message.MessageChain(message_List) + + async def _handler_compound( + self, message: Optional[dict], content_no_preifx: str + ) -> platform_message.MessageChain: + """处理复合消息 (msg_type=49),根据子类型分派""" + try: + xml_data = ET.fromstring(content_no_preifx) + appmsg_data = xml_data.find('.//appmsg') + if appmsg_data: + data_type = appmsg_data.findtext('.//type', '') + + # 二次分派处理器 + sub_handler_map = { + '57': self._handler_compound_quote, + '5': self._handler_compound_link, + '6': self._handler_compound_file, + '33': self._handler_compound_mini_program, + '36': self._handler_compound_mini_program, + '2000': partial( + self._handler_compound_unsupported, text='[转账消息]' + ), + '2001': partial( + self._handler_compound_unsupported, text='[红包消息]' + ), + '51': partial( + self._handler_compound_unsupported, text='[视频号消息]' + ), + } + + handler = sub_handler_map.get( + data_type, self._handler_compound_unsupported + ) + return await handler( + message=message, # 原始msg + xml_data=xml_data, # xml数据 + ) + else: + return platform_message.MessageChain( + [platform_message.Unknown(text=content_no_preifx)] + ) + except Exception as e: + print(f'解析复合消息失败: {str(e)}') + return platform_message.MessageChain( + [platform_message.Unknown(text=content_no_preifx)] + ) + + async def _handler_compound_quote( + self, message: Optional[dict], xml_data: ET.Element + ) -> platform_message.MessageChain: + """处理引用消息 (data_type=57)""" + message_list = [] + # print("_handler_compound_quote", ET.tostring(xml_data, encoding='unicode')) + appmsg_data = xml_data.find('.//appmsg') + quote_data = '' # 引用原文 + user_data = '' # 用户消息 + sender_id = xml_data.findtext('.//fromusername') # 发送方:单聊用户/群member + if appmsg_data: + user_data = appmsg_data.findtext('.//title') or '' + quote_data = appmsg_data.find('.//refermsg').findtext('.//content') + message_list.append( + platform_message.WeChatForwardQuote( + app_msg=ET.tostring(appmsg_data, encoding='unicode') + ) + ) + # quote_data原始的消息 + if quote_data: + quote_data_message_list = platform_message.MessageChain() + # 文本消息 + try: + if '' not in quote_data: + quote_data_message_list.append(platform_message.Plain(quote_data)) else: - try: - content_bytes = content.encode('utf-8') - decoded_content = base64.b64decode(content_bytes) - return platform_message.MessageChain( - [platform_message.Unknown(content=decoded_content)] + # 引用消息展开 + quote_data_xml = ET.fromstring(quote_data) + if quote_data_xml.find('img'): + quote_data_message_list.extend( + await self._handler_image(None, quote_data) ) - except Exception: - return platform_message.MessageChain( - [platform_message.Plain(text=content)] + elif quote_data_xml.find('voicemsg'): + quote_data_message_list.extend( + await self._handler_voice(None, quote_data) + ) + elif quote_data_xml.find('videomsg'): + quote_data_message_list.extend( + await self._handler_default(None, quote_data) + ) # 先不处理 + else: + # appmsg + quote_data_message_list.extend( + await self._handler_compound(None, quote_data) ) except Exception as e: - print(f'Error processing type 49 message: {str(e)}') - return platform_message.MessageChain( - [platform_message.Plain(text='[无法解析的消息]')] + print(f'处理引用消息异常 expcetion:{e}') + quote_data_message_list.append(platform_message.Plain(quote_data)) + message_list.append( + platform_message.Quote( + sender_id=sender_id, + origin=quote_data_message_list, ) + ) + if len(user_data) > 0: + pattern = r'@\S{1,20}' + user_data = re.sub(pattern, '', user_data) + message_list.append(platform_message.Plain(user_data)) + + # for comp in message_list: + # if isinstance(comp, platform_message.Quote): + # print(f"quote_message_chain len={len(message_list)}") + # print(f"quote_message_chain send_id={comp.sender_id}" ) + # for quote_item in comp.origin: + # print(f"--quote_message_component [msg_type={quote_item.type}][message={quote_item}]" ) + # else: + # print(f"quote_message_chain plain [msg_type={comp.type}][message={comp.text}]") + return platform_message.MessageChain(message_list) + + async def _handler_compound_file( + self, message: dict, xml_data: ET.Element + ) -> platform_message.MessageChain: + """处理文件消息 (data_type=6)""" + xml_data_str = ET.tostring(xml_data, encoding='unicode') + return platform_message.MessageChain( + [platform_message.WeChatForwardFile(xml_data=xml_data_str)] + ) + + async def _handler_compound_link( + self, message: dict, xml_data: ET.Element + ) -> platform_message.MessageChain: + """处理链接消息(如公众号文章、外部网页)""" + message_list = [] + try: + # 解析 XML 中的链接参数 + appmsg = xml_data.find('.//appmsg') + if appmsg is None: + return platform_message.MessageChain() + message_list.append( + platform_message.WeChatLink( + link_title=appmsg.findtext('title', ''), + link_desc=appmsg.findtext('des', ''), + link_url=appmsg.findtext('url', ''), + link_thumb_url=appmsg.findtext('thumburl', ''), # 这个字段拿不到 + ) + ) + # 转发消息 + xml_data_str = ET.tostring(xml_data, encoding='unicode') + # print(xml_data_str) + message_list.append( + platform_message.WeChatForwardLink(xml_data=xml_data_str) + ) + except Exception as e: + print(f'解析链接消息失败: {str(e)}') + return platform_message.MessageChain(message_list) + + async def _handler_compound_mini_program( + self, message: dict, xml_data: ET.Element + ) -> platform_message.MessageChain: + """处理小程序消息(如小程序卡片、服务通知)""" + xml_data_str = ET.tostring(xml_data, encoding='unicode') + return platform_message.MessageChain( + [platform_message.WeChatForwardMiniPrograms(xml_data=xml_data_str)] + ) + + async def _handler_default( + self, message: Optional[dict], content_no_preifx: str + ) -> platform_message.MessageChain: + """处理未知消息类型""" + if message: + msg_type = message['Data']['MsgType'] + else: + msg_type = '' + return platform_message.MessageChain( + [platform_message.Unknown(text=f'[未知消息类型 msg_type:{msg_type}]')] + ) + + def _handler_compound_unsupported( + self, message: dict, xml_data: str, text: Optional[str] = None + ) -> platform_message.MessageChain: + """处理未支持复合消息类型(msg_type=49)子类型""" + if not text: + text = f'[xml_data={xml_data}]' + content_list = [] + content_list.append( + platform_message.Unknown( + text=f'[处理未支持复合消息类型[msg_type=49]|{text}' + ) + ) + + return platform_message.MessageChain(content_list) + + # 返回是否被艾特 + def _ats_bot(self, message: dict, bot_account_id: str) -> bool: + ats_bot = False + try: + to_user_name = message['Wxid'] # 接收方: 所属微信的wxid + raw_content = message['Data']['Content']['string'] # 原始消息内容 + content_no_prefix, _ = self._extract_content_and_sender(raw_content) + # 直接艾特机器人(这个有bug,当被引用的消息里面有@bot,会套娃 + # ats_bot = ats_bot or (f"@{bot_account_id}" in content_no_prefix) + # 文本类@bot + push_content = message.get('Data', {}).get('PushContent', '') + ats_bot = ats_bot or ('在群聊中@了你' in push_content) + # 引用别人时@bot + msg_source = message.get('Data', {}).get('MsgSource', '') or '' + if len(msg_source) > 0: + msg_source_data = ET.fromstring(msg_source) + at_user_list = msg_source_data.findtext('atuserlist') or '' + ats_bot = ats_bot or (to_user_name in at_user_list) + # 引用bot + if message.get('Data', {}).get('MsgType', 0) == 49: + xml_data = ET.fromstring(content_no_prefix) + appmsg_data = xml_data.find('.//appmsg') + tousername = message['Wxid'] + if appmsg_data: # 接收方: 所属微信的wxid + quote_id = appmsg_data.find('.//refermsg').findtext( + './/chatusr' + ) # 引用消息的原发送者 + ats_bot = ats_bot or (quote_id == tousername) + except Exception as e: + print(f'_ats_bot got except: {e}') + finally: + return ats_bot + + # 提取一下content前面的sender_id, 和去掉前缀的内容 + def _extract_content_and_sender( + self, raw_content: str + ) -> Tuple[str, Optional[str]]: + try: + # 检查消息开头,如果有 wxid_sbitaz0mt65n22:\n 则删掉 + # add: 有些用户的wxid不是上述格式。换成user_name: + regex = re.compile(r'^[a-zA-Z0-9_\-]{5,20}:') + line_split = raw_content.split('\n') + if len(line_split) > 0 and regex.match(line_split[0]): + raw_content = '\n'.join(line_split[1:]) + sender_id = line_split[0].strip(':') + return raw_content, sender_id + except Exception as e: + print(f'_extract_content_and_sender got except: {e}') + finally: + return raw_content, None + + # 是否是群消息 + def _is_group_message(self, message: dict) -> bool: + from_user_name = message['Data']['FromUserName']['string'] + return from_user_name.endswith('@chatroom') class GewechatEventConverter(adapter.EventConverter): @@ -314,7 +568,6 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter): def __init__(self, config: dict, ap: app.Application): self.config = config self.ap = ap - self.quart_app = quart.Quart(__name__) self.message_converter = GewechatMessageConverter(config) @@ -324,6 +577,7 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter): async def gewechat_callback(): data = await quart.request.json # print(json.dumps(data, indent=4, ensure_ascii=False)) + self.ap.logger.debug(f'Gewechat callback event: {data}') if 'data' in data: data['Data'] = data['data'] @@ -346,36 +600,105 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter): return 'ok' - async def send_message( - self, target_type: str, target_id: str, message: platform_message.MessageChain + async def _handle_message( + self, message: platform_message.MessageChain, target_id: str ): - geweap_msg = await self.message_converter.yiri2target(message) - # 此处加上群消息at处理 - ats = [item['target'] for item in geweap_msg if item['type'] == 'at'] + """统一消息处理核心逻辑""" + content_list = await self.message_converter.yiri2target(message) + at_targets = [item['target'] for item in content_list if item['type'] == 'at'] - for msg in geweap_msg: - # at主动发送消息 - if msg['type'] == 'text': - if ats: - member_info = self.bot.get_chatroom_member_detail( - self.config['app_id'], target_id, ats[::-1] - )['data'] + # 处理@逻辑 + at_targets = at_targets or [] + member_info = [] + if at_targets: + member_info = self.bot.get_chatroom_member_detail( + self.config['app_id'], target_id, at_targets[::-1] + )['data'] - for member in member_info: - msg['content'] = f'@{member["nickName"]} {msg["content"]}' - self.bot.post_text( + # 处理消息组件 + for msg in content_list: + # 文本消息处理@ + if msg['type'] == 'text' and at_targets: + for member in member_info: + msg['content'] = f'@{member["nickName"]} {msg["content"]}' + + # 统一消息派发 + handler_map = { + 'text': lambda msg: self.bot.post_text( app_id=self.config['app_id'], to_wxid=target_id, content=msg['content'], - ats=','.join(ats), - ) - - elif msg['type'] == 'image': - self.bot.post_image( + ats=','.join(at_targets), + ), + 'image': lambda msg: self.bot.post_image( app_id=self.config['app_id'], to_wxid=target_id, img_url=msg['image'], - ) + ), + 'WeChatForwardMiniPrograms': lambda msg: self.bot.forward_mini_app( + app_id=self.config['app_id'], + to_wxid=target_id, + xml=msg['xml_data'], + cover_img_url=msg.get('image_url'), + ), + 'WeChatEmoji': lambda msg: self.bot.post_emoji( + app_id=self.config['app_id'], + to_wxid=target_id, + emoji_md5=msg['emoji_md5'], + emoji_size=msg['emoji_size'], + ), + 'WeChatLink': lambda msg: self.bot.post_link( + app_id=self.config['app_id'], + to_wxid=target_id, + title=msg['link_title'], + desc=msg['link_desc'], + link_url=msg['link_url'], + thumb_url=msg['link_thumb_url'], + ), + 'WeChatMiniPrograms': lambda msg: self.bot.post_mini_app( + app_id=self.config['app_id'], + to_wxid=target_id, + mini_app_id=msg['mini_app_id'], + display_name=msg['display_name'], + page_path=msg['page_path'], + cover_img_url=msg['cover_img_url'], + title=msg['title'], + user_name=msg['user_name'], + ), + 'WeChatForwardLink': lambda msg: self.bot.forward_url( + app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data'] + ), + 'WeChatForwardImage': lambda msg: self.bot.forward_image( + app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data'] + ), + 'WeChatForwardFile': lambda msg: self.bot.forward_file( + app_id=self.config['app_id'], to_wxid=target_id, xml=msg['xml_data'] + ), + 'voice': lambda msg: self.bot.post_voice( + app_id=self.config['app_id'], + to_wxid=target_id, + voice_url=msg['url'], + voice_duration=msg['length'], + ), + 'WeChatAppMsg': lambda msg: self.bot.post_app_msg( + app_id=self.config['app_id'], + to_wxid=target_id, + appmsg=msg['app_msg'], + ), + 'at': lambda msg: None, + } + + if handler := handler_map.get(msg['type']): + handler(msg) + else: + self.ap.logger.warning(f'未处理的消息类型: {msg["type"]}') + continue + + async def send_message( + self, target_type: str, target_id: str, message: platform_message.MessageChain + ): + """主动发送消息""" + return await self._handle_message(message, target_id) async def reply_message( self, @@ -383,32 +706,12 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter): 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), - ) + """回复消息""" + if message_source.source_platform_object: + target_id = message_source.source_platform_object['Data']['FromUserName'][ + 'string' + ] + return await self._handle_message(message, target_id) async def is_muted(self, group_id: int) -> bool: pass @@ -448,28 +751,31 @@ class GeWeChatAdapter(adapter.MessagePlatformAdapter): 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}') + def gewechat_login_process(): + 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.config['app_id'] = app_id - self.ap.logger.info(f'Gewechat 登录成功,app_id: {app_id}') + self.ap.logger.info(f'Gewechat 登录成功,app_id: {app_id}') - await self.ap.platform_mgr.write_back_config('gewechat', self, self.config) + self.ap.platform_mgr.write_back_config('gewechat', self, self.config) - # 获取 nickname - profile = self.bot.get_profile(self.config['app_id']) - self.bot_account_id = profile['data']['nickName'] + # 获取 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) + time.sleep(2) - threading.Thread(target=thread_set_callback).start() + try: + # gewechat-server容器重启, token会变,但是还会登录成功 + # 换新token也会收不到回调,要重新登陆下。 + self.bot.set_callback(self.config['token'], self.config['callback_url']) + except Exception as e: + raise Exception(f'设置 Gewechat 回调失败, token失效: {e}') + + threading.Thread(target=gewechat_login_process).start() async def shutdown_trigger_placeholder(): while True: diff --git a/pkg/platform/sources/lark.py b/pkg/platform/sources/lark.py index 1396ba3b..ae6e89ee 100644 --- a/pkg/platform/sources/lark.py +++ b/pkg/platform/sources/lark.py @@ -1,10 +1,10 @@ from __future__ import annotations import lark_oapi - +from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody +import traceback import typing import asyncio -import traceback import re import base64 import uuid @@ -57,12 +57,24 @@ class LarkMessageConverter(adapter.MessageConverter): message_chain: platform_message.MessageChain, api_client: lark_oapi.Client ) -> typing.Tuple[list]: message_elements = [] - pending_paragraph = [] - for msg in message_chain: if isinstance(msg, platform_message.Plain): - pending_paragraph.append({'tag': 'md', 'text': msg.text}) + # Ensure text is valid UTF-8 + try: + text = msg.text.encode('utf-8').decode('utf-8') + pending_paragraph.append({'tag': 'md', 'text': text}) + except UnicodeError: + # If text is not valid UTF-8, try to decode with other encodings + try: + text = msg.text.encode('latin1').decode('utf-8') + pending_paragraph.append({'tag': 'md', 'text': text}) + except UnicodeError: + # If still fails, replace invalid characters + text = msg.text.encode('utf-8', errors='replace').decode( + 'utf-8' + ) + pending_paragraph.append({'tag': 'md', 'text': text}) elif isinstance(msg, platform_message.At): pending_paragraph.append( {'tag': 'at', 'user_id': msg.target, 'style': []} @@ -73,47 +85,85 @@ class LarkMessageConverter(adapter.MessageConverter): image_bytes = None if msg.base64: - image_bytes = base64.b64decode(msg.base64) + try: + # Remove data URL prefix if present + if msg.base64.startswith('data:'): + msg.base64 = msg.base64.split(',', 1)[1] + image_bytes = base64.b64decode(msg.base64) + except Exception: + traceback.print_exc() + continue elif msg.url: - async with aiohttp.ClientSession() as session: - async with session.get(msg.url) as response: - image_bytes = await response.read() + try: + async with aiohttp.ClientSession() as session: + async with session.get(msg.url) as response: + if response.status == 200: + image_bytes = await response.read() + else: + traceback.print_exc() + continue + except Exception: + traceback.print_exc() + continue elif msg.path: - with open(msg.path, 'rb') as f: - image_bytes = f.read() + try: + with open(msg.path, 'rb') as f: + image_bytes = f.read() + except Exception: + traceback.print_exc() + continue - request: CreateImageRequest = ( - CreateImageRequest.builder() - .request_body( - CreateImageRequestBody.builder() - .image_type('message') - .image(image_bytes) - .build() - ) - .build() - ) + if image_bytes is None: + continue - response: CreateImageResponse = await api_client.im.v1.image.acreate( - request - ) + try: + # Create a temporary file to store the image bytes + import tempfile - if not response.success(): - raise Exception( - f'client.im.v1.image.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)}' - ) + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(image_bytes) + temp_file.flush() - image_key = response.data.image_key + # Create image request using the temporary file + request = ( + CreateImageRequest.builder() + .request_body( + CreateImageRequestBody.builder() + .image_type('message') + .image(open(temp_file.name, 'rb')) + .build() + ) + .build() + ) - message_elements.append(pending_paragraph) - message_elements.append( - [ - { - 'tag': 'img', - 'image_key': image_key, - } - ] - ) - pending_paragraph = [] + response = await api_client.im.v1.image.acreate(request) + + if not response.success(): + raise Exception( + f'client.im.v1.image.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)}' + ) + + image_key = response.data.image_key + + message_elements.append(pending_paragraph) + message_elements.append( + [ + { + 'tag': 'img', + 'image_key': image_key, + } + ] + ) + pending_paragraph = [] + except Exception: + traceback.print_exc() + continue + finally: + # Clean up the temporary file + import os + + if 'temp_file' in locals(): + os.unlink(temp_file.name) elif isinstance(msg, platform_message.Forward): for node in msg.node_list: message_elements.extend( @@ -326,6 +376,8 @@ class LarkAdapter(adapter.MessagePlatformAdapter): try: data = await quart.request.json + self.ap.logger.debug(f'Lark callback event: {data}') + if 'encrypt' in data: cipher = AESCipher(self.config['encrypt-key']) data = cipher.decrypt_string(data['encrypt']) @@ -337,7 +389,6 @@ class LarkAdapter(adapter.MessagePlatformAdapter): type = context.header.event_type if 'url_verification' == type: - print(data.get('challenge')) # todo 验证verification token return {'challenge': data.get('challenge')} context = EventContext(data) diff --git a/pkg/platform/sources/slack.py b/pkg/platform/sources/slack.py new file mode 100644 index 00000000..bc4e4d8e --- /dev/null +++ b/pkg/platform/sources/slack.py @@ -0,0 +1,204 @@ +from __future__ import annotations +import typing +import asyncio +import traceback + +import datetime + +from libs.slack_api.api import SlackClient +from pkg.platform.adapter import MessagePlatformAdapter +from pkg.platform.types import events as platform_events, message as platform_message +from libs.slack_api.slackevent import SlackEvent +from pkg.core import app +from .. import adapter +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 ...command.errors import ParamNotEnoughError +from ...utils import image + +class SlackMessageConverter(adapter.MessageConverter): + + @staticmethod + async def yiri2target(message_chain:platform_message.MessageChain): + content_list = [] + for msg in message_chain: + if type(msg) is platform_message.Plain: + content_list.append({ + "content":msg.text, + }) + + return content_list + + @staticmethod + async def target2yiri(message:str,message_id:str,pic_url:str,bot:SlackClient): + yiri_msg_list = [] + yiri_msg_list.append( + platform_message.Source(id=message_id,time=datetime.datetime.now()) + ) + if pic_url is not None: + base64_url = await image.get_slack_image_to_base64(pic_url=pic_url,bot_token=bot.bot_token) + yiri_msg_list.append( + platform_message.Image(base64=base64_url) + ) + + yiri_msg_list.append(platform_message.Plain(text=message)) + chain = platform_message.MessageChain(yiri_msg_list) + return chain + + +class SlackEventConverter(adapter.EventConverter): + + @staticmethod + async def yiri2target(event:platform_events.MessageEvent) -> SlackEvent: + return event.source_platform_object + + @staticmethod + async def target2yiri(event:SlackEvent,bot:SlackClient): + yiri_chain = await SlackMessageConverter.target2yiri( + message=event.text,message_id=event.message_id,pic_url=event.pic_url,bot=bot + ) + + if event.type == 'channel': + yiri_chain.insert(0, platform_message.At(target="SlackBot")) + + sender = platform_entities.GroupMember( + id = event.user_id, + member_name= str(event.sender_name), + permission= 'MEMBER', + group = platform_entities.Group( + id = event.channel_id, + name = 'MEMBER', + permission= platform_entities.Permission.Member + ), + special_title='', + join_timestamp=0, + last_speak_timestamp=0, + mute_time_remaining=0 + ) + time = int(datetime.datetime.utcnow().timestamp()) + return platform_events.GroupMessage( + sender = sender, + message_chain=yiri_chain, + time = time, + source_platform_object=event + ) + + if event.type == 'im': + return platform_events.FriendMessage( + sender=platform_entities.Friend( + id=event.user_id, + nickname = event.sender_name, + remark="" + ), + message_chain = yiri_chain, + time = float(datetime.datetime.now().timestamp()), + source_platform_object=event, + ) + + + + +class SlackAdapter(adapter.MessagePlatformAdapter): + bot: SlackClient + ap: app.Application + bot_account_id: str + message_converter: SlackMessageConverter = SlackMessageConverter() + event_converter: SlackEventConverter = SlackEventConverter() + config: dict + + def __init__(self,config:dict,ap:app.Application): + self.config = config + self.ap = ap + required_keys = [ + "bot_token", + "signing_secret", + ] + missing_keys = [key for key in required_keys if key not in config] + if missing_keys: + raise ParamNotEnoughError("Slack机器人缺少相关配置项,请查看文档或联系管理员") + + self.bot = SlackClient( + bot_token=self.config["bot_token"], + signing_secret=self.config["signing_secret"] + ) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + slack_event = await SlackEventConverter.yiri2target( + message_source + ) + + content_list = await SlackMessageConverter.yiri2target(message) + + for content in content_list: + if slack_event.type == 'channel': + await self.bot.send_message_to_channel( + content['content'],slack_event.channel_id + ) + if slack_event.type == 'im': + await self.bot.send_message_to_one( + content['content'],slack_event.user_id + ) + + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): + content_list = await SlackMessageConverter.yiri2target(message) + for content in content_list: + if target_type == 'person': + await self.bot.send_message_to_one(content['content'],target_id) + if target_type == 'group': + await self.bot.send_message_to_channel(content['content'],target_id) + + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, adapter.MessagePlatformAdapter], None + ], + ): + async def on_message(event:SlackEvent): + self.bot_account_id = 'SlackBot' + try: + return await callback( + await self.event_converter.target2yiri(event,self.bot),self + ) + except: + traceback.print_exc() + + if event_type == platform_events.FriendMessage: + self.bot.on_message("im")(on_message) + elif event_type == platform_events.GroupMessage: + self.bot.on_message("channel")(on_message) + + + async def run_async(self): + async def shutdown_trigger_placeholder(): + while True: + await asyncio.sleep(1) + + await self.bot.run_task( + host="0.0.0.0", + port=self.config["port"], + shutdown_trigger=shutdown_trigger_placeholder, + ) + + async def kill(self) -> bool: + return False + + async def unregister_listener( + self, + event_type: type, + callback: typing.Callable[[platform_events.Event, MessagePlatformAdapter], None], + ): + return super().unregister_listener(event_type, callback) + + + + + diff --git a/pkg/platform/sources/slack.yaml b/pkg/platform/sources/slack.yaml new file mode 100644 index 00000000..7b16960c --- /dev/null +++ b/pkg/platform/sources/slack.yaml @@ -0,0 +1,37 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: slack + label: + en_US: Slack API + zh_CN: Slack API + description: + en_US: Slack API + zh_CN: Slack API +spec: + config: + - name: bot_token + label: + en_US: Bot Token + zh_CN: 机器人令牌 + type: string + required: true + default: "" + - name: signing_secret + label: + en_US: signing_secret + zh_CN: 密钥 + type: string + required: true + default: "" + - name: port + label: + en_US: Port + zh_CN: 监听端口 + type: int + required: true + default: 2288 +execution: + python: + path: ./slack.py + attr: SlackAdapter \ No newline at end of file diff --git a/pkg/platform/sources/telegram.py b/pkg/platform/sources/telegram.py index 03036293..584d77f3 100644 --- a/pkg/platform/sources/telegram.py +++ b/pkg/platform/sources/telegram.py @@ -4,7 +4,7 @@ import telegram import telegram.ext from telegram import Update from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters - +import telegramify_markdown import typing import traceback import base64 @@ -74,7 +74,8 @@ class TelegramMessageConverter(adapter.MessageConverter): message_components.extend(parse_message_text(message_text)) if message.photo: - message_components.extend(parse_message_text(message.caption)) + if message.caption: + message_components.extend(parse_message_text(message.caption)) file = await message.photo[-1].get_file() @@ -117,7 +118,7 @@ class TelegramEventConverter(adapter.EventConverter): time=event.message.date.timestamp(), source_platform_object=event, ) - elif event.effective_chat.type == 'group': + elif event.effective_chat.type == 'group' or 'supergroup': return platform_events.GroupMessage( sender=platform_entities.GroupMember( id=event.effective_chat.id, @@ -196,17 +197,24 @@ class TelegramAdapter(adapter.MessagePlatformAdapter): for component in components: 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': component['text'], + 'text': content, } + if self.config['markdown_card'] is True: + args['parse_mode'] = 'MarkdownV2' + if quote_origin: + args['reply_to_message_id'] = ( + message_source.source_platform_object.message.id + ) - if quote_origin: - args['reply_to_message_id'] = ( - message_source.source_platform_object.message.id - ) - - await self.bot.send_message(**args) + await self.bot.send_message(**args) async def is_muted(self, group_id: int) -> bool: return False diff --git a/pkg/platform/sources/wecomcs.py b/pkg/platform/sources/wecomcs.py new file mode 100644 index 00000000..532d7470 --- /dev/null +++ b/pkg/platform/sources/wecomcs.py @@ -0,0 +1,223 @@ +from __future__ import annotations +import typing +import asyncio +import traceback + +import datetime + +from libs.wecom_customer_service_api.api import WecomCSClient +from pkg.platform.adapter import MessagePlatformAdapter +from pkg.platform.types import events as platform_events, message as platform_message +from libs.wecom_customer_service_api.wecomcsevent import WecomCSEvent +from pkg.core import app +from .. import adapter +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 ...command.errors import ParamNotEnoughError +from ...utils import image + +class WecomMessageConverter(adapter.MessageConverter): + + @staticmethod + async def yiri2target( + message_chain: platform_message.MessageChain, bot: WecomCSClient + ): + content_list = [] + + for msg in message_chain: + if type(msg) is platform_message.Plain: + content_list.append({ + "type": "text", + "content": msg.text, + }) + elif type(msg) is platform_message.Image: + content_list.append({ + "type": "image", + "media_id": await bot.get_media_id(msg), + }) + elif type(msg) is platform_message.Forward: + for node in msg.node_list: + content_list.extend((await WecomMessageConverter.yiri2target(node.message_chain, bot))) + else: + content_list.append({ + "type": "text", + "content": str(msg), + }) + + return content_list + + @staticmethod + async def target2yiri(message: str, message_id: int = -1): + yiri_msg_list = [] + yiri_msg_list.append( + platform_message.Source(id=message_id, time=datetime.datetime.now()) + ) + + yiri_msg_list.append(platform_message.Plain(text=message)) + chain = platform_message.MessageChain(yiri_msg_list) + + return chain + + @staticmethod + async def target2yiri_image(picurl: str, message_id: int = -1): + yiri_msg_list = [] + yiri_msg_list.append( + platform_message.Source(id=message_id, time=datetime.datetime.now()) + ) + yiri_msg_list.append(platform_message.Image(base64=picurl)) + chain = platform_message.MessageChain(yiri_msg_list) + + return chain + + +class WecomEventConverter: + + @staticmethod + async def yiri2target( + event: platform_events.Event, bot_account_id: int, bot: WecomCSClient + ) -> WecomCSEvent: + # only for extracting user information + + if type(event) is platform_events.GroupMessage: + pass + + if type(event) is platform_events.FriendMessage: + return event.source_platform_object + + @staticmethod + async def target2yiri(event: WecomCSEvent): + """ + 将 WecomEvent 转换为平台的 FriendMessage 对象。 + + Args: + event (WecomEvent): 企业微信客服事件。 + + Returns: + platform_events.FriendMessage: 转换后的 FriendMessage 对象。 + """ + # 转换消息链 + if event.type == "text": + yiri_chain = await WecomMessageConverter.target2yiri( + event.message, event.message_id + ) + friend = platform_entities.Friend( + id=f"u{event.user_id}", + nickname=str(event.user_id), + remark="", + ) + + return platform_events.FriendMessage( + sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event + ) + elif event.type == "image": + friend = platform_entities.Friend( + id=f"u{event.user_id}", + nickname=str(event.user_id), + remark="", + ) + + yiri_chain = await WecomMessageConverter.target2yiri_image( + picurl=event.picurl, message_id=event.message_id + ) + + return platform_events.FriendMessage( + sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event + ) + + +class WecomCSAdapter(adapter.MessagePlatformAdapter): + + bot: WecomCSClient + ap: app.Application + bot_account_id: str + message_converter: WecomMessageConverter = WecomMessageConverter() + event_converter: WecomEventConverter = WecomEventConverter() + config: dict + + def __init__(self, config: dict, ap: app.Application): + self.config = config + + self.ap = ap + + required_keys = [ + "corpid", + "secret", + "token", + "EncodingAESKey", + ] + missing_keys = [key for key in required_keys if key not in config] + if missing_keys: + raise ParamNotEnoughError("企业微信客服缺少相关配置项,请查看文档或联系管理员") + + self.bot = WecomCSClient( + corpid=config["corpid"], + secret=config["secret"], + token=config["token"], + EncodingAESKey=config["EncodingAESKey"], + ) + + async def reply_message( + self, + message_source: platform_events.MessageEvent, + message: platform_message.MessageChain, + quote_origin: bool = False, + ): + + Wecom_event = await WecomEventConverter.yiri2target( + message_source, self.bot_account_id, self.bot + ) + content_list = await WecomMessageConverter.yiri2target(message, self.bot) + + for content in content_list: + if content["type"] == "text": + await self.bot.send_text_msg(open_kfid=Wecom_event.receiver_id,external_userid=Wecom_event.user_id,msgid=Wecom_event.message_id,content=content["content"]) + + async def send_message( + self, target_type: str, target_id: str, message: platform_message.MessageChain + ): + pass + + def register_listener( + self, + event_type: typing.Type[platform_events.Event], + callback: typing.Callable[ + [platform_events.Event, adapter.MessagePlatformAdapter], None + ], + ): + async def on_message(event: WecomCSEvent): + self.bot_account_id = event.receiver_id + try: + return await callback( + await self.event_converter.target2yiri(event), self + ) + except: + traceback.print_exc() + + if event_type == platform_events.FriendMessage: + self.bot.on_message("text")(on_message) + self.bot.on_message("image")(on_message) + elif event_type == platform_events.GroupMessage: + pass + + async def run_async(self): + async def shutdown_trigger_placeholder(): + while True: + await asyncio.sleep(1) + + await self.bot.run_task( + host="0.0.0.0", + port=self.config["port"], + shutdown_trigger=shutdown_trigger_placeholder, + ) + + async def kill(self) -> bool: + return False + + async def unregister_listener( + self, + event_type: type, + callback: typing.Callable[[platform_events.Event, MessagePlatformAdapter], None], + ): + return super().unregister_listener(event_type, callback) \ No newline at end of file diff --git a/pkg/platform/sources/wecomcs.yaml b/pkg/platform/sources/wecomcs.yaml new file mode 100644 index 00000000..fb93d0b6 --- /dev/null +++ b/pkg/platform/sources/wecomcs.yaml @@ -0,0 +1,51 @@ +apiVersion: v1 +kind: MessagePlatformAdapter +metadata: + name: wecomcs + label: + en_US: WeComCustomerService + zh_CN: 企业微信客服 + description: + en_US: WeComCSAdapter + zh_CN: 企业微信客服适配器 +spec: + config: + - name: port + label: + en_US: Port + zh_CN: 监听端口 + type: int + required: true + default: 2289 + - name: corpid + label: + en_US: Corpid + zh_CN: 企业ID + type: string + required: true + default: "" + - name: secret + label: + en_US: Secret + zh_CN: 密钥 + type: string + required: true + default: "" + - name: token + label: + en_US: Token + zh_CN: 令牌 + type: string + required: true + default: "" + - name: EncodingAESKey + label: + en_US: EncodingAESKey + zh_CN: 消息加解密密钥 + type: string + required: true + default: "" +execution: + python: + path: ./wecomcs.py + attr: WecomCSAdapter \ No newline at end of file diff --git a/pkg/platform/types/message.py b/pkg/platform/types/message.py index 529ce4c1..3693b76f 100644 --- a/pkg/platform/types/message.py +++ b/pkg/platform/types/message.py @@ -1,15 +1,14 @@ import itertools import logging +import typing from datetime import datetime from pathlib import Path -import typing import pydantic.v1 as pydantic from . import entities as platform_entities from .base import PlatformBaseModel, PlatformIndexedMetaclass, PlatformIndexedModel - logger = logging.getLogger(__name__) @@ -685,6 +684,9 @@ class Unknown(MessageComponent): text: str """文本。""" + def __str__(self): + return f'Unknown Message: {self.text}' + class Voice(MessageComponent): """语音。""" @@ -863,3 +865,116 @@ class File(MessageComponent): def __str__(self): return f'[文件]{self.name}' + + +# ================ 个人微信专用组件 ================ + + +class WeChatMiniPrograms(MessageComponent): + """小程序。个人微信专用组件。""" + + type: str = 'WeChatMiniPrograms' + """小程序id""" + mini_app_id: str + """小程序归属用户id""" + user_name: str + """小程序名称""" + display_name: typing.Optional[str] = '' + """打开地址""" + page_path: typing.Optional[str] = '' + """小程序标题""" + title: typing.Optional[str] = '' + """首页图片""" + image_url: typing.Optional[str] = '' + + +class WeChatForwardMiniPrograms(MessageComponent): + """转发小程序。个人微信专用组件。""" + + type: str = 'WeChatForwardMiniPrograms' + """xml数据""" + xml_data: str + """首页图片""" + image_url: typing.Optional[str] = None + + def __str__(self): + return self.xml_data + + +class WeChatEmoji(MessageComponent): + """emoji表情。个人微信专用组件。""" + + type: str = 'WeChatEmoji' + """emojimd5""" + emoji_md5: str + """emoji大小""" + emoji_size: int + + +class WeChatLink(MessageComponent): + """发送链接。个人微信专用组件。""" + + type: str = 'WeChatLink' + """标题""" + link_title: str = '' + """链接描述""" + link_desc: str = '' + """链接地址""" + link_url: str = '' + """链接略缩图""" + link_thumb_url: str = '' + + +class WeChatForwardLink(MessageComponent): + """转发链接。个人微信专用组件。""" + + type: str = 'WeChatForwardLink' + """xml数据""" + xml_data: str + + def __str__(self): + return self.xml_data + + +class WeChatForwardImage(MessageComponent): + """转发图片。个人微信专用组件。""" + + type: str = 'WeChatForwardImage' + """xml数据""" + xml_data: str + + def __str__(self): + return self.xml_data + + +class WeChatForwardFile(MessageComponent): + """转发文件。个人微信专用组件。""" + + type: str = 'WeChatForwardFile' + """xml数据""" + xml_data: str + + def __str__(self): + return self.xml_data + + +class WeChatAppMsg(MessageComponent): + """通用appmsg发送。个人微信专用组件。""" + + type: str = 'WeChatAppMsg' + """xml数据""" + app_msg: str + + def __str__(self): + return self.app_msg + + +class WeChatForwardQuote(MessageComponent): + """转发引用消息。个人微信专用组件。""" + + type: str = 'WeChatForwardQuote' + """xml数据""" + app_msg: str + + def __str__(self): + return self.app_msg diff --git a/pkg/provider/modelmgr/requesters/anthropicmsgs.py b/pkg/provider/modelmgr/requesters/anthropicmsgs.py index ca145e9e..2472da04 100644 --- a/pkg/provider/modelmgr/requesters/anthropicmsgs.py +++ b/pkg/provider/modelmgr/requesters/anthropicmsgs.py @@ -2,7 +2,8 @@ from __future__ import annotations import typing import json - +import platform +import socket import anthropic import httpx @@ -25,6 +26,12 @@ class AnthropicMessages(requester.LLMAPIRequester): } async def initialize(self): + # 兼容 Windows 缺失 TCP_KEEPINTVL 和 TCP_KEEPCNT 的问题 + if platform.system() == 'Windows': + if not hasattr(socket, 'TCP_KEEPINTVL'): + socket.TCP_KEEPINTVL = 0 + if not hasattr(socket, 'TCP_KEEPCNT'): + socket.TCP_KEEPCNT = 0 httpx_client = anthropic._base_client.AsyncHttpxClientWrapper( base_url=self.requester_cfg['base_url'], # cast to a valid type because mypy doesn't understand our type narrowing @@ -61,9 +68,11 @@ class AnthropicMessages(requester.LLMAPIRequester): if m.role == 'system': system_role_message = m - messages.pop(i) break + if system_role_message: + messages.pop(i) + if isinstance(system_role_message, llm_entities.Message) and isinstance( system_role_message.content, str ): diff --git a/pkg/provider/modelmgr/requesters/bailianchatcmpl.py b/pkg/provider/modelmgr/requesters/bailianchatcmpl.py index 287eb5b9..8689008d 100644 --- a/pkg/provider/modelmgr/requesters/bailianchatcmpl.py +++ b/pkg/provider/modelmgr/requesters/bailianchatcmpl.py @@ -3,10 +3,10 @@ from __future__ import annotations import typing import openai -from . import chatcmpl +from . import modelscopechatcmpl -class BailianChatCompletions(chatcmpl.OpenAIChatCompletions): +class BailianChatCompletions(modelscopechatcmpl.ModelScopeChatCompletions): """阿里云百炼大模型平台 ChatCompletion API 请求器""" client: openai.AsyncClient diff --git a/pkg/provider/modelmgr/requesters/chatcmpl.py b/pkg/provider/modelmgr/requesters/chatcmpl.py index e341d0fb..757a9825 100644 --- a/pkg/provider/modelmgr/requesters/chatcmpl.py +++ b/pkg/provider/modelmgr/requesters/chatcmpl.py @@ -26,7 +26,7 @@ class OpenAIChatCompletions(requester.LLMAPIRequester): async def initialize(self): self.client = openai.AsyncClient( api_key='', - base_url=self.requester_cfg['base_url'], + base_url=self.requester_cfg['base-url'].replace(' ', ''), timeout=self.requester_cfg['timeout'], http_client=httpx.AsyncClient( trust_env=True, timeout=self.requester_cfg['timeout'] @@ -36,8 +36,9 @@ class OpenAIChatCompletions(requester.LLMAPIRequester): async def _req( self, args: dict, + extra_body: dict = {}, ) -> chat_completion.ChatCompletion: - return await self.client.chat.completions.create(**args) + return await self.client.chat.completions.create(**args, extra_body=extra_body) async def _make_msg( self, @@ -49,6 +50,21 @@ class OpenAIChatCompletions(requester.LLMAPIRequester): if 'role' not in chatcmpl_message or chatcmpl_message['role'] is None: chatcmpl_message['role'] = 'assistant' + reasoning_content = ( + chatcmpl_message['reasoning_content'] + if 'reasoning_content' in chatcmpl_message + else None + ) + + # deepseek的reasoner模型 + if reasoning_content is not None: + chatcmpl_message['content'] = ( + '\n' + + reasoning_content + + '\n\n' + + chatcmpl_message['content'] + ) + message = llm_entities.Message(**chatcmpl_message) return message @@ -87,7 +103,7 @@ class OpenAIChatCompletions(requester.LLMAPIRequester): args['messages'] = messages # 发送请求 - resp = await self._req(args) + resp = await self._req(args, extra_body=self.requester_cfg['args']) # 处理请求结果 message = await self._make_msg(resp) diff --git a/pkg/provider/modelmgr/requesters/deepseekchatcmpl.py b/pkg/provider/modelmgr/requesters/deepseekchatcmpl.py index 49457ac0..30848df9 100644 --- a/pkg/provider/modelmgr/requesters/deepseekchatcmpl.py +++ b/pkg/provider/modelmgr/requesters/deepseekchatcmpl.py @@ -47,7 +47,7 @@ class DeepseekChatCompletions(chatcmpl.OpenAIChatCompletions): args['messages'] = messages # 发送请求 - resp = await self._req(args) + resp = await self._req(args, extra_body=self.requester_cfg['args']) if resp is None: raise errors.RequesterError('接口返回为空,请确定模型提供商服务是否正常') diff --git a/pkg/provider/modelmgr/requesters/giteeaichatcmpl.py b/pkg/provider/modelmgr/requesters/giteeaichatcmpl.py index b85cc54d..050a04bc 100644 --- a/pkg/provider/modelmgr/requesters/giteeaichatcmpl.py +++ b/pkg/provider/modelmgr/requesters/giteeaichatcmpl.py @@ -44,7 +44,7 @@ class GiteeAIChatCompletions(chatcmpl.OpenAIChatCompletions): args['messages'] = req_messages - resp = await self._req(args) + resp = await self._req(args, extra_body=self.requester_cfg['args']) message = await self._make_msg(resp) diff --git a/pkg/provider/modelmgr/requesters/modelscopechatcmpl.py b/pkg/provider/modelmgr/requesters/modelscopechatcmpl.py new file mode 100644 index 00000000..8f51241e --- /dev/null +++ b/pkg/provider/modelmgr/requesters/modelscopechatcmpl.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import asyncio +import typing +import json +import base64 +from typing import AsyncGenerator + +import openai +import openai.types.chat.chat_completion as chat_completion +import openai.types.chat.chat_completion_message_tool_call as chat_completion_message_tool_call +import httpx +import aiohttp +import async_lru + +from .. import entities, errors, requester +from ....core import entities as core_entities, app +from ... import entities as llm_entities +from ...tools import entities as tools_entities +from ....utils import image + + +class ModelScopeChatCompletions(requester.LLMAPIRequester): + """ModelScope ChatCompletion API 请求器""" + + client: openai.AsyncClient + + requester_cfg: dict + + def __init__(self, ap: app.Application): + self.ap = ap + + self.requester_cfg = self.ap.provider_cfg.data['requester']['modelscope-chat-completions'] + + async def initialize(self): + + self.client = openai.AsyncClient( + api_key="", + base_url=self.requester_cfg['base-url'], + timeout=self.requester_cfg['timeout'], + http_client=httpx.AsyncClient( + trust_env=True, + timeout=self.requester_cfg['timeout'] + ) + ) + + async def _req( + self, + args: dict, + ) -> chat_completion.ChatCompletion: + args["stream"] = True + + chunk = None + + pending_content = "" + + tool_calls = [] + + resp_gen: openai.AsyncStream = await self.client.chat.completions.create(**args) + + async for chunk in resp_gen: + # print(chunk) + if not chunk or not chunk.id or not chunk.choices or not chunk.choices[0] or not chunk.choices[0].delta: + continue + + if chunk.choices[0].delta.content is not None: + pending_content += chunk.choices[0].delta.content + + if chunk.choices[0].delta.tool_calls is not None: + for tool_call in chunk.choices[0].delta.tool_calls: + for tc in tool_calls: + if tc.index == tool_call.index: + tc.function.arguments += tool_call.function.arguments + break + else: + tool_calls.append(tool_call) + + if chunk.choices[0].finish_reason is not None: + break + + real_tool_calls = [] + + for tc in tool_calls: + function = chat_completion_message_tool_call.Function( + name=tc.function.name, + arguments=tc.function.arguments + ) + real_tool_calls.append(chat_completion_message_tool_call.ChatCompletionMessageToolCall( + id=tc.id, + function=function, + type="function" + )) + + return chat_completion.ChatCompletion( + id=chunk.id, + object="chat.completion", + created=chunk.created, + choices=[ + chat_completion.Choice( + index=0, + message=chat_completion.ChatCompletionMessage( + role="assistant", + content=pending_content, + tool_calls=real_tool_calls if len(real_tool_calls) > 0 else None + ), + finish_reason=chunk.choices[0].finish_reason if hasattr(chunk.choices[0], 'finish_reason') and chunk.choices[0].finish_reason is not None else 'stop', + logprobs=chunk.choices[0].logprobs, + ) + ], + model=chunk.model, + service_tier=chunk.service_tier if hasattr(chunk, 'service_tier') else None, + system_fingerprint=chunk.system_fingerprint if hasattr(chunk, 'system_fingerprint') else None, + usage=chunk.usage if hasattr(chunk, 'usage') else None + ) if chunk else None + return await self.client.chat.completions.create(**args) + + async def _make_msg( + self, + chat_completion: chat_completion.ChatCompletion, + ) -> llm_entities.Message: + chatcmpl_message = chat_completion.choices[0].message.dict() + + # 确保 role 字段存在且不为 None + if 'role' not in chatcmpl_message or chatcmpl_message['role'] is None: + chatcmpl_message['role'] = 'assistant' + + message = llm_entities.Message(**chatcmpl_message) + + return message + + async def _closure( + self, + query: core_entities.Query, + req_messages: list[dict], + use_model: entities.LLMModelInfo, + use_funcs: list[tools_entities.LLMFunction] = None, + ) -> llm_entities.Message: + self.client.api_key = use_model.token_mgr.get_token() + + args = self.requester_cfg['args'].copy() + args["model"] = use_model.name if use_model.model_name is None else use_model.model_name + + if use_funcs: + tools = await self.ap.tool_mgr.generate_tools_for_openai(use_funcs) + + if tools: + args["tools"] = tools + + # 设置此次请求中的messages + messages = req_messages.copy() + + # 检查vision + for msg in messages: + if 'content' in msg and isinstance(msg["content"], list): + for me in msg["content"]: + if me["type"] == "image_base64": + me["image_url"] = { + "url": me["image_base64"] + } + me["type"] = "image_url" + del me["image_base64"] + + args["messages"] = messages + + # 发送请求 + resp = await self._req(args) + + # 处理请求结果 + message = await self._make_msg(resp) + + return message + + async def call( + self, + query: core_entities.Query, + model: entities.LLMModelInfo, + messages: typing.List[llm_entities.Message], + funcs: typing.List[tools_entities.LLMFunction] = None, + ) -> llm_entities.Message: + req_messages = [] # req_messages 仅用于类内,外部同步由 query.messages 进行 + for m in messages: + msg_dict = m.dict(exclude_none=True) + content = msg_dict.get("content") + if isinstance(content, list): + # 检查 content 列表中是否每个部分都是文本 + if all(isinstance(part, dict) and part.get("type") == "text" for part in content): + # 将所有文本部分合并为一个字符串 + msg_dict["content"] = "\n".join(part["text"] for part in content) + req_messages.append(msg_dict) + + try: + return await self._closure(query=query, req_messages=req_messages, use_model=model, use_funcs=funcs) + except asyncio.TimeoutError: + raise errors.RequesterError('请求超时') + except openai.BadRequestError as e: + if 'context_length_exceeded' in e.message: + raise errors.RequesterError(f'上文过长,请重置会话: {e.message}') + else: + raise errors.RequesterError(f'请求参数错误: {e.message}') + except openai.AuthenticationError as e: + raise errors.RequesterError(f'无效的 api-key: {e.message}') + except openai.NotFoundError as e: + raise errors.RequesterError(f'请求路径错误: {e.message}') + except openai.RateLimitError as e: + raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}') + except openai.APIError as e: + raise errors.RequesterError(f'请求错误: {e.message}') \ No newline at end of file diff --git a/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml b/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml new file mode 100644 index 00000000..eb8c70ec --- /dev/null +++ b/pkg/provider/modelmgr/requesters/modelscopechatcmpl.yaml @@ -0,0 +1,34 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: modelscope-chat-completions + label: + en_US: ModelScope + zh_CN: 魔搭社区 +spec: + config: + - name: base-url + label: + en_US: Base URL + zh_CN: 基础 URL + type: string + required: true + default: "https://api-inference.modelscope.cn/v1" + - name: args + label: + en_US: Args + zh_CN: 附加参数 + type: object + required: true + default: {} + - name: timeout + label: + en_US: Timeout + zh_CN: 超时时间 + type: int + required: true + default: 120 +execution: + python: + path: ./modelscopechatcmpl.py + attr: ModelScopeChatCompletions diff --git a/pkg/provider/modelmgr/requesters/moonshotchatcmpl.py b/pkg/provider/modelmgr/requesters/moonshotchatcmpl.py index ac565cdb..cb843fed 100644 --- a/pkg/provider/modelmgr/requesters/moonshotchatcmpl.py +++ b/pkg/provider/modelmgr/requesters/moonshotchatcmpl.py @@ -45,13 +45,13 @@ class MoonshotChatCompletions(chatcmpl.OpenAIChatCompletions): if 'content' in m and isinstance(m['content'], list): m['content'] = ' '.join([c['text'] for c in m['content']]) - # 删除空的 - messages = [m for m in messages if m['content'].strip() != ''] + # 删除空的,不知道干嘛的,直接删了。 + # messages = [m for m in messages if m["content"].strip() != "" and ('tool_calls' not in m or not m['tool_calls'])] args['messages'] = messages # 发送请求 - resp = await self._req(args) + resp = await self._req(args, extra_body=self.requester_cfg['args']) # 处理请求结果 message = await self._make_msg(resp) diff --git a/pkg/provider/modelmgr/requesters/ppiochatcmpl.py b/pkg/provider/modelmgr/requesters/ppiochatcmpl.py new file mode 100644 index 00000000..d0149a80 --- /dev/null +++ b/pkg/provider/modelmgr/requesters/ppiochatcmpl.py @@ -0,0 +1,20 @@ + +from __future__ import annotations + +import openai + +from . import chatcmpl, modelscopechatcmpl +from .. import requester +from ....core import app + +class PPIOChatCompletions(chatcmpl.OpenAIChatCompletions): + """欧派云 ChatCompletion API 请求器""" + + client: openai.AsyncClient + + requester_cfg: dict + + def __init__(self, ap: app.Application): + self.ap = ap + + self.requester_cfg = self.ap.provider_cfg.data['requester']['ppio-chat-completions'] \ No newline at end of file diff --git a/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml b/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml new file mode 100644 index 00000000..555f6416 --- /dev/null +++ b/pkg/provider/modelmgr/requesters/ppiochatcmpl.yaml @@ -0,0 +1,34 @@ +apiVersion: v1 +kind: LLMAPIRequester +metadata: + name: ppio-chat-completions + label: + en_US: ppio + zh_CN: 派欧云 +spec: + config: + - name: base-url + label: + en_US: Base URL + zh_CN: 基础 URL + type: string + required: true + default: "https://api.ppinfra.com/v3/openai" + - name: args + label: + en_US: Args + zh_CN: 附加参数 + type: object + required: true + default: {} + - name: timeout + label: + en_US: Timeout + zh_CN: 超时时间 + type: int + required: true + default: 120 +execution: + python: + path: ./ppiochatcmpl.py + attr: PPIOChatCompletions \ No newline at end of file diff --git a/pkg/provider/runners/difysvapi.py b/pkg/provider/runners/difysvapi.py index 1d1576a6..0f14533a 100644 --- a/pkg/provider/runners/difysvapi.py +++ b/pkg/provider/runners/difysvapi.py @@ -131,6 +131,8 @@ class DifyServiceAPIRunner(runner.RequestRunner): inputs.update(query.variables) + chunk = None # 初始化chunk变量,防止在没有响应时引用错误 + async for chunk in self.dify_client.chat_messages( inputs=inputs, query=plain_text, @@ -163,6 +165,11 @@ class DifyServiceAPIRunner(runner.RequestRunner): ) basic_mode_pending_chunk = '' + if chunk is None: + raise errors.DifyAPIError( + 'Dify API 没有返回任何响应,请检查网络连接和API配置' + ) + query.session.using_conversation.uuid = chunk['conversation_id'] async def _agent_chat_messages( @@ -182,12 +189,16 @@ class DifyServiceAPIRunner(runner.RequestRunner): for image_id in image_ids ] - ignored_events = ['agent_message'] + ignored_events = [] inputs = {} inputs.update(query.variables) + pending_agent_message = '' + + chunk = None # 初始化chunk变量,防止在没有响应时引用错误 + async for chunk in self.dify_client.chat_messages( inputs=inputs, query=plain_text, @@ -201,47 +212,63 @@ class DifyServiceAPIRunner(runner.RequestRunner): if chunk['event'] in ignored_events: continue - if chunk['event'] == 'agent_thought': - if ( - chunk['tool'] != '' and chunk['observation'] != '' - ): # 工具调用结果,跳过 - continue - if chunk['thought'].strip() != '': # 文字回复内容 - msg = llm_entities.Message( - role='assistant', - content=chunk['thought'], + if chunk['event'] == 'agent_message': + pending_agent_message += chunk['answer'] + else: + if pending_agent_message.strip() != '': + pending_agent_message = pending_agent_message.replace( + 'Action:', '' ) - yield msg - - if chunk['tool']: - msg = llm_entities.Message( - role='assistant', - tool_calls=[ - llm_entities.ToolCall( - id=chunk['id'], - type='function', - function=llm_entities.FunctionCall( - name=chunk['tool'], - arguments=json.dumps({}), - ), - ) - ], - ) - yield msg - if chunk['event'] == 'message_file': - if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant': - base_url = self.dify_client.base_url - - if base_url.endswith('/v1'): - base_url = base_url[:-3] - - image_url = base_url + chunk['url'] - yield llm_entities.Message( role='assistant', - content=[llm_entities.ContentElement.from_image_url(image_url)], + content=self._try_convert_thinking(pending_agent_message), ) + pending_agent_message = '' + + if chunk['event'] == 'agent_thought': + if ( + chunk['tool'] != '' and chunk['observation'] != '' + ): # 工具调用结果,跳过 + continue + + if chunk['tool']: + msg = llm_entities.Message( + role='assistant', + tool_calls=[ + llm_entities.ToolCall( + id=chunk['id'], + type='function', + function=llm_entities.FunctionCall( + name=chunk['tool'], + arguments=json.dumps({}), + ), + ) + ], + ) + yield msg + if chunk['event'] == 'message_file': + if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant': + base_url = self.dify_client.base_url + + if base_url.endswith('/v1'): + base_url = base_url[:-3] + + image_url = base_url + chunk['url'] + + yield llm_entities.Message( + role='assistant', + content=[ + llm_entities.ContentElement.from_image_url(image_url) + ], + ) + if chunk['event'] == 'error': + raise errors.DifyAPIError('dify 服务错误: ' + chunk['message']) + + if chunk is None: + raise errors.DifyAPIError( + 'Dify API 没有返回任何响应,请检查网络连接和API配置' + ) query.session.using_conversation.uuid = chunk['conversation_id'] diff --git a/pkg/utils/image.py b/pkg/utils/image.py index 9af766fb..b8100273 100644 --- a/pkg/utils/image.py +++ b/pkg/utils/image.py @@ -203,3 +203,14 @@ async def extract_b64_and_format(image_base64_data: str) -> typing.Tuple[str, st base64_str = image_base64_data.split(',')[-1] image_format = image_base64_data.split(':')[-1].split(';')[0].split('/')[-1] return base64_str, image_format + + +async def get_slack_image_to_base64(pic_url: str, bot_token: str): + headers = {'Authorization': f'Bearer {bot_token}'} + try: + async with aiohttp.ClientSession() as session: + async with session.get(pic_url, headers=headers) as resp: + image_data = await resp.read() + return base64.b64encode(image_data).decode('utf-8') + except Exception as e: + raise (e) diff --git a/requirements.txt b/requirements.txt index b34c18a3..dafa6985 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,8 +35,11 @@ python-telegram-bot certifi mcp sqlmodel +slack_sdk +telegramify-markdown # indirect taskgroup==0.0.0a4 ruff -pre-commit \ No newline at end of file +pre-commit +python-socks \ No newline at end of file diff --git a/templates/legacy/platform.json b/templates/legacy/platform.json index fe39947c..3fa4e3bf 100644 --- a/templates/legacy/platform.json +++ b/templates/legacy/platform.json @@ -71,29 +71,47 @@ "token": "" }, { - "adapter":"officialaccount", + "adapter": "officialaccount", "enable": false, "token": "", - "EncodingAESKey":"", - "AppID":"", - "AppSecret":"", - "Mode":"drop", - "LoadingMessage":"AI正在思考中,请发送任意内容获取回复。", + "EncodingAESKey": "", + "AppID": "", + "AppSecret": "", + "Mode": "drop", + "LoadingMessage": "AI正在思考中,请发送任意内容获取回复。", "host": "0.0.0.0", "port": 2287 }, { - "adapter":"dingtalk", + "adapter": "dingtalk", "enable": false, - "client_id":"", - "client_secret":"", - "robot_code":"", - "robot_name":"" + "client_id": "", + "client_secret": "", + "robot_code": "", + "robot_name": "", + "markdown_card": false }, { - "adapter":"telegram", + "adapter": "telegram", "enable": false, - "token":"" + "token": "", + "markdown_card": false + }, + { + "adapter": "slack", + "enable": false, + "bot_token": "", + "signing_secret": "", + "port": 2288 + }, + { + "adapter": "wecomcs", + "enable": false, + "port": 2289, + "corpid": "", + "secret": "", + "token": "", + "EncodingAESKey": "" } ], "track-function-calls": true, diff --git a/templates/legacy/provider.json b/templates/legacy/provider.json index e34f6d32..75b107f5 100644 --- a/templates/legacy/provider.json +++ b/templates/legacy/provider.json @@ -31,6 +31,12 @@ ], "volcark": [ "xxxxxxxx" + ], + "modelscope": [ + "xxxxxxxx" + ], + "ppio": [ + "xxxxxxxx" ] }, "requester": { @@ -95,12 +101,22 @@ "args": {}, "base-url": "https://ark.cn-beijing.volces.com/api/v3", "timeout": 120 + }, + "modelscope-chat-completions": { + "base-url": "https://api-inference.modelscope.cn/v1", + "args": {}, + "timeout": 120 + }, + "ppio-chat-completions": { + "base-url": "https://api.ppinfra.com/v3/openai", + "args": {}, + "timeout": 120 } }, "model": "gpt-4o", "prompt-mode": "normal", "prompt": { - "default": "" + "default": "You are a helpful assistant." }, "runner": "local-agent", "dify-service-api": {