Compare commits

...

42 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
Soulter f6dc16f17b style: format codes 2025-03-08 20:55:25 +08:00
Soulter 4eef42f730 refactor: 移除未使用的 defineEmits 导入 2025-03-08 20:53:43 +08:00
Soulter 8612d9a771 docs: update changelogs 2025-03-08 20:37:46 +08:00
Soulter 0caff054f5 feat: 会话控制器支持自定义会话ID算子 2025-03-08 20:29:42 +08:00
Soulter 4aa91ad599 feat: 支持当消息只有@bot时,下一条发送人的消息直接唤醒机器人 2025-03-08 19:55:24 +08:00
Soulter 7a0864f5c2 feat: 推荐插件页面 2025-03-08 18:58:50 +08:00
Soulter 73dc0dfcf6 perf: 插件市场支持显示插件 logo 2025-03-08 17:31:08 +08:00
Soulter 1ff9a69339 chore: plugin logo 2025-03-08 17:23:25 +08:00
Soulter 179eb5d847 feat: 优化了插件卡片的 UI,插件卡片支持显示 logo 2025-03-08 17:13:36 +08:00
Soulter 52c868828c perf: 插件更新、保存配置均支持热重载 2025-03-08 15:22:56 +08:00
Soulter 7eea4615b6 perf: 优化了日志显示 2025-03-08 15:22:22 +08:00
Soulter d9b351df1a fix: 修复主动人格情况下人格失效的问题 #719 #712 2025-03-08 14:14:14 +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
Soulter a5ffb0f8dc perf: 安装/更新插件后直接热重载而不重启;更新 plugin 指令 2025-03-08 00:20:48 +08:00
Soulter 9492fcde74 perf: 完善了插件的启用和禁用的生命周期管理 2025-03-07 23:44:07 +08:00
Soulter d2456ce4cd Update README.md 2025-03-07 10:52:09 +08:00
Soulter 7de27abc8d 🐛 fix: Telegram适配器使用代理地址无法获取图片 #723 2025-03-07 09:05:00 +08:00
Soulter d8155bc8eb 🐛 fix: Telegram适配器使用代理地址无法获取图片 #723 2025-03-07 00:42:15 +08:00
Soulter cf08e52a92 style: cleanup 2025-03-06 23:52:15 +08:00
Soulter 768398b991 feat: 支持 gewechat 图片等更多类型的主动消息 #710 2025-03-06 22:26:58 +08:00
Soulter 24c20a19f1 feat: 支持插件会话控制 API 2025-03-06 22:13:14 +08:00
Soulter 8fbcbcd4c0 🐛 fix: webchat cannot send active image message #710 2025-03-05 22:34:37 +08:00
Soulter e0da5bb943 chore: delete some files for project safety 2025-03-05 19:05:50 +08:00
Soulter 36fbc4fb82 Update README.md 2025-03-05 18:55:40 +08:00
Soulter cb11051f42 Update README.md 2025-03-05 17:56:23 +08:00
Soulter a824781d14 Update README.md 2025-03-05 17:55:06 +08:00
Soulter 600a2c6748 🐛 fix: context.get_platform() error 2025-03-05 13:28:55 +08:00
Soulter 77df64bfb5 🐛 fix: 修复插件在带了 __del__ 之后无法被禁用和重载的问题 2025-03-05 11:33:01 +08:00
Soulter 2d6e54903c Update README.md 2025-03-05 00:58:44 +08:00
Soulter baa2b83df9 🐛 fix: telegram cannot handle /start #620 2025-03-05 00:40:38 +08:00
Soulter 1ff02446af 🐛 fix: 404 error after installing plugins 2025-03-04 23:39:01 +08:00
Soulter b58c6ba762 feat: add template of lmstudio #691 2025-03-04 23:38:33 +08:00
44 changed files with 1493 additions and 568 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
+9
View File
@@ -17,6 +17,7 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
[![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg)](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fstats&query=v&label=7%E6%97%A5%E6%B6%88%E6%81%AF%E4%B8%8A%E8%A1%8C%E9%87%8F&cacheSeconds=3600)
[![codecov](https://codecov.io/gh/Soulter/AstrBot/graph/badge.svg?token=FF3P5967B8)](https://codecov.io/gh/Soulter/AstrBot)
[![star](https://gitcode.com/Soulter/AstrBot/star/badge.svg)](https://gitcode.com/Soulter/AstrBot)
<a href="https://github.com/Soulter/AstrBot/blob/master/README_en.md">English</a>
<a href="https://github.com/Soulter/AstrBot/blob/master/README_ja.md">日本語</a>
@@ -64,6 +65,14 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
## 🚀 路线图
### 垂类功能
1. 更好的上下文管理:限制 token 总数、对话上下文总结
3. AstrBot in Minecraft
### 横功能
## ⚡ 消息平台支持情况
+7
View File
@@ -0,0 +1,7 @@
from astrbot.core.utils.session_waiter import (
SessionWaiter,
SessionController,
session_waiter,
)
__all__ = ["SessionWaiter", "SessionController", "session_waiter"]
+31 -1
View File
@@ -2,7 +2,7 @@
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
"""
VERSION = "3.4.35"
VERSION = "3.4.37"
DB_PATH = "data/data_v3.db"
# 默认配置
@@ -36,6 +36,8 @@ DEFAULT_CONFIG = {
"content_cleanup_rule": "",
},
"no_permission_reply": True,
"empty_mention_waiting": True,
"friend_message_needs_wake_prefix": False,
},
"provider": [],
"provider_settings": {
@@ -158,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",
@@ -165,6 +174,7 @@ CONFIG_METADATA_2 = {
"telegram_token": "your_bot_token",
"start_message": "Hello, I'm AstrBot!",
"telegram_api_base_url": "https://api.telegram.org/bot",
"telegram_file_base_url": "https://api.telegram.org/file/bot",
},
},
"items": {
@@ -256,6 +266,16 @@ CONFIG_METADATA_2 = {
"type": "bool",
"hint": "启用后,当用户没有权限执行某个操作时,机器人会回复一条消息。",
},
"empty_mention_waiting": {
"description": "只 @ 机器人是否触发等待回复",
"type": "bool",
"hint": "启用后,当消息内容只有 @ 机器人时,会触发等待回复,在 60 秒内的该用户的任意一条消息均会唤醒机器人。这在某些平台不支持 @ 和语音/图片等消息同时发送时特别有用。",
},
"friend_message_needs_wake_prefix": {
"description": "私聊消息是否需要唤醒前缀",
"type": "bool",
"hint": "启用后,私聊消息需要唤醒前缀才会被处理,同群聊一样。",
},
"segmented_reply": {
"description": "分段回复",
"type": "object",
@@ -465,6 +485,16 @@ CONFIG_METADATA_2 = {
"model": "llama3.1-8b",
},
},
"LM_Studio": {
"id": "lm_studio",
"type": "openai_chat_completion",
"enable": True,
"key": ["lmstudio"],
"api_base": "http://localhost:1234/v1",
"model_config": {
"model": "llama-3.1-8b",
},
},
"Gemini(OpenAI兼容)": {
"id": "gemini_default",
"type": "openai_chat_completion",
+58 -2
View File
@@ -1,6 +1,7 @@
import logging
import colorlog
import asyncio
import os
from collections import deque
from asyncio import Queue
from typing import List
@@ -17,6 +18,31 @@ log_color_config = {
}
def is_plugin_path(pathname):
"""
检查文件路径是否来自插件目录
"""
if not pathname:
return False
norm_path = os.path.normpath(pathname)
return ("data/plugins" in norm_path) or ("packages/" in norm_path)
def get_short_level_name(level_name):
"""
将日志级别名称转换为四个字母的缩写
"""
level_map = {
"DEBUG": "DBUG",
"INFO": "INFO",
"WARNING": "WARN",
"ERROR": "ERRO",
"CRITICAL": "CRIT",
}
return level_map.get(level_name, level_name[:4].upper())
class LogBroker:
def __init__(self):
self.log_cache = deque(maxlen=CACHED_SIZE)
@@ -62,12 +88,41 @@ class LogManager:
return logger
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.DEBUG)
console_formatter = colorlog.ColoredFormatter(
fmt="%(log_color)s [%(asctime)s] [%(levelname)-5s] [%(filename)s:%(lineno)d]: %(message)s %(reset)s",
fmt="%(log_color)s [%(asctime)s] %(plugin_tag)s [%(short_levelname)-4s] [%(filename)s:%(lineno)d]: %(message)s %(reset)s",
datefmt="%H:%M:%S",
log_colors=log_color_config,
)
class PluginFilter(logging.Filter):
def filter(self, record):
record.plugin_tag = (
"[Plug]" if is_plugin_path(record.pathname) else "[Core]"
)
return True
class FileNameFilter(logging.Filter):
# 获取这个文件和父文件夹的名字:<folder>.<file> 并且去除 .py
def filter(self, record):
dirname = os.path.dirname(record.pathname)
record.filename = (
os.path.basename(dirname)
+ "."
+ os.path.basename(record.pathname).replace(".py", "")
)
return True
class LevelNameFilter(logging.Filter):
# 添加短日志级别名称
def filter(self, record):
record.short_levelname = get_short_level_name(record.levelname)
return True
console_handler.setFormatter(console_formatter)
logger.addFilter(PluginFilter())
logger.addFilter(FileNameFilter())
logger.addFilter(LevelNameFilter()) # 添加级别名称过滤器
logger.setLevel(logging.DEBUG)
logger.addHandler(console_handler)
@@ -80,9 +135,10 @@ class LogManager:
if logger.handlers:
handler.setFormatter(logger.handlers[0].formatter)
else:
# 为队列处理器设置相同格式的formatter
handler.setFormatter(
logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
"[%(asctime)s] [%(short_levelname)s] %(plugin_tag)s[%(filename)s:%(lineno)d]: %(message)s"
)
)
logger.addHandler(handler)
@@ -1,7 +1,4 @@
import re
import os
import json
import base64
from . import ContentSafetyStrategy
@@ -11,13 +8,13 @@ class KeywordsStrategy(ContentSafetyStrategy):
if extra_keywords is None:
extra_keywords = []
self.keywords.extend(extra_keywords)
keywords_path = os.path.join(os.path.dirname(__file__), "unfit_words")
# keywords_path = os.path.join(os.path.dirname(__file__), "unfit_words")
# internal keywords
if os.path.exists(keywords_path):
with open(keywords_path, "r", encoding="utf-8") as f:
self.keywords.extend(
json.loads(base64.b64decode(f.read()).decode("utf-8"))["keywords"]
)
# if os.path.exists(keywords_path):
# with open(keywords_path, "r", encoding="utf-8") as f:
# self.keywords.extend(
# json.loads(base64.b64decode(f.read()).decode("utf-8"))["keywords"]
# )
def check(self, content: str) -> bool:
for keyword in self.keywords:
@@ -1 +0,0 @@
ewogICAgImtleXdvcmRzIjogWwogICAgICAgICLkuaDov5HlubMiLAogICAgICAgICLog6HplKbmtpsiLAogICAgICAgICLmsZ/ms73msJEiLAogICAgICAgICLmuKnlrrblrp0iLAogICAgICAgICLmnY7lhYvlvLoiLAogICAgICAgICLmnY7plb/mmKUiLAogICAgICAgICLmr5vms73kuJwiLAogICAgICAgICLpgpPlsI/lubMiLAogICAgICAgICLlkajmganmnaUiLAogICAgICAgICLnpL7kvJrkuLvkuYkiLAogICAgICAgICLlhbHkuqflhZoiLAogICAgICAgICLlhbHkuqfkuLvkuYkiLAogICAgICAgICLlpKfpmYblrpjmlrkiLAogICAgICAgICLljJfkuqzmlL/mnYMiLAogICAgICAgICLkuK3ljY7luJ3lm70iLAogICAgICAgICLkuK3lm73mlL/lupwiLAogICAgICAgICLlhbHni5ciLAogICAgICAgICLlha3lm5vkuovku7YiLAogICAgICAgICLlpKnlronpl6giLAogICAgICAgICLlha3lm5siLAogICAgICAgICLmlL/msrvlsYDluLjlp5QiLAogICAgICAgICLlrabmva4iLAogICAgICAgICLlhavkuZ0iLAogICAgICAgICLkuozljYHlpKciLAogICAgICAgICLmsJHov5vlhZoiLAogICAgICAgICLlj7Dni6wiLAogICAgICAgICLlj7Dmub7ni6znq4siLAogICAgICAgICLlj7Dmub7lm70iLAogICAgICAgICLlm73msJHlhZoiLAogICAgICAgICLlj7Dmub7msJHlm70iLAogICAgICAgICLkuK3ljY7msJHlm70iLAogICAgICAgICJwb3JuaHViIiwKICAgICAgICAiUG9ybmh1YiIsCiAgICAgICAgIuS9nOeIsSIsCiAgICAgICAgIuWBmueIsSIsCiAgICAgICAgIuaAp+S6pCIsCiAgICAgICAgIuiHquaFsCIsCiAgICAgICAgIumYtOiMjiIsCiAgICAgICAgIua3q+WmhyIsCiAgICAgICAgIuiCm+S6pCIsCiAgICAgICAgIuS6pOmFjSIsCiAgICAgICAgIuaAp+WFs+ezuyIsCiAgICAgICAgIuaAp+a0u+WKqCIsCiAgICAgICAgIuiJsuaDhSIsCiAgICAgICAgIuiJsuWbviIsCiAgICAgICAgIuijuOS9kyIsCiAgICAgICAgIuWwj+eptCIsCiAgICAgICAgIua3q+iNoSIsCiAgICAgICAgIuaAp+eIsSIsCiAgICAgICAgIua4r+eLrCIsCiAgICAgICAgIuazlei9ruWKnyIsCiAgICAgICAgIuWFreWbmyIKICAgIF0KfQ==
@@ -3,6 +3,7 @@
"""
import traceback
import asyncio
import json
from typing import Union, AsyncGenerator
from ...context import PipelineContext
@@ -137,10 +138,12 @@ class LLMRequestSubStage(Stage):
# 保存到历史记录
await self._save_to_history(event, req, llm_response)
await Metric.upload(
llm_tick=1,
model_name=provider.get_model(),
provider_type=provider.meta().type,
asyncio.create_task(
Metric.upload(
llm_tick=1,
model_name=provider.get_model(),
provider_type=provider.meta().type,
)
)
if llm_response.role == "assistant":
+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 -1
View File
@@ -1,4 +1,5 @@
import abc
import asyncio
from dataclasses import dataclass
from .astrbot_message import AstrBotMessage
from .platform_metadata import PlatformMetadata
@@ -196,7 +197,9 @@ class AstrMessageEvent(abc.ABC):
"""
发送消息到消息平台。
"""
await Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
asyncio.create_task(
Metric.upload(msg_event_tick=1, adapter_name=self.platform_meta.name)
)
self._has_send_oper = True
async def _pre_send(self):
+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:
# 语音消息
@@ -37,12 +37,9 @@ class GewechatPlatformEvent(AstrMessageEvent):
self.client = client
@staticmethod
async def send_with_client(message: MessageChain, user_name: str):
pass
async def send(self, message: MessageChain):
to_wxid = self.message_obj.raw_message.get("to_wxid", None)
async def send_with_client(
message: MessageChain, to_wxid: str, client: SimpleGewechatClient
):
if not to_wxid:
logger.error("无法获取到 to_wxid。")
return
@@ -70,7 +67,7 @@ class GewechatPlatformEvent(AstrMessageEvent):
payload["content"] = text
payload["ats"] = ats
has_at = True
await self.client.post_text(**payload)
await client.post_text(**payload)
elif isinstance(comp, Image):
img_url = comp.file
@@ -90,9 +87,9 @@ class GewechatPlatformEvent(AstrMessageEvent):
img_path = save_temp_img(f.read())
file_id = os.path.basename(img_path)
img_url = f"{self.client.file_server_url}/{file_id}"
img_url = f"{client.file_server_url}/{file_id}"
logger.debug(f"gewe callback img url: {img_url}")
await self.client.post_image(to_wxid, img_url)
await client.post_image(to_wxid, img_url)
elif isinstance(comp, Record):
# 默认已经存在 data/temp 中
record_url = comp.file
@@ -110,16 +107,14 @@ class GewechatPlatformEvent(AstrMessageEvent):
duration = await wav_to_tencent_silk(record_path, silk_path)
except Exception as e:
logger.error(traceback.format_exc())
await self.send(
MessageChain().message(f"语音文件转换失败。{str(e)}")
)
await client.post_text(to_wxid, f"语音文件转换失败。{str(e)}")
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}"
record_url = f"{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)
await client.post_voice(to_wxid, record_url, duration * 1000)
elif isinstance(comp, File):
file_path = comp.file
file_name = comp.name
@@ -131,12 +126,15 @@ class GewechatPlatformEvent(AstrMessageEvent):
file_path = file_path
file_id = os.path.basename(file_path)
file_url = f"{self.client.file_server_url}/{file_id}"
file_url = f"{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)
await client.post_file(to_wxid, file_url, file_id)
elif isinstance(comp, At):
pass
else:
logger.debug(f"gewechat 忽略: {comp.type}")
async def send(self, message: MessageChain):
to_wxid = self.message_obj.raw_message.get("to_wxid", None)
await GewechatPlatformEvent.send_with_client(message, to_wxid, self.client)
await super().send(message)
@@ -4,12 +4,10 @@ import os
from astrbot.api.platform import Platform, AstrBotMessage, MessageType, PlatformMetadata
from astrbot.api.event import MessageChain
from astrbot.api import logger
from astrbot.core.platform.astr_message_event import MessageSesion
from ...register import register_platform_adapter
from .gewechat_event import GewechatPlatformEvent
from .client import SimpleGewechatClient
from astrbot.core.message.components import Plain
if sys.version_info >= (3, 12):
from typing import override
@@ -45,14 +43,16 @@ class GewechatPlatformAdapter(Platform):
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
to_wxid = session.session_id
if not to_wxid:
logger.error("无法获取到 to_wxid。")
return
session_id = session.session_id
if "#" in session_id:
# unique session
to_wxid = session_id.split("#")[1]
else:
to_wxid = session_id
for comp in message_chain.chain:
if isinstance(comp, Plain):
await self.client.post_text(to_wxid, comp.text)
await GewechatPlatformEvent.send_with_client(
message_chain, to_wxid, self.client
)
await super().send_by_session(session, message_chain)
@@ -81,7 +81,7 @@ class GewechatPlatformAdapter(Platform):
async def handle_msg(self, message: AstrBotMessage):
if message.type == MessageType.GROUP_MESSAGE:
if self.settingss["unique_session"]:
message.session_id = message.sender.user_id + "_" + message.group_id
message.session_id = message.sender.user_id + "#" + message.group_id
message_event = GewechatPlatformEvent(
message_str=message.message_str,
@@ -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(
@@ -50,10 +50,20 @@ class TelegramPlatformAdapter(Platform):
)
if not base_url:
base_url = "https://api.telegram.org/bot"
file_base_url = self.config.get(
"telegram_file_base_url", "https://api.telegram.org/file/bot"
)
if not file_base_url:
file_base_url = "https://api.telegram.org/file/bot"
self.base_url = base_url
self.application = (
ApplicationBuilder()
.token(self.config["telegram_token"])
.base_url(base_url)
.base_file_url(file_base_url)
.build()
)
message_handler = TelegramMessageHandler(
@@ -62,6 +72,7 @@ class TelegramPlatformAdapter(Platform):
)
self.application.add_handler(message_handler)
self.client = self.application.bot
logger.debug(f"Telegram base url: {self.client.base_url}")
@override
async def send_by_session(
@@ -121,16 +132,23 @@ class TelegramPlatformAdapter(Platform):
if update.message.entities:
for entity in update.message.entities:
if entity.type == "mention":
name = plain_text[entity.offset+1 : entity.offset + entity.length]
name = plain_text[
entity.offset + 1 : entity.offset + entity.length
]
message.message.append(At(qq=name, name=name))
plain_text = (
plain_text[: entity.offset]
+ plain_text[entity.offset + entity.length :]
)
message.message.append(Plain(plain_text))
if plain_text:
message.message.append(Plain(plain_text))
message.message_str = plain_text
if message.message_str == "/start":
await self.start(update, context)
return
elif update.message.voice:
file = await update.message.voice.get_file()
message.message = [
@@ -13,7 +13,7 @@ from astrbot.core.platform import (
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.message.components import Plain, Image, Record # noqa: F403
from astrbot import logger
from astrbot.core import web_chat_queue, web_chat_back_queue
from astrbot.core import web_chat_queue
from .webchat_event import WebChatMessageEvent
from astrbot.core.platform.astr_message_event import MessageSesion
from ...register import register_platform_adapter
@@ -50,14 +50,7 @@ class WebChatAdapter(Platform):
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
# abm.session_id = f"webchat!{username}!{cid}"
plain = ""
cid = session.session_id.split("!")[-1]
for comp in message_chain.chain:
if isinstance(comp, Plain):
plain += comp.text
web_chat_back_queue.put_nowait((plain, cid))
await WebChatMessageEvent._send(message_chain, session.session_id)
await super().send_by_session(session, message_chain)
async def convert_message(self, data: tuple) -> AstrBotMessage:
@@ -7,19 +7,21 @@ from astrbot.api.message_components import Plain, Image
from astrbot.core.utils.io import download_image_by_url
from astrbot.core import web_chat_back_queue
imgs_dir = "data/webchat/imgs"
class WebChatMessageEvent(AstrMessageEvent):
def __init__(self, message_str, message_obj, platform_meta, session_id):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.imgs_dir = "data/webchat/imgs"
os.makedirs(self.imgs_dir, exist_ok=True)
os.makedirs(imgs_dir, exist_ok=True)
async def send(self, message: MessageChain):
@staticmethod
async def _send(message: MessageChain, session_id: str):
if not message:
web_chat_back_queue.put_nowait(None)
return
cid = self.session_id.split("!")[-1]
cid = session_id.split("!")[-1]
for comp in message.chain:
if isinstance(comp, Plain):
@@ -27,7 +29,7 @@ class WebChatMessageEvent(AstrMessageEvent):
elif isinstance(comp, Image):
# save image to local
filename = str(uuid.uuid4()) + ".jpg"
path = os.path.join(self.imgs_dir, filename)
path = os.path.join(imgs_dir, filename)
if comp.file and comp.file.startswith("file:///"):
ph = comp.file[8:]
with open(path, "wb") as f:
@@ -48,4 +50,7 @@ class WebChatMessageEvent(AstrMessageEvent):
else:
logger.debug(f"webchat 忽略: {comp.type}")
web_chat_back_queue.put_nowait(None)
async def send(self, message: MessageChain):
await WebChatMessageEvent._send(message, session_id=self.session_id)
await super().send(message)
+1 -1
View File
@@ -46,7 +46,7 @@ class ProviderRequest:
conversation: Conversation = None
def __repr__(self):
return f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, image_urls={self.image_urls}, func_tool={self.func_tool}, contexts={self.contexts}, system_prompt={self.system_prompt})"
return f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, image_urls={self.image_urls}, func_tool={self.func_tool}, contexts={self.contexts}, system_prompt={self.system_prompt.strip()})"
def __str__(self):
return self.__repr__()
@@ -19,6 +19,9 @@ class FuncTool:
active: bool = True
"""是否激活"""
def __repr__(self):
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}), active={self.active})"
SUPPORTED_TYPES = [
"string",
+1 -2
View File
@@ -132,9 +132,8 @@ class ProviderManager:
return
logger.info(
f"载入 {provider_config['type']}({provider_config['id']}) 服务提供商适配器 ..."
f"载入 {provider_config['type']}({provider_config['id']}) 服务提供商 ..."
)
logger.debug(f"Provider Config: {provider_config}")
# 动态导入
try:
+6 -2
View File
@@ -183,11 +183,15 @@ class Context:
获取指定类型的平台适配器。
"""
for platform in self.platform_manager.platform_insts:
name = platform.meta().name
if isinstance(platform_type, str):
if platform.meta().name == platform_type:
if name == platform_type:
return platform
else:
if platform.meta().name == ADAPTER_NAME_2_TYPE[platform_type]:
if (
name in ADAPTER_NAME_2_TYPE
and ADAPTER_NAME_2_TYPE[name] & platform_type
):
return platform
async def send_message(
+2
View File
@@ -14,6 +14,8 @@ star_map: Dict[str, StarMetadata] = {}
class StarMetadata:
"""
插件的元数据。
当 activated 为 False 时,star_cls 可能为 None,请不要在插件未激活时调用 star_cls 的方法。
"""
name: str
+70 -33
View File
@@ -1,3 +1,7 @@
"""
插件的重载、启停、安装、卸载等操作。
"""
import inspect
import functools
import os
@@ -75,7 +79,7 @@ class PluginManager:
elif os.path.exists(os.path.join(path, d, d + ".py")):
module_str = d
else:
print(f"插件 {d} 未找到 main.py 或者 {d}.py,跳过。")
logger.info(f"插件 {d} 未找到 main.py 或者 {d}.py,跳过。")
continue
if os.path.exists(os.path.join(path, d, "main.py")) or os.path.exists(
os.path.join(path, d, d + ".py")
@@ -164,7 +168,6 @@ class PluginManager:
async def reload(self, specified_plugin_name=None):
"""扫描并加载所有的插件 当 specified_module_path 指定时,重载指定插件"""
specified_module_path = None
if specified_plugin_name:
for smd in star_registry:
@@ -203,10 +206,17 @@ class PluginManager:
)
await self._unbind_plugin(smd.name, specified_module_path)
try:
del sys.modules[specified_module_path]
except KeyError:
logger.warning(f"模块 {specified_module_path} 未载入")
return await self.load(specified_module_path)
async def load(self, specified_module_path=None, specified_dir_name=None):
"""载入插件。
当 specified_module_path 或者 specified_dir_name 不为 None 时,只载入指定的插件。
"""
inactivated_plugins: list = sp.get("inactivated_plugins", [])
inactivated_llm_tools: list = sp.get("inactivated_llm_tools", [])
alter_cmd = sp.get("alter_cmd", {})
plugin_modules = self._get_plugin_modules()
if plugin_modules is None:
@@ -214,11 +224,6 @@ class PluginManager:
fail_rec = ""
inactivated_plugins: list = sp.get("inactivated_plugins", [])
inactivated_llm_tools: list = sp.get("inactivated_llm_tools", [])
alter_cmd = sp.get("alter_cmd", {})
# 导入插件模块,并尝试实例化插件类
for plugin_module in plugin_modules:
try:
@@ -232,8 +237,11 @@ class PluginManager:
path = "data.plugins." if not reserved else "packages."
path += root_dir_name + "." + module_str
# 检查是否需要载入指定的插件
if specified_module_path and path != specified_module_path:
continue
if specified_dir_name and root_dir_name != specified_dir_name:
continue
logger.info(f"正在载入插件 {root_dir_name} ...")
@@ -287,18 +295,24 @@ class PluginManager:
except Exception:
pass
if plugin_config:
metadata.config = plugin_config
try:
metadata.star_cls = metadata.star_cls_type(
context=self.context, config=plugin_config
)
except TypeError as _:
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
if plugin_config:
metadata.config = plugin_config
try:
metadata.star_cls = metadata.star_cls_type(
context=self.context, config=plugin_config
)
except TypeError as _:
metadata.star_cls = metadata.star_cls_type(
context=self.context
)
else:
metadata.star_cls = metadata.star_cls_type(
context=self.context
)
else:
metadata.star_cls = metadata.star_cls_type(context=self.context)
logger.info(f"插件 {metadata.name} 已被禁用。")
metadata.module = module
metadata.root_dir_name = root_dir_name
@@ -331,19 +345,23 @@ class PluginManager:
)
classes = self._get_classes(module)
if plugin_config:
try:
obj = getattr(module, classes[0])(
context=self.context, config=plugin_config
) # 实例化插件类
except TypeError as _:
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
if plugin_config:
try:
obj = getattr(module, classes[0])(
context=self.context, config=plugin_config
) # 实例化插件类
except TypeError as _:
obj = getattr(module, classes[0])(
context=self.context
) # 实例化插件类
else:
obj = getattr(module, classes[0])(
context=self.context
) # 实例化插件类
else:
obj = getattr(module, classes[0])(
context=self.context
) # 实例化插件类
logger.info(f"插件 {metadata.name} 已被禁用。")
metadata = None
metadata = self._load_plugin_metadata(
@@ -426,7 +444,8 @@ class PluginManager:
async def install_plugin(self, repo_url: str, proxy=""):
plugin_path = await self.updator.install(repo_url, proxy)
# reload the plugin
await self.reload()
dir_name = os.path.basename(plugin_path)
await self.load(specified_dir_name=dir_name)
return plugin_path
async def uninstall_plugin(self, plugin_name: str):
@@ -476,6 +495,11 @@ class PluginManager:
logger.debug(f"unbind handler {v.handler_name} from {plugin_name} (map)")
del star_handlers_registry.star_handlers_map[k]
try:
del sys.modules[plugin_module_path]
except KeyError:
logger.warning(f"模块 {plugin_module_path} 未载入")
async def update_plugin(self, plugin_name: str, proxy=""):
"""升级一个插件"""
plugin = self.context.get_registered_star(plugin_name)
@@ -526,8 +550,15 @@ class PluginManager:
"""终止插件,调用插件的 terminate() 和 __del__() 方法"""
logging.info(f"正在终止插件 {star_metadata.name} ...")
if not star_metadata.activated:
# 说明之前已经被禁用了
logger.debug(f"插件 {star_metadata.name} 未被激活,不需要终止,跳过。")
return
if hasattr(star_metadata.star_cls, "__del__"):
asyncio.get_event_loop().run_in_executor(star_metadata.star_cls.__del__)
asyncio.get_event_loop().run_in_executor(
None, star_metadata.star_cls.__del__
)
else:
await star_metadata.star_cls.terminate()
@@ -541,12 +572,17 @@ class PluginManager:
# 启用插件启用的 llm_tool
for func_tool in llm_tools.func_list:
if func_tool.handler_module_path == plugin.module_path:
if (
func_tool.handler_module_path == plugin.module_path
and func_tool.name in inactivated_llm_tools
):
inactivated_llm_tools.remove(func_tool.name)
func_tool.active = True
sp.put("inactivated_llm_tools", inactivated_llm_tools)
plugin.activated = True
await self.reload(plugin_name)
# plugin.activated = True
async def install_plugin_from_file(self, zip_file_path: str):
dir_name = os.path.basename(zip_file_path).replace(".zip", "")
@@ -559,4 +595,5 @@ class PluginManager:
os.remove(zip_file_path)
except BaseException as e:
logger.warning(f"删除插件压缩包失败: {str(e)}")
await self.reload()
# await self.reload()
await self.load(desti_dir)
+198
View File
@@ -0,0 +1,198 @@
"""
会话控制
"""
import abc
import asyncio
import time
import functools
import copy
import astrbot.core.message.components as Comp
from typing import Dict, Any, Callable, Awaitable, List
from astrbot.core.platform import AstrMessageEvent
USER_SESSIONS: Dict[str, "SessionWaiter"] = {} # 存储 SessionWaiter 实例
FILTERS: List["SessionFilter"] = [] # 存储 SessionFilter 实例
class SessionController:
"""
控制一个 Session 是否已经结束
"""
def __init__(self):
self.future = asyncio.Future()
self.current_event: asyncio.Event = None
"""当前正在等待的所用的异步事件"""
self.ts: float = None
"""上次保持(keep)开始时的时间"""
self.timeout: float | int = None
"""上次保持(keep)开始时的超时时间"""
self.history_chains: List[List[Comp.BaseMessageComponent]] = []
def stop(self, error: Exception = None):
"""立即结束这个会话"""
if not self.future.done():
if error:
self.future.set_exception(error)
else:
self.future.set_result(None)
def keep(self, timeout: float | int = 0, reset_timeout=False):
"""保持这个会话
Args:
timeout (float): 必填。会话超时时间。
当 reset_timeout 设置为 True 时, 代表重置超时时间, timeout 必须 > 0, 如果 <= 0 则立即结束会话。
当 reset_timeout 设置为 False 时, 代表继续维持原来的超时时间, 新 timeout = 原来剩余的timeout + timeout (可以 < 0)
"""
new_ts = time.time()
if reset_timeout:
if timeout <= 0:
self.stop()
return
else:
left_timeout = self.timeout - (new_ts - self.ts)
timeout = left_timeout + timeout
if timeout <= 0:
self.stop()
return
if self.current_event and not self.current_event.is_set():
self.current_event.set() # 通知上一个 keep 结束
new_event = asyncio.Event()
self.ts = new_ts
self.current_event = new_event
self.timeout = timeout
asyncio.create_task(self._holding(new_event, timeout)) # 开始新的 keep
async def _holding(self, event: asyncio.Event, timeout: int):
"""等待事件结束或超时"""
try:
await asyncio.wait_for(event.wait(), timeout)
except asyncio.TimeoutError:
if not self.future.done():
self.future.set_exception(TimeoutError("等待超时"))
except asyncio.CancelledError:
pass # 避免报错
# finally:
def get_history_chains(self) -> List[List[Comp.BaseMessageComponent]]:
"""获取历史消息链"""
return self.history_chains
class SessionFilter:
"""如何界定一个会话"""
@abc.abstractmethod
def filter(self, event: AstrMessageEvent) -> str:
"""根据事件返回一个会话标识符"""
pass
class DefaultSessionFilter(SessionFilter):
def filter(self, event: AstrMessageEvent) -> str:
"""默认实现,返回发送者的 ID 作为会话标识符"""
return event.get_sender_id()
class SessionWaiter:
def __init__(
self,
session_filter: SessionFilter,
session_id: str,
record_history_chains: bool,
):
self.session_id = session_id
self.session_filter = session_filter
self.handler: Callable[[str], Awaitable[Any]] | None = None # 处理函数
self.session_controller = SessionController()
self.record_history_chains = record_history_chains
"""是否记录历史消息链"""
self._lock = asyncio.Lock()
"""需要保证一个 session 同时只有一个 trigger"""
async def register_wait(
self, handler: Callable[[str], Awaitable[Any]], timeout: int = 30
) -> Any:
"""等待外部输入并处理"""
self.handler = handler
USER_SESSIONS[self.session_id] = self
# 开始一个会话保持事件
self.session_controller.keep(timeout, reset_timeout=True)
try:
return await self.session_controller.future
except Exception as e:
self._cleanup(e)
raise e
finally:
self._cleanup()
def _cleanup(self, error: Exception = None):
"""清理会话"""
USER_SESSIONS.pop(self.session_id, None)
try:
FILTERS.remove(self.session_filter)
except ValueError:
pass
self.session_controller.stop(error)
@classmethod
async def trigger(cls, session_id: str, event: AstrMessageEvent):
"""外部输入触发会话处理"""
session = USER_SESSIONS.get(session_id, None)
if not session or session.session_controller.future.done():
return
async with session._lock:
if not session.session_controller.future.done():
if session.record_history_chains:
session.session_controller.history_chains.append(
[copy.deepcopy(comp) for comp in event.get_messages()]
)
try:
# TODO: 这里使用 create_task,跟踪 task,防止超时后这里 handler 仍然在执行
await session.handler(session.session_controller, event)
except Exception as e:
session.session_controller.stop(e)
def session_waiter(timeout: int = 30, record_history_chains: bool = False):
"""
装饰器:自动将函数注册为 SessionWaiter 处理函数,并等待外部输入触发执行。
:param timeout: 超时时间(秒)
:param record_history_chain: 是否自动记录历史消息链。可以通过 controller.get_history_chains() 获取。深拷贝。
"""
def decorator(func: Callable[[str], Awaitable[Any]]):
@functools.wraps(func)
async def wrapper(
event: AstrMessageEvent,
session_filter: SessionFilter = None,
*args,
**kwargs,
):
if not session_filter:
session_filter = DefaultSessionFilter()
if not isinstance(session_filter, SessionFilter):
raise ValueError("session_filter 必须是 SessionFilter")
session_id = session_filter.filter(event)
FILTERS.append(session_filter)
waiter = SessionWaiter(session_filter, session_id, record_history_chains)
return await waiter.register_wait(func, timeout)
return wrapper
return decorator
+7 -5
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)
@@ -149,9 +151,10 @@ class ConfigRoute(Route):
plugin_name = request.args.get("plugin_name", "unknown")
try:
await self._save_plugin_configs(post_configs, plugin_name)
await self.core_lifecycle.plugin_manager.reload(plugin_name)
return (
Response()
.ok(None, f"保存插件 {plugin_name} 成功~ 机器人正在重载配置")
.ok(None, f"保存插件 {plugin_name} 成功~ 机器人正在重载插件")
.__dict__
)
except Exception as e:
@@ -315,6 +318,5 @@ class ConfigRoute(Route):
try:
save_config(post_configs, md.config)
self.core_lifecycle.restart()
except Exception as e:
raise e
+4 -3
View File
@@ -188,7 +188,7 @@ class PluginRoute(Route):
try:
logger.info(f"正在安装插件 {repo_url}")
await self.plugin_manager.install_plugin(repo_url, proxy)
self.core_lifecycle.restart()
# self.core_lifecycle.restart()
logger.info(f"安装插件 {repo_url} 成功。")
return Response().ok(None, "安装成功。").__dict__
except Exception as e:
@@ -203,7 +203,7 @@ class PluginRoute(Route):
file_path = f"data/temp/{file.filename}"
await file.save(file_path)
await self.plugin_manager.install_plugin_from_file(file_path)
self.core_lifecycle.restart()
# self.core_lifecycle.restart()
logger.info(f"安装插件 {file.filename} 成功")
return Response().ok(None, "安装成功。").__dict__
except Exception as e:
@@ -229,7 +229,8 @@ class PluginRoute(Route):
try:
logger.info(f"正在更新插件 {plugin_name}")
await self.plugin_manager.update_plugin(plugin_name, proxy)
self.core_lifecycle.restart()
# self.core_lifecycle.restart()
await self.plugin_manager.reload(plugin_name)
logger.info(f"更新插件 {plugin_name} 成功。")
return Response().ok(None, "更新成功。").__dict__
except Exception as e:
+1
View File
@@ -19,6 +19,7 @@ class StaticFileRoute(Route):
"/platforms",
"/providers",
"/about",
"/extension-marketplace",
]
for i in index_:
self.app.add_url_rule(i, view_func=self.index)
+3 -1
View File
@@ -25,7 +25,9 @@ class AstrBotDashboard:
self.config = core_lifecycle.astrbot_config
self.data_path = os.path.abspath(os.path.join(DATAPATH, "dist"))
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
self.app.config['MAX_CONTENT_LENGTH'] = 128 * 1024 * 1024 # 将 Flask 允许的最大上传文件体大小设置为 128 MB
self.app.config["MAX_CONTENT_LENGTH"] = (
128 * 1024 * 1024
) # 将 Flask 允许的最大上传文件体大小设置为 128 MB
self.app.json.sort_keys = False
self.app.before_request(self.auth_middleware)
# token 用于验证请求
+15
View File
@@ -0,0 +1,15 @@
# What's Changed
1. ✨ 新增: 支持插件会话控制 API
2. ✨ 新增: add template of LMStudio #691
3. ✨ 新增: 更好的插件卡片的 UI,插件卡片支持显示 logo,推荐插件页面
4. ✨ 新增: 支持当消息只有 @bot 时,下一条发送人的消息**直接唤醒机器人** #714
5. ⚡ 优化: Webchat 和 Gewechat 的图片、语音等主动消息发送 #710
6. ⚡ 优化: 完善了插件的启用和禁用的生命周期管理
7. ⚡ 优化: 安装插件/更新插件/保存插件配置后直接热重载而不重启;优化了 plugin 指令
8. 🐛 修复: 主动人格情况下人格失效的问题 #719 #712
9. 🐛 修复: 404 error after installing plugins
10. 🐛 修复: telegram cannot handle /start #620
11. 🐛 修复: 修复插件在带了 __del__ 之后无法被禁用和重载的问题
12. 🐛 修复: context.get_platform() error
13. 🐛 修复: Telegram 适配器使用代理地址无法获取图片 #723
+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 进行 '+' 操作的问题
+188 -18
View File
@@ -1,32 +1,202 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
const props = defineProps({
title: String,
link: String,
logo: String,
has_update: Boolean,
activated: Boolean,
extension: {
type: Object,
required: true,
},
marketMode: {
type: Boolean,
default: false,
},
highlight: {
type: Boolean,
default: false,
},
});
//
const emit = defineEmits([
'configure',
'update',
'reload',
'install',
'uninstall',
'toggle-activation',
'view-handlers'
]);
const open = (link: string | undefined) => {
window.open(link, '_blank');
};
const reveal = ref(false);
//
const hasUpdate = computed(() => {
if (!props.extension.online_version || !props.extension.version) return false;
return props.extension.online_version !== props.extension.version;
});
//
const configure = () => {
emit('configure', props.extension);
};
const updateExtension = () => {
emit('update', props.extension);
};
const reloadExtension = () => {
emit('reload', props.extension);
};
const uninstallExtension = () => {
emit('uninstall', props.extension);
};
const toggleActivation = () => {
emit('toggle-activation', props.extension);
};
const viewHandlers = () => {
emit('view-handlers', props.extension);
};
</script>
<template>
<v-card variant="outlined" elevation="0" class="withbg">
<v-card-item style="padding: 10px 12px">
<div class="d-sm-flex align-center justify-space-between">
<img v-if="logo" :src="logo" alt="logo" style="width: 40px; height: 40px; margin-right: 8px;">
<v-card-title style="font-size: 15px; max-width: 70%">{{ props.title }}</v-card-title>
<v-spacer></v-spacer>
<v-icon color="success" v-if="!activated">mdi-cancel</v-icon>
<v-icon color="success" v-if="has_update">mdi-arrow-up-bold</v-icon>
<v-btn size="small" text="Read" variant="flat" border @click="open(props.link)">帮助</v-btn>
<v-card class="mx-auto d-flex flex-column" :elevation="highlight ? 0 : 1"
:style="{ height: $vuetify.display.xs ? '250px' : '220px', backgroundColor: highlight ? '#FAF0DB' : '#ffffff', color: highlight ? '#000' : '#000000' }">
<v-card-text style="padding: 16px; padding-bottom: 0px; display: flex; justify-content: space-between;">
<div class="flex-grow-1">
<div>{{ extension.author }} /</div>
<p class="text-h3 font-weight-black" :class="{ 'text-h4': $vuetify.display.xs }">
{{ extension.name }}
<v-tooltip location="top" v-if="hasUpdate && !marketMode">
<template v-slot:activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps" color="warning" class="ml-2" icon="mdi-update" size="small"></v-icon>
</template>
<span>有新版本可用: {{ extension.online_version }}</span>
</v-tooltip>
<v-tooltip location="top" v-if="!extension.activated && !marketMode">
<template v-slot:activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps" color="error" class="ml-2" icon="mdi-cancel" size="small"></v-icon>
</template>
<span>该插件已经被禁用</span>
</v-tooltip>
</p>
<div class="mt-1 d-flex flex-wrap">
<v-chip color="primary" label size="small">
<v-icon icon="mdi-source-branch" start></v-icon>
{{ extension.version }}
</v-chip>
<v-chip v-if="hasUpdate" color="warning" label size="small" class="ml-2">
<v-icon icon="mdi-arrow-up-bold" start></v-icon>
{{ extension.online_version }}
</v-chip>
<v-chip color="primary" label size="small" class="ml-2" v-if="extension.handlers?.length">
<v-icon icon="mdi-cogs" start></v-icon>
{{ extension.handlers?.length }}个行为
</v-chip>
</div>
<div class="mt-2" :class="{ 'text-caption': $vuetify.display.xs }">
{{ extension.desc }}
</div>
</div>
<div class="extension-image-container" v-if="extension.logo">
<img :src="extension.logo" :style="{
height: $vuetify.display.xs ? '75px' : '100px',
width: $vuetify.display.xs ? '75px' : '100px',
borderRadius: '8px',
objectFit: 'cover',
objectPosition: 'center'
}" alt="logo" />
</div>
</v-card-item>
<v-divider></v-divider>
<v-card-text style="padding: 16px;">
<slot />
</v-card-text>
<v-card-actions style="padding: 0px; margin-top: auto;">
<v-btn color="teal-accent-4" text="帮助" variant="text" @click="open(extension.repo)"></v-btn>
<v-btn v-if="!marketMode" color="teal-accent-4" text="操作" variant="text" @click="reveal = true"></v-btn>
<v-btn v-if="marketMode && !extension?.installed" color="teal-accent-4" text="安装" variant="text"
@click="emit('install', extension)"></v-btn>
<v-btn v-if="marketMode && extension?.installed" color="teal-accent-4" text="已安装" variant="text" disabled></v-btn>
</v-card-actions>
<v-expand-transition v-if="!marketMode">
<v-card v-if="reveal" class="position-absolute w-100" height="100%"
style="bottom: 0; display: flex; flex-direction: column;">
<v-card-text style="overflow-y: auto;">
<div class="d-flex align-center mb-4">
<img v-if="extension.logo" :src="extension.logo"
style="height: 50px; width: 50px; border-radius: 8px; margin-right: 16px;" alt="扩展图标" />
<h3>{{ extension.name }}</h3>
</div>
<div class="mt-4" :style="{
justifyContent: 'center',
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: '8px',
flexDirection: $vuetify.display.xs ? 'column' : 'row'
}">
<v-btn prepend-icon="mdi-cog" color="primary" variant="tonal" @click="configure"
:block="$vuetify.display.xs">
插件配置
</v-btn>
<v-btn prepend-icon="mdi-delete" color="error" variant="tonal" @click="uninstallExtension"
:block="$vuetify.display.xs">
卸载插件
</v-btn>
<v-btn prepend-icon="mdi-reload" color="primary" variant="tonal" @click="reloadExtension"
:block="$vuetify.display.xs">
重载插件
</v-btn>
<v-btn :prepend-icon="extension.activated ? 'mdi-cancel' : 'mdi-check-circle'"
:color="extension.activated ? 'error' : 'success'" variant="tonal" @click="toggleActivation"
:block="$vuetify.display.xs">
{{ extension.activated ? '禁用' : '启用' }}插件
</v-btn>
<v-btn prepend-icon="mdi-cogs" color="info" variant="tonal" @click="viewHandlers"
:block="$vuetify.display.xs">
查看行为 ({{ extension.handlers.length }})
</v-btn>
<v-btn prepend-icon="mdi-update" color="primary" variant="tonal" :disabled="!hasUpdate"
@click="updateExtension" :block="$vuetify.display.xs">
更新到 {{ extension.online_version || extension.version }}
</v-btn>
</div>
</v-card-text>
<v-card-actions class="pt-0 d-flex justify-center">
<v-btn color="teal-accent-4" text="返回" variant="text" @click="reveal = false"></v-btn>
</v-card-actions>
</v-card>
</v-expand-transition>
</v-card>
</template>
<style scoped>
.extension-image-container {
display: flex;
align-items: center;
margin-left: 12px;
}
@media (max-width: 600px) {
.extension-image-container {
margin-left: 8px;
}
}
</style>
+3 -1
View File
@@ -72,7 +72,9 @@ export const useCommonStore = defineStore({
"installed": false,
"version": res.data.data[key]?.version ? res.data.data[key].version : "未知",
"social_link": res.data.data[key]?.social_link,
"tags": res.data.data[key]?.tags ? res.data.data[key].tags : []
"tags": res.data.data[key]?.tags ? res.data.data[key].tags : [],
"logo": res.data.data[key]?.logo ? res.data.data[key].logo : "",
"pinned": res.data.data[key]?.pinned ? res.data.data[key].pinned : false,
})
}
this.pluginMarketData = data;
+86 -71
View File
@@ -16,89 +16,101 @@ import { useCommonStore } from '@/stores/common';
<v-col cols="12" md="12">
<v-card>
<v-card-title class="d-flex align-center pe-2">
🧩 插件市场
<v-card-title>
<div class="pl-2 pt-2 d-flex align-center pe-2">
<h2> 插件市场</h2>
<v-btn icon size="small" style="margin-left: 8px" variant="plain" @click="jumpToPluginMarket()">
<v-icon size="small">mdi-help</v-icon>
<v-tooltip activator="parent" location="start">
<span>
如无法显示请单击此按钮跳转至插件市场复制想安装插件对应的
`repo`
链接然后点击右下角 + 号安装或打开链接下载压缩包安装
<v-btn icon size="small" style="margin-left: 8px" variant="plain" @click="jumpToPluginMarket()">
<v-icon size="small">mdi-help</v-icon>
<v-tooltip activator="parent" location="start">
<span>
如无法显示请单击此按钮跳转至插件市场复制想安装插件对应的
`repo`
链接然后点击右下角 + 号安装或打开链接下载压缩包安装
如果因为网络问题安装失败点击设置页选择 GitHub 加速地址或前往仓库下载压缩包然后本地上传
</span>
</v-tooltip>
</v-btn>
如果因为网络问题安装失败点击设置页选择 GitHub 加速地址或前往仓库下载压缩包然后本地上传
</span>
<v-btn icon @click="isListView = !isListView" size="small" style="margin-left: auto;"
variant="plain">
<v-icon>{{ isListView ? 'mdi-view-grid' : 'mdi-view-list' }}</v-icon>
</v-btn>
<v-spacer></v-spacer>
</v-tooltip>
</v-btn>
<v-text-field v-model="marketSearch" density="compact" label="Search"
prepend-inner-icon="mdi-magnify" variant="solo-filled" flat hide-details
single-line></v-text-field>
</div>
<v-btn icon @click="isListView = !isListView" size="small" style="margin-left: auto;"
variant="plain">
<v-icon>{{ isListView ? 'mdi-view-grid' : 'mdi-view-list' }}</v-icon>
</v-btn>
<v-spacer></v-spacer>
<v-text-field v-model="marketSearch" density="compact" label="Search"
prepend-inner-icon="mdi-magnify" variant="solo-filled" flat hide-details
single-line></v-text-field>
</v-card-title>
<v-divider></v-divider>
<v-card-text>
<template v-if="isListView">
<v-col cols="12" md="12">
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name" :loading="loading_"
v-model:search="marketSearch" :filter-keys="['name', 'desc']">
<template v-slot:item.name="{ item }">
<span v-if="item?.repo"><a :href="item?.repo"
style="color: #000; text-decoration:none">{{
item.name }}</a></span>
<span v-else>{{ item.name }}</span>
</template>
<template v-slot:item.author="{ item }">
<span v-if="item?.social_link"><a :href="item?.social_link">{{ item.author }}</a></span>
<span v-else>{{ item.author }}</span>
</template>
<template v-slot:item.tags="{ item }">
<span v-if="item.tags.length === 0"></span>
<v-chip v-for="tag in item.tags" :key="tag" color="primary" size="small">{{ tag
<div v-if="pinnedPlugins.length > 0" class="mt-4">
<h2>🥳 推荐</h2>
<v-row style="margin-top: 8px;">
<v-col cols="12" md="6" lg="6" v-for="plugin in pinnedPlugins">
<ExtensionCard :extension="plugin" market-mode="true" :highlight="true">
</ExtensionCard>
</v-col>
</v-row>
</div>
<div v-if="isListView" class="mt-4">
<h2>📦 全部插件</h2>
<v-col cols="12" md="12" style="padding: 0px;">
<v-data-table :headers="pluginMarketHeaders" :items="pluginMarketData" item-key="name"
:loading="loading_" v-model:search="marketSearch"
:filter-keys="['name', 'desc', 'author']">
<template v-slot:item.name="{ item }">
<div class="d-flex align-center">
<img v-if="item.logo" :src="item.logo"
style="height: 80px; width: 80px; margin-right: 8px; border-radius: 8px; margin-top: 8px; margin-bottom: 8px;"
alt="logo">
<span v-if="item?.repo"><a :href="item?.repo"
style="color: #000; text-decoration:none">{{
item.name }}</a></span>
<span v-else>{{ item.name }}</span>
</div>
</template>
<template v-slot:item.author="{ item }">
<span v-if="item?.social_link"><a :href="item?.social_link">{{ item.author
}}</a></span>
<span v-else>{{ item.author }}</span>
</template>
<template v-slot:item.tags="{ item }">
<span v-if="item.tags.length === 0"></span>
<v-chip v-for="tag in item.tags" :key="tag" color="primary" size="small">{{ tag
}}</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<v-btn v-if="!item.installed" class="text-none mr-2" size="small" text="Read"
variant="flat" border @click="extension_url = item.repo; newExtension()">安装</v-btn>
<v-btn v-else class="text-none mr-2" size="small" text="Read" variant="flat" border
disabled>已安装</v-btn>
</template>
</v-data-table>
</v-col>
</template>
<template v-else>
<v-row style="margin: 8px;">
<v-col cols="12" md="6" lg="3" v-for="plugin in filteredPluginMarketData">
<ExtensionCard :key="plugin.name" :title="plugin.name" :link="plugin.repo"
style="margin-bottom: 4px;">
<div style="min-height: 130px; max-height: 130px; overflow: hidden;">
<p style="font-weight: bold;">By @{{ plugin.author }}</p>
{{ plugin.desc }}
</div>
<div class="d-flex align-center gap-2">
<v-btn v-if="!plugin.installed" class="text-none mr-2" size="small" text="Read"
</template>
<template v-slot:item.actions="{ item }">
<v-btn v-if="!item.installed" class="text-none mr-2" size="small" text="Read"
variant="flat" border
@click="extension_url = plugin.repo; newExtension()">安装</v-btn>
@click="extension_url = item.repo; newExtension()">安装</v-btn>
<v-btn v-else class="text-none mr-2" size="small" text="Read" variant="flat" border
disabled>已安装</v-btn>
</div>
</ExtensionCard>
</template>
</v-data-table>
</v-col>
</v-row>
</template>
</div>
<div v-else class="mt-4">
<h2>📦 全部插件</h2>
<v-row style="margin-top: 16px;">
<v-col cols="12" md="6" lg="6" v-for="plugin in filteredPluginMarketData">
<ExtensionCard :extension="plugin" market-mode="true">
</ExtensionCard>
</v-col>
</v-row>
</div>
</v-card-text>
</v-card>
@@ -221,6 +233,9 @@ export default {
return this.pluginMarketData.filter(plugin =>
plugin.name.toLowerCase().includes(search)
);
},
pinnedPlugins() {
return this.pluginMarketData.filter(plugin => plugin?.pinned);
}
},
mounted() {
@@ -339,7 +354,7 @@ export default {
this.upload_file = "";
this.onLoadingDialogResult(1, res.data.message);
this.dialog = false;
this.$refs.wfr.check();
// this.$refs.wfr.check();
}).catch((err) => {
this.loading_ = false;
this.onLoadingDialogResult(2, err, -1);
@@ -362,7 +377,7 @@ export default {
this.extension_url = "";
this.onLoadingDialogResult(1, res.data.message);
this.dialog = false;
this.$refs.wfr.check();
// this.$refs.wfr.check();
}).catch((err) => {
this.loading_ = false;
this.toast("安装插件失败: " + err, "error");
+275 -328
View File
@@ -6,6 +6,237 @@ import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import axios from 'axios';
import { useCommonStore } from '@/stores/common';
// setup
import { ref, computed, onMounted, reactive } from 'vue';
const commonStore = useCommonStore();
const extension_data = reactive({
data: [],
message: ""
});
const showReserved = ref(false);
const snack_message = ref("");
const snack_show = ref(false);
const snack_success = ref("success");
const configDialog = ref(false);
const extension_config = reactive({
metadata: {},
config: {}
});
const pluginMarketData = ref([]);
const loadingDialog = reactive({
show: false,
title: "加载中...",
statusCode: 0, // 0: loading, 1: success, 2: error,
result: ""
});
const showPluginInfoDialog = ref(false);
const selectedPlugin = ref({});
const curr_namespace = ref("");
const wfr = ref(null);
const plugin_handler_info_headers = [
{ title: '行为类型', key: 'event_type_h' },
{ title: '描述', key: 'desc', maxWidth: '250px' },
{ title: '具体类型', key: 'type' },
{ title: '触发方式', key: 'cmd' },
];
const filteredExtensions = computed(() => {
if (showReserved.value) {
return extension_data.data;
}
return extension_data.data.filter(ext => !ext.reserved);
});
//
const toggleShowReserved = () => {
showReserved.value = !showReserved.value;
};
const toast = (message, success) => {
snack_message.value = message;
snack_show.value = true;
snack_success.value = success;
};
const resetLoadingDialog = () => {
loadingDialog.show = false;
loadingDialog.title = "加载中...";
loadingDialog.statusCode = 0;
loadingDialog.result = "";
};
const onLoadingDialogResult = (statusCode, result, timeToClose = 2000) => {
loadingDialog.statusCode = statusCode;
loadingDialog.result = result;
if (timeToClose === -1) return;
setTimeout(resetLoadingDialog, timeToClose);
};
const getExtensions = async () => {
try {
const res = await axios.get('/api/plugin/get');
Object.assign(extension_data, res.data);
checkUpdate();
} catch (err) {
toast(err, "error");
}
};
const checkUpdate = () => {
const onlinePluginsMap = new Map();
const onlinePluginsNameMap = new Map();
pluginMarketData.value.forEach(plugin => {
if (plugin.repo) {
onlinePluginsMap.set(plugin.repo.toLowerCase(), plugin);
}
onlinePluginsNameMap.set(plugin.name, plugin);
});
extension_data.data.forEach(extension => {
const repoKey = extension.repo?.toLowerCase();
const onlinePlugin = repoKey ? onlinePluginsMap.get(repoKey) : null;
const onlinePluginByName = onlinePluginsNameMap.get(extension.name);
const matchedPlugin = onlinePlugin || onlinePluginByName;
if (matchedPlugin) {
extension.online_version = matchedPlugin.version;
extension.has_update = extension.version !== matchedPlugin.version &&
matchedPlugin.version !== "未知";
} else {
extension.has_update = false;
}
extension.logo = matchedPlugin?.logo;
});
};
const uninstallExtension = async (extension_name) => {
toast("正在卸载" + extension_name, "primary");
try {
const res = await axios.post('/api/plugin/uninstall', { name: extension_name });
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
}
Object.assign(extension_data, res.data);
toast(res.data.message, "success");
getExtensions();
} catch (err) {
toast(err, "error");
}
};
const updateExtension = async (extension_name) => {
loadingDialog.show = true;
try {
const res = await axios.post('/api/plugin/update', {
name: extension_name,
proxy: localStorage.getItem('selectedGitHubProxy') || ""
});
if (res.data.status === "error") {
onLoadingDialogResult(2, res.data.message, -1);
return;
}
Object.assign(extension_data, res.data);
onLoadingDialogResult(1, res.data.message);
} catch (err) {
toast(err, "error");
}
};
const pluginOn = async (extension) => {
try {
const res = await axios.post('/api/plugin/on', { name: extension.name });
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
}
toast(res.data.message, "success");
getExtensions();
} catch (err) {
toast(err, "error");
}
};
const pluginOff = async (extension) => {
try {
const res = await axios.post('/api/plugin/off', { name: extension.name });
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
}
toast(res.data.message, "success");
getExtensions();
} catch (err) {
toast(err, "error");
}
};
const openExtensionConfig = async (extension_name) => {
curr_namespace.value = extension_name;
configDialog.value = true;
try {
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");
}
};
const updateConfig = async () => {
try {
const res = await axios.post('/api/config/plugin/update?plugin_name=' + curr_namespace.value, extension_config.config);
if (res.data.status === "ok") {
toast(res.data.message, "success");
} else {
toast(res.data.message, "error");
}
configDialog.value = false;
extension_config.metadata = {};
extension_config.config = {};
getExtensions();
} catch (err) {
toast(err, "error");
}
};
const showPluginInfo = (plugin) => {
selectedPlugin.value = plugin;
showPluginInfoDialog.value = true;
};
const reloadPlugin = async (plugin_name) => {
try {
const res = await axios.post('/api/plugin/reload', { name: plugin_name });
if (res.data.status === "error") {
toast(res.data.message, "error");
return;
}
toast("重载成功", "success");
getExtensions();
} catch (err) {
toast(err, "error");
}
};
//
onMounted(async () => {
await getExtensions();
try {
const data = await commonStore.getPluginCollections();
pluginMarketData.value = data;
checkUpdate();
} catch (err) {
console.error("获取插件市场数据失败:", err);
}
});
</script>
<template>
@@ -14,145 +245,94 @@ import { useCommonStore } from '@/stores/common';
<div style="background-color: white; width: 100%; padding: 16px; border-radius: 10px;">
<div style="display: flex; align-items: center;">
<h3>🧩 已安装的插件</h3>
<v-dialog max-width="500px">
<v-btn class="text-none ml-2" size="small" variant="flat" border @click="toggleShowReserved">
{{ showReserved ? '隐藏系统保留插件' : '显示系统保留插件' }}
</v-btn>
<v-dialog max-width="500px" v-if="extension_data.message">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" v-if="extension_data.message" icon size="small" color="error"
style="margin-left: auto;" variant="plain">
<v-btn v-bind="props" icon size="small" color="error" style="margin-left: auto;" variant="plain">
<v-icon>mdi-alert-circle</v-icon>
</v-btn>
</template>
<template v-slot:default="{ isActive }">
<v-card>
<v-card-title class="headline">错误信息</v-card-title>
<v-card-text>{{ extension_data.message }}
<br>
<v-card-text>
{{ extension_data.message }}<br>
<small>详情请检查控制台</small>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="isActive.value = false">关闭</v-btn>
</v-card-actions>
</v-card>
</template>
</v-dialog>
</div>
</div>
</v-col>
<v-col cols="12" md="6" lg="3" v-for="extension in extension_data.data">
<ExtensionCard :key="extension.name" :title="extension.name" :link="extension.repo" :logo="extension?.logo"
:has_update="extension.has_update" style="margin-bottom: 4px;" :activated="extension.activated">
<div style="min-height: 140px; max-height: 140px; overflow: auto;">
<div>
<span style="font-weight: bold ;">By @{{ extension.author }}</span>
<span> | {{ extension.handlers.length }} 个行为</span>
</div>
<span> 当前: <v-chip size="small" color="primary">{{ extension.version }}</v-chip>
<span v-if="extension.online_version">
| 最新: <v-chip size="small" color="primary">{{ extension.online_version }}</v-chip>
</span>
<span v-if="extension.has_update" style="font-weight: bold;">有更新
</span>
</span>
<p style="margin-top: 8px;">{{ extension.desc }}</p>
<a style="font-size: 12px; cursor: pointer; text-decoration: underline; color: #555;"
@click="reloadPlugin(extension.name)">重载插件</a>
</div>
<div class="d-flex align-center gap-2 " style="overflow-x: auto;">
<v-btn v-if="!extension.reserved" class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="openExtensionConfig(extension.name)">配置</v-btn>
<v-btn v-if="!extension.reserved" class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="updateExtension(extension.name)">更新</v-btn>
<v-btn v-if="!extension.reserved" class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="uninstallExtension(extension.name)">卸载</v-btn>
<!-- <span v-else>保留插件</span> -->
<v-btn class="text-none mr-2" size="small" text="Read" variant="flat" border v-if="extension.activated"
@click="pluginOff(extension)">禁用</v-btn>
<v-btn class="text-none mr-2" size="small" text="Read" variant="flat" border v-else
@click="pluginOn(extension)">启用</v-btn>
<v-btn class="text-none mr-2" size="small" text="Read" variant="flat" border
@click="showPluginInfo(extension)">行为</v-btn>
</div>
<v-col cols="10" md="6" lg="6" v-for="extension in filteredExtensions" :key="extension.name">
<ExtensionCard :extension="extension"
@configure="openExtensionConfig(extension.name)"
@uninstall="uninstallExtension(extension.name)"
@update="updateExtension(extension.name)"
@reload="reloadPlugin(extension.name)"
@toggle-activation="extension.activated ? pluginOff(extension) : pluginOn(extension)"
@view-handlers="showPluginInfo(extension)">
</ExtensionCard>
</v-col>
</v-row>
<!-- 配置对话框 -->
<v-dialog v-model="configDialog" width="1000">
<template v-slot:activator="{ props }">
</template>
<v-card>
<v-card-title>
<span class="text-h5">插件配置</span>
</v-card-title>
<v-card-title class="text-h5">插件配置</v-card-title>
<v-card-text>
<v-container>
<AstrBotConfig v-if="extension_config.metadata" :metadata="extension_config.metadata"
:iterable="extension_config.config" :metadataKey=curr_namespace></AstrBotConfig>
<p v-else>这个插件没有配置</p>
</v-container>
<AstrBotConfig v-if="extension_config.metadata" :metadata="extension_config.metadata"
:iterable="extension_config.config" :metadataKey="curr_namespace" />
<p v-else>这个插件没有配置</p>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="updateConfig">
保存并关闭
</v-btn>
<v-btn color="blue-darken-1" variant="text" @click="configDialog = false">
关闭
</v-btn>
<v-btn color="blue-darken-1" variant="text" @click="updateConfig">保存并关闭</v-btn>
<v-btn color="blue-darken-1" variant="text" @click="configDialog = false">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 加载对话框 -->
<v-dialog v-model="loadingDialog.show" width="700" persistent>
<v-card>
<v-card-title>
<span class="text-h5">{{ loadingDialog.title }}</span>
</v-card-title>
<v-card-title class="text-h5">{{ loadingDialog.title }}</v-card-title>
<v-card-text>
<v-container>
<v-row>
<v-col cols="12">
<v-progress-linear indeterminate color="primary"
v-if="loadingDialog.statusCode === 0"></v-progress-linear>
</v-col>
</v-row>
<div class="py-12 text-center" v-if="loadingDialog.statusCode !== 0">
<v-icon class="mb-6" color="success" icon="mdi-check-circle-outline" size="128"
v-if="loadingDialog.statusCode === 1"></v-icon>
<v-icon class="mb-6" color="error" icon="mdi-alert-circle-outline" size="128"
v-if="loadingDialog.statusCode === 2"></v-icon>
<div class="text-h4 font-weight-bold">{{ loadingDialog.result }}</div>
</div>
<div style="margin-top: 32px;">
<h3>日志</h3>
<ConsoleDisplayer historyNum="10" style="height: 200px; margin-top: 16px;"></ConsoleDisplayer>
</div>
</v-container>
<v-progress-linear v-if="loadingDialog.statusCode === 0" indeterminate color="primary" class="mb-4"></v-progress-linear>
<div v-if="loadingDialog.statusCode !== 0" class="py-8 text-center">
<v-icon class="mb-6" :color="loadingDialog.statusCode === 1 ? 'success' : 'error'"
:icon="loadingDialog.statusCode === 1 ? 'mdi-check-circle-outline' : 'mdi-alert-circle-outline'"
size="128"></v-icon>
<div class="text-h4 font-weight-bold">{{ loadingDialog.result }}</div>
</div>
<div style="margin-top: 32px;">
<h3>日志</h3>
<ConsoleDisplayer historyNum="10" style="height: 200px; margin-top: 16px;"></ConsoleDisplayer>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="resetLoadingDialog()">
关闭
</v-btn>
<v-btn color="blue-darken-1" variant="text" @click="resetLoadingDialog">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 插件信息对话框 -->
<v-dialog v-model="showPluginInfoDialog" width="1200">
<template v-slot:activator="{ props }">
</template>
<v-card>
<v-card-title>
<span class="text-h5">{{ selectedPlugin.name }} 插件行为</span>
</v-card-title>
<v-card-title class="text-h5">{{ selectedPlugin.name }} 插件行为</v-card-title>
<v-card-text>
<v-data-table style="font-size: 17px;" :headers="plugin_handler_info_headers" :items="selectedPlugin.handlers"
<v-data-table style="font-size: 17px;" :headers="plugin_handler_info_headers" :items="selectedPlugin.handlers"
item-key="name">
<template v-slot:header.id="{ column }">
<p style="font-weight: bold;">{{ column.title }}</p>
@@ -175,9 +355,7 @@ import { useCommonStore } from '@/stores/common';
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="showPluginInfoDialog = false">
关闭
</v-btn>
<v-btn color="blue-darken-1" variant="text" @click="showPluginInfoDialog = false">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -187,235 +365,4 @@ import { useCommonStore } from '@/stores/common';
</v-snackbar>
<WaitingForRestart ref="wfr"></WaitingForRestart>
</template>
<script>
export default {
name: 'ExtensionPage',
components: {
ExtensionCard,
WaitingForRestart,
ConsoleDisplayer,
AstrBotConfig
},
data() {
return {
extension_data: {
"data": [],
"message": ""
},
status: "",
dialog: false,
snack_message: "",
snack_show: false,
snack_success: "success",
configDialog: false,
extension_config: {
"metadata": {},
"config": {}
},
pluginMarketData: [],
loadingDialog: {
show: false,
title: "加载中...",
statusCode: 0, // 0: loading, 1: success, 2: error,
result: ""
},
showPluginInfoDialog: false,
selectedPlugin: {},
plugin_handler_info_headers: [
{ title: '行为类型', key: 'event_type_h' },
{ title: '描述', key: 'desc', maxWidth: '250px' },
{ title: '具体类型', key: 'type' },
{ title: '触发方式', key: 'cmd' },
],
commonStore: useCommonStore()
}
},
mounted() {
this.getExtensions();
//
this.commonStore.getPluginCollections().then((data) => {
this.pluginMarketData = data;
this.checkUpdate();
}).catch((err) => {
console.error("获取插件市场数据失败:", err);
});
},
methods: {
toast(message, success) {
this.snack_message = message;
this.snack_show = true;
this.snack_success = success;
},
resetLoadingDialog() {
this.loadingDialog = {
show: false,
title: "加载中...",
statusCode: 0,
result: ""
}
},
onLoadingDialogResult(statusCode, result, timeToClose = 2000) {
this.loadingDialog.statusCode = statusCode;
this.loadingDialog.result = result;
if (timeToClose === -1) {
return
}
setTimeout(() => {
this.resetLoadingDialog()
}, timeToClose);
},
getExtensions() {
axios.get('/api/plugin/get').then((res) => {
this.extension_data = res.data;
this.checkUpdate()
});
},
checkUpdate() {
// 线map
const onlinePluginsMap = new Map();
const onlinePluginsNameMap = new Map();
// 线map
this.pluginMarketData.forEach(plugin => {
if (plugin.repo) {
onlinePluginsMap.set(plugin.repo.toLowerCase(), plugin);
}
onlinePluginsNameMap.set(plugin.name, plugin);
});
//
this.extension_data.data.forEach(extension => {
// reponame线
const repoKey = extension.repo?.toLowerCase();
const onlinePlugin = repoKey ? onlinePluginsMap.get(repoKey) : null;
const onlinePluginByName = onlinePluginsNameMap.get(extension.name);
const matchedPlugin = onlinePlugin || onlinePluginByName;
if (matchedPlugin) {
extension.online_version = matchedPlugin.version;
extension.has_update = extension.version !== matchedPlugin.version &&
matchedPlugin.version !== "未知";
} else {
extension.has_update = false;
}
});
},
uninstallExtension(extension_name) {
this.toast("正在卸载" + extension_name, "primary");
axios.post('/api/plugin/uninstall',
{
name: extension_name
}).then((res) => {
if (res.data.status === "error") {
this.toast(res.data.message, "error");
return;
}
this.extension_data = res.data;
this.toast(res.data.message, "success");
this.dialog = false;
this.getExtensions();
}).catch((err) => {
this.toast(err, "error");
});
},
updateExtension(extension_name) {
this.loadingDialog.show = true;
axios.post('/api/plugin/update',
{
name: extension_name,
proxy: localStorage.getItem('selectedGitHubProxy') || ""
}).then((res) => {
if (res.data.status === "error") {
this.onLoadingDialogResult(2, res.data.message, -1);
return;
}
this.extension_data = res.data;
console.log(this.extension_data);
this.onLoadingDialogResult(1, res.data.message);
this.dialog = false;
this.$refs.wfr.check();
}).catch((err) => {
this.toast(err, "error");
});
},
pluginOn(extension) {
axios.post('/api/plugin/on',
{
name: extension.name
}).then((res) => {
if (res.data.status === "error") {
this.toast(res.data.message, "error");
return;
}
this.toast(res.data.message, "success");
this.getExtensions();
}).catch((err) => {
this.toast(err, "error");
});
},
pluginOff(extension) {
axios.post('/api/plugin/off',
{
name: extension.name
}).then((res) => {
if (res.data.status === "error") {
this.toast(res.data.message, "error");
return;
}
this.toast(res.data.message, "success");
this.getExtensions();
}).catch((err) => {
this.toast(err, "error");
});
},
openExtensionConfig(extension_name) {
this.curr_namespace = extension_name;
this.configDialog = true;
axios.get('/api/config/get?plugin_name=' + extension_name).then((res) => {
this.extension_config = res.data.data;
console.log(this.extension_config);
}).catch((err) => {
this.toast(err, "error");
});
},
updateConfig() {
axios.post('/api/config/plugin/update?plugin_name=' + this.curr_namespace, this.extension_config.config).then((res) => {
if (res.data.status === "ok") {
this.toast(res.data.message, "success");
this.$refs.wfr.check();
} else {
this.toast(res.data.message, "error");
}
}).catch((err) => {
this.toast(err, "error");
});
},
showPluginInfo(plugin) {
this.selectedPlugin = plugin;
this.showPluginInfoDialog = true;
},
reloadPlugin(plugin_name) {
axios.post('/api/plugin/reload',
{
name: plugin_name
}).then((res) => {
if (res.data.status === "error") {
this.onLoadingDialogResult(2, res.data.message, -1);
return;
}
this.toast("重载成功", "success");
this.getExtensions();
}).catch((err) => {
this.toast(err, "error");
});
}
},
}
</script>
</template>
+2 -2
View File
@@ -89,8 +89,8 @@
</template>
</v-btn>
<div v-if="showConsole" style="margin-top: 32px; ">
<ConsoleDisplayer style="background-color: #fff; height: 300px"></ConsoleDisplayer>
<div v-if="showConsole" style="margin-top: 32px">
<ConsoleDisplayer style="background-color: #000; height: 300px"></ConsoleDisplayer>
</div>
</v-card-text>
+2 -4
View File
@@ -73,12 +73,10 @@
</template>
</v-btn>
<div v-if="showConsole" style="margin-top: 32px; ">
<ConsoleDisplayer style="background-color: #fff; height: 300px"></ConsoleDisplayer>
<div v-if="showConsole" style="margin-top: 32px">
<ConsoleDisplayer style="background-color: #000; height: 300px"></ConsoleDisplayer>
</div>
</v-card-text>
</v-card>
+33 -24
View File
@@ -1,7 +1,6 @@
import aiohttp
import datetime
import builtins
import json
import astrbot.api.star as star
import astrbot.api.event.filter as filter
from astrbot.api.event import AstrMessageEvent, MessageEventResult
@@ -72,16 +71,14 @@ class Main(star.Star):
dashboard_version = await get_dashboard_version()
msg = f"""AstrBot v{VERSION}(WebUI: {dashboard_version})
AstrBot 指令:
内置指令:
[System]
/plugin: 查看插件插件帮助
/t2i: 开关文本转图片
/tts: 开关文本转语音
/sid: 获取会话 ID
/op <admin_id>: 授权管理员(op)
/deop <admin_id>: 取消管理员(op)
/wl <sid>: 添加白名单(op)
/dwl <sid>: 删除白名单(op)
/op: 管理员
/wl: 白名单
/dashboard_update: 更新管理面板(op)
/alter_cmd: 设置指令权限(op)
@@ -164,8 +161,11 @@ AstrBot 指令:
plugin_list_info = "已加载的插件:\n"
for plugin in self.context.get_all_stars():
plugin_list_info += (
f"- `{plugin.name}` By {plugin.author}: {plugin.desc}\n"
f"- `{plugin.name}` By {plugin.author}: {plugin.desc}"
)
if not plugin.activated:
plugin_list_info += " (未启用)"
plugin_list_info += "\n"
if plugin_list_info.strip() == "":
plugin_list_info = "没有加载任何插件。"
@@ -199,12 +199,8 @@ AstrBot 指令:
if plugin is None:
event.set_result(MessageEventResult().message("未找到此插件。"))
return
help_msg = (
plugin.star_cls.__doc__
if plugin.star_cls.__doc__
else "帮助信息: 未提供"
)
help_msg += f"\n\n作者: {plugin.author}\n版本: {plugin.version}"
help_msg = ""
help_msg += f"\n\n✨ 作者: {plugin.author}\n✨ 版本: {plugin.version}"
command_handlers = []
command_names = []
for handler in star_handlers_registry:
@@ -221,13 +217,16 @@ AstrBot 指令:
command_names.append(filter_.group_name)
if len(command_handlers) > 0:
help_msg += "\n\n指令列表:\n"
help_msg += "\n\n🔧 指令列表:\n"
for i in range(len(command_handlers)):
help_msg += f"{command_names[i]}: {command_handlers[i].desc}\n"
help_msg += f"- {command_names[i]}"
if command_handlers[i].desc:
help_msg += f": {command_handlers[i].desc}"
help_msg += "\n"
help_msg += "\nTip: 指令的触发需要添加唤醒前缀,默认为 /。"
ret = f"插件 {oper1} 帮助信息:\n" + help_msg
ret = f"🧩 插件 {oper1} 帮助信息:\n" + help_msg
ret += "更多帮助信息请查看插件仓库 README。"
event.set_result(MessageEventResult().message(ret).use_t2i(False))
@@ -268,8 +267,15 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("op")
async def op(self, event: AstrMessageEvent, admin_id: str):
async def op(self, event: AstrMessageEvent, admin_id: str = None):
"""授权管理员。op <admin_id>"""
if admin_id is None:
event.set_result(
MessageEventResult().message(
"使用方法: /op <id> 授权管理员;/deop <id> 取消管理员。可通过 /sid 获取 ID。"
)
)
return
self.context.get_config()["admins_id"].append(admin_id)
self.context.get_config().save_config()
event.set_result(MessageEventResult().message("授权成功。"))
@@ -289,8 +295,14 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.permission_type(filter.PermissionType.ADMIN)
@filter.command("wl")
async def wl(self, event: AstrMessageEvent, sid: str):
async def wl(self, event: AstrMessageEvent, sid: str = None):
"""添加白名单。wl <sid>"""
if sid is None:
event.set_result(
MessageEventResult().message(
"使用方法: /wl <id> 添加白名单;/dwl <id> 删除白名单。可通过 /sid 获取 ID。"
)
)
self.context.get_config()["platform_settings"]["id_whitelist"].append(sid)
self.context.get_config().save_config()
event.set_result(MessageEventResult().message("添加白名单成功。"))
@@ -1023,9 +1035,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
return
try:
conv = None
history = []
if provider.meta().type != "dify":
# Dify 自己有维护对话,不需要 bot 端维护。
session_curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
event.unified_msg_origin
)
@@ -1039,10 +1049,8 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
conv = await self.context.conversation_manager.get_conversation(
event.unified_msg_origin, session_curr_cid
)
history = []
if conv:
history = json.loads(conv.history)
else:
# Dify 自己有维护对话,不需要 bot 端维护。
assert isinstance(provider, ProviderDify)
cid = provider.conversation_ids.get(
event.unified_msg_origin, None
@@ -1061,7 +1069,6 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
prompt=prompt,
func_tool_manager=self.context.get_llm_tool_manager(),
session_id=event.session_id,
contexts=history if history else [],
conversation=conv,
)
except BaseException as e:
@@ -1070,6 +1077,8 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
@filter.on_llm_request()
async def decorate_llm_req(self, event: AstrMessageEvent, req: ProviderRequest):
"""在请求 LLM 前注入人格信息、Identifier、时间等 System Prompt"""
logger.debug(req.conversation)
if self.prompt_prefix:
req.prompt = self.prompt_prefix + req.prompt
+12 -4
View File
@@ -303,9 +303,13 @@ class Main(star.Star):
uid = event.get_sender_id()
if uid in self.user_waiting:
self.user_waiting.pop(uid)
yield event.plain_result(f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 已清理。")
yield event.plain_result(
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 已清理。"
)
else:
yield event.plain_result(f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 没有等待上传文件。")
yield event.plain_result(
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 没有等待上传文件。"
)
@pi.command("list")
async def pi_file_list(self, event: AstrMessageEvent):
@@ -313,9 +317,13 @@ class Main(star.Star):
uid = event.get_sender_id()
if uid in self.user_file_msg_buffer:
files = self.user_file_msg_buffer[uid]
yield event.plain_result(f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 上传的文件: {files}")
yield event.plain_result(
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 上传的文件: {files}"
)
else:
yield event.plain_result(f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 没有上传文件。")
yield event.plain_result(
f"代码执行器: {event.get_sender_name()}/{event.get_sender_id()} 没有上传文件。"
)
@llm_tool("python_interpreter")
async def python_interpreter(self, event: AstrMessageEvent):
+83
View File
@@ -0,0 +1,83 @@
import astrbot.api.message_components as Comp
import copy
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.star import Context, Star, register
from astrbot.core.utils.session_waiter import (
SessionWaiter,
USER_SESSIONS,
FILTERS,
session_waiter,
SessionController,
)
from sys import maxsize
@register(
"session_controller",
"Cvandia & Soulter",
"为插件支持会话控制",
"v1.0.1",
"https://astrbot.app",
)
class Waiter(Star):
"""会话控制"""
def __init__(self, context: Context):
super().__init__(context)
self.empty_mention_waiting = self.context.get_config()["platform_settings"][
"empty_mention_waiting"
]
self.wake_prefix = self.context.get_config()["wake_prefix"]
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
async def handle_session_control_agent(self, event: AstrMessageEvent):
"""会话控制代理"""
for session_filter in FILTERS:
session_id = session_filter.filter(event)
if session_id in USER_SESSIONS:
await SessionWaiter.trigger(session_id, event)
event.stop_event()
@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1)
async def handle_empty_mention(self, event: AstrMessageEvent):
"""实现了对只有一个 @ 的消息内容的处理"""
try:
messages = event.get_messages()
if len(messages) == 1:
if (
isinstance(messages[0], Comp.At)
and str(messages[0].qq) == str(event.get_self_id())
and self.empty_mention_waiting
) or (
isinstance(messages[0], Comp.Plain)
and messages[0].text.strip() in self.wake_prefix
):
yield event.plain_result("想要问什么呢?😄")
@session_waiter(60)
async def empty_mention_waiter(
controller: SessionController, event: AstrMessageEvent
):
logger.info("empty_mention_waiter")
event.message_obj.message.insert(
0, Comp.At(qq=event.get_self_id(), name=event.get_self_id())
)
new_event = copy.copy(event)
self.context.get_event_queue().put_nowait(
new_event
) # 重新推入事件队列
event.stop_event()
controller.stop()
try:
await empty_mention_waiter(event)
except TimeoutError as _:
yield event.plain_result("如果需要帮助,请再次 @ 我哦~")
except Exception as e:
yield event.plain_result("发生错误,请联系管理员: " + str(e))
finally:
event.stop_event()
except Exception as e:
logger.error("handle_empty_mention error: " + str(e))
+1
View File
@@ -24,3 +24,4 @@ cryptography
dashscope
python-telegram-bot
wechatpy
dingtalk-stream