Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8882cb5479 | |||
| 75dace2dee | |||
| ad6487d042 | |||
| a91604e8ab | |||
| c364f7c643 | |||
| 53435ba184 | |||
| 25f8d5519b | |||
| 80b2b7dc00 | |||
| 7d6975fd31 | |||
| 08be52ed17 | |||
| 682a7700c2 | |||
| 9d87009216 | |||
| ef86838f62 | |||
| 35468233f8 | |||
| 26e229867d | |||
| 3a1578b3c6 | |||
| d5e3d2cbbc | |||
| 135dbb8f07 | |||
| c03f3eacd1 | |||
| a26e395932 | |||
| 0870b87c96 | |||
| b52a44a7dd | |||
| 0a290aafef | |||
| 9014d4c410 | |||
| 60e58b4f5f | |||
| 620e74a6aa | |||
| efa287ed35 | |||
| a24eb9d9b0 | |||
| bd3dab8aae | |||
| 4fe1ebaa5b | |||
| c5e944744b | |||
| 0c396181f7 | |||
| 0034474219 | |||
| 8136ad8287 | |||
| 681940d466 | |||
| 16488506e8 | |||
| 122fccc041 | |||
| 9d0ad35403 | |||
| f9ec97e026 | |||
| 95495a2647 | |||
| e3310a605c | |||
| b55719bf28 | |||
| b957b51279 | |||
| 90bcfab369 | |||
| f8a8e30641 | |||
| 25cb98e7a7 | |||
| 03e1bb7cf9 | |||
| 85dbb24f3a | |||
| d817635782 | |||
| 2f4f237810 | |||
| 5ac94d810f | |||
| 39dc46dc25 | |||
| 0d9cf725f7 | |||
| e55dbead5b | |||
| 7d046e5b30 | |||
| 8b4693cf66 | |||
| a1172c9a82 | |||
| 1ed2bd33f0 | |||
| 4c159bd0ba | |||
| 050654b2a9 | |||
| 61b261e1b2 | |||
| 017b010206 | |||
| 239f3c40be | |||
| 09c8c6e670 | |||
| 7e4ad01c94 | |||
| ed98e269ef | |||
| b47d63334f | |||
| 5e2a3a5aea | |||
| 1a7eb21fc7 | |||
| 834a51cdc9 | |||
| 1b69d99c06 | |||
| ad189933c6 | |||
| 9d86ff32de | |||
| 278bb57a58 | |||
| 0ba494e0ba | |||
| 8b247054bb | |||
| 7c5c8e4e0d | |||
| ad106a27f3 | |||
| 9d6f61b49e | |||
| 02368954a0 | |||
| b477a35a01 | |||
| 16622887de | |||
| 9059d1fb17 | |||
| df2b008d82 | |||
| 0da871efd0 | |||
| 1c55349f81 | |||
| 9309fa1e81 | |||
| 5996189f91 | |||
| bd2b984bfb | |||
| 194409a117 | |||
| 27978b216d | |||
| c38fa77ce6 | |||
| 3eb49f7422 | |||
| 1989d615d2 | |||
| 239412d265 | |||
| 375a419a9e | |||
| 875c8ab424 | |||
| c9bfc810ce | |||
| 46ecb16949 | |||
| f6dc16f17b | |||
| 4eef42f730 | |||
| 8612d9a771 | |||
| 0caff054f5 | |||
| 4aa91ad599 | |||
| 7a0864f5c2 | |||
| 73dc0dfcf6 | |||
| 1ff9a69339 | |||
| 179eb5d847 | |||
| 52c868828c | |||
| 7eea4615b6 | |||
| d9b351df1a | |||
| d6a785b645 | |||
| 79db828a01 | |||
| a5ffb0f8dc | |||
| 9492fcde74 | |||
| d2456ce4cd | |||
| 7de27abc8d | |||
| d8155bc8eb | |||
| cf08e52a92 | |||
| 768398b991 | |||
| 24c20a19f1 | |||
| 8fbcbcd4c0 | |||
| e0da5bb943 | |||
| 36fbc4fb82 | |||
| cb11051f42 | |||
| a824781d14 | |||
| 600a2c6748 | |||
| 77df64bfb5 | |||
| 2d6e54903c | |||
| baa2b83df9 | |||
| 1ff02446af | |||
| b58c6ba762 |
@@ -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
|
||||
@@ -26,3 +26,5 @@ venv/*
|
||||
packages/python_interpreter/workplace
|
||||
.venv/*
|
||||
.conda/
|
||||
.idea/
|
||||
pytest.ini
|
||||
|
||||
@@ -7,7 +7,7 @@ ci:
|
||||
autoupdate_commit_msg: ":balloon: pre-commit autoupdate"
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.9.9
|
||||
rev: v0.9.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
|
||||
@@ -15,8 +15,9 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
|
||||
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg"/></a>
|
||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="Static Badge" src="https://img.shields.io/badge/QQ群-630166526-purple"></a>
|
||||
[](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
|
||||

|
||||

|
||||
[](https://codecov.io/gh/Soulter/AstrBot)
|
||||
[](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
|
||||
|
||||
### 横功能
|
||||
|
||||
## ⚡ 消息平台支持情况
|
||||
|
||||
@@ -74,7 +83,8 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
|
||||
| 微信(个人号) | ✔ | 微信个人号私聊、群聊 | 文字、图片、语音 |
|
||||
| [Telegram](https://github.com/Soulter/astrbot_plugin_telegram) | ✔ | 私聊、群聊 | 文字、图片 |
|
||||
| [微信(企业微信)](https://github.com/Soulter/astrbot_plugin_wecom) | ✔ | 私聊 | 文字、图片、语音 |
|
||||
| 飞书 | ✔ | 群聊 | 文字、图片 |
|
||||
| 飞书 | ✔ | 私聊、群聊 | 文字、图片 |
|
||||
| 钉钉 | ✔ | 私聊、群聊 | 文字、图片 |
|
||||
| 微信对话开放平台 | 🚧 | 计划内 | - |
|
||||
| Discord | 🚧 | 计划内 | - |
|
||||
| WhatsApp | 🚧 | 计划内 | - |
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from astrbot.core.utils.session_waiter import (
|
||||
SessionWaiter,
|
||||
SessionController,
|
||||
session_waiter,
|
||||
)
|
||||
|
||||
__all__ = ["SessionWaiter", "SessionController", "session_waiter"]
|
||||
@@ -2,7 +2,7 @@
|
||||
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
|
||||
"""
|
||||
|
||||
VERSION = "3.4.35"
|
||||
VERSION = "3.4.38"
|
||||
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": {
|
||||
@@ -83,6 +85,7 @@ DEFAULT_CONFIG = {
|
||||
"enable": True,
|
||||
"username": "astrbot",
|
||||
"password": "77b90590a8945a7d36c963981a307dc9",
|
||||
"host": "127.0.0.1",
|
||||
"port": 6185,
|
||||
},
|
||||
"platform": [],
|
||||
@@ -120,6 +123,7 @@ CONFIG_METADATA_2 = {
|
||||
"enable": False,
|
||||
"appid": "",
|
||||
"secret": "",
|
||||
"callback_server_host": "0.0.0.0",
|
||||
"port": 6196,
|
||||
},
|
||||
"aiocqhttp(OneBotv11)": {
|
||||
@@ -144,10 +148,11 @@ CONFIG_METADATA_2 = {
|
||||
"enable": False,
|
||||
"corpid": "",
|
||||
"secret": "",
|
||||
"port": 6195,
|
||||
"token": "",
|
||||
"encoding_aes_key": "",
|
||||
"api_base_url": "https://qyapi.weixin.qq.com/cgi-bin/",
|
||||
"callback_server_host": "0.0.0.0",
|
||||
"port": 6195,
|
||||
},
|
||||
"lark(飞书)": {
|
||||
"id": "lark",
|
||||
@@ -158,6 +163,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 +177,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 +269,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",
|
||||
@@ -322,7 +345,7 @@ CONFIG_METADATA_2 = {
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"obvious_hint": True,
|
||||
"hint": "AstrBot 只处理所填写的 ID 发来的消息事件。为空时不启用白名单过滤。可以使用 /sid 指令获取在某个平台上的会话 ID。也可在 AstrBot 日志内获取会话 ID,当一条消息没通过白名单时,会输出 INFO 级别的日志。会话 ID 类似 aiocqhttp:GroupMessage:547540978。管理员可使用 /wl 添加白名单",
|
||||
"hint": "只处理所填写的 ID 发来的消息事件。为空时不启用白名单过滤。可以使用 /sid 指令获取在某个平台上的会话 ID。会话 ID 类似 aiocqhttp:GroupMessage:547540978。管理员可使用 /wl 添加白名单",
|
||||
},
|
||||
"id_whitelist_log": {
|
||||
"description": "打印白名单日志",
|
||||
@@ -465,6 +488,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",
|
||||
@@ -626,7 +659,7 @@ CONFIG_METADATA_2 = {
|
||||
"type": "fishaudio_tts_api",
|
||||
"enable": False,
|
||||
"api_key": "",
|
||||
"api_base": "https://api.fish-audio.cn/v1",
|
||||
"api_base": "https://api.fish.audio/v1",
|
||||
"fishaudio-tts-character": "可莉",
|
||||
"timeout": "20",
|
||||
},
|
||||
|
||||
+58
-2
@@ -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)
|
||||
|
||||
@@ -311,10 +311,24 @@ class Image(BaseMessageComponent):
|
||||
class Reply(BaseMessageComponent):
|
||||
type: ComponentType = "Reply"
|
||||
id: T.Union[str, int]
|
||||
text: T.Optional[str] = ""
|
||||
qq: T.Optional[int] = 0
|
||||
"""所引用的消息 ID"""
|
||||
chain: T.Optional[T.List["BaseMessageComponent"]] = []
|
||||
"""引用的消息段列表"""
|
||||
sender_id: T.Optional[int] | T.Optional[str] = 0
|
||||
"""引用的消息发送者 ID"""
|
||||
sender_nickname: T.Optional[str] = ""
|
||||
"""引用的消息发送者昵称"""
|
||||
time: T.Optional[int] = 0
|
||||
"""引用的消息发送时间"""
|
||||
message_str: T.Optional[str] = ""
|
||||
"""解析后的纯文本消息字符串"""
|
||||
|
||||
text: T.Optional[str] = ""
|
||||
"""deprecated"""
|
||||
qq: T.Optional[int] = 0
|
||||
"""deprecated"""
|
||||
seq: T.Optional[int] = 0
|
||||
"""deprecated"""
|
||||
|
||||
def __init__(self, **_):
|
||||
super().__init__(**_)
|
||||
@@ -353,16 +367,22 @@ class Node(BaseMessageComponent):
|
||||
id: T.Optional[int] = 0 # 忽略
|
||||
name: T.Optional[str] = "" # qq昵称
|
||||
uin: T.Optional[int] = 0 # qq号
|
||||
content: T.Optional[T.Union[str, list]] = "" # 子消息段列表
|
||||
content: T.Optional[T.Union[str, list, dict]] = "" # 子消息段列表
|
||||
seq: T.Optional[T.Union[str, list]] = "" # 忽略
|
||||
time: T.Optional[int] = 0
|
||||
|
||||
def __init__(self, content: T.Union[str, list], **_):
|
||||
def __init__(self, content: T.Union[str, list, dict, "Node", T.List["Node"]], **_):
|
||||
if isinstance(content, list):
|
||||
_content = ""
|
||||
for chain in content:
|
||||
_content += chain.toString()
|
||||
_content = None
|
||||
if all(isinstance(item, Node) for item in content):
|
||||
_content = [node.toDict() for node in content]
|
||||
else:
|
||||
_content = ""
|
||||
for chain in content:
|
||||
_content += chain.toString()
|
||||
content = _content
|
||||
elif isinstance(content, Node):
|
||||
content = content.toDict()
|
||||
super().__init__(content=content, **_)
|
||||
|
||||
def toString(self):
|
||||
|
||||
@@ -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,19 +138,28 @@ 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":
|
||||
# text completion
|
||||
event.set_result(
|
||||
MessageEventResult()
|
||||
.message(llm_response.completion_text)
|
||||
.set_result_content_type(ResultContentType.LLM_RESULT)
|
||||
)
|
||||
if llm_response.result_chain:
|
||||
event.set_result(
|
||||
MessageEventResult(
|
||||
chain=llm_response.result_chain.chain
|
||||
).set_result_content_type(ResultContentType.LLM_RESULT)
|
||||
)
|
||||
else:
|
||||
event.set_result(
|
||||
MessageEventResult()
|
||||
.message(llm_response.completion_text)
|
||||
.set_result_content_type(ResultContentType.LLM_RESULT)
|
||||
)
|
||||
elif llm_response.role == "err":
|
||||
event.set_result(
|
||||
MessageEventResult().message(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
import abc
|
||||
import inspect
|
||||
import traceback
|
||||
from astrbot.api import logger
|
||||
from typing import List, AsyncGenerator, Union, Awaitable
|
||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
@@ -43,25 +44,32 @@ class Stage(abc.ABC):
|
||||
"""调用 Handler。"""
|
||||
# 判断 handler 是否是类方法(通过装饰器注册的没有 __self__ 属性)
|
||||
ready_to_call = None
|
||||
|
||||
trace_ = None
|
||||
|
||||
try:
|
||||
ready_to_call = handler(event, *args, **kwargs)
|
||||
except TypeError as e:
|
||||
except TypeError as _:
|
||||
# 向下兼容
|
||||
logger.debug(str(e))
|
||||
trace_ = traceback.format_exc()
|
||||
ready_to_call = handler(event, ctx.plugin_manager.context, *args, **kwargs)
|
||||
|
||||
if isinstance(ready_to_call, AsyncGenerator):
|
||||
_has_yielded = False
|
||||
async for ret in ready_to_call:
|
||||
# 如果处理函数是生成器,返回值只能是 MessageEventResult 或者 None(无返回值)
|
||||
_has_yielded = True
|
||||
if isinstance(ret, (MessageEventResult, CommandResult)):
|
||||
event.set_result(ret)
|
||||
try:
|
||||
async for ret in ready_to_call:
|
||||
# 如果处理函数是生成器,返回值只能是 MessageEventResult 或者 None(无返回值)
|
||||
_has_yielded = True
|
||||
if isinstance(ret, (MessageEventResult, CommandResult)):
|
||||
event.set_result(ret)
|
||||
yield
|
||||
else:
|
||||
yield ret
|
||||
if not _has_yielded:
|
||||
yield
|
||||
else:
|
||||
yield ret
|
||||
if not _has_yielded:
|
||||
yield
|
||||
except Exception as e:
|
||||
logger.error(f"Previous Error: {trace_}")
|
||||
raise e
|
||||
elif inspect.iscoroutine(ready_to_call):
|
||||
# 如果只是一个 coroutine
|
||||
ret = await ready_to_call
|
||||
|
||||
@@ -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
|
||||
@@ -32,7 +36,7 @@ class WakingCheckStage(Stage):
|
||||
# 设置 sender 身份
|
||||
event.message_str = event.message_str.strip()
|
||||
for admin_id in self.ctx.astrbot_config["admins_id"]:
|
||||
if event.get_sender_id() == admin_id:
|
||||
if str(event.get_sender_id()) == admin_id:
|
||||
event.role = "admin"
|
||||
break
|
||||
|
||||
@@ -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
|
||||
@@ -102,6 +106,7 @@ class WakingCheckStage(Stage):
|
||||
f"插件 {star_map[handler.handler_module_path].name}: {e}"
|
||||
)
|
||||
)
|
||||
await event._post_send()
|
||||
event.stop_event()
|
||||
passed = False
|
||||
break
|
||||
@@ -113,6 +118,7 @@ class WakingCheckStage(Stage):
|
||||
f"ID {event.get_sender_id()} 权限不足。通过 /sid 获取 ID 并请管理员添加。"
|
||||
)
|
||||
)
|
||||
await event._post_send()
|
||||
event.stop_event()
|
||||
return
|
||||
|
||||
|
||||
@@ -51,7 +51,10 @@ class WhitelistCheckStage(Stage):
|
||||
and event.get_message_type() == MessageType.FRIEND_MESSAGE
|
||||
):
|
||||
return
|
||||
if event.unified_msg_origin not in self.whitelist:
|
||||
if (
|
||||
event.unified_msg_origin not in self.whitelist
|
||||
and event.get_group_id() not in self.whitelist
|
||||
):
|
||||
if self.wl_log:
|
||||
logger.info(
|
||||
f"会话 ID {event.unified_msg_origin} 不在会话白名单中,已终止事件传播。请在配置文件中添加该会话 ID 到白名单。"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import abc
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from .astrbot_message import AstrBotMessage
|
||||
from .platform_metadata import PlatformMetadata
|
||||
@@ -13,6 +14,7 @@ from astrbot.core.message.components import (
|
||||
At,
|
||||
AtAll,
|
||||
Forward,
|
||||
Reply,
|
||||
)
|
||||
from astrbot.core.utils.metrics import Metric
|
||||
from astrbot.core.provider.entites import ProviderRequest
|
||||
@@ -100,8 +102,15 @@ class AstrMessageEvent(abc.ABC):
|
||||
elif isinstance(i, Forward):
|
||||
# 转发消息
|
||||
outline += "[转发消息]"
|
||||
elif isinstance(i, Reply):
|
||||
# 引用回复
|
||||
if i.message_str:
|
||||
outline += f"[引用消息({i.sender_nickname}: {i.message_str})]"
|
||||
else:
|
||||
outline += "[引用消息]"
|
||||
else:
|
||||
outline += f"[{i.type}]"
|
||||
outline += " "
|
||||
return outline
|
||||
|
||||
def get_message_outline(self) -> str:
|
||||
@@ -196,7 +205,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):
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -21,6 +21,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
d = segment.toDict()
|
||||
if isinstance(segment, Plain):
|
||||
d["type"] = "text"
|
||||
d["data"]["text"] = segment.text.strip()
|
||||
elif isinstance(segment, (Image, Record)):
|
||||
# convert to base64
|
||||
if segment.file and segment.file.startswith("file:///"):
|
||||
@@ -55,8 +56,13 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
|
||||
|
||||
if send_one_by_one:
|
||||
for seg in message.chain:
|
||||
if isinstance(seg, Nodes):
|
||||
# 带有多个节点的合并转发消息
|
||||
if isinstance(seg, (Node, Nodes)):
|
||||
# 合并转发消息
|
||||
|
||||
if isinstance(seg, Node):
|
||||
nodes = Nodes([seg])
|
||||
seg = nodes
|
||||
|
||||
payload = seg.toDict()
|
||||
if self.get_group_id():
|
||||
payload["group_id"] = self.get_group_id()
|
||||
|
||||
@@ -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 = (
|
||||
@@ -160,8 +160,14 @@ class AiocqhttpAdapter(Platform):
|
||||
|
||||
return abm
|
||||
|
||||
async def _convert_handle_message_event(self, event: Event) -> AstrBotMessage:
|
||||
"""OneBot V11 消息类事件"""
|
||||
async def _convert_handle_message_event(
|
||||
self, event: Event, get_reply=True
|
||||
) -> AstrBotMessage:
|
||||
"""OneBot V11 消息类事件
|
||||
|
||||
@param event: 事件对象
|
||||
@param get_reply: 是否获取回复消息。这个参数是为了防止多个回复嵌套。
|
||||
"""
|
||||
abm = AstrBotMessage()
|
||||
abm.self_id = str(event.self_id)
|
||||
abm.sender = MessageMember(
|
||||
@@ -240,6 +246,36 @@ class AiocqhttpAdapter(Platform):
|
||||
except BaseException as e:
|
||||
logger.error(f"获取文件失败: {e},此消息段将被忽略。")
|
||||
|
||||
elif t == "reply":
|
||||
if not get_reply:
|
||||
a = ComponentTypes[t](**m["data"]) # noqa: F405
|
||||
abm.message.append(a)
|
||||
else:
|
||||
try:
|
||||
reply_event_data = await self.bot.call_action(
|
||||
action="get_msg",
|
||||
message_id=int(m["data"]["id"]),
|
||||
)
|
||||
abm_reply = await self._convert_handle_message_event(
|
||||
Event.from_payload(reply_event_data), get_reply=False
|
||||
)
|
||||
|
||||
reply_seg = Reply(
|
||||
id=abm_reply.message_id,
|
||||
chain=abm_reply.message,
|
||||
sender_id=abm_reply.sender.user_id,
|
||||
sender_nickname=abm_reply.sender.nickname,
|
||||
time=abm_reply.timestamp,
|
||||
message_str=abm_reply.message_str,
|
||||
text=abm_reply.message_str, # for compatibility
|
||||
qq=abm_reply.sender.user_id, # for compatibility
|
||||
)
|
||||
|
||||
abm.message.append(reply_seg)
|
||||
except BaseException as e:
|
||||
logger.error(f"获取引用消息失败: {e}。")
|
||||
a = ComponentTypes[t](**m["data"]) # noqa: F405
|
||||
abm.message.append(a)
|
||||
else:
|
||||
a = ComponentTypes[t](**m["data"]) # noqa: F405
|
||||
abm.message.append(a)
|
||||
|
||||
@@ -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"\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)
|
||||
@@ -87,6 +87,15 @@ class SimpleGewechatClient:
|
||||
type_name = data["type_name"]
|
||||
else:
|
||||
raise Exception("无法识别的消息类型")
|
||||
|
||||
# 以下没有业务处理,只是避免控制台打印太多的日志
|
||||
if type_name == "ModContacts":
|
||||
logger.info("gewechat下发:ModContacts消息通知。")
|
||||
return
|
||||
if type_name == "DelContacts":
|
||||
logger.info("gewechat下发:DelContacts消息通知。")
|
||||
return
|
||||
|
||||
if type_name == "Offline":
|
||||
logger.critical("收到 gewechat 下线通知。")
|
||||
return
|
||||
@@ -147,6 +156,11 @@ class SimpleGewechatClient:
|
||||
abm.type = MessageType.FRIEND_MESSAGE
|
||||
user_id = from_user_name
|
||||
|
||||
# 检查消息是否由自己发送,若是则忽略
|
||||
if user_id == abm.self_id:
|
||||
logger.info("忽略自己发送的消息")
|
||||
return None
|
||||
|
||||
abm.message = []
|
||||
if at_me:
|
||||
abm.message.insert(0, At(qq=abm.self_id))
|
||||
@@ -207,6 +221,31 @@ class SimpleGewechatClient:
|
||||
async with await anyio.open_file(file_path, "wb") as f:
|
||||
await f.write(voice_data)
|
||||
abm.message.append(Record(file=file_path, url=file_path))
|
||||
|
||||
# 以下已知消息类型,没有业务处理,只是避免控制台打印太多的日志
|
||||
case 37: # 好友申请
|
||||
logger.info("消息类型(37):好友申请")
|
||||
case 42: # 名片
|
||||
logger.info("消息类型(42):名片")
|
||||
case 43: # 视频
|
||||
logger.info("消息类型(43):视频")
|
||||
case 47: # emoji
|
||||
logger.info("消息类型(47):emoji")
|
||||
case 48: # 地理位置
|
||||
logger.info("消息类型(48):地理位置")
|
||||
case 49: # 公众号/文件/小程序/引用/转账/红包/视频号/群聊邀请
|
||||
logger.info(
|
||||
"消息类型(49):公众号/文件/小程序/引用/转账/红包/视频号/群聊邀请"
|
||||
)
|
||||
case 51: # 帐号消息同步?
|
||||
logger.info("消息类型(51):帐号消息同步?")
|
||||
case 10000: # 被踢出群聊/更换群主/修改群名称
|
||||
logger.info("消息类型(10000):被踢出群聊/更换群主/修改群名称")
|
||||
case 10002: # 撤回/拍一拍/成员邀请/被移出群聊/解散群聊/群公告/群待办
|
||||
logger.info(
|
||||
"消息类型(10002):撤回/拍一拍/成员邀请/被移出群聊/解散群聊/群公告/群待办"
|
||||
)
|
||||
|
||||
case _:
|
||||
logger.info(f"未实现的消息类型: {d['MsgType']}")
|
||||
abm.raw_message = d
|
||||
@@ -304,32 +343,49 @@ class SimpleGewechatClient:
|
||||
)
|
||||
|
||||
if self.appid:
|
||||
online = await self.check_online(self.appid)
|
||||
if online:
|
||||
logger.info(f"APPID: {self.appid} 已在线")
|
||||
return
|
||||
try:
|
||||
online = await self.check_online(self.appid)
|
||||
if online:
|
||||
logger.info(f"APPID: {self.appid} 已在线")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"检查在线状态失败: {e}")
|
||||
sp.put(f"gewechat-appid-{self.nickname}", "")
|
||||
self.appid = None
|
||||
|
||||
payload = {"appId": self.appid}
|
||||
|
||||
if self.appid:
|
||||
logger.info(f"使用 APPID: {self.appid}, {self.nickname}")
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/login/getLoginQrCode",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
) as resp:
|
||||
json_blob = await resp.json()
|
||||
if json_blob["ret"] != 200:
|
||||
raise Exception(f"获取二维码失败: {json_blob}")
|
||||
qr_data = json_blob["data"]["qrData"]
|
||||
qr_uuid = json_blob["data"]["uuid"]
|
||||
appid = json_blob["data"]["appId"]
|
||||
logger.info(f"APPID: {appid}")
|
||||
logger.warning(
|
||||
f"请打开该网址,然后使用微信扫描二维码登录: https://api.cl2wm.cn/api/qrcode/code?text={qr_data}"
|
||||
)
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{self.base_url}/login/getLoginQrCode",
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
) as resp:
|
||||
json_blob = await resp.json()
|
||||
if json_blob["ret"] != 200:
|
||||
error_msg = json_blob.get("data", {}).get("msg", "")
|
||||
if "设备不存在" in error_msg:
|
||||
logger.error(
|
||||
f"检测到无效的appid: {self.appid},将清除并重新登录。"
|
||||
)
|
||||
sp.put(f"gewechat-appid-{self.nickname}", "")
|
||||
self.appid = None
|
||||
return await self.login()
|
||||
else:
|
||||
raise Exception(f"获取二维码失败: {json_blob}")
|
||||
qr_data = json_blob["data"]["qrData"]
|
||||
qr_uuid = json_blob["data"]["uuid"]
|
||||
appid = json_blob["data"]["appId"]
|
||||
logger.info(f"APPID: {appid}")
|
||||
logger.warning(
|
||||
f"请打开该网址,然后使用微信扫描二维码登录: https://api.cl2wm.cn/api/qrcode/code?text={qr_data}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
# 执行登录
|
||||
retry_cnt = 64
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -122,16 +122,16 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
||||
plain_text += i.text
|
||||
elif isinstance(i, Image) and not image_base64:
|
||||
if i.file and i.file.startswith("file:///"):
|
||||
image_base64 = file_to_base64(i.file[8:]).replace("base64://", "")
|
||||
image_base64 = file_to_base64(i.file[8:])
|
||||
image_file_path = i.file[8:]
|
||||
elif i.file and i.file.startswith("http"):
|
||||
image_file_path = await download_image_by_url(i.file)
|
||||
image_base64 = file_to_base64(image_file_path).replace(
|
||||
"base64://", ""
|
||||
)
|
||||
image_base64 = file_to_base64(image_file_path)
|
||||
elif i.file and i.file.startswith("base64://"):
|
||||
image_base64 = i.file
|
||||
else:
|
||||
image_base64 = file_to_base64(i.file).replace("base64://", "")
|
||||
image_file_path = i.file
|
||||
image_base64 = file_to_base64(i.file)
|
||||
image_base64 = image_base64.removeprefix("base64://")
|
||||
else:
|
||||
logger.debug(f"qq_official 忽略 {i.type}")
|
||||
return plain_text, image_base64, image_file_path
|
||||
|
||||
@@ -15,6 +15,7 @@ class QQOfficialWebhook:
|
||||
self.appid = config["appid"]
|
||||
self.secret = config["secret"]
|
||||
self.port = config.get("port", 6196)
|
||||
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
|
||||
|
||||
if isinstance(self.port, str):
|
||||
self.port = int(self.port)
|
||||
@@ -95,8 +96,11 @@ class QQOfficialWebhook:
|
||||
return {"opcode": 12}
|
||||
|
||||
async def start_polling(self):
|
||||
logger.info(
|
||||
f"将在 {self.callback_server_host}:{self.port} 端口启动 QQ 官方机器人 webhook 适配器。"
|
||||
)
|
||||
await self.server.run_task(
|
||||
host="0.0.0.0",
|
||||
host=self.callback_server_host,
|
||||
port=self.port,
|
||||
shutdown_trigger=self.shutdown_trigger_placeholder,
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@ from astrbot.api.message_components import (
|
||||
File as AstrBotFile,
|
||||
Video,
|
||||
At,
|
||||
Reply,
|
||||
)
|
||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||
from astrbot.api.platform import register_platform_adapter
|
||||
@@ -50,18 +51,29 @@ 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(
|
||||
filters=filters.ALL, # receive all messages
|
||||
callback=self.convert_message,
|
||||
callback=self.message_handler,
|
||||
)
|
||||
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(
|
||||
@@ -93,44 +105,86 @@ class TelegramPlatformAdapter(Platform):
|
||||
chat_id=update.effective_chat.id, text=self.config["start_message"]
|
||||
)
|
||||
|
||||
async def message_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
logger.debug(f"Telegram message: {update.message}")
|
||||
abm = await self.convert_message(update, context)
|
||||
await self.handle_msg(abm)
|
||||
|
||||
async def convert_message(
|
||||
self, update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||
self, update: Update, context: ContextTypes.DEFAULT_TYPE, get_reply=True
|
||||
) -> AstrBotMessage:
|
||||
"""转换 Telegram 的消息对象为 AstrBotMessage 对象。
|
||||
|
||||
@param update: Telegram 的 Update 对象。
|
||||
@param context: Telegram 的 Context 对象。
|
||||
@param get_reply: 是否获取回复消息。这个参数是为了防止多个回复嵌套。
|
||||
"""
|
||||
message = AstrBotMessage()
|
||||
# 获得是群聊还是私聊
|
||||
if update.effective_chat.type == ChatType.PRIVATE:
|
||||
if update.message.chat.type == ChatType.PRIVATE:
|
||||
message.type = MessageType.FRIEND_MESSAGE
|
||||
else:
|
||||
message.type = MessageType.GROUP_MESSAGE
|
||||
message.group_id = update.effective_chat.id
|
||||
message.group_id = str(update.message.chat.id)
|
||||
if update.message.message_thread_id:
|
||||
# Topic Group
|
||||
message.group_id += "#" + str(update.message.message_thread_id)
|
||||
|
||||
message.message_id = str(update.message.message_id)
|
||||
message.session_id = str(update.effective_chat.id)
|
||||
message.session_id = str(update.message.chat.id)
|
||||
message.sender = MessageMember(
|
||||
str(update.effective_user.id), update.effective_user.username
|
||||
str(update.message.from_user.id), update.message.from_user.username
|
||||
)
|
||||
message.self_id = str(context.bot.username)
|
||||
message.raw_message = update
|
||||
message.message_str = ""
|
||||
message.message = []
|
||||
|
||||
logger.debug(f"Telegram message: {update.message}")
|
||||
if update.message.reply_to_message:
|
||||
# 获取回复消息
|
||||
reply_update = Update(
|
||||
update_id=1,
|
||||
message=update.message.reply_to_message,
|
||||
)
|
||||
reply_abm = await self.convert_message(reply_update, context, False)
|
||||
|
||||
message.message.append(
|
||||
Reply(
|
||||
id=reply_abm.message_id,
|
||||
chain=reply_abm.message,
|
||||
sender_id=reply_abm.sender.user_id,
|
||||
sender_nickname=reply_abm.sender.nickname,
|
||||
time=reply_abm.timestamp,
|
||||
message_str=reply_abm.message_str,
|
||||
text=reply_abm.message_str,
|
||||
qq=reply_abm.sender.user_id,
|
||||
)
|
||||
)
|
||||
|
||||
if update.message.text:
|
||||
# 处理文本消息
|
||||
plain_text = update.message.text
|
||||
|
||||
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 = [
|
||||
@@ -156,7 +210,7 @@ class TelegramPlatformAdapter(Platform):
|
||||
Video(file=file.file_path, path=file.file_path),
|
||||
]
|
||||
|
||||
await self.handle_msg(message)
|
||||
return message
|
||||
|
||||
async def handle_msg(self, message: AstrBotMessage):
|
||||
message_event = TelegramPlatformEvent(
|
||||
|
||||
@@ -2,6 +2,7 @@ from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.platform import AstrBotMessage, PlatformMetadata, MessageType
|
||||
from astrbot.api.message_components import Plain, Image, Reply, At, File, Record
|
||||
from telegram.ext import ExtBot
|
||||
from astrbot.core.utils.io import download_file
|
||||
|
||||
|
||||
class TelegramPlatformEvent(AstrMessageEvent):
|
||||
@@ -31,12 +32,18 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
at_user_id = i.name
|
||||
|
||||
at_flag = False
|
||||
message_thread_id = None
|
||||
if "#" in user_name:
|
||||
# it's a supergroup chat with message_thread_id
|
||||
user_name, message_thread_id = user_name.split("#")
|
||||
for i in message.chain:
|
||||
payload = {
|
||||
"chat_id": user_name,
|
||||
}
|
||||
if has_reply:
|
||||
payload["reply_to_message_id"] = reply_message_id
|
||||
if message_thread_id:
|
||||
payload["reply_to_message_id"] = message_thread_id
|
||||
|
||||
if isinstance(i, Plain):
|
||||
if at_user_id and not at_flag:
|
||||
@@ -58,6 +65,11 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
else:
|
||||
await client.send_photo(photo=image_path, **payload)
|
||||
elif isinstance(i, File):
|
||||
if i.file.startswith("https://"):
|
||||
path = "data/temp/" + i.name
|
||||
await download_file(i.file, path)
|
||||
i.file = path
|
||||
|
||||
await client.send_document(document=i.file, filename=i.name, **payload)
|
||||
elif isinstance(i, Record):
|
||||
await client.send_voice(voice=i.file, **payload)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -34,6 +34,7 @@ class WecomServer:
|
||||
def __init__(self, event_queue: asyncio.Queue, config: dict):
|
||||
self.server = quart.Quart(__name__)
|
||||
self.port = int(config.get("port"))
|
||||
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
|
||||
self.server.add_url_rule(
|
||||
"/callback/command", view_func=self.verify, methods=["GET"]
|
||||
)
|
||||
@@ -86,9 +87,11 @@ class WecomServer:
|
||||
return "success"
|
||||
|
||||
async def start_polling(self):
|
||||
logger.info(f"将在 0.0.0.0:{self.port} 端口启动 企业微信 适配器。")
|
||||
logger.info(
|
||||
f"将在 {self.callback_server_host}:{self.port} 端口启动 企业微信 适配器。"
|
||||
)
|
||||
await self.server.run_task(
|
||||
host="0.0.0.0",
|
||||
host=self.callback_server_host,
|
||||
port=self.port,
|
||||
shutdown_trigger=self.shutdown_trigger_placeholder,
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import List, Dict, Type
|
||||
from .func_tool_manager import FuncCall
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
from astrbot.core.db.po import Conversation
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
|
||||
|
||||
class ProviderType(enum.Enum):
|
||||
@@ -46,7 +47,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__()
|
||||
@@ -56,8 +57,10 @@ class ProviderRequest:
|
||||
class LLMResponse:
|
||||
role: str
|
||||
"""角色, assistant, tool, err"""
|
||||
result_chain: MessageChain = None
|
||||
"""返回的消息链"""
|
||||
completion_text: str = ""
|
||||
"""LLM 返回的文本"""
|
||||
"""LLM 返回的文本, 已经废弃但仍然兼容。使用 result_chain 替代"""
|
||||
tools_call_args: List[Dict[str, any]] = field(default_factory=list)
|
||||
"""工具调用参数"""
|
||||
tools_call_name: List[str] = field(default_factory=list)
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import textwrap
|
||||
from typing import Dict, List, Awaitable
|
||||
from dataclasses import dataclass
|
||||
from astrbot import logger
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -19,6 +20,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",
|
||||
@@ -43,14 +47,16 @@ class FuncCall:
|
||||
desc: str,
|
||||
handler: Awaitable,
|
||||
) -> None:
|
||||
"""
|
||||
为函数调用(function-calling / tools-use)添加工具。
|
||||
"""添加函数调用工具
|
||||
|
||||
@param name: 函数名
|
||||
@param func_args: 函数参数列表,格式为 [{"type": "string", "name": "arg_name", "description": "arg_description"}, ...]
|
||||
@param desc: 函数描述
|
||||
@param func_obj: 处理函数
|
||||
"""
|
||||
# check if the tool has been added before
|
||||
self.remove_func(name)
|
||||
|
||||
params = {
|
||||
"type": "object", # hard-coded here
|
||||
"properties": {},
|
||||
@@ -67,13 +73,14 @@ class FuncCall:
|
||||
handler=handler,
|
||||
)
|
||||
self.func_list.append(_func)
|
||||
logger.info(f"添加函数调用工具: {name}")
|
||||
|
||||
def remove_func(self, name: str) -> None:
|
||||
"""
|
||||
删除一个函数调用工具。
|
||||
"""
|
||||
for i, f in enumerate(self.func_list):
|
||||
if f["name"] == name:
|
||||
if f.name == name:
|
||||
self.func_list.pop(i)
|
||||
break
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import astrbot.core.message.components as Comp
|
||||
|
||||
from typing import List
|
||||
from .. import Provider, Personality
|
||||
from ..entites import LLMResponse
|
||||
@@ -5,8 +7,9 @@ from ..func_tool_manager import FuncCall
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from ..register import register_provider_adapter
|
||||
from astrbot.core.utils.dify_api_client import DifyAPIClient
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
from astrbot.core.utils.io import download_image_by_url, download_file
|
||||
from astrbot.core import logger, sp
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
|
||||
|
||||
@register_provider_adapter("dify", "Dify APP 适配器。")
|
||||
@@ -96,6 +99,9 @@ class ProviderDify(Provider):
|
||||
try:
|
||||
match self.api_type:
|
||||
case "chat" | "agent":
|
||||
if not prompt:
|
||||
prompt = "请描述这张图片。"
|
||||
|
||||
async for chunk in self.api_client.chat_messages(
|
||||
inputs={
|
||||
**payload_vars,
|
||||
@@ -148,8 +154,9 @@ class ProviderDify(Provider):
|
||||
)
|
||||
case "workflow_finished":
|
||||
logger.info(
|
||||
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束。"
|
||||
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束"
|
||||
)
|
||||
logger.debug(f"Dify 工作流结果:{chunk}")
|
||||
if chunk["data"]["error"]:
|
||||
logger.error(
|
||||
f"Dify 工作流出现错误:{chunk['data']['error']}"
|
||||
@@ -164,9 +171,7 @@ class ProviderDify(Provider):
|
||||
raise Exception(
|
||||
f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}"
|
||||
)
|
||||
result = chunk["data"]["outputs"][
|
||||
self.workflow_output_key
|
||||
]
|
||||
result = chunk
|
||||
case _:
|
||||
raise Exception(f"未知的 Dify API 类型:{self.api_type}")
|
||||
except Exception as e:
|
||||
@@ -176,7 +181,54 @@ class ProviderDify(Provider):
|
||||
if not result:
|
||||
logger.warning("Dify 请求结果为空,请查看 Debug 日志。")
|
||||
|
||||
return LLMResponse(role="assistant", completion_text=result)
|
||||
chain = await self.parse_dify_result(result)
|
||||
|
||||
return LLMResponse(role="assistant", result_chain=chain)
|
||||
|
||||
async def parse_dify_result(self, chunk: dict | str) -> MessageChain:
|
||||
if isinstance(chunk, str):
|
||||
# Chat
|
||||
return MessageChain(chain=[Comp.Plain(chunk)])
|
||||
|
||||
async def parse_file(item: dict) -> Comp:
|
||||
match item["type"]:
|
||||
case "image":
|
||||
return Comp.Image(file=item["url"], url=item["url"])
|
||||
case "audio":
|
||||
# 仅支持 wav
|
||||
path = f"data/temp/{item['filename']}.wav"
|
||||
await download_file(item["url"], path)
|
||||
return Comp.Image(file=item["url"], url=item["url"])
|
||||
case "video":
|
||||
return Comp.Video(file=item["url"])
|
||||
case _:
|
||||
return Comp.File(name=item["filename"], file=item["url"])
|
||||
|
||||
output = chunk["data"]["outputs"][self.workflow_output_key]
|
||||
chains = []
|
||||
if isinstance(output, str):
|
||||
# 纯文本输出
|
||||
chains.append(Comp.Plain(output))
|
||||
elif isinstance(output, list):
|
||||
# 主要适配 Dify 的 HTTP 请求结点的多模态输出
|
||||
for item in output:
|
||||
# handle Array[File]
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
or item.get("dify_model_identity", "") != "__dify__file__"
|
||||
):
|
||||
chains.append(Comp.Plain(str(output)))
|
||||
break
|
||||
else:
|
||||
chains.append(Comp.Plain(str(output)))
|
||||
|
||||
# scan file
|
||||
files = chunk["data"].get("files", [])
|
||||
for item in files:
|
||||
comp = await parse_file(item)
|
||||
chains.append(comp)
|
||||
|
||||
return MessageChain(chain=chains)
|
||||
|
||||
async def forget(self, session_id):
|
||||
self.conversation_ids[session_id] = ""
|
||||
|
||||
@@ -57,23 +57,30 @@ class ProviderEdgeTTS(TTSProvider):
|
||||
|
||||
# 使用ffmpeg将MP3转换为标准WAV格式
|
||||
_ = await asyncio.create_subprocess_exec(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y", # 覆盖输出文件
|
||||
"-i",
|
||||
mp3_path, # 输入文件
|
||||
"-acodec",
|
||||
"pcm_s16le", # 16位PCM编码
|
||||
"-ar",
|
||||
"24000", # 采样率24kHz (适合微信语音)
|
||||
"-ac",
|
||||
"1", # 单声道
|
||||
wav_path, # 输出文件
|
||||
],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
"ffmpeg",
|
||||
"-y", # 覆盖输出文件
|
||||
"-i",
|
||||
mp3_path, # 输入文件
|
||||
"-acodec",
|
||||
"pcm_s16le", # 16位PCM编码
|
||||
"-ar",
|
||||
"24000", # 采样率24kHz (适合微信语音)
|
||||
"-ac",
|
||||
"1", # 单声道
|
||||
"-af",
|
||||
"apad=pad_dur=2", # 确保输出时长准确
|
||||
"-fflags",
|
||||
"+genpts", # 强制生成时间戳
|
||||
"-hide_banner", # 隐藏版本信息
|
||||
wav_path, # 输出文件
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
# 等待进程完成并获取输出
|
||||
stdout, stderr = await _.communicate()
|
||||
logger.info(f"[EdgeTTS] FFmpeg 标准输出: {stdout.decode().strip()}")
|
||||
logger.debug(f"FFmpeg错误输出: {stderr.decode().strip()}")
|
||||
logger.info(f"[EdgeTTS] 返回值(0代表成功): {_.returncode}")
|
||||
os.remove(mp3_path)
|
||||
if os.path.exists(wav_path) and os.path.getsize(wav_path) > 0:
|
||||
return wav_path
|
||||
@@ -82,13 +89,15 @@ class ProviderEdgeTTS(TTSProvider):
|
||||
raise RuntimeError("生成的WAV文件不存在或为空")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"FFmpeg转换失败: {e.stderr.decode() if e.stderr else str(e)}")
|
||||
logger.error(
|
||||
f"FFmpeg 转换失败: {e.stderr.decode() if e.stderr else str(e)}"
|
||||
)
|
||||
try:
|
||||
if os.path.exists(mp3_path):
|
||||
os.remove(mp3_path)
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"FFmpeg转换失败: {str(e)}")
|
||||
raise RuntimeError(f"FFmpeg 转换失败: {str(e)}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"音频生成失败: {str(e)}")
|
||||
|
||||
@@ -18,10 +18,14 @@ class ProviderOpenAITTSAPI(TTSProvider):
|
||||
self.chosen_api_key = provider_config.get("api_key", "")
|
||||
self.voice = provider_config.get("openai-tts-voice", "alloy")
|
||||
|
||||
timeout = provider_config.get("timeout", NOT_GIVEN)
|
||||
if isinstance(timeout, str):
|
||||
timeout = int(timeout)
|
||||
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=self.chosen_api_key,
|
||||
base_url=provider_config.get("api_base", None),
|
||||
timeout=provider_config.get("timeout", NOT_GIVEN),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
self.set_model(provider_config.get("model", None))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -15,7 +15,6 @@ from ..filter.regex import RegexFilter
|
||||
from typing import Awaitable
|
||||
from astrbot.core.provider.func_tool_manager import SUPPORTED_TYPES
|
||||
from astrbot.core.provider.register import llm_tools
|
||||
from astrbot.core import logger
|
||||
|
||||
|
||||
def get_handler_full_name(awaitable: Awaitable) -> str:
|
||||
@@ -359,9 +358,9 @@ def register_llm_tool(name: str = None):
|
||||
}
|
||||
)
|
||||
md = get_handler_or_create(awaitable, EventType.OnCallingFuncToolEvent)
|
||||
llm_tools.add_func(llm_tool_name, args, docstring.description, md.handler)
|
||||
|
||||
logger.debug(f"LLM 函数工具 {llm_tool_name} 已注册")
|
||||
llm_tools.add_func(
|
||||
llm_tool_name, args, docstring.description.strip(), md.handler
|
||||
)
|
||||
return awaitable
|
||||
|
||||
return decorator
|
||||
|
||||
@@ -14,6 +14,8 @@ star_map: Dict[str, StarMetadata] = {}
|
||||
class StarMetadata:
|
||||
"""
|
||||
插件的元数据。
|
||||
|
||||
当 activated 为 False 时,star_cls 可能为 None,请不要在插件未激活时调用 star_cls 的方法。
|
||||
"""
|
||||
|
||||
name: str
|
||||
|
||||
@@ -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:
|
||||
@@ -184,6 +187,8 @@ class PluginManager:
|
||||
f"插件 {smd.name} 未被正常终止: {str(e)}, 可能会导致该插件运行不正常。"
|
||||
)
|
||||
|
||||
await self._unbind_plugin(smd.name, smd.module_path)
|
||||
|
||||
star_handlers_registry.clear()
|
||||
star_map.clear()
|
||||
star_registry.clear()
|
||||
@@ -203,10 +208,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 +226,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 +239,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 +297,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 +347,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 +446,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):
|
||||
@@ -464,7 +485,9 @@ class PluginManager:
|
||||
for handler in star_handlers_registry.get_handlers_by_module_name(
|
||||
plugin_module_path
|
||||
):
|
||||
logger.debug(f"unbind handler {handler.handler_name} from {plugin_name}")
|
||||
logger.info(
|
||||
f"移除了插件 {plugin_name} 的处理函数 {handler.handler_name} ({len(star_handlers_registry)})"
|
||||
)
|
||||
star_handlers_registry.remove(handler)
|
||||
keys_to_delete = [
|
||||
k
|
||||
@@ -472,9 +495,15 @@ class PluginManager:
|
||||
if k.startswith(plugin_module_path)
|
||||
]
|
||||
for k in keys_to_delete:
|
||||
v = star_handlers_registry.star_handlers_map[k]
|
||||
logger.debug(f"unbind handler {v.handler_name} from {plugin_name} (map)")
|
||||
del star_handlers_registry.star_handlers_map[k]
|
||||
try:
|
||||
del star_handlers_registry.star_handlers_map[k]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
try:
|
||||
del sys.modules[plugin_module_path]
|
||||
except KeyError:
|
||||
logger.warning(f"模块 {plugin_module_path} 未载入")
|
||||
|
||||
async def update_plugin(self, plugin_name: str, proxy=""):
|
||||
"""升级一个插件"""
|
||||
@@ -485,7 +514,7 @@ class PluginManager:
|
||||
raise Exception("该插件是 AstrBot 保留插件,无法更新。")
|
||||
|
||||
await self.updator.update(plugin, proxy=proxy)
|
||||
await self.reload()
|
||||
await self.reload(plugin_name)
|
||||
|
||||
async def turn_off_plugin(self, plugin_name: str):
|
||||
"""
|
||||
@@ -526,8 +555,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 +577,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 +600,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)
|
||||
|
||||
@@ -8,7 +8,7 @@ class DifyAPIClient:
|
||||
def __init__(self, api_key: str, api_base: str = "https://api.dify.ai/v1"):
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base
|
||||
self.session = ClientSession()
|
||||
self.session = ClientSession(trust_env=True)
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -29,17 +29,39 @@ def validate_config(
|
||||
) -> typing.Tuple[typing.List[str], typing.Dict]:
|
||||
errors = []
|
||||
|
||||
def validate(data, metadata=schema, path=""):
|
||||
for key, meta in metadata.items():
|
||||
if key not in data:
|
||||
def validate(data: dict, metadata: dict = schema, path=""):
|
||||
for key, value in data.items():
|
||||
print(key, value)
|
||||
if key not in metadata:
|
||||
# 无 schema 的配置项,执行类型猜测
|
||||
if isinstance(value, str):
|
||||
if value.isdigit():
|
||||
data[key] = int(value)
|
||||
elif value.replace(".", "", 1).isdigit():
|
||||
data[key] = float(value)
|
||||
elif value == "true":
|
||||
data[key] = True
|
||||
elif value == "false":
|
||||
data[key] = False
|
||||
continue
|
||||
value = data[key]
|
||||
meta = metadata[key]
|
||||
# null 转换
|
||||
if value is None:
|
||||
data[key] = DEFAULT_VALUE_MAP[meta["type"]]
|
||||
continue
|
||||
# 递归验证
|
||||
if meta["type"] == "list" and isinstance(value, list):
|
||||
if meta["type"] == "list" and not isinstance(value, list):
|
||||
errors.append(
|
||||
f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}"
|
||||
)
|
||||
elif (
|
||||
meta["type"] == "list"
|
||||
and isinstance(value, list)
|
||||
and value
|
||||
and "items" in meta
|
||||
and isinstance(value[0], dict)
|
||||
):
|
||||
# 当前仅针对 list[dict] 的情况进行类型校验,以适配 AstrBot 中 platform、provider 的配置
|
||||
for item in value:
|
||||
validate(item, meta["items"], path=f"{path}{key}.")
|
||||
elif meta["type"] == "object" and isinstance(value, dict):
|
||||
@@ -103,6 +125,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 +172,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 +339,5 @@ class ConfigRoute(Route):
|
||||
|
||||
try:
|
||||
save_config(post_configs, md.config)
|
||||
self.core_lifecycle.restart()
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 用于验证请求
|
||||
@@ -118,12 +120,14 @@ class AstrBotDashboard:
|
||||
return f"获取进程信息失败: {str(e)}"
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
ip_addr = get_local_ip_addresses()
|
||||
except Exception as _:
|
||||
ip_addr = []
|
||||
|
||||
ip_addr = []
|
||||
port = self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185)
|
||||
host = self.core_lifecycle.astrbot_config["dashboard"].get("host", "127.0.0.1")
|
||||
if host not in ["localhost", "127.0.0.1"]:
|
||||
try:
|
||||
ip_addr = get_local_ip_addresses()
|
||||
except Exception as _:
|
||||
pass
|
||||
if isinstance(port, str):
|
||||
port = int(port)
|
||||
|
||||
@@ -145,10 +149,16 @@ class AstrBotDashboard:
|
||||
for ip in ip_addr:
|
||||
display += f" ➜ 网络: http://{ip}:{port}\n"
|
||||
display += " ➜ 默认用户名和密码: astrbot\n ✨✨✨\n"
|
||||
|
||||
if not ip_addr:
|
||||
display += (
|
||||
"可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n"
|
||||
)
|
||||
|
||||
logger.info(display)
|
||||
|
||||
return self.app.run_task(
|
||||
host="0.0.0.0",
|
||||
host=host,
|
||||
port=port,
|
||||
shutdown_trigger=self.shutdown_trigger_placeholder,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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 进行 '+' 操作的问题
|
||||
@@ -0,0 +1,57 @@
|
||||
# What's Changed
|
||||
|
||||
> Special thanks for all contributors and plugin developers and users who love AstrBot. 💖
|
||||
|
||||
## ✨ 新增的功能
|
||||
|
||||
1. 支持解析回复消息,支持 LLM 对所引用消息具有感知 #783
|
||||
2. 支持 Dify 的文件、图片、视频、音频输出 #819
|
||||
3. QQ 下支持嵌套转发(napcat) @zouyonghe
|
||||
4. 配置页样式重写,更紧凑的 WebUI 配置
|
||||
|
||||
## 🎈 功能性优化
|
||||
|
||||
1. 使用系统时间而不是 UTC+8 时间作为默认时间以适应海外用户需求 @roeseth
|
||||
2. 在对话隔离情况下也可以将整个群聊加入白名单 #746
|
||||
3. 在调用插件异常时更完整的报错输出
|
||||
4. gewechat 下对已知且没有业务处理的事件类型不显示详细日志 @diudiu62
|
||||
5. 优化 WebUI 悬浮文档 @IGCrystal
|
||||
6. 支持自定义 WebUI、Wecom Webhook Server, QQ Official Webhook Server 的 host #821
|
||||
7. Dify 下当只有图片输入时的默认 prompt 防止一些报错 #837
|
||||
|
||||
## 🐛 修复的 Bug
|
||||
|
||||
1. fishaudio 默认 baseurl 不可用
|
||||
2. gewechat 下重复登录后提示设备不存在导致无法重新登陆 @beat4ocean
|
||||
3. gewechat 下用户本人发消息会触发消息回复 @beat4ocean
|
||||
4. 钉钉 WebUI 文档不显示
|
||||
5. 更新插件后插件热重载不完全、函数工具重复添加
|
||||
6. OpenAI TTS API TypeError 报错 #755
|
||||
7. EdgeTTS 部分情况下无法使用 @Soulter @需要哦
|
||||
8. QQ 官方机器人平台下发送 base64 图片消息段报错 @Soulter @shuiping233
|
||||
9. QQ 官方机器人平台下命令参数报错信息无法正常发送 @shuiping233
|
||||
10. WebUI 错误地显示未知更新
|
||||
11. 部分情况下文件无法上传到 Telegram 群组 #601
|
||||
12. 插件管理的插件简介太长导致 “帮助”“操作”图标不显示 #790
|
||||
13. LLOnebot 合并消息转发错误 #842
|
||||
14. model_config 中自定义的配置项(如温度)类型自动变回 string #854
|
||||
|
||||
## 🧩 新增的插件
|
||||
|
||||
1. astrbot_plugin_image_understanding_Janus-Pro - 使用deepseek-ai/Janus-Pro系列模型为本地模型提供的图片理解补充 @xiewoc
|
||||
2. astrbot_plugin_moyurenpro - 摸鱼人日历,支持自定义时间时区,自定义api,支持立即发送,工作日定时发送。 @quirrel-zh @DuBwTf
|
||||
3. astrbot_plugin_wechat_manager - 微信关键字好友自动审核、关键字邀请进群。@diudiu62
|
||||
4. astrbot_plugin_qwq_filter - qwq 思考过滤工具 @beat4ocean
|
||||
5. astrbot_plugin_chatsummary - 一个通过拉取历史聊天记录,调用LLM大模型接口实现消息总结功能。@laopanmemz
|
||||
6. astrBot_PGR_Dialogue - 检测到部分战双角色的名称(或别称)时,有概率发送一条语音文本 @KurisuRee7
|
||||
7. astrbot_plugin_bv - 解析群内https://www.bilibili.com/video/BV号/ 的链接并获取视频数据与视频文件,以合并转发方式发送 @haliludaxuanfeng
|
||||
8. astrbot_plugin_gemini_exp - 让你在AstrBot调用Gemini2.0-flash-exp来生成图片或者p图。Gemini2.0-flash-exp为原生多模态模型,其既是语言模型,也是生图模型,因此能够对图像使用简单的自然语言命令进行处理。@Elen123bot
|
||||
9. astrbot_plugin_sjzb - 随机生成绝地潜兵2游戏中一组4个战备配置 @tenno1174
|
||||
10. astrbot_plugin_picture_manager - 图片管理插件,允许用户通过自定义触发指令从API或直接URL获取图片。@bigshabei
|
||||
11. astrbot_plugin_bilibiliParse - 解析哔哩哔哩视频,并以图片的形式发送给用户 @7Hello12
|
||||
12. astrbot_plugin_sensoji - 这是一个模拟日本浅草寺抽签功能的插件。用户可以通过发送 /抽签 命令随机抽取一个签文,获取运势提示。签文包含吉凶结果(如“大吉”、“凶”等)以及对应的运势描述。 @Shouugou
|
||||
13. astrbot_plugin_videosummary - 使用 bibigpt 实现视频总结 @kterna
|
||||
14. astrbot_plugin_InitiativeDialogue - 使 bot 在用户长时间未发送消息时主动与用户对话的插件 @advent259141
|
||||
15. astrbot_plugin_emoji - 基于达莉娅综合群娱插件的表情包制作插件,仅保留了@其他群员制作表情包的部分。由桑帛云API提供表情包制作。@KurisuRee7
|
||||
16. astrbot_plugin_videos_analysis - 聚合视频分享链接解析(仅测试过napcat) @miaoxutao123
|
||||
17. astrbot_plugin_daily_news - 每日 60 秒新闻推送插件 - 自动推送每日热点新闻 @anka-afk
|
||||
@@ -12,3 +12,5 @@ services:
|
||||
- "11451:11451" # optional, gewechat default port
|
||||
volumes:
|
||||
- ./data:/AstrBot/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
|
||||
@@ -1,130 +1,144 @@
|
||||
<template>
|
||||
<h3 style="margin-bottom: 8px;" v-if="iterable && metadata[metadataKey]?.type === 'object'">
|
||||
{{ metadata[metadataKey]?.description }}
|
||||
</h3>
|
||||
<v-card-text>
|
||||
<div v-for="(index, key) in iterable" :key="key" style="margin-bottom: 0.5px;"
|
||||
<div style="margin-bottom: 6px;" v-if="iterable && metadata[metadataKey]?.type === 'object'">
|
||||
<v-list-item-title style="font-weight: bold;">
|
||||
{{ metadata[metadataKey]?.description }} ({{ metadataKey }})
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle style="font-size: 12px;">
|
||||
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint"
|
||||
style="opacity: 1.0;">‼️</span>
|
||||
{{ metadata[metadataKey]?.hint }}
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
|
||||
<v-card-text style="padding: 0px;">
|
||||
<div v-for="(val, key, index) in iterable" :key="key" style="margin-bottom: 0.5px;"
|
||||
v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template">
|
||||
<v-alert v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
|
||||
style="margin-bottom: 8px" :text="metadata[metadataKey].items[key]?.hint"
|
||||
:title="'💡 ' + metadata[metadataKey].items[key]?.description" type="info" variant="tonal" color="primary">
|
||||
</v-alert>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: center; gap: 16px">
|
||||
<div style="width: 100%;" v-if="metadata[metadataKey].items[key]">
|
||||
<v-select
|
||||
v-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]" variant="outlined" :items="metadata[metadataKey].items[key]?.options"
|
||||
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'" dense
|
||||
:disabled="metadata[metadataKey].items[key]?.readonly"></v-select>
|
||||
<v-text-field
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]" :label="metadata[metadataKey].items[key]?.description + '(' + key + ')'"
|
||||
variant="outlined" dense></v-text-field>
|
||||
<v-text-field
|
||||
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]" :label="metadata[metadataKey].items[key]?.description + '(' + key + ')'"
|
||||
variant="outlined" dense></v-text-field>
|
||||
<v-textarea v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible" v-model="iterable[key]"
|
||||
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'" variant="outlined"
|
||||
dense></v-textarea>
|
||||
<v-switch v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible" v-model="iterable[key]"
|
||||
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'" color="primary"
|
||||
inset></v-switch>
|
||||
<ListConfigItem
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
|
||||
:value="iterable[key]"
|
||||
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'"/>
|
||||
|
||||
<div v-else-if="metadata[metadataKey].items[key]?.type === 'object' && !metadata[metadataKey].items[key]?.invisible"
|
||||
style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px;">
|
||||
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]"
|
||||
:metadataKey=key>
|
||||
</AstrBotConfig>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="width: 100%;" v-else>
|
||||
<!-- 在 metadata 中没有 key -->
|
||||
<v-text-field v-model="iterable[key]" :label="key" variant="outlined" dense></v-text-field>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint && !metadata[metadataKey].items[key]?.invisible">
|
||||
<v-btn icon size="x-small" style="margin-bottom: 22px;">
|
||||
<v-icon size="x-small">mdi-help</v-icon>
|
||||
<v-tooltip activator="parent" location="start">{{ metadata[metadataKey].items[key]?.hint
|
||||
}}</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible" color="primary">{{ metadata[metadataKey].items[key]?.type }}</v-chip>
|
||||
<div v-if="metadata[metadataKey].items[key]?.type === 'object'" style="padding-left: 16px;">
|
||||
<div v-if="metadata[metadataKey].items[key] && !metadata[metadataKey].items[key]?.invisible"
|
||||
style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px; margin-top: 16px">
|
||||
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]" :metadataKey=key>
|
||||
</AstrBotConfig>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row v-else style="margin: 0; align-items: center;">
|
||||
<v-col cols="6" style="padding: 0px;">
|
||||
<v-list-item>
|
||||
<v-list-item-title style="font-size: 14px; font-weight: bold;">
|
||||
{{ metadata[metadataKey].items[key]?.description + '(' + key + ')' }}
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle style="font-size: 12px;">
|
||||
<span
|
||||
v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
|
||||
style="opacity: 1.0;">‼️</span>
|
||||
{{ metadata[metadataKey].items[key]?.hint }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="1">
|
||||
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible" color="primary" label size="x-small"
|
||||
class="mb-1">{{
|
||||
metadata[metadataKey].items[key]?.type }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="5">
|
||||
<div style="width: 100%;" v-if="metadata[metadataKey].items[key]">
|
||||
<v-select
|
||||
v-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]" variant="outlined"
|
||||
:items="metadata[metadataKey].items[key]?.options" dense
|
||||
:disabled="metadata[metadataKey].items[key]?.readonly" density="compact" flat hide-details
|
||||
single-line></v-select>
|
||||
<v-text-field
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]" variant="outlined" dense density="compact" flat hide-details
|
||||
single-line></v-text-field>
|
||||
<v-text-field
|
||||
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]" variant="outlined" dense density="compact" flat hide-details
|
||||
single-line></v-text-field>
|
||||
<v-textarea
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]" variant="outlined" dense flat hide-details single-line></v-textarea>
|
||||
<v-switch
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]" color="primary" hide-details></v-switch>
|
||||
<ListConfigItem
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
|
||||
:value="iterable[key]" />
|
||||
</div>
|
||||
<div style="width: 100%;" v-else>
|
||||
<!-- 在 metadata 中没有 key -->
|
||||
<v-text-field v-model="iterable[key]" :label="key" variant="outlined" dense></v-text-field>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
|
||||
<v-divider style="border-color: #ccc;" v-if="index !== Object.keys(iterable).length - 1"></v-divider>
|
||||
|
||||
</div>
|
||||
<div v-else>
|
||||
<v-alert v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint"
|
||||
style="margin-bottom: 8px" :text="metadata[metadataKey]?.hint"
|
||||
:title="'💡 ' + metadata[metadataKey]?.description" type="info" variant="tonal" color="primary">
|
||||
</v-alert>
|
||||
|
||||
<div style="display: flex; align-items: center; justify-content: center; gap: 16px">
|
||||
<div style="width: 100%;">
|
||||
<v-select v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]" variant="outlined" :items="metadata[metadataKey]?.options"
|
||||
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" dense
|
||||
:disabled="metadata[metadataKey]?.readonly"></v-select>
|
||||
<v-text-field
|
||||
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" variant="outlined"
|
||||
dense></v-text-field>
|
||||
<v-text-field
|
||||
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" variant="outlined"
|
||||
dense></v-text-field>
|
||||
<v-textarea v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" variant="outlined"
|
||||
dense></v-textarea>
|
||||
<v-switch v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" color="primary"
|
||||
inset></v-switch>
|
||||
<ListConfigItem
|
||||
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
|
||||
:value="iterable[metadataKey]"
|
||||
:label="metadata[metadataKey]?.description + '(' + metadataKey+ ')'"/>
|
||||
<div v-else-if="metadata[metadataKey]?.type === 'object' && !metadata[metadataKey]?.invisible"
|
||||
style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px;">
|
||||
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[metadataKey]"
|
||||
:metadataKey=key>
|
||||
</AstrBotConfig>
|
||||
<v-row style="margin: 0; align-items: center;">
|
||||
<v-col cols="6" style="padding: 0px;">
|
||||
<v-list-item>
|
||||
<v-list-item-title style="font-size: 14px; font-weight: bold">
|
||||
{{ metadata[metadataKey]?.description + '(' + metadataKey + ')' }}
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle style="font-size: 12px;">
|
||||
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint">‼️</span>
|
||||
{{ metadata[metadataKey]?.hint }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="1">
|
||||
<v-chip v-if="!metadata[metadataKey]?.invisible" color="primary" label size="x-small"
|
||||
class="mb-1">{{
|
||||
metadata[metadataKey]?.type }}
|
||||
</v-chip>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="5">
|
||||
<div style="width: 100%;">
|
||||
<v-select v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]" variant="outlined" :items="metadata[metadataKey]?.options"
|
||||
dense :disabled="metadata[metadataKey]?.readonly" density="compact" flat hide-details
|
||||
single-line></v-select>
|
||||
<v-text-field
|
||||
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
|
||||
single-line></v-text-field>
|
||||
<v-text-field
|
||||
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
|
||||
single-line></v-text-field>
|
||||
<v-textarea
|
||||
v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
|
||||
single-line></v-textarea>
|
||||
<v-switch
|
||||
v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]" color="primary" hide-details></v-switch>
|
||||
<ListConfigItem
|
||||
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
|
||||
:value="iterable[metadataKey]" />
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<div
|
||||
v-if="!metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint && !metadata[metadataKey]?.invisible">
|
||||
<v-btn icon size="x-small" style="margin-bottom: 22px;">
|
||||
<v-icon size="x-small">mdi-help</v-icon>
|
||||
<v-tooltip activator="parent" location="start">{{ metadata[metadataKey]?.hint
|
||||
}}</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<v-chip v-if="!metadata[metadataKey]?.invisible" color="primary">{{ metadata[metadataKey]?.type }}</v-chip>
|
||||
</div>
|
||||
</div>
|
||||
<v-divider style="border-color: #ddd;"></v-divider>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { readonly } from 'vue';
|
||||
import ListConfigItem from './ListConfigItem.vue';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,32 +1,196 @@
|
||||
<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 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="extension?.has_update && !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="extension?.has_update " 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 }" style="max-height: 65px; overflow-y: auto;">
|
||||
{{ 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="!extension?.has_update "
|
||||
@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>
|
||||
|
||||
@@ -1,93 +1,85 @@
|
||||
<template>
|
||||
<div class="list-config-item">
|
||||
<h3>{{ label }}</h3>
|
||||
<v-list dense style="background-color: transparent;max-height: 300px; overflow-y: auto;" >
|
||||
<v-list-item v-for="(item, index) in items" :key="index">
|
||||
<v-list-item-content style="display: flex; justify-content: space-between;">
|
||||
<v-list-item-title>
|
||||
<v-chip>{{ item }}</v-chip>
|
||||
</v-list-item-title>
|
||||
<v-btn @click="removeItem(index)" variant="plain">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-text-field
|
||||
v-model="newItem"
|
||||
label="添加新项,按回车确认添加"
|
||||
@keyup.enter="addItem"
|
||||
clearable
|
||||
dense
|
||||
hide-details
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
<div class="list-config-item">
|
||||
<v-list dense style="background-color: transparent;max-height: 300px; overflow-y: auto;">
|
||||
<v-list-item v-for="(item, index) in items" :key="index">
|
||||
<v-list-item-content style="display: flex; justify-content: space-between;">
|
||||
<v-list-item-title>
|
||||
<v-chip size="small" label color="primary">{{ item }}</v-chip>
|
||||
</v-list-item-title>
|
||||
<v-btn @click="removeItem(index)" variant="plain">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<v-text-field v-model="newItem" label="添加新项,按回车确认添加" @keyup.enter="addItem" clearable dense hide-details
|
||||
variant="outlined" density="compact"></v-text-field>
|
||||
<v-btn @click="addItem" text variant="tonal">
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
添加
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ListConfigItem',
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ListConfigItem',
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newItem: '',
|
||||
items: this.value,
|
||||
};
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
watch: {
|
||||
items(newVal) {
|
||||
this.$emit('input', newVal);
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newItem: '',
|
||||
items: this.value,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
items(newVal) {
|
||||
this.$emit('input', newVal);
|
||||
},
|
||||
methods: {
|
||||
addItem() {
|
||||
if (this.newItem.trim() !== '') {
|
||||
this.items.push(this.newItem.trim());
|
||||
this.newItem = '';
|
||||
}
|
||||
},
|
||||
removeItem(index) {
|
||||
this.items.splice(index, 1);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addItem() {
|
||||
if (this.newItem.trim() !== '') {
|
||||
this.items.push(this.newItem.trim());
|
||||
this.newItem = '';
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-config-item {
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
border-radius: 10px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.list-config-item h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v-list-item-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.v-btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
removeItem(index) {
|
||||
this.items.splice(index, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-config-item {
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 16px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 10px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v-list-item-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.v-btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@ const props = defineProps({ item: Object, level: Number });
|
||||
<template v-slot:prepend>
|
||||
<v-icon v-if="item.icon" :size="item.iconSize" class="hide-menu" :icon="item.icon"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
<v-list-item-title style="font-size: 15px;">{{ item.title }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="item.subCaption" class="text-caption mt-n1 hide-menu">
|
||||
{{ item.subCaption }}
|
||||
</v-list-item-subtitle>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, shallowRef } from 'vue';
|
||||
import { ref, shallowRef, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useCustomizerStore } from '../../../stores/customizer';
|
||||
import sidebarItems from './sidebarItem';
|
||||
import NavItem from './NavItem.vue';
|
||||
@@ -8,52 +9,179 @@ const customizer = useCustomizerStore();
|
||||
const sidebarMenu = shallowRef(sidebarItems);
|
||||
|
||||
const showIframe = ref(false);
|
||||
const version = ref("");
|
||||
const buildVer = ref("");
|
||||
const hasWebUIUpdate = ref(false);
|
||||
|
||||
const dragButtonStyle = {
|
||||
// 默认桌面端 iframe 样式
|
||||
const iframeStyle = ref({
|
||||
position: 'fixed',
|
||||
bottom: '16px',
|
||||
right: '16px',
|
||||
width: '490px',
|
||||
height: '640px',
|
||||
minWidth: '300px',
|
||||
minHeight: '200px',
|
||||
background: 'white',
|
||||
resize: 'both',
|
||||
overflow: 'auto',
|
||||
zIndex: '10000000',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
});
|
||||
|
||||
// 如果为移动端,则采用百分比尺寸,并设置初始位置
|
||||
if (window.innerWidth < 768) {
|
||||
iframeStyle.value = {
|
||||
position: 'fixed',
|
||||
top: '10%',
|
||||
left: '0%',
|
||||
width: '100%',
|
||||
height: '50%',
|
||||
minWidth: '300px',
|
||||
minHeight: '200px',
|
||||
background: 'white',
|
||||
resize: 'both',
|
||||
overflow: 'auto',
|
||||
zIndex: '1002',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
};
|
||||
// 移动端默认关闭侧边栏
|
||||
customizer.Sidebar_drawer = false;
|
||||
}
|
||||
|
||||
const dragHeaderStyle = {
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
cursor: 'move',
|
||||
padding: '8px',
|
||||
background: '#f0f0f0',
|
||||
borderBottom: '1px solid #ccc',
|
||||
borderTopLeftRadius: '8px',
|
||||
borderTopRightRadius: '8px'
|
||||
borderTopRightRadius: '8px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
cursor: 'move'
|
||||
};
|
||||
|
||||
function toggleIframe() {
|
||||
showIframe.value = !showIframe.value;
|
||||
}
|
||||
|
||||
function openIframeLink() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.open("https://astrbot.app", "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
// 拖拽相关变量与函数
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
let isDragging = false;
|
||||
|
||||
// @ts-ignore
|
||||
function onMouseDown(event) {
|
||||
isDragging = true;
|
||||
offsetX = event.clientX - event.target.parentElement.getBoundingClientRect().left;
|
||||
offsetY = event.clientY - event.target.parentElement.getBoundingClientRect().top;
|
||||
// 辅助函数:限制数值在一定范围内
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
// @ts-ignore
|
||||
|
||||
function startDrag(clientX, clientY) {
|
||||
isDragging = true;
|
||||
const dm = document.getElementById('draggable-iframe');
|
||||
const rect = dm.getBoundingClientRect();
|
||||
offsetX = clientX - rect.left;
|
||||
offsetY = clientY - rect.top;
|
||||
document.body.style.userSelect = 'none';
|
||||
// 绑定全局鼠标和触摸事件
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', onTouchEnd);
|
||||
}
|
||||
|
||||
function onMouseDown(event) {
|
||||
startDrag(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
function onMouseMove(event) {
|
||||
if (isDragging) {
|
||||
const dm = document.getElementById('draggable-iframe');
|
||||
// @ts-ignore
|
||||
dm.style.left = (event.clientX - offsetX) + 'px';
|
||||
// @ts-ignore
|
||||
dm.style.top = (event.clientY - offsetY) + 'px';
|
||||
moveAt(event.clientX, event.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
isDragging = false;
|
||||
endDrag();
|
||||
}
|
||||
|
||||
function onTouchStart(event) {
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0];
|
||||
startDrag(touch.clientX, touch.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchMove(event) {
|
||||
if (isDragging && event.touches.length === 1) {
|
||||
event.preventDefault();
|
||||
const touch = event.touches[0];
|
||||
moveAt(touch.clientX, touch.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
endDrag();
|
||||
}
|
||||
|
||||
function moveAt(clientX, clientY) {
|
||||
const dm = document.getElementById('draggable-iframe');
|
||||
const newLeft = clamp(clientX - offsetX, 0, window.innerWidth - dm.offsetWidth);
|
||||
const newTop = clamp(clientY - offsetY, 0, window.innerHeight - dm.offsetHeight);
|
||||
// 将拖拽后的位置同步到响应式样式变量中
|
||||
iframeStyle.value.left = newLeft + 'px';
|
||||
iframeStyle.value.top = newTop + 'px';
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
isDragging = false;
|
||||
document.body.style.userSelect = '';
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
document.removeEventListener('touchend', onTouchEnd);
|
||||
}
|
||||
|
||||
// 获取版本和更新信息
|
||||
onMounted(() => {
|
||||
axios.get('/api/stat/version')
|
||||
.then((res) => {
|
||||
version.value = "v" + res.data.data.version;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
|
||||
axios.get('/api/update/check?type=dashboard')
|
||||
.then((res) => {
|
||||
hasWebUIUpdate.value = res.data.data.has_new_version;
|
||||
buildVer.value = res.data.data.current_version;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-navigation-drawer left v-model="customizer.Sidebar_drawer" elevation="0" rail-width="80" app class="leftSidebar"
|
||||
:rail="customizer.mini_sidebar">
|
||||
<v-list class="pa-4 listitem" style="height: auto">
|
||||
<v-navigation-drawer
|
||||
left
|
||||
v-model="customizer.Sidebar_drawer"
|
||||
elevation="0"
|
||||
rail-width="80"
|
||||
app
|
||||
class="leftSidebar"
|
||||
width="220"
|
||||
:rail="customizer.mini_sidebar"
|
||||
>
|
||||
<v-list class="pa-4 listitem" style="height: auto;">
|
||||
<template v-for="(item, i) in sidebarMenu" :key="i">
|
||||
<NavItem :item="item" class="leftPadding" />
|
||||
</template>
|
||||
@@ -61,75 +189,60 @@ function onMouseUp() {
|
||||
<div class="text-center">
|
||||
<v-chip color="inputBorder" size="small"> {{ version }} </v-chip>
|
||||
</div>
|
||||
|
||||
<div style="position: absolute; bottom: 32px; width: 100%" class="text-center">
|
||||
<div style="position: absolute; bottom: 32px; width: 100%; font-size: 13px;" class="text-center">
|
||||
<v-list-item v-if="!customizer.mini_sidebar" @click="toggleIframe">
|
||||
<v-btn variant="plain" size="small">
|
||||
🤔 点击查看悬浮文档!
|
||||
🤔 点击此处 查看/关闭 悬浮文档!
|
||||
</v-btn>
|
||||
</v-list-item>
|
||||
<small style="display: block;" v-if="buildVer">构建: {{ buildVer }}</small>
|
||||
<small style="display: block;" v-if="buildVer">WebUI 版本: {{ buildVer }}</small>
|
||||
<small style="display: block;" v-else>构建: embedded</small>
|
||||
<v-tooltip text="使用 /dashboard_update 指令更新管理面板">
|
||||
<template v-slot:activator="{ props }">
|
||||
<small v-bind="props" v-if="hasWebUIUpdate" style="display: block; margin-top: 4px;">面板有更新</small>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
|
||||
<small style="display: block; margin-top: 8px;">© 2025 AstrBot</small>
|
||||
<small style="display: block; margin-top: 8px;">AGPL-3.0</small>
|
||||
</div>
|
||||
|
||||
</v-navigation-drawer>
|
||||
<div v-if="showIframe"
|
||||
|
||||
<!-- 优化后的悬浮 iframe -->
|
||||
<div
|
||||
v-if="showIframe"
|
||||
id="draggable-iframe"
|
||||
style="position: fixed; bottom: 16px; right: 16px; width: 500px; height: 400px; border: 1px solid #ccc; background: white; resize: both; overflow: auto; z-index: 10000000; border-radius: 8px;"
|
||||
@mousemove="onMouseMove"
|
||||
@mouseup="onMouseUp"
|
||||
@mouseleave="onMouseUp">
|
||||
<div :style="dragButtonStyle" @mousedown="onMouseDown">
|
||||
<v-icon icon="mdi-cursor-move" />
|
||||
:style="iframeStyle"
|
||||
>
|
||||
<!-- 拖拽头部:支持鼠标和触摸 -->
|
||||
<div :style="dragHeaderStyle" @mousedown="onMouseDown" @touchstart="onTouchStart">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<v-icon icon="mdi-cursor-move" />
|
||||
<span style="margin-left: 8px;">拖拽</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<!-- 跳转按钮 -->
|
||||
<v-btn
|
||||
icon
|
||||
@click.stop="openIframeLink"
|
||||
@mousedown.stop
|
||||
style="border-radius: 8px; border: 1px solid #ccc;"
|
||||
>
|
||||
<v-icon icon="mdi-open-in-new" />
|
||||
</v-btn>
|
||||
<!-- 关闭按钮 -->
|
||||
<v-btn
|
||||
icon
|
||||
@click.stop="toggleIframe"
|
||||
@mousedown.stop
|
||||
style="border-radius: 8px; border: 1px solid #ccc;"
|
||||
>
|
||||
<v-icon icon="mdi-close" />
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<iframe src="https://astrbot.app" style="width: 100%; height: calc(100% - 24px); border: none; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px;"></iframe>
|
||||
<!-- iframe 区域 -->
|
||||
<iframe
|
||||
src="https://astrbot.app"
|
||||
style="width: 100%; height: calc(100% - 56px); border: none; border-bottom-left-radius: 12px; border-bottom-right-radius: 12px;"
|
||||
></iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios';
|
||||
export default {
|
||||
name: 'VerticalSidebar',
|
||||
components: {
|
||||
NavItem,
|
||||
},
|
||||
data: () => ({
|
||||
version: "",
|
||||
buildVer: "",
|
||||
hasWebUIUpdate: false,
|
||||
}),
|
||||
mounted() {
|
||||
this.get_version()
|
||||
this.check_webui_update()
|
||||
},
|
||||
methods: {
|
||||
get_version() {
|
||||
axios.get('/api/stat/version')
|
||||
.then((res) => {
|
||||
this.version = "v" + res.data.data.version;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
},
|
||||
check_webui_update() {
|
||||
axios.get('/api/update/check?type=dashboard')
|
||||
.then((res) => {
|
||||
this.hasWebUIUpdate = res.data.data.has_new_version;
|
||||
this.buildVer = res.data.data.current_version;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err);
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -18,6 +18,7 @@ export const useCommonStore = defineStore({
|
||||
"gewechat": "https://astrbot.app/deploy/platform/gewechat.html",
|
||||
"lark": "https://astrbot.app/deploy/platform/lark.html",
|
||||
"telegram": "https://astrbot.app/deploy/platform/telegram.html",
|
||||
"dingtalk": "https://astrbot.app/deploy/platform/dingtalk.html",
|
||||
},
|
||||
|
||||
pluginMarketData: []
|
||||
@@ -72,7 +73,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;
|
||||
|
||||
@@ -30,73 +30,83 @@ import config from '@/config';
|
||||
<!-- 可视化编辑 -->
|
||||
<v-card v-if="editorTab === 0">
|
||||
<v-tabs v-model="tab" align-tabs="left" color="deep-purple-accent-4">
|
||||
<v-tab v-for="(val, key, index) in metadata" :key="index" :value="index" style="font-weight: 1000; font-size: 15px">
|
||||
<v-tab v-for="(val, key, index) in metadata" :key="index" :value="index"
|
||||
style="font-weight: 1000; font-size: 15px">
|
||||
{{ metadata[key]['name'] }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
<v-tabs-window v-model="tab">
|
||||
<v-tabs-window-item v-for="(val, key, index) in metadata" v-show="index == tab" :key="index">
|
||||
<v-container fluid>
|
||||
<v-expansion-panels variant="accordion">
|
||||
<v-expansion-panel v-for="(val2, key2, index2) in metadata[key]['metadata']">
|
||||
<v-expansion-panel-title>
|
||||
<h3>{{metadata[key]['metadata'][key2]['description']}}</h3>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text v-if="metadata[key]['metadata'][key2]?.config_template">
|
||||
<!-- 带有 config_template 的配置项 -->
|
||||
|
||||
<v-alert style="margin-top: 16px; margin-bottom: 16px" color="primary" variant="tonal" v-if="key2 === 'platform' || key2 === 'provider'">
|
||||
😄 消息平台适配器和服务提供商的配置已经迁移至更方便的独立页面!推荐前往左栏配置哦~
|
||||
</v-alert>
|
||||
<div v-for="(val2, key2, index2) in metadata[key]['metadata']">
|
||||
<!-- <h3>{{ metadata[key]['metadata'][key2]['description'] }}</h3> -->
|
||||
<div v-if="metadata[key]['metadata'][key2]?.config_template"
|
||||
v-show="key2 !== 'platform' && key2 !== 'provider'" style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px">
|
||||
<!-- 带有 config_template 的配置项 -->
|
||||
<v-list-item-title style="font-weight: bold;">
|
||||
{{ metadata[key]['metadata'][key2]['description'] }} ({{ key2 }})
|
||||
</v-list-item-title>
|
||||
<v-tabs style="margin-top: 16px;" align-tabs="left" color="deep-purple-accent-4"
|
||||
|
||||
v-model="config_template_tab">
|
||||
<v-tab v-if="metadata[key]['metadata'][key2]?.tmpl_display_title"
|
||||
v-for="(item, index) in config_data[key2]" :key="index" :value="index">
|
||||
{{ item[metadata[key]['metadata'][key2]?.tmpl_display_title] }}
|
||||
</v-tab>
|
||||
<v-tab v-else v-for="(item, index) in config_data[key2]" :key="index + '_'" :value="index">
|
||||
{{ item.id }}({{ item.type }})
|
||||
</v-tab>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn variant="plain" size="large" v-bind="props">
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list @update:selected="addFromDefaultConfigTmpl($event, key, key2)">
|
||||
<v-list-item v-for="(item, index) in metadata[key]['metadata'][key2]?.config_template" :key="index"
|
||||
:value="index">
|
||||
<v-list-item-title>{{ index }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-tabs>
|
||||
<v-tabs-window v-model="config_template_tab">
|
||||
<v-tabs-window-item v-for="(config_item, index) in config_data[key2]"
|
||||
v-show="config_template_tab === index" :key="index" :value="index">
|
||||
<div style="padding: 16px;">
|
||||
<v-btn variant="tonal" rounded="xl" color="error" @click="deleteItem(key2, index)">
|
||||
删除这项
|
||||
</v-btn>
|
||||
|
||||
<AstrBotConfig :metadata="metadata[key]['metadata']" :iterable="config_item" :metadataKey="key2">
|
||||
</AstrBotConfig>
|
||||
</div>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 如果配置项是一个 object,那么 iterable 需要取到这个 object 的值,否则取到整个 config_data -->
|
||||
<div v-if="metadata[key]['metadata'][key2]['type'] == 'object'" style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px">
|
||||
<AstrBotConfig
|
||||
:metadata="metadata[key]['metadata']" :iterable="config_data[key2]" :metadataKey="key2">
|
||||
</AstrBotConfig>
|
||||
</div>
|
||||
<AstrBotConfig v-else :metadata="metadata[key]['metadata']" :iterable="config_data" :metadataKey="key2">
|
||||
</AstrBotConfig>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<v-tabs style="margin-top: 16px;" align-tabs="left" color="deep-purple-accent-4" v-model="config_template_tab">
|
||||
<v-tab v-if="metadata[key]['metadata'][key2]?.tmpl_display_title" v-for="(item, index) in config_data[key2]" :key="index" :value="index">
|
||||
{{ item[metadata[key]['metadata'][key2]?.tmpl_display_title] }}
|
||||
</v-tab>
|
||||
<v-tab v-else v-for="(item, index) in config_data[key2]" :key="index + '_'" :value="index">
|
||||
{{ item.id }}({{ item.type }})
|
||||
</v-tab>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn variant="plain" size="large" v-bind="props">
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list @update:selected="addFromDefaultConfigTmpl($event, key, key2)">
|
||||
<v-list-item v-for="(item, index) in metadata[key]['metadata'][key2]?.config_template" :key="index" :value="index">
|
||||
<v-list-item-title>{{ index }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-tabs>
|
||||
<v-tabs-window v-model="config_template_tab">
|
||||
<v-tabs-window-item v-for="(config_item, index) in config_data[key2]" v-show="config_template_tab === index"
|
||||
:key="index" :value="index">
|
||||
<v-container>
|
||||
<v-btn variant="tonal" rounded="xl" color="error" @click="deleteItem(key2, index)">
|
||||
删除这项
|
||||
</v-btn>
|
||||
|
||||
<AstrBotConfig :metadata="metadata[key]['metadata']" :iterable="config_item" :metadataKey="key2"></AstrBotConfig>
|
||||
</v-container>
|
||||
</v-tabs-window-item>
|
||||
</v-tabs-window>
|
||||
</v-expansion-panel-text>
|
||||
<v-expansion-panel-text v-else>
|
||||
<!-- 如果配置项是一个 object,那么 iterable 需要取到这个 object 的值,否则取到整个 config_data -->
|
||||
<AstrBotConfig v-if="metadata[key]['metadata'][key2]['type'] == 'object'" :metadata="metadata[key]['metadata']" :iterable="config_data[key2]" :metadataKey="key2"></AstrBotConfig>
|
||||
<AstrBotConfig v-else :metadata="metadata[key]['metadata']" :iterable="config_data" :metadataKey="key2"></AstrBotConfig>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
|
||||
</v-expansion-panels>
|
||||
</v-container>
|
||||
</v-tabs-window-item>
|
||||
|
||||
|
||||
<div style="margin-left: 16px; padding-bottom: 16px">
|
||||
<small>不了解配置?请见 <a
|
||||
href="https://astrbot.app/">官方文档</a>
|
||||
<small>不了解配置?请见 <a href="https://astrbot.app/">官方文档</a>
|
||||
或 <a
|
||||
href="https://qm.qq.com/cgi-bin/qm/qr?k=EYGsuUTfe00_iOu9JTXS7_TEpMkXOvwv&jump_from=webapi&authKey=uUEMKCROfsseS+8IzqPjzV3y1tzy4AkykwTib2jNkOFdzezF9s9XknqnIaf3CDft">加群询问</a>。</small>
|
||||
</div>
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 => {
|
||||
// 通过repo或name查找在线版本
|
||||
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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+69
-35
@@ -1,7 +1,7 @@
|
||||
import aiohttp
|
||||
import datetime
|
||||
import builtins
|
||||
import json
|
||||
import traceback
|
||||
import astrbot.api.star as star
|
||||
import astrbot.api.event.filter as filter
|
||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||
@@ -17,7 +17,7 @@ from astrbot.core.star.filter.permission import PermissionTypeFilter
|
||||
from astrbot.core.config.default import VERSION
|
||||
from .long_term_memory import LongTermMemory
|
||||
from astrbot.core import logger
|
||||
from astrbot.api.message_components import Plain, Image
|
||||
from astrbot.api.message_components import Plain, Image, Reply
|
||||
|
||||
from typing import Union
|
||||
|
||||
@@ -72,16 +72,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 +162,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 +200,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 +218,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))
|
||||
|
||||
@@ -262,15 +262,32 @@ AstrBot 指令:
|
||||
"""获取会话 ID 和 管理员 ID"""
|
||||
sid = event.unified_msg_origin
|
||||
user_id = str(event.get_sender_id())
|
||||
ret = f"""SID: {sid} 此 ID 可用于设置会话白名单。/wl <SID> 添加白名单, /dwl <SID> 删除白名单。
|
||||
UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deop <UID> 取消管理员。"""
|
||||
ret = f"""SID: {sid} 此 ID 可用于设置会话白名单。
|
||||
/wl <SID> 添加白名单, /dwl <SID> 删除白名单。
|
||||
|
||||
UID: {user_id} 此 ID 可用于设置管理员。
|
||||
/op <UID> 授权管理员, /deop <UID> 取消管理员。"""
|
||||
|
||||
if (
|
||||
self.context.get_config()["platform_settings"]["unique_session"]
|
||||
and event.get_group_id()
|
||||
):
|
||||
ret += f"\n\n当前处于独立会话模式, 此群 ID: {event.get_group_id()}, 也可将此 ID 加入白名单来放行整个群聊。"
|
||||
|
||||
event.set_result(MessageEventResult().message(ret).use_t2i(False))
|
||||
|
||||
@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>"""
|
||||
self.context.get_config()["admins_id"].append(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(str(admin_id))
|
||||
self.context.get_config().save_config()
|
||||
event.set_result(MessageEventResult().message("授权成功。"))
|
||||
|
||||
@@ -279,7 +296,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
|
||||
async def deop(self, event: AstrMessageEvent, admin_id: str):
|
||||
"""取消授权管理员。deop <admin_id>"""
|
||||
try:
|
||||
self.context.get_config()["admins_id"].remove(admin_id)
|
||||
self.context.get_config()["admins_id"].remove(str(admin_id))
|
||||
self.context.get_config().save_config()
|
||||
event.set_result(MessageEventResult().message("取消授权成功。"))
|
||||
except ValueError:
|
||||
@@ -289,9 +306,15 @@ 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>"""
|
||||
self.context.get_config()["platform_settings"]["id_whitelist"].append(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(str(sid))
|
||||
self.context.get_config().save_config()
|
||||
event.set_result(MessageEventResult().message("添加白名单成功。"))
|
||||
|
||||
@@ -300,7 +323,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
|
||||
async def dwl(self, event: AstrMessageEvent, sid: str):
|
||||
"""删除白名单。dwl <sid>"""
|
||||
try:
|
||||
self.context.get_config()["platform_settings"]["id_whitelist"].remove(sid)
|
||||
self.context.get_config()["platform_settings"]["id_whitelist"].remove(str(sid))
|
||||
self.context.get_config().save_config()
|
||||
event.set_result(MessageEventResult().message("删除白名单成功。"))
|
||||
except ValueError:
|
||||
@@ -1023,9 +1046,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 +1060,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,18 +1080,25 @@ 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:
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error(f"主动回复失败: {e}")
|
||||
|
||||
@filter.on_llm_request()
|
||||
async def decorate_llm_req(self, event: AstrMessageEvent, req: ProviderRequest):
|
||||
"""在请求 LLM 前注入人格信息、Identifier、时间等 System Prompt"""
|
||||
"""在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
|
||||
if self.prompt_prefix:
|
||||
req.prompt = self.prompt_prefix + req.prompt
|
||||
|
||||
# 解析引用内容
|
||||
quote = None
|
||||
for comp in event.message_obj.message:
|
||||
if isinstance(comp, Reply):
|
||||
quote = comp
|
||||
break
|
||||
|
||||
if self.identifier:
|
||||
user_id = event.message_obj.sender.user_id
|
||||
user_nickname = event.message_obj.sender.nickname
|
||||
@@ -1080,9 +1106,10 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
|
||||
req.prompt = user_info + req.prompt
|
||||
|
||||
if self.enable_datetime:
|
||||
tz_offset = datetime.timedelta(hours=8)
|
||||
tz = datetime.timezone(tz_offset)
|
||||
current_time = datetime.datetime.now(tz).strftime("%Y-%m-%d %H:%M")
|
||||
# Including timezone
|
||||
current_time = (
|
||||
datetime.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%Z)")
|
||||
)
|
||||
req.system_prompt += f"\nCurrent datetime: {current_time}\n"
|
||||
|
||||
if req.conversation:
|
||||
@@ -1107,6 +1134,13 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
|
||||
if begin_dialogs := persona["_begin_dialogs_processed"]:
|
||||
req.contexts[:0] = begin_dialogs
|
||||
|
||||
if quote and quote.message_str:
|
||||
if quote.sender_nickname:
|
||||
sender_info = f"(Sent by {quote.sender_nickname})"
|
||||
else:
|
||||
sender_info = ""
|
||||
req.system_prompt += f"\nUser is quoting the message{sender_info}: {quote.message_str}, please consider the context."
|
||||
|
||||
if self.ltm:
|
||||
try:
|
||||
await self.ltm.on_req_llm(event, req)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
@@ -24,3 +24,4 @@ cryptography
|
||||
dashscope
|
||||
python-telegram-bot
|
||||
wechatpy
|
||||
dingtalk-stream
|
||||
Reference in New Issue
Block a user