Compare commits

...

11 Commits

Author SHA1 Message Date
Soulter bd2b984bfb v3.4.37 2025-03-09 22:14:23 +08:00
Soulter c38fa77ce6 🐛 fix: 修复 gewechat 部分场景下下载图片报错 #700 2025-03-09 18:10:38 +08:00
Soulter 3eb49f7422 feat: 支持设置私聊是否需要唤醒前缀唤醒 #735 2025-03-09 18:03:23 +08:00
Soulter 1989d615d2 🌈 style: format codes 2025-03-09 17:48:59 +08:00
Soulter 239412d265 feat: 支持接入钉钉 #643 2025-03-09 17:47:51 +08:00
Soulter 375a419a9e Merge pull request #732 from xiewoc/master
Update aiocqhttp_platform_adapter.py
2025-03-09 12:36:48 +08:00
Soulter 875c8ab424 ci: upate astrbot webui build cis 2025-03-09 11:31:10 +08:00
Soulter c9bfc810ce ci: upload astrbot webui build ci 2025-03-09 11:26:10 +08:00
Soulter 46ecb16949 🐛 fix: 无法正常保存插件的 list 类型配置 #737 2025-03-09 11:12:24 +08:00
pre-commit-ci[bot] d6a785b645 🎈 auto fixes by pre-commit hooks 2025-03-08 04:33:19 +00:00
xiewoc 79db828a01 Update aiocqhttp_platform_adapter.py 2025-03-08 12:30:49 +08:00
13 changed files with 343 additions and 13 deletions
+31
View File
@@ -0,0 +1,31 @@
name: AstrBot Dashboard CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: npm install, build
run: |
cd dashboard
npm install
npm run build
- name: Inject Commit SHA
id: get_sha
run: |
echo "COMMIT_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV
mkdir -p dashboard/dist/assets
echo $COMMIT_SHA > dashboard/dist/assets/version
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: dist-without-markdown
path: |
dashboard/dist
!dist/**/*.md
+14 -1
View File
@@ -2,7 +2,7 @@
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
"""
VERSION = "3.4.36"
VERSION = "3.4.37"
DB_PATH = "data/data_v3.db"
# 默认配置
@@ -37,6 +37,7 @@ DEFAULT_CONFIG = {
},
"no_permission_reply": True,
"empty_mention_waiting": True,
"friend_message_needs_wake_prefix": False,
},
"provider": [],
"provider_settings": {
@@ -159,6 +160,13 @@ CONFIG_METADATA_2 = {
"app_secret": "",
"domain": "https://open.feishu.cn",
},
"dingtalk(钉钉)": {
"id": "dingtalk",
"type": "dingtalk",
"enable": False,
"client_id": "",
"client_secret": "",
},
"telegram": {
"id": "telegram",
"type": "telegram",
@@ -263,6 +271,11 @@ CONFIG_METADATA_2 = {
"type": "bool",
"hint": "启用后,当消息内容只有 @ 机器人时,会触发等待回复,在 60 秒内的该用户的任意一条消息均会唤醒机器人。这在某些平台不支持 @ 和语音/图片等消息同时发送时特别有用。",
},
"friend_message_needs_wake_prefix": {
"description": "私聊消息是否需要唤醒前缀",
"type": "bool",
"hint": "启用后,私聊消息需要唤醒前缀才会被处理,同群聊一样。",
},
"segmented_reply": {
"description": "分段回复",
"type": "object",
+5 -1
View File
@@ -25,6 +25,10 @@ class WakingCheckStage(Stage):
self.no_permission_reply = self.ctx.astrbot_config["platform_settings"].get(
"no_permission_reply", True
)
# 私聊是否需要 wake_prefix 才能唤醒机器人
self.friend_message_needs_wake_prefix = self.ctx.astrbot_config[
"platform_settings"
].get("friend_message_needs_wake_prefix", False)
async def process(
self, event: AstrMessageEvent
@@ -68,7 +72,7 @@ class WakingCheckStage(Stage):
event.is_at_or_wake_command = True
break
# 检查是否是私聊
if event.is_private_chat():
if event.is_private_chat() and not self.friend_message_needs_wake_prefix:
is_wake = True
event.is_wake = True
event.is_at_or_wake_command = True
+4
View File
@@ -64,6 +64,10 @@ class PlatformManager:
)
case "lark":
from .sources.lark.lark_adapter import LarkPlatformAdapter # noqa: F401
case "dingtalk":
from .sources.dingtalk.dingtalk_adapter import (
DingtalkPlatformAdapter, # noqa: F401
)
case "telegram":
from .sources.telegram.tg_adapter import TelegramPlatformAdapter # noqa: F401
case "wecom":
@@ -140,7 +140,7 @@ class AiocqhttpAdapter(Platform):
abm.type = MessageType.FRIEND_MESSAGE
if self.unique_session and abm.type == MessageType.GROUP_MESSAGE:
abm.session_id = (
abm.sender.user_id + "_" + str(event.group_id)
str(abm.sender.user_id) + "_" + str(event.group_id)
) # 也保留群组 id
else:
abm.session_id = (
@@ -0,0 +1,202 @@
import asyncio
import uuid
import aiohttp
import dingtalk_stream
from astrbot.api.platform import (
Platform,
AstrBotMessage,
MessageMember,
MessageType,
PlatformMetadata,
)
from astrbot.api.event import MessageChain
from astrbot.api.message_components import Image, Plain, At
from astrbot.core.platform.astr_message_event import MessageSesion
from .dingtalk_event import DingtalkMessageEvent
from ...register import register_platform_adapter
from astrbot import logger
from dingtalk_stream import AckMessage
from astrbot.core.utils.io import download_file
class MyEventHandler(dingtalk_stream.EventHandler):
async def process(self, event: dingtalk_stream.EventMessage):
print(
"2",
event.headers.event_type,
event.headers.event_id,
event.headers.event_born_time,
event.data,
)
return AckMessage.STATUS_OK, "OK"
@register_platform_adapter("dingtalk", "钉钉机器人官方 API 适配器")
class DingtalkPlatformAdapter(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.client_id = platform_config["client_id"]
self.client_secret = platform_config["client_secret"]
class AstrCallbackClient(dingtalk_stream.ChatbotHandler):
async def process(self_, message: dingtalk_stream.CallbackMessage):
logger.debug(f"dingtalk: {message.data}")
im = dingtalk_stream.ChatbotMessage.from_dict(message.data)
abm = await self.convert_msg(im)
await self.handle_msg(abm)
return AckMessage.STATUS_OK, "OK"
self.client = AstrCallbackClient()
credential = dingtalk_stream.Credential(self.client_id, self.client_secret)
client = dingtalk_stream.DingTalkStreamClient(credential, logger=logger)
client.register_all_event_handler(MyEventHandler())
client.register_callback_handler(
dingtalk_stream.ChatbotMessage.TOPIC, self.client
)
self.client_ = client # 用于 websockets 的 client
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
raise NotImplementedError("钉钉机器人适配器不支持 send_by_session")
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
"dingtalk",
"钉钉机器人官方 API 适配器",
)
async def convert_msg(
self, message: dingtalk_stream.ChatbotMessage
) -> AstrBotMessage:
abm = AstrBotMessage()
abm.message = []
abm.message_str = ""
abm.timestamp = int(message.create_at / 1000)
abm.type = (
MessageType.GROUP_MESSAGE
if message.conversation_type == "2"
else MessageType.FRIEND_MESSAGE
)
abm.sender = MessageMember(
user_id=message.sender_id, nickname=message.sender_nick
)
abm.self_id = message.chatbot_user_id
abm.message_id = message.message_id
abm.raw_message = message
if abm.type == MessageType.GROUP_MESSAGE:
if message.is_in_at_list:
abm.message.append(At(qq=abm.self_id))
abm.group_id = message.conversation_id
if self.unique_session:
abm.session_id = abm.sender.user_id
else:
abm.session_id = abm.group_id
else:
abm.session_id = abm.sender.user_id
message_type: str = message.message_type
match message_type:
case "text":
abm.message_str = message.text.content.strip()
abm.message.append(Plain(abm.message_str))
case "richText":
rtc: dingtalk_stream.RichTextContent = message.rich_text_content
contents: list[dict] = rtc.rich_text_list
for content in contents:
plains = ""
if "text" in content:
plains += content["text"]
abm.message.append(Plain(plains))
elif "type" in content and content["type"] == "picture":
f_path = await self.download_ding_file(
content["downloadCode"],
message.robot_code,
"jpg",
)
abm.message.append(Image.fromFileSystem(f_path))
case "audio":
pass
return abm # 别忘了返回转换后的消息对象
async def download_ding_file(
self, download_code: str, robot_code: str, ext: str
) -> str:
"""下载钉钉文件
:param access_token: 钉钉机器人的 access_token
:param download_code: 下载码
:param robot_code: 机器人码
:param ext: 文件后缀
:return: 文件路径
"""
access_token = await self.get_access_token()
headers = {
"x-acs-dingtalk-access-token": access_token,
}
payload = {
"downloadCode": download_code,
"robotCode": robot_code,
}
f_path = f"data/dingtalk_file_{uuid.uuid4()}.{ext}"
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.dingtalk.com/v1.0/robot/messageFiles/download",
headers=headers,
json=payload,
) as resp:
if resp.status != 200:
logger.error(
f"下载钉钉文件失败: {resp.status}, {await resp.text()}"
)
return None
resp_data = await resp.json()
download_url = resp_data["data"]["downloadUrl"]
await download_file(download_url, f_path)
return f_path
async def get_access_token(self) -> str:
payload = {
"appKey": self.client_id,
"appSecret": self.client_secret,
}
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.dingtalk.com/v1.0/oauth2/accessToken",
json=payload,
) as resp:
if resp.status != 200:
logger.error(
f"获取钉钉机器人 access_token 失败: {resp.status}, {await resp.text()}"
)
return None
return (await resp.json())["data"]["accessToken"]
async def handle_msg(self, abm: AstrBotMessage):
event = DingtalkMessageEvent(
message_str=abm.message_str,
message_obj=abm,
platform_meta=self.meta(),
session_id=abm.session_id,
client=self.client,
)
self._event_queue.put_nowait(event)
async def run(self):
await self.client_.start()
def get_client(self):
return self.client
@@ -0,0 +1,58 @@
import asyncio
import dingtalk_stream
import astrbot.api.message_components as Comp
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot import logger
class DingtalkMessageEvent(AstrMessageEvent):
def __init__(
self,
message_str,
message_obj,
platform_meta,
session_id,
client: dingtalk_stream.ChatbotHandler,
):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
async def send_with_client(
self, client: dingtalk_stream.ChatbotHandler, message: MessageChain
):
for segment in message.chain:
if isinstance(segment, Comp.Plain):
segment.text = segment.text.strip()
await asyncio.get_event_loop().run_in_executor(
None, client.reply_text, segment.text, self.message_obj.raw_message
)
elif isinstance(segment, Comp.Image):
markdown_str = ""
if segment.file and segment.file.startswith("file:///"):
logger.warning(
"dingtalk only support url image, not: " + segment.file
)
continue
elif segment.file and segment.file.startswith("http"):
markdown_str += f"![image]({segment.file})\n\n"
elif segment.file and segment.file.startswith("base64://"):
logger.warning("dingtalk only support url image, not base64")
continue
else:
logger.warning(
"dingtalk only support url image, not: " + segment.file
)
continue
ret = await asyncio.get_event_loop().run_in_executor(
None,
client.reply_markdown,
"😄",
markdown_str,
self.message_obj.raw_message,
)
logger.debug(f"send image: {ret}")
async def send(self, message: MessageChain):
await self.send_with_client(self.client, message)
await super().send(message)
@@ -186,12 +186,17 @@ class SimpleGewechatClient:
abm.message_str = content
case 3:
# 图片消息
file_url = await self.multimedia_downloader.download_image(
self.appid, content
)
logger.debug(f"下载图片: {file_url}")
file_path = await download_image_by_url(file_url)
abm.message.append(Image(file=file_path, url=file_path))
# 先看看 base64 数据
if "ImgBuf" in d and "buffer" in d["ImgBuf"]:
logger.debug("发现图片消息包含 base64 数据,使用。")
abm.message.append(Image.fromBase64(d["ImgBuf"]["buffer"]))
else:
file_url = await self.multimedia_downloader.download_image(
self.appid, content
)
logger.debug(f"下载图片: {file_url}")
file_path = await download_image_by_url(file_url)
abm.message.append(Image(file=file_path, url=file_path))
case 34:
# 语音消息
@@ -66,7 +66,7 @@ class LarkPlatformAdapter(Platform):
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
raise NotImplementedError("Lark 适配器不支持 send_by_session")
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
+5 -3
View File
@@ -39,9 +39,10 @@ def validate_config(
data[key] = DEFAULT_VALUE_MAP[meta["type"]]
continue
# 递归验证
if meta["type"] == "list" and isinstance(value, list):
for item in value:
validate(item, meta["items"], path=f"{path}{key}.")
if meta["type"] == "list" and not isinstance(value, list):
errors.append(
f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}"
)
elif meta["type"] == "object" and isinstance(value, dict):
validate(value, meta["items"], path=f"{path}{key}.")
@@ -103,6 +104,7 @@ def save_config(post_config: dict, config: AstrBotConfig, is_core: bool = False)
except BaseException as e:
logger.error(traceback.format_exc())
logger.warning(f"验证配置时出现异常: {e}")
raise ValueError(f"验证配置时出现异常: {e}")
if errors:
raise ValueError(f"格式校验未通过: {errors}")
config.save_config(post_config)
+6
View File
@@ -0,0 +1,6 @@
# What's Changed
1. ✨ 新增: 支持接入钉钉 #643
2. ✨ 新增: 支持设置私聊是否需要唤醒前缀唤醒 [#735](https://github.com/Soulter/AstrBot/issues/735)
3. 🐛 修复: 无法正常保存插件的 list 类型配置 #737
4. 🐛 修复: 部分情况下使用 aiocqhttp 报错 int 不能与 str 进行 '+' 操作的问题
+4
View File
@@ -183,6 +183,7 @@ const openExtensionConfig = async (extension_name) => {
const res = await axios.get('/api/config/get?plugin_name=' + extension_name);
extension_config.metadata = res.data.data.metadata;
extension_config.config = res.data.data.config;
} catch (err) {
toast(err, "error");
}
@@ -197,6 +198,9 @@ const updateConfig = async () => {
toast(res.data.message, "error");
}
configDialog.value = false;
extension_config.metadata = {};
extension_config.config = {};
getExtensions();
} catch (err) {
toast(err, "error");
}
+1
View File
@@ -24,3 +24,4 @@ cryptography
dashscope
python-telegram-bot
wechatpy
dingtalk-stream