mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-05 04:10:15 +08:00
Compare commits
22 Commits
v4.25.2
...
feat/futur
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4bc404095 | ||
|
|
f6965f4676 | ||
|
|
f28ae5f73e | ||
|
|
5b420c74be | ||
|
|
8a1988a2c9 | ||
|
|
df6eef052f | ||
|
|
f01dc474ef | ||
|
|
072691877d | ||
|
|
6a467fc043 | ||
|
|
d912e1497c | ||
|
|
92b2ce872c | ||
|
|
4bb1b897df | ||
|
|
d2f5551513 | ||
|
|
25b134444f | ||
|
|
def81530b0 | ||
|
|
4b097011cf | ||
|
|
7d45a247d5 | ||
|
|
e8d13af5b9 | ||
|
|
e4044cc5a0 | ||
|
|
c89ac61892 | ||
|
|
fbc0633cd3 | ||
|
|
90a3a2171a |
4
.github/workflows/docker-image.yml
vendored
4
.github/workflows/docker-image.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
||||
echo "build_date=$build_date" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v4.0.0
|
||||
uses: docker/setup-qemu-action@v4.1.0
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4.1.0
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
cp -r dashboard/dist data/
|
||||
|
||||
- name: Set QEMU
|
||||
uses: docker/setup-qemu-action@v4.0.0
|
||||
uses: docker/setup-qemu-action@v4.1.0
|
||||
|
||||
- name: Set Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4.1.0
|
||||
|
||||
@@ -169,10 +169,14 @@ class Main(star.Star):
|
||||
"provider_ltm_settings"
|
||||
]["group_icl_enable"]
|
||||
if group_icl_enable:
|
||||
try:
|
||||
await self.group_chat_context.handle_message(event)
|
||||
except BaseException as e:
|
||||
logger.error(e)
|
||||
# Skip recording if a command handler matched (e.g. /reset,
|
||||
# /help, /new). Slash commands are bot instructions, not group
|
||||
# chat context that should be injected into future LLM requests.
|
||||
if not event.get_extra("handlers_parsed_params", {}):
|
||||
try:
|
||||
await self.group_chat_context.handle_message(event)
|
||||
except BaseException as e:
|
||||
logger.error(e)
|
||||
|
||||
if need_active:
|
||||
provider = self.context.get_using_provider(event.unified_msg_origin)
|
||||
|
||||
@@ -241,7 +241,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.tool_result_overflow_dir = tool_result_overflow_dir
|
||||
self.read_tool = read_tool
|
||||
self._tool_result_token_counter = EstimateTokenCounter()
|
||||
self.request_context_manager_config = ContextConfig(
|
||||
self.context_config = ContextConfig(
|
||||
# <=0 disables token-based guarding.
|
||||
max_context_tokens=provider.provider_config.get("max_context_tokens", 0),
|
||||
# Enforce max turns before token-based guarding.
|
||||
@@ -253,9 +253,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
custom_token_counter=self.custom_token_counter,
|
||||
custom_compressor=self.custom_compressor,
|
||||
)
|
||||
self.request_context_manager = ContextManager(
|
||||
self.request_context_manager_config
|
||||
)
|
||||
self.context_manager = ContextManager(self.context_config)
|
||||
|
||||
self.provider = provider
|
||||
self.fallback_providers: list[Provider] = []
|
||||
@@ -331,7 +329,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
request: ProviderRequest,
|
||||
) -> dict[str, T.Any]:
|
||||
modalities = self.provider.provider_config.get("modalities", None)
|
||||
if not isinstance(modalities, list):
|
||||
if not modalities: # Unconfigured (None or empty list) defaults to support all modalities for backward compatibility
|
||||
return await request.assemble_context()
|
||||
|
||||
supports_image = "image" in modalities
|
||||
@@ -458,11 +456,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self, *, include_model: bool = True
|
||||
) -> T.AsyncGenerator[LLMResponse, None]:
|
||||
"""Yields chunks *and* a final LLMResponse."""
|
||||
messages_for_provider = getattr(
|
||||
self, "_provider_messages", self.run_context.messages
|
||||
)
|
||||
payload = {
|
||||
"contexts": self._sanitize_contexts_for_provider(messages_for_provider),
|
||||
"contexts": self._sanitize_contexts_for_provider(self.run_context.messages),
|
||||
"func_tool": self._func_tool_for_provider(),
|
||||
"session_id": self.req.session_id,
|
||||
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
|
||||
@@ -583,7 +578,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self,
|
||||
contexts: list[Message] | list[dict[str, T.Any]],
|
||||
) -> list[Message] | list[dict[str, T.Any]]:
|
||||
if not self._should_fix_modalities_for_provider():
|
||||
modalities = self.provider.provider_config.get("modalities", None)
|
||||
if not modalities: # Unconfigured (None or empty list) defaults to support all modalities
|
||||
return contexts
|
||||
sanitized_contexts, stats = sanitize_contexts_by_modalities(
|
||||
contexts,
|
||||
@@ -592,15 +588,11 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
log_context_sanitize_stats(stats)
|
||||
return sanitized_contexts
|
||||
|
||||
def _should_fix_modalities_for_provider(self) -> bool:
|
||||
modalities = self.provider.provider_config.get("modalities", None)
|
||||
return isinstance(modalities, list)
|
||||
|
||||
def _func_tool_for_provider(self) -> ToolSet | None:
|
||||
if not self.req.func_tool:
|
||||
return None
|
||||
modalities = self.provider.provider_config.get("modalities", None)
|
||||
if isinstance(modalities, list) and "tool_use" not in modalities:
|
||||
if isinstance(modalities, list) and modalities and "tool_use" not in modalities:
|
||||
logger.debug(
|
||||
"Provider %s does not support tool_use, clearing tools for request.",
|
||||
self.provider,
|
||||
@@ -712,7 +704,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
# memory layer.
|
||||
token_usage = self.req.conversation.token_usage if self.req.conversation else 0
|
||||
self._simple_print_message_role("[BefCompact]")
|
||||
self._provider_messages = await self.request_context_manager.process(
|
||||
self.run_context.messages = await self.context_manager.process(
|
||||
self.run_context.messages, trusted_token_usage=token_usage
|
||||
)
|
||||
self._simple_print_message_role("[AftCompact]")
|
||||
@@ -913,7 +905,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
# append a user message with images so LLM can see them
|
||||
if cached_images:
|
||||
modalities = self.provider.provider_config.get("modalities", [])
|
||||
supports_image = "image" in modalities
|
||||
supports_image = (
|
||||
not modalities or "image" in modalities
|
||||
) # Empty list is treated as unconfigured for backward compatibility
|
||||
if supports_image:
|
||||
# Build user message with images for LLM to review
|
||||
image_parts = []
|
||||
|
||||
@@ -1210,6 +1210,8 @@ def _get_fallback_chat_providers(
|
||||
|
||||
def _provider_supports_modality(provider: Provider, modality: str) -> bool:
|
||||
modalities = provider.provider_config.get("modalities", None)
|
||||
if modalities == []:
|
||||
return True # Empty list from migration is treated as unconfigured for backward compatibility
|
||||
return isinstance(modalities, list) and modality in modalities
|
||||
|
||||
|
||||
@@ -1428,8 +1430,10 @@ async def build_main_agent(
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Error occurred while applying file extract: %s", exc)
|
||||
|
||||
has_reply = any(isinstance(comp, Reply) for comp in event.message_obj.message)
|
||||
|
||||
if not req.prompt and not req.image_urls and not req.audio_urls:
|
||||
if not event.get_group_id() and req.extra_user_content_parts:
|
||||
if has_reply or req.extra_user_content_parts:
|
||||
req.prompt = "<attachment>"
|
||||
else:
|
||||
return None
|
||||
|
||||
@@ -254,6 +254,17 @@ DEFAULT_CONFIG = {
|
||||
"host": "0.0.0.0",
|
||||
"port": 6185,
|
||||
"disable_access_log": True,
|
||||
"trust_proxy_headers": False,
|
||||
"auth_rate_limit": {
|
||||
"enable": True,
|
||||
"average_interval": 1.0,
|
||||
"max_burst": 3,
|
||||
},
|
||||
"totp": {
|
||||
"enable": False,
|
||||
"secret": "",
|
||||
"recovery_code_hash": "",
|
||||
},
|
||||
"ssl": {
|
||||
"enable": False,
|
||||
"cert_file": "",
|
||||
@@ -2042,8 +2053,8 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
"max_tokens": {
|
||||
"name": "Max Tokens",
|
||||
"description": "最大令牌数",
|
||||
"hint": "生成的最大令牌数。",
|
||||
"description": "最大词元(Tokens)数",
|
||||
"hint": "生成的最大词元(Tokens)数。",
|
||||
"type": "int",
|
||||
"default": 8192,
|
||||
},
|
||||
@@ -2987,6 +2998,10 @@ CONFIG_METADATA_2 = {
|
||||
"options": ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
|
||||
},
|
||||
"dashboard.ssl.enable": {"type": "bool"},
|
||||
"dashboard.trust_proxy_headers": {"type": "bool"},
|
||||
"dashboard.auth_rate_limit.enable": {"type": "bool"},
|
||||
"dashboard.auth_rate_limit.average_interval": {"type": "float"},
|
||||
"dashboard.auth_rate_limit.max_burst": {"type": "int"},
|
||||
"dashboard.ssl.cert_file": {
|
||||
"type": "string",
|
||||
"condition": {"dashboard.ssl.enable": True},
|
||||
@@ -4229,6 +4244,34 @@ CONFIG_METADATA_3_SYSTEM = {
|
||||
"type": "bool",
|
||||
"hint": "启用后,WebUI 将直接使用 HTTPS 提供服务。",
|
||||
},
|
||||
"dashboard.trust_proxy_headers": {
|
||||
"description": "信任代理请求头获取客户端 IP",
|
||||
"type": "bool",
|
||||
"hint": "关闭时忽略 X-Forwarded-For/X-Real-IP,仅使用连接地址。",
|
||||
},
|
||||
"dashboard.auth_rate_limit.enable": {
|
||||
"description": "启用登录验证速率限制",
|
||||
"type": "bool",
|
||||
"hint": "关闭后将不对登录、TOTP 等身份验证接口进行速率限制。",
|
||||
},
|
||||
"dashboard.auth_rate_limit.average_interval": {
|
||||
"description": "验证端点速率限制平均间隔(秒)",
|
||||
"type": "float",
|
||||
"hint": "两次身份验证请求之间的最小平均间隔时间。例如设置为 1.0 表示每秒最多处理 1 个请求。",
|
||||
"condition": {"dashboard.auth_rate_limit.enable": True},
|
||||
},
|
||||
"dashboard.auth_rate_limit.max_burst": {
|
||||
"description": "验证端点速率限制最大突发数",
|
||||
"type": "int",
|
||||
"hint": "允许的瞬时最大突发请求数。例如设置为 3 表示在短时间内最多连续处理 3 个请求。",
|
||||
"condition": {"dashboard.auth_rate_limit.enable": True},
|
||||
},
|
||||
"dashboard.totp.enable": {
|
||||
"description": "启用 WebUI TOTP 双因素认证",
|
||||
"type": "bool",
|
||||
"hint": "启用后,登录 WebUI 需要额外输入验证码。",
|
||||
"_special": "dashboard_totp_manager",
|
||||
},
|
||||
"dashboard.ssl.cert_file": {
|
||||
"description": "SSL 证书文件路径",
|
||||
"type": "string",
|
||||
|
||||
@@ -15,6 +15,7 @@ from astrbot.core.cron.events import CronMessageEvent
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import CronJob
|
||||
from astrbot.core.platform.message_session import MessageSession
|
||||
from astrbot.core.platform.message_type import MessageType
|
||||
from astrbot.core.provider.entites import ProviderRequest
|
||||
from astrbot.core.utils.history_saver import persist_agent_history
|
||||
|
||||
@@ -200,9 +201,18 @@ class CronJobManager:
|
||||
return None
|
||||
return aps_job.next_run_time.astimezone(timezone.utc)
|
||||
|
||||
async def _run_job(self, job_id: str) -> None:
|
||||
async def run_job_now(self, job_id: str) -> None:
|
||||
await self._run_job(job_id, ignore_enabled=True, delete_run_once=False)
|
||||
|
||||
async def _run_job(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
ignore_enabled: bool = False,
|
||||
delete_run_once: bool = True,
|
||||
) -> None:
|
||||
job = await self.db.get_cron_job(job_id)
|
||||
if not job or not job.enabled:
|
||||
if not job or (not job.enabled and not ignore_enabled):
|
||||
return
|
||||
start_time = datetime.now(timezone.utc)
|
||||
await self.db.update_cron_job(
|
||||
@@ -230,7 +240,7 @@ class CronJobManager:
|
||||
last_error=last_error,
|
||||
next_run_time=next_run,
|
||||
)
|
||||
if job.run_once:
|
||||
if job.run_once and delete_run_once:
|
||||
# one-shot: remove after execution regardless of success
|
||||
await self.delete_job(job_id)
|
||||
|
||||
@@ -245,9 +255,14 @@ class CronJobManager:
|
||||
|
||||
async def _run_active_agent_job(self, job: CronJob, start_time: datetime) -> None:
|
||||
payload = job.payload or {}
|
||||
session_str = payload.get("session")
|
||||
if not session_str:
|
||||
raise ValueError("ActiveAgentCronJob missing session.")
|
||||
delivery_session_str = str(payload.get("session") or "").strip()
|
||||
session_str = delivery_session_str or str(
|
||||
MessageSession(
|
||||
platform_name="cron",
|
||||
message_type=MessageType.OTHER_MESSAGE,
|
||||
session_id=job.job_id,
|
||||
)
|
||||
)
|
||||
note = payload.get("note") or job.description or job.name
|
||||
|
||||
extras = {
|
||||
@@ -262,7 +277,7 @@ class CronJobManager:
|
||||
"run_at": (
|
||||
job.payload.get("run_at") if isinstance(job.payload, dict) else None
|
||||
),
|
||||
"session": session_str,
|
||||
"session": delivery_session_str,
|
||||
},
|
||||
"cron_payload": payload,
|
||||
}
|
||||
@@ -271,6 +286,7 @@ class CronJobManager:
|
||||
message=note,
|
||||
session_str=session_str,
|
||||
extras=extras,
|
||||
delivery_session_str=delivery_session_str,
|
||||
)
|
||||
|
||||
async def _woke_main_agent(
|
||||
@@ -279,6 +295,7 @@ class CronJobManager:
|
||||
message: str,
|
||||
session_str: str,
|
||||
extras: dict,
|
||||
delivery_session_str: str = "",
|
||||
) -> None:
|
||||
"""Woke the main agent to handle the cron job message."""
|
||||
from astrbot.core.astr_main_agent import (
|
||||
@@ -353,11 +370,12 @@ class CronJobManager:
|
||||
"Output using same language as previous conversation. "
|
||||
"After completing your task, summarize and output your actions and results."
|
||||
)
|
||||
if not req.func_tool:
|
||||
req.func_tool = ToolSet()
|
||||
req.func_tool.add_tool(
|
||||
self.ctx.get_llm_tool_manager().get_builtin_tool(SendMessageToUserTool)
|
||||
)
|
||||
if delivery_session_str:
|
||||
if not req.func_tool:
|
||||
req.func_tool = ToolSet()
|
||||
req.func_tool.add_tool(
|
||||
self.ctx.get_llm_tool_manager().get_builtin_tool(SendMessageToUserTool)
|
||||
)
|
||||
|
||||
result = await build_main_agent(
|
||||
event=cron_event, plugin_context=self.ctx, config=config, req=req
|
||||
|
||||
@@ -382,6 +382,21 @@ class ApiKey(TimestampMixin, SQLModel, table=True):
|
||||
)
|
||||
|
||||
|
||||
class DashboardTrustedDevice(TimestampMixin, SQLModel, table=True):
|
||||
"""Trusted dashboard device token used to skip TOTP for a limited time."""
|
||||
|
||||
__tablename__: str = "dashboard_trusted_devices"
|
||||
|
||||
id: int | None = Field(
|
||||
default=None,
|
||||
primary_key=True,
|
||||
sa_column_kwargs={"autoincrement": True},
|
||||
)
|
||||
token_hash: str = Field(max_length=64, nullable=False, unique=True, index=True)
|
||||
totp_secret_hash: str = Field(max_length=64, nullable=False, index=True)
|
||||
expires_at: datetime = Field(nullable=False, index=True)
|
||||
|
||||
|
||||
class ChatUIProject(TimestampMixin, SQLModel, table=True):
|
||||
"""This class represents projects for organizing ChatUI conversations.
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from pathlib import Path, PurePosixPath
|
||||
@@ -140,6 +141,55 @@ class Record(BaseMessageComponent):
|
||||
def fromBase64(bs64_data: str, **_):
|
||||
return Record(file=f"base64://{bs64_data}", **_)
|
||||
|
||||
@staticmethod
|
||||
def _decode_file_uri(uri: str) -> str:
|
||||
"""解码 file:/// URI 为本地文件路径。
|
||||
|
||||
file:///C:/Users/... → C:/Users/... (Windows)
|
||||
file:///home/user/... → /home/user/... (Linux)
|
||||
其中的 URL 编码(如 %20 空格)也会被解码。
|
||||
"""
|
||||
path = uri.removeprefix("file:///")
|
||||
path = urllib.parse.unquote(path)
|
||||
return path
|
||||
|
||||
async def _resolve_file_source(self) -> str:
|
||||
"""选择可用的文件源。
|
||||
|
||||
NapCat 在 Windows 上可能只给 file 字段一个裸文件名(如 0d2bb1468a87d64414f8e563cc61c33c.amr),
|
||||
而真实路径在 url(如 file:///C:/Users/...)或 path(如 C:\\Users\\...)中。
|
||||
Image.convert_to_file_path 使用 self.url or self.file,Record 同样需要 fallback。
|
||||
"""
|
||||
# 1) 优先尝试 file:如果它已包含完整 URI 或已知格式,直接使用
|
||||
if self.file:
|
||||
if (
|
||||
self.file.startswith("file:///")
|
||||
or self.file.startswith("http")
|
||||
or self.file.startswith("base64://")
|
||||
or os.path.exists(self.file)
|
||||
):
|
||||
return self.file
|
||||
|
||||
# 2) 尝试 url(可能是 file:/// 或 http 链接)
|
||||
if self.url:
|
||||
if (
|
||||
self.url.startswith("file:///")
|
||||
or self.url.startswith("http")
|
||||
or os.path.exists(self.url)
|
||||
or (
|
||||
self.url.startswith("file:///")
|
||||
and os.path.exists(self._decode_file_uri(self.url))
|
||||
)
|
||||
):
|
||||
return self.url
|
||||
|
||||
# 3) 尝试 path(可能是 Windows 绝对路径如 C:\Users\...)
|
||||
if self.path and os.path.exists(self.path):
|
||||
return self.path
|
||||
|
||||
# 4) 最后裸返回 file(即使不行也要让调用方看到原始内容)
|
||||
return self.file or self.url or ""
|
||||
|
||||
async def convert_to_file_path(self) -> str:
|
||||
"""将这个语音统一转换为本地文件路径。这个方法避免了手动判断语音数据类型,直接返回语音数据的本地路径(如果是网络 URL, 则会自动进行下载)。
|
||||
|
||||
@@ -147,24 +197,25 @@ class Record(BaseMessageComponent):
|
||||
str: 语音的本地路径,以绝对路径表示。
|
||||
|
||||
"""
|
||||
if not self.file:
|
||||
file_source = await self._resolve_file_source()
|
||||
if not file_source:
|
||||
raise Exception(f"not a valid file: {self.file}")
|
||||
if self.file.startswith("file:///"):
|
||||
return self.file[8:]
|
||||
if self.file.startswith("http"):
|
||||
file_path = await download_image_by_url(self.file)
|
||||
if file_source.startswith("file:///"):
|
||||
return self._decode_file_uri(file_source)
|
||||
if file_source.startswith("http"):
|
||||
file_path = await download_image_by_url(file_source)
|
||||
return os.path.abspath(file_path)
|
||||
if self.file.startswith("base64://"):
|
||||
bs64_data = self.file.removeprefix("base64://")
|
||||
if file_source.startswith("base64://"):
|
||||
bs64_data = file_source.removeprefix("base64://")
|
||||
image_bytes = base64.b64decode(bs64_data)
|
||||
file_path = os.path.join(
|
||||
get_astrbot_temp_path(), f"recordseg_{uuid.uuid4()}.jpg"
|
||||
get_astrbot_temp_path(), f"recordseg_{uuid.uuid4()}.wav"
|
||||
)
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(image_bytes)
|
||||
return os.path.abspath(file_path)
|
||||
if os.path.exists(self.file):
|
||||
return os.path.abspath(self.file)
|
||||
if os.path.exists(file_source):
|
||||
return os.path.abspath(file_source)
|
||||
raise Exception(f"not a valid file: {self.file}")
|
||||
|
||||
async def convert_to_base64(self) -> str:
|
||||
@@ -174,18 +225,18 @@ class Record(BaseMessageComponent):
|
||||
str: 语音的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
|
||||
|
||||
"""
|
||||
# convert to base64
|
||||
if not self.file:
|
||||
file_source = await self._resolve_file_source()
|
||||
if not file_source:
|
||||
raise Exception(f"not a valid file: {self.file}")
|
||||
if self.file.startswith("file:///"):
|
||||
bs64_data = file_to_base64(self.file[8:])
|
||||
elif self.file.startswith("http"):
|
||||
file_path = await download_image_by_url(self.file)
|
||||
if file_source.startswith("file:///"):
|
||||
bs64_data = file_to_base64(self._decode_file_uri(file_source))
|
||||
elif file_source.startswith("http"):
|
||||
file_path = await download_image_by_url(file_source)
|
||||
bs64_data = file_to_base64(file_path)
|
||||
elif self.file.startswith("base64://"):
|
||||
bs64_data = self.file
|
||||
elif os.path.exists(self.file):
|
||||
bs64_data = file_to_base64(self.file)
|
||||
elif file_source.startswith("base64://"):
|
||||
bs64_data = file_source
|
||||
elif os.path.exists(file_source):
|
||||
bs64_data = file_to_base64(file_source)
|
||||
else:
|
||||
raise Exception(f"not a valid file: {self.file}")
|
||||
bs64_data = bs64_data.removeprefix("base64://")
|
||||
@@ -526,6 +577,10 @@ class Reply(BaseMessageComponent):
|
||||
def __init__(self, **_) -> None:
|
||||
super().__init__(**_)
|
||||
|
||||
def toDict(self):
|
||||
"""仅输出 id 字段,符合 OneBot V11 reply 段标准格式。"""
|
||||
return {"type": "reply", "data": {"id": str(self.id)}}
|
||||
|
||||
|
||||
class Poke(BaseMessageComponent):
|
||||
type: ComponentType = ComponentType.Poke
|
||||
|
||||
@@ -19,7 +19,7 @@ from astrbot.core.astr_main_agent import (
|
||||
MainAgentBuildResult,
|
||||
build_main_agent,
|
||||
)
|
||||
from astrbot.core.message.components import File, Image, Record, Video
|
||||
from astrbot.core.message.components import File, Image, Record, Reply, Video
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageChain,
|
||||
MessageEventResult,
|
||||
@@ -98,7 +98,7 @@ class InternalAgentSubStage(Stage):
|
||||
"llm_compress_instruction", ""
|
||||
)
|
||||
self.llm_compress_keep_recent: int = settings.get(
|
||||
"llm_compress_keep_recent", 10
|
||||
"llm_compress_keep_recent", 5
|
||||
)
|
||||
self.llm_compress_provider_id: str = settings.get(
|
||||
"llm_compress_provider_id", ""
|
||||
@@ -177,11 +177,15 @@ class InternalAgentSubStage(Stage):
|
||||
isinstance(comp, (Image, File, Record, Video))
|
||||
for comp in event.message_obj.message
|
||||
)
|
||||
has_reply = any(
|
||||
isinstance(comp, Reply) for comp in event.message_obj.message
|
||||
)
|
||||
|
||||
if (
|
||||
not has_provider_request
|
||||
and not has_valid_message
|
||||
and not has_media_content
|
||||
and not has_reply
|
||||
):
|
||||
logger.debug("skip llm request: empty message and no provider_request")
|
||||
return
|
||||
@@ -478,18 +482,6 @@ class InternalAgentSubStage(Stage):
|
||||
continue
|
||||
if message.role in ["assistant", "user"] and message._no_save:
|
||||
continue
|
||||
# Truncate long tool results before persisting (8192 chars)
|
||||
if (
|
||||
message.role == "tool"
|
||||
and isinstance(message.content, str)
|
||||
and len(message.content) > 8192
|
||||
):
|
||||
message = Message(
|
||||
role="tool",
|
||||
tool_call_id=message.tool_call_id,
|
||||
content=message.content[:8192]
|
||||
+ f"\n...[truncated {len(message.content) - 8192} chars]",
|
||||
)
|
||||
messages_to_save.append(message)
|
||||
|
||||
checkpoint_id = event.get_extra("llm_checkpoint_id")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from typing import cast
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
@@ -61,9 +60,12 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
|
||||
async def get_embeddings(self, text: list[str]) -> list[list[float]]:
|
||||
"""批量获取文本的嵌入"""
|
||||
try:
|
||||
contents = [
|
||||
types.Content(parts=[types.Part.from_text(text=s)]) for s in text
|
||||
]
|
||||
result = await self.client.models.embed_content(
|
||||
model=self.model,
|
||||
contents=cast(types.ContentListUnion, text),
|
||||
contents=contents,
|
||||
config=types.EmbedContentConfig(
|
||||
output_dimensionality=self.get_dim(),
|
||||
),
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import httpx
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.provider.sources.anthropic_source import ProviderAnthropic
|
||||
|
||||
from ..register import register_provider_adapter
|
||||
|
||||
MINIMAX_TOKEN_PLAN_MODELS = [
|
||||
"MiniMax-M2.7",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
"MiniMax-M2.5",
|
||||
"MiniMax-M2.5-highspeed",
|
||||
"MiniMax-M2.1",
|
||||
"MiniMax-M2.1-highspeed",
|
||||
"MiniMax-M2",
|
||||
]
|
||||
|
||||
|
||||
@register_provider_adapter(
|
||||
"minimax_token_plan",
|
||||
@@ -21,9 +13,9 @@ MINIMAX_TOKEN_PLAN_MODELS = [
|
||||
class ProviderMiniMaxTokenPlan(ProviderAnthropic):
|
||||
"""MiniMax Token Plan provider.
|
||||
|
||||
The Token Plan API does not support the /models endpoint, so get_models()
|
||||
returns a hard-coded model list. This is a Token Plan API limitation.
|
||||
See https://github.com/AstrBotDevs/AstrBot/issues/7585 for details.
|
||||
The model list is fetched dynamically from the MiniMax API's /v1/models
|
||||
endpoint, so newly released models are automatically discovered without
|
||||
a code change. The default model is MiniMax-M3, the current flagship.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -45,19 +37,25 @@ class ProviderMiniMaxTokenPlan(ProviderAnthropic):
|
||||
provider_settings,
|
||||
)
|
||||
|
||||
configured_model = provider_config.get("model", "MiniMax-M2.7")
|
||||
if configured_model not in MINIMAX_TOKEN_PLAN_MODELS:
|
||||
logger.warning(
|
||||
f"Configured model {configured_model!r} is not in the known "
|
||||
f"Token Plan model list "
|
||||
f"({', '.join(MINIMAX_TOKEN_PLAN_MODELS)}). "
|
||||
f"The model may still work if your plan supports it. "
|
||||
f"If you encounter errors, please check your plan's "
|
||||
f"model availability."
|
||||
)
|
||||
|
||||
configured_model = provider_config.get("model", "MiniMax-M3")
|
||||
self.set_model(configured_model)
|
||||
|
||||
async def get_models(self) -> list[str]:
|
||||
"""Return the hard-coded known model list because Token Plan cannot fetch it dynamically."""
|
||||
return MINIMAX_TOKEN_PLAN_MODELS.copy()
|
||||
"""Dynamically fetch available models from the MiniMax API."""
|
||||
key = self.chosen_api_key
|
||||
if not key:
|
||||
logger.warning("No API key configured for MiniMax Token Plan.")
|
||||
return []
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
"https://api.minimaxi.com/v1/models",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return [m["id"] for m in data.get("data", [])]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch MiniMax model list: {e}")
|
||||
return []
|
||||
|
||||
@@ -72,6 +72,23 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
|
||||
logger.warning(
|
||||
f"embedding_dimensions in embedding configs is not a valid integer: '{self.provider_config['embedding_dimensions']}', ignored."
|
||||
)
|
||||
|
||||
# Fix: SiliconFlow provider does not support dimensions parameter, except for Qwen models.
|
||||
provider_api_base = self.provider_config.get("embedding_api_base")
|
||||
provider_id = self.provider_config.get("id", "unknown_id")
|
||||
if (
|
||||
provider_api_base
|
||||
# Hard-code SiliconFlow API Base Prefix and Model Name, as it's just a temporary workaround.
|
||||
and provider_api_base.strip().startswith("https://api.siliconflow.cn")
|
||||
and not self.model.lower().startswith("qwen")
|
||||
):
|
||||
# For SiliconFlow and Non-Qwen models, dimensions parameter is not supported. so remove it.
|
||||
removed_dimensions = kwargs.pop("dimensions", None)
|
||||
if removed_dimensions is not None:
|
||||
# Log a warning message if dimensions parameter is removed.
|
||||
logger.warning(
|
||||
f"dimensions not supported for model '{self.model}' of provider '{provider_id}' as SiliconFlow does not support this parameter for non-Qwen models: '{removed_dimensions}'."
|
||||
)
|
||||
return kwargs
|
||||
|
||||
def get_dim(self) -> int:
|
||||
|
||||
@@ -116,10 +116,11 @@ class ProviderOpenAIWhisperAPI(STTProvider):
|
||||
|
||||
audio_url = output_path
|
||||
|
||||
result = await self.client.audio.transcriptions.create(
|
||||
model=self.model_name,
|
||||
file=("audio.wav", open(audio_url, "rb")),
|
||||
)
|
||||
with open(audio_url, "rb") as audio_file:
|
||||
result = await self.client.audio.transcriptions.create(
|
||||
model=self.model_name,
|
||||
file=("audio.wav", audio_file),
|
||||
)
|
||||
|
||||
# remove temp file
|
||||
if output_path and os.path.exists(output_path):
|
||||
|
||||
@@ -251,6 +251,11 @@ async def ensure_wav(audio_path: str, output_path: str | None = None) -> str:
|
||||
if not audio_path:
|
||||
return audio_path
|
||||
|
||||
if not os.path.exists(audio_path):
|
||||
# File not available yet (e.g. napcat race condition);
|
||||
# return the path as-is so upstream retry logic can handle it later.
|
||||
return audio_path
|
||||
|
||||
audio_type = _get_audio_magic_type(audio_path)
|
||||
if audio_type == "wav":
|
||||
return audio_path
|
||||
|
||||
274
astrbot/core/utils/totp.py
Normal file
274
astrbot/core/utils/totp.py
Normal file
@@ -0,0 +1,274 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from enum import Enum
|
||||
|
||||
import pyotp
|
||||
from sqlmodel import col, delete, select
|
||||
|
||||
from astrbot.core.db.po import DashboardTrustedDevice
|
||||
|
||||
TOTP_TRUSTED_DEVICE_COOKIE_NAME = "astrbot_totp_trusted_device"
|
||||
TOTP_TRUSTED_DEVICE_MAX_AGE = 30 * 24 * 60 * 60
|
||||
RECOVERY_CODE_GROUP_COUNT = 4
|
||||
RECOVERY_CODE_GROUP_LENGTH = 8
|
||||
RECOVERY_CODE_LENGTH = RECOVERY_CODE_GROUP_COUNT * RECOVERY_CODE_GROUP_LENGTH
|
||||
_RECOVERY_CODE_KDF_ITERATIONS = 600_000
|
||||
_RECOVERY_CODE_KDF_SALT_BYTES = 16
|
||||
_RECOVERY_CODE_KDF_ALGORITHM = "pbkdf2_sha256"
|
||||
|
||||
_last_totp_timecode: dict[str, int] = {}
|
||||
_totp_replay_lock = asyncio.Lock()
|
||||
_totp_pending_secret: str | None = (
|
||||
None # pending new secret after rotation, before config save
|
||||
)
|
||||
_totp_rotation_verified: bool = (
|
||||
False # user passed the current-TOTP verify step during rotation
|
||||
)
|
||||
|
||||
|
||||
class TwoFactorCodeType(Enum):
|
||||
TOTP = "totp"
|
||||
RECOVERY = "recovery"
|
||||
|
||||
|
||||
def _get_totp_config(config) -> dict:
|
||||
totp_config = config.get("dashboard", {}).get("totp", {})
|
||||
return totp_config if isinstance(totp_config, dict) else {}
|
||||
|
||||
|
||||
def is_totp_enabled(config) -> bool:
|
||||
"""TOTP is fully configured and operational (enable + secret + recovery hash all present)."""
|
||||
totp_config = _get_totp_config(config)
|
||||
if not totp_config.get("enable", False):
|
||||
return False
|
||||
secret = totp_config.get("secret", "")
|
||||
if not isinstance(secret, str) or not secret.strip():
|
||||
return False
|
||||
recovery_code_hash = totp_config.get("recovery_code_hash", "")
|
||||
if not isinstance(recovery_code_hash, str) or not recovery_code_hash.strip():
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _get_verified_totp_timecode(secret: str, code: str) -> int | None:
|
||||
code = code.strip()
|
||||
try:
|
||||
totp = pyotp.TOTP(secret.strip())
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
for offset in (-1, 0, 1):
|
||||
candidate_time = now + datetime.timedelta(seconds=offset * totp.interval)
|
||||
if hmac.compare_digest(str(totp.at(candidate_time)), code):
|
||||
return int(totp.timecode(candidate_time))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
async def consume_totp_code(secret: str, code: str) -> bool:
|
||||
global _last_totp_timecode
|
||||
timecode = _get_verified_totp_timecode(secret, code)
|
||||
if timecode is None:
|
||||
return False
|
||||
secret = secret.strip()
|
||||
async with _totp_replay_lock:
|
||||
if _last_totp_timecode.get(secret, -1) >= timecode:
|
||||
return False
|
||||
_last_totp_timecode[secret] = timecode
|
||||
return True
|
||||
|
||||
|
||||
async def consume_configured_totp_code(config, code: str) -> bool:
|
||||
if not is_totp_enabled(config):
|
||||
return False
|
||||
secret = _get_totp_config(config).get("secret", "")
|
||||
return await consume_totp_code(secret, code)
|
||||
|
||||
|
||||
async def verify_configured_2fa_code(
|
||||
config, code: str, include_pending: bool = False, allow_recovery: bool = False
|
||||
) -> TwoFactorCodeType | None:
|
||||
"""Return a 2FA code type when a configured code is valid.
|
||||
|
||||
When include_pending is True, also checks the in-memory pending TOTP
|
||||
secret from an active rotation (used by config-save verification).
|
||||
When allow_recovery is False, only TOTP codes are accepted (recovery
|
||||
codes are rejected to prevent privilege escalation on sensitive ops).
|
||||
"""
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
return None
|
||||
if await consume_configured_totp_code(config, code):
|
||||
return TwoFactorCodeType.TOTP
|
||||
if include_pending:
|
||||
pending = _totp_pending_secret
|
||||
if pending and await consume_totp_code(pending, code):
|
||||
return TwoFactorCodeType.TOTP
|
||||
if allow_recovery and verify_recovery_code(config, code):
|
||||
return TwoFactorCodeType.RECOVERY
|
||||
return None
|
||||
|
||||
|
||||
def set_pending_totp_secret(secret: str | None) -> None:
|
||||
"""Set the pending TOTP secret for an in-memory rotation.
|
||||
|
||||
After a successful TOTP rotation, the new secret is stored in memory
|
||||
so that the subsequent config save 2FA check can verify against it.
|
||||
Cleared once the config save completes.
|
||||
"""
|
||||
global _totp_pending_secret
|
||||
_totp_pending_secret = secret
|
||||
|
||||
|
||||
def set_rotation_verified(value: bool) -> None:
|
||||
"""Set or clear the rotation-verified flag."""
|
||||
global _totp_rotation_verified
|
||||
_totp_rotation_verified = value
|
||||
|
||||
|
||||
def consume_rotation_verified() -> bool:
|
||||
"""Check and consume the rotation-verified flag (single-use).
|
||||
|
||||
Returns True if the user has passed the old-key verification step.
|
||||
"""
|
||||
global _totp_rotation_verified
|
||||
if _totp_rotation_verified:
|
||||
_totp_rotation_verified = False
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _hash_totp_trusted_device_token(config, token: str) -> str:
|
||||
jwt_secret = config["dashboard"].get("jwt_secret", "")
|
||||
if not isinstance(jwt_secret, str) or not jwt_secret:
|
||||
return ""
|
||||
return hmac.new(
|
||||
jwt_secret.encode("utf-8"),
|
||||
token.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def _hash_totp_secret(config) -> str:
|
||||
secret = _get_totp_config(config).get("secret", "")
|
||||
if not isinstance(secret, str) or not secret.strip():
|
||||
return ""
|
||||
return hashlib.sha256(secret.strip().encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def is_totp_trusted_device_valid(config, db, cookie_token: str) -> bool:
|
||||
if not cookie_token:
|
||||
return False
|
||||
token_hash = _hash_totp_trusted_device_token(config, cookie_token)
|
||||
totp_secret_hash = _hash_totp_secret(config)
|
||||
if not token_hash or not totp_secret_hash:
|
||||
return False
|
||||
|
||||
await _cleanup_expired_totp_trusted_devices(db)
|
||||
async with db.get_db() as session:
|
||||
result = await session.execute(
|
||||
select(DashboardTrustedDevice).where(
|
||||
col(DashboardTrustedDevice.token_hash) == token_hash,
|
||||
col(DashboardTrustedDevice.totp_secret_hash) == totp_secret_hash,
|
||||
col(DashboardTrustedDevice.expires_at)
|
||||
> datetime.datetime.now(datetime.timezone.utc),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def issue_totp_trusted_device(config, db) -> str | None:
|
||||
"""Issue a trusted device token, save to DB, and return the raw token for cookie."""
|
||||
raw_token = secrets.token_urlsafe(48)
|
||||
token_hash = _hash_totp_trusted_device_token(config, raw_token)
|
||||
totp_secret_hash = _hash_totp_secret(config)
|
||||
if not token_hash or not totp_secret_hash:
|
||||
return None
|
||||
|
||||
expires_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
|
||||
seconds=TOTP_TRUSTED_DEVICE_MAX_AGE
|
||||
)
|
||||
async with db.get_db() as session:
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
delete(DashboardTrustedDevice).where(
|
||||
col(DashboardTrustedDevice.token_hash) == token_hash
|
||||
)
|
||||
)
|
||||
trusted_device = DashboardTrustedDevice.model_validate(
|
||||
{
|
||||
"token_hash": token_hash,
|
||||
"totp_secret_hash": totp_secret_hash,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
)
|
||||
session.add(trusted_device)
|
||||
return raw_token
|
||||
|
||||
|
||||
async def _cleanup_expired_totp_trusted_devices(db) -> None:
|
||||
async with db.get_db() as session:
|
||||
async with session.begin():
|
||||
await session.execute(
|
||||
delete(DashboardTrustedDevice).where(
|
||||
col(DashboardTrustedDevice.expires_at)
|
||||
<= datetime.datetime.now(datetime.timezone.utc)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def revoke_user_trusted_devices(db) -> None:
|
||||
async with db.get_db() as session:
|
||||
async with session.begin():
|
||||
await session.execute(delete(DashboardTrustedDevice))
|
||||
|
||||
|
||||
def generate_recovery_code() -> tuple[str, str]:
|
||||
raw = secrets.token_bytes(20)
|
||||
recovery_code = base64.b32encode(raw).decode("ascii").rstrip("=")
|
||||
salt = secrets.token_hex(_RECOVERY_CODE_KDF_SALT_BYTES)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
recovery_code.encode("utf-8"),
|
||||
bytes.fromhex(salt),
|
||||
_RECOVERY_CODE_KDF_ITERATIONS,
|
||||
).hex()
|
||||
kdf_hash = f"{_RECOVERY_CODE_KDF_ALGORITHM}${_RECOVERY_CODE_KDF_ITERATIONS}${salt}${digest}"
|
||||
parts = [
|
||||
recovery_code[i : i + RECOVERY_CODE_GROUP_LENGTH]
|
||||
for i in range(0, len(recovery_code), RECOVERY_CODE_GROUP_LENGTH)
|
||||
]
|
||||
return "-".join(parts), kdf_hash
|
||||
|
||||
|
||||
def verify_recovery_code(config, code: str) -> bool:
|
||||
"""Verify a recovery code against configured recovery_code_hash (PBKDF2)."""
|
||||
cleaned = "".join(char for char in code.upper() if char.isalnum())
|
||||
if len(cleaned) != RECOVERY_CODE_LENGTH:
|
||||
return False
|
||||
totp_config = _get_totp_config(config)
|
||||
stored_hash = totp_config.get("recovery_code_hash", "")
|
||||
if not isinstance(stored_hash, str) or not stored_hash:
|
||||
return False
|
||||
|
||||
parts = stored_hash.split("$")
|
||||
if len(parts) != 4 or parts[0] != _RECOVERY_CODE_KDF_ALGORITHM:
|
||||
return False
|
||||
try:
|
||||
iterations = int(parts[1])
|
||||
salt = parts[2]
|
||||
expected_digest = parts[3]
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
|
||||
candidate = hashlib.pbkdf2_hmac(
|
||||
"sha256",
|
||||
cleaned.encode("utf-8"),
|
||||
bytes.fromhex(salt),
|
||||
iterations,
|
||||
).hex()
|
||||
return hmac.compare_digest(candidate, expected_digest)
|
||||
@@ -3,6 +3,7 @@ import datetime
|
||||
import os
|
||||
|
||||
import jwt
|
||||
import pyotp
|
||||
from quart import current_app, g, jsonify, make_response, request
|
||||
|
||||
from astrbot import logger
|
||||
@@ -13,6 +14,22 @@ from astrbot.core.utils.auth_password import (
|
||||
validate_dashboard_password,
|
||||
verify_dashboard_password,
|
||||
)
|
||||
from astrbot.core.utils.totp import (
|
||||
TOTP_TRUSTED_DEVICE_COOKIE_NAME,
|
||||
TOTP_TRUSTED_DEVICE_MAX_AGE,
|
||||
TwoFactorCodeType,
|
||||
consume_configured_totp_code,
|
||||
consume_rotation_verified,
|
||||
consume_totp_code,
|
||||
generate_recovery_code,
|
||||
is_totp_enabled,
|
||||
is_totp_trusted_device_valid,
|
||||
issue_totp_trusted_device,
|
||||
revoke_user_trusted_devices,
|
||||
set_pending_totp_secret,
|
||||
set_rotation_verified,
|
||||
verify_configured_2fa_code,
|
||||
)
|
||||
from astrbot.dashboard.password_state import (
|
||||
get_dashboard_password_hash,
|
||||
is_password_change_required,
|
||||
@@ -57,6 +74,8 @@ class AuthRoute(Route):
|
||||
"/auth/setup-status": ("GET", self.setup_status),
|
||||
"/auth/setup": ("POST", self.setup),
|
||||
"/auth/setup-authenticated": ("POST", self.setup_authenticated),
|
||||
"/auth/totp/setup": ("POST", self.totp_setup),
|
||||
"/auth/totp/recovery": ("POST", self.totp_recovery),
|
||||
"/auth/account/edit": ("POST", self.edit_account),
|
||||
}
|
||||
self.register_routes()
|
||||
@@ -77,6 +96,68 @@ class AuthRoute(Route):
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def totp_setup(self):
|
||||
post_data = await request.json
|
||||
|
||||
if isinstance(post_data, dict) and post_data.get("secret"):
|
||||
secret = post_data["secret"]
|
||||
code = post_data.get("code")
|
||||
if not isinstance(secret, str) or not secret.strip():
|
||||
return Response().error("Invalid request payload").__dict__
|
||||
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
return Response().error("TOTP 验证码是必需的").__dict__
|
||||
if not await consume_totp_code(secret, code):
|
||||
return Response().error("TOTP 验证码无效").__dict__
|
||||
|
||||
if is_totp_enabled(self.config) and not consume_rotation_verified():
|
||||
return Response().error("需要先验证当前 TOTP").__dict__
|
||||
|
||||
set_pending_totp_secret(secret)
|
||||
recovery_code, recovery_code_hash = generate_recovery_code()
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"recovery_code": recovery_code,
|
||||
"recovery_code_hash": recovery_code_hash,
|
||||
},
|
||||
"TOTP verified",
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
if is_totp_enabled(self.config):
|
||||
if not isinstance(post_data, dict):
|
||||
return Response().error("Invalid request payload").__dict__
|
||||
|
||||
set_rotation_verified(False)
|
||||
|
||||
code = post_data.get("code")
|
||||
if isinstance(code, str) and code.strip():
|
||||
if await consume_configured_totp_code(self.config, code):
|
||||
set_rotation_verified(True)
|
||||
return Response().ok({"secret": pyotp.random_base32()}).__dict__
|
||||
return Response().error("当前 TOTP 验证码无效").__dict__
|
||||
|
||||
return Response().error("需要提供 TOTP 验证码或新密钥").__dict__
|
||||
|
||||
return Response().ok({"secret": pyotp.random_base32()}).__dict__
|
||||
|
||||
async def totp_recovery(self):
|
||||
# This endpoint MUST NOT persist the recovery code.
|
||||
recovery_code, recovery_code_hash = generate_recovery_code()
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"recovery_code": recovery_code,
|
||||
"recovery_code_hash": recovery_code_hash,
|
||||
}
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def setup(self):
|
||||
if not self._can_skip_default_password_auth():
|
||||
return Response().error("Setup without password is not enabled").__dict__
|
||||
@@ -147,6 +228,12 @@ class AuthRoute(Route):
|
||||
req_password = (
|
||||
post_data.get("password") if isinstance(post_data, dict) else None
|
||||
)
|
||||
totp_code = post_data.get("code") if isinstance(post_data, dict) else None
|
||||
trust_device_flag = (
|
||||
post_data.get("trust_device_flag") is True
|
||||
if isinstance(post_data, dict)
|
||||
else False
|
||||
)
|
||||
if not isinstance(req_username, str) or not isinstance(req_password, str):
|
||||
return Response().error("Invalid request payload").__dict__
|
||||
|
||||
@@ -154,43 +241,98 @@ class AuthRoute(Route):
|
||||
password, req_password
|
||||
)
|
||||
|
||||
if login_verified:
|
||||
change_pwd_hint = False
|
||||
legacy_pwd_hint = is_legacy_dashboard_password(password)
|
||||
password_change_required = await is_password_change_required(
|
||||
self.db,
|
||||
self.config,
|
||||
if not login_verified:
|
||||
await asyncio.sleep(3)
|
||||
if req_password == "astrbot":
|
||||
return Response().error(DEFAULT_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__
|
||||
if is_legacy_dashboard_password(password):
|
||||
return Response().error(LEGACY_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__
|
||||
return await self._error_response(
|
||||
"用户名或密码错误",
|
||||
401,
|
||||
)
|
||||
if (
|
||||
storage_upgraded
|
||||
and username == "astrbot"
|
||||
and is_default_dashboard_password(password)
|
||||
and not DEMO_MODE
|
||||
|
||||
totp_verified = False
|
||||
|
||||
if is_totp_enabled(self.config):
|
||||
cookie_token = request.cookies.get(
|
||||
TOTP_TRUSTED_DEVICE_COOKIE_NAME, ""
|
||||
).strip()
|
||||
if not await is_totp_trusted_device_valid(
|
||||
self.config, self.db, cookie_token
|
||||
):
|
||||
change_pwd_hint = True
|
||||
legacy_pwd_hint = True
|
||||
logger.warning("为了保证安全,请尽快修改默认密码。")
|
||||
if password_change_required and not DEMO_MODE:
|
||||
change_pwd_hint = True
|
||||
token = self.generate_jwt(username)
|
||||
payload = Response().ok(
|
||||
{
|
||||
"token": token,
|
||||
"username": username,
|
||||
"change_pwd_hint": change_pwd_hint,
|
||||
"legacy_pwd_hint": legacy_pwd_hint,
|
||||
"password_upgrade_required": not storage_upgraded,
|
||||
},
|
||||
)
|
||||
response = await make_response(jsonify(payload.__dict__))
|
||||
self._set_dashboard_jwt_cookie(response, token)
|
||||
return response
|
||||
await asyncio.sleep(3)
|
||||
if req_password == "astrbot":
|
||||
return Response().error(DEFAULT_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__
|
||||
if is_legacy_dashboard_password(password):
|
||||
return Response().error(LEGACY_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__
|
||||
return Response().error("用户名或密码错误").__dict__
|
||||
if not isinstance(totp_code, str) or not totp_code.strip():
|
||||
response = await make_response(
|
||||
jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"message": "需要 TOTP 验证",
|
||||
"data": {"totp_required": True},
|
||||
}
|
||||
)
|
||||
)
|
||||
response.status_code = 401
|
||||
return response
|
||||
verified_type = await verify_configured_2fa_code(
|
||||
self.config, totp_code, allow_recovery=True
|
||||
)
|
||||
if verified_type is TwoFactorCodeType.TOTP:
|
||||
totp_verified = True
|
||||
elif verified_type is TwoFactorCodeType.RECOVERY:
|
||||
self.config["dashboard"]["totp"] = {
|
||||
"enable": False,
|
||||
"secret": "",
|
||||
"recovery_code_hash": "",
|
||||
}
|
||||
await revoke_user_trusted_devices(self.db)
|
||||
self.config.save_config()
|
||||
elif len(totp_code) == 6 and totp_code.isdigit():
|
||||
return await self._error_response("TOTP 验证码无效", 401)
|
||||
else:
|
||||
return await self._error_response("恢复码无效", 401)
|
||||
|
||||
change_pwd_hint = False
|
||||
legacy_pwd_hint = is_legacy_dashboard_password(password)
|
||||
password_change_required = await is_password_change_required(
|
||||
self.db,
|
||||
self.config,
|
||||
)
|
||||
if (
|
||||
storage_upgraded
|
||||
and username == "astrbot"
|
||||
and is_default_dashboard_password(password)
|
||||
and not DEMO_MODE
|
||||
):
|
||||
change_pwd_hint = True
|
||||
legacy_pwd_hint = True
|
||||
logger.warning("为了保证安全,请尽快修改默认密码。")
|
||||
if password_change_required and not DEMO_MODE:
|
||||
change_pwd_hint = True
|
||||
token = self.generate_jwt(username)
|
||||
login_data = {
|
||||
"token": token,
|
||||
"username": username,
|
||||
"change_pwd_hint": change_pwd_hint,
|
||||
"legacy_pwd_hint": legacy_pwd_hint,
|
||||
"password_upgrade_required": not storage_upgraded,
|
||||
}
|
||||
payload = Response().ok(login_data)
|
||||
response = await make_response(jsonify(payload.__dict__))
|
||||
self._set_dashboard_jwt_cookie(response, token)
|
||||
|
||||
if totp_verified and trust_device_flag:
|
||||
raw_token = await issue_totp_trusted_device(self.config, self.db)
|
||||
if raw_token:
|
||||
response.set_cookie(
|
||||
TOTP_TRUSTED_DEVICE_COOKIE_NAME,
|
||||
raw_token,
|
||||
max_age=TOTP_TRUSTED_DEVICE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="Strict",
|
||||
secure=AuthRoute._use_secure_dashboard_jwt_cookie(),
|
||||
path="/api/auth",
|
||||
)
|
||||
return response
|
||||
|
||||
async def logout(self):
|
||||
response = await make_response(
|
||||
@@ -245,6 +387,8 @@ class AuthRoute(Route):
|
||||
set_dashboard_password_hashes(self.config, new_pwd)
|
||||
await set_password_storage_upgraded(self.db, self.config, True)
|
||||
await set_password_change_required(self.db, self.config, False)
|
||||
if is_totp_enabled(self.config):
|
||||
await revoke_user_trusted_devices(self.db)
|
||||
if new_username:
|
||||
self.config["dashboard"]["username"] = new_username
|
||||
|
||||
@@ -286,6 +430,12 @@ class AuthRoute(Route):
|
||||
dashboard_config.get("pbkdf2_password", "")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _error_response(message: str, status_code: int):
|
||||
response = await make_response(jsonify(Response().error(message).__dict__))
|
||||
response.status_code = status_code
|
||||
return response
|
||||
|
||||
def _can_skip_default_password_auth(self) -> bool:
|
||||
if not self._env_flag_enabled(SKIP_DEFAULT_PASSWORD_AUTH_ENV):
|
||||
return False
|
||||
|
||||
@@ -6,7 +6,7 @@ import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from quart import request
|
||||
from quart import jsonify, make_response, request
|
||||
|
||||
from astrbot.core import astrbot_config, file_token_service, logger
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
@@ -27,6 +27,13 @@ from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_plugin_data_path,
|
||||
)
|
||||
from astrbot.core.utils.llm_metadata import LLM_METADATAS
|
||||
from astrbot.core.utils.totp import (
|
||||
TwoFactorCodeType,
|
||||
is_totp_enabled,
|
||||
revoke_user_trusted_devices,
|
||||
set_pending_totp_secret,
|
||||
verify_configured_2fa_code,
|
||||
)
|
||||
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
@@ -38,6 +45,12 @@ from .util import (
|
||||
)
|
||||
|
||||
MAX_FILE_BYTES = 500 * 1024 * 1024
|
||||
PROTECTED_2FA_CONFIG_PATHS = (
|
||||
("dashboard", "totp", "enable"),
|
||||
("dashboard", "totp", "secret"),
|
||||
("dashboard", "totp", "recovery_code_hash"),
|
||||
)
|
||||
TWO_FACTOR_CODE_HEADER = "X-2FA-Code"
|
||||
|
||||
|
||||
def try_cast(value: Any, type_: str):
|
||||
@@ -95,7 +108,7 @@ def _validate_template_list(value, meta, path_key, errors, validate_fn) -> None:
|
||||
validate_fn(
|
||||
item,
|
||||
template_meta.get("items", {}),
|
||||
path=f"{item_path}.",
|
||||
path=f"{path_key}.templates.{template_key}.",
|
||||
)
|
||||
|
||||
|
||||
@@ -244,6 +257,33 @@ def _log_computer_config_changes(old_config: dict, new_config: dict) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _get_nested_value(data: dict, path: tuple[str, ...]) -> Any:
|
||||
current = data
|
||||
for key in path:
|
||||
if not isinstance(current, dict) or key not in current:
|
||||
return None
|
||||
current = current[key]
|
||||
return current
|
||||
|
||||
|
||||
def _set_nested_value(data: dict, path: tuple[str, ...], value: Any) -> None:
|
||||
current = data
|
||||
for key in path[:-1]:
|
||||
next_value = current.get(key)
|
||||
if not isinstance(next_value, dict):
|
||||
next_value = {}
|
||||
current[key] = next_value
|
||||
current = next_value
|
||||
current[path[-1]] = value
|
||||
|
||||
|
||||
def _protected_2fa_config_changed(old_config: dict, new_config: dict) -> bool:
|
||||
return any(
|
||||
_get_nested_value(old_config, path) != _get_nested_value(new_config, path)
|
||||
for path in PROTECTED_2FA_CONFIG_PATHS
|
||||
)
|
||||
|
||||
|
||||
async def _validate_neo_connectivity(
|
||||
post_config: dict,
|
||||
) -> str | None:
|
||||
@@ -339,6 +379,7 @@ class ConfigRoute(Route):
|
||||
super().__init__(context)
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.config: AstrBotConfig = core_lifecycle.astrbot_config
|
||||
self.db = core_lifecycle.db
|
||||
self._logo_token_cache = {} # 缓存logo token,避免重复注册
|
||||
self.acm = core_lifecycle.astrbot_config_mgr
|
||||
self.ucr = core_lifecycle.umop_config_router
|
||||
@@ -1010,10 +1051,18 @@ class ConfigRoute(Route):
|
||||
|
||||
async def post_astrbot_configs(self):
|
||||
data = await request.json
|
||||
if not isinstance(data, dict):
|
||||
return Response().error("Invalid request payload").__dict__
|
||||
config = data.get("config", None)
|
||||
conf_id = data.get("conf_id", None)
|
||||
|
||||
try:
|
||||
if not isinstance(config, dict):
|
||||
return Response().error("Invalid config payload").__dict__
|
||||
|
||||
if conf_id not in self.acm.confs:
|
||||
raise ValueError(f"Config file {conf_id} does not exist")
|
||||
|
||||
# 不更新 provider_sources, provider, platform
|
||||
# 这些配置有单独的接口进行更新
|
||||
if conf_id == "default":
|
||||
@@ -1021,7 +1070,26 @@ class ConfigRoute(Route):
|
||||
for key in no_update_keys:
|
||||
config[key] = self.acm.default_conf[key]
|
||||
|
||||
current_config = self.acm.confs[conf_id]
|
||||
protected_2fa_changed = _protected_2fa_config_changed(
|
||||
current_config, config
|
||||
)
|
||||
verified_2fa = None
|
||||
if await self._requires_config_2fa(current_config, protected_2fa_changed):
|
||||
verified_2fa = await self._verify_config_2fa(current_config)
|
||||
if not verified_2fa:
|
||||
return await self._config_2fa_required_response()
|
||||
|
||||
if not _get_nested_value(config, ("dashboard", "totp", "enable")):
|
||||
_set_nested_value(config, ("dashboard", "totp", "secret"), "")
|
||||
_set_nested_value(
|
||||
config, ("dashboard", "totp", "recovery_code_hash"), ""
|
||||
)
|
||||
|
||||
set_pending_totp_secret(None)
|
||||
await self._save_astrbot_configs(config, conf_id)
|
||||
if protected_2fa_changed:
|
||||
await revoke_user_trusted_devices(self.db)
|
||||
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
|
||||
|
||||
# Non-blocking Bay connectivity check
|
||||
@@ -1033,6 +1101,40 @@ class ConfigRoute(Route):
|
||||
logger.error(traceback.format_exc())
|
||||
return Response().error(str(e)).__dict__
|
||||
|
||||
async def _requires_config_2fa(
|
||||
self, current_config: dict, protected_2fa_changed: bool
|
||||
) -> bool:
|
||||
if not is_totp_enabled(current_config):
|
||||
return False
|
||||
if not protected_2fa_changed:
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _verify_config_2fa(
|
||||
self, current_config: dict
|
||||
) -> TwoFactorCodeType | None:
|
||||
code = request.headers.get(TWO_FACTOR_CODE_HEADER, "").strip()
|
||||
if not code:
|
||||
return None
|
||||
|
||||
return await verify_configured_2fa_code(
|
||||
current_config, code, include_pending=True, allow_recovery=False
|
||||
)
|
||||
|
||||
async def _config_2fa_required_response(self):
|
||||
response = await make_response(
|
||||
jsonify(
|
||||
{
|
||||
"status": "error",
|
||||
"data": {
|
||||
"totp_required": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
response.status_code = 401
|
||||
return response
|
||||
|
||||
async def post_plugin_configs(self):
|
||||
post_configs = await request.json
|
||||
plugin_name = request.args.get("plugin_name", "unknown")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -15,11 +16,13 @@ class CronRoute(Route):
|
||||
) -> None:
|
||||
super().__init__(context)
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self._background_tasks: set[asyncio.Task] = set()
|
||||
self.routes = [
|
||||
("/cron/jobs", ("GET", self.list_jobs)),
|
||||
("/cron/jobs", ("POST", self.create_job)),
|
||||
("/cron/jobs/<job_id>", ("PATCH", self.update_job)),
|
||||
("/cron/jobs/<job_id>", ("DELETE", self.delete_job)),
|
||||
("/cron/jobs/<job_id>/run", ("POST", self.run_job_now)),
|
||||
]
|
||||
self.register_routes()
|
||||
|
||||
@@ -71,7 +74,7 @@ class CronRoute(Route):
|
||||
name = payload.get("name") or "active_agent_task"
|
||||
cron_expression = payload.get("cron_expression")
|
||||
note = payload.get("note") or payload.get("description") or name
|
||||
session = payload.get("session")
|
||||
session = str(payload.get("session") or "").strip()
|
||||
persona_id = payload.get("persona_id")
|
||||
provider_id = payload.get("provider_id")
|
||||
timezone = payload.get("timezone")
|
||||
@@ -79,8 +82,6 @@ class CronRoute(Route):
|
||||
run_once = bool(payload.get("run_once", False))
|
||||
run_at = payload.get("run_at")
|
||||
|
||||
if not session:
|
||||
return jsonify(Response().error("session is required").__dict__)
|
||||
if run_once and not run_at:
|
||||
return jsonify(
|
||||
Response().error("run_at is required when run_once=true").__dict__
|
||||
@@ -172,11 +173,10 @@ class CronRoute(Route):
|
||||
|
||||
if "session" in payload:
|
||||
session = str(payload.get("session") or "").strip()
|
||||
if not session:
|
||||
return jsonify(
|
||||
Response().error("session cannot be empty").__dict__
|
||||
)
|
||||
merged_payload["session"] = session
|
||||
if session:
|
||||
merged_payload["session"] = session
|
||||
else:
|
||||
merged_payload.pop("session", None)
|
||||
|
||||
note_updated = False
|
||||
if "note" in payload:
|
||||
@@ -281,3 +281,21 @@ class CronRoute(Route):
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(traceback.format_exc())
|
||||
return jsonify(Response().error(f"Failed to delete job: {e!s}").__dict__)
|
||||
|
||||
async def run_job_now(self, job_id: str):
|
||||
try:
|
||||
cron_mgr = self.core_lifecycle.cron_manager
|
||||
if cron_mgr is None:
|
||||
return jsonify(
|
||||
Response().error("Cron manager not initialized").__dict__
|
||||
)
|
||||
job = await cron_mgr.db.get_cron_job(job_id)
|
||||
if not job:
|
||||
return jsonify(Response().error("Job not found").__dict__)
|
||||
task = asyncio.create_task(cron_mgr.run_job_now(job_id))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
return jsonify(Response().ok(message="started").__dict__)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(traceback.format_exc())
|
||||
return jsonify(Response().error(f"Failed to run job: {e!s}").__dict__)
|
||||
|
||||
@@ -16,6 +16,7 @@ def get_schema_item(schema: dict | None, key_path: str) -> dict | None:
|
||||
同时支持:
|
||||
- 扁平 schema(直接 key 命中)
|
||||
- 嵌套 object schema({type: "object", items: {...}})
|
||||
- template_list schema(<field>.templates.<template>.items)
|
||||
"""
|
||||
|
||||
if not isinstance(schema, dict) or not key_path:
|
||||
@@ -23,17 +24,31 @@ def get_schema_item(schema: dict | None, key_path: str) -> dict | None:
|
||||
if key_path in schema:
|
||||
return schema.get(key_path)
|
||||
|
||||
current = schema
|
||||
parts = key_path.split(".")
|
||||
for idx, part in enumerate(parts):
|
||||
current = schema
|
||||
idx = 0
|
||||
while idx < len(parts):
|
||||
part = parts[idx]
|
||||
if part not in current:
|
||||
return None
|
||||
meta = current.get(part)
|
||||
if idx == len(parts) - 1:
|
||||
return meta
|
||||
if not isinstance(meta, dict) or meta.get("type") != "object":
|
||||
return None
|
||||
if not isinstance(meta, dict) or meta.get("type") != "template_list":
|
||||
return None
|
||||
if idx + 2 >= len(parts) or parts[idx + 1] != "templates":
|
||||
return None
|
||||
template_meta = meta.get("templates", {}).get(parts[idx + 2])
|
||||
if not isinstance(template_meta, dict):
|
||||
return None
|
||||
if idx + 2 == len(parts) - 1:
|
||||
return template_meta
|
||||
current = template_meta.get("items", {})
|
||||
idx += 3
|
||||
continue
|
||||
current = meta.get("items", {})
|
||||
idx += 1
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Protocol, cast
|
||||
@@ -12,6 +14,8 @@ import psutil
|
||||
from flask.json.provider import DefaultJSONProvider
|
||||
from hypercorn.asyncio import serve
|
||||
from hypercorn.config import Config as HyperConfig
|
||||
from hypercorn.logging import AccessLogAtoms
|
||||
from hypercorn.logging import Logger as HypercornLogger
|
||||
from quart import Quart, g, jsonify, request
|
||||
from quart.logging import default_handler
|
||||
from werkzeug.exceptions import MethodNotAllowed, NotFound
|
||||
@@ -41,6 +45,76 @@ from .routes.session_management import SessionManagementRoute
|
||||
from .routes.subagent import SubAgentRoute
|
||||
from .routes.t2i import T2iRoute
|
||||
|
||||
_RATE_LIMITED_ENDPOINTS: frozenset = frozenset(
|
||||
{
|
||||
"/api/config/astrbot/update",
|
||||
"/api/auth/totp/setup",
|
||||
"/api/auth/login",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _AuthRateLimiter:
|
||||
def __init__(self, capacity: int, refill_rate: float):
|
||||
self.capacity = capacity
|
||||
self.refill_rate = refill_rate
|
||||
self.tokens = float(capacity)
|
||||
self.last_refill = time.monotonic()
|
||||
self.last_accessed = time.monotonic()
|
||||
self.lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self) -> bool:
|
||||
async with self.lock:
|
||||
now = time.monotonic()
|
||||
elapsed = now - self.last_refill
|
||||
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
|
||||
self.last_refill = now
|
||||
self.last_accessed = now
|
||||
if self.tokens >= 1:
|
||||
self.tokens -= 1
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class _RateLimiterRegistry:
|
||||
"""Per-IP token-bucket rate limiter registry. Idle entries expire after 1 hour."""
|
||||
|
||||
_ENTRY_TTL: float = 3600.0
|
||||
_INTERVAL: float = 1800.0
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._limiters: dict[str, _AuthRateLimiter] = {}
|
||||
self._last_eviction = time.monotonic()
|
||||
|
||||
def get_or_create(
|
||||
self, key: str, capacity: int, refill_rate: float
|
||||
) -> _AuthRateLimiter:
|
||||
self._evict_expired()
|
||||
limiter = self._limiters.get(key)
|
||||
if limiter is None:
|
||||
limiter = _AuthRateLimiter(capacity=capacity, refill_rate=refill_rate)
|
||||
self._limiters[key] = limiter
|
||||
return limiter
|
||||
|
||||
def _evict_expired(self) -> None:
|
||||
now = time.monotonic()
|
||||
if now - self._last_eviction < self._INTERVAL:
|
||||
return
|
||||
self._last_eviction = now
|
||||
cutoff = now - self._ENTRY_TTL
|
||||
stale = [k for k, v in self._limiters.items() if v.last_accessed < cutoff]
|
||||
for k in stale:
|
||||
del self._limiters[k]
|
||||
|
||||
def clear(self) -> None:
|
||||
self._limiters.clear()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._limiters)
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
return key in self._limiters
|
||||
|
||||
|
||||
class _AddrWithPort(Protocol):
|
||||
port: int
|
||||
@@ -92,6 +166,53 @@ def _parse_env_bool(value: str | None, default: bool) -> bool:
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
class _ProxyAwareHypercornLogger(HypercornLogger):
|
||||
@staticmethod
|
||||
def _get_request_log_host(request_scope) -> str | None:
|
||||
forwarded_for = None
|
||||
real_ip = None
|
||||
for raw_name, raw_value in request_scope.get("headers", []):
|
||||
header_name = raw_name.decode("latin1").lower()
|
||||
if header_name == "x-forwarded-for":
|
||||
forwarded_for = raw_value.decode("latin1")
|
||||
elif header_name == "x-real-ip":
|
||||
real_ip = raw_value.decode("latin1")
|
||||
|
||||
if forwarded_for is not None and real_ip is not None:
|
||||
break
|
||||
|
||||
forwarded_for = str(forwarded_for or "").strip()
|
||||
if forwarded_for:
|
||||
first_ip = forwarded_for.split(",", 1)[0].strip()
|
||||
if first_ip and first_ip.lower() != "unknown":
|
||||
try:
|
||||
return str(ipaddress.ip_address(first_ip))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
real_ip = str(real_ip or "").strip()
|
||||
if real_ip and real_ip.lower() != "unknown":
|
||||
try:
|
||||
return str(ipaddress.ip_address(real_ip))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
client = request_scope.get("client")
|
||||
if not client:
|
||||
return None
|
||||
host = str(client[0]).strip()
|
||||
if host:
|
||||
return host
|
||||
return None
|
||||
|
||||
def atoms(self, request, response, request_time):
|
||||
atoms = AccessLogAtoms(request, response, request_time)
|
||||
client_host = self._get_request_log_host(request)
|
||||
if client_host:
|
||||
atoms["h"] = client_host
|
||||
return atoms
|
||||
|
||||
|
||||
class AstrBotJSONProvider(DefaultJSONProvider):
|
||||
def default(self, obj):
|
||||
if isinstance(obj, datetime):
|
||||
@@ -132,6 +253,7 @@ class AstrBotDashboard:
|
||||
# Fall back to expected user path (will fail gracefully later)
|
||||
self.data_path = os.path.abspath(user_dist)
|
||||
|
||||
self._rate_limiter_registry = _RateLimiterRegistry()
|
||||
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
|
||||
APP = self.app # noqa
|
||||
self.app.config["MAX_CONTENT_LENGTH"] = (
|
||||
@@ -246,6 +368,33 @@ class AstrBotDashboard:
|
||||
await self.db.touch_api_key(api_key.key_id)
|
||||
return None
|
||||
|
||||
if (
|
||||
os.environ.get("ASTRBOT_TEST_MODE") != "true"
|
||||
and request.path in _RATE_LIMITED_ENDPOINTS
|
||||
):
|
||||
rl_config = self.config.get("dashboard", {}).get("auth_rate_limit", {})
|
||||
rl_enabled = rl_config.get("enable", True)
|
||||
if rl_enabled:
|
||||
average_interval = float(rl_config.get("average_interval", 1.0))
|
||||
max_burst = int(rl_config.get("max_burst", 3))
|
||||
if average_interval <= 0:
|
||||
average_interval = 1.0
|
||||
if max_burst <= 0:
|
||||
max_burst = 3
|
||||
refill_rate = 1.0 / average_interval
|
||||
client_ip = self._get_request_client_ip(request)
|
||||
limiter = self._rate_limiter_registry.get_or_create(
|
||||
client_ip, capacity=max_burst, refill_rate=refill_rate
|
||||
)
|
||||
if not await limiter.acquire():
|
||||
r = jsonify(
|
||||
Response()
|
||||
.error("验证尝试过于频繁,系统可能正在遭受暴力破解")
|
||||
.__dict__
|
||||
)
|
||||
r.status_code = 429
|
||||
return r
|
||||
|
||||
allowed_exact_endpoints = {
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
@@ -295,6 +444,35 @@ class AstrBotDashboard:
|
||||
r.status_code = 401
|
||||
return r
|
||||
|
||||
def _get_request_client_ip(self, current_request) -> str:
|
||||
if bool(self.config.get("dashboard", {}).get("trust_proxy_headers", False)):
|
||||
forwarded_for = str(
|
||||
current_request.headers.get("X-Forwarded-For", "")
|
||||
).strip()
|
||||
if forwarded_for:
|
||||
first_ip = forwarded_for.split(",", 1)[0].strip()
|
||||
if first_ip and first_ip.lower() != "unknown":
|
||||
try:
|
||||
return str(ipaddress.ip_address(first_ip))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
real_ip = str(current_request.headers.get("X-Real-IP", "")).strip()
|
||||
if real_ip and real_ip.lower() != "unknown":
|
||||
try:
|
||||
return str(ipaddress.ip_address(real_ip))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
remote_addr = str(current_request.remote_addr or "").strip()
|
||||
if remote_addr:
|
||||
try:
|
||||
return str(ipaddress.ip_address(remote_addr))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _extract_dashboard_jwt() -> str | None:
|
||||
auth_header = request.headers.get("Authorization", "").strip()
|
||||
@@ -530,6 +708,8 @@ class AstrBotDashboard:
|
||||
# 配置 Hypercorn
|
||||
config = HyperConfig()
|
||||
config.bind = [f"{host}:{port}"]
|
||||
if bool(self.config.get("dashboard", {}).get("trust_proxy_headers", False)):
|
||||
config.logger_class = _ProxyAwareHypercornLogger
|
||||
if ssl_enable:
|
||||
config.certfile = resolved_ssl_config["certfile"]
|
||||
config.keyfile = resolved_ssl_config["keyfile"]
|
||||
|
||||
1361
dashboard/src/assets/mdi-subset/materialdesignicons-subset.css
Normal file
1361
dashboard/src/assets/mdi-subset/materialdesignicons-subset.css
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -25,13 +25,25 @@ const props = defineProps({
|
||||
searchKeyword: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
pluginName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
pluginI18n: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
pathPrefix: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const { getRaw } = useModuleI18n('features/config-metadata')
|
||||
const { tm: tmConfig } = useModuleI18n('features/config')
|
||||
const { translateIfKey } = useConfigTextResolver()
|
||||
const { translateIfKey, resolveConfigText } = useConfigTextResolver(props)
|
||||
|
||||
const hintMarkdown = new MarkdownIt({
|
||||
linkify: true,
|
||||
@@ -114,6 +126,18 @@ function createSelectorModel(selector) {
|
||||
})
|
||||
}
|
||||
|
||||
function getItemPath(key) {
|
||||
return props.pathPrefix ? `${props.pathPrefix}.${key}` : key
|
||||
}
|
||||
|
||||
function getItemDescription(itemKey, itemMeta) {
|
||||
return resolveConfigText(getItemPath(itemKey), 'description', itemMeta?.description) || itemKey
|
||||
}
|
||||
|
||||
function getItemHint(itemKey, itemMeta) {
|
||||
return resolveConfigText(getItemPath(itemKey), 'hint', itemMeta?.hint)
|
||||
}
|
||||
|
||||
function openEditorDialog(key, value, theme, language) {
|
||||
currentEditingKey.value = key
|
||||
currentEditingLanguage.value = language || 'json'
|
||||
@@ -143,8 +167,8 @@ function shouldShowItem(itemMeta, itemKey) {
|
||||
|
||||
const searchableText = [
|
||||
itemKey,
|
||||
translateIfKey(itemMeta?.description || ''),
|
||||
translateIfKey(itemMeta?.hint || '')
|
||||
getItemDescription(itemKey, itemMeta),
|
||||
getItemHint(itemKey, itemMeta)
|
||||
].join(' ').toLowerCase()
|
||||
|
||||
return searchableText.includes(keyword)
|
||||
@@ -259,13 +283,13 @@ function getSpecialSubtype(value) {
|
||||
<v-col cols="12" sm="6" class="property-info">
|
||||
<v-list-item density="compact">
|
||||
<v-list-item-title class="property-name">
|
||||
{{ translateIfKey(itemMeta?.description) || itemKey }}
|
||||
{{ getItemDescription(itemKey, itemMeta) }}
|
||||
<span class="property-key">({{ itemKey }})</span>
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="property-hint">
|
||||
<span v-if="itemMeta?.obvious_hint && itemMeta?.hint" class="important-hint">‼️</span>
|
||||
<span v-html="renderHint(itemMeta?.hint)"></span>
|
||||
<span v-html="renderHint(getItemHint(itemKey, itemMeta))"></span>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
@@ -274,12 +298,19 @@ function getSpecialSubtype(value) {
|
||||
v-if="itemMeta?.type === 'template_list'"
|
||||
v-model="createSelectorModel(itemKey).value"
|
||||
:templates="itemMeta?.templates || {}"
|
||||
:plugin-name="pluginName"
|
||||
:plugin-i18n="pluginI18n"
|
||||
:config-path="getItemPath(itemKey)"
|
||||
class="config-field"
|
||||
/>
|
||||
<ConfigItemRenderer
|
||||
v-else
|
||||
v-model="createSelectorModel(itemKey).value"
|
||||
:item-meta="itemMeta || null"
|
||||
:config-root="iterable"
|
||||
:plugin-name="pluginName"
|
||||
:plugin-i18n="pluginI18n"
|
||||
:config-key="getItemPath(itemKey)"
|
||||
:show-fullscreen-btn="!!itemMeta?.editor_mode"
|
||||
@open-fullscreen="openEditorDialog(itemKey, iterable, itemMeta?.editor_theme, itemMeta?.editor_language)"
|
||||
/>
|
||||
@@ -339,13 +370,13 @@ function getSpecialSubtype(value) {
|
||||
<v-col cols="12" sm="6" class="property-info">
|
||||
<v-list-item density="compact">
|
||||
<v-list-item-title class="property-name">
|
||||
{{ translateIfKey(itemMeta?.description) || itemKey }}
|
||||
{{ getItemDescription(itemKey, itemMeta) }}
|
||||
<span class="property-key">({{ itemKey }})</span>
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="property-hint">
|
||||
<span v-if="itemMeta?.obvious_hint && itemMeta?.hint" class="important-hint">‼️</span>
|
||||
<span v-html="renderHint(itemMeta?.hint)"></span>
|
||||
<span v-html="renderHint(getItemHint(itemKey, itemMeta))"></span>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
@@ -354,12 +385,19 @@ function getSpecialSubtype(value) {
|
||||
v-if="itemMeta?.type === 'template_list'"
|
||||
v-model="createSelectorModel(itemKey).value"
|
||||
:templates="itemMeta?.templates || {}"
|
||||
:plugin-name="pluginName"
|
||||
:plugin-i18n="pluginI18n"
|
||||
:config-path="getItemPath(itemKey)"
|
||||
class="config-field"
|
||||
/>
|
||||
<ConfigItemRenderer
|
||||
v-else
|
||||
v-model="createSelectorModel(itemKey).value"
|
||||
:item-meta="itemMeta || null"
|
||||
:config-root="iterable"
|
||||
:plugin-name="pluginName"
|
||||
:plugin-i18n="pluginI18n"
|
||||
:config-key="getItemPath(itemKey)"
|
||||
:show-fullscreen-btn="!!itemMeta?.editor_mode"
|
||||
@open-fullscreen="openEditorDialog(itemKey, iterable, itemMeta?.editor_theme, itemMeta?.editor_language)"
|
||||
/>
|
||||
|
||||
@@ -45,6 +45,13 @@
|
||||
<template v-else-if="itemMeta?._special === 't2i_template'">
|
||||
<T2ITemplateEditor />
|
||||
</template>
|
||||
<template v-else-if="itemMeta?._special === 'dashboard_totp_manager'">
|
||||
<DashboardTotpManager
|
||||
:model-value="Boolean(modelValue)"
|
||||
:config-root="configRoot"
|
||||
@update:model-value="emitUpdate"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="itemMeta?._special === 'get_embedding_dim'">
|
||||
<div class="d-flex align-center gap-2">
|
||||
<v-text-field
|
||||
@@ -242,6 +249,7 @@ import PersonaSelector from './PersonaSelector.vue'
|
||||
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
|
||||
import PluginSetSelector from './PluginSetSelector.vue'
|
||||
import T2ITemplateEditor from './T2ITemplateEditor.vue'
|
||||
import DashboardTotpManager from './DashboardTotpManager.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables'
|
||||
import { usePluginI18n } from '@/utils/pluginI18n'
|
||||
@@ -277,6 +285,10 @@ const props = defineProps({
|
||||
showFullscreenBtn: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
configRoot: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
101
dashboard/src/components/shared/DashboardTotpManageDialog.vue
Normal file
101
dashboard/src/components/shared/DashboardTotpManageDialog.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="520"
|
||||
@update:model-value="val => emit('update:modelValue', val)"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center pa-4">
|
||||
{{ tm('system_group.system.dashboard.totp.configuration') }}
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon variant="text" size="small" @click="emit('update:modelValue', false)">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text class="pa-4">
|
||||
<div class="totp-dialog-subtitle mb-3">
|
||||
{{ tm('system_group.system.dashboard.totp.activeSubtitle') }}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<QrCodeViewer
|
||||
v-if="totpProvisioningUri"
|
||||
:value="totpProvisioningUri"
|
||||
alt="TOTP QR Code"
|
||||
:size="220"
|
||||
/>
|
||||
<div class="totp-current-secret-wrap mt-3">
|
||||
<code class="totp-secret">{{ totpSecret }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-center ga-3 mt-4">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
@click="emit('rotate')"
|
||||
>
|
||||
<v-icon class="mr-1" size="16">mdi-shield-key</v-icon>
|
||||
{{ tm('system_group.system.dashboard.totp.rotate') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="secondary"
|
||||
variant="tonal"
|
||||
@click="emit('rotate-recovery')"
|
||||
>
|
||||
<v-icon class="mr-1" size="16">mdi-key-variant</v-icon>
|
||||
{{ tm('system_group.system.dashboard.totp.rotateRecovery') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import QrCodeViewer from './QrCodeViewer.vue'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
configRoot: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'rotate', 'rotate-recovery'])
|
||||
const { tm } = useModuleI18n('features/config-metadata')
|
||||
|
||||
const totpSecret = computed(() => props.configRoot?.dashboard?.totp?.secret || '')
|
||||
|
||||
const totpProvisioningUri = computed(() => {
|
||||
if (!totpSecret.value) return ''
|
||||
const label = encodeURIComponent(props.configRoot?.dashboard?.username || 'AstrBot')
|
||||
const issuer = encodeURIComponent('AstrBot')
|
||||
return `otpauth://totp/${label}?secret=${encodeURIComponent(totpSecret.value)}&issuer=${issuer}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.totp-dialog-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
}
|
||||
|
||||
.totp-current-secret-wrap {
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.totp-secret {
|
||||
word-break: break-all;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
187
dashboard/src/components/shared/DashboardTotpManager.vue
Normal file
187
dashboard/src/components/shared/DashboardTotpManager.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div class="totp-manager">
|
||||
<v-switch
|
||||
:model-value="modelValue"
|
||||
@update:model-value="onTotpToggle"
|
||||
color="primary"
|
||||
inset
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-switch>
|
||||
<div v-if="modelValue" class="totp-manager-actions">
|
||||
<v-chip
|
||||
size="small"
|
||||
:color="isTotpInitialSetup ? 'warning' : 'success'"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon start size="14">
|
||||
{{ isTotpInitialSetup ? 'mdi-alert-circle-outline' : 'mdi-check-circle-outline' }}
|
||||
</v-icon>
|
||||
{{ isTotpInitialSetup
|
||||
? tm('system_group.system.dashboard.totp.statusPending')
|
||||
: tm('system_group.system.dashboard.totp.statusEnabled') }}
|
||||
</v-chip>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
@click="openTotpDialog"
|
||||
>
|
||||
{{ tm('system_group.system.dashboard.totp.manage') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="modelValue && isTotpInitialSetup" class="totp-setup-hint">
|
||||
{{ tm('system_group.system.dashboard.totp.setupRequiredHint') }}
|
||||
</div>
|
||||
|
||||
<DashboardTotpSetupDialog
|
||||
v-model="setupDialogVisible"
|
||||
:config-root="configRoot"
|
||||
:mode="setupDialogMode"
|
||||
@setup-complete="onSetupComplete"
|
||||
/>
|
||||
<DashboardTotpRecoveryDialog
|
||||
v-model="recoveryDialogVisible"
|
||||
:recovery-code="pendingRecoveryCode"
|
||||
@close="recoveryDialogVisible = false"
|
||||
/>
|
||||
<DashboardTotpManageDialog
|
||||
v-model="manageDialogVisible"
|
||||
:config-root="configRoot"
|
||||
@rotate="onStartRotate"
|
||||
@rotate-recovery="onStartRotateRecovery"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import DashboardTotpSetupDialog from './DashboardTotpSetupDialog.vue'
|
||||
import DashboardTotpRecoveryDialog from './DashboardTotpRecoveryDialog.vue'
|
||||
import DashboardTotpManageDialog from './DashboardTotpManageDialog.vue'
|
||||
import axios from 'axios'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
configRoot: {
|
||||
type: Object,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const { tm } = useModuleI18n('features/config-metadata')
|
||||
|
||||
const setupDialogVisible = ref(false)
|
||||
const recoveryDialogVisible = ref(false)
|
||||
const manageDialogVisible = ref(false)
|
||||
const setupDialogMode = ref('setup')
|
||||
const pendingRecoveryCode = ref('')
|
||||
|
||||
const totpSecret = computed(() => props.configRoot?.dashboard?.totp?.secret || '')
|
||||
const totpRecoveryCodeHash = computed(
|
||||
() => props.configRoot?.dashboard?.totp?.recovery_code_hash || ''
|
||||
)
|
||||
const isTotpInitialSetup = computed(
|
||||
() =>
|
||||
props.modelValue === true
|
||||
&& (!totpSecret.value || !totpRecoveryCodeHash.value)
|
||||
)
|
||||
|
||||
function emitUpdate(val) {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
|
||||
function clearTotpConfig() {
|
||||
if (props.configRoot?.dashboard?.totp) {
|
||||
props.configRoot.dashboard.totp.enable = false
|
||||
}
|
||||
}
|
||||
|
||||
function writeTotpSecretToConfig(secret, recoveryCodeHash = '') {
|
||||
if (!props.configRoot?.dashboard) return
|
||||
if (!props.configRoot.dashboard.totp) {
|
||||
props.configRoot.dashboard.totp = {}
|
||||
}
|
||||
props.configRoot.dashboard.totp.enable = true
|
||||
props.configRoot.dashboard.totp.secret = secret
|
||||
props.configRoot.dashboard.totp.recovery_code_hash = recoveryCodeHash
|
||||
}
|
||||
|
||||
function onTotpToggle(val) {
|
||||
if (!val) {
|
||||
clearTotpConfig()
|
||||
emitUpdate(val)
|
||||
return
|
||||
}
|
||||
if (!totpSecret.value || !totpRecoveryCodeHash.value) {
|
||||
setupDialogMode.value = 'setup'
|
||||
setupDialogVisible.value = true
|
||||
}
|
||||
emitUpdate(true)
|
||||
}
|
||||
|
||||
function openTotpDialog() {
|
||||
if (isTotpInitialSetup.value) {
|
||||
setupDialogMode.value = 'setup'
|
||||
setupDialogVisible.value = true
|
||||
return
|
||||
}
|
||||
manageDialogVisible.value = true
|
||||
}
|
||||
|
||||
function onSetupComplete({ secret, recoveryCode, recoveryCodeHash }) {
|
||||
writeTotpSecretToConfig(secret, recoveryCodeHash)
|
||||
pendingRecoveryCode.value = recoveryCode
|
||||
recoveryDialogVisible.value = true
|
||||
}
|
||||
|
||||
function onStartRotate() {
|
||||
manageDialogVisible.value = false
|
||||
setupDialogMode.value = 'rotate'
|
||||
setupDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function onStartRotateRecovery() {
|
||||
manageDialogVisible.value = false
|
||||
if (!totpSecret.value) return
|
||||
try {
|
||||
const res = await axios.post('/api/auth/totp/recovery')
|
||||
if (res.data.status !== 'ok') return
|
||||
const { recovery_code: recoveryCode, recovery_code_hash: recoveryCodeHash } = res.data.data || {}
|
||||
if (!recoveryCode || !recoveryCodeHash) return
|
||||
if (!props.configRoot?.dashboard?.totp) return
|
||||
props.configRoot.dashboard.totp.recovery_code_hash = recoveryCodeHash
|
||||
pendingRecoveryCode.value = recoveryCode
|
||||
recoveryDialogVisible.value = true
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.totp-manager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.totp-manager-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.totp-setup-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(var(--v-theme-warning), 0.95);
|
||||
}
|
||||
</style>
|
||||
101
dashboard/src/components/shared/DashboardTotpRecoveryDialog.vue
Normal file
101
dashboard/src/components/shared/DashboardTotpRecoveryDialog.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="520"
|
||||
persistent
|
||||
@update:model-value="val => emit('update:modelValue', val)"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center pa-4">
|
||||
{{ tm('system_group.system.dashboard.totp.recoveryTitle') }}
|
||||
<v-spacer></v-spacer>
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text class="pa-4">
|
||||
<div class="totp-dialog-subtitle mb-3">
|
||||
{{ tm('system_group.system.dashboard.totp.recoverySubtitle') }}
|
||||
</div>
|
||||
<v-alert
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
:text="tm('system_group.system.dashboard.totp.recoveryWarning')"
|
||||
hide-details
|
||||
></v-alert>
|
||||
<div class="totp-recovery-card">
|
||||
<code class="totp-recovery-text">{{ recoveryCode }}</code>
|
||||
</div>
|
||||
<v-checkbox
|
||||
v-model="acknowledged"
|
||||
:label="tm('system_group.system.dashboard.totp.recoveryAcknowledge')"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
class="mt-2"
|
||||
></v-checkbox>
|
||||
<div class="d-flex justify-end mt-4">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:disabled="!acknowledged"
|
||||
@click="onClose"
|
||||
>
|
||||
{{ tm('system_group.system.dashboard.totp.recoveryClose') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
recoveryCode: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
const { tm } = useModuleI18n('features/config-metadata')
|
||||
|
||||
const acknowledged = ref(false)
|
||||
|
||||
function onClose() {
|
||||
acknowledged.value = false
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.totp-dialog-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
}
|
||||
|
||||
.totp-recovery-card {
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.totp-recovery-text {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.3px;
|
||||
word-break: keep-all;
|
||||
user-select: all;
|
||||
}
|
||||
</style>
|
||||
309
dashboard/src/components/shared/DashboardTotpSetupDialog.vue
Normal file
309
dashboard/src/components/shared/DashboardTotpSetupDialog.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="520"
|
||||
@update:model-value="onVisibilityChange"
|
||||
@click:outside="onCancel"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center pa-4">
|
||||
{{ cardTitle }}
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon variant="text" size="small" @click="onCancel">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text class="pa-4">
|
||||
<template v-if="step === 'verify'">
|
||||
<div class="totp-dialog-subtitle mb-3">
|
||||
输入当前认证器应用中的验证码以验证身份。
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<v-text-field
|
||||
v-model="verifyCode"
|
||||
label="当前验证码"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="totp-code-input"
|
||||
maxlength="6"
|
||||
:error-messages="verifyError"
|
||||
:loading="verifyingIdentity"
|
||||
hide-details="auto"
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
@keyup.enter="verifyIdentity"
|
||||
></v-text-field>
|
||||
</div>
|
||||
<div class="d-flex justify-end ga-2 mt-4">
|
||||
<v-btn
|
||||
variant="text"
|
||||
:disabled="verifyingIdentity"
|
||||
@click="onCancel"
|
||||
>
|
||||
取消
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:loading="verifyingIdentity"
|
||||
:disabled="!verifyCode || verifyCode.length < 6"
|
||||
@click="verifyIdentity"
|
||||
>
|
||||
验证
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="totp-dialog-subtitle mb-3">
|
||||
{{ dialogSubtitle }}
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<v-alert
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3 text-start"
|
||||
:text="tm('system_group.system.dashboard.totp.rotateCodeHint')"
|
||||
hide-details
|
||||
></v-alert>
|
||||
<QrCodeViewer
|
||||
v-if="totpProvisioningUri"
|
||||
:value="totpProvisioningUri"
|
||||
alt="TOTP QR Code"
|
||||
:size="220"
|
||||
/>
|
||||
<div class="totp-new-secret-wrap mt-3">
|
||||
<code class="totp-secret">{{ newSecret }}</code>
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model="code"
|
||||
:label="tm('system_group.system.dashboard.totp.rotateCode')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="totp-code-input mt-3"
|
||||
maxlength="6"
|
||||
:error-messages="codeError"
|
||||
:loading="verifying"
|
||||
hide-details="auto"
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
@keyup.enter="confirmSetup"
|
||||
></v-text-field>
|
||||
</div>
|
||||
<div class="d-flex justify-end ga-2 mt-4">
|
||||
<v-btn
|
||||
variant="text"
|
||||
:disabled="verifying"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ tm('system_group.system.dashboard.totp.rotateCancel') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:loading="verifying"
|
||||
:disabled="!code || code.length < 6"
|
||||
@click="confirmSetup"
|
||||
>
|
||||
{{ confirmLabel }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import QrCodeViewer from './QrCodeViewer.vue'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
configRoot: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'setup',
|
||||
validator: (v) => ['setup', 'rotate'].includes(v)
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'setupComplete'])
|
||||
const { tm } = useModuleI18n('features/config-metadata')
|
||||
|
||||
const step = ref('verify')
|
||||
const loading = ref(false)
|
||||
const newSecret = ref('')
|
||||
const code = ref('')
|
||||
const codeError = ref('')
|
||||
const verifying = ref(false)
|
||||
|
||||
const verifyCode = ref('')
|
||||
const verifyError = ref('')
|
||||
const verifyingIdentity = ref(false)
|
||||
|
||||
const cardTitle = computed(() => {
|
||||
if (step.value === 'verify') {
|
||||
return '验证当前 TOTP'
|
||||
}
|
||||
return props.mode === 'rotate'
|
||||
? tm('system_group.system.dashboard.totp.rotateTitle')
|
||||
: tm('system_group.system.dashboard.totp.setupTitle')
|
||||
})
|
||||
|
||||
const dialogSubtitle = computed(() => {
|
||||
return props.mode === 'rotate'
|
||||
? tm('system_group.system.dashboard.totp.rotateSubtitle')
|
||||
: tm('system_group.system.dashboard.totp.setupSubtitle')
|
||||
})
|
||||
|
||||
const confirmLabel = computed(() => {
|
||||
return props.mode === 'rotate'
|
||||
? tm('system_group.system.dashboard.totp.rotateConfirm')
|
||||
: tm('system_group.system.dashboard.totp.setupConfirm')
|
||||
})
|
||||
|
||||
const totpProvisioningUri = computed(() => {
|
||||
if (!newSecret.value) return ''
|
||||
const label = encodeURIComponent(props.configRoot?.dashboard?.username || 'AstrBot')
|
||||
const issuer = encodeURIComponent('AstrBot')
|
||||
return `otpauth://totp/${label}?secret=${encodeURIComponent(newSecret.value)}&issuer=${issuer}`
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
if (props.mode === 'rotate') {
|
||||
step.value = 'verify'
|
||||
return
|
||||
}
|
||||
step.value = 'setup'
|
||||
void fetchNewSecret()
|
||||
return
|
||||
}
|
||||
resetState()
|
||||
}
|
||||
)
|
||||
|
||||
function resetState() {
|
||||
step.value = 'verify'
|
||||
newSecret.value = ''
|
||||
code.value = ''
|
||||
codeError.value = ''
|
||||
verifyCode.value = ''
|
||||
verifyError.value = ''
|
||||
verifyingIdentity.value = false
|
||||
}
|
||||
|
||||
function onVisibilityChange(val) {
|
||||
if (!val) {
|
||||
resetState()
|
||||
}
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
resetState()
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function fetchNewSecret() {
|
||||
if (loading.value || newSecret.value) {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await axios.post('/api/auth/totp/setup')
|
||||
if (res.data.status !== 'ok') {
|
||||
return
|
||||
}
|
||||
newSecret.value = res.data.data?.secret || ''
|
||||
code.value = ''
|
||||
codeError.value = ''
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyIdentity() {
|
||||
if (!verifyCode.value) return
|
||||
verifyingIdentity.value = true
|
||||
verifyError.value = ''
|
||||
try {
|
||||
const res = await axios.post('/api/auth/totp/setup', { code: verifyCode.value })
|
||||
if (res.data.status !== 'ok') {
|
||||
verifyError.value = res.data.message || '验证失败'
|
||||
return
|
||||
}
|
||||
newSecret.value = res.data.data?.secret || ''
|
||||
step.value = 'setup'
|
||||
} catch {
|
||||
verifyError.value = '验证失败'
|
||||
} finally {
|
||||
verifyingIdentity.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmSetup() {
|
||||
if (!code.value || code.value.length < 6) return
|
||||
verifying.value = true
|
||||
codeError.value = ''
|
||||
try {
|
||||
const res = await axios.post('/api/auth/totp/setup', {
|
||||
secret: newSecret.value,
|
||||
code: code.value,
|
||||
})
|
||||
if (res.data.status !== 'ok') {
|
||||
codeError.value = res.data.message || tm('system_group.system.dashboard.totp.rotateError')
|
||||
return
|
||||
}
|
||||
const recoveryCode = String(res.data.data?.recovery_code || '')
|
||||
const recoveryCodeHash = String(res.data.data?.recovery_code_hash || '')
|
||||
const secret = newSecret.value
|
||||
resetState()
|
||||
emit('setupComplete', {
|
||||
secret,
|
||||
recoveryCode,
|
||||
recoveryCodeHash,
|
||||
})
|
||||
emit('update:modelValue', false)
|
||||
} catch {
|
||||
codeError.value = tm('system_group.system.dashboard.totp.rotateError')
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.totp-dialog-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
}
|
||||
|
||||
.totp-new-secret-wrap {
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.totp-secret {
|
||||
word-break: break-all;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.totp-code-input {
|
||||
max-width: 240px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
130
dashboard/src/components/shared/DashboardTwoFactorDialog.vue
Normal file
130
dashboard/src/components/shared/DashboardTwoFactorDialog.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="520"
|
||||
@update:model-value="onVisibilityChange"
|
||||
@click:outside="onCancel"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center pa-4">
|
||||
{{ tm('system_group.system.dashboard.totp.configSaveTitle') }}
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon variant="text" size="small" @click="onCancel">
|
||||
<v-icon>mdi-close</v-icon>
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text class="pa-4">
|
||||
<div class="totp-dialog-subtitle mb-3">
|
||||
{{ tm('system_group.system.dashboard.totp.configSaveSubtitle') }}
|
||||
</div>
|
||||
<div v-if="rotationHint" class="totp-dialog-rotation-hint mb-3">
|
||||
{{ rotationHint }}
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model="code"
|
||||
:label="tm('system_group.system.dashboard.totp.configSaveCode')"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="totp-code-input"
|
||||
maxlength="6"
|
||||
:error-messages="errorMessage"
|
||||
:loading="saving"
|
||||
hide-details="auto"
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
@keyup.enter="confirm"
|
||||
></v-text-field>
|
||||
<div class="d-flex justify-end ga-2 mt-4">
|
||||
<v-btn
|
||||
variant="text"
|
||||
:disabled="saving"
|
||||
@click="onCancel"
|
||||
>
|
||||
{{ tm('system_group.system.dashboard.totp.configSaveCancel') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
:loading="saving"
|
||||
:disabled="!code || code.length < 6"
|
||||
@click="confirm"
|
||||
>
|
||||
{{ tm('system_group.system.dashboard.totp.configSaveConfirm') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useModuleI18n } from '@/i18n/composables'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
errorMessage: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
saving: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
rotationHint: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel'])
|
||||
const { tm } = useModuleI18n('features/config-metadata')
|
||||
|
||||
const code = ref('')
|
||||
|
||||
function resetState() {
|
||||
code.value = ''
|
||||
}
|
||||
|
||||
function onVisibilityChange(val) {
|
||||
if (!val) {
|
||||
resetState()
|
||||
}
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
resetState()
|
||||
emit('cancel')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (!code.value) return
|
||||
emit('confirm', code.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.totp-dialog-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
}
|
||||
|
||||
.totp-dialog-rotation-hint {
|
||||
font-size: 0.82rem;
|
||||
color: rgba(var(--v-theme-info, 33, 150, 243), 0.78);
|
||||
background: rgba(var(--v-theme-info, 33, 150, 243), 0.08);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.totp-code-input {
|
||||
max-width: 240px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
@@ -57,8 +57,11 @@
|
||||
</v-btn>
|
||||
<div class="d-flex flex-column">
|
||||
<v-list-item-title class="property-name">{{ templateLabel(entry.__template_key) }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="property-hint" v-if="getTemplate(entry)?.hint || getTemplate(entry)?.description">
|
||||
{{ templateText(entry.__template_key, 'hint', getTemplate(entry)?.hint || getTemplate(entry)?.description) }}
|
||||
<v-list-item-subtitle class="property-hint entry-display-text" v-if="templateDisplayText(entry)">
|
||||
{{ templateDisplayText(entry) }}
|
||||
</v-list-item-subtitle>
|
||||
<v-list-item-subtitle class="property-hint" v-if="templateHintText(entry)">
|
||||
{{ templateHintText(entry) }}
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,6 +204,7 @@ const defaultValueMap = {
|
||||
string: '',
|
||||
text: '',
|
||||
list: [],
|
||||
file: [],
|
||||
object: {},
|
||||
template_list: []
|
||||
}
|
||||
@@ -348,6 +352,49 @@ function getTemplate(entry) {
|
||||
return props.templates?.[key] || null
|
||||
}
|
||||
|
||||
function templateHintText(entry) {
|
||||
const template = getTemplate(entry)
|
||||
if (!template || template.hide_hint_in_list) return ''
|
||||
return templateText(entry.__template_key, 'hint', template.hint || template.description || '')
|
||||
}
|
||||
|
||||
function getItemMetaBySelector(itemsMeta = {}, selector = '') {
|
||||
const keys = selector.split('.').filter(Boolean)
|
||||
let currentItems = itemsMeta
|
||||
let currentMeta = null
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
currentMeta = currentItems?.[keys[i]]
|
||||
if (!currentMeta) return null
|
||||
if (i < keys.length - 1) {
|
||||
if (currentMeta.type !== 'object') return null
|
||||
currentItems = currentMeta.items || {}
|
||||
}
|
||||
}
|
||||
|
||||
return currentMeta
|
||||
}
|
||||
|
||||
function templateDisplayText(entry) {
|
||||
const template = getTemplate(entry)
|
||||
const displayItem = template?.display_item
|
||||
if (!template || typeof displayItem !== 'string' || !displayItem) return ''
|
||||
|
||||
const displayMeta = getItemMetaBySelector(template.items || {}, displayItem)
|
||||
if (displayMeta?.type !== 'string') return ''
|
||||
|
||||
const value = getValueBySelector(entry, displayItem)
|
||||
if (typeof value !== 'string' || !value.trim()) return ''
|
||||
|
||||
const label = templateItemText(
|
||||
entry.__template_key,
|
||||
displayItem,
|
||||
'description',
|
||||
displayMeta.description || displayItem,
|
||||
)
|
||||
return `${label}: ${value.trim()}`
|
||||
}
|
||||
|
||||
function getValueBySelector(obj, selector) {
|
||||
const keys = selector.split('.')
|
||||
let current = obj
|
||||
@@ -450,6 +497,11 @@ function hasVisibleItemsAfter(entries, currentIndex, entry) {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.entry-display-text {
|
||||
color: var(--v-theme-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.property-key {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -3,6 +3,23 @@
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"defaultHint": "If this is your first login, check the logs for the default password.",
|
||||
"totp": {
|
||||
"code": "Verification code",
|
||||
"verify": "Verify",
|
||||
"trustDevice": "Trust this device for 30 days"
|
||||
},
|
||||
"recovery": {
|
||||
"title": "Recovery Code Login",
|
||||
"subtitle": "Lost access to your authenticator app? Use a recovery code to log in.",
|
||||
"code": "Recovery Code",
|
||||
"submit": "Log in with Recovery Code",
|
||||
"useRecoveryCode": "Can't use TOTP?",
|
||||
"backToLogin": "Back to Login",
|
||||
"savedWarning": "If lost, account access cannot be restored through normal means.",
|
||||
"continue": "Continue",
|
||||
"acknowledge": "I have saved my recovery codes",
|
||||
"totpDisableWarning": "Using a recovery code will disable two-factor authentication."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Set Up Account",
|
||||
"subtitle": "Create the account used to manage AstrBot",
|
||||
@@ -11,6 +28,17 @@
|
||||
"confirmPassword": "Confirm new password",
|
||||
"passwordHint": "Use at least 8 characters with uppercase, lowercase, and a number.",
|
||||
"submit": "Complete Setup",
|
||||
"totp": {
|
||||
"code": "Verification code",
|
||||
"qrAlt": "TOTP QR code",
|
||||
"title": "Complete TOTP Setup",
|
||||
"subtitle": "Scan the QR code with your authenticator app to enable two-factor authentication.",
|
||||
"step2Hint": "Scan this QR code with your authenticator app (e.g. Google Authenticator, Authy) and enter the code below.",
|
||||
"verify": "Verify & Complete",
|
||||
"verifyError": "Unable to verify the code. Enter the latest code from your authenticator app.",
|
||||
"disableError": "Unable to disable TOTP. Please try again.",
|
||||
"back": "Back"
|
||||
},
|
||||
"validation": {
|
||||
"usernameRequired": "Enter a username",
|
||||
"usernameMinLength": "Username must be at least 3 characters",
|
||||
@@ -25,6 +53,7 @@
|
||||
},
|
||||
"logo": {
|
||||
"title": "AstrBot Dashboard",
|
||||
"totpTitle": "Two-Factor Verification",
|
||||
"subtitle": "Welcome"
|
||||
},
|
||||
"theme": {
|
||||
|
||||
@@ -1084,6 +1084,24 @@
|
||||
"hint": "When disabled, AstrBot will not upload anonymous usage statistics."
|
||||
},
|
||||
"dashboard": {
|
||||
"trust_proxy_headers": {
|
||||
"description": "Trust Proxy Headers for Client IP",
|
||||
"hint": "When disabled, ignore X-Forwarded-For/X-Real-IP and use the connection address only."
|
||||
},
|
||||
"auth_rate_limit": {
|
||||
"enable": {
|
||||
"description": "Enable Login Rate Limiting",
|
||||
"hint": "When disabled, authentication endpoints (login, TOTP, etc.) will not be rate-limited."
|
||||
},
|
||||
"average_interval": {
|
||||
"description": "Endpoint Rate Limit Average Interval (seconds)",
|
||||
"hint": "Minimum average interval between authentication requests. For example, 1.0 means at most 1 request per second."
|
||||
},
|
||||
"max_burst": {
|
||||
"description": "Endpoint Rate Limit Max Burst",
|
||||
"hint": "Maximum number of consecutive burst requests allowed. For example, 3 allows up to 3 requests in a short burst."
|
||||
}
|
||||
},
|
||||
"ssl": {
|
||||
"enable": {
|
||||
"description": "Enable WebUI HTTPS",
|
||||
@@ -1101,6 +1119,52 @@
|
||||
"description": "SSL CA Certificate File Path",
|
||||
"hint": "Optional. Path to CA certificate file."
|
||||
}
|
||||
},
|
||||
"totp": {
|
||||
"enable": {
|
||||
"description": "Enable WebUI TOTP",
|
||||
"hint": "When enabled, a TOTP code is required during dashboard login."
|
||||
},
|
||||
"manage": "Manage",
|
||||
"configuration": "TOTP",
|
||||
"statusPending": "Setup required",
|
||||
"statusEnabled": "Enabled",
|
||||
"setupRequiredHint": "TOTP is enabled but not yet configured. Click Manage to complete setup.",
|
||||
"setupTitle": "Set up TOTP",
|
||||
"setupSubtitle": "Scan this QR code in your authenticator app, then enter a verification code.",
|
||||
"setupConfirm": "Verify and continue",
|
||||
"activeSubtitle": "Use this QR code or secret to add another authenticator device.",
|
||||
"rotateTitle": "Rotate TOTP Secret",
|
||||
"rotateSubtitle": "Generate a new secret, then enter a code from your authenticator to confirm the replacement.",
|
||||
"rotate": "Rotate",
|
||||
"rotateRecovery": "Rotate Recovery Code",
|
||||
"rotateConfirm": "Confirm Rotation",
|
||||
"rotateCancel": "Cancel",
|
||||
"rotateCode": "Verification Code",
|
||||
"rotateCodeHint": "Enter the code from your authenticator app to confirm the new key.",
|
||||
"rotateError": "Invalid code, please try again.",
|
||||
"recoveryTitle": "Recovery Codes",
|
||||
"recoverySubtitle": "This recovery code is shown once. Save it before continuing.",
|
||||
"recoveryWarning": "If lost, account access cannot be restored through normal means.",
|
||||
"recoveryAcknowledge": "I have saved my recovery codes",
|
||||
"recoveryClose": "Done",
|
||||
"disableTitle": "Disable TOTP",
|
||||
"disableSubtitle": "Enter a verification code to disable two-factor authentication.",
|
||||
"disableRecoverySubtitle": "Enter a recovery code to disable two-factor authentication.",
|
||||
"disableCode": "Verification Code",
|
||||
"disableRecoveryCode": "Recovery Code",
|
||||
"disableConfirm": "Disable",
|
||||
"disableCancel": "Cancel",
|
||||
"disableError": "Verification failed. Please try again.",
|
||||
"disableUseRecovery": "Can't use TOTP?",
|
||||
"disableUseCode": "Use verification code",
|
||||
"configSaveTitle": "Two-Factor Verification",
|
||||
"configSaveSubtitle": "Enter a verification code to change protected configuration.",
|
||||
"configSaveRotationHint": "When rotating TOTP keys, both the old and new verification codes are accepted (allowed once per rotation operation).",
|
||||
"configSaveCode": "Verification Code",
|
||||
"configSaveConfirm": "Continue",
|
||||
"configSaveCancel": "Cancel",
|
||||
"configSaveError": "Verification failed. Please try again."
|
||||
}
|
||||
},
|
||||
"timezone": {
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"packageLabel": "*Package name, e.g. llmtuner",
|
||||
"mirrorLabel": "Force PyPI repository URL (optional)",
|
||||
"mirrorHint": "Force PyPI repository URL > Config item `PyPI Repository Address`",
|
||||
"installButton": "Install"
|
||||
"installButton": "Install",
|
||||
"installSuccess": "Installation successful.",
|
||||
"installFailed": "Installation failed.",
|
||||
"requestFailed": "Request failed."
|
||||
},
|
||||
"debugHint": {
|
||||
"text": "Debug logs can be enabled in \"Configuration File → System → Console Log Level\""
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
"page": {
|
||||
"title": "Future Task Management",
|
||||
"beta": "Experimental",
|
||||
"subtitle": "See scheduled tasks for AstrBot. AstrBot will wake up, run them, and deliver the results.",
|
||||
"subtitle": "AstrBot wakes up at the scheduled time, completes the task, and sends the result back to the target conversation.",
|
||||
"proactive": {
|
||||
"link": "Supported platforms",
|
||||
"supported": "Proactive delivery is available only on the configured platforms below",
|
||||
"unsupported": "No proactive messaging platforms enabled. Turn them on in Platform settings."
|
||||
}
|
||||
@@ -18,9 +19,36 @@
|
||||
"refresh": "Refresh",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"more": "More actions",
|
||||
"runNow": "Run now",
|
||||
"save": "Save",
|
||||
"submit": "Create"
|
||||
},
|
||||
"platformDialog": {
|
||||
"title": "Supported Platforms",
|
||||
"description": "Only the following platforms support proactive message delivery. AstrBot can send future task results back only to target conversations on these platforms."
|
||||
},
|
||||
"card": {
|
||||
"onceAt": "One-off · {time}",
|
||||
"runAt": "Run at: {time}",
|
||||
"nextRun": "Next run: {time}",
|
||||
"dailyAt": "Daily at {time}",
|
||||
"weeklyAt": "Every {day} at {time}",
|
||||
"monthlyAt": "Monthly on day {day} at {time}",
|
||||
"everyMinutes": "Every {count} minutes",
|
||||
"everyHours": "Every {count} hours",
|
||||
"everyDays": "Every {count} days",
|
||||
"customCron": "Custom · {cron}",
|
||||
"noDeliveryTarget": "No delivery target"
|
||||
},
|
||||
"filters": {
|
||||
"search": "Search title and content",
|
||||
"umo": "Filter by delivery target",
|
||||
"noUmos": "No UMOs",
|
||||
"noDeliveryTarget": "No delivery target",
|
||||
"noMatches": "No matching future tasks."
|
||||
},
|
||||
"overview": {
|
||||
"totalTasks": "Total Tasks",
|
||||
"totalTasksNote": "Registered future tasks",
|
||||
@@ -82,12 +110,43 @@
|
||||
"editTitle": "Edit Task",
|
||||
"chatHint": "You can ask AstrBot in chat to create future tasks instead of adding them here.",
|
||||
"runOnce": "One-off task",
|
||||
"name": "Task name",
|
||||
"note": "Task description",
|
||||
"cron": "Cron expression",
|
||||
"name": "Task name *",
|
||||
"note": "Task requirements *",
|
||||
"scheduleMode": "Execution time *",
|
||||
"scheduleModes": {
|
||||
"once": "One-off",
|
||||
"interval": "Interval",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly",
|
||||
"cron": "Custom Cron"
|
||||
},
|
||||
"intervalEvery": "Every *",
|
||||
"intervalUnit": "Unit *",
|
||||
"intervalUnits": {
|
||||
"minutes": "Minutes",
|
||||
"hours": "Hours",
|
||||
"days": "Days"
|
||||
},
|
||||
"dailyTime": "Time *",
|
||||
"weeklyDay": "Weekday *",
|
||||
"weeklyTime": "Time *",
|
||||
"monthlyDay": "Day *",
|
||||
"monthlyTime": "Time *",
|
||||
"weekdays": {
|
||||
"sunday": "Sunday",
|
||||
"monday": "Monday",
|
||||
"tuesday": "Tuesday",
|
||||
"wednesday": "Wednesday",
|
||||
"thursday": "Thursday",
|
||||
"friday": "Friday",
|
||||
"saturday": "Saturday"
|
||||
},
|
||||
"cron": "Cron expression *",
|
||||
"cronPlaceholder": "0 9 * * *",
|
||||
"runAt": "Run at",
|
||||
"session": "Target session (platform_id:message_type:session_id)",
|
||||
"runAt": "Run at *",
|
||||
"session": "Deliver to",
|
||||
"noUmos": "No sessions available",
|
||||
"timezone": "Timezone (optional, e.g. Asia/Shanghai)",
|
||||
"enabled": "Enabled"
|
||||
},
|
||||
@@ -97,11 +156,18 @@
|
||||
"updateFailed": "Failed to update",
|
||||
"deleteSuccess": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"sessionRequired": "Session is required",
|
||||
"noteRequired": "Description is required",
|
||||
"nameRequired": "Task name is required",
|
||||
"sessionRequired": "Delivery target is required",
|
||||
"noteRequired": "Task requirements are required",
|
||||
"cronRequired": "Cron expression is required",
|
||||
"runAtRequired": "Please select run time",
|
||||
"intervalRequired": "Please enter a valid interval",
|
||||
"dailyTimeRequired": "Please select the daily run time",
|
||||
"weeklyTimeRequired": "Please select the weekly day and time",
|
||||
"monthlyTimeRequired": "Please select the monthly day and time",
|
||||
"createSuccess": "Created successfully",
|
||||
"createFailed": "Failed to create"
|
||||
"createFailed": "Failed to create",
|
||||
"runStarted": "Started",
|
||||
"runFailed": "Failed to run"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +1,63 @@
|
||||
{
|
||||
"login": "Вход",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"defaultHint": "Если это ваш первый вход, проверьте пароль по умолчанию в логах.",
|
||||
"setup": {
|
||||
"title": "Настройка аккаунта",
|
||||
"subtitle": "Создайте аккаунт для управления AstrBot",
|
||||
"username": "Новое имя пользователя",
|
||||
"password": "Новый пароль",
|
||||
"confirmPassword": "Подтвердите новый пароль",
|
||||
"passwordHint": "Минимум 8 символов, включая заглавную букву, строчную букву и цифру.",
|
||||
"submit": "Завершить настройку",
|
||||
"validation": {
|
||||
"usernameRequired": "Введите имя пользователя",
|
||||
"usernameMinLength": "Имя пользователя должно содержать минимум 3 символа",
|
||||
"passwordRequired": "Введите пароль",
|
||||
"passwordMinLength": "Пароль должен содержать минимум 8 символов",
|
||||
"passwordUppercase": "Пароль должен содержать хотя бы одну заглавную букву",
|
||||
"passwordLowercase": "Пароль должен содержать хотя бы одну строчную букву",
|
||||
"passwordDigit": "Пароль должен содержать хотя бы одну цифру",
|
||||
"confirmPasswordRequired": "Подтвердите пароль",
|
||||
"passwordMatch": "Пароли не совпадают"
|
||||
}
|
||||
{
|
||||
"login": "Вход",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"defaultHint": "Если это первый вход, проверьте пароль по умолчанию в логах.",
|
||||
"totp": {
|
||||
"code": "Код подтверждения",
|
||||
"verify": "Проверить",
|
||||
"trustDevice": "Доверять этому устройству 30 дней"
|
||||
},
|
||||
"recovery": {
|
||||
"title": "Вход по коду восстановления",
|
||||
"subtitle": "Потеряли доступ к приложению-аутентификатору? Используйте код восстановления для входа.",
|
||||
"code": "Код восстановления",
|
||||
"submit": "Войти по коду восстановления",
|
||||
"useRecoveryCode": "Не можете использовать TOTP?",
|
||||
"backToLogin": "Назад к входу",
|
||||
"savedWarning": "При утере этого кода восстановить доступ к учётной записи обычными средствами будет невозможно.",
|
||||
"continue": "Продолжить",
|
||||
"acknowledge": "Я сохранил(а) коды восстановления",
|
||||
"totpDisableWarning": "Использование кода восстановления отключит двухфакторную аутентификацию."
|
||||
},
|
||||
"setup": {
|
||||
"title": "Настройка аккаунта",
|
||||
"subtitle": "Создайте аккаунт для управления AstrBot",
|
||||
"username": "Новое имя пользователя",
|
||||
"password": "Новый пароль",
|
||||
"confirmPassword": "Подтвердите новый пароль",
|
||||
"passwordHint": "Минимум 8 символов, включая заглавную букву, строчную букву и цифру.",
|
||||
"submit": "Завершить настройку",
|
||||
"totp": {
|
||||
"code": "Код подтверждения",
|
||||
"qrAlt": "QR-код TOTP",
|
||||
"title": "Завершить настройку TOTP",
|
||||
"subtitle": "Отсканируйте QR-код в приложении-аутентификаторе, чтобы включить двухфакторную аутентификацию.",
|
||||
"step2Hint": "Отсканируйте этот QR-код в приложении-аутентификаторе (например, Google Authenticator, Authy) и введите код ниже.",
|
||||
"verify": "Проверить и завершить",
|
||||
"verifyError": "Не удалось проверить код. Введите последний код из приложения-аутентификатора.",
|
||||
"disableError": "Не удалось отключить TOTP. Пожалуйста, попробуйте снова.",
|
||||
"back": "Назад"
|
||||
},
|
||||
"logo": {
|
||||
"title": "Панель управления AstrBot",
|
||||
"subtitle": "Добро пожаловать"
|
||||
},
|
||||
"theme": {
|
||||
"switchToDark": "Перейти на темную тему",
|
||||
"switchToLight": "Перейти на светлую тему"
|
||||
"validation": {
|
||||
"usernameRequired": "Введите имя пользователя",
|
||||
"usernameMinLength": "Имя пользователя должно содержать минимум 3 символа",
|
||||
"passwordRequired": "Введите пароль",
|
||||
"passwordMinLength": "Пароль должен содержать минимум 8 символов",
|
||||
"passwordUppercase": "Пароль должен содержать хотя бы одну заглавную букву",
|
||||
"passwordLowercase": "Пароль должен содержать хотя бы одну строчную букву",
|
||||
"passwordDigit": "Пароль должен содержать хотя бы одну цифру",
|
||||
"confirmPasswordRequired": "Подтвердите пароль",
|
||||
"passwordMatch": "Пароли не совпадают"
|
||||
}
|
||||
},
|
||||
"logo": {
|
||||
"title": "Панель управления AstrBot",
|
||||
"totpTitle": "Двухфакторная аутентификация",
|
||||
"subtitle": "Добро пожаловать"
|
||||
},
|
||||
"theme": {
|
||||
"switchToDark": "Перейти на темную тему",
|
||||
"switchToLight": "Перейти на светлую тему"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1085,6 +1085,24 @@
|
||||
"hint": "После отключения AstrBot не будет отправлять анонимные данные об использовании."
|
||||
},
|
||||
"dashboard": {
|
||||
"trust_proxy_headers": {
|
||||
"description": "Доверять прокси-заголовкам для IP клиента",
|
||||
"hint": "Если выключено, X-Forwarded-For/X-Real-IP игнорируются и используется только адрес соединения."
|
||||
},
|
||||
"auth_rate_limit": {
|
||||
"enable": {
|
||||
"description": "Включить ограничение скорости входа",
|
||||
"hint": "Если выключено, конечные точки аутентификации (вход, TOTP и т.д.) не будут ограничены по скорости."
|
||||
},
|
||||
"average_interval": {
|
||||
"description": "Средний интервал ограничения скорости конечных точек (сек)",
|
||||
"hint": "Минимальный средний интервал между запросами аутентификации. Например, 1.0 означает не более 1 запроса в секунду."
|
||||
},
|
||||
"max_burst": {
|
||||
"description": "Максимальный всплеск ограничения скорости конечных точек",
|
||||
"hint": "Максимальное количество последовательных всплесков запросов. Например, 3 допускает до 3 запросов за короткий всплеск."
|
||||
}
|
||||
},
|
||||
"ssl": {
|
||||
"enable": {
|
||||
"description": "Включить HTTPS для WebUI",
|
||||
@@ -1102,6 +1120,52 @@
|
||||
"description": "Путь к сертификату CA SSL",
|
||||
"hint": "Опционально. Путь к сертификату CA."
|
||||
}
|
||||
},
|
||||
"totp": {
|
||||
"enable": {
|
||||
"description": "Включить TOTP для WebUI",
|
||||
"hint": "Когда включено, TOTP-код требуется для входа в панель управления."
|
||||
},
|
||||
"manage": "Управление",
|
||||
"configuration": "TOTP",
|
||||
"statusPending": "Требуется настройка",
|
||||
"statusEnabled": "Включено",
|
||||
"setupRequiredHint": "TOTP включен, но ещё не настроен. Откройте «Управление», чтобы завершить настройку.",
|
||||
"setupTitle": "Настройка TOTP",
|
||||
"setupSubtitle": "Отсканируйте QR-код в приложении-аутентификаторе и введите код подтверждения.",
|
||||
"setupConfirm": "Подтвердить и продолжить",
|
||||
"activeSubtitle": "Используйте этот QR-код или секрет для добавления нового устройства-аутентификатора.",
|
||||
"rotateTitle": "Смена секрета TOTP",
|
||||
"rotateSubtitle": "Сгенерируйте новый секрет и подтвердите его перед заменой текущего.",
|
||||
"rotate": "Сменить",
|
||||
"rotateRecovery": "Сменить код восстановления",
|
||||
"rotateConfirm": "Подтвердить смену",
|
||||
"rotateCancel": "Отмена",
|
||||
"rotateCode": "Код подтверждения",
|
||||
"rotateCodeHint": "Введите код из приложения-аутентификатора для подтверждения нового ключа.",
|
||||
"rotateError": "Неверный код, попробуйте снова.",
|
||||
"recoveryTitle": "Коды восстановления",
|
||||
"recoverySubtitle": "Этот код показывается один раз. Сохраните его перед продолжением.",
|
||||
"recoveryWarning": "При утере этого кода восстановить доступ к учётной записи обычными средствами будет невозможно.",
|
||||
"recoveryAcknowledge": "Я сохранил(а) коды восстановления",
|
||||
"recoveryClose": "Готово",
|
||||
"disableTitle": "Отключить TOTP",
|
||||
"disableSubtitle": "Введите код подтверждения для отключения двухфакторной аутентификации.",
|
||||
"disableRecoverySubtitle": "Введите код восстановления для отключения двухфакторной аутентификации.",
|
||||
"disableCode": "Код подтверждения",
|
||||
"disableRecoveryCode": "Код восстановления",
|
||||
"disableConfirm": "Отключить",
|
||||
"disableCancel": "Отмена",
|
||||
"disableError": "Ошибка проверки. Попробуйте снова.",
|
||||
"disableUseRecovery": "Не можете использовать TOTP?",
|
||||
"disableUseCode": "Использовать код подтверждения",
|
||||
"configSaveTitle": "Двухфакторная проверка",
|
||||
"configSaveSubtitle": "Введите код подтверждения для изменения защищённой конфигурации.",
|
||||
"configSaveRotationHint": "При смене TOTP-ключа принимаются как старый, так и новый коды подтверждения (только однократно при смене).",
|
||||
"configSaveCode": "Код подтверждения",
|
||||
"configSaveConfirm": "Продолжить",
|
||||
"configSaveCancel": "Отмена",
|
||||
"configSaveError": "Ошибка проверки. Попробуйте снова."
|
||||
}
|
||||
},
|
||||
"timezone": {
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"packageLabel": "*Имя пакета, например: llmtuner",
|
||||
"mirrorLabel": "Использовать зеркало PyPI (опционально)",
|
||||
"mirrorHint": "Приоритет зеркала PyPI > настройки «Зеркало репозитория PyPI»",
|
||||
"installButton": "Установить"
|
||||
"installButton": "Установить",
|
||||
"installSuccess": "Установка выполнена успешно.",
|
||||
"installFailed": "Ошибка установки.",
|
||||
"requestFailed": "Ошибка запроса."
|
||||
},
|
||||
"debugHint": {
|
||||
"text": "Для отображения Debug-логов необходимо установить соответствующий уровень в «Конфигурация → Система → Уровень логирования»"
|
||||
|
||||
@@ -1,107 +1,173 @@
|
||||
{
|
||||
"header": {
|
||||
"eyebrow": "Automation",
|
||||
"live": "Живая синхронизация"
|
||||
},
|
||||
"page": {
|
||||
"title": "Запланированные задачи",
|
||||
"beta": "Экспериментальные функции",
|
||||
"subtitle": "Управление будущими задачами AstrBot. Бот автоматически проснется, выполнит задачу и отправит результат. Требуется включить «Проактивные способности» в конфигурации.",
|
||||
"proactive": {
|
||||
"supported": "Отправка результатов поддерживается только на указанных ниже настроенных платформах",
|
||||
"unsupported": "Нет платформ, поддерживающих проактивные сообщения. Включите их в настройках платформ."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"create": "Новая задача",
|
||||
"edit": "Изменить",
|
||||
"refresh": "Обновить",
|
||||
"delete": "Удалить",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить",
|
||||
"submit": "Создать"
|
||||
},
|
||||
"overview": {
|
||||
"totalTasks": "Всего задач",
|
||||
"totalTasksNote": "Все зарегистрированные будущие задачи",
|
||||
"enabledTasks": "Активные задачи",
|
||||
"enabledTasksNote": "Задачи, которые будут автоматически выполнены",
|
||||
"oneOffTasks": "Разовые задачи",
|
||||
"recurringTasksNote": "Повторяющихся задач: {count}",
|
||||
"proactivePlatforms": "Проактивные платформы",
|
||||
"proactivePlatformsNote": "Платформы, способные отправлять результат сами"
|
||||
},
|
||||
"section": {
|
||||
"registered": {
|
||||
"title": "Список задач",
|
||||
"subtitle": "Просматривайте зарегистрированные задачи, время запуска и состояние"
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Статус доставки",
|
||||
"subtitle": "После выполнения задачи результат будет отправлен обратно через поддерживаемые платформы",
|
||||
"support": "Проактивная доставка",
|
||||
"available": "Доступна",
|
||||
"unavailable": "Недоступна",
|
||||
"enabledPlatforms": "Включенные платформы"
|
||||
},
|
||||
"quickCreate": {
|
||||
"title": "Быстрое создание",
|
||||
"runMode": "Режим задачи",
|
||||
"target": "Целевой контекст"
|
||||
},
|
||||
"platforms": {
|
||||
"title": "Поддерживаемые платформы"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"title": "Список задач",
|
||||
"subtitle": "Отслеживайте cron, целевую сессию, историю запусков и состояние",
|
||||
"empty": "Задач пока нет.",
|
||||
"headers": {
|
||||
"name": "Имя",
|
||||
"type": "Тип",
|
||||
"cron": "Cron",
|
||||
"session": "ID сессии",
|
||||
"nextRun": "Следующий запуск",
|
||||
"lastRun": "Последний запуск",
|
||||
"note": "Описание",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"type": {
|
||||
"once": "Разовая",
|
||||
"recurring": "Повторяющаяся",
|
||||
"activeAgent": "Активный агент",
|
||||
"workflow": "Рабочий процесс",
|
||||
"unknown": "{type}"
|
||||
},
|
||||
"timezoneLocal": "Местное время",
|
||||
"notAvailable": "—"
|
||||
},
|
||||
"form": {
|
||||
"title": "Создать задачу",
|
||||
"editTitle": "Редактировать задачу",
|
||||
"chatHint": "Вы можете ставить задачи прямо в чате, AstrBot создаст их автоматически без заполнения этой формы.",
|
||||
"runOnce": "Разовая задача",
|
||||
"name": "Имя задачи",
|
||||
"note": "Описание",
|
||||
"cron": "Cron-выражения",
|
||||
"cronPlaceholder": "0 9 * * *",
|
||||
"runAt": "Время запуска",
|
||||
"session": "Целевая сессия (platform_id:message_type:session_id)",
|
||||
"timezone": "Часовой пояс (опционально, напр. Europe/Moscow)",
|
||||
"enabled": "Включено"
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "Ошибка загрузки задач",
|
||||
"updateSuccess": "Задача обновлена",
|
||||
"updateFailed": "Ошибка обновления",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"sessionRequired": "Укажите сессию",
|
||||
"noteRequired": "Заполните описание",
|
||||
"cronRequired": "Укажите Cron-выражение",
|
||||
"runAtRequired": "Выберите время запуска",
|
||||
"createSuccess": "Задача создана",
|
||||
"createFailed": "Ошибка создания"
|
||||
"header": {
|
||||
"eyebrow": "Automation",
|
||||
"live": "Живая синхронизация"
|
||||
},
|
||||
"page": {
|
||||
"title": "Запланированные задачи",
|
||||
"beta": "Экспериментальные функции",
|
||||
"subtitle": "AstrBot проснется в заданное время, выполнит задачу и отправит результат в целевую беседу.",
|
||||
"proactive": {
|
||||
"link": "Поддерживаемые платформы",
|
||||
"supported": "Отправка результатов поддерживается только на указанных ниже настроенных платформах",
|
||||
"unsupported": "Нет платформ, поддерживающих проактивные сообщения. Включите их в настройках платформ."
|
||||
}
|
||||
},
|
||||
"actions": {
|
||||
"create": "Новая задача",
|
||||
"edit": "Изменить",
|
||||
"refresh": "Обновить",
|
||||
"delete": "Удалить",
|
||||
"cancel": "Отмена",
|
||||
"close": "Закрыть",
|
||||
"more": "Другие действия",
|
||||
"runNow": "Запустить сейчас",
|
||||
"save": "Сохранить",
|
||||
"submit": "Создать"
|
||||
},
|
||||
"platformDialog": {
|
||||
"title": "Поддерживаемые платформы",
|
||||
"description": "Только следующие платформы поддерживают проактивную отправку сообщений. AstrBot сможет отправить результат будущей задачи только в целевые беседы на этих платформах."
|
||||
},
|
||||
"card": {
|
||||
"onceAt": "Один раз · {time}",
|
||||
"runAt": "Время запуска: {time}",
|
||||
"nextRun": "Следующий запуск: {time}",
|
||||
"dailyAt": "Каждый день в {time}",
|
||||
"weeklyAt": "Каждый {day} в {time}",
|
||||
"monthlyAt": "Каждый месяц, день {day}, {time}",
|
||||
"everyMinutes": "Каждые {count} мин.",
|
||||
"everyHours": "Каждые {count} ч.",
|
||||
"everyDays": "Каждые {count} дн.",
|
||||
"customCron": "Вручную · {cron}",
|
||||
"noDeliveryTarget": "Цель доставки не задана"
|
||||
},
|
||||
"filters": {
|
||||
"search": "Поиск по названию и содержанию",
|
||||
"umo": "Фильтр по цели доставки",
|
||||
"noUmos": "Нет UMO",
|
||||
"noDeliveryTarget": "Цель доставки не задана",
|
||||
"noMatches": "Нет подходящих будущих задач."
|
||||
},
|
||||
"overview": {
|
||||
"totalTasks": "Всего задач",
|
||||
"totalTasksNote": "Все зарегистрированные будущие задачи",
|
||||
"enabledTasks": "Активные задачи",
|
||||
"enabledTasksNote": "Задачи, которые будут автоматически выполнены",
|
||||
"oneOffTasks": "Разовые задачи",
|
||||
"recurringTasksNote": "Повторяющихся задач: {count}",
|
||||
"proactivePlatforms": "Проактивные платформы",
|
||||
"proactivePlatformsNote": "Платформы, способные отправлять результат сами"
|
||||
},
|
||||
"section": {
|
||||
"registered": {
|
||||
"title": "Список задач",
|
||||
"subtitle": "Просматривайте зарегистрированные задачи, время запуска и состояние"
|
||||
},
|
||||
"delivery": {
|
||||
"title": "Статус доставки",
|
||||
"subtitle": "После выполнения задачи результат будет отправлен обратно через поддерживаемые платформы",
|
||||
"support": "Проактивная доставка",
|
||||
"available": "Доступна",
|
||||
"unavailable": "Недоступна",
|
||||
"enabledPlatforms": "Включенные платформы"
|
||||
},
|
||||
"quickCreate": {
|
||||
"title": "Быстрое создание",
|
||||
"runMode": "Режим задачи",
|
||||
"target": "Целевой контекст"
|
||||
},
|
||||
"platforms": {
|
||||
"title": "Поддерживаемые платформы"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
"title": "Список задач",
|
||||
"subtitle": "Отслеживайте cron, целевую сессию, историю запусков и состояние",
|
||||
"empty": "Задач пока нет.",
|
||||
"headers": {
|
||||
"name": "Имя",
|
||||
"type": "Тип",
|
||||
"cron": "Cron",
|
||||
"session": "ID сессии",
|
||||
"nextRun": "Следующий запуск",
|
||||
"lastRun": "Последний запуск",
|
||||
"note": "Описание",
|
||||
"actions": "Действия"
|
||||
},
|
||||
"type": {
|
||||
"once": "Разовая",
|
||||
"recurring": "Повторяющаяся",
|
||||
"activeAgent": "Активный агент",
|
||||
"workflow": "Рабочий процесс",
|
||||
"unknown": "{type}"
|
||||
},
|
||||
"timezoneLocal": "Местное время",
|
||||
"notAvailable": "—"
|
||||
},
|
||||
"form": {
|
||||
"title": "Создать задачу",
|
||||
"editTitle": "Редактировать задачу",
|
||||
"chatHint": "Вы можете ставить задачи прямо в чате, AstrBot создаст их автоматически без заполнения этой формы.",
|
||||
"runOnce": "Разовая задача",
|
||||
"name": "Имя задачи *",
|
||||
"note": "Требования задачи *",
|
||||
"scheduleMode": "Время выполнения *",
|
||||
"scheduleModes": {
|
||||
"once": "Один раз",
|
||||
"interval": "Интервал",
|
||||
"daily": "Каждый день",
|
||||
"weekly": "Каждую неделю",
|
||||
"monthly": "Каждый месяц",
|
||||
"cron": "Cron вручную"
|
||||
},
|
||||
"intervalEvery": "Каждые *",
|
||||
"intervalUnit": "Единица *",
|
||||
"intervalUnits": {
|
||||
"minutes": "Минуты",
|
||||
"hours": "Часы",
|
||||
"days": "Дни"
|
||||
},
|
||||
"dailyTime": "Время *",
|
||||
"weeklyDay": "День недели *",
|
||||
"weeklyTime": "Время *",
|
||||
"monthlyDay": "День *",
|
||||
"monthlyTime": "Время *",
|
||||
"weekdays": {
|
||||
"sunday": "Воскресенье",
|
||||
"monday": "Понедельник",
|
||||
"tuesday": "Вторник",
|
||||
"wednesday": "Среда",
|
||||
"thursday": "Четверг",
|
||||
"friday": "Пятница",
|
||||
"saturday": "Суббота"
|
||||
},
|
||||
"cron": "Cron-выражения *",
|
||||
"cronPlaceholder": "0 9 * * *",
|
||||
"runAt": "Время запуска *",
|
||||
"session": "Доставить в",
|
||||
"noUmos": "Нет доступных сессий",
|
||||
"timezone": "Часовой пояс (опционально, напр. Europe/Moscow)",
|
||||
"enabled": "Включено"
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "Ошибка загрузки задач",
|
||||
"updateSuccess": "Задача обновлена",
|
||||
"updateFailed": "Ошибка обновления",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
"nameRequired": "Укажите имя задачи",
|
||||
"sessionRequired": "Укажите цель доставки",
|
||||
"noteRequired": "Заполните требования задачи",
|
||||
"cronRequired": "Укажите Cron-выражение",
|
||||
"runAtRequired": "Выберите время запуска",
|
||||
"intervalRequired": "Укажите корректный интервал",
|
||||
"dailyTimeRequired": "Выберите ежедневное время запуска",
|
||||
"weeklyTimeRequired": "Выберите день недели и время запуска",
|
||||
"monthlyTimeRequired": "Выберите день месяца и время запуска",
|
||||
"createSuccess": "Задача создана",
|
||||
"createFailed": "Ошибка создания",
|
||||
"runStarted": "Запущено",
|
||||
"runFailed": "Ошибка запуска"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,46 @@
|
||||
"login": "登录",
|
||||
"username": "用户名",
|
||||
"password": "密码",
|
||||
"defaultHint": "如果是第一次登录,请留意日志输出的默认密码",
|
||||
"defaultHint": "如果这是首次登录,请在日志中查看默认密码。",
|
||||
"totp": {
|
||||
"code": "验证码",
|
||||
"verify": "验证",
|
||||
"trustDevice": "信任此设备 30 天"
|
||||
},
|
||||
"recovery": {
|
||||
"title": "恢复码登录",
|
||||
"subtitle": "无法使用认证器应用时,可通过恢复码登录。",
|
||||
"code": "恢复码",
|
||||
"submit": "使用恢复码登录",
|
||||
"useRecoveryCode": "无法使用 TOTP?",
|
||||
"backToLogin": "返回登录",
|
||||
"savedWarning": "若恢复码丢失将无法通过常规途径恢复账户访问权限。",
|
||||
"continue": "继续",
|
||||
"acknowledge": "我已保存恢复码",
|
||||
"totpDisableWarning": "使用恢复码登录将禁用双因素认证。"
|
||||
},
|
||||
"setup": {
|
||||
"title": "设置账户",
|
||||
"subtitle": "创建用于管理 AstrBot 的账户",
|
||||
"username": "新用户名",
|
||||
"password": "新密码",
|
||||
"confirmPassword": "确认新密码",
|
||||
"passwordHint": "长度至少 8 位,且包含大写字母、小写字母和数字",
|
||||
"passwordHint": "长度至少 8 位,并包含大写字母、小写字母和数字。",
|
||||
"submit": "完成设置",
|
||||
"totp": {
|
||||
"code": "验证码",
|
||||
"qrAlt": "TOTP 二维码",
|
||||
"title": "完成 TOTP 配置",
|
||||
"subtitle": "使用认证器应用扫描二维码,以完成双因素认证配置。",
|
||||
"step2Hint": "使用认证器应用(如 Google Authenticator、Authy)扫描此二维码,然后输入验证码。",
|
||||
"verify": "验证并完成",
|
||||
"verifyError": "验证失败,请输入认证器应用中的最新验证码。",
|
||||
"disableError": "无法关闭 TOTP,请重试。",
|
||||
"back": "返回"
|
||||
},
|
||||
"validation": {
|
||||
"usernameRequired": "请输入用户名",
|
||||
"usernameMinLength": "用户名长度至少3位",
|
||||
"usernameMinLength": "用户名长度至少 3 位",
|
||||
"passwordRequired": "请输入密码",
|
||||
"passwordMinLength": "密码长度至少 8 位",
|
||||
"passwordUppercase": "密码必须包含至少一个大写字母",
|
||||
@@ -25,10 +53,11 @@
|
||||
},
|
||||
"logo": {
|
||||
"title": "AstrBot WebUI",
|
||||
"totpTitle": "两步验证",
|
||||
"subtitle": "欢迎使用"
|
||||
},
|
||||
"theme": {
|
||||
"switchToDark": "切换到深色主题",
|
||||
"switchToLight": "切换到浅色主题"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1086,6 +1086,24 @@
|
||||
"hint": "禁用后,AstrBot 将不再上传匿名使用统计数据。"
|
||||
},
|
||||
"dashboard": {
|
||||
"trust_proxy_headers": {
|
||||
"description": "信任代理请求头获取客户端 IP",
|
||||
"hint": "关闭时忽略 X-Forwarded-For/X-Real-IP,仅使用连接地址。"
|
||||
},
|
||||
"auth_rate_limit": {
|
||||
"enable": {
|
||||
"description": "启用登录验证速率限制",
|
||||
"hint": "关闭后将不对登录、TOTP 等身份验证接口进行速率限制。"
|
||||
},
|
||||
"average_interval": {
|
||||
"description": "验证端点速率限制平均间隔(秒)",
|
||||
"hint": "两次身份验证请求之间的最小平均间隔时间。例如设置为 1.0 表示每秒最多处理 1 个请求。"
|
||||
},
|
||||
"max_burst": {
|
||||
"description": "验证端点速率限制最大突发数",
|
||||
"hint": "允许的瞬时最大突发请求数。例如设置为 3 表示在短时间内最多连续处理 3 个请求。"
|
||||
}
|
||||
},
|
||||
"ssl": {
|
||||
"enable": {
|
||||
"description": "启用 WebUI HTTPS",
|
||||
@@ -1103,6 +1121,52 @@
|
||||
"description": "SSL CA 证书文件路径",
|
||||
"hint": "可选。用于指定 CA 证书文件路径。"
|
||||
}
|
||||
},
|
||||
"totp": {
|
||||
"enable": {
|
||||
"description": "启用 WebUI TOTP 双因素认证",
|
||||
"hint": "启用后,登录 WebUI 需要额外输入验证码。"
|
||||
},
|
||||
"manage": "管理",
|
||||
"configuration": "TOTP",
|
||||
"statusPending": "需完成设置",
|
||||
"statusEnabled": "已启用",
|
||||
"setupRequiredHint": "TOTP 已开启但尚未完成配置,请点击“管理”完成初始化。",
|
||||
"setupTitle": "设置 TOTP",
|
||||
"setupSubtitle": "请使用认证器应用扫描二维码,然后输入验证码。",
|
||||
"setupConfirm": "验证并继续",
|
||||
"activeSubtitle": "可使用此二维码和密钥添加新的认证器设备。",
|
||||
"rotateTitle": "更换 TOTP 密钥",
|
||||
"rotateSubtitle": "生成新密钥并完成验证后,将替换当前密钥。",
|
||||
"rotate": "更换密钥",
|
||||
"rotateRecovery": "更换恢复码",
|
||||
"rotateConfirm": "确认更换",
|
||||
"rotateCancel": "取消",
|
||||
"rotateCode": "验证码",
|
||||
"rotateCodeHint": "输入认证器应用中的验证码以确认新密钥。",
|
||||
"rotateError": "验证码无效,请重试。",
|
||||
"recoveryTitle": "恢复码",
|
||||
"recoverySubtitle": "恢复码仅展示一次,请在继续前妥善保存。",
|
||||
"recoveryWarning": "若恢复码丢失将无法通过常规途径恢复账户访问权限。",
|
||||
"recoveryAcknowledge": "我已保存恢复码",
|
||||
"recoveryClose": "完成",
|
||||
"disableTitle": "关闭 TOTP",
|
||||
"disableSubtitle": "输入验证码以确认关闭双因素认证。",
|
||||
"disableRecoverySubtitle": "输入恢复码以确认关闭双因素认证。",
|
||||
"disableCode": "验证码",
|
||||
"disableRecoveryCode": "恢复码",
|
||||
"disableConfirm": "确认关闭",
|
||||
"disableCancel": "取消",
|
||||
"disableError": "验证失败,请重试。",
|
||||
"disableUseRecovery": "无法使用TOTP?",
|
||||
"disableUseCode": "使用验证码",
|
||||
"configSaveTitle": "两步验证",
|
||||
"configSaveSubtitle": "输入验证码以更改受保护的配置。",
|
||||
"configSaveRotationHint": "轮换 TOTP 密钥时,轮换前和轮换后的验证码均可用(仅轮换操作中单次允许)。",
|
||||
"configSaveCode": "验证码",
|
||||
"configSaveConfirm": "继续",
|
||||
"configSaveCancel": "取消",
|
||||
"configSaveError": "验证失败,请重试。"
|
||||
}
|
||||
},
|
||||
"timezone": {
|
||||
@@ -1209,8 +1273,8 @@
|
||||
"name": "Top-p"
|
||||
},
|
||||
"max_tokens": {
|
||||
"description": "最大令牌数",
|
||||
"hint": "生成的最大令牌数。",
|
||||
"description": "最大词元(Tokens)数",
|
||||
"hint": "生成的最大词元(Tokens)数。",
|
||||
"name": "Max Tokens"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
"packageLabel": "*库名,如 llmtuner",
|
||||
"mirrorLabel": "强制 PyPI 软件仓库链接(可选)",
|
||||
"mirrorHint": "强制 PyPI 软件仓库链接 > 配置项 `PyPI 软件仓库地址`",
|
||||
"installButton": "安装"
|
||||
"installButton": "安装",
|
||||
"installSuccess": "安装成功。",
|
||||
"installFailed": "安装失败。",
|
||||
"requestFailed": "请求失败。"
|
||||
},
|
||||
"debugHint": {
|
||||
"text": "Debug 日志需要在「配置文件 → 系统 → 控制台日志级别」中开启"
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
"live": "实时同步"
|
||||
},
|
||||
"page": {
|
||||
"title": "未来任务管理",
|
||||
"title": "未来任务",
|
||||
"beta": "实验性",
|
||||
"subtitle": "AstrBot 可以被自动唤醒然后执行任务,并将结果告知任务布置方。需要先在配置文件中启用“主动型能力”。",
|
||||
"subtitle": "AstrBot 会在设定时间自动唤醒,完成任务后把结果投递回目标会话。",
|
||||
"proactive": {
|
||||
"link": "支持的消息平台",
|
||||
"supported": "主动发送结果仅支持以下您已配置的平台",
|
||||
"unsupported": "暂无支持主动消息的平台,请在平台设置中开启。"
|
||||
}
|
||||
@@ -18,9 +19,36 @@
|
||||
"refresh": "刷新",
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"close": "关闭",
|
||||
"more": "更多操作",
|
||||
"runNow": "立即执行",
|
||||
"save": "保存",
|
||||
"submit": "创建"
|
||||
},
|
||||
"platformDialog": {
|
||||
"title": "支持的消息平台",
|
||||
"description": "只有以下平台支持主动推送消息。未来任务完成后,AstrBot 才能把结果投递回这些平台的目标会话。"
|
||||
},
|
||||
"card": {
|
||||
"onceAt": "一次性 · {time}",
|
||||
"runAt": "执行时间:{time}",
|
||||
"nextRun": "下次执行:{time}",
|
||||
"dailyAt": "每天 {time}",
|
||||
"weeklyAt": "每周{day} {time}",
|
||||
"monthlyAt": "每月 {day} 日 {time}",
|
||||
"everyMinutes": "每隔 {count} 分钟",
|
||||
"everyHours": "每隔 {count} 小时",
|
||||
"everyDays": "每隔 {count} 天",
|
||||
"customCron": "自定义 · {cron}",
|
||||
"noDeliveryTarget": "未设置投递地"
|
||||
},
|
||||
"filters": {
|
||||
"search": "搜索任务名称和内容",
|
||||
"umo": "按投递地筛选",
|
||||
"noUmos": "暂无 UMO",
|
||||
"noDeliveryTarget": "未设置投递地",
|
||||
"noMatches": "没有匹配的未来任务。"
|
||||
},
|
||||
"overview": {
|
||||
"totalTasks": "任务总数",
|
||||
"totalTasksNote": "当前已注册的未来任务",
|
||||
@@ -82,12 +110,43 @@
|
||||
"editTitle": "编辑任务",
|
||||
"chatHint": "你可以直接通过聊天的方式来让 AstrBot 创建未来任务,而不必在此添加。",
|
||||
"runOnce": "一次性任务",
|
||||
"name": "任务名称",
|
||||
"note": "任务说明",
|
||||
"cron": "Cron 表达式",
|
||||
"name": "任务名称 *",
|
||||
"note": "任务需求 *",
|
||||
"scheduleMode": "执行时间 *",
|
||||
"scheduleModes": {
|
||||
"once": "一次性",
|
||||
"interval": "间隔",
|
||||
"daily": "每天",
|
||||
"weekly": "每周",
|
||||
"monthly": "每个月",
|
||||
"cron": "自定义"
|
||||
},
|
||||
"intervalEvery": "每隔 *",
|
||||
"intervalUnit": "单位 *",
|
||||
"intervalUnits": {
|
||||
"minutes": "分钟",
|
||||
"hours": "小时",
|
||||
"days": "天"
|
||||
},
|
||||
"dailyTime": "时间 *",
|
||||
"weeklyDay": "星期 *",
|
||||
"weeklyTime": "时间 *",
|
||||
"monthlyDay": "日期 *",
|
||||
"monthlyTime": "时间 *",
|
||||
"weekdays": {
|
||||
"sunday": "周日",
|
||||
"monday": "周一",
|
||||
"tuesday": "周二",
|
||||
"wednesday": "周三",
|
||||
"thursday": "周四",
|
||||
"friday": "周五",
|
||||
"saturday": "周六"
|
||||
},
|
||||
"cron": "Cron 表达式 *",
|
||||
"cronPlaceholder": "0 9 * * *",
|
||||
"runAt": "执行时间",
|
||||
"session": "目标 session (platform_id:message_type:session_id)",
|
||||
"runAt": "执行时间 *",
|
||||
"session": "投递到(可选)",
|
||||
"noUmos": "暂无可用会话",
|
||||
"timezone": "时区(可选,如 Asia/Shanghai)",
|
||||
"enabled": "启用"
|
||||
},
|
||||
@@ -97,11 +156,18 @@
|
||||
"updateFailed": "更新失败",
|
||||
"deleteSuccess": "已删除",
|
||||
"deleteFailed": "删除失败",
|
||||
"sessionRequired": "请填写 session",
|
||||
"noteRequired": "请填写说明",
|
||||
"nameRequired": "请填写任务名称",
|
||||
"sessionRequired": "请选择投递目标",
|
||||
"noteRequired": "请填写任务需求",
|
||||
"cronRequired": "请填写 Cron 表达式",
|
||||
"runAtRequired": "请选择执行时间",
|
||||
"intervalRequired": "请填写有效的间隔时间",
|
||||
"dailyTimeRequired": "请选择每天执行的时间",
|
||||
"weeklyTimeRequired": "请选择每周执行的星期和时间",
|
||||
"monthlyTimeRequired": "请选择每月执行的日期和时间",
|
||||
"createSuccess": "创建成功",
|
||||
"createFailed": "创建失败"
|
||||
"createFailed": "创建失败",
|
||||
"runStarted": "已开始执行",
|
||||
"runFailed": "执行失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,16 @@ axios.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
axios.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 429 && error.response?.data?.message) {
|
||||
return Promise.reject(error.response.data.message);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Keep fetch() calls consistent with axios by automatically attaching the JWT.
|
||||
// Some parts of the UI use fetch directly; without this, those requests will 401.
|
||||
const _origFetch = window.fetch.bind(window);
|
||||
|
||||
@@ -17,7 +17,12 @@ export const router = createRouter({
|
||||
interface AuthStore {
|
||||
username: string;
|
||||
returnUrl: string | null;
|
||||
login(username: string, password: string): Promise<void>;
|
||||
login(
|
||||
username: string,
|
||||
password: string,
|
||||
code?: string,
|
||||
trustDeviceToken?: boolean,
|
||||
): Promise<void | 'totp_required'>;
|
||||
logout(): void;
|
||||
has_token(): boolean;
|
||||
}
|
||||
@@ -41,7 +46,8 @@ router.beforeEach(async (to, from, next) => {
|
||||
if (authRequired && !auth.has_token()) {
|
||||
auth.returnUrl = to.fullPath;
|
||||
return next('/auth/login');
|
||||
} else next();
|
||||
}
|
||||
return next();
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export const useAuthStore = defineStore("auth", {
|
||||
state: () => ({
|
||||
// @ts-ignore
|
||||
username: '',
|
||||
returnUrl: null
|
||||
returnUrl: null,
|
||||
}),
|
||||
actions: {
|
||||
async finishAuthenticatedSession(data: any): Promise<void> {
|
||||
@@ -46,13 +46,26 @@ export const useAuthStore = defineStore("auth", {
|
||||
router.push('/welcome');
|
||||
}
|
||||
},
|
||||
async login(username: string, password: string): Promise<void> {
|
||||
async login(
|
||||
username: string,
|
||||
password: string,
|
||||
code?: string,
|
||||
trustDeviceToken = false,
|
||||
): Promise<'totp_required' | void> {
|
||||
try {
|
||||
const res = await axios.post('/api/auth/login', {
|
||||
username: username,
|
||||
password: password
|
||||
password: password,
|
||||
code: code,
|
||||
trust_device_flag: trustDeviceToken,
|
||||
}, {
|
||||
validateStatus: (status) => (status >= 200 && status < 300) || status === 401
|
||||
});
|
||||
|
||||
|
||||
if (res.status === 401 && res.data?.data?.totp_required) {
|
||||
return 'totp_required';
|
||||
}
|
||||
|
||||
if (res.data.status === 'error') {
|
||||
return Promise.reject(res.data.message);
|
||||
}
|
||||
@@ -62,13 +75,17 @@ export const useAuthStore = defineStore("auth", {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
async setup(username: string, password: string, confirmPassword: string): Promise<void> {
|
||||
async setup(
|
||||
username: string,
|
||||
password: string,
|
||||
confirmPassword: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const setupEndpoint = this.has_token() ? '/api/auth/setup-authenticated' : '/api/auth/setup';
|
||||
const res = await axios.post(setupEndpoint, {
|
||||
const endpoint = this.has_token() ? '/api/auth/setup-authenticated' : '/api/auth/setup';
|
||||
const res = await axios.post(endpoint, {
|
||||
username: username,
|
||||
password: password,
|
||||
confirm_password: confirmPassword
|
||||
confirm_password: confirmPassword,
|
||||
});
|
||||
|
||||
if (res.data.status === 'error') {
|
||||
|
||||
@@ -27,7 +27,7 @@ const PurpleTheme: ThemeTypes = {
|
||||
borderLight: '#d0d0d0',
|
||||
border: '#d0d0d0',
|
||||
inputBorder: '#787878',
|
||||
containerBg: '#f9fafcf4',
|
||||
containerBg: '#fffffff4',
|
||||
surface: '#fff',
|
||||
'on-surface-variant': '#fff',
|
||||
facebook: '#4267b2',
|
||||
|
||||
@@ -170,6 +170,15 @@
|
||||
{{ save_message }}
|
||||
</v-snackbar>
|
||||
|
||||
<DashboardTwoFactorDialog
|
||||
v-model="configSave2faDialogVisible"
|
||||
:error-message="configSave2faError"
|
||||
:saving="configSave2faSaving"
|
||||
:rotation-hint="configSave2faRotationHint"
|
||||
@confirm="handleConfigSave2faConfirm"
|
||||
@cancel="handleConfigSave2faCancel"
|
||||
/>
|
||||
|
||||
<WaitingForRestart ref="wfr"></WaitingForRestart>
|
||||
|
||||
<!-- 测试聊天抽屉 -->
|
||||
@@ -219,6 +228,7 @@ import {
|
||||
useConfirmDialog
|
||||
} from '@/utils/confirmDialog';
|
||||
import UnsavedChangesConfirmDialog from '@/components/config/UnsavedChangesConfirmDialog.vue';
|
||||
import DashboardTwoFactorDialog from '@/components/shared/DashboardTwoFactorDialog.vue';
|
||||
import { normalizeTextInput } from '@/utils/inputValue';
|
||||
|
||||
export default {
|
||||
@@ -228,7 +238,8 @@ export default {
|
||||
VueMonacoEditor,
|
||||
WaitingForRestart,
|
||||
StandaloneChat,
|
||||
UnsavedChangesConfirmDialog
|
||||
UnsavedChangesConfirmDialog,
|
||||
DashboardTwoFactorDialog
|
||||
},
|
||||
props: {
|
||||
initialConfigId: {
|
||||
@@ -239,11 +250,13 @@ export default {
|
||||
setup() {
|
||||
const { t } = useI18n();
|
||||
const { tm } = useModuleI18n('features/config');
|
||||
const { tm: tmMeta } = useModuleI18n('features/config-metadata');
|
||||
const confirmDialog = useConfirmDialog();
|
||||
|
||||
return {
|
||||
t,
|
||||
tm,
|
||||
tmMeta,
|
||||
confirmDialog
|
||||
};
|
||||
},
|
||||
@@ -373,8 +386,13 @@ export default {
|
||||
save_message_snack: false,
|
||||
save_message: "",
|
||||
save_message_success: "",
|
||||
configContentKey: 0,
|
||||
configContentKey: 0,
|
||||
lastSavedConfigSnapshot: '',
|
||||
configSave2faDialogVisible: false,
|
||||
configSave2faError: '',
|
||||
configSave2faSaving: false,
|
||||
configSave2faRotationHint: '',
|
||||
configSavePendingPostData: null,
|
||||
|
||||
// 配置类型切换
|
||||
configType: 'normal', // 'normal' 或 'system'
|
||||
@@ -527,7 +545,7 @@ export default {
|
||||
this.save_message_success = "error";
|
||||
});
|
||||
},
|
||||
updateConfig() {
|
||||
async updateConfig() {
|
||||
if (!this.fetched) return;
|
||||
|
||||
const postData = {
|
||||
@@ -540,8 +558,32 @@ export default {
|
||||
postData.conf_id = this.selectedConfigID;
|
||||
}
|
||||
|
||||
return axios.post('/api/config/astrbot/update', postData).then((res) => {
|
||||
return this.saveAstrbotConfig(postData);
|
||||
},
|
||||
async saveAstrbotConfig(postData, headers = {}, allow2faPrompt = true) {
|
||||
try {
|
||||
const res = await axios.post('/api/config/astrbot/update', postData, {
|
||||
headers,
|
||||
validateStatus: (status) => (status >= 200 && status < 300) || status === 401,
|
||||
});
|
||||
|
||||
if (res.status === 401 && res.data?.data?.totp_required) {
|
||||
if (allow2faPrompt && !headers['X-2FA-Code']) {
|
||||
this.configSavePendingPostData = JSON.parse(JSON.stringify(postData));
|
||||
this.configSave2faError = '';
|
||||
this.configSave2faRotationHint = this._getConfigSaveRotationHint(postData);
|
||||
this.configSave2faDialogVisible = true;
|
||||
return { success: false, requires2fa: true };
|
||||
}
|
||||
this.configSave2faError = this.tmMeta('system_group.system.dashboard.totp.configSaveError');
|
||||
this.configSave2faDialogVisible = true;
|
||||
return { success: false, requires2fa: true };
|
||||
}
|
||||
|
||||
if (res.data.status === "ok") {
|
||||
this.configSavePendingPostData = null;
|
||||
this.configSave2faDialogVisible = false;
|
||||
this.configSave2faError = '';
|
||||
this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
|
||||
this.save_message = res.data.message || this.messages.saveSuccess;
|
||||
this.save_message_snack = true;
|
||||
@@ -552,18 +594,62 @@ export default {
|
||||
restartAstrBotRuntime(this.$refs.wfr).catch(() => {})
|
||||
}
|
||||
return { success: true };
|
||||
} else {
|
||||
this.save_message = res.data.message || this.messages.saveError;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "error";
|
||||
return { success: false };
|
||||
}
|
||||
}).catch((err) => {
|
||||
|
||||
this.save_message = res.data.message || this.messages.saveError;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "error";
|
||||
return { success: false };
|
||||
} catch (err) {
|
||||
this.save_message = this.messages.saveError;
|
||||
this.save_message_snack = true;
|
||||
this.save_message_success = "error";
|
||||
return { success: false };
|
||||
});
|
||||
}
|
||||
},
|
||||
async handleConfigSave2faConfirm(payload) {
|
||||
if (!this.configSavePendingPostData || this.configSave2faSaving) {
|
||||
return;
|
||||
}
|
||||
this.configSave2faSaving = true;
|
||||
this.configSave2faError = '';
|
||||
const headers = {
|
||||
'X-2FA-Code': payload,
|
||||
};
|
||||
try {
|
||||
await this.saveAstrbotConfig(
|
||||
JSON.parse(JSON.stringify(this.configSavePendingPostData)),
|
||||
headers,
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
this.configSave2faSaving = false;
|
||||
}
|
||||
},
|
||||
handleConfigSave2faCancel() {
|
||||
if (this.lastSavedConfigSnapshot && this.config_data?.dashboard?.totp) {
|
||||
try {
|
||||
const savedConfig = JSON.parse(this.lastSavedConfigSnapshot);
|
||||
const savedTotp = savedConfig?.dashboard?.totp;
|
||||
if (savedTotp) {
|
||||
this.config_data.dashboard.totp.enable = savedTotp.enable;
|
||||
this.config_data.dashboard.totp.secret = savedTotp.secret;
|
||||
this.config_data.dashboard.totp.recovery_code_hash = savedTotp.recovery_code_hash;
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
this.configSavePendingPostData = null;
|
||||
this.configSave2faError = '';
|
||||
this.configSave2faDialogVisible = false;
|
||||
},
|
||||
_getConfigSaveRotationHint(postData) {
|
||||
const postedSecret = postData?.config?.dashboard?.totp?.secret;
|
||||
if (postedSecret && typeof postedSecret === 'string' && postedSecret.trim()) {
|
||||
return this.tmMeta('system_group.system.dashboard.totp.configSaveRotationHint');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
// 重置未保存状态
|
||||
onConfigSaved() {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import axios from 'axios';
|
||||
import { useToast } from '@/utils/toast';
|
||||
|
||||
const { tm } = useModuleI18n('features/console');
|
||||
</script>
|
||||
@@ -37,10 +38,6 @@ const { tm } = useModuleI18n('features/console');
|
||||
<v-text-field v-model="pipInstallPayload.package" :label="tm('pipInstall.packageLabel')" variant="outlined"></v-text-field>
|
||||
<v-text-field v-model="pipInstallPayload.mirror" :label="tm('pipInstall.mirrorLabel')" variant="outlined"></v-text-field>
|
||||
<small>{{ tm('pipInstall.mirrorHint') }}</small>
|
||||
<div>
|
||||
<small>{{ status }}</small>
|
||||
</div>
|
||||
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
@@ -69,8 +66,7 @@ export default {
|
||||
package: '',
|
||||
mirror: ''
|
||||
},
|
||||
loading: false,
|
||||
status: ''
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -88,17 +84,19 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
pipInstall() {
|
||||
const toast = useToast();
|
||||
this.loading = true;
|
||||
axios.post('/api/update/pip-install', this.pipInstallPayload)
|
||||
.then(res => {
|
||||
this.status = res.data.message;
|
||||
setTimeout(() => {
|
||||
this.status = '';
|
||||
if (res.data.status === 'ok') {
|
||||
toast.success(res.data.message || tm('pipInstall.installSuccess'));
|
||||
this.pipDialog = false;
|
||||
}, 2000);
|
||||
} else {
|
||||
toast.error(res.data.message || tm('pipInstall.installFailed'));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
this.status = err.response.data.message;
|
||||
toast.error(err.response?.data?.message || tm('pipInstall.requestFailed'));
|
||||
}).finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AuthLogin from '../authForms/AuthLogin.vue';
|
||||
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
@@ -15,6 +15,14 @@ const authStore = useAuthStore();
|
||||
const customizer = useCustomizerStore();
|
||||
const { tm: t } = useModuleI18n('features/auth');
|
||||
const theme = useTheme();
|
||||
const authLoginRef = ref<InstanceType<typeof AuthLogin> | null>(null);
|
||||
|
||||
const logoTitle = computed(() => {
|
||||
if (authLoginRef.value?.stage === 'totp' || authLoginRef.value?.stage === 'recovery') {
|
||||
return t('logo.totpTitle');
|
||||
}
|
||||
return t('logo.title');
|
||||
});
|
||||
|
||||
// 主题切换函数
|
||||
function toggleTheme() {
|
||||
@@ -75,11 +83,11 @@ onMounted(async () => {
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2" style="font-size: 26px;">{{ t('logo.title') }}</div>
|
||||
<div class="mt-2 ml-2" style="font-size: 14px; color: grey;">{{ t('logo.subtitle') }}</div>
|
||||
<div class="ml-2" style="font-size: 26px;">{{ logoTitle }}</div>
|
||||
<div v-if="authLoginRef?.stage !== 'totp' && authLoginRef?.stage !== 'recovery'" class="mt-2 ml-2" style="font-size: 14px; color: grey;">{{ t('logo.subtitle') }}</div>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<AuthLogin />
|
||||
<AuthLogin ref="authLoginRef" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
@@ -1,60 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useCssModule } from 'vue';
|
||||
import { ref } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { Form } from 'vee-validate';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import AuthStageAccount from './stages/AuthStageAccount.vue';
|
||||
import AuthStageTotp from './stages/AuthStageTotp.vue';
|
||||
import AuthStageRecovery from './stages/AuthStageRecovery.vue';
|
||||
|
||||
const { tm: t } = useModuleI18n('features/auth');
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const valid = ref(false);
|
||||
const show1 = ref(false);
|
||||
const password = ref('');
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
const totpCode = ref('');
|
||||
const trustTotpDevice = ref(false);
|
||||
const recoveryCode = ref('');
|
||||
const loading = ref(false);
|
||||
const apiError = ref('');
|
||||
const stage = ref<'account' | 'totp' | 'recovery'>('account');
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
async function validate(values: any, { setErrors }: any) {
|
||||
loading.value = true;
|
||||
|
||||
const authStore = useAuthStore();
|
||||
// @ts-ignore
|
||||
authStore.returnUrl = new URLSearchParams(window.location.search).get('redirect');
|
||||
return authStore.login(username.value, password.value).then((res) => {
|
||||
console.log(res);
|
||||
loading.value = false;
|
||||
}).catch((err) => {
|
||||
setErrors({ apiError: err });
|
||||
loading.value = false;
|
||||
});
|
||||
function resetTotpStage() {
|
||||
totpCode.value = '';
|
||||
trustTotpDevice.value = false;
|
||||
}
|
||||
|
||||
function goToAccountStage() {
|
||||
stage.value = 'account';
|
||||
apiError.value = '';
|
||||
resetTotpStage();
|
||||
}
|
||||
|
||||
function goToTotpStage() {
|
||||
stage.value = 'totp';
|
||||
apiError.value = '';
|
||||
}
|
||||
|
||||
function goToRecoveryStage() {
|
||||
stage.value = 'recovery';
|
||||
apiError.value = '';
|
||||
recoveryCode.value = '';
|
||||
}
|
||||
|
||||
async function submitAccountStage() {
|
||||
if (!username.value || !password.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
apiError.value = '';
|
||||
try {
|
||||
// @ts-ignore
|
||||
authStore.returnUrl = new URLSearchParams(window.location.search).get('redirect');
|
||||
const res = await authStore.login(username.value, password.value);
|
||||
if (res === 'totp_required') {
|
||||
goToTotpStage();
|
||||
}
|
||||
} catch (err) {
|
||||
apiError.value = String(err || '') || 'Login failed';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTotpStage() {
|
||||
if (!totpCode.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
apiError.value = '';
|
||||
try {
|
||||
await authStore.login(
|
||||
username.value,
|
||||
password.value,
|
||||
totpCode.value,
|
||||
trustTotpDevice.value,
|
||||
);
|
||||
} catch (err) {
|
||||
apiError.value = String(err || '') || 'Verification failed';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ stage });
|
||||
|
||||
async function submitRecoveryStage() {
|
||||
if (!recoveryCode.value) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
apiError.value = '';
|
||||
try {
|
||||
await authStore.login(username.value, password.value, recoveryCode.value);
|
||||
} catch (err) {
|
||||
apiError.value = String(err || '') || 'Recovery login failed';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form @submit="validate" class="mt-4 login-form" v-slot="{ errors, isSubmitting }">
|
||||
<v-text-field v-model="username" :label="t('username')" class="mb-6 input-field" required hide-details="auto"
|
||||
variant="outlined" prepend-inner-icon="mdi-account" :disabled="loading"></v-text-field>
|
||||
<div class="mt-4 login-form">
|
||||
<AuthStageAccount
|
||||
v-if="stage === 'account'"
|
||||
:username="username"
|
||||
:password="password"
|
||||
:loading="loading"
|
||||
@update:username="(value) => (username = value)"
|
||||
@update:password="(value) => (password = value)"
|
||||
@submit="submitAccountStage"
|
||||
/>
|
||||
|
||||
<v-text-field v-model="password" :label="t('password')" required variant="outlined" hide-details="auto"
|
||||
:append-inner-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'" :type="show1 ? 'text' : 'password'"
|
||||
@click:append-inner="show1 = !show1" class="pwd-input" prepend-inner-icon="mdi-lock" :disabled="loading"></v-text-field>
|
||||
<AuthStageTotp
|
||||
v-else-if="stage === 'totp'"
|
||||
:username="username"
|
||||
:code="totpCode"
|
||||
:trust-device="trustTotpDevice"
|
||||
:loading="loading"
|
||||
@update:code="(value) => (totpCode = value)"
|
||||
@update:trust-device="(value) => (trustTotpDevice = value)"
|
||||
@submit="submitTotpStage"
|
||||
@back="goToAccountStage"
|
||||
@use-recovery="goToRecoveryStage"
|
||||
/>
|
||||
|
||||
<div class="mt-2">
|
||||
<small style="color: grey;">{{ t('defaultHint') }}</small>
|
||||
</div>
|
||||
<AuthStageRecovery
|
||||
v-else
|
||||
:code="recoveryCode"
|
||||
:loading="loading"
|
||||
@update:code="(value) => (recoveryCode = value)"
|
||||
@submit="submitRecoveryStage"
|
||||
@back="goToTotpStage"
|
||||
/>
|
||||
|
||||
|
||||
<v-btn color="secondary" :loading="isSubmitting || loading" block class="login-btn mt-8" variant="flat" size="large"
|
||||
:disabled="valid" type="submit">
|
||||
<span class="login-btn-text">{{ t('login') }}</span>
|
||||
</v-btn>
|
||||
|
||||
<div v-if="errors.apiError" class="mt-4 error-container">
|
||||
<div v-if="apiError" class="mt-4 error-container">
|
||||
<v-alert color="error" variant="tonal" icon="mdi-alert-circle" border="start">
|
||||
{{ errors.apiError }}
|
||||
{{ apiError }}
|
||||
</v-alert>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -122,19 +205,24 @@ async function validate(values: any, { setErrors }: any) {
|
||||
}
|
||||
}
|
||||
|
||||
.hint-text {
|
||||
color: var(--v-theme-secondaryText);
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
.v-alert {
|
||||
border-left-width: 4px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-divider {
|
||||
border-color: rgba(0, 0, 0, 0.08) !important;
|
||||
.account-stage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0 4px;
|
||||
}
|
||||
|
||||
.account-stage-user {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: rgba(var(--v-theme-on-surface), 0.85);
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
const { tm: t } = useModuleI18n('features/auth');
|
||||
|
||||
const props = defineProps<{
|
||||
username: string;
|
||||
password: string;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:username', value: string): void;
|
||||
(e: 'update:password', value: string): void;
|
||||
(e: 'submit'): void;
|
||||
}>();
|
||||
|
||||
const showPassword = ref(false);
|
||||
|
||||
function onSubmit() {
|
||||
emit('submit');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-text-field
|
||||
:model-value="props.username"
|
||||
:label="t('username')"
|
||||
class="mb-6 input-field"
|
||||
required
|
||||
hide-details="auto"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-account"
|
||||
:disabled="props.loading"
|
||||
@update:model-value="(value: string) => emit('update:username', value)"
|
||||
@keyup.enter="onSubmit"
|
||||
></v-text-field>
|
||||
|
||||
<v-text-field
|
||||
:model-value="props.password"
|
||||
:label="t('password')"
|
||||
required
|
||||
variant="outlined"
|
||||
hide-details="auto"
|
||||
:append-inner-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
@click:append-inner="showPassword = !showPassword"
|
||||
class="pwd-input"
|
||||
prepend-inner-icon="mdi-lock"
|
||||
:disabled="props.loading"
|
||||
@update:model-value="(value: string) => emit('update:password', value)"
|
||||
@keyup.enter="onSubmit"
|
||||
></v-text-field>
|
||||
|
||||
<div class="mt-2">
|
||||
<small style="color: grey;">{{ t('defaultHint') }}</small>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
color="secondary"
|
||||
block
|
||||
class="login-btn mt-8"
|
||||
variant="flat"
|
||||
size="large"
|
||||
:loading="props.loading"
|
||||
:disabled="props.loading || !props.username || !props.password"
|
||||
@click="onSubmit"
|
||||
>
|
||||
<span class="login-btn-text">{{ t('login') }}</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
const { tm: t } = useModuleI18n('features/auth');
|
||||
|
||||
const props = defineProps<{
|
||||
code: string;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:code', value: string): void;
|
||||
(e: 'submit'): void;
|
||||
(e: 'back'): void;
|
||||
}>();
|
||||
|
||||
function normalizeRecoveryCode(code: string): string {
|
||||
return code.toUpperCase().replace(/[^A-Z2-7]/g, '').slice(0, 32);
|
||||
}
|
||||
|
||||
function formatRecoveryCode(code: string): string {
|
||||
const normalized = normalizeRecoveryCode(code);
|
||||
const groups = normalized.match(/.{1,8}/g);
|
||||
return groups ? groups.join('-') : '';
|
||||
}
|
||||
|
||||
function onCodeInput(raw: string) {
|
||||
emit('update:code', formatRecoveryCode(raw));
|
||||
}
|
||||
|
||||
function isRecoveryCodeComplete(code: string): boolean {
|
||||
return normalizeRecoveryCode(code).length === 32;
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
emit('submit');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="account-stage-header">
|
||||
<div class="account-stage-user">{{ t('recovery.title') }}</div>
|
||||
<v-btn variant="text" size="small" color="primary" :disabled="props.loading" @click="emit('back')">
|
||||
{{ t('setup.totp.back') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="recovery-code-section mt-2">
|
||||
<v-alert color="warning" variant="tonal" icon="mdi-alert" class="mb-4" density="compact">
|
||||
{{ t('recovery.totpDisableWarning') }}
|
||||
</v-alert>
|
||||
|
||||
<v-text-field
|
||||
:model-value="props.code"
|
||||
:label="t('recovery.code')"
|
||||
class="mt-6 mb-2 input-field"
|
||||
required
|
||||
hide-details="auto"
|
||||
variant="outlined"
|
||||
:disabled="props.loading"
|
||||
maxlength="35"
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
@update:model-value="(value: string) => onCodeInput(value)"
|
||||
@keyup.enter="onSubmit"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
color="secondary"
|
||||
block
|
||||
class="login-btn mt-4"
|
||||
variant="flat"
|
||||
size="large"
|
||||
:loading="props.loading"
|
||||
:disabled="props.loading || !isRecoveryCodeComplete(props.code)"
|
||||
@click="onSubmit"
|
||||
>
|
||||
<span class="login-btn-text">{{ t('recovery.submit') }}</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
|
||||
const { tm: t } = useModuleI18n('features/auth');
|
||||
|
||||
const props = defineProps<{
|
||||
username: string;
|
||||
code: string;
|
||||
trustDevice: boolean;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:code', value: string): void;
|
||||
(e: 'update:trustDevice', value: boolean): void;
|
||||
(e: 'submit'): void;
|
||||
(e: 'back'): void;
|
||||
(e: 'useRecovery'): void;
|
||||
}>();
|
||||
|
||||
function onSubmit() {
|
||||
emit('submit');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="account-stage-header">
|
||||
<div class="account-stage-user">{{ props.username }}</div>
|
||||
<v-btn variant="text" size="small" color="primary" :disabled="props.loading" @click="emit('back')">
|
||||
{{ t('setup.totp.back') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
:model-value="props.code"
|
||||
:label="t('totp.code')"
|
||||
class="mt-6 mb-2 input-field"
|
||||
required
|
||||
hide-details="auto"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
:disabled="props.loading"
|
||||
inputmode="numeric"
|
||||
@update:model-value="(value: string) => emit('update:code', value)"
|
||||
@keyup.enter="onSubmit"
|
||||
></v-text-field>
|
||||
|
||||
<div class="totp-actions mt-1">
|
||||
<v-checkbox
|
||||
:model-value="props.trustDevice"
|
||||
:label="t('totp.trustDevice')"
|
||||
color="secondary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
@update:model-value="(value: boolean | null) => emit('update:trustDevice', !!value)"
|
||||
></v-checkbox>
|
||||
|
||||
<v-btn variant="text" size="small" color="primary" :disabled="props.loading" @click="emit('useRecovery')">
|
||||
{{ t('recovery.useRecoveryCode') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
color="secondary"
|
||||
block
|
||||
class="login-btn mt-8"
|
||||
variant="flat"
|
||||
size="large"
|
||||
:loading="props.loading"
|
||||
:disabled="props.loading || !props.code"
|
||||
@click="onSubmit"
|
||||
>
|
||||
<span class="login-btn-text">{{ t('totp.verify') }}</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.totp-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -146,7 +146,14 @@ Plugin developers can add a template-style configuration to `_conf_schema` in th
|
||||
"template_1": {
|
||||
"name": "Template One",
|
||||
"hint":"hint",
|
||||
"display_item": "attr_name",
|
||||
"hide_hint_in_list": true,
|
||||
"items": {
|
||||
"attr_name": {
|
||||
"description": "Attribute Name",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"attr_a": {
|
||||
"description": "Attribute A",
|
||||
"type": "int",
|
||||
@@ -187,6 +194,7 @@ Saved config example:
|
||||
"field_id": [
|
||||
{
|
||||
"__template_key": "template_1",
|
||||
"attr_name": "",
|
||||
"attr_a": 10,
|
||||
"attr_b": true
|
||||
},
|
||||
@@ -198,6 +206,11 @@ Saved config example:
|
||||
]
|
||||
```
|
||||
|
||||
Templates also support these optional fields:
|
||||
|
||||
- `display_item`: Specifies the key of a `string` item inside the template `items`. When set, the WebUI shows that field's current value in the collapsed list of added template entries, for example `Attribute Name: my-adapter`, making it easier to distinguish multiple entries created from the same template. Dot paths are supported for fields inside nested objects, for example `meta.name`.
|
||||
- `hide_hint_in_list`: When set to `true`, the WebUI hides the template `hint` in the collapsed list of added template entries. The template selection dropdown still shows the `hint`, and hints for fields inside the expanded entry are not affected.
|
||||
|
||||
<img width="1000" alt="image" src="https://github.com/user-attachments/assets/74876d30-11a4-491b-a7a0-8ebe8d603782" />
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,31 @@ After starting AstrBot, you can access the admin panel by visiting `http://local
|
||||
|
||||
For first-time login, AstrBot generates a random initial password and prints it in startup logs. Please read the startup log line containing the WebUI credential and use that password to log in (username is usually `astrbot`).
|
||||
|
||||
## Two-Factor Authentication
|
||||
|
||||
AstrBot WebUI supports TOTP (Time-based One-Time Password) based two-factor authentication.
|
||||
|
||||
### Enabling Two-Factor Authentication
|
||||
|
||||
1. In the left menu, click Config → System Config.
|
||||
2. Toggle on "Enable WebUI TOTP"; a setup dialog with a QR code will appear.
|
||||
3. Scan the QR code using any TOTP-compatible authenticator app (e.g., Google Authenticator, etc.).
|
||||
4. Enter the 6-digit verification code generated by the authenticator app to complete the verification.
|
||||
5. The system will generate recovery codes for logging in if the authenticator device is lost. Be sure to save them securely.
|
||||
|
||||
To replace the TOTP secret, you can do so in the TOTP management window, where you will need to verify the current TOTP code first.
|
||||
|
||||
### Recovery Codes
|
||||
|
||||
- Each recovery code can be used only once. After logging in with a recovery code, two-factor authentication will be automatically disabled, and you will need to set it up again.
|
||||
- Recovery codes can be regenerated in the TOTP management window.
|
||||
- If you lose the recovery codes, you will not be able to regain account access through normal means. You will need to manually edit `data/cmd_config.json`, set `dashboard.totp.enable` to `false`, and manually clear `dashboard.totp.secret` and `dashboard.totp.recovery_code_hash` to disable two-factor authentication.
|
||||
|
||||
### Security Related
|
||||
|
||||
- Once two-factor authentication is enabled, modifying TOTP-related configurations requires verifying the current TOTP code to prevent unauthorized changes.
|
||||
- Changing the admin panel password will revoke all trusted devices.
|
||||
|
||||
## ChatUI
|
||||
|
||||
AstrBot includes a built-in ChatUI for talking to configured models directly in your browser.
|
||||
|
||||
@@ -122,8 +122,8 @@ AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户
|
||||
},
|
||||
"max_tokens": {
|
||||
"name": "Max Tokens",
|
||||
"description": "最大令牌数",
|
||||
"hint": "生成的最大令牌数。",
|
||||
"description": "最大词元(Tokens)数",
|
||||
"hint": "生成的最大词元(Tokens)数。",
|
||||
"type": "int",
|
||||
"default": 8192,
|
||||
},
|
||||
@@ -146,7 +146,14 @@ AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户
|
||||
"template_1": {
|
||||
"name": "Template One",
|
||||
"hint":"hint",
|
||||
"display_item": "attr_name",
|
||||
"hide_hint_in_list": true,
|
||||
"items": {
|
||||
"attr_name": {
|
||||
"description": "Attribute Name",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"attr_a": {
|
||||
"description": "Attribute A",
|
||||
"type": "int",
|
||||
@@ -187,6 +194,7 @@ AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户
|
||||
"field_id": [
|
||||
{
|
||||
"__template_key": "template_1",
|
||||
"attr_name": "",
|
||||
"attr_a": 10,
|
||||
"attr_b": true
|
||||
},
|
||||
@@ -198,6 +206,11 @@ AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户
|
||||
]
|
||||
```
|
||||
|
||||
模板本身还支持以下可选字段:
|
||||
|
||||
- `display_item`: 指定模板 `items` 中一个 `string` 类型字段的 key。设置后,WebUI 会在已添加模板条目的折叠列表中显示该字段当前值,例如 `Attribute Name: my-adapter`,便于添加多个同类型模板时快速区分。支持用点号选择嵌套 object 中的字段,例如 `meta.name`。
|
||||
- `hide_hint_in_list`: 设置为 `true` 时,WebUI 会在已添加模板条目的折叠列表中隐藏该模板的 `hint`。添加模板时的下拉菜单仍会显示 `hint`,展开条目后各配置项自己的 `hint` 也不受影响。
|
||||
|
||||
<img width="1000" alt="image" src="https://github.com/user-attachments/assets/74876d30-11a4-491b-a7a0-8ebe8d603782" />
|
||||
|
||||
## 在插件中使用配置
|
||||
|
||||
@@ -15,6 +15,31 @@ AstrBot 管理面板具有管理插件、查看日志、可视化配置、查看
|
||||
|
||||
新用户首次登录时,AstrBot 会生成一个随机初始密码并写入启动日志。请先在启动日志中查找并使用该密码登录(用户名通常为 `astrbot`),登录后请立即修改密码。
|
||||
|
||||
## 双因素认证
|
||||
|
||||
AstrBot WebUI支持基于 TOTP(Time-based One-Time Password)的双因素认证。
|
||||
|
||||
### 开启双因素认证
|
||||
|
||||
1. 在左侧菜单中依次点击 配置文件 → 系统配置。
|
||||
2. 打开“启用 WebUI TOTP 双因素认证”开关,WebUI将显示二维码。
|
||||
3. 使用任意支持 TOTP 的验证器应用(如 Google Authenticator 等)扫描二维码。
|
||||
4. 输入验证器应用生成的 6 位验证码完成验证。
|
||||
5. 系统会生成恢复码,用于丢失验证器设备后的登录。请务必妥善保存。
|
||||
|
||||
如需更换 TOTP 密钥,可在 TOTP 管理弹窗中操作,更换时需要先验证当前的 TOTP 验证码。
|
||||
|
||||
### 恢复码相关
|
||||
|
||||
- 恢复码仅可使用一次。使用恢复码登录后,双因素认证会被自动关闭,需要重新设置。
|
||||
- 在 TOTP 管理弹窗中可以重新生成恢复码。
|
||||
- 若恢复码丢失,将无法通过常规途径恢复账户访问权限,需要手动编辑 `data/cmd_config.json`,将 `dashboard.totp.enable` 设为 `false` 并手动清空 `dashboard.totp.secret` 和 `dashboard.totp.recovery_code_hash` 以关闭双因素认证。
|
||||
|
||||
### 安全性相关
|
||||
|
||||
- 启用双因素认证后,修改 TOTP 相关配置时需要先验证当前的 TOTP 验证码,以防止未经授权的修改。
|
||||
- 修改管理面板密码会撤销所有受信任设备。
|
||||
|
||||
## ChatUI
|
||||
|
||||
AstrBot 内置 ChatUI,可在浏览器中直接与已配置的模型对话。
|
||||
|
||||
@@ -65,6 +65,7 @@ dependencies = [
|
||||
"pysocks>=1.7.1",
|
||||
"packaging>=24.2",
|
||||
"python-ripgrep==0.0.8",
|
||||
"pyotp>=2.9.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -54,3 +54,4 @@ shipyard-neo-sdk>=0.2.0
|
||||
packaging>=24.2
|
||||
qrcode>=8.2
|
||||
python-ripgrep==0.0.8
|
||||
pyotp>=2.9.0
|
||||
|
||||
@@ -12,6 +12,7 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, urlsplit, urlunsplit
|
||||
|
||||
import pyotp
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from quart import Quart, jsonify
|
||||
@@ -28,6 +29,10 @@ from astrbot.core.utils.auth_password import (
|
||||
verify_dashboard_password,
|
||||
)
|
||||
from astrbot.core.utils.pip_installer import PipInstallError
|
||||
from astrbot.core.utils.totp import (
|
||||
TOTP_TRUSTED_DEVICE_COOKIE_NAME,
|
||||
generate_recovery_code,
|
||||
)
|
||||
from astrbot.dashboard.password_state import (
|
||||
get_dashboard_password_hash,
|
||||
is_password_change_required,
|
||||
@@ -202,6 +207,7 @@ def app(core_lifecycle_td: AstrBotCoreLifecycle):
|
||||
shutdown_event = asyncio.Event()
|
||||
# The db instance is already part of the core_lifecycle_td
|
||||
server = AstrBotDashboard(core_lifecycle_td, core_lifecycle_td.db, shutdown_event)
|
||||
server.app._dashboard_server = server # expose for test cleanup
|
||||
return server.app
|
||||
|
||||
|
||||
@@ -356,6 +362,527 @@ async def test_auth_login_secure_cookie_override(
|
||||
assert "SameSite=Strict" in jwt_cookie_header
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_rate_limit_uses_same_bucket_across_paths(
|
||||
app: Quart,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Same client IP shares a rate-limit bucket across different auth endpoints."""
|
||||
monkeypatch.setenv("ASTRBOT_TEST_MODE", "false")
|
||||
app._dashboard_server._rate_limiter_registry.clear()
|
||||
cfg = core_lifecycle_td.astrbot_config["dashboard"]
|
||||
rl_original = cfg.get("auth_rate_limit", {})
|
||||
tp_original = cfg.get("trust_proxy_headers", False)
|
||||
cfg["auth_rate_limit"] = {"enable": True, "average_interval": 3600.0, "max_burst": 1}
|
||||
cfg["trust_proxy_headers"] = True
|
||||
|
||||
try:
|
||||
client = app.test_client()
|
||||
h = {"X-Forwarded-For": "198.51.100.10"}
|
||||
r1 = await client.post(
|
||||
"/api/auth/login", json={"username": "u", "password": "p"}, headers=h
|
||||
)
|
||||
assert r1.status_code != 429, "first request from IP should not be rate limited"
|
||||
|
||||
r2 = await client.post("/api/auth/totp/setup", json={}, headers=h)
|
||||
assert r2.status_code == 429, (
|
||||
"second request from same IP should be rate limited"
|
||||
)
|
||||
finally:
|
||||
cfg["auth_rate_limit"] = rl_original
|
||||
cfg["trust_proxy_headers"] = tp_original
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_rate_limit_separates_different_client_ips(
|
||||
app: Quart,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Different client IPs have independent rate-limit buckets."""
|
||||
monkeypatch.setenv("ASTRBOT_TEST_MODE", "false")
|
||||
app._dashboard_server._rate_limiter_registry.clear()
|
||||
cfg = core_lifecycle_td.astrbot_config["dashboard"]
|
||||
rl_original = cfg.get("auth_rate_limit", {})
|
||||
tp_original = cfg.get("trust_proxy_headers", False)
|
||||
cfg["auth_rate_limit"] = {"enable": True, "average_interval": 3600.0, "max_burst": 1}
|
||||
cfg["trust_proxy_headers"] = True
|
||||
|
||||
try:
|
||||
client = app.test_client()
|
||||
r_a = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "u", "password": "p"},
|
||||
headers={"X-Forwarded-For": "198.51.100.10"},
|
||||
)
|
||||
assert r_a.status_code != 429
|
||||
|
||||
r_b = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "u", "password": "p"},
|
||||
headers={"X-Forwarded-For": "198.51.100.10"},
|
||||
)
|
||||
assert r_b.status_code == 429, (
|
||||
"second request from same IP should be rate limited"
|
||||
)
|
||||
|
||||
r_c = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "u", "password": "p"},
|
||||
headers={"X-Forwarded-For": "198.51.100.11"},
|
||||
)
|
||||
assert r_c.status_code != 429, "different IP has its own bucket"
|
||||
finally:
|
||||
cfg["auth_rate_limit"] = rl_original
|
||||
cfg["trust_proxy_headers"] = tp_original
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_rate_limit_ignores_proxy_headers_by_default(
|
||||
app: Quart,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""When trust_proxy_headers is False, all proxy-spoofed IPs fall back to the connection IP."""
|
||||
monkeypatch.setenv("ASTRBOT_TEST_MODE", "false")
|
||||
app._dashboard_server._rate_limiter_registry.clear()
|
||||
cfg = core_lifecycle_td.astrbot_config["dashboard"]
|
||||
rl_original = cfg.get("auth_rate_limit", {})
|
||||
tp_original = cfg.get("trust_proxy_headers", False)
|
||||
cfg["auth_rate_limit"] = {"enable": True, "average_interval": 3600.0, "max_burst": 1}
|
||||
cfg["trust_proxy_headers"] = False
|
||||
|
||||
try:
|
||||
client = app.test_client()
|
||||
r1 = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "u", "password": "p"},
|
||||
headers={"X-Forwarded-For": "198.51.100.20"},
|
||||
)
|
||||
assert r1.status_code != 429
|
||||
|
||||
r2 = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "u", "password": "p"},
|
||||
headers={"X-Forwarded-For": "198.51.100.21"},
|
||||
)
|
||||
assert r2.status_code == 429, (
|
||||
"same connection IP, same bucket despite proxy headers"
|
||||
)
|
||||
finally:
|
||||
cfg["auth_rate_limit"] = rl_original
|
||||
cfg["trust_proxy_headers"] = tp_original
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_login_requires_totp_when_enabled_and_not_trusted(
|
||||
app: Quart,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
original_dashboard_config = copy.deepcopy(
|
||||
core_lifecycle_td.astrbot_config["dashboard"]
|
||||
)
|
||||
test_client = app.test_client()
|
||||
_, recovery_code_hash = generate_recovery_code()
|
||||
secret = pyotp.random_base32()
|
||||
|
||||
try:
|
||||
core_lifecycle_td.astrbot_config["dashboard"]["totp"] = {
|
||||
"enable": True,
|
||||
"secret": secret,
|
||||
"recovery_code_hash": recovery_code_hash,
|
||||
}
|
||||
response = await test_client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"username": core_lifecycle_td.astrbot_config["dashboard"]["username"],
|
||||
"password": _resolve_dashboard_password(core_lifecycle_td),
|
||||
},
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert response.status_code == 401
|
||||
assert data["status"] == "error"
|
||||
assert data["data"]["totp_required"] is True
|
||||
finally:
|
||||
await _restore_dashboard_password_state(
|
||||
core_lifecycle_td,
|
||||
original_dashboard_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_login_accepts_valid_totp_code(
|
||||
app: Quart,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
original_dashboard_config = copy.deepcopy(
|
||||
core_lifecycle_td.astrbot_config["dashboard"]
|
||||
)
|
||||
test_client = app.test_client()
|
||||
_, recovery_code_hash = generate_recovery_code()
|
||||
secret = pyotp.random_base32()
|
||||
|
||||
try:
|
||||
core_lifecycle_td.astrbot_config["dashboard"]["totp"] = {
|
||||
"enable": True,
|
||||
"secret": secret,
|
||||
"recovery_code_hash": recovery_code_hash,
|
||||
}
|
||||
response = await test_client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"username": core_lifecycle_td.astrbot_config["dashboard"]["username"],
|
||||
"password": _resolve_dashboard_password(core_lifecycle_td),
|
||||
"code": pyotp.TOTP(secret).now(),
|
||||
},
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert "token" in data["data"]
|
||||
finally:
|
||||
await _restore_dashboard_password_state(
|
||||
core_lifecycle_td,
|
||||
original_dashboard_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_login_rejects_invalid_totp_code(
|
||||
app: Quart,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
original_dashboard_config = copy.deepcopy(
|
||||
core_lifecycle_td.astrbot_config["dashboard"]
|
||||
)
|
||||
test_client = app.test_client()
|
||||
_, recovery_code_hash = generate_recovery_code()
|
||||
secret = pyotp.random_base32()
|
||||
|
||||
try:
|
||||
core_lifecycle_td.astrbot_config["dashboard"]["totp"] = {
|
||||
"enable": True,
|
||||
"secret": secret,
|
||||
"recovery_code_hash": recovery_code_hash,
|
||||
}
|
||||
valid_code = pyotp.TOTP(secret).now()
|
||||
invalid_code = str((int(valid_code) + 1) % 1_000_000).zfill(6)
|
||||
response = await test_client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"username": core_lifecycle_td.astrbot_config["dashboard"]["username"],
|
||||
"password": _resolve_dashboard_password(core_lifecycle_td),
|
||||
"code": invalid_code,
|
||||
},
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert response.status_code == 401
|
||||
assert data["status"] == "error"
|
||||
finally:
|
||||
await _restore_dashboard_password_state(
|
||||
core_lifecycle_td,
|
||||
original_dashboard_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_login_with_recovery_code_disables_totp(
|
||||
app: Quart,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
original_dashboard_config = copy.deepcopy(
|
||||
core_lifecycle_td.astrbot_config["dashboard"]
|
||||
)
|
||||
test_client = app.test_client()
|
||||
recovery_code, recovery_code_hash = generate_recovery_code()
|
||||
secret = pyotp.random_base32()
|
||||
|
||||
try:
|
||||
core_lifecycle_td.astrbot_config["dashboard"]["totp"] = {
|
||||
"enable": True,
|
||||
"secret": secret,
|
||||
"recovery_code_hash": recovery_code_hash,
|
||||
}
|
||||
response = await test_client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"username": core_lifecycle_td.astrbot_config["dashboard"]["username"],
|
||||
"password": _resolve_dashboard_password(core_lifecycle_td),
|
||||
"code": recovery_code,
|
||||
},
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert core_lifecycle_td.astrbot_config["dashboard"]["totp"] == {
|
||||
"enable": False,
|
||||
"secret": "",
|
||||
"recovery_code_hash": "",
|
||||
}
|
||||
finally:
|
||||
await _restore_dashboard_password_state(
|
||||
core_lifecycle_td,
|
||||
original_dashboard_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_login_sets_trusted_device_cookie_when_flag_true(
|
||||
app: Quart,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
original_dashboard_config = copy.deepcopy(
|
||||
core_lifecycle_td.astrbot_config["dashboard"]
|
||||
)
|
||||
test_client = app.test_client()
|
||||
_, recovery_code_hash = generate_recovery_code()
|
||||
secret = pyotp.random_base32()
|
||||
|
||||
try:
|
||||
core_lifecycle_td.astrbot_config["dashboard"]["totp"] = {
|
||||
"enable": True,
|
||||
"secret": secret,
|
||||
"recovery_code_hash": recovery_code_hash,
|
||||
}
|
||||
response = await test_client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"username": core_lifecycle_td.astrbot_config["dashboard"]["username"],
|
||||
"password": _resolve_dashboard_password(core_lifecycle_td),
|
||||
"code": pyotp.TOTP(secret).now(),
|
||||
"trust_device_flag": True,
|
||||
},
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
set_cookie_headers = response.headers.getlist("Set-Cookie")
|
||||
trusted_cookie_header = next(
|
||||
(
|
||||
value
|
||||
for value in set_cookie_headers
|
||||
if TOTP_TRUSTED_DEVICE_COOKIE_NAME in value
|
||||
),
|
||||
"",
|
||||
)
|
||||
assert trusted_cookie_header
|
||||
assert "HttpOnly" in trusted_cookie_header
|
||||
assert "SameSite=Strict" in trusted_cookie_header
|
||||
assert "Path=/api/auth" in trusted_cookie_header
|
||||
finally:
|
||||
await _restore_dashboard_password_state(
|
||||
core_lifecycle_td,
|
||||
original_dashboard_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_login_skips_totp_when_trusted_cookie_valid(
|
||||
app: Quart,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
original_dashboard_config = copy.deepcopy(
|
||||
core_lifecycle_td.astrbot_config["dashboard"]
|
||||
)
|
||||
test_client = app.test_client()
|
||||
_, recovery_code_hash = generate_recovery_code()
|
||||
secret = pyotp.random_base32()
|
||||
|
||||
try:
|
||||
core_lifecycle_td.astrbot_config["dashboard"]["totp"] = {
|
||||
"enable": True,
|
||||
"secret": secret,
|
||||
"recovery_code_hash": recovery_code_hash,
|
||||
}
|
||||
first_login = await test_client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"username": core_lifecycle_td.astrbot_config["dashboard"]["username"],
|
||||
"password": _resolve_dashboard_password(core_lifecycle_td),
|
||||
"code": pyotp.TOTP(secret).now(),
|
||||
"trust_device_flag": True,
|
||||
},
|
||||
)
|
||||
first_data = await first_login.get_json()
|
||||
assert first_data["status"] == "ok"
|
||||
|
||||
second_login = await test_client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"username": core_lifecycle_td.astrbot_config["dashboard"]["username"],
|
||||
"password": _resolve_dashboard_password(core_lifecycle_td),
|
||||
},
|
||||
)
|
||||
second_data = await second_login.get_json()
|
||||
assert second_login.status_code == 200
|
||||
assert second_data["status"] == "ok"
|
||||
finally:
|
||||
await _restore_dashboard_password_state(
|
||||
core_lifecycle_td,
|
||||
original_dashboard_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_save_requires_two_factor_for_protected_totp_changes(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
original_dashboard_config = copy.deepcopy(
|
||||
core_lifecycle_td.astrbot_config["dashboard"]
|
||||
)
|
||||
test_client = app.test_client()
|
||||
_, recovery_code_hash = generate_recovery_code()
|
||||
secret = pyotp.random_base32()
|
||||
|
||||
try:
|
||||
core_lifecycle_td.astrbot_config["dashboard"]["totp"] = {
|
||||
"enable": True,
|
||||
"secret": secret,
|
||||
"recovery_code_hash": recovery_code_hash,
|
||||
}
|
||||
post_config = copy.deepcopy(dict(core_lifecycle_td.astrbot_config))
|
||||
post_config["dashboard"]["totp"] = {
|
||||
"enable": False,
|
||||
"secret": "",
|
||||
"recovery_code_hash": "",
|
||||
}
|
||||
response = await test_client.post(
|
||||
"/api/config/astrbot/update",
|
||||
headers=authenticated_header,
|
||||
json={"conf_id": "default", "config": post_config},
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert response.status_code == 401
|
||||
assert data["status"] == "error"
|
||||
assert data["data"]["totp_required"] is True
|
||||
assert core_lifecycle_td.astrbot_config["dashboard"]["totp"] == {
|
||||
"enable": True,
|
||||
"secret": secret,
|
||||
"recovery_code_hash": recovery_code_hash,
|
||||
}
|
||||
finally:
|
||||
await _restore_dashboard_password_state(
|
||||
core_lifecycle_td,
|
||||
original_dashboard_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_save_accepts_totp_code_for_protected_totp_changes(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
original_dashboard_config = copy.deepcopy(
|
||||
core_lifecycle_td.astrbot_config["dashboard"]
|
||||
)
|
||||
test_client = app.test_client()
|
||||
_, recovery_code_hash = generate_recovery_code()
|
||||
secret = pyotp.random_base32()
|
||||
|
||||
try:
|
||||
core_lifecycle_td.astrbot_config["dashboard"]["totp"] = {
|
||||
"enable": True,
|
||||
"secret": secret,
|
||||
"recovery_code_hash": recovery_code_hash,
|
||||
}
|
||||
post_config = copy.deepcopy(dict(core_lifecycle_td.astrbot_config))
|
||||
post_config["dashboard"]["totp"] = {
|
||||
"enable": False,
|
||||
"secret": "",
|
||||
"recovery_code_hash": "",
|
||||
}
|
||||
response = await test_client.post(
|
||||
"/api/config/astrbot/update",
|
||||
headers={
|
||||
**authenticated_header,
|
||||
"X-2FA-Code": pyotp.TOTP(secret).now(),
|
||||
},
|
||||
json={"conf_id": "default", "config": post_config},
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert core_lifecycle_td.astrbot_config["dashboard"]["totp"] == {
|
||||
"enable": False,
|
||||
"secret": "",
|
||||
"recovery_code_hash": "",
|
||||
}
|
||||
finally:
|
||||
await _restore_dashboard_password_state(
|
||||
core_lifecycle_td,
|
||||
original_dashboard_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_save_rejects_recovery_code_for_protected_totp_changes(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
core_lifecycle_td: AstrBotCoreLifecycle,
|
||||
):
|
||||
original_dashboard_config = copy.deepcopy(
|
||||
core_lifecycle_td.astrbot_config["dashboard"]
|
||||
)
|
||||
test_client = app.test_client()
|
||||
recovery_code, recovery_code_hash = generate_recovery_code()
|
||||
secret = pyotp.random_base32()
|
||||
|
||||
try:
|
||||
core_lifecycle_td.astrbot_config["dashboard"]["totp"] = {
|
||||
"enable": True,
|
||||
"secret": secret,
|
||||
"recovery_code_hash": recovery_code_hash,
|
||||
}
|
||||
post_config = copy.deepcopy(dict(core_lifecycle_td.astrbot_config))
|
||||
post_config["dashboard"]["totp"] = {
|
||||
"enable": False,
|
||||
"secret": "",
|
||||
"recovery_code_hash": recovery_code_hash,
|
||||
}
|
||||
response = await test_client.post(
|
||||
"/api/config/astrbot/update",
|
||||
headers={
|
||||
**authenticated_header,
|
||||
"X-2FA-Code": recovery_code,
|
||||
},
|
||||
json={"conf_id": "default", "config": post_config},
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert response.status_code == 401
|
||||
assert data["status"] == "error"
|
||||
assert data["data"]["totp_required"] is True
|
||||
assert core_lifecycle_td.astrbot_config["dashboard"]["totp"] == {
|
||||
"enable": True,
|
||||
"secret": secret,
|
||||
"recovery_code_hash": recovery_code_hash,
|
||||
}
|
||||
finally:
|
||||
await _restore_dashboard_password_state(
|
||||
core_lifecycle_td,
|
||||
original_dashboard_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_totp_setup_with_valid_code_returns_recovery_code(
|
||||
app: Quart,
|
||||
authenticated_header: dict,
|
||||
):
|
||||
test_client = app.test_client()
|
||||
secret = pyotp.random_base32()
|
||||
response = await test_client.post(
|
||||
"/api/auth/totp/setup",
|
||||
headers=authenticated_header,
|
||||
json={"secret": secret, "code": pyotp.TOTP(secret).now()},
|
||||
)
|
||||
data = await response.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert isinstance(data["data"]["recovery_code"], str)
|
||||
assert isinstance(data["data"]["recovery_code_hash"], str)
|
||||
assert data["data"]["recovery_code"]
|
||||
assert data["data"]["recovery_code_hash"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legacy_md5_dashboard_password_keeps_legacy_auth_until_edit(
|
||||
app: Quart,
|
||||
|
||||
@@ -689,7 +689,9 @@ async def test_tool_result_includes_all_calltoolresult_content(
|
||||
"mime_type": mime_type,
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(file_path=f"/tmp/{tool_call_id}_{index}.png")
|
||||
return SimpleNamespace(
|
||||
file_path=f"/tmp/{tool_call_id}_{index}.png", mime_type=mime_type
|
||||
)
|
||||
|
||||
monkeypatch.setattr(tool_image_cache, "save_image", fake_save_image)
|
||||
|
||||
|
||||
199
tests/unit/test_aiocqhttp_reply.py
Normal file
199
tests/unit/test_aiocqhttp_reply.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""测试 aiocqhttp 平台中私聊环境下引用回复(Reply)的消息发送行为。
|
||||
|
||||
Bug 背景:在私聊中引用上文消息时,OneBot 协议端返回
|
||||
ActionFailed status='failed', retcode=100, wording='message not found'。
|
||||
|
||||
根因:Reply.toDict() 继承了 BaseMessageComponent.toDict(),
|
||||
会将所有非 None 的默认字段(chain, sender_id, qq, seq 等)序列化到
|
||||
OneBot 协议的 message 数组中。OneBot V11 标准只期望
|
||||
{"type": "reply", "data": {"id": "..."}},多余的字段可能导致
|
||||
协议端(napcat/Lagrange)查找消息时失败。
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.pipeline.respond.stage import RespondStage # noqa: F401 — 预加载避免循环导入
|
||||
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import (
|
||||
AiocqhttpMessageEvent,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Reply.toDict() 输出格式测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_reply_to_dict_contains_only_id_in_data():
|
||||
"""Reply.toDict() 应当只输出 id 字段,不含 chain、sender_id 等多余字段。
|
||||
|
||||
当前实际行为:继承了 BaseMessageComponent.toDict(),会将所有
|
||||
非 None 的默认值(chain: [], sender_id: 0, qq: 0, seq: 0 等)
|
||||
一起序列化,违反了 OneBot V11 的 reply 段格式约定。
|
||||
"""
|
||||
reply = Comp.Reply(id="123456")
|
||||
|
||||
result = reply.toDict()
|
||||
|
||||
assert result["type"] == "reply"
|
||||
assert "id" in result["data"]
|
||||
|
||||
# 这些字段不应出现在 OneBot 协议的 reply segment 中
|
||||
unexpectedFields = ["chain", "sender_id", "qq", "seq", "text"]
|
||||
for field in unexpectedFields:
|
||||
if field in result["data"]:
|
||||
pytest.fail(
|
||||
f"Reply.toDict() 的 data 中不应包含 '{field}' 字段,"
|
||||
f"但实际输出了 {field}={result['data'][field]!r}。"
|
||||
f"完整输出: {result}"
|
||||
)
|
||||
|
||||
|
||||
def test_reply_to_dict_outputs_only_id():
|
||||
"""Reply.toDict() 应当只输出 id 字段,不含任何多余字段。"""
|
||||
reply = Comp.Reply(id="123456")
|
||||
result = reply.toDict()
|
||||
|
||||
assert result["type"] == "reply"
|
||||
assert set(result["data"].keys()) == {"id"}, (
|
||||
f"Reply.toDict() data 中包含多余字段: {set(result['data'].keys()) - {'id'}}"
|
||||
)
|
||||
assert result["data"]["id"] == "123456"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# _parse_onebot_json 输出测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_onebot_json_reply_produces_extra_fields():
|
||||
"""_parse_onebot_json 处理 Reply 时会输出多余字段。
|
||||
|
||||
这验证了 bug 的链路:从 Reply 组件 → _parse_onebot_json →
|
||||
OneBot 协议 payload,多余字段一直传递到 send_private_msg。
|
||||
"""
|
||||
chain = MessageChain([Comp.Reply(id="123456"), Comp.Plain("你好")])
|
||||
|
||||
data = await AiocqhttpMessageEvent._parse_onebot_json(chain)
|
||||
|
||||
assert len(data) == 2
|
||||
replySegment = data[0]
|
||||
assert replySegment["type"] == "reply"
|
||||
|
||||
# 检查 reply 段的 data 中是否有多余字段
|
||||
extraFields = [k for k in replySegment["data"] if k != "id"]
|
||||
if extraFields:
|
||||
pytest.fail(
|
||||
f"_parse_onebot_json 输出的 reply 段包含了多余的 data 字段: "
|
||||
f"{extraFields}。这些字段可能被 OneBot 协议端误解析,"
|
||||
f"导致 message not found 错误。\n"
|
||||
f"完整 reply 段: {replySegment}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 私聊发送路径测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_private_msg_with_reply_includes_extra_fields():
|
||||
"""验证私聊发送带 Reply 的消息时,实际传给 bot.send_private_msg 的
|
||||
payload 包含多余字段。
|
||||
|
||||
这是导致私聊下 'message not found' 的直接原因:
|
||||
OneBot 协议端收到的 reply 段数据不符合标准格式。
|
||||
"""
|
||||
bot = AsyncMock()
|
||||
chain = MessageChain([Comp.Reply(id="123456"), Comp.Plain("你好")])
|
||||
|
||||
await AiocqhttpMessageEvent.send_message(
|
||||
bot=bot,
|
||||
message_chain=chain,
|
||||
event=None,
|
||||
is_group=False, # 私聊
|
||||
session_id="987654",
|
||||
)
|
||||
|
||||
# 验证调用了 send_private_msg(而非 send_group_msg)
|
||||
bot.send_private_msg.assert_awaited_once()
|
||||
|
||||
callArgs = bot.send_private_msg.call_args
|
||||
assert callArgs.kwargs["user_id"] == 987654
|
||||
|
||||
messages = callArgs.kwargs["message"]
|
||||
assert len(messages) >= 1
|
||||
replySegment = messages[0]
|
||||
assert replySegment["type"] == "reply"
|
||||
|
||||
# 检查 payload 中的多余字段
|
||||
extraFields = [k for k in replySegment["data"] if k != "id"]
|
||||
if extraFields:
|
||||
pytest.fail(
|
||||
f"send_private_msg 的 message[0] reply 段包含了多余的 data 字段: "
|
||||
f"{extraFields}。\n"
|
||||
f"这是导致私聊引用回复报 'message not found' 的根因。\n"
|
||||
f"完整 payload: {messages}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_group_msg_with_reply_also_includes_extra_fields():
|
||||
"""对比:群聊发送带 Reply 的消息同样包含多余字段。
|
||||
|
||||
如果群聊引用回复正常而私聊失败,可能的原因是不同协议端
|
||||
对多余字段的容忍度不同(例如 napcat 在 send_group_msg 中
|
||||
忽略了多余字段,但在 send_private_msg 中严格校验)。
|
||||
"""
|
||||
bot = AsyncMock()
|
||||
chain = MessageChain([Comp.Reply(id="123456"), Comp.Plain("你好")])
|
||||
|
||||
await AiocqhttpMessageEvent.send_message(
|
||||
bot=bot,
|
||||
message_chain=chain,
|
||||
event=None,
|
||||
is_group=True,
|
||||
session_id="123456",
|
||||
)
|
||||
|
||||
bot.send_group_msg.assert_awaited_once()
|
||||
|
||||
callArgs = bot.send_group_msg.call_args
|
||||
messages = callArgs.kwargs["message"]
|
||||
replySegment = messages[0]
|
||||
assert replySegment["type"] == "reply"
|
||||
|
||||
extraFields = [k for k in replySegment["data"] if k != "id"]
|
||||
if extraFields:
|
||||
pytest.fail(
|
||||
f"send_group_msg 的 reply 段也包含多余字段: {extraFields}。\n"
|
||||
f"完整 payload: {messages}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Reply 组件只传 id 时的正确 OneBot 格式测试
|
||||
# ============================================================
|
||||
|
||||
|
||||
def test_reply_to_dict_matches_onebot_v11_format():
|
||||
"""OneBot V11 标准 reply 段格式:
|
||||
{"type": "reply", "data": {"id": "..."}}
|
||||
"""
|
||||
expected = {
|
||||
"type": "reply",
|
||||
"data": {"id": "123456"},
|
||||
}
|
||||
|
||||
reply = Comp.Reply(id="123456")
|
||||
actual = reply.toDict()
|
||||
|
||||
assert actual == expected, (
|
||||
f"Reply.toDict() 输出不符合 OneBot V11 标准。\n"
|
||||
f"期望: {expected}\n"
|
||||
f"实际: {actual}"
|
||||
)
|
||||
88
tests/unit/test_dashboard_util.py
Normal file
88
tests/unit/test_dashboard_util.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Tests for dashboard route utility helpers."""
|
||||
|
||||
from astrbot.dashboard.routes.config import validate_config
|
||||
from astrbot.dashboard.routes.util import get_schema_item
|
||||
|
||||
|
||||
def test_get_schema_item_template_list_file_item():
|
||||
schema = {
|
||||
"demo_templates": {
|
||||
"type": "template_list",
|
||||
"templates": {
|
||||
"api_provider": {
|
||||
"items": {
|
||||
"tls_certificate_files": {"type": "file"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
meta = get_schema_item(
|
||||
schema,
|
||||
"demo_templates.templates.api_provider.tls_certificate_files",
|
||||
)
|
||||
|
||||
assert meta == {"type": "file"}
|
||||
|
||||
|
||||
def test_get_schema_item_nested_template_list_file_item():
|
||||
schema = {
|
||||
"group": {
|
||||
"type": "object",
|
||||
"items": {
|
||||
"demo_templates": {
|
||||
"type": "template_list",
|
||||
"templates": {
|
||||
"nested_profile": {
|
||||
"items": {
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"items": {
|
||||
"attachments": {"type": "file"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
meta = get_schema_item(
|
||||
schema,
|
||||
"group.demo_templates.templates.nested_profile.profile.attachments",
|
||||
)
|
||||
|
||||
assert meta == {"type": "file"}
|
||||
|
||||
|
||||
def test_validate_config_template_list_file_path_uses_template_schema_path():
|
||||
schema = {
|
||||
"demo_templates": {
|
||||
"type": "template_list",
|
||||
"templates": {
|
||||
"api_provider": {
|
||||
"items": {
|
||||
"tls_certificate_files": {"type": "file"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data = {
|
||||
"demo_templates": [
|
||||
{
|
||||
"__template_key": "api_provider",
|
||||
"tls_certificate_files": [
|
||||
"files/demo_templates/templates/api_provider/tls_certificate_files/cert.pem"
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
errors, validated = validate_config(data, schema, is_core=False)
|
||||
|
||||
assert errors == []
|
||||
assert validated == data
|
||||
@@ -14,13 +14,33 @@ def make_main_with_conversation_manager(conv_mgr):
|
||||
return main
|
||||
|
||||
|
||||
def make_event(umo: str = "aiocqhttp:GroupMessage:user_123_group_456"):
|
||||
def _make_extras_store():
|
||||
"""Return a mutable dict and get_extra / set_extra side_effects bound to it."""
|
||||
store: dict[str, object] = {}
|
||||
get_extra = lambda key, default=None: store.get(key, default) # noqa: E731
|
||||
set_extra = store.__setitem__ # type: ignore[assignment]
|
||||
return store, get_extra, set_extra
|
||||
|
||||
|
||||
def make_event(
|
||||
umo: str = "aiocqhttp:GroupMessage:user_123_group_456",
|
||||
*,
|
||||
handlers_parsed_params: dict | None = None,
|
||||
):
|
||||
event = MagicMock()
|
||||
event.unified_msg_origin = umo
|
||||
event.get_platform_id.return_value = "aiocqhttp"
|
||||
event.message_obj = SimpleNamespace(message=[Plain("hello")])
|
||||
event.message_str = "hello"
|
||||
event.session_id = "session-1"
|
||||
|
||||
store, get_extra, set_extra = _make_extras_store()
|
||||
# Simulate WakingCheckStage output: an empty dict means no command matched.
|
||||
store["handlers_parsed_params"] = (
|
||||
{} if handlers_parsed_params is None else handlers_parsed_params
|
||||
)
|
||||
event.get_extra.side_effect = get_extra
|
||||
event.set_extra.side_effect = set_extra
|
||||
return event
|
||||
|
||||
|
||||
@@ -118,3 +138,30 @@ async def test_on_message_does_not_clear_group_context_on_first_enabled_message(
|
||||
main.group_chat_context.need_active_reply.assert_awaited_once_with(event)
|
||||
main.group_chat_context.handle_message.assert_awaited_once_with(event)
|
||||
main.group_chat_context.remove_session.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_message_skips_recording_when_command_handler_matched():
|
||||
"""A slash-command message (handlers_parsed_params non-empty) must not be
|
||||
recorded into the group context buffer."""
|
||||
main = Main.__new__(Main)
|
||||
main.context = MagicMock()
|
||||
main.context.get_config.return_value = {
|
||||
"provider_ltm_settings": {
|
||||
"group_icl_enable": True,
|
||||
"active_reply": {"enable": False},
|
||||
},
|
||||
}
|
||||
main.group_chat_context = SimpleNamespace(
|
||||
need_active_reply=AsyncMock(return_value=False),
|
||||
handle_message=AsyncMock(),
|
||||
)
|
||||
event = make_event(
|
||||
handlers_parsed_params={"astrbot.builtin_stars.builtin_commands.main_reset": {}},
|
||||
)
|
||||
|
||||
async for _ in main.on_message(event):
|
||||
pass
|
||||
|
||||
main.group_chat_context.need_active_reply.assert_awaited_once_with(event)
|
||||
main.group_chat_context.handle_message.assert_not_awaited()
|
||||
|
||||
98
tests/unit/test_totp_utils.py
Normal file
98
tests/unit/test_totp_utils.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import pyotp
|
||||
import pytest
|
||||
|
||||
from astrbot.core.db.sqlite import SQLiteDatabase
|
||||
from astrbot.core.utils.totp import (
|
||||
consume_totp_code,
|
||||
generate_recovery_code,
|
||||
is_totp_enabled,
|
||||
is_totp_trusted_device_valid,
|
||||
issue_totp_trusted_device,
|
||||
verify_recovery_code,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("totp_config", "expected"),
|
||||
[
|
||||
({}, False),
|
||||
({"enable": False, "secret": "abc", "recovery_code_hash": "hash"}, False),
|
||||
({"enable": True, "secret": "", "recovery_code_hash": "hash"}, False),
|
||||
({"enable": True, "secret": "abc", "recovery_code_hash": ""}, False),
|
||||
({"enable": True, "secret": "abc", "recovery_code_hash": "hash"}, True),
|
||||
],
|
||||
)
|
||||
def test_is_totp_enabled_requires_enable_secret_and_recovery_hash(
|
||||
totp_config: dict,
|
||||
expected: bool,
|
||||
):
|
||||
config = {"dashboard": {"totp": totp_config}}
|
||||
assert is_totp_enabled(config) is expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consume_totp_code_prevents_replay_same_timecode():
|
||||
secret = pyotp.random_base32()
|
||||
code = pyotp.TOTP(secret).now()
|
||||
assert await consume_totp_code(secret, code) is True
|
||||
assert await consume_totp_code(secret, code) is False
|
||||
|
||||
|
||||
def test_generate_and_verify_recovery_code_roundtrip():
|
||||
recovery_code, recovery_code_hash = generate_recovery_code()
|
||||
config = {"dashboard": {"totp": {"recovery_code_hash": recovery_code_hash}}}
|
||||
assert verify_recovery_code(config, recovery_code) is True
|
||||
|
||||
|
||||
def test_verify_recovery_code_rejects_malformed_or_wrong_length():
|
||||
recovery_code, recovery_code_hash = generate_recovery_code()
|
||||
config = {"dashboard": {"totp": {"recovery_code_hash": recovery_code_hash}}}
|
||||
assert verify_recovery_code(config, "abc") is False
|
||||
assert verify_recovery_code(config, recovery_code[:-1]) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_issue_and_validate_trusted_device_token(tmp_path):
|
||||
db = SQLiteDatabase(str(tmp_path / "trusted-device.db"))
|
||||
config = {
|
||||
"dashboard": {
|
||||
"jwt_secret": "test-jwt-secret",
|
||||
"totp": {
|
||||
"enable": True,
|
||||
"secret": pyotp.random_base32(),
|
||||
"recovery_code_hash": "hash",
|
||||
},
|
||||
}
|
||||
}
|
||||
try:
|
||||
token = await issue_totp_trusted_device(config, db)
|
||||
assert isinstance(token, str) and token
|
||||
assert await is_totp_trusted_device_valid(config, db, token) is True
|
||||
finally:
|
||||
await db.engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trusted_device_invalid_after_totp_secret_change(tmp_path):
|
||||
db = SQLiteDatabase(str(tmp_path / "trusted-device.db"))
|
||||
old_secret = pyotp.random_base32()
|
||||
new_secret = pyotp.random_base32()
|
||||
config = {
|
||||
"dashboard": {
|
||||
"jwt_secret": "test-jwt-secret",
|
||||
"totp": {
|
||||
"enable": True,
|
||||
"secret": old_secret,
|
||||
"recovery_code_hash": "hash",
|
||||
},
|
||||
}
|
||||
}
|
||||
try:
|
||||
token = await issue_totp_trusted_device(config, db)
|
||||
assert isinstance(token, str) and token
|
||||
assert await is_totp_trusted_device_valid(config, db, token) is True
|
||||
|
||||
config["dashboard"]["totp"]["secret"] = new_secret
|
||||
assert await is_totp_trusted_device_valid(config, db, token) is False
|
||||
finally:
|
||||
await db.engine.dispose()
|
||||
Reference in New Issue
Block a user