Compare commits

...

22 Commits

Author SHA1 Message Date
Soulter
b4bc404095 feat: add tooltip for last run time and error in cron job display 2026-06-03 22:02:33 +08:00
Soulter
f6965f4676 fix: update session label to indicate optional delivery target 2026-06-03 21:44:27 +08:00
Soulter
f28ae5f73e feat: enhance cron job management with delivery target handling and UI improvements 2026-06-03 21:41:12 +08:00
Soulter
5b420c74be fix: update filter label for UMO in English and Chinese locales 2026-06-03 21:31:03 +08:00
Soulter
8a1988a2c9 feat: future task UI 2026-06-03 21:14:14 +08:00
Soulter
df6eef052f fix: fix some bugs in #8226 2026-06-03 10:58:20 +08:00
Rat
f01dc474ef fix(gemini-embedding): wrap batch embedding texts in Content to avoid collapse on gemini-embedding-2 (#8537)
* fix(provider): wrap batch embedding texts in Content to avoid collapse on gemini-embedding-2

* fix(gemini_embedding): format list comprehension for better readability

---------

Co-authored-by: Rat0323 <Rat0323@users.noreply.github.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-06-03 10:42:04 +08:00
Allen You
072691877d fix(openai-embedding): temporarily fix invalid paramater for SiliconFlow provider's non-Qwen embedding models (#8508)
* fix(openai-embedding): SiliconFlow provider's non-Qwen embedding models do not support dimensions parameter

* fix: accept AI Reviewers' suggestions
2026-06-03 10:38:31 +08:00
tjc66666666
6a467fc043 perf(stt-whisper): close the audio file handle after calling the OpenAI transcription API (#8528)
* 在调用 OpenAI API 后关闭文件句柄再删除临时文件。

核心问题:whisper_api_source.py 第 121 行用 open(audio_url, "rb") 打开文件后,文件句柄没有被关闭,导致 Windows 上报 "另一个程序正在使用此文件" 的错误,temp wav 文件无法删除。
修复方案:在调用 OpenAI API 后关闭文件句柄再删除临时文件。

* fix(whisper_api): use context manager for audio file handling to ensure proper closure

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-06-03 10:32:12 +08:00
tjc66666666
d912e1497c Add check for audio file existence (#8529)
Handle case where audio file may not exist yet.
2026-06-03 10:28:32 +08:00
dependabot[bot]
92b2ce872c chore(deps): bump docker/setup-qemu-action in the github-actions group (#8533)
Bumps the github-actions group with 1 update: [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action).


Updates `docker/setup-qemu-action` from 4.0.0 to 4.1.0
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v4.0.0...v4.1.0)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: 4.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 10:27:06 +08:00
tjc66666666
4bb1b897df fix: cannot resolve voice when using aiocqhttp adapter (#8523)
Refactor file handling to resolve sources more effectively, including decoding file URIs and handling base64 data. Update methods to ensure compatibility with different file formats and paths.
2026-06-03 10:22:35 +08:00
Ruochen Pan
d2f5551513 fix(ltm): prevent wake commands from being recorded as group chat context (#8536)
* fix(ltm): prevent wake commands from being recorded as group chat context

* test: fix mock event get_extra return value in group context wiring tests

* test: strengthen group context mock and add slash-command skip test
2026-06-03 09:25:01 +08:00
Caleb
25b134444f fix(console): use toast for pip-install error display (#8462)
* fix(console): use toast for pip-install error display

The pip-install dialog displayed error messages as unstyled inline
<small> text with no color differentiation from success messages.
Replaced with useToast() to show errors as red snackbar, consistent
with the rest of the dashboard.

* fix(console): use i18n for pip-install toast fallback messages
2026-06-02 12:52:55 +08:00
Octopus
def81530b0 feat: upgrade MiniMax Token Plan default model to M3 (#8505)
Set MiniMax-M3 as the default fallback model for the Token Plan provider.
The model list itself is already fetched dynamically from the MiniMax API,
so all available models including M3 are auto-discovered. This change just
updates the hardcoded fallback used when no model is configured to the
current flagship.

MiniMax-M2.7 remains fully usable; users can still configure it explicitly.
2026-06-02 12:45:04 +08:00
C₂₂H₂₅NO₆
4b097011cf fix(core): Fix image delivery to the model by treating empty modalities as unconfigured (#8451)
* fix(core): 将空 list modalities 视为未配置,修复图片无法传递到模型的问题

migra_helper 将未配置的 modalities 迁移为空 list [],而新增的
_provider_supports_modality()、_assemble_request_context_for_provider()、
_should_fix_modalities_for_provider()、_func_tool_for_provider() 四个函数
对 [] 和 None 的处理不一致,导致所有未在 WebUI 手动配置 modalities 的
provider 无法传递图片、引用图片被跳过、工具被错误清除。

修改策略:将 [] 与 None 统一视为未配置状态,保持向后兼容。
仅在 modalities 为非空 list 时才启用过滤和修复逻辑。

- _provider_supports_modality: [] 返回 True,默认支持
- _assemble_request_context_for_provider: [] 不过滤图片
- _should_fix_modalities_for_provider: [] 不触发历史上下文修复
- _func_tool_for_provider: [] 不清除工具

复现脚本已确认 Bug 不再复现,82 个相关单元测试全部通过。

* fix(core): 补充修复 tool loop 中 cached images 的 modalities 判断

上一笔 commit 遗漏了 tool_loop_agent_runner.py 第 917 行对
cached images 的 modalities 检查,当 modalities=[] 时,tool call
返回的图片不会被追加到消息中,导致模型看不到 tool 结果中的图片。

修复方式与主修复一致:not modalities or image in modalities

复现脚本和 82 个单元测试全部通过。

* test: 补充 fake_save_image mock 缺少的 mime_type 字段

修复 modalities=[] 导致 cached images 分支变为可达后,暴露
了 test_tool_result_includes_all_calltoolresult_content 中
fake_save_image mock 不完整的问题:返回的 SimpleNamespace
缺少 mime_type 属性,而实际 tool_image_cache.save_image()
始终包含该字段。

---------

Co-authored-by: C₂₂H₂₅NO₆ <Sisyphbaous-DT-Project@users.noreply.github.com>
2026-06-02 08:53:59 +08:00
NayukiChiba
7d45a247d5 fix(message): Fix private message sending failure caused by extra fields in Reply component's toDict method (#8477)
- Reply.toDict() 继承 BaseMessageComponent.toDict() 会将所有非 None 默认字段序列化,导致 OneBot V11 reply 段包含多余字段,引起私聊引用回复失败(message not found)
- 重写 Reply.toDict() 方法,仅返回 {"type": "reply", "data": {"id": str(self.id)}},符合协议标准
- 新增 tests/unit/test_aiocqhttp_reply.py,覆盖 Reply.toDict() 输出格式、_parse_onebot_json 路径及私聊发送场景的验证
2026-06-02 08:40:55 +08:00
鸦羽
e8d13af5b9 feat: add TOTP two-factor authentication for dashboard login (#8189)
* feat: add TOTP two-factor authentication for dashboard login

* fix: ensure TOTP verification uses UTC for accurate time comparison

* fix: update recovery code validation logic for disabling TOTP

* test: add unit tests for TOTP functionality and recovery code validation

* chore: format

* feat: add trust_proxy_headers switch for auth rate-limit IP source

* feat: make dashboard auth rate-limit configurable via system settings

Add auth_rate_limit config block to dashboard settings with enable
(default: true), average_interval (default: 1.0s), and max_burst
(default: 3) options. The dashboard auth middleware now reads from
config instead of using hardcoded values. The average_interval and
max_burst fields are conditionally shown only when rate limiting is
enabled.

* fix: normalize dashboard client IP from trusted proxy headers

* refactor: encapsulate rate limiter state into registry, add TTL

* feat: show dynamic page title during two-factor verification

* fix: require two-factor verification for protected config saves

* chore: format

* refactor: reorganize TOTP verification UI components for better layout

* refactor: clean up recovery stage UI by removing unused styles and improving label handling

* docs: add TOTP two-factor authentication documentation for WebUI

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-06-01 16:40:29 +08:00
lingyun14
e4044cc5a0 fix: waking bot with a reply component and empty user prompt (#8461)
* Update internal.py

* Update astr_main_agent.py

* Update astr_main_agent.py

* ruff

* Update astr_main_agent.py
2026-06-01 10:03:24 +08:00
千岚之夏
c89ac61892 feat: dynamically fetch model list for MiniMax Token Plan (#8475) 2026-06-01 09:59:52 +08:00
Rain-0x01_
fbc0633cd3 chore: fix token terminology in zh (#8465) 2026-05-31 21:54:44 +08:00
Misaka Mikoto
90a3a2171a fix: Template config optimization (#8228)
* fix: improve template list config handling

* feat(webui): show template list display item

* feat(webui): allow hiding template list hints

* docs: document template list metadata fields

* fix: support file fields in template list configs
2026-05-30 22:13:09 +08:00
67 changed files with 6770 additions and 913 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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 = []

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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.

View File

@@ -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.fileRecord 同样需要 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

View File

@@ -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")

View File

@@ -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(),
),

View File

@@ -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 []

View File

@@ -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:

View File

@@ -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):

View File

@@ -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
View 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)

View File

@@ -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

View File

@@ -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")

View File

@@ -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__)

View File

@@ -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

View File

@@ -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"]

File diff suppressed because it is too large Load Diff

View File

@@ -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)"
/>

View File

@@ -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
}
})

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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;

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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\""

View File

@@ -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"
}
}

View File

@@ -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": "Перейти на светлую тему"
}
}

View File

@@ -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": {

View File

@@ -10,7 +10,10 @@
"packageLabel": "*Имя пакета, например: llmtuner",
"mirrorLabel": "Использовать зеркало PyPI (опционально)",
"mirrorHint": "Приоритет зеркала PyPI > настройки «Зеркало репозитория PyPI»",
"installButton": "Установить"
"installButton": "Установить",
"installSuccess": "Установка выполнена успешно.",
"installFailed": "Ошибка установки.",
"requestFailed": "Ошибка запроса."
},
"debugHint": {
"text": "Для отображения Debug-логов необходимо установить соответствующий уровень в «Конфигурация → Система → Уровень логирования»"

View File

@@ -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": "Ошибка запуска"
}
}

View File

@@ -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": "切换到浅色主题"
}
}
}

View File

@@ -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"
}
}

View File

@@ -10,7 +10,10 @@
"packageLabel": "*库名,如 llmtuner",
"mirrorLabel": "强制 PyPI 软件仓库链接(可选)",
"mirrorHint": "强制 PyPI 软件仓库链接 > 配置项 `PyPI 软件仓库地址`",
"installButton": "安装"
"installButton": "安装",
"installSuccess": "安装成功。",
"installFailed": "安装失败。",
"requestFailed": "请求失败。"
},
"debugHint": {
"text": "Debug 日志需要在「配置文件 → 系统 → 控制台日志级别」中开启"

View File

@@ -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": "执行失败"
}
}

View File

@@ -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);

View File

@@ -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();
}

View File

@@ -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') {

View File

@@ -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',

View File

@@ -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() {

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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.

View File

@@ -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" />
## 在插件中使用配置

View File

@@ -15,6 +15,31 @@ AstrBot 管理面板具有管理插件、查看日志、可视化配置、查看
新用户首次登录时AstrBot 会生成一个随机初始密码并写入启动日志。请先在启动日志中查找并使用该密码登录(用户名通常为 `astrbot`),登录后请立即修改密码。
## 双因素认证
AstrBot WebUI支持基于 TOTPTime-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可在浏览器中直接与已配置的模型对话。

View File

@@ -65,6 +65,7 @@ dependencies = [
"pysocks>=1.7.1",
"packaging>=24.2",
"python-ripgrep==0.0.8",
"pyotp>=2.9.0",
]
[dependency-groups]

View File

@@ -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

View File

@@ -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,

View File

@@ -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)

View 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}"
)

View 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

View File

@@ -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()

View 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()