diff --git a/README.md b/README.md
index 6efd8463..a36e1b0f 100644
--- a/README.md
+++ b/README.md
@@ -19,7 +19,6 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_


-
English |
日本語 |
查看文档 |
@@ -28,11 +27,14 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用的插件系统和完善的大语言模型(LLM)接入功能的聊天机器人及开发框架。
-[](https://gitcode.com/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 ✨_
+此外,本项目的诞生离不开以下开源项目:
+
+- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ)
+- [wechatpy/wechatpy](https://github.com/wechatpy/wechatpy)
## ⭐ Star History
diff --git a/astrbot/core/__init__.py b/astrbot/core/__init__.py
index b87978fe..bce4073c 100644
--- a/astrbot/core/__init__.py
+++ b/astrbot/core/__init__.py
@@ -7,28 +7,28 @@ from astrbot.core.utils.pip_installer import PipInstaller
from astrbot.core.db.sqlite import SQLiteDatabase
from astrbot.core.config.default import DB_PATH
from astrbot.core.config import AstrBotConfig
+from astrbot.core.file_token_service import FileTokenService
from .utils.astrbot_path import get_astrbot_data_path
# 初始化数据存储文件夹
os.makedirs(get_astrbot_data_path(), exist_ok=True)
+WEBUI_SK = "Advanced_System_for_Text_Response_and_Bot_Operations_Tool"
+DEMO_MODE = os.getenv("DEMO_MODE", False)
+
astrbot_config = AstrBotConfig()
t2i_base_url = astrbot_config.get("t2i_endpoint", "https://t2i.soulter.top/text2img")
html_renderer = HtmlRenderer(t2i_base_url)
logger = LogManager.GetLogger(log_name="astrbot")
-
-if os.environ.get("TESTING", ""):
- logger.setLevel("DEBUG")
-
db_helper = SQLiteDatabase(DB_PATH)
-sp = (
- SharedPreferences()
-) # 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
+# 简单的偏好设置存储, 这里后续应该存储到数据库中, 一些部分可以存储到配置中
+sp = SharedPreferences()
+# 文件令牌服务
+file_token_service = FileTokenService()
pip_installer = PipInstaller(
astrbot_config.get("pip_install_arg", ""),
astrbot_config.get("pypi_index_url", None),
)
web_chat_queue = asyncio.Queue(maxsize=32)
web_chat_back_queue = asyncio.Queue(maxsize=32)
-WEBUI_SK = "Advanced_System_for_Text_Response_and_Bot_Operations_Tool"
-DEMO_MODE = os.getenv("DEMO_MODE", False)
+
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index 41677cc5..9d3c13cd 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -5,7 +5,7 @@
import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
-VERSION = "3.5.6"
+VERSION = "3.5.8"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db")
# 默认配置
@@ -107,6 +107,7 @@ DEFAULT_CONFIG = {
"knowledge_db": {},
"persona": [],
"timezone": "",
+ "callback_api_base": "",
}
@@ -154,6 +155,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",
@@ -162,6 +175,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,
@@ -196,6 +210,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",
@@ -240,7 +259,7 @@ CONFIG_METADATA_2 = {
"secret": {
"description": "secret",
"type": "string",
- "hint": "必填项。QQ 官方机器人平台的 secret。如何获取请参考文档。",
+ "hint": "必填项。",
},
"enable_group_c2c": {
"description": "启用消息列表单聊",
@@ -1268,6 +1287,12 @@ CONFIG_METADATA_2 = {
"obvious_hint": True,
"hint": "时区设置。请填写 IANA 时区名称, 如 Asia/Shanghai, 为空时使用系统默认时区。所有时区请查看: https://data.iana.org/time-zones/tzdb-2021a/zone1970.tab",
},
+ "callback_api_base": {
+ "description": "对外可达的回调接口地址",
+ "type": "string",
+ "obvious_hint": True,
+ "hint": "外部服务可能会通过 AstrBot 生成的回调链接(如文件下载链接)访问 AstrBot 后端。由于 AstrBot 无法自动判断部署环境中对外可达的主机地址(host),因此需要通过此配置项显式指定 “外部服务如何访问 AstrBot” 的地址。如 http://localhost:6185,https://example.com 等。"
+ },
"log_level": {
"description": "控制台日志级别",
"type": "string",
diff --git a/astrbot/core/file_token_service.py b/astrbot/core/file_token_service.py
new file mode 100644
index 00000000..2ed46d43
--- /dev/null
+++ b/astrbot/core/file_token_service.py
@@ -0,0 +1,68 @@
+import asyncio
+import os
+import uuid
+import time
+
+
+class FileTokenService:
+ """维护一个简单的基于令牌的文件下载服务,支持超时和懒清除。"""
+
+ def __init__(self, default_timeout: float = 300):
+ self.lock = asyncio.Lock()
+ self.staged_files = {} # token: (file_path, expire_time)
+ self.default_timeout = default_timeout
+
+ async def _cleanup_expired_tokens(self):
+ """清理过期的令牌"""
+ now = time.time()
+ expired_tokens = [token for token, (_, expire) in self.staged_files.items() if expire < now]
+ for token in expired_tokens:
+ self.staged_files.pop(token, None)
+
+ async def register_file(self, file_path: str, timeout: float = None) -> str:
+ """向令牌服务注册一个文件。
+
+ Args:
+ file_path(str): 文件路径
+ timeout(float): 超时时间,单位秒(可选)
+
+ Returns:
+ str: 一个单次令牌
+
+ Raises:
+ FileNotFoundError: 当路径不存在时抛出
+ """
+ async with self.lock:
+ await self._cleanup_expired_tokens()
+
+ if not os.path.exists(file_path):
+ raise FileNotFoundError(f"文件不存在: {file_path}")
+
+ file_token = str(uuid.uuid4())
+ expire_time = time.time() + (timeout if timeout is not None else self.default_timeout)
+ self.staged_files[file_token] = (file_path, expire_time)
+ return file_token
+
+ async def handle_file(self, file_token: str) -> str:
+ """根据令牌获取文件路径,使用后令牌失效。
+
+ Args:
+ file_token(str): 注册时返回的令牌
+
+ Returns:
+ str: 文件路径
+
+ Raises:
+ KeyError: 当令牌不存在或已过期时抛出
+ FileNotFoundError: 当文件本身已被删除时抛出
+ """
+ async with self.lock:
+ await self._cleanup_expired_tokens()
+
+ if file_token not in self.staged_files:
+ raise KeyError(f"无效或过期的文件 token: {file_token}")
+
+ file_path, _ = self.staged_files.pop(file_token)
+ if not os.path.exists(file_path):
+ raise FileNotFoundError(f"文件不存在: {file_path}")
+ return file_path
diff --git a/astrbot/core/message/components.py b/astrbot/core/message/components.py
index 55722c0e..48592b20 100644
--- a/astrbot/core/message/components.py
+++ b/astrbot/core/message/components.py
@@ -465,10 +465,10 @@ class Node(BaseMessageComponent):
type: ComponentType = "Node"
id: T.Optional[int] = 0 # 忽略
name: T.Optional[str] = "" # qq昵称
- uin: T.Optional[int] = 0 # qq号
+ uin: T.Optional[str] = "0" # qq号
content: T.Optional[T.Union[str, list, dict]] = "" # 子消息段列表
seq: T.Optional[T.Union[str, list]] = "" # 忽略
- time: T.Optional[int] = 0
+ time: T.Optional[int] = 0 # 忽略
def __init__(self, content: T.Union[str, list, dict, "Node", T.List["Node"]], **_):
if isinstance(content, list):
@@ -497,8 +497,14 @@ class Nodes(BaseMessageComponent):
super().__init__(nodes=nodes, **_)
def toDict(self):
- return {"messages": [node.toDict() for node in self.nodes]}
-
+ ret = {
+ "messages": [],
+ }
+ for node in self.nodes:
+ d = node.toDict()
+ d["data"]["uin"] = str(node.uin) # 转为字符串
+ ret["messages"].append(d)
+ return ret
class Xml(BaseMessageComponent):
type: ComponentType = "Xml"
@@ -562,12 +568,12 @@ class File(BaseMessageComponent):
type: ComponentType = "File"
name: T.Optional[str] = "" # 名字
- _file: T.Optional[str] = "" # 本地路径
+ file_: T.Optional[str] = "" # 本地路径
url: T.Optional[str] = "" # url
- _downloaded: bool = False # 是否已经下载
- def __init__(self, name: str = "", file: str = "", url: str = ""):
- super().__init__(name=name, _file=file, url=url)
+ def __init__(self, name: str, file: str = "", url: str = ""):
+ """文件消息段。"""
+ super().__init__(name=name, file_=file, url=url)
@property
def file(self) -> str:
@@ -577,23 +583,25 @@ class File(BaseMessageComponent):
Returns:
str: 文件路径
"""
- if self._file and os.path.exists(self._file):
- return self._file
+ if self.file_ and os.path.exists(self.file_):
+ return os.path.abspath(self.file_)
- if self.url and not self._downloaded:
+ if self.url:
try:
loop = asyncio.get_event_loop()
if loop.is_running():
- logger.warning(
- "不可以在异步上下文中同步等待下载! 请使用 await get_file() 代替"
- )
+ logger.warning((
+ "不可以在异步上下文中同步等待下载! "
+ "这个警告通常发生于某些逻辑试图通过 .file 获取文件消息段的文件内容。"
+ "请使用 await get_file() 代替直接获取 .file 字段"
+ ))
return ""
else:
# 等待下载完成
loop.run_until_complete(self._download_file())
- if self._file and os.path.exists(self._file):
- return self._file
+ if self.file_ and os.path.exists(self.file_):
+ return os.path.abspath(self.file_)
except Exception as e:
logger.error(f"文件下载失败: {e}")
@@ -610,38 +618,33 @@ class File(BaseMessageComponent):
if value.startswith("http://") or value.startswith("https://"):
self.url = value
else:
- self._file = value
+ self.file_ = value
- async def get_file(self) -> str:
- """
- 异步获取文件
- To 插件开发者: 请注意在使用后清理下载的文件, 以免占用过多空间
+ async def get_file(self, allow_return_url: bool=False) -> str:
+ """异步获取文件。请注意在使用后清理下载的文件, 以免占用过多空间
+ Args:
+ allow_return_url: 是否允许以文件 http 下载链接的形式返回,这允许您自行控制是否需要下载文件。
+ 注意,如果为 True,也可能返回文件路径。
Returns:
- str: 文件路径
+ str: 文件路径或者 http 下载链接
"""
- if self._file and os.path.exists(self._file):
- return self._file
+ if self.file_ and os.path.exists(self.file_):
+ return os.path.abspath(self.file_)
if self.url:
await self._download_file()
- return self._file
+ return os.path.abspath(self.file_)
return ""
async def _download_file(self):
"""下载文件"""
- if self._downloaded:
- return
-
- download_dir = os.path.join(get_astrbot_data_path(), "download")
+ download_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(download_dir, exist_ok=True)
file_path = os.path.join(download_dir, f"{uuid.uuid4().hex}")
-
await download_file(self.url, file_path)
-
- self._file = file_path
- self._downloaded = True
+ self.file_ = os.path.abspath(file_path)
class WechatEmoji(BaseMessageComponent):
diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py
index 776f4a62..bff94a64 100644
--- a/astrbot/core/pipeline/respond/stage.py
+++ b/astrbot/core/pipeline/respond/stage.py
@@ -26,33 +26,14 @@ class RespondStage(Stage):
Comp.Record: lambda comp: bool(comp.file), # 语音
Comp.Video: lambda comp: bool(comp.file), # 视频
Comp.At: lambda comp: bool(comp.qq) or bool(comp.name), # @
- Comp.AtAll: lambda comp: True, # @所有人
- Comp.RPS: lambda comp: True, # 不知道是啥(未完成)
- Comp.Dice: lambda comp: True, # 骰子(未完成)
- Comp.Shake: lambda comp: True, # 摇一摇(未完成)
- Comp.Anonymous: lambda comp: True, # 匿名(未完成)
- Comp.Share: lambda comp: bool(comp.url) and bool(comp.title), # 分享
- Comp.Contact: lambda comp: True, # 联系人(未完成)
- Comp.Location: lambda comp: bool(comp.lat and comp.lon), # 位置
- Comp.Music: lambda comp: bool(comp._type)
- and bool(comp.url)
- and bool(comp.audio), # 音乐
Comp.Image: lambda comp: bool(comp.file), # 图片
Comp.Reply: lambda comp: bool(comp.id) and comp.sender_id is not None, # 回复
- Comp.RedBag: lambda comp: bool(comp.title), # 红包
Comp.Poke: lambda comp: comp.id != 0 and comp.qq != 0, # 戳一戳
- Comp.Forward: lambda comp: bool(comp.id and comp.id.strip()), # 转发
Comp.Node: lambda comp: bool(comp.name)
and comp.uin != 0
and bool(comp.content), # 一个转发节点
Comp.Nodes: lambda comp: bool(comp.nodes), # 多个转发节点
- Comp.Xml: lambda comp: bool(comp.data and comp.data.strip()), # XML
- Comp.Json: lambda comp: bool(comp.data), # JSON
- Comp.CardImage: lambda comp: bool(comp.file), # 卡片图片
- Comp.TTS: lambda comp: bool(comp.text and comp.text.strip()), # 语音合成
- Comp.Unknown: lambda comp: bool(comp.text and comp.text.strip()), # 未知消息
- Comp.File: lambda comp: bool(comp.file), # 文件
- Comp.WechatEmoji: lambda comp: bool(comp.md5), # 微信表情
+ Comp.File: lambda comp: bool(comp.file_ or comp.url),
}
async def initialize(self, ctx: PipelineContext):
@@ -129,8 +110,6 @@ class RespondStage(Stage):
if comp_type in self._component_validators:
if self._component_validators[comp_type](comp):
return False
- else:
- logger.info(f"空内容检查: 无法识别的组件类型: {comp_type.__name__}")
# 如果所有组件都为空
return True
diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py
index 22a06b73..4ac57544 100644
--- a/astrbot/core/platform/manager.py
+++ b/astrbot/core/platform/manager.py
@@ -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库 中安装依赖库。"
diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py
index 4acb677d..068a8bf3 100644
--- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py
+++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py
@@ -3,8 +3,9 @@ import re
from typing import AsyncGenerator, Dict, List
from aiocqhttp import CQHttp
from astrbot.api.event import AstrMessageEvent, MessageChain
-from astrbot.api.message_components import At, Image, Node, Nodes, Plain, Record
+from astrbot.api.message_components import At, Image, Node, Nodes, Plain, Record, File
from astrbot.api.platform import Group, MessageMember
+from astrbot.core import file_token_service, astrbot_config, logger
class AiocqhttpMessageEvent(AstrMessageEvent):
@@ -34,24 +35,16 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
}
elif isinstance(segment, At):
d["data"] = {
- "qq": str(segment.qq) # 转换为字符串
+ "qq": str(segment.qq), # 转换为字符串
}
ret.append(d)
return ret
async def send(self, message: MessageChain):
- ret = await AiocqhttpMessageEvent._parse_onebot_json(message)
-
- if not ret:
- return
-
- send_one_by_one = False
- for seg in message.chain:
- if isinstance(seg, (Node, Nodes)):
- # 转发消息不能和普通消息混在一起发送
- send_one_by_one = True
- break
-
+ # 转发消息、文件消息不能和普通消息混在一起发送
+ send_one_by_one = any(
+ isinstance(seg, (Node, Nodes, File)) for seg in message.chain
+ )
if send_one_by_one:
for seg in message.chain:
if isinstance(seg, (Node, Nodes)):
@@ -70,6 +63,26 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
await self.bot.call_action(
"send_private_forward_msg", **payload
)
+ elif isinstance(seg, File):
+ d = seg.toDict()
+ url_or_path = await seg.get_file(allow_return_url=True)
+ if url_or_path.startswith("http"):
+ payload_file = url_or_path
+ elif callback_host := astrbot_config.get("callback_api_base"):
+ callback_host = str(callback_host).removesuffix("/")
+ token = await file_token_service.register_file(url_or_path)
+ payload_file = f"{callback_host}/api/file/{token}"
+ logger.debug(f"Generated file callback link: {payload_file}")
+ else:
+ payload_file = url_or_path
+ d["data"] = {
+ "name": seg.name,
+ "file": payload_file,
+ }
+ await self.bot.send(
+ self.message_obj.raw_message,
+ [d],
+ )
else:
await self.bot.send(
self.message_obj.raw_message,
@@ -79,6 +92,9 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
)
await asyncio.sleep(0.5)
else:
+ ret = await AiocqhttpMessageEvent._parse_onebot_json(message)
+ if not ret:
+ return
await self.bot.send(self.message_obj.raw_message, ret)
await super().send(message)
diff --git a/astrbot/core/platform/sources/wecom/wecom_adapter.py b/astrbot/core/platform/sources/wecom/wecom_adapter.py
index d04a7b74..1e71838b 100644
--- a/astrbot/core/platform/sources/wecom/wecom_adapter.py
+++ b/astrbot/core/platform/sources/wecom/wecom_adapter.py
@@ -3,6 +3,7 @@ import sys
import uuid
import asyncio
import quart
+import aiohttp
from astrbot.api.platform import (
Platform,
@@ -21,11 +22,15 @@ 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 astrbot.core.utils.astrbot_path import get_astrbot_data_path
+from .wecom_kf import WeChatKF
+from .wecom_kf_message import WeChatKFMessage
+
if sys.version_info >= (3, 12):
from typing import override
else:
@@ -133,9 +138,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
@@ -155,9 +191,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)
@@ -221,10 +287,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,
diff --git a/astrbot/core/platform/sources/wecom/wecom_event.py b/astrbot/core/platform/sources/wecom/wecom_event.py
index fb820d6e..1c1c09c9 100644
--- a/astrbot/core/platform/sources/wecom/wecom_event.py
+++ b/astrbot/core/platform/sources/wecom/wecom_event.py
@@ -5,6 +5,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
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@@ -85,58 +86,98 @@ 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
- temp_dir = os.path.join(get_astrbot_data_path(), "temp")
- record_path_amr = os.path.join(temp_dir, f"{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
+ temp_dir = os.path.join(get_astrbot_data_path(), "temp")
+ record_path_amr = os.path.join(temp_dir, f"{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):
diff --git a/astrbot/core/platform/sources/wecom/wecom_kf.py b/astrbot/core/platform/sources/wecom/wecom_kf.py
new file mode 100644
index 00000000..316f6da3
--- /dev/null
+++ b/astrbot/core/platform/sources/wecom/wecom_kf.py
@@ -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)
diff --git a/astrbot/core/platform/sources/wecom/wecom_kf_message.py b/astrbot/core/platform/sources/wecom/wecom_kf_message.py
new file mode 100644
index 00000000..493d0405
--- /dev/null
+++ b/astrbot/core/platform/sources/wecom/wecom_kf_message.py
@@ -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},
+ },
+ )
diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py
new file mode 100644
index 00000000..d7463d4d
--- /dev/null
+++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_adapter.py
@@ -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("微信公众平台 适配器已被优雅地关闭")
diff --git a/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py
new file mode 100644
index 00000000..9519cd49
--- /dev/null
+++ b/astrbot/core/platform/sources/weixin_official_account/weixin_offacc_event.py
@@ -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)
diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py
index bf234953..fb47143d 100644
--- a/astrbot/core/provider/sources/gemini_source.py
+++ b/astrbot/core/provider/sources/gemini_source.py
@@ -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):
"""
组装上下文。
"""
diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py
index 5399fbc3..f25ee3fc 100644
--- a/astrbot/core/provider/sources/openai_source.py
+++ b/astrbot/core/provider/sources/openai_source.py
@@ -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,
diff --git a/astrbot/dashboard/routes/__init__.py b/astrbot/dashboard/routes/__init__.py
index 3e24583e..f9309c3e 100644
--- a/astrbot/dashboard/routes/__init__.py
+++ b/astrbot/dashboard/routes/__init__.py
@@ -8,6 +8,7 @@ from .static_file import StaticFileRoute
from .chat import ChatRoute
from .tools import ToolsRoute # 导入新的ToolsRoute
from .conversation import ConversationRoute
+from .file import FileRoute
__all__ = [
@@ -19,6 +20,7 @@ __all__ = [
"LogRoute",
"StaticFileRoute",
"ChatRoute",
- "ToolsRoute", # 添加新的ToolsRoute
+ "ToolsRoute",
"ConversationRoute",
+ "FileRoute",
]
diff --git a/astrbot/dashboard/routes/file.py b/astrbot/dashboard/routes/file.py
new file mode 100644
index 00000000..8ea73d08
--- /dev/null
+++ b/astrbot/dashboard/routes/file.py
@@ -0,0 +1,24 @@
+from .route import Route, RouteContext
+from astrbot import logger
+from quart import abort, send_file
+from astrbot.core import file_token_service
+
+
+class FileRoute(Route):
+ def __init__(
+ self,
+ context: RouteContext,
+ ) -> None:
+ super().__init__(context)
+ self.routes = {
+ "/file/": ("GET", self.serve_file),
+ }
+ self.register_routes()
+
+ async def serve_file(self, file_token: str):
+ try:
+ file_path = await file_token_service.handle_file(file_token)
+ return await send_file(file_path)
+ except (FileNotFoundError, KeyError) as e:
+ logger.warning(str(e))
+ return abort(404)
diff --git a/astrbot/dashboard/routes/static_file.py b/astrbot/dashboard/routes/static_file.py
index 4503a28e..729fe854 100644
--- a/astrbot/dashboard/routes/static_file.py
+++ b/astrbot/dashboard/routes/static_file.py
@@ -28,7 +28,7 @@ class StaticFileRoute(Route):
@self.app.errorhandler(404)
async def page_not_found(e):
- return "404 Not found。如果你初次使用打开面板发现 404, 请参考文档: https://astrbot.app/faq.html。"
+ return "404 Not found。如果你初次使用打开面板发现 404, 请参考文档: https://astrbot.app/faq.html。如果你正在测试回调地址可达性,显示这段文字说明测试成功了。"
async def index(self):
return await self.app.send_static_file("index.html")
diff --git a/astrbot/dashboard/server.py b/astrbot/dashboard/server.py
index 5f0de72e..e4e4d60b 100644
--- a/astrbot/dashboard/server.py
+++ b/astrbot/dashboard/server.py
@@ -52,15 +52,15 @@ class AstrBotDashboard:
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.tools_root = ToolsRoute(self.context, core_lifecycle)
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
+ self.file_route = FileRoute(self.context)
self.shutdown_event = shutdown_event
async def auth_middleware(self):
if not request.path.startswith("/api"):
return
- if request.path == "/api/auth/login":
- return
- if request.path == "/api/chat/get_file":
+ allowed_endpoints = ["/api/auth/login", "/api/chat/get_file", "/api/file"]
+ if any(request.path.startswith(prefix) for prefix in allowed_endpoints):
return
# claim jwt
token = request.headers.get("Authorization")
diff --git a/changelogs/v3.5.7.md b/changelogs/v3.5.7.md
new file mode 100644
index 00000000..f8097dfa
--- /dev/null
+++ b/changelogs/v3.5.7.md
@@ -0,0 +1,5 @@
+# What's Changed
+
+> Gewechat 已经停止维护,此版本提供了 `微信客服` 的接入方式,可以在直接微信内聊天。这是微信官方推出的接入方式,因此没有风控风险。详见 [AstrBot 接入企业微信](https://astrbot.app/deploy/platform/wecom.html)。此接入方式处于测试阶段,有问题请及时在 GitHub 上提交 Issue。
+
+1. 支持接入微信客服。
\ No newline at end of file
diff --git a/changelogs/v3.5.8.md b/changelogs/v3.5.8.md
new file mode 100644
index 00000000..bbab9fee
--- /dev/null
+++ b/changelogs/v3.5.8.md
@@ -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
\ No newline at end of file
diff --git a/packages/astrbot/main.py b/packages/astrbot/main.py
index 8f8e1fa1..613922ef 100644
--- a/packages/astrbot/main.py
+++ b/packages/astrbot/main.py
@@ -1019,6 +1019,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"]
diff --git a/packages/vpet/main.py b/packages/vpet/main.py
new file mode 100644
index 00000000..6623cd6f
--- /dev/null
+++ b/packages/vpet/main.py
@@ -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):
+ """可选择实现异步的插件销毁方法,当插件被卸载/停用时会调用。"""
diff --git a/pyproject.toml b/pyproject.toml
index 57e9b54a..e514fb0e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
-version = "3.5.6"
+version = "3.5.8"
description = "易上手的多平台 LLM 聊天机器人及开发框架"
readme = "README.md"
requires-python = ">=3.10"
@@ -31,6 +31,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",
diff --git a/uv.lock b/uv.lock
index 178ff4f9..815d2ac6 100644
--- a/uv.lock
+++ b/uv.lock
@@ -192,7 +192,7 @@ wheels = [
[[package]]
name = "astrbot"
-version = "3.5.6"
+version = "3.5.7"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -221,6 +221,7 @@ dependencies = [
{ name = "pip" },
{ name = "psutil" },
{ name = "pydantic" },
+ { name = "pydub" },
{ name = "pyjwt" },
{ name = "python-telegram-bot" },
{ name = "qq-botpy" },
@@ -260,6 +261,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" },
@@ -1671,6 +1673,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"