Merge branch 'master' into dev

This commit is contained in:
LIghtJUNction
2026-03-18 17:22:50 +08:00
committed by GitHub
32 changed files with 2313 additions and 650 deletions
+1
View File
@@ -40,6 +40,7 @@ jobs:
pytest --cov=astrbot -v -o log_cli=true -o log_level=DEBUG
- name: Upload results to Codecov
if: github.repository == 'AstrBotDevs/AstrBot'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
+1
View File
@@ -8,6 +8,7 @@ on:
jobs:
build:
if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
+2 -2
View File
@@ -11,7 +11,7 @@ on:
jobs:
build-nightly-image:
if: github.event_name == 'schedule'
if: github.repository == 'AstrBotDevs/AstrBot' && github.event_name == 'schedule'
runs-on: ubuntu-latest
env:
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -109,7 +109,7 @@ jobs:
run: echo "Test Docker image has been built and pushed successfully"
build-release-image:
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))
if: github.repository == 'AstrBotDevs/AstrBot' && (github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')))
runs-on: ubuntu-latest
env:
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
+1
View File
@@ -6,6 +6,7 @@ on:
jobs:
title-format:
if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-latest
permissions:
pull-requests: write
+3
View File
@@ -20,6 +20,7 @@ permissions:
jobs:
build-dashboard:
name: Build Dashboard
if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-24.04
env:
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
@@ -104,6 +105,7 @@ jobs:
publish-release:
name: Publish GitHub Release
if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-24.04
needs:
- build-dashboard
@@ -183,6 +185,7 @@ jobs:
publish-pypi:
name: Publish PyPI
if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-24.04
needs:
- publish-release
+1
View File
@@ -18,6 +18,7 @@ on:
jobs:
stale:
if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-latest
permissions:
issues: write
+1
View File
@@ -18,6 +18,7 @@ concurrency:
jobs:
sync:
if: github.repository == 'AstrBotDevs/AstrBot'
runs-on: ubuntu-latest
permissions:
contents: read
+13 -4
View File
@@ -82,14 +82,19 @@ from .permissions import check_admin_permission
@dataclass
class FileUploadTool(FunctionTool):
name: str = "astrbot_upload_file"
description: str = "Upload a local file to the sandbox. The file must exist on the local filesystem."
description: str = (
"Transfer a file FROM the host machine INTO the sandbox so that sandbox "
"code can access it. Use this when the user sends/attaches a file and you "
"need to process it inside the sandbox. The local_path must point to an "
"existing file on the host filesystem."
)
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"local_path": {
"type": "string",
"description": "The local file path to upload. This must be an absolute path to an existing file on the local filesystem.",
"description": "Absolute path to the file on the host filesystem that will be copied into the sandbox.",
},
# "remote_path": {
# "type": "string",
@@ -143,14 +148,18 @@ class FileUploadTool(FunctionTool):
@dataclass
class FileDownloadTool(FunctionTool):
name: str = "astrbot_download_file"
description: str = "Download a file from the sandbox. Only call this when user explicitly need you to download a file."
description: str = (
"Transfer a file FROM the sandbox OUT to the host and optionally send it "
"to the user. Use this ONLY when the user asks to retrieve/export a file "
"that was created or modified inside the sandbox."
)
parameters: dict = field(
default_factory=lambda: {
"type": "object",
"properties": {
"remote_path": {
"type": "string",
"description": "The path of the file in the sandbox to download.",
"description": "Path of the file inside the sandbox to copy out to the host.",
},
"also_send_to_user": {
"type": "boolean",
@@ -1,5 +1,5 @@
import json
from enum import IntEnum, StrEnum
from enum import Enum, IntEnum
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -36,7 +36,7 @@ class KookMessageType(IntEnum):
SYSTEM = 255
class KookModuleType(StrEnum):
class KookModuleType(str, Enum):
PLAIN_TEXT = "plain-text"
KMARKDOWN = "kmarkdown"
IMAGE = "image"
@@ -311,7 +311,7 @@ class KookMessageSignal(IntEnum):
"""server->client resume ack"""
class KookChannelType(StrEnum):
class KookChannelType(str, Enum):
GROUP = "GROUP"
PERSON = "PERSON"
BROADCAST = "BROADCAST"
+82 -43
View File
@@ -5,6 +5,7 @@ import contextlib
import functools
import inspect
import json
import keyword
import logging
import os
import sys
@@ -423,6 +424,43 @@ class PluginManager:
return metadata
@staticmethod
def _normalize_plugin_dir_name(plugin_name: str) -> str:
return plugin_name.strip()
@staticmethod
def _validate_importable_name(plugin_name: str) -> None:
if "/" in plugin_name or "\\" in plugin_name:
raise ValueError(
"metadata.yaml 中 name 含有路径分隔符,不可用于 importlib 加载。"
)
if not plugin_name.isidentifier() or keyword.iskeyword(plugin_name):
raise Exception(
"metadata.yaml 中 name 不是合法的模块名称(应为合法 Python 标识符且非关键字)。"
)
@staticmethod
def _get_plugin_dir_name_from_metadata(plugin_path: str) -> str:
metadata_path = os.path.join(plugin_path, "metadata.yaml")
if not os.path.exists(metadata_path):
raise Exception("未找到 metadata.yaml,无法获取插件目录名。")
with open(metadata_path, encoding="utf-8") as f:
metadata = yaml.safe_load(f)
if not isinstance(metadata, dict):
raise Exception("metadata.yaml 格式错误。")
plugin_name = metadata.get("name")
if not isinstance(plugin_name, str) or not plugin_name.strip():
raise Exception("metadata.yaml 中缺少 name 字段。")
plugin_dir_name = PluginManager._normalize_plugin_dir_name(plugin_name)
if not plugin_dir_name:
raise Exception("metadata.yaml 中 name 字段内容非法。")
PluginManager._validate_importable_name(plugin_dir_name)
return plugin_dir_name
@staticmethod
def _validate_astrbot_version_specifier(
version_spec: str | None,
@@ -1205,10 +1243,30 @@ class PluginManager:
plugin_path = ""
dir_name = ""
try:
_, repo_name, _ = self.updator.parse_github_url(repo_url)
repo_name = self.updator.format_name(repo_name)
plugin_path = os.path.join(self.plugin_store_path, repo_name)
if os.path.exists(plugin_path):
raise Exception(
f"安装失败:目录 {os.path.basename(plugin_path)} 已存在。"
)
plugin_path = await self.updator.install(repo_url, proxy)
# reload the plugin
dir_name = os.path.basename(plugin_path)
metadata_dir_name = self._get_plugin_dir_name_from_metadata(plugin_path)
target_plugin_path = os.path.join(
self.plugin_store_path,
metadata_dir_name,
)
if target_plugin_path != plugin_path and os.path.exists(
target_plugin_path
):
raise Exception(f"安装失败:目录 {metadata_dir_name} 已存在。")
if target_plugin_path != plugin_path:
os.rename(plugin_path, target_plugin_path)
plugin_path = target_plugin_path
dir_name = metadata_dir_name
await self._ensure_plugin_requirements(
plugin_path,
dir_name,
@@ -1578,52 +1636,25 @@ class PluginManager:
async def install_plugin_from_file(
self, zip_file_path: str, ignore_version_check: bool = False
):
dir_name = os.path.basename(zip_file_path).replace(".zip", "")
dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower()
desti_dir = os.path.join(self.plugin_store_path, dir_name)
# 第一步:检查是否已安装同目录名的插件,先终止旧插件
existing_plugin = None
for star in self.context.get_all_stars():
if star.root_dir_name == dir_name:
existing_plugin = star
break
if existing_plugin:
logger.info(f"检测到插件 {existing_plugin.name} 已安装,正在终止旧插件...")
try:
await self._terminate_plugin(existing_plugin)
except Exception:
logger.warning(traceback.format_exc())
if existing_plugin.name and existing_plugin.module_path:
await self._unbind_plugin(
existing_plugin.name, existing_plugin.module_path
)
dir_name = os.path.splitext(os.path.basename(zip_file_path))[0]
desti_dir = tempfile.mkdtemp(
dir=self.plugin_store_path, prefix="plugin_upload_"
)
temp_desti_dir = desti_dir
try:
self.updator.unzip_file(zip_file_path, desti_dir)
# 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件
try:
new_metadata = self._load_plugin_metadata(desti_dir)
if new_metadata and new_metadata.name:
for star in self.context.get_all_stars():
if (
star.name == new_metadata.name
and star.root_dir_name != dir_name
):
logger.warning(
f"检测到同名插件 {star.name} 存在于不同目录 {star.root_dir_name},正在终止..."
)
try:
await self._terminate_plugin(star)
except Exception:
logger.warning(traceback.format_exc())
if star.name and star.module_path:
await self._unbind_plugin(star.name, star.module_path)
break # 只处理第一个匹配的
except Exception as e:
logger.debug(f"读取新插件 metadata.yaml 失败,跳过同名检查: {e!s}")
metadata_dir_name = self._get_plugin_dir_name_from_metadata(desti_dir)
target_plugin_path = os.path.join(
self.plugin_store_path,
metadata_dir_name,
)
if target_plugin_path != desti_dir and os.path.exists(target_plugin_path):
raise Exception(f"安装失败:目录 {metadata_dir_name} 已存在。")
if target_plugin_path != desti_dir:
os.rename(desti_dir, target_plugin_path)
dir_name = metadata_dir_name
desti_dir = target_plugin_path
# remove the zip
try:
@@ -1692,3 +1723,11 @@ class PluginManager:
f"安装插件 {dir_name} 失败,插件安装目录:{desti_dir}",
)
raise
finally:
if temp_desti_dir != desti_dir and os.path.isdir(temp_desti_dir):
try:
remove_dir(temp_desti_dir)
except Exception as e:
logger.warning(
f"清理临时插件解压目录失败: {temp_desti_dir},原因: {e!s}",
)
+8 -3
View File
@@ -5,6 +5,7 @@
"author": "CodedThemes",
"scripts": {
"dev": "vite --host",
"subset-icons": "node scripts/subset-mdi-font.mjs",
"build": "vue-tsc --noEmit && vite build",
"build-stage": "vue-tsc --noEmit && vite build --base=/vue/free/stage/",
"build-prod": "vue-tsc --noEmit && vite build --base=/vue/free/",
@@ -33,7 +34,6 @@
"monaco-editor": "^0.52.2",
"pinia": "2.1.6",
"pinyin-pro": "^3.26.0",
"remixicon": "3.5.0",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.13",
"vee-validate": "4.11.3",
@@ -60,12 +60,17 @@
"eslint-plugin-vue": "9.17.0",
"prettier": "3.0.2",
"sass": "1.66.1",
"sass-loader": "13.3.2",
"subset-font": "^2.4.0",
"typescript": "5.1.6",
"vite": "6.4.1",
"vue-tsc": "1.8.27"
"vite-plugin-webfont-dl": "^3.12.0",
"vue-cli-plugin-vuetify": "2.5.8",
"vue-tsc": "1.8.8",
"vuetify-loader": "^2.0.0-alpha.9"
},
"overrides": {
"immutable": "4.3.8",
"lodash-es": "4.17.23"
}
}
}
+235 -19
View File
@@ -68,9 +68,6 @@ importers:
pinyin-pro:
specifier: ^3.26.0
version: 3.28.0
remixicon:
specifier: 3.5.0
version: 3.5.0
shiki:
specifier: ^3.20.0
version: 3.22.0
@@ -144,12 +141,24 @@ importers:
sass:
specifier: 1.66.1
version: 1.66.1
sass-loader:
specifier: 13.3.2
version: 13.3.2(sass@1.66.1)(webpack@5.105.0)
subset-font:
specifier: ^2.4.0
version: 2.4.0
typescript:
specifier: 5.1.6
version: 5.1.6
vite:
specifier: 6.4.1
version: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)
vite-plugin-webfont-dl:
specifier: ^3.12.0
version: 3.12.0(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))
vue-cli-plugin-vuetify:
specifier: 2.5.8
version: 2.5.8(sass-loader@13.3.2(sass@1.66.1)(webpack@5.105.0))(vue@3.3.4)(vuetify-loader@2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0))(webpack@5.105.0)
vue-tsc:
specifier: 1.8.27
version: 1.8.27(typescript@5.1.6)
@@ -183,6 +192,12 @@ packages:
'@braintree/sanitize-url@7.1.2':
resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==}
'@cacheable/memory@2.0.8':
resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==}
'@cacheable/utils@2.4.0':
resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==}
'@chevrotain/cst-dts-gen@11.0.3':
resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==}
@@ -438,6 +453,15 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@keyv/bigmap@1.3.1':
resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==}
engines: {node: '>= 18'}
peerDependencies:
keyv: ^5.6.0
'@keyv/serialize@1.1.1':
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
'@mdi/font@7.2.96':
resolution: {integrity: sha512-e//lmkmpFUMZKhmCY9zdjRe4zNXfbOIJnn6xveHbaV2kSw5aJ5dLXUxcRt1Gxfi7ZYpFLUWlkG2MGSFAiqAu7w==}
@@ -503,79 +527,66 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -1129,6 +1140,9 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
cacheable@2.3.3:
resolution: {integrity: sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
@@ -1165,6 +1179,14 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
chrome-trace-event@1.0.4:
resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==}
engines: {node: '>=6.0'}
clean-css@5.3.3:
resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==}
engines: {node: '>= 10.0'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1579,6 +1601,9 @@ packages:
resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
engines: {node: ^10.12.0 || >=12.0.0}
flat-cache@6.1.20:
resolution: {integrity: sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==}
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
@@ -1591,6 +1616,9 @@ packages:
debug:
optional: true
fontverter@2.0.0:
resolution: {integrity: sha512-DFVX5hvXuhi1Jven1tbpebYTCT9XYnvx6/Z+HFUPb7ZRMCW+pj2clU9VMhoTPgWKPhAs7JJDSk3CW1jNUvKCZQ==}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
@@ -1644,6 +1672,9 @@ packages:
hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
harfbuzzjs@0.4.15:
resolution: {integrity: sha512-p1edvnlc+vpRe2kz7OKzcscf0gyFiDZpco+miDxAiiZ67cu1oNlbuOkmP/A/i1l/w938VrkF2FdZ8scNcnkPrQ==}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -1656,6 +1687,10 @@ packages:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hashery@1.5.0:
resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==}
engines: {node: '>=20'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@@ -1674,6 +1709,9 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hookified@1.15.1:
resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@@ -1760,6 +1798,9 @@ packages:
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
keyv@5.6.0:
resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==}
khroma@2.1.0:
resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==}
@@ -1968,6 +2009,9 @@ packages:
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -2128,6 +2172,15 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qified@0.6.0:
resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==}
engines: {node: '>=20'}
querystring@0.2.1:
resolution: {integrity: sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==}
engines: {node: '>=0.4.x'}
deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead.
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2144,8 +2197,9 @@ packages:
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
remixicon@3.5.0:
resolution: {integrity: sha512-wNzWGKf4frb3tEmgvP5shry0n1OdTjjEk9RHLuRuAhfA50bvEdpKH1XWNUYrHUSjAQQkkdyIm+lf4mOuysIKTQ==}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
@@ -2253,6 +2307,9 @@ packages:
stylis@4.3.6:
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
subset-font@2.4.0:
resolution: {integrity: sha512-DA/45nIj4NiseVdfHxVdVGL7hvNo3Ol6HjEm3KSYtPyDcsr6jh8Q37vSgz+A722wMfUd6nL8kgsi7uGv9DExXQ==}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -2415,6 +2472,11 @@ packages:
vue: ^3.0.0
vuetify: '>=3'
vite-plugin-webfont-dl@3.12.0:
resolution: {integrity: sha512-0jxsr8ycuoK/uV5Y3ytttTRhgvfZo8v3O4JZBlVc4C7QWIws/vCLVR4B3ag+TGVkLNQya6hXfY3UnZge3M8vmA==}
peerDependencies:
vite: ^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8
vite@6.4.1:
resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -2543,11 +2605,36 @@ packages:
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
watchpack@2.5.1:
resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==}
engines: {node: '>=10.13.0'}
wawoff2@2.0.1:
resolution: {integrity: sha512-r0CEmvpH63r4T15ebFqeOjGqU4+EgTx4I510NtK35EMciSdcTxCw3Byy3JnBonz7iyIFZ0AbVo0bbFpEVuhCYA==}
hasBin: true
webpack-sources@3.3.4:
resolution: {integrity: sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==}
engines: {node: '>=10.13.0'}
webpack@5.105.0:
resolution: {integrity: sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==}
engines: {node: '>=10.13.0'}
hasBin: true
peerDependencies:
webpack-cli: '*'
peerDependenciesMeta:
webpack-cli:
optional: true
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
woff2sfnt-sfnt2woff@1.0.0:
resolution: {integrity: sha512-edK4COc1c1EpRfMqCZO1xJOvdUtM5dbVb9iz97rScvnTevqEB3GllnLWCmMVp1MfQBdF1DFg/11I0rSyAdS4qQ==}
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@@ -2593,6 +2680,18 @@ snapshots:
'@braintree/sanitize-url@7.1.2': {}
'@cacheable/memory@2.0.8':
dependencies:
'@cacheable/utils': 2.4.0
'@keyv/bigmap': 1.3.1(keyv@5.6.0)
hookified: 1.15.1
keyv: 5.6.0
'@cacheable/utils@2.4.0':
dependencies:
hashery: 1.5.0
keyv: 5.6.0
'@chevrotain/cst-dts-gen@11.0.3':
dependencies:
'@chevrotain/gast': 11.0.3
@@ -2784,6 +2883,14 @@ snapshots:
'@jridgewell/sourcemap-codec': 1.5.5
optional: true
'@keyv/bigmap@1.3.1(keyv@5.6.0)':
dependencies:
hashery: 1.5.0
hookified: 1.15.1
keyv: 5.6.0
'@keyv/serialize@1.1.1': {}
'@mdi/font@7.2.96': {}
'@mermaid-js/parser@0.6.3':
@@ -3543,6 +3650,14 @@ snapshots:
buffer-from@1.1.2:
optional: true
cacheable@2.3.3:
dependencies:
'@cacheable/memory': 2.0.8
'@cacheable/utils': 2.4.0
hookified: 1.15.1
keyv: 5.6.0
qified: 0.6.0
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
@@ -3589,6 +3704,12 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
chrome-trace-event@1.0.4: {}
clean-css@5.3.3:
dependencies:
source-map: 0.6.1
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -4061,10 +4182,21 @@ snapshots:
keyv: 4.5.4
rimraf: 3.0.2
flat-cache@6.1.20:
dependencies:
cacheable: 2.3.3
flatted: 3.3.3
hookified: 1.15.1
flatted@3.3.3: {}
follow-redirects@1.15.11: {}
fontverter@2.0.0:
dependencies:
wawoff2: 2.0.1
woff2sfnt-sfnt2woff: 1.0.0
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
@@ -4134,6 +4266,8 @@ snapshots:
hachure-fill@0.5.2: {}
harfbuzzjs@0.4.15: {}
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
@@ -4142,6 +4276,10 @@ snapshots:
dependencies:
has-symbols: 1.1.0
hashery@1.5.0:
dependencies:
hookified: 1.15.1
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@@ -4168,6 +4306,8 @@ snapshots:
highlight.js@11.11.1: {}
hookified@1.15.1: {}
html-void-elements@3.0.0: {}
iconv-lite@0.6.3:
@@ -4234,6 +4374,10 @@ snapshots:
dependencies:
json-buffer: 3.0.1
keyv@5.6.0:
dependencies:
'@keyv/serialize': 1.1.1
khroma@2.1.0: {}
langium@3.3.1:
@@ -4454,6 +4598,8 @@ snapshots:
package-manager-detector@1.6.0: {}
pako@1.0.11: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -4633,6 +4779,12 @@ snapshots:
punycode@2.3.1: {}
qified@0.6.0:
dependencies:
hookified: 1.15.1
querystring@0.2.1: {}
queue-microtask@1.2.3: {}
readdirp@3.6.0:
@@ -4649,7 +4801,7 @@ snapshots:
dependencies:
regex-utilities: 2.3.0
remixicon@3.5.0: {}
require-from-string@2.0.2: {}
resolve-from@4.0.0: {}
@@ -4787,6 +4939,13 @@ snapshots:
stylis@4.3.6: {}
subset-font@2.4.0:
dependencies:
fontverter: 2.0.0
harfbuzzjs: 0.4.15
lodash: 4.17.23
p-limit: 3.1.0
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -4944,6 +5103,16 @@ snapshots:
transitivePeerDependencies:
- supports-color
vite-plugin-webfont-dl@3.12.0(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)):
dependencies:
axios: 1.13.5
clean-css: 5.3.3
flat-cache: 6.1.20
picocolors: 1.1.1
vite: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)
transitivePeerDependencies:
- debug
vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0):
dependencies:
esbuild: 0.25.12
@@ -5042,10 +5211,57 @@ snapshots:
w3c-keyname@2.2.8: {}
watchpack@2.5.1:
dependencies:
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
wawoff2@2.0.1:
dependencies:
argparse: 2.0.1
webpack-sources@3.3.4: {}
webpack@5.105.0:
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.8
'@types/json-schema': 7.0.15
'@webassemblyjs/ast': 1.14.1
'@webassemblyjs/wasm-edit': 1.14.1
'@webassemblyjs/wasm-parser': 1.14.1
acorn: 8.16.0
acorn-import-phases: 1.0.4(acorn@8.16.0)
browserslist: 4.28.1
chrome-trace-event: 1.0.4
enhanced-resolve: 5.20.0
es-module-lexer: 2.0.0
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
loader-runner: 4.3.1
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 4.3.3
tapable: 2.3.0
terser-webpack-plugin: 5.4.0(webpack@5.105.0)
watchpack: 2.5.1
webpack-sources: 3.3.4
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
which@2.0.2:
dependencies:
isexe: 2.0.0
woff2sfnt-sfnt2woff@1.0.0:
dependencies:
pako: 1.0.11
word-wrap@1.2.5: {}
wrappy@1.0.2: {}
+291
View File
@@ -0,0 +1,291 @@
/**
* subset-mdi-font.mjs
*
* Build script that:
* 1. Scans src/ for all mdi-* icon names used in .vue/.ts files
* 2. Resolves their Unicode codepoints from @mdi/font CSS
* 3. Subsets the MDI font to include only those glyphs (via subset-font, pure JS)
* 4. Generates a minimal CSS file with only the needed icon classes
* 5. Outputs to src/assets/mdi-subset/
*
* Fallback: if any step fails, copies the original full @mdi/font CSS and fonts
* so the build never breaks.
*/
import { readFileSync, writeFileSync, copyFileSync, readdirSync, statSync, existsSync, mkdirSync } from "fs";
import { join, resolve, extname } from "path";
import { fileURLToPath } from "url";
// Derive __dirname portably from import.meta.url (works across all Node ESM versions)
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const ROOT = resolve(__dirname, "..");
const SRC = join(ROOT, "src");
const MDI_CSS_PATH = join(ROOT, "node_modules/@mdi/font/css/materialdesignicons.css");
const MDI_TTF_PATH = join(ROOT, "node_modules/@mdi/font/fonts/materialdesignicons-webfont.ttf");
const MDI_WOFF2_PATH = join(ROOT, "node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff2");
const MDI_WOFF_PATH = join(ROOT, "node_modules/@mdi/font/fonts/materialdesignicons-webfont.woff");
const OUT_DIR = join(ROOT, "src/assets/mdi-subset");
// Utility classes that should not be treated as icon names
const UTILITY_CLASSES = new Set([
"mdi-set", "mdi-spin", "mdi-rotate-45", "mdi-rotate-90", "mdi-rotate-135",
"mdi-rotate-180", "mdi-rotate-225", "mdi-rotate-270", "mdi-rotate-315",
"mdi-flip-h", "mdi-flip-v", "mdi-light", "mdi-dark", "mdi-inactive",
"mdi-18px", "mdi-24px", "mdi-36px", "mdi-48px",
]);
// Regex to match individual icon class definitions in MDI CSS
export const ICON_CLASS_PATTERN = /\.(mdi-[a-z][a-z0-9-]*)::before\s*\{\s*content:\s*"\\([0-9A-Fa-f]+)"\s*;?\s*}/g;
// ── Helper functions ────────────────────────────────────────────────────────
/** Recursively collect files with given extensions, skipping node_modules. */
export function* collectFiles(dir, exts) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory() && entry.name !== "node_modules") {
yield* collectFiles(full, exts);
} else if (exts.includes(extname(entry.name))) {
yield full;
}
}
}
/** Scan source files and return a Set of used mdi-* icon names. */
export function scanUsedIcons(sourceFiles) {
const iconPattern = /mdi-[a-z][a-z0-9-]*/g;
const usedIcons = new Set();
for (const file of sourceFiles) {
const content = readFileSync(file, "utf-8");
for (const match of content.matchAll(iconPattern)) {
if (!UTILITY_CLASSES.has(match[0])) {
usedIcons.add(match[0]);
}
}
}
return usedIcons;
}
/** Parse @mdi/font CSS and return a Map of icon-name → hex codepoint. */
export function parseIconCodepoints(mdiCSS) {
const iconMap = new Map();
for (const match of mdiCSS.matchAll(ICON_CLASS_PATTERN)) {
iconMap.set(match[1], match[2]);
}
return iconMap;
}
/** Resolve used icons against the codepoint map, returning resolved/missing/subsetChars. */
export function resolveUsedIcons(usedIcons, iconMap) {
const resolvedIcons = [];
const missingIcons = [];
const subsetChars = [];
for (const icon of usedIcons) {
const cp = iconMap.get(icon);
if (cp) {
resolvedIcons.push(icon);
subsetChars.push(String.fromCodePoint(parseInt(cp, 16)));
} else {
missingIcons.push(icon);
}
}
return { resolvedIcons, missingIcons, subsetChars };
}
/**
* Extract utility CSS rules (size, rotation, flip, spin, etc.) from the original MDI CSS.
* Uses a subtraction approach: removes the parts we regenerate (icon definitions,
* @font-face, base .mdi rules) and keeps everything else. This is more robust than
* relying on a fixed start marker, as it tolerates CSS reordering in future versions.
*/
export function extractUtilityCss(mdiCSS, iconClassPattern) {
let utilityCss = mdiCSS
.replace(iconClassPattern, "") // Remove icon definitions
.replace(/@font-face\s*\{[\s\S]*?}/g, "") // Remove @font-face
.replace(/\.mdi:before,\s*\.mdi-set\s*\{[\s\S]*?}/g, "") // Remove base rules
.replace(/\/\*# sourceMappingURL=.*\*\//, "") // Remove source map
.trim();
// Clean up excess blank lines left after removals
utilityCss = utilityCss.replace(/(\r\n|\n){3,}/g, "\n\n");
return utilityCss;
}
/** Build a fallback CSS that rewrites font URLs to use subset filenames. */
function buildFallbackCss() {
const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8");
return mdiCSS
// Rewrite woff/woff2 URLs to point at subset filenames
.replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(woff2?)\?[^"]*"\)/g,
(_, ext) => `url("./materialdesignicons-webfont-subset.${ext}")`)
// Remove legacy eot/ttf sources
.replace(/url\("\.\.\/fonts\/materialdesignicons-webfont\.(eot|ttf)\?[^"]*"\)[^,]*/g, "")
// Clean up dangling commas/separators
.replace(/src:\s*,/g, "src:")
.replace(/,\s*;/g, ";");
}
// ── Fallback: copy original full MDI font if subsetting fails ───────────────
function fallbackToFullFont(reason) {
console.warn(`\n⚠️ Subsetting failed: ${reason}`);
console.warn(`⚠️ Falling back to full @mdi/font (build will not break)\n`);
// Copy original font files
if (existsSync(MDI_WOFF2_PATH)) {
copyFileSync(MDI_WOFF2_PATH, join(OUT_DIR, "materialdesignicons-webfont-subset.woff2"));
}
if (existsSync(MDI_WOFF_PATH)) {
copyFileSync(MDI_WOFF_PATH, join(OUT_DIR, "materialdesignicons-webfont-subset.woff"));
}
writeFileSync(join(OUT_DIR, "materialdesignicons-subset.css"), buildFallbackCss());
const size = existsSync(MDI_WOFF2_PATH) ? statSync(MDI_WOFF2_PATH).size : 0;
console.warn(`⚠️ Fallback complete: using full font (${(size / 1024).toFixed(1)} KB woff2)`);
}
// ── Exported entry point ────────────────────────────────────────────────────
export async function runMdiSubset() {
mkdirSync(OUT_DIR, { recursive: true });
try {
// Pre-checks
if (!existsSync(MDI_CSS_PATH)) {
throw new Error(`@mdi/font CSS not found at ${MDI_CSS_PATH}. Run 'pnpm install' first.`);
}
if (!existsSync(MDI_TTF_PATH)) {
throw new Error(`@mdi/font TTF not found at ${MDI_TTF_PATH}. Run 'pnpm install' first.`);
}
// Dynamic import subset-font (may not be installed in all environments)
let subsetFont;
try {
subsetFont = (await import("subset-font")).default;
} catch (e) {
throw new Error(`subset-font package not available: ${e.message}. Run 'pnpm install' first.`);
}
// Step 1: Scan source files for mdi-* icon names
const sourceFiles = collectFiles(SRC, [".vue", ".ts", ".js"]);
const usedIcons = scanUsedIcons(sourceFiles);
if (usedIcons.size === 0) {
throw new Error("No mdi-* icons found in source files. Something is wrong with scanning.");
}
console.log(`✅ Found ${usedIcons.size} unique mdi-* icons in source files`);
// Step 2: Parse @mdi/font CSS to get codepoints for each icon
const mdiCSS = readFileSync(MDI_CSS_PATH, "utf-8");
const iconMap = parseIconCodepoints(mdiCSS);
if (iconMap.size === 0) {
throw new Error("Could not parse any icon definitions from @mdi/font CSS. Format may have changed.");
}
console.log(`📦 MDI font CSS contains ${iconMap.size} icon definitions`);
// Step 3: Resolve codepoints for used icons
const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons(usedIcons, iconMap);
if (missingIcons.length > 0) {
console.warn(`⚠️ ${missingIcons.length} icons not found in MDI CSS:`, missingIcons.join(", "));
}
if (resolvedIcons.length === 0) {
throw new Error("No icon codepoints could be resolved. Icon name format may have changed.");
}
console.log(`🔍 Resolved ${resolvedIcons.length} codepoints for subsetting`);
// Add space character
subsetChars.push(" ");
const subsetText = subsetChars.join("");
// Step 4: Subset font with subset-font (pure JS/WASM)
const fontBuffer = readFileSync(MDI_TTF_PATH);
console.log(`🔧 Subsetting font to woff2...`);
const woff2Buffer = await subsetFont(fontBuffer, subsetText, {
targetFormat: "woff2",
});
console.log(`🔧 Subsetting font to woff...`);
const woffBuffer = await subsetFont(fontBuffer, subsetText, {
targetFormat: "woff",
});
if (woff2Buffer.length === 0 || woffBuffer.length === 0) {
throw new Error("subset-font produced empty output. Font file may be corrupted.");
}
const outWoff2 = join(OUT_DIR, "materialdesignicons-webfont-subset.woff2");
const outWoff = join(OUT_DIR, "materialdesignicons-webfont-subset.woff");
writeFileSync(outWoff2, woff2Buffer);
writeFileSync(outWoff, woffBuffer);
// Step 5: Generate subset CSS
let css = `/* Auto-generated MDI subset – ${resolvedIcons.length} icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
font-family: "Material Design Icons";
src: url("./materialdesignicons-webfont-subset.woff2") format("woff2"),
url("./materialdesignicons-webfont-subset.woff") format("woff");
font-weight: normal;
font-style: normal;
}
.mdi:before,
.mdi-set {
display: inline-block;
font: normal normal normal 24px/1 "Material Design Icons";
font-size: inherit;
text-rendering: auto;
line-height: inherit;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
`;
for (const icon of resolvedIcons.sort()) {
const cp = iconMap.get(icon);
css += `.${icon}::before {\n content: "\\${cp}";\n}\n\n`;
}
const utilityCss = extractUtilityCss(mdiCSS, ICON_CLASS_PATTERN);
if (utilityCss) {
css += `/* Utility classes (extracted from @mdi/font) */\n${utilityCss}\n`;
} else {
console.warn("⚠️ Could not find MDI utility classes in original CSS, skipping");
}
const outCSS = join(OUT_DIR, "materialdesignicons-subset.css");
writeFileSync(outCSS, css);
// Report
const origSize = statSync(MDI_TTF_PATH).size;
const subsetWoff2Size = woff2Buffer.length;
console.log(`\n📊 Results:`);
console.log(` Original TTF font: ${(origSize / 1024).toFixed(1)} KB`);
console.log(` Subset WOFF2: ${(subsetWoff2Size / 1024).toFixed(1)} KB`);
console.log(` Reduction: ${((1 - subsetWoff2Size / origSize) * 100).toFixed(1)}%`);
console.log(` Icons included: ${resolvedIcons.length}`);
console.log(` CSS file: ${outCSS}`);
console.log(`\n✅ MDI font subset generated successfully!`);
} catch (err) {
// Fallback: any failure → use original full font so build never breaks
try {
fallbackToFullFont(err.message);
} catch (fallbackErr) {
console.error(`❌ Fallback also failed: ${fallbackErr.message}`);
console.error(`❌ Please ensure @mdi/font is installed: pnpm install`);
throw fallbackErr;
}
}
}
// ── CLI entry point: allows running directly via `node scripts/subset-mdi-font.mjs` ──
if (import.meta.url.startsWith('file:') && process.argv[1] === fileURLToPath(import.meta.url)) {
runMdiSubset().catch(err => {
console.error(err);
process.exit(1);
});
}
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,8 +1,8 @@
import { createVuetify } from "vuetify";
import "@mdi/font/css/materialdesignicons.css";
import * as components from "vuetify/components";
import * as directives from "vuetify/directives";
import { PurpleTheme } from "@/theme/LightTheme";
import { createVuetify } from 'vuetify';
import '@/assets/mdi-subset/materialdesignicons-subset.css';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import { PurpleTheme } from '@/theme/LightTheme';
import { PurpleThemeDark } from "@/theme/DarkTheme";
import { LIGHT_THEME_NAME, DARK_THEME_NAME } from "@/theme/constants";
+250
View File
@@ -0,0 +1,250 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, writeFileSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import {
collectFiles,
scanUsedIcons,
parseIconCodepoints,
resolveUsedIcons,
extractUtilityCss,
ICON_CLASS_PATTERN,
} from '../scripts/subset-mdi-font.mjs';
// ── Helper: create a temporary directory tree for file-system tests ─────────
function makeTmpDir() {
const base = join(tmpdir(), `mdi-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(base, { recursive: true });
return base;
}
// ── collectFiles ────────────────────────────────────────────────────────────
test('collectFiles yields files matching given extensions', () => {
const tmp = makeTmpDir();
writeFileSync(join(tmp, 'a.vue'), '');
writeFileSync(join(tmp, 'b.ts'), '');
writeFileSync(join(tmp, 'c.txt'), '');
const files = [...collectFiles(tmp, ['.vue', '.ts'])];
assert.equal(files.length, 2);
assert.ok(files.some(f => f.endsWith('a.vue')));
assert.ok(files.some(f => f.endsWith('b.ts')));
rmSync(tmp, { recursive: true });
});
test('collectFiles recurses into subdirectories', () => {
const tmp = makeTmpDir();
const sub = join(tmp, 'sub');
mkdirSync(sub);
writeFileSync(join(sub, 'deep.vue'), '');
const files = [...collectFiles(tmp, ['.vue'])];
assert.equal(files.length, 1);
assert.ok(files[0].endsWith('deep.vue'));
rmSync(tmp, { recursive: true });
});
test('collectFiles skips node_modules directories', () => {
const tmp = makeTmpDir();
const nm = join(tmp, 'node_modules');
mkdirSync(nm);
writeFileSync(join(nm, 'pkg.vue'), '');
writeFileSync(join(tmp, 'app.vue'), '');
const files = [...collectFiles(tmp, ['.vue'])];
assert.equal(files.length, 1);
assert.ok(files[0].endsWith('app.vue'));
rmSync(tmp, { recursive: true });
});
test('collectFiles yields nothing for empty directory', () => {
const tmp = makeTmpDir();
const files = [...collectFiles(tmp, ['.vue'])];
assert.equal(files.length, 0);
rmSync(tmp, { recursive: true });
});
// ── scanUsedIcons ───────────────────────────────────────────────────────────
test('scanUsedIcons extracts mdi-* icon names from files', () => {
const tmp = makeTmpDir();
writeFileSync(join(tmp, 'A.vue'), '<v-icon>mdi-home</v-icon><v-icon>mdi-close</v-icon>');
writeFileSync(join(tmp, 'B.vue'), 'icon="mdi-home"');
const icons = scanUsedIcons(collectFiles(tmp, ['.vue']));
assert.ok(icons instanceof Set);
assert.ok(icons.has('mdi-home'));
assert.ok(icons.has('mdi-close'));
assert.equal(icons.size, 2); // mdi-home deduplicated
rmSync(tmp, { recursive: true });
});
test('scanUsedIcons excludes utility classes', () => {
const tmp = makeTmpDir();
writeFileSync(join(tmp, 'A.vue'), 'mdi-spin mdi-rotate-90 mdi-flip-h mdi-home');
const icons = scanUsedIcons(collectFiles(tmp, ['.vue']));
assert.ok(icons.has('mdi-home'));
assert.ok(!icons.has('mdi-spin'));
assert.ok(!icons.has('mdi-rotate-90'));
assert.ok(!icons.has('mdi-flip-h'));
rmSync(tmp, { recursive: true });
});
test('scanUsedIcons returns empty set when no icons found', () => {
const tmp = makeTmpDir();
writeFileSync(join(tmp, 'A.vue'), '<div>Hello</div>');
const icons = scanUsedIcons(collectFiles(tmp, ['.vue']));
assert.equal(icons.size, 0);
rmSync(tmp, { recursive: true });
});
// ── parseIconCodepoints ─────────────────────────────────────────────────────
test('parseIconCodepoints parses icon definitions from CSS', () => {
const css = `
.mdi-home::before { content: "\\F02DC"; }
.mdi-close::before { content: "\\F0156"; }
`;
const map = parseIconCodepoints(css);
assert.equal(map.size, 2);
assert.equal(map.get('mdi-home'), 'F02DC');
assert.equal(map.get('mdi-close'), 'F0156');
});
test('parseIconCodepoints handles CSS with semicolons inside braces', () => {
const css = `.mdi-check::before { content: "\\F012C"; }`;
const map = parseIconCodepoints(css);
assert.equal(map.get('mdi-check'), 'F012C');
});
test('parseIconCodepoints returns empty map for non-matching CSS', () => {
const css = `.some-other-class { color: red; }`;
const map = parseIconCodepoints(css);
assert.equal(map.size, 0);
});
// ── resolveUsedIcons ────────────────────────────────────────────────────────
test('resolveUsedIcons separates resolved and missing icons', () => {
const usedIcons = new Set(['mdi-home', 'mdi-close', 'mdi-nonexistent']);
const iconMap = new Map([
['mdi-home', 'F02DC'],
['mdi-close', 'F0156'],
]);
const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons(usedIcons, iconMap);
assert.ok(resolvedIcons.includes('mdi-home'));
assert.ok(resolvedIcons.includes('mdi-close'));
assert.equal(resolvedIcons.length, 2);
assert.deepEqual(missingIcons, ['mdi-nonexistent']);
// Verify subsetChars contains correct Unicode characters
assert.equal(subsetChars.length, 2);
assert.equal(subsetChars[0], String.fromCodePoint(0xF02DC));
assert.equal(subsetChars[1], String.fromCodePoint(0xF0156));
});
test('resolveUsedIcons returns all missing when iconMap is empty', () => {
const usedIcons = new Set(['mdi-home']);
const iconMap = new Map();
const { resolvedIcons, missingIcons, subsetChars } = resolveUsedIcons(usedIcons, iconMap);
assert.equal(resolvedIcons.length, 0);
assert.deepEqual(missingIcons, ['mdi-home']);
assert.equal(subsetChars.length, 0);
});
// ── extractUtilityCss ───────────────────────────────────────────────────────
test('extractUtilityCss removes icon definitions and keeps utility rules', () => {
const css = `
@font-face {
font-family: "Material Design Icons";
src: url("../fonts/materialdesignicons-webfont.woff2") format("woff2");
}
.mdi:before,
.mdi-set {
display: inline-block;
font: normal normal normal 24px/1 "Material Design Icons";
}
.mdi-home::before { content: "\\F02DC"; }
.mdi-close::before { content: "\\F0156"; }
.mdi-spin:before {
animation: mdi-spin 2s infinite linear;
}
.mdi-18px.mdi-set, .mdi-18px.mdi:before {
font-size: 18px;
}
/*# sourceMappingURL=materialdesignicons.css.map */
`;
const result = extractUtilityCss(css, ICON_CLASS_PATTERN);
// Should NOT contain icon definitions
assert.ok(!result.includes('mdi-home'));
assert.ok(!result.includes('mdi-close'));
// Should NOT contain @font-face
assert.ok(!result.includes('@font-face'));
// Should NOT contain base .mdi rules
assert.ok(!result.includes('display: inline-block'));
// Should NOT contain source map
assert.ok(!result.includes('sourceMappingURL'));
// SHOULD contain utility classes
assert.ok(result.includes('mdi-spin'));
assert.ok(result.includes('mdi-18px'));
});
test('extractUtilityCss returns empty string when only icon defs exist', () => {
const css = `
@font-face { font-family: "MDI"; src: url("font.woff2"); }
.mdi:before, .mdi-set { display: inline-block; }
.mdi-home::before { content: "\\F02DC"; }
`;
const result = extractUtilityCss(css, ICON_CLASS_PATTERN);
assert.equal(result, '');
});
test('extractUtilityCss handles empty CSS input', () => {
const result = extractUtilityCss('', ICON_CLASS_PATTERN);
assert.equal(result, '');
});
// ── ICON_CLASS_PATTERN ──────────────────────────────────────────────────────
test('ICON_CLASS_PATTERN matches standard MDI icon definitions', () => {
const css = `.mdi-home::before { content: "\\F02DC"; }`;
const matches = [...css.matchAll(ICON_CLASS_PATTERN)];
assert.equal(matches.length, 1);
assert.equal(matches[0][1], 'mdi-home');
assert.equal(matches[0][2], 'F02DC');
});
test('ICON_CLASS_PATTERN does not match non-icon classes', () => {
const css = `.some-class::before { content: "hello"; }`;
const matches = [...css.matchAll(ICON_CLASS_PATTERN)];
assert.equal(matches.length, 0);
});
+28 -11
View File
@@ -1,11 +1,27 @@
import { fileURLToPath, URL } from "url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import vuetify from "vite-plugin-vuetify";
import { fileURLToPath, URL } from 'url';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vuetify from 'vite-plugin-vuetify';
import webfontDl from 'vite-plugin-webfont-dl';
// @ts-ignore — .mjs not in TS project scope; Vite resolves this at runtime
import { runMdiSubset } from './scripts/subset-mdi-font.mjs';
// Vite plugin: run MDI icon font subsetting (build only)
function mdiSubset() {
return {
name: 'vite-plugin-mdi-subset',
async buildStart() {
console.log('\n🔧 Running MDI icon font subsetting...');
await runMdiSubset();
},
};
}
// https://vitejs.dev/config/
export default defineConfig({
export default defineConfig(({ command }) => ({
plugins: [
// Only run MDI subsetting during production builds, skip in dev server
...(command === 'build' ? [mdiSubset()] : []),
vue({
template: {
compilerOptions: {
@@ -14,8 +30,9 @@ export default defineConfig({
},
}),
vuetify({
autoImport: true,
autoImport: true
}),
webfontDl()
],
resolve: {
alias: {
@@ -43,8 +60,8 @@ export default defineConfig({
"/api": {
target: "http://127.0.0.1:6185/",
changeOrigin: true,
ws: true,
},
},
},
});
ws: true
}
}
}
}));
+4 -16
View File
@@ -87,13 +87,7 @@ export default defineConfig({
},
{
text: "OneBot v11",
base: "/platform/aiocqhttp",
collapsed: true,
items: [
{ text: "NapCat", link: "/napcat" },
{ text: "Lagrange", link: "/lagrange" },
{ text: "其他端", link: "/others" },
],
link: "/aiocqhttp"
},
{ text: "企微应用", link: "/wecom" },
{ text: "企微智能机器人", link: "/wecom_ai_bot" },
@@ -111,7 +105,7 @@ export default defineConfig({
base: "/platform/satori",
collapsed: true,
items: [
{ text: "使用 LLOneBot", link: "/llonebot" },
{ text: "接入 Satori", link: "/guide" },
{ text: "使用 server-satori", link: "/server-satori" },
],
},
@@ -327,13 +321,7 @@ export default defineConfig({
},
{
text: "OneBot v11",
base: "/en/platform/aiocqhttp",
collapsed: true,
items: [
{ text: "NapCat", link: "/napcat" },
{ text: "Lagrange", link: "/lagrange" },
{ text: "Other Clients", link: "/others" },
],
link: "/aiocqhttp",
},
{ text: "WeCom Application", link: "/wecom" },
{ text: "WeCom AI Bot", link: "/wecom_ai_bot" },
@@ -350,7 +338,7 @@ export default defineConfig({
base: "/en/platform/satori",
collapsed: true,
items: [
{ text: "Using LLOneBot", link: "/llonebot" },
{ text: "Connect Satori", link: "/guide" },
{ text: "Using server-satori", link: "/server-satori" },
],
},
+44
View File
@@ -0,0 +1,44 @@
# Connect OneBot v11 Protocol Implementations
OneBot is a standardized bot application interface designed to unify bot development across different chat platforms, so developers can write business logic once and use it on multiple platforms.
AstrBot supports all client implementations that implement OneBot v11 reverse WebSocket (AstrBot acts as the server).
Common OneBot v11 implementation projects are listed below:
- [NapCat](https://github.com/NapNeko/NapCatQQ)
- [OneDisc](https://github.com/ITCraftDevelopmentTeam/OneDisc)
- [Tele-KiraLink](https://github.com/Echomirix/Tele-KiraLink)
Please refer to each implementation project's deployment documentation.
## 1. Configure OneBot v11
1. Open AstrBot's WebUI
2. Click `Bots` in the left sidebar
3. In the right panel, click `+ Create Bot`
4. Select `OneBot v11`
Fill in the form:
- ID (`id`): any value, used only to distinguish instances of different platforms.
- Enable (`enable`): check it.
- Reverse WebSocket host: fill your machine IP, usually `0.0.0.0`.
- Reverse WebSocket port: choose any port, default is `6199`.
- Reverse WebSocket token: fill this only when NapCat network configuration has a token set.
Click `Save`.
## 2. Configure the protocol implementation side
Please refer to each protocol implementation project's deployment documentation.
Notes:
1. The implementation must support `Reverse WebSocket`, with AstrBot acting as the server and the implementation client as the client.
2. The reverse WebSocket URL is `ws(s)://<your-host>:6199/ws`.
## 3. Verify
Go to AstrBot WebUI `Console`. If a blue log appears saying `aiocqhttp(OneBot v11) adapter connected.`, the connection is successful.
If after a few seconds you see `aiocqhttp adapter has been closed`, it means the connection timed out (failed). Please double-check your configuration.
-55
View File
@@ -1,55 +0,0 @@
# Connect to Lagrange
> [!TIP]
> - Please control message frequency responsibly. Sending messages too frequently may trigger risk control.
> - This project must not be used for illegal purposes.
> - For the latest deployment steps, always refer to the official [Lagrange Docs](https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E4%B8%8B%E8%BD%BD%E5%AE%89%E8%A3%85).
## Download
Download the latest `Lagrange.OneBot` from [GitHub Releases](https://github.com/LagrangeDev/Lagrange.Core/releases).
- Windows: `Lagrange.OneBot_win-x64_xxxx`
- Linux x86_64: `Lagrange.OneBot_linux-x64_xxx`
- Linux ARM64: `Lagrange.OneBot_linux-arm64_xxx`
- macOS Apple Silicon: `Lagrange.OneBot_osx-arm64_xxx`
- macOS Intel: `Lagrange.OneBot_osx-x64_xxx`
## Deploy
Follow the official docs:
- Run guide: <https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E8%BF%90%E8%A1%8C>
- Config file guide: <https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6>
In your config file, add this under `Implementations`:
```json
{
"Type": "ReverseWebSocket",
"Host": "127.0.0.1",
"Port": 6199,
"Suffix": "/ws",
"ReconnectInterval": 5000,
"HeartBeatInterval": 5000,
"AccessToken": ""
}
```
Make sure `Suffix` is exactly `/ws`.
## Connect to AstrBot
### Configure `aiocqhttp` Adapter
1. Open AstrBot Dashboard.
2. Click `Bots` in the left sidebar.
3. Click `+ Create Bot`.
4. Select `aiocqhttp (OneBot v11)`.
Fill in:
- ID (`id`): any unique identifier.
- Enable (`enable`): checked.
- Reverse WebSocket host: your machine IP (usually `0.0.0.0`).
- Reverse WebSocket port: an available port, for example `6199`.
-141
View File
@@ -1,141 +0,0 @@
# Using NapCat
> [!TIP]
>
> - Please control usage frequency appropriately. Sending messages too frequently may be identified as abnormal behavior, increasing the risk of triggering risk control mechanisms.
> - This project is strictly prohibited from being used for any purpose that violates laws and regulations. If you intend to use AstrBot for illegal industries or activities, we **explicitly oppose and refuse** your use of this project.
> - AstrBot connects to the OneBot v11 protocol through the `aiocqhttp` adapter. OneBot v11 protocol is an open communication protocol and does not represent any specific software or service.
NapCat's GitHub Repository: [NapCat](https://github.com/NapNeko/NapCatQQ)
NapCat's Documentation: [NapCat Documentation](https://napcat.napneko.icu/)
NapCat provides multiple deployment methods, including Docker, Windows one-click installation packages, and more.
## Deploy via One-Click Script
This deployment method is recommended.
### Windows
Refer to this article: [NapCat.Shell - Windows Manual Start Tutorial](https://napneko.github.io/guide/boot/Shell#napcat-shell-win%E6%89%8B%E5%8A%A8%E5%90%AF%E5%8A%A8%E6%95%99%E7%A8%8B)
### Linux
Refer to this article: [NapCat.Installer - Linux One-Click Script (Supports Ubuntu 20+/Debian 10+/Centos9)](https://napneko.github.io/guide/boot/Shell#napcat-installer-linux%E4%B8%80%E9%94%AE%E4%BD%BF%E7%94%A8%E8%84%9A%E6%9C%AC-%E6%94%AF%E6%8C%81ubuntu-20-debian-10-centos9)
> [!TIP]
> **Where to open Napcat WebUI**:
> The WebUI link will be displayed in napcat's logs.
>
> If napcat is deployed via Linux command line one-click deployment: `docker log <account>`.
>
> For Docker-deployed NapCat: `docker logs napcat`.
## Deploy via Docker Compose
> [!TIP]
> If deploying with Docker Compose, no configuration is needed on the NapCat side. Just log in via NapCat WebUI (running on port 6099) or `docker logs napcat`, enable the aiocqhttp adapter on the AstrBot side to connect, and you can directly implement normal receiving and sending of `voice data` and `file data`.
1. Download or copy the content of [astrbot.yml](https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml)
2. Rename the downloaded file to `astrbot.yml`
3. Modify `astrbot.yml`, change `#- "6199:6199` to `- "6199:6199"`, remove the flag of "#"
4. Execute in the directory where the `astrbot.yml` file is located:
```bash
NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose -f ./astrbot.yml up -d
```
## Deploy via Docker
> [!TIP]
> If deploying with Docker, you will not be able to properly receive `voice data` and `file data`. This means voice-to-text and sandbox file input functions will not be available. You can receive text messages, image messages, and other types of messages.
This tutorial assumes you have Docker installed.
Execute the following command in the terminal for one-click deployment.
```bash
docker run -d \
-e NAPCAT_GID=$(id -g) \
-e NAPCAT_UID=$(id -u) \
-p 3000:3000 \
-p 3001:3001 \
-p 6099:6099 \
--name napcat \
--restart=always \
mlikiowa/napcat-docker:latest
```
After successful execution, you need to check the logs to get the login QR code and the management panel URL.
```bash
docker logs napcat
```
Please copy the management panel URL and open it in your browser.
Then use the account you want to log in with to scan the QR code that appears.
If there are no issues during the login stage, deployment is successful.
## Connect to AstrBot
## Configure aiocqhttp in AstrBot
1. Enter AstrBot's management panel
2. Click `Bots` in the left sidebar
3. Then in the interface on the right, click `+ Create Bot`
4. Select `OneBot v11`
Fill in the configuration items that appear:
- ID(id): Fill in arbitrarily, only used to distinguish different messaging platform instances.
- Enable: Check this.
- Reverse WebSocket Host Address: Please fill in your machine's IP address, generally fill in `0.0.0.0` directly
- Reverse WebSocket Port: Fill in a port, default is `6199`.
- Reverse Websocket Token: Only needs to be filled when a token is configured in NapCat's network settings.
Example image: (At the fastest, just check Enable, then save)
<img width="818" height="799" alt="xinjianya" src="https://github.com/user-attachments/assets/813ac338-2fd7-4add-bde4-8b0f6d0bda95" />
Click `Save`.
### Configure Administrator
After filling in, go to the `Configuration File` page, click the `Platform Configuration` tab, find `Administrator ID`, and fill in your account number (not the bot's account number).
Remember to click `Save` in the lower right corner, AstrBot will restart and apply the configuration.
### Add WebSocket Client in NapCat
Switch back to NapCat's management panel, click `Network Configuration->New->WebSockets Client`.
<img width="649" height="751" alt="jiaochenXJY" src="https://github.com/user-attachments/assets/5044f96a-a81f-407a-a3b1-0c518499eda4" />
In the newly opened window:
- Check `Enable`.
- Fill in `URL` with `ws://HostIP:Port/ws`. For example, `ws://localhost:6199/ws` or `ws://127.0.0.1:6199/ws`.
> [!IMPORTANT]
> 1. If deploying with Docker and both AstrBot and NapCat containers are connected to the same network, use `ws://astrbot:6199/ws` (refer to the Docker script in this documentation).
> 2. Due to Docker network isolation, when not on the same network, please use the internal network IP address or public network IP address ***(unsafe)*** to connect, i.e., `ws://(internal/public IP):6199/ws`.
- Message Format: `Array`
- Heartbeat Interval: `5000`
- Reconnection Interval: `5000`
> [!WARNING]
>
> 1. Remember to add `/ws` at the end!
> 2. The IP here cannot be `0.0.0.0`
Click `Save`.
Go to AstrBot WebUI `Console`, if you see the blue log ` aiocqhttp(OneBot v11) adapter connected.`, it means the connection is successful. If not, and after several seconds ` aiocqhttp adapter has been closed` appears, it indicates connection timeout (failed), please check if the configuration is correct.
## 🎉 All Done
At this point, your AstrBot and NapCat should be successfully connected! Use `private message` to send `/help` to the bot to check if the connection is successful.
-1
View File
@@ -1 +0,0 @@
AstrBot can connect to any bot protocol client that supports OneBot v11 reverse WebSocket (AstrBot acts as the server side).
+32
View File
@@ -0,0 +1,32 @@
# Connect to Satori Protocol
## Satori protocol overview
> Excerpt from: https://satori.chat/introduction.html
Satori is a unified chat protocol. It aims to reduce differences between chat platforms and let developers build cross-platform, extensible, high-performance chat applications with lower cost.
The protocol is named after [Komeiji Satori](https://satori.js.org) in Touhou Project. The idea is that Satori can serve as a bridge between chat platforms, as Komeiji Satori communicates telepathically.
The development team behind Satori has long worked on bot development and is familiar with the communication patterns of many platforms. After about 4 years, Satori now has a mature design and implementation. The official project currently provides adapters for more than 15 platforms, covering major messaging services worldwide such as QQ, Discord, WeCom, KOOK, and others.
## 1. Configure the protocol server side
Please refer to the deployment documentation of the chosen implementation project.
## 2. Configure Satori protocol in AstrBot
1. Open AstrBot WebUI.
2. Click `Bots` in the left sidebar.
3. In the right panel, click `+ Create Bot`.
4. Select `satori`.
Fill in the form:
- Bot ID (`id`): e.g. `satori` (any value is fine).
- Enable (`enable`): check it.
- Satori API base URL (`satori_api_base_url`): `http://localhost:5600/v1` (same port as the protocol implementation).
- Satori WebSocket endpoint (`satori_endpoint`): `ws://localhost:5600/v1/events` (same port as the protocol implementation).
- Satori token (`satori_token`): fill according to implementation settings.
Click `Save`.
-73
View File
@@ -1,73 +0,0 @@
# Connect LLTwoBot (Satori)
> [!TIP]
> LLTwoBot is a multi-protocol implementation based on QQNT (OneBot v11 + Satori), allowing AstrBot to communicate with QQ via Satori.
> [!TIP]
> - Please control message frequency responsibly.
> - This project must not be used for illegal purposes.
## Preparation
First complete basic setup using official LLTwoBot documentation:
[LLTwoBot Docs](https://llonebot.com/guide/getting-started)
Make sure you have:
1. Installed LLTwoBot.
2. Logged into a QQ account successfully.
## Configure Satori in LLTwoBot
After QQ login succeeds, open LLTwoBot WebUI:
> Default WebUI URL: <http://localhost:3080/>
In the WebUI sidebar, open the `Satori` tab and configure:
1. Enable Satori protocol.
2. Port defaults to `5600`.
3. Set Satori token if needed.
4. Click Save.
![image](https://files.astrbot.app/docs/source/images/satori/2025-10-10_15-52-32.png)
## Configure Satori Adapter in AstrBot
1. Open AstrBot Dashboard.
2. Click `Bots`.
3. Click `+ Create Bot`.
4. Select `satori`.
Fill in:
- Bot ID (`id`): `LLTwoBot`
- Enable (`enable`): checked
- Satori API endpoint (`satori_api_base_url`): `http://localhost:5600/v1`
- Satori WebSocket endpoint (`satori_endpoint`): `ws://localhost:5600/v1/events`
- Satori token (`satori_token`): from LLTwoBot config if set
> [!NOTE]
> - LLTwoBot Satori service defaults to port `5600`.
> - The complete API base path is `http://localhost:5600/v1`.
> - If your Satori service runs on another port/path, adjust these values.
![image](https://files.astrbot.app/docs/source/images/satori/2025-10-10_16-10-54.png)
Click `Save`.
## Done
AstrBot should now be connected to LLTwoBot via Satori.
Send `/help` in QQ to verify.
## Troubleshooting
If connection fails, check:
1. LLTwoBot is running.
2. Satori service is enabled.
3. Port/path are configured correctly.
4. Token matches (if configured).
+83
View File
@@ -0,0 +1,83 @@
# 接入 OneBot v11 协议实现
OneBot 是一个**聊天机器人应用接口标准**,旨在统一不同聊天平台上的机器人应用开发接口。
AstrBot 支持接入所有适配了 OneBotv11 反向 Websockets(AstrBot 做服务器端)的机器人协议端。
下文给出一些常见的 OneBot v11 协议实现端项目。
- [NapCat](https://github.com/NapNeko/NapCatQQ) (连接到 QQ)
- [OneDisc](https://github.com/ITCraftDevelopmentTeam/OneDisc) (连接到 Discord)
- [Tele-KiraLink](https://github.com/Echomirix/Tele-KiraLink) (连接到 Telegram)
请参阅对应的协议实现端项目的部署文档。
对于 Napcat 项目,请参考下文的 `附录:部署 Napcat`
## 1. 配置 OneBot v11
1. 进入 AstrBot 的 WebUI
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `OneBot v11`
在出现的表单中,填写:
- ID(id):随意填写,仅用于区分不同的消息平台实例。
- 启用(enable): 勾选。
- 反向 WebSocket 主机地址:请填写你的机器的 IP 地址,一般情况下请直接填写 `0.0.0.0`
- 反向 WebSocket 端口:填写一个端口,默认为 `6199`。
- 反向 Websocket Token:只有当 NapCat 网络配置中配置了 token 才需填写。
点击 `保存`。
## 2. 配置协议实现端
请参阅对应的协议实现端项目的部署文档。
一些注意点:
1. 协议实现端需要支持 `反向 WebSocket` 实现,及 AstrBot 端作为服务端,实现端作为客户端。
2. `反向 WebSocket` 的 URL 为 `ws(s)://<your-host>:6199/ws`。
## 3. 验证
前往 AstrBot WebUI `控制台`,如果出现 ` aiocqhttp(OneBot v11) 适配器已连接。` 蓝色的日志,说明连接成功。如果没有,若干秒后出现` aiocqhttp 适配器已被关闭` 则为连接超时(失败),请检查配置是否正确。
## 附录:部署 Napcat
### 通过一键启动脚本部署
推荐采用这种方式部署。
#### Windows
看这篇文章:[NapCat.Shell - Win手动启动教程](https://napneko.github.io/guide/boot/Shell#napcat-shell-win%E6%89%8B%E5%8A%A8%E5%90%AF%E5%8A%A8%E6%95%99%E7%A8%8B)
#### Linux
看这篇文章:[NapCat.Installer - Linux一键使用脚本(支持Ubuntu 20+/Debian 10+/Centos9)](https://napneko.github.io/guide/boot/Shell#napcat-installer-linux%E4%B8%80%E9%94%AE%E4%BD%BF%E7%94%A8%E8%84%9A%E6%9C%AC-%E6%94%AF%E6%8C%81ubuntu-20-debian-10-centos9)
> [!TIP]
> **Napcat WebUI 在哪打开**:
> 在 napcat 的日志里会显示 WebUI 链接。
>
> 如果是 linux 命令行一键部署的napcat:`docker log <账号>`。
>
> Docker部署的 NapCat:`docker logs napcat`。
## 通过 Docker Compose 部署
1. 下载或复制 [astrbot.yml](https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml) 内容
2. 将刚刚下载的文件重命名为 `astrbot.yml`
3. 编辑 `astrbot.yml`,将 `# - "6199:6199"` 修改为 `- "6199:6199"`,移除开头的 `#`
4. 在 `astrbot.yml` 文件所在目录执行:
```bash
NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose -f ./astrbot.yml up -d
```
部署完毕之后,可以去 Napcat 的 WebUI(默认端口 6099)中新增 OneBot 连接实例:点击`网络配置->新建->WebSockets客户端`,在新弹出的窗口中:勾选`启用`,
URL 填写 `ws://宿主机IP:端口/ws`。如 `ws://127.0.0.1:6199/ws`。如果采用上面的 Docker Compose 部署,可以填写 `ws://astrbot:6199/ws`(参考本文档的 Docker 脚本)。心跳间隔和重连间隔可以改为 `1000`(1 秒)。点击保存,然后去 AstrBot WebUI 的控制台中检查是否连接成功,出现 `aiocqhttp(OneBot v11) 适配器已连接` 日志即代表成功。
如果您对部署、网络配置不了解,请千万不要在公网暴露 Napcat 的端口。
-61
View File
@@ -1,61 +0,0 @@
# 接入 Lagrange
> [!TIP]
>
> - 请合理控制使用频率。过于频繁地发送消息可能会被判定为异常行为,增加触发风控机制的风险。
> - 本项目严禁用于任何违反法律法规的用途。若您意图将 AstrBot 应用于非法产业或活动,我们**明确反对并拒绝**您使用本项目。
> - 最新的部署方式请以 [Lagrange Doc](https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E4%B8%8B%E8%BD%BD%E5%AE%89%E8%A3%85) 为准。
## 下载
从 [GitHub Release](https://github.com/LagrangeDev/Lagrange.Core/releases) 下载最新版的 `Lagrange.OneBot`。
对于 Windows 设备,请下载 `Lagrange.OneBot_win-x64_xxxx` 压缩包。
对于 X86 的 Linux 用户,下载 `Lagrange.OneBot_linux-x64_xxx` 压缩包。
对于 Arm 的 Linux 用户,下载 `Lagrange.OneBot_linux-arm64_xxx` 压缩包。
对于 M 芯片 Mac 用户,下载 `Lagrange.OneBot_osx-arm64_xxx` 压缩包。
对于 Intel 芯片 Mac 用户,下载 `Lagrange.OneBot_osx-x64_xxx` 压缩包。
## 部署
请参阅 [Lagrange Doc](https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E8%BF%90%E8%A1%8C)。
运行完成后,请修改 [配置文件](https://lagrangedev.github.io/Lagrange.Doc/Lagrange.OneBot/Config/#%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6),
在 `Implementations` 字段下添加:
```json
{
"Type": "ReverseWebSocket",
"Host": "127.0.0.1",
"Port": 6199,
"Suffix": "/ws",
"ReconnectInterval": 5000,
"HeartBeatInterval": 5000,
"AccessToken": ""
}
```
一定要保证 `Suffix` 为 `/ws`。
## 连接到 AstrBot
### 配置 aiocqhttp
1. 进入 AstrBot 的管理面板
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `aiocqhttp(OneBotv11)`
弹出的配置项填写:
配置项填写:
- ID(id):随意填写,用于区分不同的消息平台实例。
- 启用(enable): 勾选。
- 反向 WebSocket 主机地址:请填写你的机器的 IP 地址。一般情况下请直接填写 `0.0.0.0`
- 反向 WebSocket 端口:填写一个端口,例如 `6199`。
-134
View File
@@ -1,134 +0,0 @@
# 使用 NapCat
> [!TIP]
>
> - 本项目严禁用于任何违反法律法规的用途。若您意图将 AstrBot 应用于非法产业或活动,我们**明确反对并拒绝**您使用本项目。
> - AstrBot 通过 `aiocqhttp` 适配器接入 OneBot v11 协议。OneBot v11 协议是一个开放的通信协议,并不代表任何具体的软件或服务。
NapCat 的 GitHub 仓库:[NapCat](https://github.com/NapNeko/NapCatQQ)
NapCat 的文档:[NapCat 文档](https://napcat.napneko.icu/)
NapCat 提供了大量的部署方式,包括 Docker、Windows 一键安装包等等。
## 通过一键脚本部署
推荐采用这种方式部署。
### Windows
看这篇文章:[NapCat.Shell - Win手动启动教程](https://napneko.github.io/guide/boot/Shell#napcat-shell-win%E6%89%8B%E5%8A%A8%E5%90%AF%E5%8A%A8%E6%95%99%E7%A8%8B)
### Linux
看这篇文章:[NapCat.Installer - Linux一键使用脚本(支持Ubuntu 20+/Debian 10+/Centos9)](https://napneko.github.io/guide/boot/Shell#napcat-installer-linux%E4%B8%80%E9%94%AE%E4%BD%BF%E7%94%A8%E8%84%9A%E6%9C%AC-%E6%94%AF%E6%8C%81ubuntu-20-debian-10-centos9)
> [!TIP]
> **Napcat WebUI 在哪打开**:
> 在 napcat 的日志里会显示 WebUI 链接。
>
> 如果是 linux 命令行一键部署的napcat:`docker log <账号>`。
>
> Docker部署的 NapCat:`docker logs napcat`。
## 通过 Docker Compose 部署
1. 下载或复制 [astrbot.yml](https://github.com/NapNeko/NapCat-Docker/blob/main/compose/astrbot.yml) 内容
2. 将刚刚下载的文件重命名为 `astrbot.yml`
3. 编辑 `astrbot.yml`,将 `# - "6199:6199"` 修改为 `- "6199:6199"`,移除开头的 `#`
4. 在 `astrbot.yml` 文件所在目录执行:
```bash
NAPCAT_UID=$(id -u) NAPCAT_GID=$(id -g) docker compose -f ./astrbot.yml up -d
```
## 通过 Docker 部署
此教程默认您安装了 Docker。
在终端执行以下命令即可一键部署。
```bash
docker run -d \
-e NAPCAT_GID=$(id -g) \
-e NAPCAT_UID=$(id -u) \
-p 3000:3000 \
-p 3001:3001 \
-p 6099:6099 \
--name napcat \
--restart=always \
mlikiowa/napcat-docker:latest
```
执行成功后,需要查看日志以得到登录二维码和管理面板的 URL。
```bash
docker logs napcat
```
请复制管理面板的 URL,然后在浏览器中打开备用。
然后使用你要登录的账号扫描出现的二维码,即可登录。
如果登录阶段没有出现问题,即成功部署。
## 连接到 AstrBot
## 在 AstrBot 配置 aiocqhttp
1. 进入 AstrBot 的管理面板
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `OneBot v11`
弹出的配置项填写:
- ID(id):随意填写,仅用于区分不同的消息平台实例。
- 启用(enable): 勾选。
- 反向 WebSocket 主机地址:请填写你的机器的 IP 地址,一般情况下请直接填写 `0.0.0.0`
- 反向 WebSocket 端口:填写一个端口,默认为 `6199`。
- 反向 Websocket Token:只有当 NapCat 网络配置中配置了 token 才需填写。
图例:(最快只需要点击启用,然后保存即可)
<img width="818" height="799" alt="xinjianya" src="https://github.com/user-attachments/assets/813ac338-2fd7-4add-bde4-8b0f6d0bda95" />
点击 `保存`。
### 配置管理员
填写完毕后,进入 `配置文件` 页,点击 `平台配置` 选项卡,找到 `管理员 ID`,填写你的账号(不是机器人的账号)。
切记点击右下角 `保存`,AstrBot 重启并会应用配置。
### 在 NapCat 中添加 WebSocket 客户端
切换回 NapCat 的管理面板,点击 `网络配置->新建->WebSockets客户端`。
<img width="649" height="751" alt="jiaochenXJY" src="https://github.com/user-attachments/assets/5044f96a-a81f-407a-a3b1-0c518499eda4" />
在新弹出的窗口中:
- 勾选 `启用`。
- `URL` 填写 `ws://宿主机IP:端口/ws`。如 `ws://localhost:6199/ws` 或 `ws://127.0.0.1:6199/ws`。
> [!IMPORTANT]
> 1. 如果采用 Docker 部署并同时把 AstrBot 和 NapCat 两个容器接入了同一网络,`ws://astrbot:6199/ws`(参考本文档的 Docker 脚本)。
> 2. 由于 Docker 网络隔离的原因,不在同一个网络时请使用内网 IP 地址或公网 IP 地址 ***(不安全)*** 进行连接,即 `ws://(内网/公网):6199/ws`。
- 消息格式:`Array`
- 心跳间隔: `5000`
- 重连间隔: `5000`
> [!WARNING]
>
> 1. 切记后面加一个 `/ws`!
> 2. 这里的 IP 不能填为 `0.0.0.0`
点击 `保存`。
前往 AstrBot WebUI `控制台`,如果出现 ` aiocqhttp(OneBot v11) 适配器已连接。` 蓝色的日志,说明连接成功。如果没有,若干秒后出现` aiocqhttp 适配器已被关闭` 则为连接超时(失败),请检查配置是否正确。
## 🎉 大功告成
此时,你的 AstrBot 和 NapCat 应该已经连接成功!使用 `私聊` 的方式对机器人发送 `/help` 以检查是否连接成功。
-1
View File
@@ -1 +0,0 @@
支持接入所有适配了 OneBotv11 反向 Websockets(AstrBot 做服务器端) 的机器人协议端。
+32
View File
@@ -0,0 +1,32 @@
# 接入 Satori 协议
## Satori 协议简介
> 摘录自:https://satori.chat/zh-CN/introduction.html
Satori 是一个通用的聊天协议。Satori 协议希望能够抹平不同聊天平台之间的差异,让开发者以更低的成本开发出跨平台、可扩展、高性能的聊天应用。
Satori 的名称来源于游戏东方 Project 中的角色 [古明地觉 (Komeiji Satori)](https://zh.touhouwiki.net/wiki/%E5%8F%A4%E6%98%8E%E5%9C%B0%E8%A7%89)。古明地觉能够以心灵感应的方式与各种动物交流,取这个名字是希望 Satori 能够成为各个聊天平台之间的桥梁。
Satori 的开发团队长期从事聊天机器人开发,熟悉各种聊天平台的通信方式。经过长达 4 年的发展,Satori 有了健全的设计和完善的实现。目前,Satori 官方提供了超过 15 个聊天平台的适配器,完全覆盖了世界上主流的聊天平台,如 QQ、Discord、企业微信、KOOK 等等。
## 1. 配置协议实现端
请参阅对应的协议实现端项目的部署文档。
## 2. 配置 Satori 协议
1. 进入 AstrBot 的 WebUI
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `Satori`
弹出的配置项填写:
- 机器人名称 (id): `satori` (随意)
- 启用 (enable): 勾选
- Satori API 终结点 (satori_api_base_url):`http://localhost:5600/v1`(端口和上面配置的协议端端口一致)
- Satori WebSocket 终结点 (satori_endpoint):`ws://localhost:5600/v1/events`(端口和上面配置的协议端端口一致)
- Satori 令牌 (satori_token):根据协议端配置情况选择填写
点击 `保存`。
-78
View File
@@ -1,78 +0,0 @@
# 接入 LLTwoBot (Satori)
> [!TIP]
> LLTwoBot 是一个基于 QQNT 的 Onebot v11、Satori 多协议实现端,可以让你在 QQ 平台使用 Satori 协议与 AstrBot 进行通信。
> [!TIP]
>
> - 请合理控制使用频率。过于频繁地发送消息可能会被判定为异常行为,增加触发风控机制的风险。
> - 本项目严禁用于任何违反法律法规的用途。若您意图将 AstrBot 应用于非法产业或活动,我们**明确反对并拒绝**您使用本项目。
## 准备工作
请先参考 LLTwoBot 官方文档完成基础配置:
[LLTwoBot 文档](https://llonebot.com/guide/getting-started)
完成文档中的步骤,确保你已经:
1. 下载并安装了 LLTwoBot
2. 成功登录了 QQ 账号
## 配置 LLTwoBot 的 Satori 服务
在成功登录 QQ 后,先打开 LLTwoBot 的 WebUI 配置界面。
> WebUI 默认地址为:<http://localhost:3080/>
---
在WebUI的配置界面侧边,选择【Satori】选项卡,进行如下配置:
1. 确认【启用 Satori 协议】配置项已开启
2. 端口默认为 5600(如需修改请记住新端口)
3. 如有必要,可填写【Satori Token】
4. 点击右下角的【保存配置】
![image](https://files.astrbot.app/docs/source/images/satori/2025-10-10_15-52-32.png)
## 在 AstrBot 中配置 Satori 适配器
1. 进入 AstrBot 的管理面板
2. 点击左边栏 `机器人`
3. 然后在右边的界面中,点击 `+ 创建机器人`
4. 选择 `satori`
弹出的配置项填写:
- 机器人名称 (id): `LLTwoBot`
- 启用 (enable): 勾选
- Satori API 终结点 (satori_api_base_url):`http://localhost:5600/v1`
- Satori WebSocket 终结点 (satori_endpoint):`ws://localhost:5600/v1/events`
- Satori 令牌 (satori_token):根据 LLTwoBot 配置填写(如有设置)
> [!NOTE]
>
> - LLTwoBot 的 satori协议 默认在 `5600` 端口提供服务
> - 因此完整的 URL 路径为 `http://localhost:5600/v1`
>
> 如果你的 satori协议运行在其他端口,请根据实际情况修改对应的配置!
![image](https://files.astrbot.app/docs/source/images/satori/2025-10-10_16-10-54.png)
点击右下角 `保存` 完成配置。
## 🎉 大功告成
此时,你的 AstrBot 应该已经通过 Satori 协议成功连接到 LLTwoBot。
在 QQ 中发送 `/help` 以检查是否连接成功。
如果成功回复,则配置成功。
## 常见问题
如果遇到连接问题,请检查:
1. LLTwoBot 是否正常运行
2. Satori 服务是否已启用
3. 端口配置是否正确
4. Token 是否匹配(如设置了 Token)