Compare commits

...

12 Commits

Author SHA1 Message Date
Soulter 6837d4d692 chore: update version 2025-02-11 02:05:06 +08:00
Soulter 8aba83735b Update README.md 2025-02-11 01:31:31 +08:00
Soulter aa51187747 perf(core): change log level to debug for platform and provider adapter instantiation 2025-02-11 01:25:52 +08:00
Soulter 5f07a9ae95 perf(core): better handle in loading platforms 2025-02-11 01:23:50 +08:00
Soulter a2ca767bf4 v3.4.25 2025-02-11 01:12:23 +08:00
Soulter 5806c74e7c chore(core): display the unsupported message segments 2025-02-11 01:10:17 +08:00
Soulter 0481e1d45e fix(core): github mirror not applied successfully 2025-02-11 01:10:17 +08:00
Soulter 3177b61421 feat(platform): support lark platform 2025-02-11 01:07:14 +08:00
Soulter 6009cf5dfa feat: 添加 moonshot 配置模板 #446 2025-02-10 18:54:59 +08:00
Soulter 0a970e8c31 feat: 支持gewechat文件输出 2025-02-10 18:46:54 +08:00
Soulter aa276ca6af fix: 修复gewechat无法at人和发语音失败的问题 #447 #438 2025-02-10 18:11:22 +08:00
Soulter 9f02dd13ff fix: 修复qq在@和回复开启的情况下转发消息异常的问题 2025-02-10 13:07:09 +08:00
25 changed files with 488 additions and 258 deletions
+2 -1
View File
@@ -21,4 +21,5 @@ node_modules/
.DS_Store
package-lock.json
package.json
venv/*
venv/*
packages/python_interpreter/workplace
+2 -2
View File
@@ -27,7 +27,7 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
## ✨ 主要功能
1. **大语言模型对话**。支持各种大语言模型,包括 OpenAI API、Google Gemini、Llama、Deepseek、ChatGLM 等,支持接入本地部署的大模型,通过 Ollama、LLMTuner。具有多轮对话、人格情境、多模态能力,支持图片理解、语音转文字(Whisper)。
2. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat、VChat)、Telegram。后续将支持钉钉、飞书、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核。
2. **多消息平台接入**。支持接入 QQ(OneBot)、QQ 频道、微信(Gewechat、飞书、Telegram。后续将支持钉钉、Discord、WhatsApp、小爱音响。支持速率限制、白名单、关键词过滤、百度内容审核。
3. **Agent**。原生支持部分 Agent 能力,如代码执行器、自然语言待办、网页搜索。对接 [Dify 平台](https://astrbot.app/others/dify.html),便捷接入 Dify 智能助手、知识库和 Dify 工作流。
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,极简开发。已支持安装多个插件。
5. **可视化管理面板**。支持可视化修改配置、插件管理、日志查看等功能,降低配置难度。集成 WebChat,可在面板上与大模型对话。
@@ -72,8 +72,8 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
| 微信(个人号) | ✔ | 微信个人号私聊、群聊 | 文字、图片、语音 |
| [Telegram](https://github.com/Soulter/astrbot_plugin_telegram) | ✔ | 私聊、群聊 | 文字、图片 |
| [微信(企业微信)](https://github.com/Soulter/astrbot_plugin_wecom) | ✔ | 私聊 | 文字、图片、语音 |
| 飞书 | ✔ | 群聊 | 文字、图片 |
| 微信对话开放平台 | 🚧 | 计划内 | - |
| 飞书 | 🚧 | 计划内 | - |
| Discord | 🚧 | 计划内 | - |
| WhatsApp | 🚧 | 计划内 | - |
| 小爱音响 | 🚧 | 计划内 | - |
+31 -4
View File
@@ -2,7 +2,7 @@
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
"""
VERSION = "3.4.24"
VERSION = "3.4.25"
DB_PATH = "data/data_v3.db"
# 默认配置
@@ -125,12 +125,21 @@ CONFIG_METADATA_2 = {
"host": "这里填写你的局域网IP或者公网服务器IP",
"port": 11451,
},
"lark(飞书)": {
"id": "lark",
"type": "lark",
"enable": False,
"lark_bot_name": "",
"app_id": "",
"app_secret": "",
"domain": "https://open.feishu.cn"
},
},
"items": {
"id": {
"description": "ID",
"type": "string",
"hint": "提供商 ID 名,用于在多实例下方便管理和识别。自定义,ID 不能重复。",
"hint": "用于在多实例下方便管理和识别。自定义,ID 不能重复。",
},
"type": {
"description": "适配器类型",
@@ -172,6 +181,12 @@ CONFIG_METADATA_2 = {
"type": "int",
"hint": "aiocqhttp 适配器的反向 Websocket 端口。",
},
"lark_bot_name": {
"description": "飞书机器人的名字",
"type": "string",
"hint": "请务必填对,否则 @ 机器人将无法唤醒,只能通过前缀唤醒。",
"obvious_hint": True
}
},
},
"platform_settings": {
@@ -406,7 +421,7 @@ CONFIG_METADATA_2 = {
"model": "glm-4-flash",
},
},
"硅基流动": {
"siliconflow": {
"id": "siliconflow",
"type": "openai_chat_completion",
"enable": True,
@@ -417,6 +432,17 @@ CONFIG_METADATA_2 = {
"model": "deepseek-ai/DeepSeek-V3",
},
},
"moonshot(kimi)": {
"id": "moonshot",
"type": "openai_chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://api.moonshot.cn/v1",
"model_config": {
"model": "moonshot-v1-8k",
},
},
"llmtuner": {
"id": "llmtuner_default",
"type": "llm_tuner",
@@ -829,7 +855,8 @@ CONFIG_METADATA_2 = {
"plugin_repo_mirror": {
"description": "插件仓库镜像",
"type": "string",
"hint": "插件仓库的镜像地址,用于加速插件的下载。",
"hint": "已废弃,请使用管理面板->设置页的代理地址选择",
"obvious_hint": True,
"options": [
"default",
"https://ghp.ci/",
+7 -7
View File
@@ -341,14 +341,14 @@ class Forward(BaseMessageComponent):
def __init__(self, **_):
super().__init__(**_)
class Node(BaseMessageComponent): # 该 component 仅支持使用 sendGroupForwardMessage 发送
class Node(BaseMessageComponent):
'''群合并转发消息'''
type: ComponentType = "Node"
id: T.Optional[int] = 0
name: T.Optional[str] = ""
uin: T.Optional[int] = 0
content: T.Optional[T.Union[str, list]] = ""
seq: T.Optional[T.Union[str, list]] = "" # 不清楚是什么
id: T.Optional[int] = 0 # 忽略
name: T.Optional[str] = "" # qq昵称
uin: T.Optional[int] = 0 # qq号
content: T.Optional[T.Union[str, list]] = "" # 子消息段列表
seq: T.Optional[T.Union[str, list]] = "" # 忽略
time: T.Optional[int] = 0
def __init__(self, content: T.Union[str, list], **_):
+18 -17
View File
@@ -15,22 +15,23 @@ class PlatformManager():
self.settings = config['platform_settings']
self.event_queue = event_queue
for platform in self.platforms_config:
if not platform['enable']:
continue
match platform['type']:
case "aiocqhttp":
from .sources.aiocqhttp.aiocqhttp_platform_adapter import AiocqhttpAdapter # noqa: F401
case "qq_official":
from .sources.qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter # noqa: F401
case "vchat":
try:
from .sources.vchat.vchat_platform_adapter import VChatPlatformAdapter # noqa: F401
except BaseException:
logger.warning("当前 astrbot 已不维护 vchat 的接入,如有需要请 pip 安装 vchat 然后重启")
case "gewechat":
from .sources.gewechat.gewechat_platform_adapter import GewechatPlatformAdapter # noqa: F401
try:
for platform in self.platforms_config:
if not platform['enable']:
continue
match platform['type']:
case "aiocqhttp":
from .sources.aiocqhttp.aiocqhttp_platform_adapter import AiocqhttpAdapter # noqa: F401
case "qq_official":
from .sources.qqofficial.qqofficial_platform_adapter import QQOfficialPlatformAdapter # noqa: F401
case "gewechat":
from .sources.gewechat.gewechat_platform_adapter import GewechatPlatformAdapter # noqa: F401
case "lark":
from .sources.lark.lark_adapter import LarkPlatformAdapter # noqa: F401
except (ImportError, ModuleNotFoundError) as e:
logger.error(f"加载平台适配器 {platform['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。")
except Exception as e:
logger.error(f"加载平台适配器 {platform['type']} 失败,原因:{e}")
async def initialize(self):
for platform in self.platforms_config:
@@ -40,7 +41,7 @@ class PlatformManager():
logger.error(f"未找到适用于 {platform['type']}({platform['id']}) 平台适配器,请检查是否已经安装或者名称填写错误。已跳过。")
continue
cls_type = platform_cls_map[platform['type']]
logger.info(f"尝试实例化 {platform['type']}({platform['id']}) 平台适配器 ...")
logger.debug(f"尝试实例化 {platform['type']}({platform['id']}) 平台适配器 ...")
inst = cls_type(platform, self.settings, self.event_queue)
self.platform_insts.append(inst)
@@ -1,7 +1,7 @@
import os
import asyncio
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import Plain, Image, Record, At
from astrbot.api.message_components import Plain, Image, Record, At, Node, Music, Video
from aiocqhttp import CQHttp
from astrbot.core.utils.io import file_to_base64, download_image_by_url
@@ -18,7 +18,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
d = segment.toDict()
if isinstance(segment, Plain):
d['type'] = 'text'
if isinstance(segment, (Image, Record)):
elif isinstance(segment, (Image, Record)):
# convert to base64
if segment.file and segment.file.startswith("file:///"):
bs64_data = file_to_base64(segment.file[8:])
@@ -26,12 +26,14 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
elif segment.file and segment.file.startswith("http"):
image_file_path = await download_image_by_url(segment.file)
bs64_data = file_to_base64(image_file_path)
elif segment.file and segment.file.startswith("base64://"):
bs64_data = segment.file
else:
bs64_data = file_to_base64(segment.file)
d['data'] = {
'file': bs64_data,
}
if isinstance(segment, At):
elif isinstance(segment, At):
d['data'] = {
'qq': str(segment.qq) # 转换为字符串
}
@@ -40,7 +42,19 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
async def send(self, message: MessageChain):
ret = await AiocqhttpMessageEvent._parse_onebot_json(message)
if os.environ.get('TEST_MODE', 'off') == 'on':
return
await self.bot.send(self.message_obj.raw_message, ret)
send_one_by_one = False
for seg in message.chain:
if isinstance(seg, (Node, Music)):
# 转发消息不能和普通消息混在一起发送
send_one_by_one = True
break
if send_one_by_one:
for seg in message.chain:
await self.bot.send(self.message_obj.raw_message, await AiocqhttpMessageEvent._parse_onebot_json(MessageChain([seg])))
await asyncio.sleep(0.5)
else:
await self.bot.send(self.message_obj.raw_message, ret)
await super().send(message)
@@ -153,12 +153,11 @@ class SimpleGewechatClient():
with open(file_path, "wb") as f:
f.write(voice_data)
abm.message.append(Record(file=file_path, url=file_path))
case _:
logger.info(f"未实现的消息类型: {d['MsgType']}")
return
logger.info(f"abm: {abm}")
logger.debug(f"abm: {abm}")
return abm
async def callback(self):
@@ -312,12 +311,14 @@ class SimpleGewechatClient():
self.appid = appid
logger.info(f"已保存 APPID: {appid}")
async def post_text(self, to_wxid, content: str):
async def post_text(self, to_wxid, content: str, ats: str = ""):
payload = {
"appId": self.appid,
"toWxid": to_wxid,
"content": content,
}
if ats:
payload['ats'] = ats
async with aiohttp.ClientSession() as session:
async with session.post(
@@ -361,4 +362,21 @@ class SimpleGewechatClient():
json=payload
) as resp:
json_blob = await resp.json()
logger.debug(f"发送语音结果: {json_blob}")
logger.debug(f"发送语音结果: {json_blob}")
async def post_file(self, to_wxid, file_url: str, file_name: str):
payload = {
"appId": self.appid,
"toWxid": to_wxid,
"fileUrl": file_url,
"fileName": file_name
}
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/message/postFile",
headers=self.headers,
json=payload
) as resp:
json_blob = await resp.json()
logger.debug(f"发送文件结果: {json_blob}")
@@ -6,7 +6,7 @@ from astrbot.core.utils.tencent_record_helper import wav_to_tencent_silk
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image, Record
from astrbot.api.message_components import Plain, Image, Record, At, File
from .client import SimpleGewechatClient
def get_wav_duration(file_path):
@@ -15,6 +15,8 @@ def get_wav_duration(file_path):
n_channels, sampwidth, framerate, n_frames = wav_file.getparams()[:4]
if n_frames == 2147483647:
duration = (file_size - 44) / (n_channels * sampwidth * framerate)
elif n_frames == 0:
duration = (file_size - 44) / (n_channels * sampwidth * framerate)
else:
duration = n_frames / float(framerate)
return duration
@@ -43,9 +45,31 @@ class GewechatPlatformEvent(AstrMessageEvent):
logger.error("无法获取到 to_wxid。")
return
# 检查@
ats = []
ats_names = []
for comp in message.chain:
if isinstance(comp, At):
ats.append(comp.qq)
ats_names.append(comp.name)
has_at = False
for comp in message.chain:
if isinstance(comp, Plain):
await self.client.post_text(to_wxid, comp.text)
text = comp.text
payload = {
"to_wxid": to_wxid,
"content": text,
}
if not has_at and ats:
ats = f"{','.join(ats)}"
ats_names = f"@{' @'.join(ats_names)}"
text = f"{ats_names} {text}"
payload["content"] = text
payload["ats"] = ats
has_at = True
await self.client.post_text(**payload)
elif isinstance(comp, Image):
img_url = comp.file
img_path = ""
@@ -81,22 +105,28 @@ class GewechatPlatformEvent(AstrMessageEvent):
silk_path = f"data/temp/{uuid.uuid4()}.silk"
duration = await wav_to_tencent_silk(record_path, silk_path)
print(f"duration: {duration}, {silk_path}")
# 检查 record_path 是否在 data/temp 目录中, record_path 可能是绝对路径
# temp_directory = os.path.abspath('data/temp')
# record_path = os.path.abspath(record_path)
# if os.path.commonpath([temp_directory, record_path]) != temp_directory:
# with open(record_path, "rb") as f:
# record_path = f"data/temp/{uuid.uuid4()}.wav"
# with open(record_path, "wb") as f2:
# f2.write(f.read())
logger.info("Silk 语音文件格式转换至: " + record_path)
if duration == 0:
duration = get_wav_duration(record_path)
file_id = os.path.basename(silk_path)
record_url = f"{self.client.file_server_url}/{file_id}"
logger.debug(f"gewe callback record url: {record_url}")
await self.client.post_voice(to_wxid, record_url, duration*1000)
elif isinstance(comp, File):
file_path = comp.file
file_name = comp.name
if file_path.startswith("file:///"):
file_path = file_path[8:]
elif file_path.startswith("http"):
await download_file(file_path, f"data/temp/{file_name}")
else:
file_path = file_path
file_id = os.path.basename(file_path)
file_url = f"{self.client.file_server_url}/{file_id}"
logger.debug(f"gewe callback file url: {file_url}")
await self.client.post_file(to_wxid, file_url, file_id)
else:
logger.error(f"gewechat 暂不支持发送消息类型: {comp.type}")
await super().send(message)
@@ -0,0 +1,175 @@
import base64
import time
import asyncio
import json
import re
from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, MessageType, PlatformMetadata
from astrbot.api.event import MessageChain
from typing import Union, List
from astrbot.api.message_components import Image, Plain, At
from astrbot.core.platform.astr_message_event import MessageSesion
from .lark_event import LarkMessageEvent
from ...register import register_platform_adapter
from astrbot.core.message.components import BaseMessageComponent
from astrbot import logger
import lark_oapi as lark
from lark_oapi.api.im.v1 import *
@register_platform_adapter("lark", "飞书机器人官方 API 适配器")
class LarkPlatformAdapter(Platform):
def __init__(self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue) -> None:
super().__init__(event_queue)
self.config = platform_config
self.unique_session = platform_settings['unique_session']
self.appid = platform_config['app_id']
self.appsecret = platform_config['app_secret']
self.domain = platform_config.get('domain', lark.FEISHU_DOMAIN)
self.bot_name = platform_config.get('lark_bot_name', "astrbot")
if not self.bot_name:
logger.warning("未设置飞书机器人名称,@ 机器人可能得不到回复。")
async def on_msg_event_recv(event: lark.im.v1.P2ImMessageReceiveV1):
await self.convert_msg(event)
def do_v2_msg_event(event: lark.im.v1.P2ImMessageReceiveV1):
asyncio.create_task(on_msg_event_recv(event))
self.event_handler = lark.EventDispatcherHandler.builder("", "") \
.register_p2_im_message_receive_v1(do_v2_msg_event) \
.build()
self.client = lark.ws.Client(
app_id=self.appid,
app_secret=self.appsecret,
log_level=lark.LogLevel.ERROR,
domain=self.domain,
event_handler=self.event_handler
)
self.lark_api = (
lark.Client.builder()
.app_id(self.appid)
.app_secret(self.appsecret)
.build()
)
async def send_by_session(self, session: MessageSesion, message_chain: MessageChain):
raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
"lark",
"飞书机器人官方 API 适配器",
)
async def convert_msg(self, event: lark.im.v1.P2ImMessageReceiveV1):
message = event.event.message
abm = AstrBotMessage()
abm.timestamp = int(message.create_time) / 1000
abm.message = []
abm.type = MessageType.GROUP_MESSAGE if message.chat_type == 'group' else MessageType.FRIEND_MESSAGE
if message.chat_type == 'group':
abm.group_id = message.chat_id
abm.self_id = self.bot_name
abm.message_str = ""
at_list = {}
if message.mentions:
for m in message.mentions:
at_list[m.key] = At(qq=m.id.open_id, name=m.name)
if m.name == self.bot_name:
abm.self_id = m.id.open_id
content_json_b = json.loads(message.content)
if message.message_type == 'text':
message_str_raw = content_json_b['text'] # 带有 @ 的消息
at_pattern = r"(@_user_\d+)" # 可以根据需求修改正则
at_users = re.findall(at_pattern, message_str_raw)
# 拆分文本,去掉AT符号部分
parts = re.split(at_pattern, message_str_raw)
for i in range(len(parts)):
s = parts[i].strip()
if not s:
continue
if s in at_list:
abm.message.append(at_list[s])
else:
abm.message.append(Plain(parts[i].strip()))
elif message.message_type == 'post':
_ls = []
content_ls = content_json_b.get('content', [])
for comp in content_ls:
if isinstance(comp, list):
_ls.extend(comp)
elif isinstance(comp, dict):
_ls.append(comp)
content_json_b = _ls
elif message.message_type == 'image':
content_json_b = [
{"tag": "img", "image_key": content_json_b["image_key"], "style": []}
]
if message.message_type in ('post', 'image'):
for comp in content_json_b:
if comp['tag'] == 'at':
abm.message.append(at_list[comp['user_id']])
elif comp['tag'] == 'text' and comp['text'].strip():
abm.message.append(Plain(comp['text'].strip()))
elif comp['tag'] == 'img':
image_key = comp['image_key']
request = GetMessageResourceRequest.builder() \
.message_id(message.message_id) \
.file_key(image_key) \
.type("image") \
.build()
response = await self.lark_api.im.v1.message_resource.aget(request)
if not response.success():
logger.error(f"无法下载飞书图片: {image_key}")
image_bytes = response.file.read()
image_base64 = base64.b64encode(image_bytes).decode()
abm.message.append(Image.fromBase64(image_base64))
for comp in abm.message:
if isinstance(comp, Plain):
abm.message_str += comp.text
abm.message_id = message.message_id
abm.raw_message = message
abm.sender = MessageMember(
user_id=event.event.sender.sender_id.open_id,
nickname=event.event.sender.sender_id.open_id[:8]
)
# 独立会话
if not self.unique_session:
if abm.type == MessageType.GROUP_MESSAGE:
abm.session_id = abm.group_id
else:
abm.session_id = abm.sender.user_id
else:
abm.session_id = abm.sender.user_id
logger.debug(abm)
await self.handle_msg(abm)
async def handle_msg(self, abm: AstrBotMessage):
event = LarkMessageEvent(
message_str=abm.message_str,
message_obj=abm,
platform_meta=self.meta(),
session_id=abm.session_id,
bot=self.lark_api
)
self._event_queue.put_nowait(event)
async def run(self):
# self.client.start()
await self.client._connect()
@@ -0,0 +1,96 @@
import json
import uuid
import lark_oapi as lark
from typing import List
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import Plain, Image as AstrBotImage, Record, At, Node, Music, Video
from astrbot.core.utils.io import file_to_base64, download_image_by_url
from lark_oapi.api.im.v1 import *
from astrbot import logger
class LarkMessageEvent(AstrMessageEvent):
def __init__(self, message_str, message_obj, platform_meta, session_id, bot: lark.Client):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.bot = bot
@staticmethod
async def _convert_to_lark(message: MessageChain, lark_client: lark.Client) -> List:
ret = []
_stage = []
for comp in message.chain:
if isinstance(comp, Plain):
_stage.append({
"tag": "md",
"text": comp.text
})
elif isinstance(comp, At):
_stage.append({
"tag": "at",
"user_id": comp.qq,
"style": []
})
elif isinstance(comp, AstrBotImage):
file_path = ""
if comp.file and comp.file.startswith("file:///"):
file_path = comp.file.replace('file:///', '')
elif comp.file and comp.file.startswith("http"):
image_file_path = await download_image_by_url(comp.file)
file_path = image_file_path
elif comp.file and comp.file.startswith("base64://"):
pass
else:
file_path = comp.file
request = CreateImageRequest.builder() \
.request_body( \
CreateImageRequestBody.builder() \
.image_type("message") \
.image(open(file_path, 'rb')) \
.build() \
) \
.build()
response = await lark_client.im.v1.image.acreate(request)
if not response.success():
logger.error(f"无法上传飞书图片({response.code}): {response.msg}")
image_key = response.data.image_key
print(image_key)
ret.append(_stage)
ret.append([{
"tag": "img",
"image_key": image_key
}])
_stage.clear()
else:
logger.warning(f"飞书 暂时不支持消息段: {comp.type}")
if _stage:
ret.append(_stage)
return ret
async def send(self, message: MessageChain):
res = await LarkMessageEvent._convert_to_lark(message, self.bot)
wrapped = {
"zh_cn": {
"title": "",
"content": res,
}
}
request = ReplyMessageRequest.builder() \
.message_id(self.message_obj.message_id) \
.request_body( \
ReplyMessageRequestBody.builder() \
.content(json.dumps(wrapped)) \
.msg_type("post") \
.uuid(str(uuid.uuid4())) \
.reply_in_thread(False) \
.build() \
) \
.build()
response = await self.bot.im.v1.message.areply(request)
if not response.success():
logger.error(f"回复飞书消息失败({response.code}): {response.msg}")
await super().send(message)
@@ -8,6 +8,7 @@ from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image, Reply
from botpy import Client
from botpy.http import Route
from astrbot.api import logger
class QQOfficialMessageEvent(AstrMessageEvent):
@@ -114,4 +115,6 @@ class QQOfficialMessageEvent(AstrMessageEvent):
else:
image_base64 = file_to_base64(i.file).replace("base64://", "")
image_file_path = i.file
else:
logger.error(f"qq_official 暂不支持发送消息类型 {i.type}")
return plain_text, image_base64, image_file_path
@@ -1,44 +0,0 @@
import random
import asyncio
from astrbot.core.utils.io import download_image_by_url
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
from astrbot.api.message_components import Plain, Image
from vchat import Core
class VChatPlatformEvent(AstrMessageEvent):
def __init__(self, message_str: str, message_obj: AstrBotMessage, platform_meta: PlatformMetadata, session_id: str, client: Core):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
@staticmethod
async def send_with_client(client: Core, message: MessageChain, user_name: str):
plain = ""
for comp in message.chain:
if isinstance(comp, Plain):
if message.is_split_:
await client.send_msg(comp.text, user_name)
else:
plain += comp.text
elif isinstance(comp, Image):
if comp.file and comp.file.startswith("file:///"):
file_path = comp.file.replace("file:///", "")
with open(file_path, "rb") as f:
await client.send_image(user_name, fd=f)
elif comp.file and comp.file.startswith("http"):
image_path = await download_image_by_url(comp.file)
with open(image_path, "rb") as f:
await client.send_image(user_name, fd=f)
else:
logger.error(f"不支持的 vchat(微信适配器) 消息类型: {comp}")
await asyncio.sleep(random.uniform(0.5, 1.5)) # 🤓
if plain:
await client.send_msg(plain, user_name)
async def send(self, message: MessageChain):
await VChatPlatformEvent.send_with_client(self.client, message, self.message_obj.raw_message.from_.username)
await super().send(message)
@@ -1,120 +0,0 @@
import sys
import time
import uuid
import asyncio
import os
from astrbot.api.platform import Platform, AstrBotMessage, MessageMember, MessageType, PlatformMetadata
from astrbot.api.event import MessageChain
from astrbot.api.message_components import *
from astrbot.api import logger
from astrbot.core.platform.astr_message_event import MessageSesion
from .vchat_message_event import VChatPlatformEvent
from ...register import register_platform_adapter
from vchat import Core
from vchat import model
if sys.version_info >= (3, 12):
from typing import override
else:
from typing_extensions import override
@register_platform_adapter("vchat", "基于 VChat 的 Wechat 适配器")
class VChatPlatformAdapter(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.test_mode = os.environ.get('TEST_MODE', 'off') == 'on'
self.client_self_id = uuid.uuid4().hex[:8]
@override
async def send_by_session(self, session: MessageSesion, message_chain: MessageChain):
from_username = session.session_id.split('$$')[0]
await VChatPlatformEvent.send_with_client(self.client, message_chain, from_username)
await super().send_by_session(session, message_chain)
@override
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
"vchat",
"基于 VChat 的 Wechat 适配器",
)
@override
def run(self):
self.client = Core()
@self.client.msg_register(msg_types=model.ContentTypes.TEXT,
contact_type=model.ContactTypes.CHATROOM | model.ContactTypes.USER)
async def _(msg: model.Message):
if isinstance(msg.content, model.UselessContent):
return
if msg.create_time < self.start_time:
logger.debug(f"忽略旧消息: {msg}")
return
logger.debug(f"收到消息: {msg.todict()}")
abmsg = self.convert_message(msg)
# await self.handle_msg(abmsg) # 不能直接调用,否则会阻塞
asyncio.create_task(self.handle_msg(abmsg))
# TODO: 对齐微信服务器时间
self.start_time = int(time.time())
return self._run()
async def _run(self):
await self.client.init()
await self.client.auto_login(hot_reload=True, enable_cmd_qr=True)
await self.client.run()
def convert_message(self, msg: model.Message) -> AstrBotMessage:
# credits: https://github.com/z2z63/astrbot_plugin_vchat/blob/master/main.py#L49
assert isinstance(msg.content, model.TextContent)
amsg = AstrBotMessage()
amsg.message = [Plain(msg.content.content)]
amsg.self_id = self.client_self_id
if msg.content.is_at_me:
amsg.message.insert(0, At(qq=amsg.self_id))
sender = msg.chatroom_sender or msg.from_
amsg.sender = MessageMember(sender.username, sender.nickname)
if msg.content.is_at_me:
amsg.message_str = msg.content.content.split("\u2005")[1].strip()
else:
amsg.message_str = msg.content.content
amsg.message_id = msg.message_id
if isinstance(msg.from_, model.User):
amsg.type = MessageType.FRIEND_MESSAGE
elif isinstance(msg.from_, model.Chatroom):
amsg.type = MessageType.GROUP_MESSAGE
amsg.group_id = msg.from_.username
else:
logger.error(f"不支持的 Wechat 消息类型: {msg.from_}")
amsg.raw_message = msg
if self.settingss['unique_session']:
session_id = msg.from_.username + "$$" + msg.to.username
if msg.chatroom_sender is not None:
session_id += '$$' + msg.chatroom_sender.username
else:
session_id = msg.from_.username
amsg.session_id = session_id
return amsg
async def handle_msg(self, message: AstrBotMessage):
message_event = VChatPlatformEvent(
message_str=message.message_str,
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
client=self.client
)
logger.info(f"处理消息: {message_event}")
self.commit_event(message_event)
@@ -1,8 +1,9 @@
import os
import uuid
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import Plain, Image
from astrbot.core.utils.io import file_to_base64, download_image_by_url
from astrbot.core.utils.io import download_image_by_url
from astrbot.core import web_chat_back_queue
class WebChatMessageEvent(AstrMessageEvent):
@@ -37,5 +38,7 @@ class WebChatMessageEvent(AstrMessageEvent):
with open(comp.file, "rb") as f2:
f.write(f2.read())
web_chat_back_queue.put_nowait((f"[IMAGE]{filename}", cid))
else:
logger.error(f"webchat 暂不支持发送消息类型: {comp.type}")
web_chat_back_queue.put_nowait(None)
await super().send(message)
+1 -1
View File
@@ -52,7 +52,7 @@ class ProviderRequest():
@dataclass
class LLMResponse:
role: str
'''角色'''
'''角色, assistant, tool, err'''
completion_text: str = ""
'''LLM 返回的文本'''
tools_call_args: List[Dict[str, any]] = field(default_factory=list)
+1 -1
View File
@@ -162,7 +162,7 @@ class ProviderManager():
continue
provider_metadata = provider_cls_map[provider_config['type']]
logger.info(f"尝试实例化 {provider_config['type']}({provider_config['id']}) 提供商适配器 ...")
logger.debug(f"尝试实例化 {provider_config['type']}({provider_config['id']}) 提供商适配器 ...")
try:
# 按任务实例化提供商
@@ -2,7 +2,7 @@ import base64
import json
import os
from openai import AsyncOpenAI, AsyncAzureOpenAI, NOT_GIVEN
from openai import AsyncOpenAI, AsyncAzureOpenAI
from openai.types.chat.chat_completion import ChatCompletion
from openai._exceptions import NotFoundError, UnprocessableEntityError
from astrbot.core.utils.io import download_image_by_url
@@ -100,6 +100,9 @@ class ProviderOpenAIOfficial(Provider):
llm_response.role = "tool"
llm_response.tools_call_args = args_ls
llm_response.tools_call_name = func_name_ls
if choice.finish_reason == 'content_filter':
raise Exception("API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。")
if not llm_response.completion_text and not llm_response.tools_call_args:
logger.error(f"API 返回的 completion 无法解析:{completion}")
+2 -2
View File
@@ -340,8 +340,8 @@ class PluginManager:
self.failed_plugin_info = fail_rec
return False, fail_rec
async def install_plugin(self, repo_url: str):
plugin_path = await self.updator.install(repo_url)
async def install_plugin(self, repo_url: str, proxy=""):
plugin_path = await self.updator.install(repo_url, proxy)
# reload the plugin
await self.reload()
return plugin_path
+2 -2
View File
@@ -15,10 +15,10 @@ class PluginUpdator(RepoZipUpdator):
def get_plugin_store_path(self) -> str:
return self.plugin_store_path
async def install(self, repo_url: str) -> str:
async def install(self, repo_url: str, proxy="") -> str:
repo_name = self.format_repo_name(repo_url)
plugin_path = os.path.join(self.plugin_store_path, repo_name)
await self.download_from_repo_url(plugin_path, repo_url)
await self.download_from_repo_url(plugin_path, repo_url, proxy)
self.unzip_file(plugin_path + ".zip", plugin_path)
return plugin_path
+21 -15
View File
@@ -22,21 +22,27 @@ async def tencent_silk_to_wav(silk_path: str, output_path: str) -> str:
async def wav_to_tencent_silk(wav_path: str, output_path: str) -> int:
'''返回 duration'''
import pysilk
with wave.open(wav_path, 'rb') as wav:
wav_data = wav.readframes(wav.getnframes())
wav_data = BytesIO(wav_data)
output_io = BytesIO()
pysilk.encode(wav_data, output_io, 24000, 24000)
output_io.seek(0)
try:
import pilk
except (ImportError, ModuleNotFoundError) as _:
raise Exception("pilk 模块未安装,请前往管理面板->控制台->安装pip库 安装 pilk 这个库")
# with wave.open(wav_path, 'rb') as wav:
# wav_data = wav.readframes(wav.getnframes())
# wav_data = BytesIO(wav_data)
# output_io = BytesIO()
# pysilk.encode(wav_data, output_io, 24000, 24000)
# output_io.seek(0)
# 在首字节添加 \x02,去除结尾的\xff\xff
silk_data = output_io.read()
silk_data_with_prefix = b'\x02' + silk_data[:-2]
# # 在首字节添加 \x02,去除结尾的\xff\xff
# silk_data = output_io.read()
# silk_data_with_prefix = b'\x02' + silk_data[:-2]
# return BytesIO(silk_data_with_prefix)
with open(output_path, "wb") as f:
f.write(silk_data_with_prefix)
# # return BytesIO(silk_data_with_prefix)
# with open(output_path, "wb") as f:
# f.write(silk_data_with_prefix)
return 0
# return 0
with wave.open(wav_path, 'rb') as wav:
rate = wav.getframerate()
duration = pilk.encode(wav_path, output_path, pcm_rate=rate, tencent=True)
return duration
+12 -8
View File
@@ -100,7 +100,7 @@ class RepoZipUpdator():
body=update_data[0]['body']
)
async def download_from_repo_url(self, target_path: str, repo_url: str):
async def download_from_repo_url(self, target_path: str, repo_url: str, proxy=""):
repo_namespace = repo_url.split("/")[-2:]
author = repo_namespace[0]
repo = repo_namespace[1]
@@ -116,13 +116,17 @@ class RepoZipUpdator():
release_url = releases[0]['zipball_url']
# 镜像站点
match self.repo_mirror:
case 'https://github-mirror.us.kg/':
release_url = self.repo_mirror + release_url
case "https://ghp.ci/":
release_url = self.repo_mirror + release_url
case _:
pass
# match self.repo_mirror:
# case 'https://github-mirror.us.kg/':
# release_url = self.repo_mirror + release_url
# case "https://ghp.ci/":
# release_url = self.repo_mirror + release_url
# case _:
# pass
if proxy:
release_url = f"{proxy}/{release_url}"
logger.info(f"使用代理下载: {release_url}")
await download_file(release_url, target_path + ".zip")
+1 -2
View File
@@ -146,11 +146,10 @@ class PluginRoute(Route):
proxy: str = post_data.get("proxy", None)
if proxy:
proxy = proxy.removesuffix("/")
repo_url = f"{proxy}/{repo_url}"
try:
logger.info(f"正在安装插件 {repo_url}")
await self.plugin_manager.install_plugin(repo_url)
await self.plugin_manager.install_plugin(repo_url, proxy)
self.core_lifecycle.restart()
logger.info(f"安装插件 {repo_url} 成功。")
return Response().ok(None, "安装成功。").__dict__
+9
View File
@@ -0,0 +1,9 @@
# What's Changed
1. ✨ 新增: 支持接入飞书(Lark)。支持飞书文字、图片。
2. ✨ 新增: 添加月之暗面配置模板 #446
3. ✨ 新增: Gewechat 支持文件输出
4. 🐛 修复: 修复gewechat无法at人和发语音失败的问题 #447 #438
5. 🐛 修复: 修复qq在@和回复开启的情况下转发消息异常的问题
6. 🐛 修复: GitHub 加速镜像没有正确被应用
7. 🐛 优化: 平台将显示不受支持的消息段
+7 -4
View File
@@ -74,8 +74,8 @@ AstrBot 指令:
/model: 模型列表
/ls: 对话列表
/new: 创建新对话
/switch: 切换对话
/rename: 重命名对话
/switch 序号: 切换对话
/rename 新名字: 重命名当前对话
/del: 删除当前会话对话(op)
/reset: 重置 LLM 会话(op)
/history: 当前对话的对话记录
@@ -495,8 +495,11 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
message.set_result(MessageEventResult().message(f"切换到新对话: 新对话({cid[:4]})。"))
@filter.command("switch")
async def switch_conv(self, message: AstrMessageEvent, index: int):
async def switch_conv(self, message: AstrMessageEvent, index: int = None):
'''通过 /ls 前面的序号切换对话'''
if index is None:
message.set_result(MessageEventResult().message("请输入对话序号。/switch 对话序号。/ls 查看对话 /new 新建对话"))
return
conversations = await self.context.conversation_manager.get_conversations(message.unified_msg_origin)
if index > len(conversations) or index < 1:
message.set_result(MessageEventResult().message("对话序号错误,请使用 /ls 查看"))
@@ -860,4 +863,4 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
# if results:
# req.system_prompt += "\nHere are documents that related to user's query: \n"
# for result in results:
# req.system_prompt += f"- {result}\n"
# req.system_prompt += f"- {result}\n"7
+2
View File
@@ -17,4 +17,6 @@ apscheduler
docstring_parser
aiodocker
silk-python
lark-oapi
ormsgpack