♻️ refactor(command): 配置系统从 SQLite 迁移至 YAML 并修复白名单失效
- 用 CommandConfig dataclass 单例替代模块级变量,解决 from import 造成的本地绑定不随 global 更新的 bug - 删除 db.py,改用 settings.yaml 存储动态配置,首次启动自动创建并合并 .env 默认值 - 新增文件轮询 watcher(2 秒),检测 YAML 变更自动热重载 - 管理界面 API 改为直接读写 YAML,即时生效 - 依赖 aiosqlite 替换为 pyyaml
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -15,3 +15,9 @@ uploads/
|
|||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Dynamic config
|
||||||
|
settings.yaml
|
||||||
|
|
||||||
|
# Legacy data
|
||||||
|
data/
|
||||||
|
|||||||
307
config.py
307
config.py
@@ -1,107 +1,270 @@
|
|||||||
"""项目配置:静态配置从环境变量读取,命令监听配置支持数据库动态修改。"""
|
"""项目配置:静态配置从环境变量读取,命令监听配置通过 settings.yaml 动态管理。"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# 加载 .env 文件(优先从插件目录查找)
|
# 加载 .env 文件(优先从插件目录查找)
|
||||||
load_dotenv(Path(__file__).parent / ".env")
|
load_dotenv(Path(__file__).parent / ".env")
|
||||||
|
|
||||||
# ── 鉴权 ────────────────────────────────────────────────────
|
logger = logging.getLogger("webhook-plugin.config")
|
||||||
WEBHOOK_API_KEY: str = os.environ.get("WEBHOOK_API_KEY", "") or uuid.uuid4().hex
|
|
||||||
|
|
||||||
# ── 网络 ─────────────────────────────────────────────────────
|
# ── 静态配置(环境变量,运行时不变)────────────────────────────
|
||||||
|
WEBHOOK_API_KEY: str = os.environ.get("WEBHOOK_API_KEY", "") or uuid.uuid4().hex
|
||||||
HOST: str = os.environ.get("WEBHOOK_HOST", "0.0.0.0")
|
HOST: str = os.environ.get("WEBHOOK_HOST", "0.0.0.0")
|
||||||
try:
|
try:
|
||||||
PORT: int = int(os.environ.get("WEBHOOK_PORT", "8081"))
|
PORT: int = int(os.environ.get("WEBHOOK_PORT", "8081"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
PORT = 8081
|
PORT = 8081
|
||||||
|
|
||||||
# ── 上传 ─────────────────────────────────────────────────────
|
|
||||||
UPLOAD_DIR: Path = Path(os.environ.get("UPLOAD_DIR", str(Path(__file__).parent / "uploads")))
|
UPLOAD_DIR: Path = Path(os.environ.get("UPLOAD_DIR", str(Path(__file__).parent / "uploads")))
|
||||||
# 单个文件最大 20 MB
|
|
||||||
MAX_UPLOAD_SIZE: int = int(os.environ.get("MAX_UPLOAD_SIZE", str(20 * 1024 * 1024)))
|
MAX_UPLOAD_SIZE: int = int(os.environ.get("MAX_UPLOAD_SIZE", str(20 * 1024 * 1024)))
|
||||||
# 允许的文件扩展名(小写,不含点),为空则不限制
|
|
||||||
ALLOWED_EXTENSIONS: set[str] = set(
|
ALLOWED_EXTENSIONS: set[str] = set(
|
||||||
filter(None, os.environ.get("ALLOWED_EXTENSIONS", "").lower().split(","))
|
filter(None, os.environ.get("ALLOWED_EXTENSIONS", "").lower().split(","))
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── QQ API ───────────────────────────────────────────────────
|
|
||||||
QQ_API_TIMEOUT: float = float(os.environ.get("QQ_API_TIMEOUT", "10"))
|
QQ_API_TIMEOUT: float = float(os.environ.get("QQ_API_TIMEOUT", "10"))
|
||||||
QQ_API_MAX_RETRIES: int = int(os.environ.get("QQ_API_MAX_RETRIES", "2"))
|
QQ_API_MAX_RETRIES: int = int(os.environ.get("QQ_API_MAX_RETRIES", "2"))
|
||||||
|
|
||||||
# ── 命令监听(可动态修改,从数据库加载) ──────────────────────
|
# ── YAML 文件路径 ────────────────────────────────────────────
|
||||||
COMMAND_PREFIX: str = os.environ.get("COMMAND_PREFIX", "#")
|
SETTINGS_YAML_PATH: Path = Path(__file__).parent / "settings.yaml"
|
||||||
COMMAND_LENGTH_MIN: int = int(os.environ.get("COMMAND_LENGTH_MIN", "2"))
|
|
||||||
COMMAND_LENGTH_MAX: int = int(os.environ.get("COMMAND_LENGTH_MAX", "4"))
|
# ── 动态配置 dataclass ──────────────────────────────────────
|
||||||
COMMAND_CALLBACK_URL: str = os.environ.get("COMMAND_CALLBACK_URL", "")
|
@dataclass
|
||||||
COMMAND_CALLBACK_TIMEOUT: int = int(os.environ.get("COMMAND_CALLBACK_TIMEOUT", "180"))
|
class CommandConfig:
|
||||||
COMMAND_SCOPE: str = os.environ.get("COMMAND_SCOPE", "all") # all / group / private
|
"""命令监听动态配置,通过 settings.yaml 热重载。"""
|
||||||
COMMAND_LIST_MODE: str = os.environ.get("COMMAND_LIST_MODE", "allow") # allow / deny
|
|
||||||
COMMAND_ALLOWED_GROUPS: frozenset[str] = frozenset(
|
prefix: str = "#"
|
||||||
filter(None, os.environ.get("COMMAND_ALLOWED_GROUPS", "").split(","))
|
length_min: int = 2
|
||||||
|
length_max: int = 4
|
||||||
|
scope: str = "all" # all / group / private
|
||||||
|
list_mode: str = "allow" # allow / deny
|
||||||
|
allowed_groups: frozenset[str] = frozenset()
|
||||||
|
denied_groups: frozenset[str] = frozenset()
|
||||||
|
allowed_users: frozenset[str] = frozenset()
|
||||||
|
denied_users: frozenset[str] = frozenset()
|
||||||
|
at_sender: bool = True
|
||||||
|
callback_url: str = ""
|
||||||
|
callback_timeout: int = 180
|
||||||
|
|
||||||
|
|
||||||
|
# 全局单例 —— 所有消费者通过 config.command.xxx 访问,避免 stale import 问题
|
||||||
|
command = CommandConfig()
|
||||||
|
|
||||||
|
# ── 环境变量默认值(YAML 缺失 key 时的回退)────────────────────
|
||||||
|
_ENV_DEFAULTS: dict = {
|
||||||
|
"prefix": os.environ.get("COMMAND_PREFIX", "#"),
|
||||||
|
"length_min": int(os.environ.get("COMMAND_LENGTH_MIN", "2")),
|
||||||
|
"length_max": int(os.environ.get("COMMAND_LENGTH_MAX", "4")),
|
||||||
|
"scope": os.environ.get("COMMAND_SCOPE", "all"),
|
||||||
|
"list_mode": os.environ.get("COMMAND_LIST_MODE", "allow"),
|
||||||
|
"allowed_groups": os.environ.get("COMMAND_ALLOWED_GROUPS", ""),
|
||||||
|
"denied_groups": os.environ.get("COMMAND_DENIED_GROUPS", ""),
|
||||||
|
"allowed_users": os.environ.get("COMMAND_ALLOWED_USERS", ""),
|
||||||
|
"denied_users": os.environ.get("COMMAND_DENIED_USERS", ""),
|
||||||
|
"at_sender": os.environ.get("COMMAND_AT_SENDER", "true").lower() in ("true", "1", "yes"),
|
||||||
|
"callback_url": os.environ.get("COMMAND_CALLBACK_URL", ""),
|
||||||
|
"callback_timeout": int(os.environ.get("COMMAND_CALLBACK_TIMEOUT", "180")),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── YAML I/O ─────────────────────────────────────────────────
|
||||||
|
_YAML_HEADER = (
|
||||||
|
"# settings.yaml - 命令监听动态配置\n"
|
||||||
|
"# 可通过后台管理界面修改,也可手动编辑(自动热重载)\n\n"
|
||||||
)
|
)
|
||||||
COMMAND_DENIED_GROUPS: frozenset[str] = frozenset(
|
|
||||||
filter(None, os.environ.get("COMMAND_DENIED_GROUPS", "").split(","))
|
|
||||||
)
|
|
||||||
COMMAND_ALLOWED_USERS: frozenset[str] = frozenset(
|
|
||||||
filter(None, os.environ.get("COMMAND_ALLOWED_USERS", "").split(","))
|
|
||||||
)
|
|
||||||
COMMAND_DENIED_USERS: frozenset[str] = frozenset(
|
|
||||||
filter(None, os.environ.get("COMMAND_DENIED_USERS", "").split(","))
|
|
||||||
)
|
|
||||||
COMMAND_AT_SENDER: bool = os.environ.get("COMMAND_AT_SENDER", "true").lower() in ("true", "1", "yes")
|
|
||||||
|
|
||||||
|
|
||||||
# ── 动态配置刷新 ─────────────────────────────────────────────
|
def _yaml_defaults() -> dict:
|
||||||
def _parse_frozenset(value: str) -> frozenset[str]:
|
"""返回默认 YAML 结构(首次启动时写入)。"""
|
||||||
"""将逗号分隔字符串解析为 frozenset。"""
|
return {
|
||||||
return frozenset(filter(None, (v.strip() for v in value.split(","))))
|
"command": {
|
||||||
|
"prefix": "#",
|
||||||
|
"length_min": 2,
|
||||||
|
"length_max": 4,
|
||||||
|
"scope": "all",
|
||||||
|
"list_mode": "allow",
|
||||||
|
"allowed_groups": [],
|
||||||
|
"denied_groups": [],
|
||||||
|
"allowed_users": [],
|
||||||
|
"denied_users": [],
|
||||||
|
"at_sender": True,
|
||||||
|
"callback_url": "",
|
||||||
|
"callback_timeout": 180,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def reload_settings(settings: dict[str, str]) -> None:
|
def load_yaml() -> dict:
|
||||||
"""从数据库读取的配置覆盖模块级变量,使配置动态生效。"""
|
"""读取 settings.yaml,文件不存在时返回空字典。"""
|
||||||
global COMMAND_PREFIX, COMMAND_LENGTH_MIN, COMMAND_LENGTH_MAX
|
if not SETTINGS_YAML_PATH.exists():
|
||||||
global COMMAND_CALLBACK_URL, COMMAND_CALLBACK_TIMEOUT, COMMAND_SCOPE
|
return {}
|
||||||
global COMMAND_LIST_MODE, COMMAND_ALLOWED_GROUPS, COMMAND_DENIED_GROUPS
|
with open(SETTINGS_YAML_PATH, "r", encoding="utf-8") as f:
|
||||||
global COMMAND_ALLOWED_USERS, COMMAND_DENIED_USERS, COMMAND_AT_SENDER
|
data = yaml.safe_load(f)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
if "command_prefix" in settings:
|
|
||||||
COMMAND_PREFIX = settings["command_prefix"]
|
|
||||||
if "command_length_min" in settings:
|
|
||||||
try:
|
|
||||||
COMMAND_LENGTH_MIN = int(settings["command_length_min"])
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if "command_length_max" in settings:
|
|
||||||
try:
|
|
||||||
COMMAND_LENGTH_MAX = int(settings["command_length_max"])
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if "command_callback_url" in settings:
|
|
||||||
COMMAND_CALLBACK_URL = settings["command_callback_url"]
|
|
||||||
if "command_callback_timeout" in settings:
|
|
||||||
try:
|
|
||||||
COMMAND_CALLBACK_TIMEOUT = int(settings["command_callback_timeout"])
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if "command_scope" in settings:
|
|
||||||
COMMAND_SCOPE = settings["command_scope"]
|
|
||||||
if "command_list_mode" in settings:
|
|
||||||
COMMAND_LIST_MODE = settings["command_list_mode"]
|
|
||||||
if "command_allowed_groups" in settings:
|
|
||||||
COMMAND_ALLOWED_GROUPS = _parse_frozenset(settings["command_allowed_groups"])
|
|
||||||
if "command_denied_groups" in settings:
|
|
||||||
COMMAND_DENIED_GROUPS = _parse_frozenset(settings["command_denied_groups"])
|
|
||||||
if "command_allowed_users" in settings:
|
|
||||||
COMMAND_ALLOWED_USERS = _parse_frozenset(settings["command_allowed_users"])
|
|
||||||
if "command_denied_users" in settings:
|
|
||||||
COMMAND_DENIED_USERS = _parse_frozenset(settings["command_denied_users"])
|
|
||||||
if "command_at_sender" in settings:
|
|
||||||
COMMAND_AT_SENDER = settings["command_at_sender"].lower() in ("true", "1", "yes")
|
|
||||||
|
|
||||||
# 刷新正则编译缓存
|
def save_yaml(data: dict) -> None:
|
||||||
|
"""将配置写入 settings.yaml。"""
|
||||||
|
with open(SETTINGS_YAML_PATH, "w", encoding="utf-8") as f:
|
||||||
|
f.write(_YAML_HEADER)
|
||||||
|
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_settings_yaml() -> None:
|
||||||
|
"""首次启动时创建 settings.yaml,合并环境变量覆盖。"""
|
||||||
|
if SETTINGS_YAML_PATH.exists():
|
||||||
|
return
|
||||||
|
data = _yaml_defaults()
|
||||||
|
# 用环境变量覆盖默认值
|
||||||
|
env_map = {
|
||||||
|
"prefix": ("COMMAND_PREFIX", str),
|
||||||
|
"length_min": ("COMMAND_LENGTH_MIN", int),
|
||||||
|
"length_max": ("COMMAND_LENGTH_MAX", int),
|
||||||
|
"scope": ("COMMAND_SCOPE", str),
|
||||||
|
"list_mode": ("COMMAND_LIST_MODE", str),
|
||||||
|
"allowed_groups": ("COMMAND_ALLOWED_GROUPS", "csv_list"),
|
||||||
|
"denied_groups": ("COMMAND_DENIED_GROUPS", "csv_list"),
|
||||||
|
"allowed_users": ("COMMAND_ALLOWED_USERS", "csv_list"),
|
||||||
|
"denied_users": ("COMMAND_DENIED_USERS", "csv_list"),
|
||||||
|
"at_sender": ("COMMAND_AT_SENDER", "bool"),
|
||||||
|
"callback_url": ("COMMAND_CALLBACK_URL", str),
|
||||||
|
"callback_timeout": ("COMMAND_CALLBACK_TIMEOUT", int),
|
||||||
|
}
|
||||||
|
for key, (env_name, conv) in env_map.items():
|
||||||
|
env_val = os.environ.get(env_name)
|
||||||
|
if env_val is not None:
|
||||||
|
if conv == "csv_list":
|
||||||
|
data["command"][key] = [v.strip() for v in env_val.split(",") if v.strip()]
|
||||||
|
elif conv == "bool":
|
||||||
|
data["command"][key] = env_val.lower() in ("true", "1", "yes")
|
||||||
|
else:
|
||||||
|
data["command"][key] = conv(env_val)
|
||||||
|
save_yaml(data)
|
||||||
|
logger.info("Created settings.yaml with defaults (env-var overrides applied)")
|
||||||
|
|
||||||
|
|
||||||
|
# ── 将 YAML 数据应用到全局 command 对象 ───────────────────────
|
||||||
|
def _apply_yaml_to_command(data: dict) -> None:
|
||||||
|
"""将 YAML command 段合并到全局 command 对象,缺失 key 回退到环境变量默认值。"""
|
||||||
|
cmd_data = data.get("command", {})
|
||||||
|
|
||||||
|
command.prefix = str(cmd_data.get("prefix", _ENV_DEFAULTS["prefix"]))
|
||||||
|
command.length_min = int(cmd_data.get("length_min", _ENV_DEFAULTS["length_min"]))
|
||||||
|
command.length_max = int(cmd_data.get("length_max", _ENV_DEFAULTS["length_max"]))
|
||||||
|
command.scope = str(cmd_data.get("scope", _ENV_DEFAULTS["scope"]))
|
||||||
|
command.list_mode = str(cmd_data.get("list_mode", _ENV_DEFAULTS["list_mode"]))
|
||||||
|
command.callback_url = str(cmd_data.get("callback_url", _ENV_DEFAULTS["callback_url"]))
|
||||||
|
command.callback_timeout = int(cmd_data.get("callback_timeout", _ENV_DEFAULTS["callback_timeout"]))
|
||||||
|
|
||||||
|
# at_sender: YAML bool → Python bool
|
||||||
|
at_sender_val = cmd_data.get("at_sender", _ENV_DEFAULTS["at_sender"])
|
||||||
|
if isinstance(at_sender_val, str):
|
||||||
|
command.at_sender = at_sender_val.lower() in ("true", "1", "yes")
|
||||||
|
else:
|
||||||
|
command.at_sender = bool(at_sender_val)
|
||||||
|
|
||||||
|
# 列表字段: YAML list → frozenset
|
||||||
|
for field_name in ("allowed_groups", "denied_groups", "allowed_users", "denied_users"):
|
||||||
|
raw = cmd_data.get(field_name)
|
||||||
|
if raw is None:
|
||||||
|
# 回退到环境变量默认值
|
||||||
|
env_default = _ENV_DEFAULTS[field_name]
|
||||||
|
if isinstance(env_default, str):
|
||||||
|
setattr(command, field_name, frozenset(
|
||||||
|
v.strip() for v in env_default.split(",") if v.strip()
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
setattr(command, field_name, frozenset(env_default) if env_default else frozenset())
|
||||||
|
elif isinstance(raw, str):
|
||||||
|
setattr(command, field_name, frozenset(
|
||||||
|
v.strip() for v in raw.split(",") if v.strip()
|
||||||
|
))
|
||||||
|
elif isinstance(raw, list):
|
||||||
|
setattr(command, field_name, frozenset(str(v).strip() for v in raw if str(v).strip()))
|
||||||
|
else:
|
||||||
|
setattr(command, field_name, frozenset())
|
||||||
|
|
||||||
|
# 重建命令匹配正则
|
||||||
from .handlers.command import rebuild_pattern
|
from .handlers.command import rebuild_pattern
|
||||||
rebuild_pattern()
|
rebuild_pattern()
|
||||||
|
|
||||||
|
|
||||||
|
# ── 公共 API ─────────────────────────────────────────────────
|
||||||
|
def reload_settings() -> None:
|
||||||
|
"""重新读取 settings.yaml 并应用到全局 command 对象。"""
|
||||||
|
data = load_yaml()
|
||||||
|
_apply_yaml_to_command(data)
|
||||||
|
logger.info("Config reloaded from settings.yaml")
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings_flat() -> dict[str, str]:
|
||||||
|
"""返回所有动态配置的扁平字典 {key: str_value},供管理 API 使用。"""
|
||||||
|
return {
|
||||||
|
"command_prefix": command.prefix,
|
||||||
|
"command_length_min": str(command.length_min),
|
||||||
|
"command_length_max": str(command.length_max),
|
||||||
|
"command_scope": command.scope,
|
||||||
|
"command_list_mode": command.list_mode,
|
||||||
|
"command_allowed_groups": ",".join(sorted(command.allowed_groups)),
|
||||||
|
"command_denied_groups": ",".join(sorted(command.denied_groups)),
|
||||||
|
"command_allowed_users": ",".join(sorted(command.allowed_users)),
|
||||||
|
"command_denied_users": ",".join(sorted(command.denied_users)),
|
||||||
|
"command_at_sender": str(command.at_sender).lower(),
|
||||||
|
"command_callback_url": command.callback_url,
|
||||||
|
"command_callback_timeout": str(command.callback_timeout),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# API 平坦 key → (YAML key, 类型转换) 映射
|
||||||
|
_API_KEY_MAP: dict[str, tuple[str, ...]] = {
|
||||||
|
"command_prefix": ("prefix", str),
|
||||||
|
"command_length_min": ("length_min", int),
|
||||||
|
"command_length_max": ("length_max", int),
|
||||||
|
"command_scope": ("scope", str),
|
||||||
|
"command_list_mode": ("list_mode", str),
|
||||||
|
"command_at_sender": ("at_sender", "bool"),
|
||||||
|
"command_callback_url": ("callback_url", str),
|
||||||
|
"command_callback_timeout": ("callback_timeout", int),
|
||||||
|
"command_allowed_groups": ("allowed_groups", "csv_list"),
|
||||||
|
"command_denied_groups": ("denied_groups", "csv_list"),
|
||||||
|
"command_allowed_users": ("allowed_users", "csv_list"),
|
||||||
|
"command_denied_users": ("denied_users", "csv_list"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def update_settings_from_api(data: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""从管理 API 接收扁平字典,合并到 settings.yaml 并热重载。返回已应用的字段。"""
|
||||||
|
allowed_keys = set(_API_KEY_MAP.keys())
|
||||||
|
filtered = {k: str(v) for k, v in data.items() if k in allowed_keys}
|
||||||
|
if not filtered:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# 读取当前 YAML,合并修改
|
||||||
|
yaml_data = load_yaml()
|
||||||
|
if "command" not in yaml_data:
|
||||||
|
yaml_data["command"] = {}
|
||||||
|
cmd = yaml_data["command"]
|
||||||
|
|
||||||
|
for flat_key, value in filtered.items():
|
||||||
|
yaml_key, conv = _API_KEY_MAP[flat_key]
|
||||||
|
if conv == "csv_list":
|
||||||
|
cmd[yaml_key] = [v.strip() for v in value.split(",") if v.strip()]
|
||||||
|
elif conv == "bool":
|
||||||
|
cmd[yaml_key] = value.lower() in ("true", "1", "yes")
|
||||||
|
elif conv is int:
|
||||||
|
try:
|
||||||
|
cmd[yaml_key] = int(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
cmd[yaml_key] = value
|
||||||
|
|
||||||
|
save_yaml(yaml_data)
|
||||||
|
# 写入后立即应用(不等文件 watcher)
|
||||||
|
_apply_yaml_to_command(yaml_data)
|
||||||
|
return filtered
|
||||||
|
|||||||
82
db.py
82
db.py
@@ -1,82 +0,0 @@
|
|||||||
"""SQLite 数据库层:存储可动态修改的配置项。"""
|
|
||||||
|
|
||||||
import aiosqlite
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
DB_PATH = Path(__file__).parent / "data" / "webhook.db"
|
|
||||||
|
|
||||||
# 需要持久化的配置项及其默认值(从 .env 加载时的回退)
|
|
||||||
SETTINGS_DEFAULTS: dict[str, str] = {
|
|
||||||
"command_prefix": "#",
|
|
||||||
"command_length_min": "2",
|
|
||||||
"command_length_max": "4",
|
|
||||||
"command_scope": "all",
|
|
||||||
"command_list_mode": "allow",
|
|
||||||
"command_allowed_groups": "",
|
|
||||||
"command_denied_groups": "",
|
|
||||||
"command_allowed_users": "",
|
|
||||||
"command_denied_users": "",
|
|
||||||
"command_at_sender": "true",
|
|
||||||
"command_callback_url": "",
|
|
||||||
"command_callback_timeout": "180",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def init_db() -> None:
|
|
||||||
"""建表 + 首次启动时写入默认值。"""
|
|
||||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS settings (
|
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL DEFAULT ''
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# 首次启动:插入不存在的默认值
|
|
||||||
for key, default in SETTINGS_DEFAULTS.items():
|
|
||||||
await db.execute(
|
|
||||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
|
|
||||||
(key, default),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_setting(key: str) -> str | None:
|
|
||||||
"""读取单个配置值,不存在返回 None。"""
|
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
|
||||||
cursor = await db.execute("SELECT value FROM settings WHERE key = ?", (key,))
|
|
||||||
row = await cursor.fetchone()
|
|
||||||
return row[0] if row else None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_settings() -> dict[str, str]:
|
|
||||||
"""读取全部配置,返回 {key: value} 字典。"""
|
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
|
||||||
cursor = await db.execute("SELECT key, value FROM settings")
|
|
||||||
rows = await cursor.fetchall()
|
|
||||||
return {row[0]: row[1] for row in rows}
|
|
||||||
|
|
||||||
|
|
||||||
async def update_setting(key: str, value: str) -> None:
|
|
||||||
"""更新单个配置值。"""
|
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
|
||||||
await db.execute(
|
|
||||||
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
|
|
||||||
(key, value, value),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
async def update_settings(data: dict[str, str]) -> None:
|
|
||||||
"""批量更新配置值。"""
|
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
|
||||||
for key, value in data.items():
|
|
||||||
await db.execute(
|
|
||||||
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
|
|
||||||
(key, value, value),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
@@ -2,22 +2,7 @@
|
|||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from ..config import (
|
from ..config import command, get_settings_flat, reload_settings, update_settings_from_api
|
||||||
COMMAND_AT_SENDER,
|
|
||||||
COMMAND_CALLBACK_TIMEOUT,
|
|
||||||
COMMAND_CALLBACK_URL,
|
|
||||||
COMMAND_DENIED_GROUPS,
|
|
||||||
COMMAND_DENIED_USERS,
|
|
||||||
COMMAND_ALLOWED_GROUPS,
|
|
||||||
COMMAND_ALLOWED_USERS,
|
|
||||||
COMMAND_LENGTH_MAX,
|
|
||||||
COMMAND_LENGTH_MIN,
|
|
||||||
COMMAND_LIST_MODE,
|
|
||||||
COMMAND_PREFIX,
|
|
||||||
COMMAND_SCOPE,
|
|
||||||
reload_settings,
|
|
||||||
)
|
|
||||||
from ..db import get_settings, update_settings
|
|
||||||
from ..response import error, ok
|
from ..response import error, ok
|
||||||
|
|
||||||
|
|
||||||
@@ -25,8 +10,7 @@ from ..response import error, ok
|
|||||||
|
|
||||||
async def api_get_settings(request: web.Request) -> web.Response:
|
async def api_get_settings(request: web.Request) -> web.Response:
|
||||||
"""GET /api/settings — 返回全部动态配置。"""
|
"""GET /api/settings — 返回全部动态配置。"""
|
||||||
settings = await get_settings()
|
return ok(data=get_settings_flat())
|
||||||
return ok(data=settings)
|
|
||||||
|
|
||||||
|
|
||||||
async def api_update_settings(request: web.Request) -> web.Response:
|
async def api_update_settings(request: web.Request) -> web.Response:
|
||||||
@@ -39,37 +23,17 @@ async def api_update_settings(request: web.Request) -> web.Response:
|
|||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return error("request body must be a json object")
|
return error("request body must be a json object")
|
||||||
|
|
||||||
# 允许更新的 key 白名单
|
filtered = update_settings_from_api(data)
|
||||||
allowed_keys = {
|
|
||||||
"command_prefix",
|
|
||||||
"command_length_min",
|
|
||||||
"command_length_max",
|
|
||||||
"command_scope",
|
|
||||||
"command_list_mode",
|
|
||||||
"command_allowed_groups",
|
|
||||||
"command_denied_groups",
|
|
||||||
"command_allowed_users",
|
|
||||||
"command_denied_users",
|
|
||||||
"command_at_sender",
|
|
||||||
"command_callback_url",
|
|
||||||
"command_callback_timeout",
|
|
||||||
}
|
|
||||||
|
|
||||||
filtered = {k: str(v) for k, v in data.items() if k in allowed_keys}
|
|
||||||
if not filtered:
|
if not filtered:
|
||||||
return error("no valid settings to update")
|
return error("no valid settings to update")
|
||||||
|
|
||||||
await update_settings(filtered)
|
|
||||||
reload_settings(filtered)
|
|
||||||
|
|
||||||
return ok(data=filtered, msg="配置已更新并生效")
|
return ok(data=filtered, msg="配置已更新并生效")
|
||||||
|
|
||||||
|
|
||||||
async def api_reload_settings(request: web.Request) -> web.Response:
|
async def api_reload_settings(request: web.Request) -> web.Response:
|
||||||
"""POST /api/settings/reload — 从数据库重新加载配置。"""
|
"""POST /api/settings/reload — 从 settings.yaml 重新加载配置。"""
|
||||||
settings = await get_settings()
|
reload_settings()
|
||||||
reload_settings(settings)
|
return ok(msg="配置已从 settings.yaml 重新加载")
|
||||||
return ok(msg="配置已从数据库重新加载")
|
|
||||||
|
|
||||||
|
|
||||||
# ── 管理页面 ─────────────────────────────────────────────────
|
# ── 管理页面 ─────────────────────────────────────────────────
|
||||||
@@ -568,7 +532,7 @@ body {
|
|||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<span class="header-badge" id="statusBadge">● 运行中</span>
|
<span class="header-badge" id="statusBadge">● 运行中</span>
|
||||||
<button class="btn btn-sm btn-primary" onclick="saveAll()">保存配置</button>
|
<button class="btn btn-sm btn-primary" onclick="saveAll()">保存配置</button>
|
||||||
<button class="btn btn-sm btn-default" onclick="reloadFromDb()">重新加载</button>
|
<button class="btn btn-sm btn-default" onclick="reloadFromYaml()">重新加载</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -750,7 +714,7 @@ body {
|
|||||||
</div>
|
</div>
|
||||||
<div style="display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--border-color); font-size:13px;">
|
<div style="display:flex; justify-content:space-between; padding:8px 0; border-bottom:1px solid var(--border-color); font-size:13px;">
|
||||||
<span style="color:var(--text-secondary);">存储</span>
|
<span style="color:var(--text-secondary);">存储</span>
|
||||||
<span>SQLite</span>
|
<span>YAML</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -933,13 +897,13 @@ async function saveAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 从数据库重新加载 ──────────────────────────────────────
|
// ── 从 YAML 重新加载 ──────────────────────────────────────
|
||||||
async function reloadFromDb() {
|
async function reloadFromYaml() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/settings/reload', { method: 'POST' });
|
const resp = await fetch('/api/settings/reload', { method: 'POST' });
|
||||||
const json = await resp.json();
|
const json = await resp.json();
|
||||||
if (json.code === 0) {
|
if (json.code === 0) {
|
||||||
showToast('已从数据库重新加载', 'success');
|
showToast('已从 YAML 重新加载', 'success');
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
} else {
|
} else {
|
||||||
showToast('重新加载失败: ' + json.msg, 'error');
|
showToast('重新加载失败: ' + json.msg, 'error');
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import re
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from ..config import COMMAND_AT_SENDER, COMMAND_CALLBACK_TIMEOUT, COMMAND_CALLBACK_URL, COMMAND_LENGTH_MAX, COMMAND_LENGTH_MIN, COMMAND_PREFIX, UPLOAD_DIR
|
from ..config import UPLOAD_DIR, command
|
||||||
from ..handlers.message import _resolve_url
|
from ..handlers.message import _resolve_url
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ def build_command_pattern() -> re.Pattern:
|
|||||||
- 其他非空白字符 = 1
|
- 其他非空白字符 = 1
|
||||||
"""
|
"""
|
||||||
return re.compile(
|
return re.compile(
|
||||||
rf"^{re.escape(COMMAND_PREFIX)}(\S{{{COMMAND_LENGTH_MIN},{COMMAND_LENGTH_MAX}}})(?:\s+(.+))?$",
|
rf"^{re.escape(command.prefix)}(\S{{{command.length_min},{command.length_max}}})(?:\s+(.+))?$",
|
||||||
re.DOTALL,
|
re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,28 +56,28 @@ async def send_command_callback(data: dict, event, api, logger) -> None:
|
|||||||
{"type": "file", "url": "..."},
|
{"type": "file", "url": "..."},
|
||||||
{"type": "video", "url": "..."}
|
{"type": "video", "url": "..."}
|
||||||
],
|
],
|
||||||
"at_sender": true // 是否 @发送者(默认取 COMMAND_AT_SENDER 配置,仅群聊)
|
"at_sender": true // 是否 @发送者(默认取配置,仅群聊)
|
||||||
}
|
}
|
||||||
|
|
||||||
所有字段均为可选,无回复内容时返回空 JSON 即可。
|
所有字段均为可选,无回复内容时返回空 JSON 即可。
|
||||||
回复会引用触发命令的原消息。
|
回复会引用触发命令的原消息。
|
||||||
"""
|
"""
|
||||||
if not COMMAND_CALLBACK_URL:
|
if not command.callback_url:
|
||||||
logger.warning("COMMAND_CALLBACK_URL 未配置,跳过命令回调")
|
logger.warning("callback_url 未配置,跳过命令回调")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.post(
|
async with session.post(
|
||||||
COMMAND_CALLBACK_URL,
|
command.callback_url,
|
||||||
json=data,
|
json=data,
|
||||||
timeout=aiohttp.ClientTimeout(total=COMMAND_CALLBACK_TIMEOUT),
|
timeout=aiohttp.ClientTimeout(total=command.callback_timeout),
|
||||||
) as resp:
|
) as resp:
|
||||||
if resp.status >= 400:
|
if resp.status >= 400:
|
||||||
body = await resp.text()
|
body = await resp.text()
|
||||||
logger.error(
|
logger.error(
|
||||||
"命令回调失败: status=%d url=%s body=%s",
|
"命令回调失败: status=%d url=%s body=%s",
|
||||||
resp.status, COMMAND_CALLBACK_URL, body[:200],
|
resp.status, command.callback_url, body[:200],
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -93,13 +93,13 @@ async def send_command_callback(data: dict, event, api, logger) -> None:
|
|||||||
await _handle_reply(result, event.data, api, logger)
|
await _handle_reply(result, event.data, api, logger)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("命令回调异常: url=%s error=%s", COMMAND_CALLBACK_URL, exc)
|
logger.error("命令回调异常: url=%s error=%s", command.callback_url, exc)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_reply(result: dict, msg_event, api, logger) -> None:
|
async def _handle_reply(result: dict, msg_event, api, logger) -> None:
|
||||||
"""处理回调响应,引用原消息自动回复。msg_event 是 GroupMessageEvent / PrivateMessageEvent。"""
|
"""处理回调响应,引用原消息自动回复。msg_event 是 GroupMessageEvent / PrivateMessageEvent。"""
|
||||||
# at_sender: 回调响应中的值优先,未指定则使用全局配置
|
# at_sender: 回调响应中的值优先,未指定则使用全局配置
|
||||||
at_sender = result.get("at_sender", COMMAND_AT_SENDER)
|
at_sender = result.get("at_sender", command.at_sender)
|
||||||
messages = result.get("messages")
|
messages = result.get("messages")
|
||||||
reply = result.get("reply")
|
reply = result.get("reply")
|
||||||
group_id = getattr(msg_event, "group_id", None)
|
group_id = getattr(msg_event, "group_id", None)
|
||||||
|
|||||||
88
plugin.py
88
plugin.py
@@ -8,24 +8,15 @@ from aiohttp import web
|
|||||||
from ncatbot.plugin import NcatBotPlugin
|
from ncatbot.plugin import NcatBotPlugin
|
||||||
|
|
||||||
from .config import (
|
from .config import (
|
||||||
COMMAND_ALLOWED_GROUPS,
|
|
||||||
COMMAND_ALLOWED_USERS,
|
|
||||||
COMMAND_AT_SENDER,
|
|
||||||
COMMAND_CALLBACK_URL,
|
|
||||||
COMMAND_DENIED_GROUPS,
|
|
||||||
COMMAND_DENIED_USERS,
|
|
||||||
COMMAND_LENGTH_MAX,
|
|
||||||
COMMAND_LENGTH_MIN,
|
|
||||||
COMMAND_LIST_MODE,
|
|
||||||
COMMAND_PREFIX,
|
|
||||||
COMMAND_SCOPE,
|
|
||||||
HOST,
|
HOST,
|
||||||
PORT,
|
PORT,
|
||||||
UPLOAD_DIR,
|
UPLOAD_DIR,
|
||||||
WEBHOOK_API_KEY,
|
WEBHOOK_API_KEY,
|
||||||
|
SETTINGS_YAML_PATH,
|
||||||
|
command,
|
||||||
|
ensure_settings_yaml,
|
||||||
reload_settings,
|
reload_settings,
|
||||||
)
|
)
|
||||||
from .db import get_settings, init_db
|
|
||||||
from .handlers.admin import admin_page_handler, api_get_settings, api_reload_settings, api_update_settings
|
from .handlers.admin import admin_page_handler, api_get_settings, api_reload_settings, api_update_settings
|
||||||
from .handlers.command import parse_command, send_command_callback
|
from .handlers.command import parse_command, send_command_callback
|
||||||
from .handlers.health import health_handler
|
from .handlers.health import health_handler
|
||||||
@@ -47,12 +38,12 @@ class WebHookPlugin(NcatBotPlugin):
|
|||||||
self._webhook_runner: web.AppRunner | None = None
|
self._webhook_runner: web.AppRunner | None = None
|
||||||
self._cleanup_task: asyncio.Task | None = None
|
self._cleanup_task: asyncio.Task | None = None
|
||||||
self._listener_task: asyncio.Task | None = None
|
self._listener_task: asyncio.Task | None = None
|
||||||
|
self._watcher_task: asyncio.Task | None = None
|
||||||
|
|
||||||
async def on_load(self):
|
async def on_load(self):
|
||||||
# 初始化数据库并加载动态配置
|
# 初始化 settings.yaml 并加载配置
|
||||||
await init_db()
|
ensure_settings_yaml()
|
||||||
settings = await get_settings()
|
reload_settings()
|
||||||
reload_settings(settings)
|
|
||||||
|
|
||||||
self.logger.info("Webhook 插件已加载")
|
self.logger.info("Webhook 插件已加载")
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
@@ -61,32 +52,28 @@ class WebHookPlugin(NcatBotPlugin):
|
|||||||
)
|
)
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
"命令监听: 前缀=%s 长度=%d~%d 范围=%s 名单=%s 回调=%s",
|
"命令监听: 前缀=%s 长度=%d~%d 范围=%s 名单=%s 回调=%s",
|
||||||
COMMAND_PREFIX,
|
command.prefix,
|
||||||
COMMAND_LENGTH_MIN,
|
command.length_min,
|
||||||
COMMAND_LENGTH_MAX,
|
command.length_max,
|
||||||
COMMAND_SCOPE,
|
command.scope,
|
||||||
COMMAND_LIST_MODE,
|
command.list_mode,
|
||||||
COMMAND_CALLBACK_URL or "未配置",
|
command.callback_url or "未配置",
|
||||||
)
|
)
|
||||||
asyncio.create_task(self._start_webhook())
|
asyncio.create_task(self._start_webhook())
|
||||||
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
|
||||||
self._listener_task = asyncio.create_task(self._message_listener())
|
self._listener_task = asyncio.create_task(self._message_listener())
|
||||||
|
self._watcher_task = asyncio.create_task(self._config_watcher())
|
||||||
|
|
||||||
async def on_close(self):
|
async def on_close(self):
|
||||||
if self._listener_task is not None:
|
for task_attr in ("_watcher_task", "_listener_task", "_cleanup_task"):
|
||||||
self._listener_task.cancel()
|
task = getattr(self, task_attr)
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
try:
|
try:
|
||||||
await self._listener_task
|
await task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
self._listener_task = None
|
setattr(self, task_attr, None)
|
||||||
if self._cleanup_task is not None:
|
|
||||||
self._cleanup_task.cancel()
|
|
||||||
try:
|
|
||||||
await self._cleanup_task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
self._cleanup_task = None
|
|
||||||
await self._stop_webhook()
|
await self._stop_webhook()
|
||||||
self.logger.info("Webhook 插件已卸载")
|
self.logger.info("Webhook 插件已卸载")
|
||||||
|
|
||||||
@@ -99,6 +86,21 @@ class WebHookPlugin(NcatBotPlugin):
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.logger.error("清理过期文件失败: %s", exc)
|
self.logger.error("清理过期文件失败: %s", exc)
|
||||||
|
|
||||||
|
async def _config_watcher(self) -> None:
|
||||||
|
"""轮询 settings.yaml 的 mtime,变更时热重载配置。"""
|
||||||
|
last_mtime: float = 0.0
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
try:
|
||||||
|
if SETTINGS_YAML_PATH.exists():
|
||||||
|
current_mtime = SETTINGS_YAML_PATH.stat().st_mtime
|
||||||
|
if current_mtime != last_mtime:
|
||||||
|
last_mtime = current_mtime
|
||||||
|
reload_settings()
|
||||||
|
self.logger.info("settings.yaml changed, config reloaded")
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.error("Config watcher error: %s", exc)
|
||||||
|
|
||||||
async def _message_listener(self) -> None:
|
async def _message_listener(self) -> None:
|
||||||
"""监听 QQ 消息,匹配命令模式后转发到外部回调。"""
|
"""监听 QQ 消息,匹配命令模式后转发到外部回调。"""
|
||||||
try:
|
try:
|
||||||
@@ -114,25 +116,25 @@ class WebHookPlugin(NcatBotPlugin):
|
|||||||
|
|
||||||
# 范围过滤:group / private / all
|
# 范围过滤:group / private / all
|
||||||
is_group = hasattr(event.data, "group_id")
|
is_group = hasattr(event.data, "group_id")
|
||||||
if COMMAND_SCOPE == "group" and not is_group:
|
if command.scope == "group" and not is_group:
|
||||||
continue
|
continue
|
||||||
if COMMAND_SCOPE == "private" and is_group:
|
if command.scope == "private" and is_group:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 黑白名单过滤
|
# 黑白名单过滤
|
||||||
if COMMAND_LIST_MODE == "allow":
|
if command.list_mode == "allow":
|
||||||
# 白名单模式:在名单内才放行
|
# 白名单模式:在名单内才放行
|
||||||
if COMMAND_ALLOWED_GROUPS and is_group:
|
if command.allowed_groups and is_group:
|
||||||
if event.data.group_id not in COMMAND_ALLOWED_GROUPS:
|
if event.data.group_id not in command.allowed_groups:
|
||||||
continue
|
continue
|
||||||
if COMMAND_ALLOWED_USERS and event.data.user_id not in COMMAND_ALLOWED_USERS:
|
if command.allowed_users and event.data.user_id not in command.allowed_users:
|
||||||
continue
|
continue
|
||||||
elif COMMAND_LIST_MODE == "deny":
|
elif command.list_mode == "deny":
|
||||||
# 黑名单模式:在名单内则拒绝
|
# 黑名单模式:在名单内则拒绝
|
||||||
if COMMAND_DENIED_GROUPS and is_group:
|
if command.denied_groups and is_group:
|
||||||
if event.data.group_id in COMMAND_DENIED_GROUPS:
|
if event.data.group_id in command.denied_groups:
|
||||||
continue
|
continue
|
||||||
if COMMAND_DENIED_USERS and event.data.user_id in COMMAND_DENIED_USERS:
|
if command.denied_users and event.data.user_id in command.denied_users:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 构建回调数据
|
# 构建回调数据
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ dependencies = [
|
|||||||
"ncatbot5>=5.5.2.post3",
|
"ncatbot5>=5.5.2.post3",
|
||||||
"aiohttp>=3.9",
|
"aiohttp>=3.9",
|
||||||
"python-dotenv>=1.0",
|
"python-dotenv>=1.0",
|
||||||
"aiosqlite>=0.20",
|
"pyyaml>=6.0",
|
||||||
]
|
]
|
||||||
|
|||||||
13
uv.lock
generated
13
uv.lock
generated
@@ -109,15 +109,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aiosqlite"
|
|
||||||
version = "0.22.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -918,17 +909,17 @@ version = "0.1.1"
|
|||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "aiosqlite" },
|
|
||||||
{ name = "ncatbot5" },
|
{ name = "ncatbot5" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiohttp", specifier = ">=3.9" },
|
{ name = "aiohttp", specifier = ">=3.9" },
|
||||||
{ name = "aiosqlite", specifier = ">=0.20" },
|
|
||||||
{ name = "ncatbot5", specifier = ">=5.5.2.post3" },
|
{ name = "ncatbot5", specifier = ">=5.5.2.post3" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0" },
|
{ name = "python-dotenv", specifier = ">=1.0" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user