Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ace4199a1 | |||
| e6bd7524c1 | |||
| 699c86e8c1 | |||
| f40fa0ecea | |||
| 626f94686b | |||
| 752d13b1b1 | |||
| 54c0dc1b2b | |||
| c5bc709898 | |||
| 9cc4e97a53 | |||
| dca1c0b0f3 | |||
| 3c8ec2f42e | |||
| 7e193f7f52 | |||
| 7069b02929 | |||
| 66995db927 | |||
| c36054ca1b | |||
| 3e07fbf3dc |
@@ -19,7 +19,6 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
|
||||

|
||||

|
||||
|
||||
|
||||
<a href="https://github.com/Soulter/AstrBot/blob/master/README_en.md">English</a> |
|
||||
<a href="https://github.com/Soulter/AstrBot/blob/master/README_ja.md">日本語</a> |
|
||||
<a href="https://astrbot.app/">查看文档</a> |
|
||||
@@ -28,11 +27,14 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
|
||||
|
||||
AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用的插件系统和完善的大语言模型(LLM)接入功能的聊天机器人及开发框架。
|
||||
|
||||
[](https://gitcode.com/Soulter/AstrBot)
|
||||
|
||||
<!-- [](https://codecov.io/gh/Soulter/AstrBot)
|
||||
-->
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> 个人微信接入所依赖的开源项目 Gewechat 近期已停止维护,我们正在评估其他方案(如 xxxbot 等)并将在数日内接入(很快!)。目前推荐微信用户暂时使用**微信官方**推出的企业微信接入方式和微信客服接入方式(版本 >= v3.5.7)。详情请前往 [#1443](https://github.com/AstrBotDevs/AstrBot/issues/1443) 讨论。
|
||||
|
||||
## ✨ 近期更新
|
||||
|
||||
1. AstrBot 现已支持接入 [MCP](https://modelcontextprotocol.io/) 服务器!
|
||||
@@ -96,9 +98,10 @@ uv run main.py
|
||||
| -------- | ------- | ------- | ------ |
|
||||
| QQ(官方机器人接口) | ✔ | 私聊、群聊,QQ 频道私聊、群聊 | 文字、图片 |
|
||||
| QQ(OneBot) | ✔ | 私聊、群聊 | 文字、图片、语音 |
|
||||
| 微信(个人号) | ✔ | 微信个人号私聊、群聊 | 文字、图片、语音 |
|
||||
| [Telegram](https://github.com/Soulter/astrbot_plugin_telegram) | ✔ | 私聊、群聊 | 文字、图片 |
|
||||
| [微信(企业微信)](https://github.com/Soulter/astrbot_plugin_wecom) | ✔ | 私聊 | 文字、图片、语音 |
|
||||
| 微信个人号 | ✔ | 微信个人号私聊、群聊 | 文字、图片、语音 |
|
||||
| Telegram | ✔ | 私聊、群聊 | 文字、图片 |
|
||||
| 企业微信 | ✔ | 私聊 | 文字、图片、语音 |
|
||||
| 微信客服 | ✔ | 私聊 | 文字、图片 |
|
||||
| 飞书 | ✔ | 私聊、群聊 | 文字、图片 |
|
||||
| 钉钉 | ✔ | 私聊、群聊 | 文字、图片 |
|
||||
| 微信对话开放平台 | 🚧 | 计划内 | - |
|
||||
@@ -186,6 +189,10 @@ _✨ WebUI ✨_
|
||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||
</a>
|
||||
|
||||
此外,本项目的诞生离不开以下开源项目:
|
||||
|
||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ)
|
||||
- [wechatpy/wechatpy](https://github.com/wechatpy/wechatpy)
|
||||
|
||||
## ⭐ Star History
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
|
||||
"""
|
||||
|
||||
VERSION = "3.5.6"
|
||||
VERSION = "3.5.8"
|
||||
DB_PATH = "data/data_v3.db"
|
||||
|
||||
# 默认配置
|
||||
@@ -151,6 +151,18 @@ CONFIG_METADATA_2 = {
|
||||
"host": "这里填写你的局域网IP或者公网服务器IP",
|
||||
"port": 11451,
|
||||
},
|
||||
"weixin_official_account(微信公众平台)": {
|
||||
"id": "weixin_official_account",
|
||||
"type": "weixin_official_account",
|
||||
"enable": False,
|
||||
"appid": "",
|
||||
"secret": "",
|
||||
"token": "",
|
||||
"encoding_aes_key": "",
|
||||
"api_base_url": "https://api.weixin.qq.com/cgi-bin/",
|
||||
"callback_server_host": "0.0.0.0",
|
||||
"port": 6194,
|
||||
},
|
||||
"wecom(企业微信)": {
|
||||
"id": "wecom",
|
||||
"type": "wecom",
|
||||
@@ -159,6 +171,7 @@ CONFIG_METADATA_2 = {
|
||||
"secret": "",
|
||||
"token": "",
|
||||
"encoding_aes_key": "",
|
||||
"kf_name": "",
|
||||
"api_base_url": "https://qyapi.weixin.qq.com/cgi-bin/",
|
||||
"callback_server_host": "0.0.0.0",
|
||||
"port": 6195,
|
||||
@@ -193,6 +206,11 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
},
|
||||
"items": {
|
||||
"kf_name": {
|
||||
"description": "微信客服账号名",
|
||||
"type": "string",
|
||||
"hint": "可选。微信客服账号名(不是 ID)。可在 https://kf.weixin.qq.com/kf/frame#/accounts 获取"
|
||||
},
|
||||
"telegram_token": {
|
||||
"description": "Bot Token",
|
||||
"type": "string",
|
||||
@@ -237,7 +255,7 @@ CONFIG_METADATA_2 = {
|
||||
"secret": {
|
||||
"description": "secret",
|
||||
"type": "string",
|
||||
"hint": "必填项。QQ 官方机器人平台的 secret。如何获取请参考文档。",
|
||||
"hint": "必填项。",
|
||||
},
|
||||
"enable_group_c2c": {
|
||||
"description": "启用消息列表单聊",
|
||||
|
||||
@@ -72,6 +72,8 @@ class PlatformManager:
|
||||
from .sources.telegram.tg_adapter import TelegramPlatformAdapter # noqa: F401
|
||||
case "wecom":
|
||||
from .sources.wecom.wecom_adapter import WecomPlatformAdapter # noqa: F401
|
||||
case "weixin_official_account":
|
||||
from .sources.weixin_official_account.weixin_offacc_adapter import WeixinOfficialAccountPlatformAdapter # noqa
|
||||
except (ImportError, ModuleNotFoundError) as e:
|
||||
logger.error(
|
||||
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。"
|
||||
|
||||
@@ -2,6 +2,7 @@ import sys
|
||||
import uuid
|
||||
import asyncio
|
||||
import quart
|
||||
import aiohttp
|
||||
|
||||
from astrbot.api.platform import (
|
||||
Platform,
|
||||
@@ -20,10 +21,14 @@ from requests import Response
|
||||
from wechatpy.enterprise.crypto import WeChatCrypto
|
||||
from wechatpy.enterprise import WeChatClient
|
||||
from wechatpy.enterprise.messages import TextMessage, ImageMessage, VoiceMessage
|
||||
from wechatpy.messages import BaseMessage
|
||||
from wechatpy.exceptions import InvalidSignatureException
|
||||
from wechatpy.enterprise import parse_message
|
||||
from .wecom_event import WecomPlatformEvent
|
||||
|
||||
from .wecom_kf import WeChatKF
|
||||
from .wecom_kf_message import WeChatKFMessage
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
else:
|
||||
@@ -131,9 +136,40 @@ class WecomPlatformAdapter(Platform):
|
||||
self.config["corpid"].strip(),
|
||||
self.config["secret"].strip(),
|
||||
)
|
||||
self.client.API_BASE_URL = self.api_base_url
|
||||
|
||||
async def callback(msg):
|
||||
# 微信客服
|
||||
self.kf_name = self.config.get("kf_name", None)
|
||||
if self.kf_name:
|
||||
# inject
|
||||
self.wechat_kf_api = WeChatKF(client=self.client)
|
||||
self.wechat_kf_message_api = WeChatKFMessage(self.client)
|
||||
self.client.kf = self.wechat_kf_api
|
||||
self.client.kf_message = self.wechat_kf_message_api
|
||||
|
||||
self.client.API_BASE_URL = self.api_base_url
|
||||
|
||||
async def callback(msg: BaseMessage):
|
||||
if msg.type == "unknown" and msg._data["Event"] == "kf_msg_or_event":
|
||||
|
||||
def get_latest_msg_item() -> dict | None:
|
||||
token = msg._data["Token"]
|
||||
kfid = msg._data["OpenKfId"]
|
||||
has_more = 1
|
||||
ret = {}
|
||||
while has_more:
|
||||
ret = self.wechat_kf_api.sync_msg(token, kfid)
|
||||
has_more = ret["has_more"]
|
||||
msg_list = ret.get("msg_list", [])
|
||||
if msg_list:
|
||||
return msg_list[-1]
|
||||
return None
|
||||
|
||||
msg_new = await asyncio.get_event_loop().run_in_executor(
|
||||
None, get_latest_msg_item
|
||||
)
|
||||
if msg_new:
|
||||
await self.convert_wechat_kf_message(msg_new)
|
||||
return
|
||||
await self.convert_message(msg)
|
||||
|
||||
self.server.callback = callback
|
||||
@@ -153,9 +189,39 @@ class WecomPlatformAdapter(Platform):
|
||||
|
||||
@override
|
||||
async def run(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
if self.kf_name:
|
||||
try:
|
||||
acc_list = (
|
||||
await loop.run_in_executor(
|
||||
None, self.wechat_kf_api.get_account_list
|
||||
)
|
||||
).get("account_list", [])
|
||||
logger.debug(f"获取到微信客服列表: {str(acc_list)}")
|
||||
for acc in acc_list:
|
||||
name = acc.get("name", None)
|
||||
if name != self.kf_name:
|
||||
continue
|
||||
open_kfid = acc.get("open_kfid", None)
|
||||
if not open_kfid:
|
||||
logger.error("获取微信客服失败,open_kfid 为空。")
|
||||
logger.debug(f"Found open_kfid: {str(open_kfid)}")
|
||||
kf_url = (
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self.wechat_kf_api.add_contact_way,
|
||||
open_kfid,
|
||||
"astrbot_placeholder",
|
||||
)
|
||||
).get("url", "")
|
||||
logger.info(
|
||||
f"请打开以下链接,在微信扫码以获取客服微信: https://api.cl2wm.cn/api/qrcode/code?text={kf_url}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
await self.server.start_polling()
|
||||
|
||||
async def convert_message(self, msg):
|
||||
async def convert_message(self, msg: BaseMessage) -> AstrBotMessage | None:
|
||||
abm = AstrBotMessage()
|
||||
if msg.type == "text":
|
||||
assert isinstance(msg, TextMessage)
|
||||
@@ -218,10 +284,42 @@ class WecomPlatformAdapter(Platform):
|
||||
abm.timestamp = msg.time
|
||||
abm.session_id = abm.sender.user_id
|
||||
abm.raw_message = msg
|
||||
else:
|
||||
logger.warning(f"暂未实现的事件: {msg.type}")
|
||||
return
|
||||
|
||||
logger.info(f"abm: {abm}")
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def convert_wechat_kf_message(self, msg: dict) -> AstrBotMessage | None:
|
||||
msgtype = msg.get("msgtype", None)
|
||||
external_userid = msg.get("external_userid", None)
|
||||
abm = AstrBotMessage()
|
||||
abm.raw_message = msg
|
||||
abm.raw_message["_wechat_kf_flag"] = None # 方便处理
|
||||
abm.self_id = msg["open_kfid"]
|
||||
abm.sender = MessageMember(external_userid, external_userid)
|
||||
abm.session_id = external_userid
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
if msgtype == "text":
|
||||
text = msg.get("text", {}).get("content", "").strip()
|
||||
abm.message = [Plain(text=text)]
|
||||
abm.message_str = text
|
||||
elif msgtype == "image":
|
||||
media_id = msg.get("image", {}).get("media_id", "")
|
||||
resp: Response = await asyncio.get_event_loop().run_in_executor(
|
||||
None, self.client.media.download, media_id
|
||||
)
|
||||
path = f"data/temp/wechat_kf_{media_id}.jpg"
|
||||
with open(path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
abm.message = [Image(file=path, url=path)]
|
||||
abm.message_str = "[图片]"
|
||||
else:
|
||||
logger.warning(f"未实现的微信客服消息事件: {msg}")
|
||||
return
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage):
|
||||
message_event = WecomPlatformEvent(
|
||||
message_str=message.message_str,
|
||||
|
||||
@@ -4,6 +4,7 @@ from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.api.message_components import Plain, Image, Record
|
||||
from wechatpy.enterprise import WeChatClient
|
||||
from .wecom_kf_message import WeChatKFMessage
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
@@ -52,19 +53,29 @@ class WecomPlatformEvent(AstrMessageEvent):
|
||||
if start + 2048 >= len(plain):
|
||||
result.append(plain[start:])
|
||||
break
|
||||
|
||||
|
||||
# 向前搜索分割标点符号
|
||||
end = min(start + 2048, len(plain))
|
||||
cut_position = end
|
||||
for i in range(end, start, -1):
|
||||
if i < len(plain) and plain[i-1] in ["。", "!", "?", ".", "!", "?", "\n", ";", ";"]:
|
||||
if i < len(plain) and plain[i - 1] in [
|
||||
"。",
|
||||
"!",
|
||||
"?",
|
||||
".",
|
||||
"!",
|
||||
"?",
|
||||
"\n",
|
||||
";",
|
||||
";",
|
||||
]:
|
||||
cut_position = i
|
||||
break
|
||||
|
||||
|
||||
# 没找到合适的位置分割, 直接切分
|
||||
if cut_position == end and end < len(plain):
|
||||
cut_position = end
|
||||
|
||||
|
||||
result.append(plain[start:cut_position])
|
||||
start = cut_position
|
||||
|
||||
@@ -73,57 +84,97 @@ class WecomPlatformEvent(AstrMessageEvent):
|
||||
async def send(self, message: MessageChain):
|
||||
message_obj = self.message_obj
|
||||
|
||||
for comp in message.chain:
|
||||
if isinstance(comp, Plain):
|
||||
# Split long text messages if needed
|
||||
plain_chunks = await self.split_plain(comp.text)
|
||||
for chunk in plain_chunks:
|
||||
self.client.message.send_text(
|
||||
message_obj.self_id, message_obj.session_id, chunk
|
||||
)
|
||||
await asyncio.sleep(0.5) # Avoid sending too fast
|
||||
elif isinstance(comp, Image):
|
||||
img_path = await comp.convert_to_file_path()
|
||||
is_wechat_kf = hasattr(self.client, "kf_message")
|
||||
if is_wechat_kf:
|
||||
# 微信客服
|
||||
kf_message_api = getattr(self.client, "kf_message", None)
|
||||
if not kf_message_api:
|
||||
logger.warning("未找到微信客服发送消息方法。")
|
||||
return
|
||||
assert isinstance(kf_message_api, WeChatKFMessage)
|
||||
user_id = self.get_sender_id()
|
||||
for comp in message.chain:
|
||||
if isinstance(comp, Plain):
|
||||
# Split long text messages if needed
|
||||
plain_chunks = await self.split_plain(comp.text)
|
||||
for chunk in plain_chunks:
|
||||
kf_message_api.send_text(user_id, self.get_self_id(), chunk)
|
||||
await asyncio.sleep(0.5) # Avoid sending too fast
|
||||
elif isinstance(comp, Image):
|
||||
img_path = await comp.convert_to_file_path()
|
||||
|
||||
with open(img_path, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("image", f)
|
||||
except Exception as e:
|
||||
logger.error(f"企业微信上传图片失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(f"企业微信上传图片失败: {e}")
|
||||
with open(img_path, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("image", f)
|
||||
except Exception as e:
|
||||
logger.error(f"微信客服上传图片失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(f"微信客服上传图片失败: {e}")
|
||||
)
|
||||
return
|
||||
logger.debug(f"微信客服上传图片返回: {response}")
|
||||
kf_message_api.send_image(
|
||||
user_id,
|
||||
self.get_self_id(),
|
||||
response["media_id"],
|
||||
)
|
||||
return
|
||||
logger.info(f"企业微信上传图片返回: {response}")
|
||||
self.client.message.send_image(
|
||||
message_obj.self_id,
|
||||
message_obj.session_id,
|
||||
response["media_id"],
|
||||
)
|
||||
elif isinstance(comp, Record):
|
||||
record_path = await comp.convert_to_file_path()
|
||||
# 转成amr
|
||||
record_path_amr = f"data/temp/{uuid.uuid4()}.amr"
|
||||
pydub.AudioSegment.from_wav(record_path).export(
|
||||
record_path_amr, format="amr"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"还没实现这个消息类型的发送逻辑: {comp.type}。")
|
||||
else:
|
||||
# 企业微信应用
|
||||
for comp in message.chain:
|
||||
if isinstance(comp, Plain):
|
||||
# Split long text messages if needed
|
||||
plain_chunks = await self.split_plain(comp.text)
|
||||
for chunk in plain_chunks:
|
||||
self.client.message.send_text(
|
||||
message_obj.self_id, message_obj.session_id, chunk
|
||||
)
|
||||
await asyncio.sleep(0.5) # Avoid sending too fast
|
||||
elif isinstance(comp, Image):
|
||||
img_path = await comp.convert_to_file_path()
|
||||
|
||||
with open(record_path_amr, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("voice", f)
|
||||
except Exception as e:
|
||||
logger.error(f"企业微信上传语音失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(f"企业微信上传语音失败: {e}")
|
||||
with open(img_path, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("image", f)
|
||||
except Exception as e:
|
||||
logger.error(f"企业微信上传图片失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(f"企业微信上传图片失败: {e}")
|
||||
)
|
||||
return
|
||||
logger.debug(f"企业微信上传图片返回: {response}")
|
||||
self.client.message.send_image(
|
||||
message_obj.self_id,
|
||||
message_obj.session_id,
|
||||
response["media_id"],
|
||||
)
|
||||
return
|
||||
logger.info(f"企业微信上传语音返回: {response}")
|
||||
self.client.message.send_voice(
|
||||
message_obj.self_id,
|
||||
message_obj.session_id,
|
||||
response["media_id"],
|
||||
elif isinstance(comp, Record):
|
||||
record_path = await comp.convert_to_file_path()
|
||||
# 转成amr
|
||||
record_path_amr = f"data/temp/{uuid.uuid4()}.amr"
|
||||
pydub.AudioSegment.from_wav(record_path).export(
|
||||
record_path_amr, format="amr"
|
||||
)
|
||||
|
||||
with open(record_path_amr, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("voice", f)
|
||||
except Exception as e:
|
||||
logger.error(f"企业微信上传语音失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(f"企业微信上传语音失败: {e}")
|
||||
)
|
||||
return
|
||||
logger.info(f"企业微信上传语音返回: {response}")
|
||||
self.client.message.send_voice(
|
||||
message_obj.self_id,
|
||||
message_obj.session_id,
|
||||
response["media_id"],
|
||||
)
|
||||
else:
|
||||
logger.warning(f"还没实现这个消息类型的发送逻辑: {comp.type}。")
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2020 messense
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"""
|
||||
|
||||
from wechatpy.client.api.base import BaseWeChatAPI
|
||||
|
||||
|
||||
class WeChatKF(BaseWeChatAPI):
|
||||
"""
|
||||
微信客服接口
|
||||
|
||||
https://work.weixin.qq.com/api/doc/90000/90135/94670
|
||||
"""
|
||||
|
||||
def sync_msg(self, token, open_kfid, cursor="", limit=1000):
|
||||
"""
|
||||
微信客户发送的消息、接待人员在企业微信回复的消息、发送消息接口发送失败事件(如被用户拒收)
|
||||
、客户点击菜单消息的回复消息,可以通过该接口获取具体的消息内容和事件。不支持读取通过发送消息接口发送的消息。
|
||||
支持的消息类型:文本、图片、语音、视频、文件、位置、链接、名片、小程序、事件。
|
||||
|
||||
|
||||
:param token: 回调事件返回的token字段,10分钟内有效;可不填,如果不填接口有严格的频率限制。不多于128字节
|
||||
:param open_kfid: 客服帐号ID
|
||||
:param cursor: 上一次调用时返回的next_cursor,第一次拉取可以不填。不多于64字节
|
||||
:param limit: 期望请求的数据量,默认值和最大值都为1000。
|
||||
注意:可能会出现返回条数少于limit的情况,需结合返回的has_more字段判断是否继续请求。
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
data = {"token": token, "cursor": cursor, "limit": limit, "open_kfid": open_kfid}
|
||||
return self._post("kf/sync_msg", data=data)
|
||||
|
||||
def get_service_state(self, open_kfid, external_userid):
|
||||
"""
|
||||
获取会话状态
|
||||
|
||||
ID 状态 说明
|
||||
0 未处理 新会话接入。可选择:1.直接用API自动回复消息。2.放进待接入池等待接待人员接待。3.指定接待人员进行接待
|
||||
1 由智能助手接待 可使用API回复消息。可选择转入待接入池或者指定接待人员处理。
|
||||
2 待接入池排队中 在待接入池中排队等待接待人员接入。可选择转为指定人员接待
|
||||
3 由人工接待 人工接待中。可选择结束会话
|
||||
4 已结束 会话已经结束。不允许变更会话状态,等待用户重新发起咨询
|
||||
|
||||
:param open_kfid: 客服帐号ID
|
||||
:param external_userid: 微信客户的external_userid
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
data = {
|
||||
"open_kfid": open_kfid,
|
||||
"external_userid": external_userid,
|
||||
}
|
||||
return self._post("kf/service_state/get", data=data)
|
||||
|
||||
def trans_service_state(self, open_kfid, external_userid, service_state, servicer_userid=""):
|
||||
"""
|
||||
变更会话状态
|
||||
|
||||
:param open_kfid: 客服帐号ID
|
||||
:param external_userid: 微信客户的external_userid
|
||||
:param service_state: 当前的会话状态,状态定义参考概述中的表格
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
data = {
|
||||
"open_kfid": open_kfid,
|
||||
"external_userid": external_userid,
|
||||
"service_state": service_state,
|
||||
}
|
||||
if servicer_userid:
|
||||
data["servicer_userid"] = servicer_userid
|
||||
return self._post("kf/service_state/trans", data=data)
|
||||
|
||||
def get_servicer_list(self, open_kfid):
|
||||
"""
|
||||
获取接待人员列表
|
||||
|
||||
:param open_kfid: 客服帐号ID
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
data = {
|
||||
"open_kfid": open_kfid,
|
||||
}
|
||||
return self._get("kf/servicer/list", params=data)
|
||||
|
||||
def add_servicer(self, open_kfid, userid_list):
|
||||
"""
|
||||
添加接待人员
|
||||
添加指定客服帐号的接待人员。
|
||||
|
||||
:param open_kfid: 客服帐号ID
|
||||
:param userid_list: 接待人员userid列表
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
if not isinstance(userid_list, list):
|
||||
userid_list = [userid_list]
|
||||
|
||||
data = {
|
||||
"open_kfid": open_kfid,
|
||||
"userid_list": userid_list,
|
||||
}
|
||||
return self._post("kf/servicer/add", data=data)
|
||||
|
||||
def del_servicer(self, open_kfid, userid_list):
|
||||
"""
|
||||
删除接待人员
|
||||
从客服帐号删除接待人员
|
||||
|
||||
:param open_kfid: 客服帐号ID
|
||||
:param userid_list: 接待人员userid列表
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
if not isinstance(userid_list, list):
|
||||
userid_list = [userid_list]
|
||||
|
||||
data = {
|
||||
"open_kfid": open_kfid,
|
||||
"userid_list": userid_list,
|
||||
}
|
||||
return self._post("kf/servicer/del", data=data)
|
||||
|
||||
def batchget_customer(self, external_userid_list):
|
||||
"""
|
||||
客户基本信息获取
|
||||
|
||||
:param external_userid_list: external_userid列表
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
if not isinstance(external_userid_list, list):
|
||||
external_userid_list = [external_userid_list]
|
||||
|
||||
data = {
|
||||
"external_userid_list": external_userid_list,
|
||||
}
|
||||
return self._post("kf/customer/batchget", data=data)
|
||||
|
||||
def get_account_list(self):
|
||||
"""
|
||||
获取客服帐号列表
|
||||
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
return self._get("kf/account/list")
|
||||
|
||||
def add_contact_way(self, open_kfid, scene):
|
||||
"""
|
||||
获取客服帐号链接
|
||||
|
||||
:param open_kfid: 客服帐号ID
|
||||
:param scene: 场景值,字符串类型,由开发者自定义。不多于32字节;字符串取值范围(正则表达式):[0-9a-zA-Z_-]*
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
data = {"open_kfid": open_kfid, "scene": scene}
|
||||
return self._post("kf/add_contact_way", data=data)
|
||||
|
||||
def get_upgrade_service_config(self):
|
||||
"""
|
||||
获取配置的专员与客户群
|
||||
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
return self._get("kf/customer/get_upgrade_service_config")
|
||||
|
||||
def upgrade_service(self, open_kfid, external_userid, service_type, member=None, groupchat=None):
|
||||
"""
|
||||
为客户升级为专员或客户群服务
|
||||
|
||||
:param open_kfid: 客服帐号ID
|
||||
:param external_userid: 微信客户的external_userid
|
||||
:param service_type: 表示是升级到专员服务还是客户群服务。1:专员服务。2:客户群服务
|
||||
:param member: 推荐的服务专员,type等于1时有效
|
||||
:param groupchat: 推荐的客户群,type等于2时有效
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
|
||||
data = {
|
||||
"open_kfid": open_kfid,
|
||||
"external_userid": external_userid,
|
||||
"type": service_type,
|
||||
}
|
||||
if service_type == 1:
|
||||
data["member"] = member
|
||||
else:
|
||||
data["groupchat"] = groupchat
|
||||
return self._post("kf/customer/upgrade_service", data=data)
|
||||
|
||||
def cancel_upgrade_service(self, open_kfid, external_userid):
|
||||
"""
|
||||
为客户取消推荐
|
||||
|
||||
:param open_kfid: 客服帐号ID
|
||||
:param external_userid: 微信客户的external_userid
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
|
||||
data = {"open_kfid": open_kfid, "external_userid": external_userid}
|
||||
return self._post("kf/customer/cancel_upgrade_service", data=data)
|
||||
|
||||
def send_msg_on_event(self, code, msgtype, msg_content, msgid=None):
|
||||
"""
|
||||
当特定的事件回调消息包含code字段,可以此code为凭证,调用该接口给用户发送相应事件场景下的消息,如客服欢迎语。
|
||||
支持发送消息类型:文本、菜单消息。
|
||||
|
||||
:param code: 事件响应消息对应的code。通过事件回调下发,仅可使用一次。
|
||||
:param msgtype: 消息类型。对不同的msgtype,有相应的结构描述,详见消息类型
|
||||
:param msg_content: 目前支持文本与菜单消息,具体查看文档
|
||||
:param msgid: 消息ID。如果请求参数指定了msgid,则原样返回,否则系统自动生成并返回。不多于32字节;
|
||||
字符串取值范围(正则表达式):[0-9a-zA-Z_-]*
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
|
||||
data = {"code": code, "msgtype": msgtype}
|
||||
if msgid:
|
||||
data["msgid"] = msgid
|
||||
data.update(msg_content)
|
||||
return self._post("kf/send_msg_on_event", data=data)
|
||||
|
||||
def get_corp_statistic(self, start_time, end_time, open_kfid=None):
|
||||
"""
|
||||
获取「客户数据统计」企业汇总数据
|
||||
|
||||
:param start_time: 开始时间
|
||||
:param end_time: 结束时间
|
||||
:param open_kfid: 客服帐号ID
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
data = {"open_kfid": open_kfid, "start_time": start_time, "end_time": end_time}
|
||||
return self._post("kf/get_corp_statistic", data=data)
|
||||
|
||||
def get_servicer_statistic(self, start_time, end_time, open_kfid=None, servicer_userid=None):
|
||||
"""
|
||||
获取「客户数据统计」接待人员明细数据
|
||||
|
||||
:param start_time: 开始时间
|
||||
:param end_time: 结束时间
|
||||
:param open_kfid: 客服帐号ID
|
||||
:param servicer_userid: 接待人员
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
data = {
|
||||
"open_kfid": open_kfid,
|
||||
"servicer_userid": servicer_userid,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
}
|
||||
return self._post("kf/get_servicer_statistic", data=data)
|
||||
|
||||
def account_update(self, open_kfid, name, media_id):
|
||||
"""
|
||||
修改客服账号
|
||||
|
||||
:param open_kfid: 客服帐号ID
|
||||
:param name: 客服名称
|
||||
:param media_id: 客服头像临时素材
|
||||
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
data = {"open_kfid": open_kfid, "name": name, "media_id": media_id}
|
||||
return self._post("kf/account/update", data=data)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014-2020 messense
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"""
|
||||
|
||||
from optionaldict import optionaldict
|
||||
|
||||
from wechatpy.client.api.base import BaseWeChatAPI
|
||||
|
||||
class WeChatKFMessage(BaseWeChatAPI):
|
||||
"""
|
||||
发送微信客服消息
|
||||
|
||||
https://work.weixin.qq.com/api/doc/90000/90135/94677
|
||||
|
||||
支持:
|
||||
* 文本消息
|
||||
* 图片消息
|
||||
* 语音消息
|
||||
* 视频消息
|
||||
* 文件消息
|
||||
* 图文链接
|
||||
* 小程序
|
||||
* 菜单消息
|
||||
* 地理位置
|
||||
"""
|
||||
|
||||
def send(self, user_id, open_kfid, msgid="", msg=None):
|
||||
"""
|
||||
当微信客户处于“新接入待处理”或“由智能助手接待”状态下,可调用该接口给用户发送消息。
|
||||
注意仅当微信客户在主动发送消息给客服后的48小时内,企业可发送消息给客户,最多可发送5条消息;若用户继续发送消息,企业可再次下发消息。
|
||||
支持发送消息类型:文本、图片、语音、视频、文件、图文、小程序、菜单消息、地理位置。
|
||||
|
||||
:param user_id: 指定接收消息的客户UserID
|
||||
:param open_kfid: 指定发送消息的客服帐号ID
|
||||
:param msgid: 指定消息ID
|
||||
:param tag_ids: 标签ID列表。
|
||||
:param msg: 发送消息的 dict 对象
|
||||
:type msg: dict | None
|
||||
:return: 接口调用结果
|
||||
"""
|
||||
msg = msg or {}
|
||||
data = {
|
||||
"touser": user_id,
|
||||
"open_kfid": open_kfid,
|
||||
}
|
||||
if msgid:
|
||||
data["msgid"] = msgid
|
||||
data.update(msg)
|
||||
return self._post("kf/send_msg", data=data)
|
||||
|
||||
def send_text(self, user_id, open_kfid, content, msgid=""):
|
||||
return self.send(
|
||||
user_id,
|
||||
open_kfid,
|
||||
msgid,
|
||||
msg={"msgtype": "text", "text": {"content": content}},
|
||||
)
|
||||
|
||||
def send_image(self, user_id, open_kfid, media_id, msgid=""):
|
||||
return self.send(
|
||||
user_id,
|
||||
open_kfid,
|
||||
msgid,
|
||||
msg={"msgtype": "image", "image": {"media_id": media_id}},
|
||||
)
|
||||
|
||||
def send_voice(self, user_id, open_kfid, media_id, msgid=""):
|
||||
return self.send(
|
||||
user_id,
|
||||
open_kfid,
|
||||
msgid,
|
||||
msg={"msgtype": "voice", "voice": {"media_id": media_id}},
|
||||
)
|
||||
|
||||
def send_video(self, user_id, open_kfid, media_id, msgid=""):
|
||||
video_data = optionaldict()
|
||||
video_data["media_id"] = media_id
|
||||
|
||||
return self.send(
|
||||
user_id,
|
||||
open_kfid,
|
||||
msgid,
|
||||
msg={"msgtype": "video", "video": dict(video_data)},
|
||||
)
|
||||
|
||||
def send_file(self, user_id, open_kfid, media_id, msgid=""):
|
||||
return self.send(
|
||||
user_id,
|
||||
open_kfid,
|
||||
msgid,
|
||||
msg={"msgtype": "file", "file": {"media_id": media_id}},
|
||||
)
|
||||
|
||||
def send_articles_link(self, user_id, open_kfid, article, msgid=""):
|
||||
articles_data = {
|
||||
"title": article["title"],
|
||||
"desc": article["desc"],
|
||||
"url": article["url"],
|
||||
"thumb_media_id": article["thumb_media_id"],
|
||||
}
|
||||
return self.send(
|
||||
user_id,
|
||||
open_kfid,
|
||||
msgid,
|
||||
msg={"msgtype": "news", "link": {"link": articles_data}},
|
||||
)
|
||||
|
||||
def send_msgmenu(self, user_id, open_kfid, head_content, menu_list, tail_content, msgid=""):
|
||||
return self.send(
|
||||
user_id,
|
||||
open_kfid,
|
||||
msgid,
|
||||
msg={
|
||||
"msgtype": "msgmenu",
|
||||
"msgmenu": {"head_content": head_content, "list": menu_list, "tail_content": tail_content},
|
||||
},
|
||||
)
|
||||
|
||||
def send_location(self, user_id, open_kfid, name, address, latitude, longitude, msgid=""):
|
||||
return self.send(
|
||||
user_id,
|
||||
open_kfid,
|
||||
msgid,
|
||||
msg={
|
||||
"msgtype": "location",
|
||||
"msgmenu": {"name": name, "address": address, "latitude": latitude, "longitude": longitude},
|
||||
},
|
||||
)
|
||||
|
||||
def send_miniprogram(self, user_id, open_kfid, appid, title, thumb_media_id, pagepath, msgid=""):
|
||||
return self.send(
|
||||
user_id,
|
||||
open_kfid,
|
||||
msgid,
|
||||
msg={
|
||||
"msgtype": "miniprogram",
|
||||
"msgmenu": {"appid": appid, "title": title, "thumb_media_id": thumb_media_id, "pagepath": pagepath},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,252 @@
|
||||
import sys
|
||||
import uuid
|
||||
import asyncio
|
||||
import quart
|
||||
|
||||
from astrbot.api.platform import (
|
||||
Platform,
|
||||
AstrBotMessage,
|
||||
MessageMember,
|
||||
PlatformMetadata,
|
||||
MessageType,
|
||||
)
|
||||
from astrbot.api.event import MessageChain
|
||||
from astrbot.api.message_components import Plain, Image, Record
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.api.platform import register_platform_adapter
|
||||
from astrbot.core import logger
|
||||
from requests import Response
|
||||
|
||||
from wechatpy.utils import check_signature
|
||||
from wechatpy.crypto import WeChatCrypto
|
||||
from wechatpy import WeChatClient
|
||||
from wechatpy.messages import TextMessage, ImageMessage, VoiceMessage
|
||||
from wechatpy.exceptions import InvalidSignatureException
|
||||
from wechatpy import parse_message
|
||||
from .weixin_offacc_event import WeixinOfficialAccountPlatformEvent
|
||||
|
||||
if sys.version_info >= (3, 12):
|
||||
from typing import override
|
||||
else:
|
||||
from typing_extensions import override
|
||||
|
||||
|
||||
class WecomServer:
|
||||
def __init__(self, event_queue: asyncio.Queue, config: dict):
|
||||
self.server = quart.Quart(__name__)
|
||||
self.port = int(config.get("port"))
|
||||
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
|
||||
self.token = config.get("token")
|
||||
self.encoding_aes_key = config.get("encoding_aes_key")
|
||||
self.appid = config.get("appid")
|
||||
self.server.add_url_rule(
|
||||
"/callback/command", view_func=self.verify, methods=["GET"]
|
||||
)
|
||||
self.server.add_url_rule(
|
||||
"/callback/command", view_func=self.callback_command, methods=["POST"]
|
||||
)
|
||||
self.crypto = WeChatCrypto(self.token, self.encoding_aes_key, self.appid)
|
||||
|
||||
self.event_queue = event_queue
|
||||
|
||||
self.callback = None
|
||||
self.shutdown_event = asyncio.Event()
|
||||
|
||||
async def verify(self):
|
||||
logger.info(f"验证请求有效性: {quart.request.args}")
|
||||
|
||||
args = quart.request.args
|
||||
if not args.get("signature", None):
|
||||
logger.error("未知的响应,请检查回调地址是否填写正确。")
|
||||
return "err"
|
||||
try:
|
||||
check_signature(
|
||||
self.token,
|
||||
args.get("signature"),
|
||||
args.get("timestamp"),
|
||||
args.get("nonce"),
|
||||
)
|
||||
logger.info("验证请求有效性成功。")
|
||||
return args.get("echostr", "empty")
|
||||
except InvalidSignatureException:
|
||||
logger.error("验证请求有效性失败,签名异常,请检查配置。")
|
||||
return "err"
|
||||
|
||||
async def callback_command(self):
|
||||
data = await quart.request.get_data()
|
||||
msg_signature = quart.request.args.get("msg_signature")
|
||||
timestamp = quart.request.args.get("timestamp")
|
||||
nonce = quart.request.args.get("nonce")
|
||||
try:
|
||||
xml = self.crypto.decrypt_message(data, msg_signature, timestamp, nonce)
|
||||
except InvalidSignatureException:
|
||||
logger.error("解密失败,签名异常,请检查配置。")
|
||||
raise
|
||||
else:
|
||||
msg = parse_message(xml)
|
||||
logger.info(f"解析成功: {msg}")
|
||||
|
||||
if self.callback:
|
||||
await self.callback(msg)
|
||||
|
||||
return "success"
|
||||
|
||||
async def start_polling(self):
|
||||
logger.info(
|
||||
f"将在 {self.callback_server_host}:{self.port} 端口启动 微信公众平台 适配器。"
|
||||
)
|
||||
await self.server.run_task(
|
||||
host=self.callback_server_host,
|
||||
port=self.port,
|
||||
shutdown_trigger=self.shutdown_trigger,
|
||||
)
|
||||
|
||||
async def shutdown_trigger(self):
|
||||
await self.shutdown_event.wait()
|
||||
|
||||
|
||||
@register_platform_adapter("weixin_official_account", "微信公众平台 适配器")
|
||||
class WeixinOfficialAccountPlatformAdapter(Platform):
|
||||
def __init__(
|
||||
self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
|
||||
) -> None:
|
||||
super().__init__(event_queue)
|
||||
self.config = platform_config
|
||||
self.settingss = platform_settings
|
||||
self.client_self_id = uuid.uuid4().hex[:8]
|
||||
self.api_base_url = platform_config.get(
|
||||
"api_base_url", "https://api.weixin.qq.com/cgi-bin/"
|
||||
)
|
||||
|
||||
if not self.api_base_url:
|
||||
self.api_base_url = "https://api.weixin.qq.com/cgi-bin/"
|
||||
|
||||
if self.api_base_url.endswith("/"):
|
||||
self.api_base_url = self.api_base_url[:-1]
|
||||
if not self.api_base_url.endswith("/cgi-bin"):
|
||||
self.api_base_url += "/cgi-bin"
|
||||
|
||||
if not self.api_base_url.endswith("/"):
|
||||
self.api_base_url += "/"
|
||||
|
||||
self.server = WecomServer(self._event_queue, self.config)
|
||||
|
||||
self.client = WeChatClient(
|
||||
self.config["appid"].strip(),
|
||||
self.config["secret"].strip(),
|
||||
)
|
||||
|
||||
async def callback(msg):
|
||||
try:
|
||||
await self.convert_message(msg)
|
||||
except Exception as e:
|
||||
logger.error(f"转换消息时出现异常: {e}")
|
||||
|
||||
self.server.callback = callback
|
||||
|
||||
@override
|
||||
async def send_by_session(
|
||||
self, session: MessageSesion, message_chain: MessageChain
|
||||
):
|
||||
await super().send_by_session(session, message_chain)
|
||||
|
||||
@override
|
||||
def meta(self) -> PlatformMetadata:
|
||||
return PlatformMetadata(
|
||||
"weixin_official_account",
|
||||
"微信公众平台 适配器",
|
||||
)
|
||||
|
||||
@override
|
||||
async def run(self):
|
||||
await self.server.start_polling()
|
||||
|
||||
async def convert_message(self, msg) -> AstrBotMessage | None:
|
||||
abm = AstrBotMessage()
|
||||
if isinstance(msg, TextMessage):
|
||||
abm.message_str = msg.content
|
||||
abm.self_id = str(msg.target)
|
||||
abm.message = [Plain(msg.content)]
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
abm.sender = MessageMember(
|
||||
msg.source,
|
||||
msg.source,
|
||||
)
|
||||
abm.message_id = msg.id
|
||||
abm.timestamp = msg.time
|
||||
abm.session_id = abm.sender.user_id
|
||||
abm.raw_message = msg
|
||||
elif msg.type == "image":
|
||||
assert isinstance(msg, ImageMessage)
|
||||
abm.message_str = "[图片]"
|
||||
abm.self_id = str(msg.target)
|
||||
abm.message = [Image(file=msg.image, url=msg.image)]
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
abm.sender = MessageMember(
|
||||
msg.source,
|
||||
msg.source,
|
||||
)
|
||||
abm.message_id = msg.id
|
||||
abm.timestamp = msg.time
|
||||
abm.session_id = abm.sender.user_id
|
||||
abm.raw_message = msg
|
||||
elif msg.type == "voice":
|
||||
assert isinstance(msg, VoiceMessage)
|
||||
|
||||
resp: Response = await asyncio.get_event_loop().run_in_executor(
|
||||
None, self.client.media.download, msg.media_id
|
||||
)
|
||||
path = f"data/temp/wecom_{msg.media_id}.amr"
|
||||
with open(path, "wb") as f:
|
||||
f.write(resp.content)
|
||||
|
||||
try:
|
||||
from pydub import AudioSegment
|
||||
|
||||
path_wav = f"data/temp/wecom_{msg.media_id}.wav"
|
||||
audio = AudioSegment.from_file(path)
|
||||
audio.export(path_wav, format="wav")
|
||||
except Exception as e:
|
||||
logger.error(f"转换音频失败: {e}。如果没有安装 pydub 和 ffmpeg 请先安装。")
|
||||
path_wav = path
|
||||
return
|
||||
|
||||
abm.message_str = ""
|
||||
abm.self_id = str(msg.target)
|
||||
abm.message = [Record(file=path_wav, url=path_wav)]
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
abm.sender = MessageMember(
|
||||
msg.source,
|
||||
msg.source,
|
||||
)
|
||||
abm.message_id = msg.id
|
||||
abm.timestamp = msg.time
|
||||
abm.session_id = abm.sender.user_id
|
||||
abm.raw_message = msg
|
||||
else:
|
||||
logger.warning(f"暂未实现的事件: {msg.type}")
|
||||
return
|
||||
|
||||
logger.info(f"abm: {abm}")
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage):
|
||||
message_event = WeixinOfficialAccountPlatformEvent(
|
||||
message_str=message.message_str,
|
||||
message_obj=message,
|
||||
platform_meta=self.meta(),
|
||||
session_id=message.session_id,
|
||||
client=self.client,
|
||||
)
|
||||
self.commit_event(message_event)
|
||||
|
||||
def get_client(self) -> WeChatClient:
|
||||
return self.client
|
||||
|
||||
async def terminate(self):
|
||||
self.server.shutdown_event.set()
|
||||
try:
|
||||
await self.server.server.shutdown()
|
||||
except Exception as _:
|
||||
pass
|
||||
logger.info("微信公众平台 适配器已被优雅地关闭")
|
||||
@@ -0,0 +1,147 @@
|
||||
import uuid
|
||||
import asyncio
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||
from astrbot.api.message_components import Plain, Image, Record
|
||||
from wechatpy import WeChatClient
|
||||
|
||||
from astrbot.api import logger
|
||||
|
||||
try:
|
||||
import pydub
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"检测到 pydub 库未安装,微信公众平台将无法语音收发。如需使用语音,请前往管理面板 -> 控制台 -> 安装 Pip 库安装 pydub。"
|
||||
)
|
||||
pass
|
||||
|
||||
|
||||
class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
||||
def __init__(
|
||||
self,
|
||||
message_str: str,
|
||||
message_obj: AstrBotMessage,
|
||||
platform_meta: PlatformMetadata,
|
||||
session_id: str,
|
||||
client: WeChatClient,
|
||||
):
|
||||
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||
self.client = client
|
||||
|
||||
@staticmethod
|
||||
async def send_with_client(
|
||||
client: WeChatClient, message: MessageChain, user_name: str
|
||||
):
|
||||
pass
|
||||
|
||||
async def split_plain(self, plain: str) -> list[str]:
|
||||
"""将长文本分割成多个小文本, 每个小文本长度不超过 2048 字符
|
||||
|
||||
Args:
|
||||
plain (str): 要分割的长文本
|
||||
Returns:
|
||||
list[str]: 分割后的文本列表
|
||||
"""
|
||||
if len(plain) <= 2048:
|
||||
return [plain]
|
||||
else:
|
||||
result = []
|
||||
start = 0
|
||||
while start < len(plain):
|
||||
# 剩下的字符串长度<2048时结束
|
||||
if start + 2048 >= len(plain):
|
||||
result.append(plain[start:])
|
||||
break
|
||||
|
||||
# 向前搜索分割标点符号
|
||||
end = min(start + 2048, len(plain))
|
||||
cut_position = end
|
||||
for i in range(end, start, -1):
|
||||
if i < len(plain) and plain[i - 1] in [
|
||||
"。",
|
||||
"!",
|
||||
"?",
|
||||
".",
|
||||
"!",
|
||||
"?",
|
||||
"\n",
|
||||
";",
|
||||
";",
|
||||
]:
|
||||
cut_position = i
|
||||
break
|
||||
|
||||
# 没找到合适的位置分割, 直接切分
|
||||
if cut_position == end and end < len(plain):
|
||||
cut_position = end
|
||||
|
||||
result.append(plain[start:cut_position])
|
||||
start = cut_position
|
||||
|
||||
return result
|
||||
|
||||
async def send(self, message: MessageChain):
|
||||
message_obj = self.message_obj
|
||||
for comp in message.chain:
|
||||
if isinstance(comp, Plain):
|
||||
# Split long text messages if needed
|
||||
plain_chunks = await self.split_plain(comp.text)
|
||||
for chunk in plain_chunks:
|
||||
self.client.message.send_text(message_obj.sender.user_id, chunk)
|
||||
await asyncio.sleep(0.5) # Avoid sending too fast
|
||||
elif isinstance(comp, Image):
|
||||
img_path = await comp.convert_to_file_path()
|
||||
|
||||
with open(img_path, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("image", f)
|
||||
except Exception as e:
|
||||
logger.error(f"微信公众平台上传图片失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(f"微信公众平台上传图片失败: {e}")
|
||||
)
|
||||
return
|
||||
logger.debug(f"微信公众平台上传图片返回: {response}")
|
||||
self.client.message.send_image(
|
||||
message_obj.sender.user_id,
|
||||
response["media_id"],
|
||||
)
|
||||
elif isinstance(comp, Record):
|
||||
record_path = await comp.convert_to_file_path()
|
||||
# 转成amr
|
||||
record_path_amr = f"data/temp/{uuid.uuid4()}.amr"
|
||||
pydub.AudioSegment.from_wav(record_path).export(
|
||||
record_path_amr, format="amr"
|
||||
)
|
||||
|
||||
with open(record_path_amr, "rb") as f:
|
||||
try:
|
||||
response = self.client.media.upload("voice", f)
|
||||
except Exception as e:
|
||||
logger.error(f"微信公众平台上传语音失败: {e}")
|
||||
await self.send(
|
||||
MessageChain().message(f"微信公众平台上传语音失败: {e}")
|
||||
)
|
||||
return
|
||||
logger.info(f"微信公众平台上传语音返回: {response}")
|
||||
self.client.message.send_voice(
|
||||
message_obj.sender.user_id,
|
||||
response["media_id"],
|
||||
)
|
||||
else:
|
||||
logger.warning(f"还没实现这个消息类型的发送逻辑: {comp.type}。")
|
||||
|
||||
await super().send(message)
|
||||
|
||||
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||
buffer = None
|
||||
async for chain in generator:
|
||||
if not buffer:
|
||||
buffer = chain
|
||||
else:
|
||||
buffer.chain.extend(chain.chain)
|
||||
if not buffer:
|
||||
return
|
||||
buffer.squash_plain()
|
||||
await self.send(buffer)
|
||||
return await super().send_streaming(generator, use_fallback)
|
||||
@@ -3,7 +3,7 @@ import base64
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Optional
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from google import genai
|
||||
@@ -15,7 +15,7 @@ from astrbot import logger
|
||||
from astrbot.api.provider import Personality, Provider
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
from astrbot.core.provider.entities import LLMResponse, ToolCallsResult
|
||||
from astrbot.core.provider.func_tool_manager import FuncCall
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
|
||||
@@ -65,7 +65,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
db_helper,
|
||||
default_persona,
|
||||
)
|
||||
self.api_keys: List = provider_config.get("key", [])
|
||||
self.api_keys: list = provider_config.get("key", [])
|
||||
self.chosen_api_key: str = self.api_keys[0] if len(self.api_keys) > 0 else None
|
||||
self.timeout: int = int(provider_config.get("timeout", 180))
|
||||
|
||||
@@ -99,7 +99,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
and threshold_str in self.THRESHOLD_MAPPING
|
||||
]
|
||||
|
||||
async def _handle_api_error(self, e: APIError, keys: List[str]) -> bool:
|
||||
async def _handle_api_error(self, e: APIError, keys: list[str]) -> bool:
|
||||
"""处理API错误,返回是否需要重试"""
|
||||
if e.code == 429 or "API key not valid" in e.message:
|
||||
keys.remove(self.chosen_api_key)
|
||||
@@ -126,7 +126,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
payloads: dict,
|
||||
tools: Optional[FuncCall] = None,
|
||||
system_instruction: Optional[str] = None,
|
||||
modalities: Optional[List[str]] = None,
|
||||
modalities: Optional[list[str]] = None,
|
||||
temperature: float = 0.7,
|
||||
) -> types.GenerateContentConfig:
|
||||
"""准备查询配置"""
|
||||
@@ -195,7 +195,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
),
|
||||
)
|
||||
|
||||
def _prepare_conversation(self, payloads: Dict) -> List[types.Content]:
|
||||
def _prepare_conversation(self, payloads: dict) -> list[types.Content]:
|
||||
"""准备 Gemini SDK 的 Content 列表"""
|
||||
|
||||
def create_text_part(text: str) -> types.Part:
|
||||
@@ -220,7 +220,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
else:
|
||||
contents.append(content_cls(parts=part))
|
||||
|
||||
gemini_contents: List[types.Content] = []
|
||||
gemini_contents: list[types.Content] = []
|
||||
native_tool_enabled = any(
|
||||
[
|
||||
self.provider_config.get("gm_native_coderunner", False),
|
||||
@@ -464,13 +464,15 @@ class ProviderGoogleGenAI(Provider):
|
||||
self,
|
||||
prompt: str,
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = None,
|
||||
image_urls: list[str] = None,
|
||||
func_tool: FuncCall = None,
|
||||
contexts=[],
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
contexts: list = None,
|
||||
system_prompt: str = None,
|
||||
tool_calls_result: ToolCallsResult = None,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
context_query = [*contexts, new_record]
|
||||
if system_prompt:
|
||||
@@ -504,13 +506,15 @@ class ProviderGoogleGenAI(Provider):
|
||||
self,
|
||||
prompt: str,
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = [],
|
||||
image_urls: list[str] = None,
|
||||
func_tool: FuncCall = None,
|
||||
contexts=[],
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
contexts: str = None,
|
||||
system_prompt: str = None,
|
||||
tool_calls_result: ToolCallsResult = None,
|
||||
**kwargs,
|
||||
) -> AsyncGenerator[LLMResponse, None]:
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
context_query = [*contexts, new_record]
|
||||
if system_prompt:
|
||||
@@ -556,14 +560,14 @@ class ProviderGoogleGenAI(Provider):
|
||||
def get_current_key(self) -> str:
|
||||
return self.chosen_api_key
|
||||
|
||||
def get_keys(self) -> List[str]:
|
||||
def get_keys(self) -> list[str]:
|
||||
return self.api_keys
|
||||
|
||||
def set_key(self, key):
|
||||
self.chosen_api_key = key
|
||||
self._init_client()
|
||||
|
||||
async def assemble_context(self, text: str, image_urls: List[str] = None):
|
||||
async def assemble_context(self, text: str, image_urls: list[str] = None):
|
||||
"""
|
||||
组装上下文。
|
||||
"""
|
||||
|
||||
@@ -21,7 +21,7 @@ from astrbot import logger
|
||||
from astrbot.core.provider.func_tool_manager import FuncCall
|
||||
from typing import List, AsyncGenerator
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
from astrbot.core.provider.entities import LLMResponse, ToolCallsResult
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
@@ -221,14 +221,16 @@ class ProviderOpenAIOfficial(Provider):
|
||||
self,
|
||||
prompt: str,
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = [],
|
||||
image_urls: list[str] = None,
|
||||
func_tool: FuncCall = None,
|
||||
contexts=[],
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
contexts: list=None,
|
||||
system_prompt: str=None,
|
||||
tool_calls_result: ToolCallsResult=None,
|
||||
**kwargs,
|
||||
) -> tuple:
|
||||
"""准备聊天所需的有效载荷和上下文"""
|
||||
if contexts is None:
|
||||
contexts = []
|
||||
new_record = await self.assemble_context(prompt, image_urls)
|
||||
context_query = [*contexts, new_record]
|
||||
if system_prompt:
|
||||
@@ -337,11 +339,11 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
async def text_chat(
|
||||
self,
|
||||
prompt: str,
|
||||
session_id: str = None,
|
||||
image_urls: List[str] = [],
|
||||
func_tool: FuncCall = None,
|
||||
contexts=[],
|
||||
prompt,
|
||||
session_id = None,
|
||||
image_urls = None,
|
||||
func_tool = None,
|
||||
contexts=None,
|
||||
system_prompt=None,
|
||||
tool_calls_result=None,
|
||||
**kwargs,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# What's Changed
|
||||
|
||||
> Gewechat 已经停止维护,此版本提供了 `微信客服` 的接入方式,可以在直接微信内聊天。这是微信官方推出的接入方式,因此没有风控风险。详见 [AstrBot 接入企业微信](https://astrbot.app/deploy/platform/wecom.html)。此接入方式处于测试阶段,有问题请及时在 GitHub 上提交 Issue。
|
||||
|
||||
1. 支持接入微信客服。
|
||||
@@ -0,0 +1,5 @@
|
||||
# What's Changed
|
||||
|
||||
1. 支持接入微信公众平台,详见 [AstrBot - 微信公众平台](https://astrbot.app/deploy/platform/weixin-official-account.html) @Soulter
|
||||
2. 优化 gemini_source 方法默认参数 @Raven95678
|
||||
3. 优化 persona 错误显示 @Soulter
|
||||
@@ -1017,6 +1017,8 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
||||
conversation = await self.context.conversation_manager.get_conversation(
|
||||
message.unified_msg_origin, cid
|
||||
)
|
||||
if not conversation:
|
||||
message.set_result(MessageEventResult().message("请先进入一个对话。可以使用 /new 创建。"))
|
||||
if not conversation.persona_id and not conversation.persona_id == "[%None]":
|
||||
curr_persona_name = (
|
||||
self.context.provider_manager.selected_default_persona["name"]
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
from astrbot.api.event import filter, AstrMessageEvent
|
||||
from astrbot.api.star import Context, Star, register
|
||||
from astrbot.api import logger
|
||||
|
||||
@register("vpet", "AstrBot Team", "虚拟桌宠", "0.0.1")
|
||||
class VPet(Star):
|
||||
def __init__(self, context: Context):
|
||||
super().__init__(context)
|
||||
|
||||
async def initialize(self):
|
||||
"""可选择实现异步的插件初始化方法,当实例化该插件类之后会自动调用该方法。"""
|
||||
|
||||
@filter.llm_tool("screenshot")
|
||||
async def screenshot(self, event: AstrMessageEvent):
|
||||
"""Capture the screen and return the image."""
|
||||
|
||||
|
||||
async def terminate(self):
|
||||
"""可选择实现异步的插件销毁方法,当插件被卸载/停用时会调用。"""
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "AstrBot"
|
||||
version = "3.5.6"
|
||||
version = "3.5.8"
|
||||
description = "易上手的多平台 LLM 聊天机器人及开发框架"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -30,6 +30,7 @@ dependencies = [
|
||||
"pip>=25.0.1",
|
||||
"psutil>=5.8.0",
|
||||
"pydantic~=2.10.3",
|
||||
"pydub>=0.25.1",
|
||||
"pyjwt>=2.10.1",
|
||||
"python-telegram-bot>=22.0",
|
||||
"qq-botpy>=1.2.1",
|
||||
|
||||
@@ -192,7 +192,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "astrbot"
|
||||
version = "3.4.39"
|
||||
version = "3.5.7"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocqhttp" },
|
||||
@@ -220,6 +220,7 @@ dependencies = [
|
||||
{ name = "pip" },
|
||||
{ name = "psutil" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydub" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "python-telegram-bot" },
|
||||
{ name = "qq-botpy" },
|
||||
@@ -257,6 +258,7 @@ requires-dist = [
|
||||
{ name = "pip", specifier = ">=25.0.1" },
|
||||
{ name = "psutil", specifier = ">=5.8.0" },
|
||||
{ name = "pydantic", specifier = "~=2.10.3" },
|
||||
{ name = "pydub", specifier = ">=0.25.1" },
|
||||
{ name = "pyjwt", specifier = ">=2.10.1" },
|
||||
{ name = "python-telegram-bot", specifier = ">=22.0" },
|
||||
{ name = "qq-botpy", specifier = ">=1.2.1" },
|
||||
@@ -1658,6 +1660,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydub"
|
||||
version = "0.25.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
|
||||
Reference in New Issue
Block a user