From 31c5dfce537670a6b1b12cc0ec4e872953264b12 Mon Sep 17 00:00:00 2001
From: MhankBarBar
Date: Sat, 14 Mar 2026 14:01:10 +0700
Subject: [PATCH 1/2] feat: enhance dashboard security and rate limiting
configuration with validation and caching
---
.github/workflows/ci.yml | 3 +
config.schema.json | 43 ++++-
dashboard/src/hooks/use-websocket.ts | 59 ++++--
dashboard/src/lib/api.ts | 29 +--
pyproject.toml | 7 +-
src/ai/agent.py | 62 +++++-
src/ai/token_tracker.py | 46 ++++-
src/core/analytics.py | 43 ++++-
src/core/env.py | 26 ++-
src/core/rate_limiter.py | 41 +++-
src/core/runtime_config.py | 179 ++++++++++++++----
src/core/storage.py | 127 ++++++++++---
src/dashboard_api.py | 170 ++++++++++++++---
tests/test_ai_action_policy.py | 30 +++
.../test_dashboard_security_and_ratelimit.py | 59 ++++++
tests/test_runtime_config_validation.py | 46 +++++
uv.lock | 57 +++++-
17 files changed, 878 insertions(+), 149 deletions(-)
create mode 100644 tests/test_ai_action_policy.py
create mode 100644 tests/test_dashboard_security_and_ratelimit.py
create mode 100644 tests/test_runtime_config_validation.py
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index e45ba23..93a4d25 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -27,3 +27,6 @@ jobs:
- name: Lint
run: uv run ruff check .
+
+ - name: Run Tests
+ run: uv run pytest -q
diff --git a/config.schema.json b/config.schema.json
index b8b6802..9d1fb35 100644
--- a/config.schema.json
+++ b/config.schema.json
@@ -35,7 +35,7 @@
"phone_number": {
"type": "string",
"description": "Phone number in international format without '+' (e.g., '628xxxx'). Used for PAIR_CODE login method",
- "pattern": "^[0-9]+$"
+ "pattern": "^$|^[0-9]+$"
},
"owner_jid": {
"type": "string",
@@ -293,6 +293,36 @@
}
}
},
+ "rate_limit": {
+ "type": "object",
+ "description": "Command rate limiter settings",
+ "properties": {
+ "enabled": {
+ "type": "boolean",
+ "default": true
+ },
+ "user_cooldown": {
+ "type": "number",
+ "minimum": 0,
+ "default": 3.0
+ },
+ "command_cooldown": {
+ "type": "number",
+ "minimum": 0,
+ "default": 2.0
+ },
+ "burst_limit": {
+ "type": "integer",
+ "minimum": 1,
+ "default": 5
+ },
+ "burst_window": {
+ "type": "number",
+ "minimum": 1,
+ "default": 10.0
+ }
+ }
+ },
"downloader": {
"type": "object",
"description": "Downloader settings for /dl, /audio, and /video commands",
@@ -452,6 +482,17 @@
"type": "boolean",
"description": "Enable the Dashboard API server",
"default": false
+ },
+ "cors_origins": {
+ "type": "array",
+ "description": "Allowed CORS origins for dashboard API",
+ "items": {
+ "type": "string"
+ },
+ "default": [
+ "http://localhost:3000",
+ "http://127.0.0.1:3000"
+ ]
}
}
}
diff --git a/dashboard/src/hooks/use-websocket.ts b/dashboard/src/hooks/use-websocket.ts
index 870d7b7..7979f3e 100644
--- a/dashboard/src/hooks/use-websocket.ts
+++ b/dashboard/src/hooks/use-websocket.ts
@@ -1,6 +1,6 @@
"use client";
-import { WS_BASE } from "@/lib/api";
+import { api, WS_BASE } from "@/lib/api";
import { useCallback, useEffect, useRef, useState } from "react";
export interface WsEvent {
@@ -24,34 +24,53 @@ export function useWebSocket(maxEvents = 50) {
useEffect(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
- const ws = new WebSocket(`${WS_BASE}/ws`);
- wsRef.current = ws;
+ let cancelled = false;
- ws.onopen = () => setConnected(true);
-
- ws.onmessage = (e) => {
+ const connect = async () => {
try {
- const event: WsEvent = JSON.parse(e.data);
- setEvents((prev) => [event, ...prev].slice(0, maxEvents));
+ const { token } = await api.getWsToken();
+ if (cancelled) {
+ return;
+ }
+
+ const ws = new WebSocket(`${WS_BASE}/ws?token=${encodeURIComponent(token)}`);
+ wsRef.current = ws;
+
+ ws.onopen = () => setConnected(true);
+
+ ws.onmessage = (e) => {
+ try {
+ const event: WsEvent = JSON.parse(e.data);
+ setEvents((prev) => [event, ...prev].slice(0, maxEvents));
+ } catch {
+ // Ignore parse errors
+ }
+ };
+
+ ws.onclose = () => {
+ setConnected(false);
+ reconnectTimer.current = setTimeout(() => {
+ setReconnectTrigger((prev) => prev + 1);
+ }, 3000);
+ };
+
+ ws.onerror = () => {
+ ws.close();
+ };
} catch {
- // Ignore parse errors
+ setConnected(false);
+ reconnectTimer.current = setTimeout(() => {
+ setReconnectTrigger((prev) => prev + 1);
+ }, 3000);
}
};
- ws.onclose = () => {
- setConnected(false);
- reconnectTimer.current = setTimeout(() => {
- setReconnectTrigger((prev) => prev + 1);
- }, 3000);
- };
-
- ws.onerror = () => {
- ws.close();
- };
+ void connect();
return () => {
+ cancelled = true;
clearTimeout(reconnectTimer.current);
- ws.close();
+ wsRef.current?.close();
};
}, [maxEvents, reconnectTrigger]);
diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts
index 79e864c..b11d2c2 100644
--- a/dashboard/src/lib/api.ts
+++ b/dashboard/src/lib/api.ts
@@ -184,6 +184,13 @@ export interface AutomationRule {
action_value: string;
}
+function getStoredAuth(): string | null {
+ if (typeof window === "undefined") {
+ return null;
+ }
+ return localStorage.getItem("dashboard_auth");
+}
+
async function fetchAPI(endpoint: string, options?: RequestInit): Promise {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
@@ -197,11 +204,9 @@ async function fetchAPI(endpoint: string, options?: RequestInit): Promise
Object.assign(headers, customHeaders);
}
- if (typeof window !== "undefined") {
- const auth = localStorage.getItem("dashboard_auth");
- if (auth) {
- headers["Authorization"] = `Basic ${auth}`;
- }
+ const auth = getStoredAuth();
+ if (auth) {
+ headers["Authorization"] = `Basic ${auth}`;
}
try {
@@ -287,7 +292,7 @@ export const api = {
formData.append("caption", caption);
formData.append("file", file);
- const auth = typeof window !== "undefined" ? localStorage.getItem("dashboard_auth") : null;
+ const auth = getStoredAuth();
const headers: Record = {};
if (auth) {
headers["Authorization"] = `Basic ${auth}`;
@@ -335,6 +340,9 @@ export const api = {
method: "PUT",
body: JSON.stringify(config),
}),
+ getWsToken: () => fetchAPI<{ token: string; expires_in: number }>("/api/ws-token", {
+ method: "POST",
+ }),
getWelcome: (groupId: string) => fetchAPI(`/api/groups/${groupId}/welcome`),
updateWelcome: (groupId: string, config: WelcomeConfig) =>
@@ -396,12 +404,9 @@ export const api = {
const formData = new FormData();
formData.append("file", file);
const headers: Record = {};
- const username =
- typeof window !== "undefined" ? localStorage.getItem("dashboard_username") || "" : "";
- const password =
- typeof window !== "undefined" ? localStorage.getItem("dashboard_password") || "" : "";
- if (username && password) {
- headers["Authorization"] = `Basic ${btoa(`${username}:${password}`)}`;
+ const auth = getStoredAuth();
+ if (auth) {
+ headers["Authorization"] = `Basic ${auth}`;
}
const res = await fetch(
`${API_BASE}/api/groups/${encodeURIComponent(groupId)}/notes/${encodeURIComponent(noteName)}/media`,
diff --git a/pyproject.toml b/pyproject.toml
index 83e7c99..4797c2f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,6 +18,7 @@ dependencies = [
"apscheduler>=3.10.0",
"pydantic-ai>=1.48.0",
"python-dotenv>=1.2.1",
+ "jsonschema>=4.23.0",
"yt-dlp>=2026.03.03",
"gallery-dl>=1.31.7",
"Pillow>=10.0.0",
@@ -27,7 +28,7 @@ dependencies = [
zero-ichi = "main:main"
[tool.uv]
-dev-dependencies = ["ruff>=0.9.0"]
+dev-dependencies = ["ruff>=0.9.0", "pytest>=8.4.0", "pytest-asyncio>=1.1.0"]
[tool.hatch.build.targets.wheel]
packages = [
@@ -70,3 +71,7 @@ quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
+
+[tool.pytest.ini_options]
+pythonpath = ["src"]
+testpaths = ["tests"]
diff --git a/src/ai/agent.py b/src/ai/agent.py
index b852589..c7f7cf3 100644
--- a/src/ai/agent.py
+++ b/src/ai/agent.py
@@ -17,6 +17,7 @@
from ai.token_tracker import token_tracker
from core.command import CommandContext, command_loader
from core.logger import log_debug, log_error, log_info, log_warning
+from core.permissions import check_command_permissions
from core.runtime_config import runtime_config
if TYPE_CHECKING:
@@ -48,6 +49,32 @@
"""
+def _normalize_actions(values: object) -> set[str]:
+ """Normalize action values from config for policy checks."""
+ if not isinstance(values, list):
+ return set()
+ return {str(v).strip().lower() for v in values if str(v).strip()}
+
+
+def _is_ai_action_allowed(command_name: str) -> bool:
+ """Check if AI is allowed to use a command by config policy."""
+ cmd = command_name.lower().strip()
+ allowed = _normalize_actions(
+ runtime_config.get_nested("agentic_ai", "allowed_actions", default=[])
+ )
+ blocked = _normalize_actions(
+ runtime_config.get_nested("agentic_ai", "blocked_actions", default=[])
+ )
+
+ if cmd in blocked:
+ return False
+
+ if allowed and cmd not in allowed:
+ return False
+
+ return True
+
+
def _create_agent() -> Agent:
"""Create and configure the Pydantic AI agent with all tools."""
agent = Agent(
@@ -86,9 +113,18 @@ async def get_commands(ctx: RunContext[BotDependencies], category: str = "") ->
for group_name, commands in grouped.items():
if category and category.lower() not in group_name.lower():
continue
- result_lines.append(f"\n{group_name}:")
+ group_lines = []
for cmd in commands:
- result_lines.append(f" - {cmd.name}: {cmd.description}")
+ if not _is_ai_action_allowed(cmd.name):
+ continue
+ perm_result = await check_command_permissions(cmd, ctx.deps.msg, ctx.deps.bot)
+ if not perm_result:
+ continue
+ group_lines.append(f" - {cmd.name}: {cmd.description}")
+
+ if group_lines:
+ result_lines.append(f"\n{group_name}:")
+ result_lines.extend(group_lines)
return "\n".join(result_lines) if result_lines else "No commands found"
@@ -101,8 +137,13 @@ async def run_command(ctx: RunContext[BotDependencies], command: str, args: str
if not cmd:
return f"Command '{cmd_name}' not found"
- if not cmd.enabled:
- return f"Command '{cmd_name}' is disabled"
+ resolved_name = cmd.name.lower()
+ if not _is_ai_action_allowed(resolved_name):
+ return f"Command '{resolved_name}' is not allowed for AI"
+
+ perm_result = await check_command_permissions(cmd, ctx.deps.msg, ctx.deps.bot)
+ if not perm_result:
+ return perm_result.error_message or f"Permission denied for '{resolved_name}'"
args_list = args.split() if args else []
cmd_ctx = CommandContext(
@@ -110,12 +151,13 @@ async def run_command(ctx: RunContext[BotDependencies], command: str, args: str
message=ctx.deps.msg,
args=args_list,
raw_args=args,
- command_name=cmd_name,
+ command_name=resolved_name,
+ prefix=runtime_config.display_prefix,
)
try:
await cmd.execute(cmd_ctx)
- return f"Executed command: {cmd_name}"
+ return f"Executed command: {resolved_name}"
except Exception as e:
return f"Command error: {str(e)}"
@@ -340,7 +382,13 @@ async def process(self, msg: MessageHelper, bot: BotClient) -> str | None:
log_info(f"AI token limit reached for user={user_id} chat={chat_id}")
return "⏳ AI daily limit reached. Try again tomorrow!"
- os.environ["OPENAI_API_KEY"] = self.api_key
+ if self.provider == "openai":
+ os.environ["OPENAI_API_KEY"] = self.api_key
+ elif self.provider == "anthropic":
+ os.environ["ANTHROPIC_API_KEY"] = self.api_key
+ elif self.provider == "google":
+ os.environ["GOOGLE_API_KEY"] = self.api_key
+ os.environ["GEMINI_API_KEY"] = self.api_key
model_str = f"{self.provider}:{self.model}"
log_info(f"AI processing with model: {model_str}")
diff --git a/src/ai/token_tracker.py b/src/ai/token_tracker.py
index 5ca78a1..3bb3d33 100644
--- a/src/ai/token_tracker.py
+++ b/src/ai/token_tracker.py
@@ -5,6 +5,10 @@
"""
import json
+import os
+import tempfile
+import time
+from atexit import register as on_exit
from datetime import datetime
from core.constants import DATA_DIR
@@ -12,6 +16,23 @@
from core.runtime_config import runtime_config
TOKEN_FILE = DATA_DIR / "ai_tokens.json"
+SAVE_INTERVAL_SECONDS = 2.0
+
+
+def _atomic_write_json(file_path, data: dict) -> None:
+ """Atomically write JSON payload to disk."""
+ file_path.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp_path = tempfile.mkstemp(dir=file_path.parent, suffix=".tmp")
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2)
+ os.replace(tmp_path, file_path)
+ except BaseException:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ raise
class TokenTracker:
@@ -19,7 +40,10 @@ class TokenTracker:
def __init__(self):
self._data: dict = {}
+ self._dirty = False
+ self._last_save_ts = 0.0
self._load()
+ on_exit(self.flush)
def _load(self) -> None:
"""Load token data from disk."""
@@ -32,12 +56,25 @@ def _load(self) -> None:
today = datetime.now().strftime("%Y-%m-%d")
if self._data.get("date") != today:
self._data = {"date": today, "users": {}, "chats": {}}
- self._save()
+ self._schedule_save(force=True)
def _save(self) -> None:
"""Save token data to disk."""
- DATA_DIR.mkdir(parents=True, exist_ok=True)
- TOKEN_FILE.write_text(json.dumps(self._data, indent=2), encoding="utf-8")
+ _atomic_write_json(TOKEN_FILE, self._data)
+ self._dirty = False
+ self._last_save_ts = time.time()
+
+ def _schedule_save(self, force: bool = False) -> None:
+ """Persist token usage on interval to reduce disk writes."""
+ self._dirty = True
+ now = time.time()
+ if force or now - self._last_save_ts >= SAVE_INTERVAL_SECONDS:
+ self._save()
+
+ def flush(self) -> None:
+ """Flush pending token usage writes."""
+ if self._dirty:
+ self._save()
@property
def _user_limit(self) -> int:
@@ -54,6 +91,7 @@ def _ensure_today(self) -> None:
today = datetime.now().strftime("%Y-%m-%d")
if self._data.get("date") != today:
self._data = {"date": today, "users": {}, "chats": {}}
+ self._schedule_save(force=True)
def can_use(self, user_id: str, chat_id: str, estimated_tokens: int = 1000) -> bool:
"""Check if a user/chat can use more tokens."""
@@ -84,7 +122,7 @@ def record(self, user_id: str, chat_id: str, tokens_used: int) -> None:
self._data["users"][user_id] = self._data["users"].get(user_id, 0) + tokens_used
self._data["chats"][chat_id] = self._data["chats"].get(chat_id, 0) + tokens_used
- self._save()
+ self._schedule_save()
log_debug(
f"Token usage: user={user_id} +{tokens_used} "
f"(total: {self._data['users'][user_id]}), "
diff --git a/src/core/analytics.py b/src/core/analytics.py
index 32a7162..34ad5a5 100644
--- a/src/core/analytics.py
+++ b/src/core/analytics.py
@@ -5,6 +5,10 @@
"""
import json
+import os
+import tempfile
+import time
+from atexit import register as on_exit
from datetime import datetime, timedelta
from core.constants import DATA_DIR
@@ -13,6 +17,23 @@
ANALYTICS_FILE = DATA_DIR / "analytics.json"
DEFAULT_RETENTION_DAYS = 30
+SAVE_INTERVAL_SECONDS = 2.0
+
+
+def _atomic_write_json(file_path, data: dict) -> None:
+ """Atomically write JSON payload to disk."""
+ file_path.parent.mkdir(parents=True, exist_ok=True)
+ fd, tmp_path = tempfile.mkstemp(dir=file_path.parent, suffix=".tmp")
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2)
+ os.replace(tmp_path, file_path)
+ except BaseException:
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ raise
class CommandAnalytics:
@@ -20,7 +41,10 @@ class CommandAnalytics:
def __init__(self):
self._data: dict = {}
+ self._dirty = False
+ self._last_save_ts = 0.0
self._load()
+ on_exit(self.flush)
def _load(self) -> None:
"""Load analytics data from disk."""
@@ -35,8 +59,21 @@ def _load(self) -> None:
def _save(self) -> None:
"""Save analytics data to disk."""
- DATA_DIR.mkdir(parents=True, exist_ok=True)
- ANALYTICS_FILE.write_text(json.dumps(self._data, indent=2), encoding="utf-8")
+ _atomic_write_json(ANALYTICS_FILE, self._data)
+ self._dirty = False
+ self._last_save_ts = time.time()
+
+ def _schedule_save(self, force: bool = False) -> None:
+ """Persist analytics on interval to reduce disk writes."""
+ self._dirty = True
+ now = time.time()
+ if force or now - self._last_save_ts >= SAVE_INTERVAL_SECONDS:
+ self._save()
+
+ def flush(self) -> None:
+ """Flush pending analytics writes."""
+ if self._dirty:
+ self._save()
def record_command(self, name: str, user_jid: str = "", chat_jid: str = "") -> None:
"""Record a command execution."""
@@ -55,7 +92,7 @@ def record_command(self, name: str, user_jid: str = "", chat_jid: str = "") -> N
)
self._prune()
- self._save()
+ self._schedule_save()
log_debug(f"Analytics: recorded {name}")
def _prune(self) -> None:
diff --git a/src/core/env.py b/src/core/env.py
index 83f289c..c7c3682 100644
--- a/src/core/env.py
+++ b/src/core/env.py
@@ -49,23 +49,29 @@ def validate_environment():
user = os.getenv("DASHBOARD_USERNAME")
password = os.getenv("DASHBOARD_PASSWORD")
+ dashboard_enabled = runtime_config.get_nested("dashboard", "enabled", default=False)
if not user or not password:
- log_warning(
- "DASHBOARD_USERNAME or DASHBOARD_PASSWORD not set. Using defaults (admin:admin)."
- )
- log_bullet("This is INSECURE for production deployments!")
+ if dashboard_enabled:
+ log_warning(
+ "Dashboard is enabled but credentials are missing. Set DASHBOARD_USERNAME and DASHBOARD_PASSWORD."
+ )
+ else:
+ log_bullet("Dashboard credentials are not set (dashboard disabled).")
else:
log_success("Dashboard credentials configured via environment variables.")
ai_config = runtime_config.get("agentic_ai", {})
if ai_config.get("enabled"):
provider = ai_config.get("provider", "openai")
- api_key = (
- ai_config.get("API_KEY")
- or os.getenv("AI_API_KEY")
- or os.getenv("GEMINI_API_KEY")
- or os.getenv("OPENAI_API_KEY")
- )
+ config_key = str(ai_config.get("api_key") or "").strip()
+ env_keys = [
+ os.getenv("AI_API_KEY", ""),
+ os.getenv("OPENAI_API_KEY", ""),
+ os.getenv("ANTHROPIC_API_KEY", ""),
+ os.getenv("GOOGLE_API_KEY", ""),
+ os.getenv("GEMINI_API_KEY", ""),
+ ]
+ api_key = config_key or next((k for k in env_keys if k), "")
if api_key and not os.getenv("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = api_key
diff --git a/src/core/rate_limiter.py b/src/core/rate_limiter.py
index 258a79f..510cb81 100644
--- a/src/core/rate_limiter.py
+++ b/src/core/rate_limiter.py
@@ -8,6 +8,8 @@
from collections import defaultdict
from dataclasses import dataclass
+from core.runtime_config import runtime_config
+
@dataclass
class RateLimitConfig:
@@ -46,6 +48,10 @@ def __init__(self, config: RateLimitConfig | None = None):
self._user_bursts: dict[str, list[float]] = defaultdict(list)
+ def update_config(self, config: RateLimitConfig) -> None:
+ """Update limiter configuration at runtime."""
+ self.config = config
+
def is_limited(self, user_id: str, command_name: str) -> bool:
"""
Check if a user is rate limited.
@@ -131,4 +137,37 @@ def reset_all(self) -> None:
self._user_bursts.clear()
-rate_limiter = RateLimiter()
+def _to_float(value: object, default: float, minimum: float = 0.0) -> float:
+ """Cast value to bounded float."""
+ try:
+ parsed = float(value)
+ except (TypeError, ValueError):
+ parsed = default
+ return max(minimum, parsed)
+
+
+def _to_int(value: object, default: int, minimum: int = 1) -> int:
+ """Cast value to bounded integer."""
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError):
+ parsed = default
+ return max(minimum, parsed)
+
+
+def load_rate_limit_config() -> RateLimitConfig:
+ """Load validated rate limiter config from runtime configuration."""
+ section = runtime_config.get_nested("rate_limit", default={})
+ if not isinstance(section, dict):
+ section = {}
+
+ return RateLimitConfig(
+ enabled=bool(section.get("enabled", True)),
+ user_cooldown=_to_float(section.get("user_cooldown"), 3.0, minimum=0.0),
+ command_cooldown=_to_float(section.get("command_cooldown"), 2.0, minimum=0.0),
+ burst_limit=_to_int(section.get("burst_limit"), 5, minimum=1),
+ burst_window=_to_float(section.get("burst_window"), 10.0, minimum=1.0),
+ )
+
+
+rate_limiter = RateLimiter(load_rate_limit_config())
diff --git a/src/core/runtime_config.py b/src/core/runtime_config.py
index 0c6a800..7d324d6 100644
--- a/src/core/runtime_config.py
+++ b/src/core/runtime_config.py
@@ -13,9 +13,12 @@
from pathlib import Path
from typing import Any
+from jsonschema import Draft7Validator
+
from core import jsonc
CONFIG_FILE = Path(__file__).parent.parent.parent / "config.json"
+SCHEMA_FILE = Path(__file__).parent.parent.parent / "config.schema.json"
OVERRIDES_FILE = Path(__file__).parent.parent.parent / "data" / "runtime_overrides.json"
OVERRIDES_MIGRATION_MARKER = (
Path(__file__).parent.parent.parent / "data" / ".runtime_overrides_migrated"
@@ -102,10 +105,20 @@
"allowed_actions": [],
"blocked_actions": ["eval", "aeval", "addcommand", "delcommand"],
"owner_only": True,
+ "daily_token_limit_user": 50000,
+ "daily_token_limit_chat": 200000,
+ },
+ "rate_limit": {
+ "enabled": True,
+ "user_cooldown": 3.0,
+ "command_cooldown": 2.0,
+ "burst_limit": 5,
+ "burst_window": 10.0,
},
"disabled_commands": [],
"dashboard": {
"enabled": False,
+ "cors_origins": ["http://localhost:3000", "http://127.0.0.1:3000"],
},
}
@@ -131,8 +144,40 @@ def __init__(self):
return
self._initialized = True
self._config: dict[str, Any] = {}
+ self._validator = Draft7Validator(self._load_schema())
self._load()
+ def _load_schema(self) -> dict[str, Any]:
+ """Load JSON schema definition from disk."""
+ if not SCHEMA_FILE.exists():
+ raise FileNotFoundError(f"Schema file not found: {SCHEMA_FILE}")
+
+ with open(SCHEMA_FILE, encoding="utf-8") as f:
+ schema = json.load(f)
+
+ if not isinstance(schema, dict):
+ raise ValueError("Invalid schema format: expected object")
+
+ return schema
+
+ def _format_validation_error(self, error) -> str:
+ """Format jsonschema validation errors for operators/users."""
+ path = ".".join(str(p) for p in error.absolute_path)
+ location = path or ""
+ return f"{location}: {error.message}"
+
+ def _assert_valid_config(self, config: dict[str, Any]) -> None:
+ """Validate config against schema and raise on violations."""
+ errors = sorted(self._validator.iter_errors(config), key=lambda e: list(e.absolute_path))
+ if not errors:
+ return
+
+ formatted = [self._format_validation_error(err) for err in errors[:5]]
+ details = "; ".join(formatted)
+ if len(errors) > 5:
+ details += f"; ... and {len(errors) - 5} more"
+ raise ValueError(f"Config validation failed: {details}")
+
def _ensure_config_file(self) -> None:
"""Ensure the config file exists with defaults."""
if not CONFIG_FILE.exists():
@@ -141,6 +186,7 @@ def _ensure_config_file(self) -> None:
def _write_default_config(self) -> None:
"""Write default config file."""
default_config = self._ensure_schema_key(deepcopy(DEFAULT_CONFIG))
+ self._assert_valid_config(default_config)
jsonc.dump(default_config, CONFIG_FILE, indent=2)
def _ensure_schema_key(self, config: dict[str, Any]) -> dict[str, Any]:
@@ -273,6 +319,69 @@ def _normalize_legacy_actions(self, config: dict[str, Any]) -> tuple[dict[str, A
call_guard["delay_seconds"] = delay
changed = True
+ rate_limit = config.get("rate_limit")
+ if not isinstance(rate_limit, dict):
+ config["rate_limit"] = deepcopy(DEFAULT_CONFIG["rate_limit"])
+ changed = True
+ rate_limit = config["rate_limit"]
+
+ try:
+ user_cd = float(rate_limit.get("user_cooldown", 3.0))
+ except (TypeError, ValueError):
+ user_cd = 3.0
+ user_cd = max(0.0, user_cd)
+ if rate_limit.get("user_cooldown") != user_cd:
+ rate_limit["user_cooldown"] = user_cd
+ changed = True
+
+ try:
+ cmd_cd = float(rate_limit.get("command_cooldown", 2.0))
+ except (TypeError, ValueError):
+ cmd_cd = 2.0
+ cmd_cd = max(0.0, cmd_cd)
+ if rate_limit.get("command_cooldown") != cmd_cd:
+ rate_limit["command_cooldown"] = cmd_cd
+ changed = True
+
+ try:
+ burst_limit = int(rate_limit.get("burst_limit", 5))
+ except (TypeError, ValueError):
+ burst_limit = 5
+ burst_limit = max(1, burst_limit)
+ if rate_limit.get("burst_limit") != burst_limit:
+ rate_limit["burst_limit"] = burst_limit
+ changed = True
+
+ try:
+ burst_window = float(rate_limit.get("burst_window", 10.0))
+ except (TypeError, ValueError):
+ burst_window = 10.0
+ burst_window = max(1.0, burst_window)
+ if rate_limit.get("burst_window") != burst_window:
+ rate_limit["burst_window"] = burst_window
+ changed = True
+
+ enabled = bool(rate_limit.get("enabled", True))
+ if rate_limit.get("enabled") != enabled:
+ rate_limit["enabled"] = enabled
+ changed = True
+
+ dashboard = config.get("dashboard")
+ if not isinstance(dashboard, dict):
+ config["dashboard"] = deepcopy(DEFAULT_CONFIG["dashboard"])
+ changed = True
+ dashboard = config["dashboard"]
+
+ cors_origins = dashboard.get("cors_origins", [])
+ if not isinstance(cors_origins, list):
+ dashboard["cors_origins"] = deepcopy(DEFAULT_CONFIG["dashboard"]["cors_origins"])
+ changed = True
+ else:
+ cleaned = [str(origin).strip() for origin in cors_origins if str(origin).strip()]
+ if cleaned != cors_origins:
+ dashboard["cors_origins"] = cleaned
+ changed = True
+
return config, changed
def _migrate_runtime_overrides(self, config: dict[str, Any]) -> tuple[dict[str, Any], bool]:
@@ -307,6 +416,7 @@ def _load(self) -> None:
config, migrated = self._migrate_runtime_overrides(config)
config, normalized = self._normalize_legacy_actions(config)
config = self._ensure_schema_key(config)
+ self._assert_valid_config(config)
self._config = config
@@ -340,7 +450,13 @@ def _deep_merge(self, base: dict, overrides: dict) -> dict:
def _save(self) -> None:
"""Persist full runtime config into config.json."""
- self._config = self._ensure_schema_key(self._config)
+ self._save_candidate(self._config)
+
+ def _save_candidate(self, candidate: dict[str, Any]) -> None:
+ """Validate and persist a candidate runtime config."""
+ normalized = self._ensure_schema_key(candidate)
+ self._assert_valid_config(normalized)
+ self._config = normalized
jsonc.dump(self._config, CONFIG_FILE, indent=2)
def reload(self) -> None:
@@ -413,10 +529,11 @@ def self_mode(self) -> bool:
def set_self_mode(self, enabled: bool) -> None:
"""Set self mode on or off."""
- if "bot" not in self._config:
- self._config["bot"] = {}
- self._config["bot"]["self_mode"] = enabled
- self._save()
+ updated = deepcopy(self._config)
+ if "bot" not in updated or not isinstance(updated["bot"], dict):
+ updated["bot"] = {}
+ updated["bot"]["self_mode"] = enabled
+ self._save_candidate(updated)
@property
def login_method(self) -> str:
@@ -432,10 +549,11 @@ def get_owner_jid(self) -> str:
def set_owner_jid(self, jid: str) -> None:
"""Set the owner JID."""
- if "bot" not in self._config:
- self._config["bot"] = {}
- self._config["bot"]["owner_jid"] = jid
- self._save()
+ updated = deepcopy(self._config)
+ if "bot" not in updated or not isinstance(updated["bot"], dict):
+ updated["bot"] = {}
+ updated["bot"]["owner_jid"] = jid
+ self._save_candidate(updated)
def is_owner(self, sender_jid: str) -> bool:
"""Check if the sender is the bot owner (sync fallback, compares user parts only)."""
@@ -476,18 +594,11 @@ def get_feature(self, name: str) -> bool:
def set_feature(self, name: str, value: bool) -> None:
"""Set a feature flag value."""
- if "features" not in self._config:
- self._config["features"] = {}
- self._config["features"][name] = value
- self._save()
-
- try:
- from config.settings import features
-
- if hasattr(features, name):
- setattr(features, name, value)
- except ImportError:
- pass
+ updated = deepcopy(self._config)
+ if "features" not in updated or not isinstance(updated["features"], dict):
+ updated["features"] = {}
+ updated["features"][name] = value
+ self._save_candidate(updated)
def get_all_features(self) -> dict[str, bool]:
"""Get all feature flags."""
@@ -503,23 +614,25 @@ def is_command_enabled(self, command_name: str) -> bool:
def enable_command(self, command_name: str) -> bool:
"""Enable a command. Returns True if it was disabled."""
- disabled = self.get_disabled_commands()
+ disabled = self.get_disabled_commands().copy()
cmd = command_name.lower()
if cmd in disabled:
disabled.remove(cmd)
- self._config["disabled_commands"] = disabled
- self._save()
+ updated = deepcopy(self._config)
+ updated["disabled_commands"] = disabled
+ self._save_candidate(updated)
return True
return False
def disable_command(self, command_name: str) -> bool:
"""Disable a command. Returns True if it was enabled."""
- disabled = self.get_disabled_commands()
+ disabled = self.get_disabled_commands().copy()
cmd = command_name.lower()
if cmd not in disabled:
disabled.append(cmd)
- self._config["disabled_commands"] = disabled
- self._save()
+ updated = deepcopy(self._config)
+ updated["disabled_commands"] = disabled
+ self._save_candidate(updated)
return True
return False
@@ -541,8 +654,9 @@ def get_nested(self, *keys, default: Any = None) -> Any:
def set(self, key: str, value: Any) -> None:
"""Set a top-level config value."""
- self._config[key] = value
- self._save()
+ updated = deepcopy(self._config)
+ updated[key] = value
+ self._save_candidate(updated)
def set_nested(self, *keys_and_value) -> None:
"""Set a nested config value. Last argument is the value."""
@@ -552,14 +666,15 @@ def set_nested(self, *keys_and_value) -> None:
keys = keys_and_value[:-1]
value = keys_and_value[-1]
- current = self._config
+ updated = deepcopy(self._config)
+ current = updated
for key in keys[:-1]:
- if key not in current:
+ if key not in current or not isinstance(current[key], dict):
current[key] = {}
current = current[key]
current[keys[-1]] = value
- self._save()
+ self._save_candidate(updated)
def all_config(self) -> dict[str, Any]:
"""Get all configuration."""
diff --git a/src/core/storage.py b/src/core/storage.py
index 72cff1f..c382866 100644
--- a/src/core/storage.py
+++ b/src/core/storage.py
@@ -7,6 +7,9 @@
import json
import os
import tempfile
+import threading
+import time
+from atexit import register as on_exit
from pathlib import Path
from typing import Any
@@ -213,10 +216,22 @@ def save_muted(self, users: list) -> None:
class Storage:
"""Global storage manager for dashboard API."""
+ _cache_lock = threading.Lock()
+ _stats_cache: dict[str, Any] | None = None
+ _groups_cache: dict[str, Any] | None = None
+ _stats_dirty = False
+ _groups_dirty = False
+ _last_flush_ts = 0.0
+ _flush_interval_seconds = 2.0
+ _atexit_registered = False
+
def __init__(self) -> None:
"""Initialize storage manager."""
self.stats_file = DATA_DIR / "stats.json"
self.groups_file = DATA_DIR / "groups.json"
+ if not Storage._atexit_registered:
+ on_exit(self.flush)
+ Storage._atexit_registered = True
def _load_json(self, file: Path, default: Any = None) -> Any:
"""Load JSON file."""
@@ -232,56 +247,106 @@ def _save_json(self, file: Path, data: Any) -> None:
"""Save JSON file (atomic write)."""
_atomic_write(file, data)
+ def _ensure_stats_cache(self) -> dict[str, Any]:
+ """Ensure stats cache is loaded into memory."""
+ if Storage._stats_cache is None:
+ loaded = self._load_json(self.stats_file, {})
+ Storage._stats_cache = loaded if isinstance(loaded, dict) else {}
+ return Storage._stats_cache
+
+ def _ensure_groups_cache(self) -> dict[str, Any]:
+ """Ensure groups cache is loaded into memory."""
+ if Storage._groups_cache is None:
+ loaded = self._load_json(self.groups_file, {})
+ Storage._groups_cache = loaded if isinstance(loaded, dict) else {}
+ return Storage._groups_cache
+
+ def _flush_if_needed(self, force: bool = False) -> None:
+ """Flush dirty caches to disk on interval or force."""
+ now = time.time()
+ if not force and now - Storage._last_flush_ts < Storage._flush_interval_seconds:
+ return
+
+ if Storage._stats_dirty and Storage._stats_cache is not None:
+ self._save_json(self.stats_file, Storage._stats_cache)
+ Storage._stats_dirty = False
+
+ if Storage._groups_dirty and Storage._groups_cache is not None:
+ self._save_json(self.groups_file, Storage._groups_cache)
+ Storage._groups_dirty = False
+
+ Storage._last_flush_ts = now
+
+ def flush(self, force: bool = True) -> None:
+ """Flush in-memory dirty data to disk."""
+ with Storage._cache_lock:
+ self._flush_if_needed(force=force)
+
def get_all_groups(self) -> dict:
"""Get all groups and their settings."""
- return self._load_json(self.groups_file, {})
+ with Storage._cache_lock:
+ groups = self._ensure_groups_cache()
+ return groups.copy()
def get_group_settings(self, group_id: str) -> dict | None:
"""Get settings for a specific group."""
- groups = self.get_all_groups()
- return groups.get(group_id)
+ with Storage._cache_lock:
+ groups = self._ensure_groups_cache()
+ settings = groups.get(group_id)
+ if isinstance(settings, dict):
+ return settings.copy()
+ return settings
def set_group_settings(self, group_id: str, settings: dict) -> None:
"""Update settings for a group."""
- groups = self.get_all_groups()
- if group_id not in groups:
- groups[group_id] = {}
- groups[group_id].update(settings)
- self._save_json(self.groups_file, groups)
+ with Storage._cache_lock:
+ groups = self._ensure_groups_cache()
+ if group_id not in groups or not isinstance(groups[group_id], dict):
+ groups[group_id] = {}
+ groups[group_id].update(settings)
+ Storage._groups_dirty = True
+ self._flush_if_needed()
def register_group(
self, group_id: str, name: str, member_count: int = 0, is_admin: bool = False
) -> None:
"""Register a new group or update existing."""
- groups = self.get_all_groups()
- if group_id not in groups:
- groups[group_id] = {
- "name": name,
- "member_count": member_count,
- "is_admin": is_admin,
- "antilink": False,
- "welcome": True,
- "mute": False,
- }
- else:
- groups[group_id]["name"] = name
- groups[group_id]["member_count"] = member_count
- groups[group_id]["is_admin"] = is_admin
- self._save_json(self.groups_file, groups)
+ with Storage._cache_lock:
+ groups = self._ensure_groups_cache()
+ if group_id not in groups or not isinstance(groups[group_id], dict):
+ groups[group_id] = {
+ "name": name,
+ "member_count": member_count,
+ "is_admin": is_admin,
+ "antilink": False,
+ "welcome": True,
+ "mute": False,
+ }
+ else:
+ groups[group_id]["name"] = name
+ groups[group_id]["member_count"] = member_count
+ groups[group_id]["is_admin"] = is_admin
+ Storage._groups_dirty = True
+ self._flush_if_needed()
def get_stat(self, key: str, default: Any = 0) -> Any:
"""Get a stat value."""
- stats = self._load_json(self.stats_file, {})
- return stats.get(key, default)
+ with Storage._cache_lock:
+ stats = self._ensure_stats_cache()
+ return stats.get(key, default)
def set_stat(self, key: str, value: Any) -> None:
"""Set a stat value."""
- stats = self._load_json(self.stats_file, {})
- stats[key] = value
- self._save_json(self.stats_file, stats)
+ with Storage._cache_lock:
+ stats = self._ensure_stats_cache()
+ stats[key] = value
+ Storage._stats_dirty = True
+ self._flush_if_needed()
def increment_stat(self, key: str, amount: int = 1) -> None:
"""Increment a stat value."""
- stats = self._load_json(self.stats_file, {})
- stats[key] = stats.get(key, 0) + amount
- self._save_json(self.stats_file, stats)
+ with Storage._cache_lock:
+ stats = self._ensure_stats_cache()
+ stats[key] = stats.get(key, 0) + amount
+ Storage._stats_dirty = True
+ self._flush_if_needed()
diff --git a/src/dashboard_api.py b/src/dashboard_api.py
index a3ce6f4..ac680c6 100644
--- a/src/dashboard_api.py
+++ b/src/dashboard_api.py
@@ -16,6 +16,7 @@
from typing import Any
from urllib.parse import unquote
+from dotenv import load_dotenv
from fastapi import (
APIRouter,
Body,
@@ -47,7 +48,7 @@
set_goodbye_config,
set_welcome_config,
)
-from core.rate_limiter import rate_limiter
+from core.rate_limiter import RateLimitConfig, rate_limiter
from core.reports import create_report, get_report, list_reports, update_report_status
from core.runtime_config import runtime_config
from core.scheduler import get_scheduler
@@ -56,6 +57,102 @@
from core.storage import GroupData, Storage
BOT_START_TIME = datetime.now()
+_DOTENV_PATH = Path(__file__).parent.parent / ".env"
+_dotenv_loaded = False
+DEFAULT_CORS_ORIGINS = [
+ "http://localhost:3000",
+ "http://127.0.0.1:3000",
+ "http://localhost:5173",
+ "http://127.0.0.1:5173",
+]
+WS_TOKEN_TTL_SECONDS = max(30, int(os.getenv("DASHBOARD_WS_TOKEN_TTL_SECONDS", "300")))
+_ws_tokens: dict[str, dict[str, Any]] = {}
+
+
+def _ensure_dotenv_loaded() -> None:
+ """Load .env once for standalone dashboard process usage."""
+ global _dotenv_loaded
+ if _dotenv_loaded:
+ return
+ load_dotenv(_DOTENV_PATH)
+ _dotenv_loaded = True
+
+
+def _get_dashboard_credentials() -> tuple[str, str]:
+ """Get dashboard credentials from environment variables."""
+ _ensure_dotenv_loaded()
+ username = str(os.getenv("DASHBOARD_USERNAME", "")).strip()
+ password = str(os.getenv("DASHBOARD_PASSWORD", "")).strip()
+
+ if not username or not password:
+ raise HTTPException(
+ status_code=503,
+ detail="Dashboard credentials are not configured. Set DASHBOARD_USERNAME and DASHBOARD_PASSWORD.",
+ )
+
+ if username == "admin" and password == "admin":
+ raise HTTPException(
+ status_code=503,
+ detail="Insecure dashboard credentials detected. Change DASHBOARD_USERNAME and DASHBOARD_PASSWORD.",
+ )
+
+ return username, password
+
+
+def _get_cors_origins() -> list[str]:
+ """Resolve allowed dashboard origins from env/config with secure defaults."""
+ _ensure_dotenv_loaded()
+ from_env = [
+ o.strip()
+ for o in os.getenv("DASHBOARD_CORS_ORIGINS", "").split(",")
+ if o and o.strip() and o.strip() != "*"
+ ]
+
+ from_config = runtime_config.get_nested("dashboard", "cors_origins", default=[])
+ config_origins = [
+ o.strip() for o in from_config if isinstance(o, str) and o.strip() and o.strip() != "*"
+ ]
+
+ origins = from_env or config_origins or DEFAULT_CORS_ORIGINS
+ seen = set()
+ deduped = []
+ for origin in origins:
+ if origin not in seen:
+ seen.add(origin)
+ deduped.append(origin)
+ return deduped
+
+
+def _prune_ws_tokens() -> None:
+ """Remove expired WebSocket auth tokens."""
+ now_ts = datetime.now().timestamp()
+ for token in list(_ws_tokens.keys()):
+ if _ws_tokens[token].get("expires_at", 0.0) <= now_ts:
+ _ws_tokens.pop(token, None)
+
+
+def _issue_ws_token(username: str) -> tuple[str, int]:
+ """Issue one-time WebSocket token for an authenticated user."""
+ _prune_ws_tokens()
+ token = secrets.token_urlsafe(32)
+ expires_in = WS_TOKEN_TTL_SECONDS
+ _ws_tokens[token] = {
+ "username": username,
+ "expires_at": datetime.now().timestamp() + float(expires_in),
+ }
+ return token, expires_in
+
+
+def _consume_ws_token(token: str) -> str | None:
+ """Consume and validate one-time WebSocket token."""
+ _prune_ws_tokens()
+ payload = _ws_tokens.pop(token, None)
+ if not payload:
+ return None
+ expires_at = float(payload.get("expires_at", 0.0))
+ if expires_at <= datetime.now().timestamp():
+ return None
+ return str(payload.get("username", "")) or None
async def get_current_username(request: Request) -> str:
@@ -91,8 +188,7 @@ async def get_current_username(request: Request) -> str:
headers={"WWW-Authenticate": "Basic"},
) from e
- expected_username = os.getenv("DASHBOARD_USERNAME", "admin")
- expected_password = os.getenv("DASHBOARD_PASSWORD", "admin")
+ expected_username, expected_password = _get_dashboard_credentials()
correct_username = secrets.compare_digest(cred_username, expected_username)
correct_password = secrets.compare_digest(cred_password, expected_password)
@@ -115,7 +211,7 @@ async def get_current_username(request: Request) -> str:
app.add_middleware(
CORSMiddleware,
- allow_origins=["*"],
+ allow_origins=_get_cors_origins(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -355,12 +451,20 @@ async def get_qr():
return {"qr": session_state.qr_code}
+@_api.post("/api/ws-token")
+async def create_ws_token(username: str = Depends(get_current_username)):
+ """Issue a short-lived token for authenticated WebSocket connections."""
+ token, expires_in = _issue_ws_token(username)
+ return {"token": token, "expires_in": expires_in}
+
+
@_api.post("/api/auth/pair")
async def start_pairing(req: PairRequest):
"""Start pairing with phone number."""
- runtime_config.set_nested("bot", "login_method", "PAIR_CODE")
- runtime_config.set_nested("bot", "phone_number", req.phone)
- runtime_config._save()
+ bot_cfg = runtime_config.get("bot", {}).copy()
+ bot_cfg["login_method"] = "PAIR_CODE"
+ bot_cfg["phone_number"] = req.phone
+ runtime_config.set("bot", bot_cfg)
return {
"success": True,
@@ -413,7 +517,6 @@ async def update_config(update: ConfigUpdate):
"""Update a configuration value."""
try:
runtime_config.set_nested(update.section, update.key, update.value)
- runtime_config._save()
await event_bus.emit(
"config_update", {"section": update.section, "key": update.key, "value": update.value}
)
@@ -457,7 +560,6 @@ async def toggle_command(name: str, toggle: CommandToggle):
disabled.append(name)
runtime_config.set("disabled_commands", disabled)
- runtime_config._save()
await event_bus.emit("command_update", {"name": name, "enabled": toggle.enabled})
return {"success": True, "name": name, "enabled": toggle.enabled}
@@ -772,24 +874,34 @@ def get_last_lines(filename, count):
@_api.get("/api/ratelimit")
async def get_rate_limit():
"""Get rate limit configuration."""
- config = rate_limiter.config
+ config = runtime_config.get_nested("rate_limit", default={})
+ if not isinstance(config, dict):
+ config = {}
return {
- "enabled": config.enabled,
- "user_cooldown": config.user_cooldown,
- "command_cooldown": config.command_cooldown,
- "burst_limit": config.burst_limit,
- "burst_window": config.burst_window,
+ "enabled": bool(config.get("enabled", rate_limiter.config.enabled)),
+ "user_cooldown": float(config.get("user_cooldown", rate_limiter.config.user_cooldown)),
+ "command_cooldown": float(
+ config.get("command_cooldown", rate_limiter.config.command_cooldown)
+ ),
+ "burst_limit": int(config.get("burst_limit", rate_limiter.config.burst_limit)),
+ "burst_window": float(config.get("burst_window", rate_limiter.config.burst_window)),
}
@_api.put("/api/ratelimit")
async def update_rate_limit(settings: RateLimitSettings):
"""Update rate limit configuration."""
- rate_limiter.config.enabled = settings.enabled
- rate_limiter.config.user_cooldown = settings.user_cooldown
- rate_limiter.config.command_cooldown = settings.command_cooldown
- rate_limiter.config.burst_limit = settings.burst_limit
- rate_limiter.config.burst_window = settings.burst_window
+ rate_limit_config = {
+ "enabled": settings.enabled,
+ "user_cooldown": settings.user_cooldown,
+ "command_cooldown": settings.command_cooldown,
+ "burst_limit": settings.burst_limit,
+ "burst_window": settings.burst_window,
+ }
+
+ runtime_config.set("rate_limit", rate_limit_config)
+ rate_limiter.update_config(RateLimitConfig(**rate_limit_config))
+ await event_bus.emit("config_update", {"section": "rate_limit", "key": "all"})
return {"success": True}
@@ -1410,6 +1522,11 @@ async def delete_group_automation(group_id: str, rule_id: str):
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
"""WebSocket for real-time dashboard updates."""
+ token = ws.query_params.get("token", "")
+ if not token or not _consume_ws_token(token):
+ await ws.close(code=1008, reason="Unauthorized")
+ return
+
await ws.accept()
queue = event_bus.subscribe()
try:
@@ -1475,12 +1592,13 @@ async def get_ai_config():
@_api.put("/api/ai-config")
async def update_ai_config(config: AIConfigUpdate):
"""Update AI configuration."""
- runtime_config.set_nested("agentic_ai", "enabled", config.enabled)
- runtime_config.set_nested("agentic_ai", "provider", config.provider)
- runtime_config.set_nested("agentic_ai", "model", config.model)
- runtime_config.set_nested("agentic_ai", "trigger_mode", config.trigger_mode)
- runtime_config.set_nested("agentic_ai", "owner_only", config.owner_only)
- runtime_config._save()
+ ai_cfg = runtime_config.get("agentic_ai", {}).copy()
+ ai_cfg["enabled"] = config.enabled
+ ai_cfg["provider"] = config.provider
+ ai_cfg["model"] = config.model
+ ai_cfg["trigger_mode"] = config.trigger_mode
+ ai_cfg["owner_only"] = config.owner_only
+ runtime_config.set("agentic_ai", ai_cfg)
await event_bus.emit(
"config_update", {"section": "agentic_ai", "key": "all", "value": config.dict()}
)
diff --git a/tests/test_ai_action_policy.py b/tests/test_ai_action_policy.py
new file mode 100644
index 0000000..7947d85
--- /dev/null
+++ b/tests/test_ai_action_policy.py
@@ -0,0 +1,30 @@
+from ai import agent as ai_agent
+
+
+def test_blocked_actions_are_enforced(monkeypatch):
+ def fake_get_nested(*keys, default=None):
+ if keys == ("agentic_ai", "allowed_actions"):
+ return ["ping", "eval"]
+ if keys == ("agentic_ai", "blocked_actions"):
+ return ["eval"]
+ return default
+
+ monkeypatch.setattr(ai_agent.runtime_config, "get_nested", fake_get_nested)
+
+ assert ai_agent._is_ai_action_allowed("ping")
+ assert not ai_agent._is_ai_action_allowed("eval")
+ assert not ai_agent._is_ai_action_allowed("help")
+
+
+def test_allowed_actions_empty_uses_blocklist_only(monkeypatch):
+ def fake_get_nested(*keys, default=None):
+ if keys == ("agentic_ai", "allowed_actions"):
+ return []
+ if keys == ("agentic_ai", "blocked_actions"):
+ return ["shutdown"]
+ return default
+
+ monkeypatch.setattr(ai_agent.runtime_config, "get_nested", fake_get_nested)
+
+ assert ai_agent._is_ai_action_allowed("ping")
+ assert not ai_agent._is_ai_action_allowed("shutdown")
diff --git a/tests/test_dashboard_security_and_ratelimit.py b/tests/test_dashboard_security_and_ratelimit.py
new file mode 100644
index 0000000..aa023f6
--- /dev/null
+++ b/tests/test_dashboard_security_and_ratelimit.py
@@ -0,0 +1,59 @@
+import pytest
+from fastapi import HTTPException
+
+import dashboard_api
+
+
+def test_dashboard_credentials_require_env(monkeypatch):
+ monkeypatch.delenv("DASHBOARD_USERNAME", raising=False)
+ monkeypatch.delenv("DASHBOARD_PASSWORD", raising=False)
+
+ with pytest.raises(HTTPException) as exc:
+ dashboard_api._get_dashboard_credentials()
+
+ assert exc.value.status_code == 503
+
+
+def test_dashboard_credentials_reject_admin_defaults(monkeypatch):
+ monkeypatch.setenv("DASHBOARD_USERNAME", "admin")
+ monkeypatch.setenv("DASHBOARD_PASSWORD", "admin")
+
+ with pytest.raises(HTTPException) as exc:
+ dashboard_api._get_dashboard_credentials()
+
+ assert exc.value.status_code == 503
+
+
+@pytest.mark.asyncio
+async def test_rate_limit_update_persists(monkeypatch):
+ captured = {}
+ limiter = {}
+ emitted = []
+
+ def fake_set(key, value):
+ captured[key] = value
+
+ def fake_update(config):
+ limiter.update(config.__dict__)
+
+ async def fake_emit(event_type, payload):
+ emitted.append((event_type, payload))
+
+ monkeypatch.setattr(dashboard_api.runtime_config, "set", fake_set)
+ monkeypatch.setattr(dashboard_api.rate_limiter, "update_config", fake_update)
+ monkeypatch.setattr(dashboard_api.event_bus, "emit", fake_emit)
+
+ result = await dashboard_api.update_rate_limit(
+ dashboard_api.RateLimitSettings(
+ enabled=True,
+ user_cooldown=4.5,
+ command_cooldown=3.0,
+ burst_limit=9,
+ burst_window=12.0,
+ )
+ )
+
+ assert result == {"success": True}
+ assert captured["rate_limit"]["burst_limit"] == 9
+ assert limiter["burst_limit"] == 9
+ assert emitted[0][0] == "config_update"
diff --git a/tests/test_runtime_config_validation.py b/tests/test_runtime_config_validation.py
new file mode 100644
index 0000000..8ba0d82
--- /dev/null
+++ b/tests/test_runtime_config_validation.py
@@ -0,0 +1,46 @@
+from pathlib import Path
+
+import pytest
+
+import core.runtime_config as runtime_config_module
+
+
+@pytest.fixture
+def isolated_runtime_config(tmp_path, monkeypatch):
+ schema_path = Path(__file__).resolve().parents[1] / "config.schema.json"
+
+ monkeypatch.setattr(runtime_config_module, "CONFIG_FILE", tmp_path / "config.json")
+ monkeypatch.setattr(runtime_config_module, "SCHEMA_FILE", schema_path)
+ monkeypatch.setattr(
+ runtime_config_module, "OVERRIDES_FILE", tmp_path / "runtime_overrides.json"
+ )
+ monkeypatch.setattr(
+ runtime_config_module,
+ "OVERRIDES_MIGRATION_MARKER",
+ tmp_path / ".runtime_overrides_migrated",
+ )
+ runtime_config_module.RuntimeConfig._instance = None
+
+ cfg = runtime_config_module.RuntimeConfig()
+ yield cfg
+
+ runtime_config_module.RuntimeConfig._instance = None
+
+
+def test_invalid_schema_update_is_rejected(isolated_runtime_config):
+ cfg = isolated_runtime_config
+
+ before = cfg.get_nested("rate_limit", "burst_limit")
+
+ with pytest.raises(ValueError):
+ cfg.set_nested("rate_limit", "burst_limit", 0)
+
+ assert cfg.get_nested("rate_limit", "burst_limit") == before
+
+
+def test_valid_schema_update_is_persisted(isolated_runtime_config):
+ cfg = isolated_runtime_config
+
+ cfg.set_nested("rate_limit", "burst_limit", 9)
+
+ assert cfg.get_nested("rate_limit", "burst_limit") == 9
diff --git a/uv.lock b/uv.lock
index a840a8a..4d297bf 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1173,6 +1173,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" },
]
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
[[package]]
name = "invoke"
version = "2.2.1"
@@ -2029,6 +2038,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
]
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
[[package]]
name = "prometheus-client"
version = "0.24.1"
@@ -2548,6 +2566,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" },
]
+[[package]]
+name = "pytest"
+version = "9.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
+]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
+]
+
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -3685,6 +3732,7 @@ dependencies = [
{ name = "apscheduler" },
{ name = "fastapi" },
{ name = "gallery-dl" },
+ { name = "jsonschema" },
{ name = "neonize" },
{ name = "pillow" },
{ name = "pydantic-ai" },
@@ -3698,6 +3746,8 @@ dependencies = [
[package.dev-dependencies]
dev = [
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
{ name = "ruff" },
]
@@ -3706,6 +3756,7 @@ requires-dist = [
{ name = "apscheduler", specifier = ">=3.10.0" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "gallery-dl", specifier = ">=1.31.7" },
+ { name = "jsonschema", specifier = ">=4.23.0" },
{ name = "neonize", specifier = ">=0.3.14.post0" },
{ name = "pillow", specifier = ">=10.0.0" },
{ name = "pydantic-ai", specifier = ">=1.48.0" },
@@ -3718,7 +3769,11 @@ requires-dist = [
]
[package.metadata.requires-dev]
-dev = [{ name = "ruff", specifier = ">=0.9.0" }]
+dev = [
+ { name = "pytest", specifier = ">=8.4.0" },
+ { name = "pytest-asyncio", specifier = ">=1.1.0" },
+ { name = "ruff", specifier = ">=0.9.0" },
+]
[[package]]
name = "zipp"
From 3220c1308c35ad85da98ac75d5eae5fcd2a90b6f Mon Sep 17 00:00:00 2001
From: MhankBarBar
Date: Sat, 14 Mar 2026 18:13:43 +0700
Subject: [PATCH 2/2] feat: migrate runtime storage to SQL DB and add webhook
management across API/dashboard
- add SQLAlchemy DB layer with SQLite default, PostgreSQL support, and legacy JSON migration
- move runtime persistence (storage, scheduler, reports, analytics, AI memory/tokens, AFK, i18n chat lang) to DB
- add webhook dispatcher with retries, signatures, and delivery logging
- add webhook CRUD/test/delivery endpoints and dashboard webhooks page/nav
- harden dashboard auth/CORS/websocket token flow and improve login error messages
- update docs for CLI args (`uv run zero-ichi --...`), DB/webhooks, and mobile docs UI fixes
---
.env.example | 18 +-
README.md | 13 +
dashboard/README.md | 13 +-
dashboard/src/app/login/page.tsx | 21 +-
dashboard/src/app/webhooks/page.tsx | 263 ++++++++
dashboard/src/components/dashboard-layout.tsx | 1 +
dashboard/src/lib/api.ts | 67 ++
docs/.vitepress/config.mts | 11 +-
docs/.vitepress/theme/custom.css | 53 +-
docs/development/architecture.md | 25 +-
docs/development/contributing.md | 2 +-
docs/features/dashboard.md | 22 +-
docs/features/webhooks.md | 72 ++
docs/getting-started/configuration.md | 26 +-
docs/getting-started/first-run.md | 25 +
docs/getting-started/installation.md | 17 +-
docs/index.md | 2 +
pyproject.toml | 3 +
src/ai/memory.py | 47 +-
src/ai/token_tracker.py | 56 +-
src/core/analytics.py | 48 +-
src/core/db.py | 628 ++++++++++++++++++
src/core/env.py | 6 +-
src/core/event_bus.py | 7 +
src/core/handlers/afk.py | 44 +-
src/core/i18n.py | 38 +-
src/core/reports.py | 28 +-
src/core/scheduler.py | 50 +-
src/core/storage.py | 263 ++------
src/core/webhooks.py | 204 ++++++
src/dashboard_api.py | 148 +++++
src/main.py | 3 +
tests/test_db_webhooks.py | 57 ++
uv.lock | 180 +++++
34 files changed, 2039 insertions(+), 422 deletions(-)
create mode 100644 dashboard/src/app/webhooks/page.tsx
create mode 100644 docs/features/webhooks.md
create mode 100644 src/core/db.py
create mode 100644 src/core/webhooks.py
create mode 100644 tests/test_db_webhooks.py
diff --git a/.env.example b/.env.example
index c74385b..af650ae 100644
--- a/.env.example
+++ b/.env.example
@@ -4,7 +4,19 @@
# AI API Key
AI_API_KEY=your_api_key_here
+# Optional Database URL
+# Default when empty: sqlite:///data/zeroichi.db
+# Example PostgreSQL:
+# DATABASE_URL=postgresql://user:password@localhost:5432/zeroichi
+DATABASE_URL=
+
# Dashboard Authentication
-# Default: admin / admin
-DASHBOARD_USERNAME=admin
-DASHBOARD_PASSWORD=admin
+# Required when dashboard.enabled=true
+DASHBOARD_USERNAME=change_me
+DASHBOARD_PASSWORD=change_me_too
+
+# Optional Dashboard CORS origins (comma-separated)
+# DASHBOARD_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
+
+# Optional WebSocket token TTL in seconds
+# DASHBOARD_WS_TOKEN_TTL_SECONDS=300
diff --git a/README.md b/README.md
index 42d4607..608f5f5 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,8 @@ Because life is too short for boring bots.
- **Universal Downloader**: Grab videos from YouTube, TikTok, Instagram, and 1000+ other sites. Yes, even that one.
- **Mod Toolkit**: Keep your groups clean with anti-link, anti-delete, warnings, reports, and blacklists.
- **Time Travel**: Okay, not really, but our **Scheduler** lets you send messages in the future (cron supported!).
+- **Webhooks**: Stream bot events into your own apps, Discord, CI, or alerting stack.
+- **Database-Backed State**: SQLite out of the box, PostgreSQL when you need it.
- **Polyglot**: Speaks English and Indonesian fluently. Add your own language if you're feeling adventurous.
- **Shiny Dashboard**: A web interface to manage everything because terminals are scary sometimes.
@@ -57,6 +59,17 @@ cp .env.example .env
uv run zero-ichi
```
+Common CLI options:
+
+```bash
+uv run zero-ichi --debug --auto-reload
+uv run zero-ichi --qr
+uv run zero-ichi --phone 6281234567890
+uv run zero-ichi --dashboard
+uv run zero-ichi --session my_session
+uv run zero-ichi update
+```
+
Scan the QR code and you're in business.
---
diff --git a/dashboard/README.md b/dashboard/README.md
index a84e5cb..37cedf4 100644
--- a/dashboard/README.md
+++ b/dashboard/README.md
@@ -30,7 +30,7 @@ The dashboard provides a web-based interface for managing and monitoring the Zer
### Prerequisites
-- Node.js 20+
+- Bun 1.2+
- The bot API running on `http://localhost:8000`
### Installation
@@ -39,11 +39,8 @@ The dashboard provides a web-based interface for managing and monitoring the Zer
# Navigate to dashboard directory
cd dashboard
-# Install dependencies (using Bun)
+# Install dependencies
bun install
-
-# Or using npm
-npm install
```
### Development
@@ -51,9 +48,6 @@ npm install
```bash
# Start development server
bun dev
-
-# Or using npm
-npm run dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
@@ -100,12 +94,13 @@ dashboard/
| Configuration | Modify bot settings via UI |
| Task Scheduler | View and manage scheduled tasks |
| Logs Viewer | Browse recent bot activity |
+| Webhooks | Configure outbound event webhooks |
---
## API Connection
-The dashboard expects the bot API to be running at `http://localhost:8000`. The API is started automatically when running the bot via `uv run main.py`.
+The dashboard expects the bot API to be running at `http://localhost:8000`. The API is started automatically when running the bot via `uv run zero-ichi`.
| Endpoint | Description |
| ------------------- | ----------------------- |
diff --git a/dashboard/src/app/login/page.tsx b/dashboard/src/app/login/page.tsx
index fdcc113..f9def06 100644
--- a/dashboard/src/app/login/page.tsx
+++ b/dashboard/src/app/login/page.tsx
@@ -34,9 +34,23 @@ export default function LoginPage() {
localStorage.setItem("dashboard_auth", auth);
window.location.href = "/";
} else {
- setError("Invalid username or password");
+ let detail = "";
+ try {
+ const data = (await res.json()) as { detail?: string };
+ detail = data?.detail || "";
+ } catch {
+ detail = "";
+ }
+
+ if (res.status === 503 && detail) {
+ setError(detail);
+ } else if (res.status === 401) {
+ setError("Invalid username or password");
+ } else {
+ setError(detail || `Login failed (HTTP ${res.status})`);
+ }
}
- } catch (err) {
+ } catch {
setError("Failed to connect to API server");
} finally {
setLoading(false);
@@ -122,6 +136,9 @@ export default function LoginPage() {
Credentials can be set in your{" "}
.env file
+
+ Use non-default credentials. admin/admin is blocked.
+
);
diff --git a/dashboard/src/app/webhooks/page.tsx b/dashboard/src/app/webhooks/page.tsx
new file mode 100644
index 0000000..e985c46
--- /dev/null
+++ b/dashboard/src/app/webhooks/page.tsx
@@ -0,0 +1,263 @@
+"use client";
+
+import { api, type WebhookDelivery, type WebhookItem } from "@/lib/api";
+import { useEffect, useMemo, useState } from "react";
+
+export default function WebhooksPage() {
+ const [webhooks, setWebhooks] = useState([]);
+ const [availableEvents, setAvailableEvents] = useState([]);
+ const [selectedEvents, setSelectedEvents] = useState([]);
+ const [name, setName] = useState("Main Webhook");
+ const [url, setUrl] = useState("");
+ const [secret, setSecret] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+ const [deliveries, setDeliveries] = useState>({});
+
+ const selectedLabel = useMemo(() => {
+ if (selectedEvents.length === 0) {
+ return "No events selected";
+ }
+ return selectedEvents.join(", ");
+ }, [selectedEvents]);
+
+ const loadWebhooks = async () => {
+ setLoading(true);
+ setError("");
+ try {
+ const res = await api.getWebhooks();
+ setWebhooks(res.webhooks || []);
+ setAvailableEvents(res.available_events || []);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load webhooks");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ void loadWebhooks();
+ }, []);
+
+ const toggleEvent = (eventName: string) => {
+ setSelectedEvents((prev) =>
+ prev.includes(eventName) ? prev.filter((e) => e !== eventName) : [...prev, eventName],
+ );
+ };
+
+ const createWebhook = async () => {
+ setError("");
+ setSuccess("");
+ if (!url.trim()) {
+ setError("URL is required");
+ return;
+ }
+
+ try {
+ const res = await api.createWebhook({
+ name: name.trim() || "Webhook",
+ url: url.trim(),
+ events: selectedEvents.length ? selectedEvents : ["*"],
+ secret: secret.trim() || undefined,
+ enabled: true,
+ });
+ setSuccess(`Webhook created. Secret: ${res.secret}`);
+ setUrl("");
+ setSecret("");
+ setSelectedEvents([]);
+ await loadWebhooks();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create webhook");
+ }
+ };
+
+ const toggleWebhook = async (hook: WebhookItem) => {
+ try {
+ await api.updateWebhook(hook.id, { enabled: !hook.enabled });
+ await loadWebhooks();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to update webhook");
+ }
+ };
+
+ const removeWebhook = async (hook: WebhookItem) => {
+ if (!confirm(`Delete webhook \"${hook.name}\"?`)) {
+ return;
+ }
+ try {
+ await api.deleteWebhook(hook.id);
+ await loadWebhooks();
+ setDeliveries((prev) => {
+ const next = { ...prev };
+ delete next[hook.id];
+ return next;
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to delete webhook");
+ }
+ };
+
+ const testWebhook = async (hook: WebhookItem) => {
+ try {
+ await api.testWebhook(hook.id);
+ await loadDeliveries(hook.id);
+ setSuccess(`Test sent to ${hook.name}`);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to test webhook");
+ }
+ };
+
+ const loadDeliveries = async (webhookId: number) => {
+ try {
+ const res = await api.getWebhookDeliveries(webhookId, 20);
+ setDeliveries((prev) => ({ ...prev, [webhookId]: res.deliveries || [] }));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load deliveries");
+ }
+ };
+
+ return (
+
+
+
Webhooks
+
+ Send bot events to external services (CI, Discord, Slack, custom apps).
+
+
+
+
+
Create Webhook
+
+
+
+ {availableEvents.map((eventName) => {
+ const active = selectedEvents.includes(eventName);
+ return (
+
+ );
+ })}
+
+
+
+
+ {error ?
{error}
: null}
+ {success ?
{success}
: null}
+
+
+
+
Configured Endpoints
+ {loading ?
Loading...
: null}
+ {!loading && webhooks.length === 0 ? (
+
No webhooks yet.
+ ) : null}
+
+
+ {webhooks.map((hook) => (
+
+
+
+
{hook.name}
+
{hook.url}
+
+ Events: {hook.events.join(", ") || "*"}
+
+
+
+
+
+
+
+
+
+
+ {deliveries[hook.id] ? (
+
+ {deliveries[hook.id].slice(0, 8).map((d) => (
+
+
+ {d.event_type} • attempt {d.attempt}
+
+
+ {d.success
+ ? `OK${d.status_code ? ` (${d.status_code})` : ""}`
+ : d.error || "Failed"}
+
+
+ ))}
+
+ ) : null}
+
+ ))}
+
+
+
+ );
+}
diff --git a/dashboard/src/components/dashboard-layout.tsx b/dashboard/src/components/dashboard-layout.tsx
index 0420ae5..2dec3ef 100644
--- a/dashboard/src/components/dashboard-layout.tsx
+++ b/dashboard/src/components/dashboard-layout.tsx
@@ -30,6 +30,7 @@ const navLinks = [
{ label: "Dashboard", href: "/", icon: },
{ label: "Send Message", href: "/send", icon: },
{ label: "Configuration", href: "/config", icon: },
+ { label: "Webhooks", href: "/webhooks", icon: },
{ label: "Commands", href: "/commands", icon: },
{ label: "Groups", href: "/groups", icon: },
{ label: "Notes", href: "/notes", icon: },
diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts
index b11d2c2..4d2e275 100644
--- a/dashboard/src/lib/api.ts
+++ b/dashboard/src/lib/api.ts
@@ -184,6 +184,30 @@ export interface AutomationRule {
action_value: string;
}
+export interface WebhookItem {
+ id: number;
+ name: string;
+ url: string;
+ events: string[];
+ enabled: boolean;
+ has_secret: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface WebhookDelivery {
+ id: number;
+ webhook_id: number;
+ event_type: string;
+ payload: Record;
+ success: boolean;
+ status_code: number | null;
+ error: string | null;
+ attempt: number;
+ response_body: string | null;
+ created_at: string;
+}
+
function getStoredAuth(): string | null {
if (typeof window === "undefined") {
return null;
@@ -519,6 +543,49 @@ export const api = {
},
),
+ getWebhooks: () =>
+ fetchAPI<{ webhooks: WebhookItem[]; available_events: string[] }>("/api/webhooks"),
+ createWebhook: (payload: {
+ name: string;
+ url: string;
+ events: string[];
+ secret?: string;
+ enabled?: boolean;
+ }) =>
+ fetchAPI<{ success: boolean; webhook: WebhookItem; secret: string }>("/api/webhooks", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ }),
+ updateWebhook: (
+ webhookId: number,
+ payload: {
+ name?: string;
+ url?: string;
+ events?: string[];
+ secret?: string;
+ enabled?: boolean;
+ },
+ ) =>
+ fetchAPI<{ success: boolean; webhook: WebhookItem }>(`/api/webhooks/${webhookId}`, {
+ method: "PUT",
+ body: JSON.stringify(payload),
+ }),
+ deleteWebhook: (webhookId: number) =>
+ fetchAPI<{ success: boolean }>(`/api/webhooks/${webhookId}`, {
+ method: "DELETE",
+ }),
+ testWebhook: (webhookId: number) =>
+ fetchAPI<{ success: boolean; result: { success: boolean; status_code?: number } }>(
+ `/api/webhooks/${webhookId}/test`,
+ {
+ method: "POST",
+ },
+ ),
+ getWebhookDeliveries: (webhookId: number, limit = 50) =>
+ fetchAPI<{ deliveries: WebhookDelivery[]; count: number }>(
+ `/api/webhooks/${webhookId}/deliveries?limit=${limit}`,
+ ),
+
getTopCommands: (days = 7, groupId?: string) => {
const query = new URLSearchParams({ days: days.toString() });
if (groupId) query.set("group_id", groupId);
diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts
index d4d5bc2..7b19e2b 100644
--- a/docs/.vitepress/config.mts
+++ b/docs/.vitepress/config.mts
@@ -25,7 +25,15 @@ export default defineConfig({
nav: [
{ text: 'Guide', link: '/getting-started/installation' },
{ text: 'Commands', link: '/commands/general' },
- { text: 'Features', link: '/features/ai' },
+ {
+ text: 'Features',
+ items: [
+ { text: 'Agentic AI', link: '/features/ai' },
+ { text: 'Internationalization', link: '/features/i18n' },
+ { text: 'Web Dashboard', link: '/features/dashboard' },
+ { text: 'Webhooks', link: '/features/webhooks' },
+ ],
+ },
{
text: 'Development',
items: [
@@ -67,6 +75,7 @@ export default defineConfig({
{ text: 'Agentic AI', link: '/features/ai' },
{ text: 'Internationalization', link: '/features/i18n' },
{ text: 'Web Dashboard', link: '/features/dashboard' },
+ { text: 'Webhooks', link: '/features/webhooks' },
],
},
{
diff --git a/docs/.vitepress/theme/custom.css b/docs/.vitepress/theme/custom.css
index aa8fa4c..93ac1fe 100644
--- a/docs/.vitepress/theme/custom.css
+++ b/docs/.vitepress/theme/custom.css
@@ -851,6 +851,31 @@ html:not(.dark) .vp-doc div[class*='language-'] .line-numbers {
padding: 44px 1rem 1rem;
}
+ /* Keep code blocks inside content width on mobile */
+ .vp-doc div[class*='language-'] {
+ width: auto !important;
+ max-width: 100% !important;
+ margin: 1rem 0 !important;
+ border-radius: 6px;
+ overflow-x: auto;
+ }
+
+ .vp-doc div[class*='language-'] pre {
+ padding-left: 0.9rem !important;
+ padding-right: 0.9rem !important;
+ }
+
+ /* Remove mobile line-number gutter so code starts further left */
+ .vp-doc div[class*='language-'] .line-numbers-wrapper,
+ .vp-doc div[class*='language-'] .line-numbers {
+ display: none !important;
+ }
+
+ .vp-doc div[class*='language-'] pre code {
+ padding-left: 0 !important;
+ margin-left: 0 !important;
+ }
+
.terminal-window::before {
padding: 6px 10px;
font-size: 0.75rem;
@@ -867,16 +892,27 @@ html:not(.dark) .vp-doc div[class*='language-'] .line-numbers {
/* Stats — keep 3-col but smaller fonts */
.stats-section {
+ display: grid !important;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem !important;
padding: 1rem 0 !important;
}
+ .stat-item {
+ min-width: 0;
+ }
+
.stat-number {
- font-size: 2rem !important;
+ font-size: 1.35rem !important;
+ line-height: 1.1;
}
.stat-label {
- font-size: 0.7rem !important;
+ font-size: 0.45rem !important;
+ letter-spacing: 0.06em;
+ line-height: 1.3;
+ white-space: normal;
+ margin-top: 0.4rem;
}
/* Step cards — stack vertically */
@@ -935,11 +971,20 @@ html:not(.dark) .vp-doc div[class*='language-'] .line-numbers {
padding: 40px 0.75rem 0.75rem;
}
+ .vp-doc div[class*='language-'] {
+ margin: 0.85rem 0 !important;
+ }
+
.stat-number {
- font-size: 1.6rem !important;
+ font-size: 1.1rem !important;
+ }
+
+ .stat-label {
+ font-size: 0.4rem !important;
+ letter-spacing: 0.04em;
}
.categories-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
-}
\ No newline at end of file
+}
diff --git a/docs/development/architecture.md b/docs/development/architecture.md
index b80d85f..67a38bc 100644
--- a/docs/development/architecture.md
+++ b/docs/development/architecture.md
@@ -41,6 +41,7 @@ zero-ichi/
│ │ ├── client.py # WhatsApp client wrapper
│ │ ├── command.py # Command base class & loader
│ │ ├── constants.py # Project constants
+│ │ ├── db.py # SQLAlchemy database layer + migration bridge
│ │ ├── downloader.py # Media downloader logic
│ │ ├── errors.py # Error handling utilities
│ │ ├── event_bus.py # Event system
@@ -54,14 +55,15 @@ zero-ichi/
│ │ ├── rate_limiter.py # Rate limiting
│ │ ├── runtime_config.py # Live configuration manager
│ │ ├── scheduler.py # Task scheduler
-│ │ ├── storage.py # Per-group data storage
+│ │ ├── storage.py # Per-group/global runtime storage API (DB-backed)
│ │ ├── symbols.py # Unicode symbols
+│ │ ├── webhooks.py # Webhook dispatcher worker
│ │ └── handlers/ # Event handlers
│ │
│ └── locales/ # Translation files (en, id)
│
├── dashboard/ # Next.js admin dashboard
-├── data/ # Per-group persistent data
+├── data/ # Runtime data (SQLite DB, media files, caches)
└── logs/ # Log files
```
@@ -130,7 +132,12 @@ Commands are auto-discovered from `src/commands/*/` directories.
### Storage
-`core/storage.py` provides per-group persistent storage using JSON files in the `data/` directory:
+Runtime state uses a SQL database through `core/db.py`:
+
+- Default: SQLite at `data/zeroichi.db`
+- Optional: PostgreSQL when `DATABASE_URL` is set
+
+`core/storage.py` keeps a simple API over DB-backed persistence:
```python
from core.storage import GroupData
@@ -140,6 +147,18 @@ storage.save("rules", {"text": "Be kind!"})
rules = storage.load("rules", {"text": ""})
```
+Other runtime modules (`scheduler`, `analytics`, `token_tracker`, `afk`, `i18n` chat language state, AI memory) are also persisted in the database.
+
+### Webhooks
+
+`core/event_bus.py` emits internal events for dashboard live updates and webhook fanout.
+
+`core/webhooks.py` subscribes to emitted events asynchronously and delivers them to configured endpoints with:
+
+- HMAC signature headers
+- retry/backoff on failures
+- delivery logs stored in DB (`webhook_deliveries`)
+
### JID Resolver
WhatsApp uses two ID formats: **PN** (phone number) and **LID** (linked ID). The JID resolver handles conversion between them:
diff --git a/docs/development/contributing.md b/docs/development/contributing.md
index f757621..37e7d81 100644
--- a/docs/development/contributing.md
+++ b/docs/development/contributing.md
@@ -33,7 +33,7 @@ Contributions are welcome. Here's how to get started.
4. **Run the bot** to test your changes:
```bash
- uv run zero-ichi
+ uv run zero-ichi --debug --auto-reload
```
The bot supports auto-reload — file changes are picked up without restarting.
diff --git a/docs/features/dashboard.md b/docs/features/dashboard.md
index bf96738..7af10aa 100644
--- a/docs/features/dashboard.md
+++ b/docs/features/dashboard.md
@@ -18,6 +18,8 @@ Then start the bot:
```bash
uv run zero-ichi
+# or, without editing config.json:
+uv run zero-ichi --dashboard
```
The API runs on `http://localhost:8000`.
@@ -28,8 +30,8 @@ In a separate terminal:
```bash
cd dashboard
-bun install # or npm install
-bun dev # or npm run dev
+bun install
+bun dev
```
Open `http://localhost:3000` in your browser.
@@ -43,8 +45,16 @@ Open `http://localhost:3000` in your browser.
- **Reports Center** — inspect and resolve/dismiss moderation reports
- **Digest Manager** — configure daily/weekly group digests with preview + send-now
- **Automation Rules** — create no-code trigger/action moderation rules
+- **Webhook Manager** — configure outbound event webhooks and inspect delivery logs
- **Statistics** — message counts, command usage, and more
+## Security Notes
+
+- Set `DASHBOARD_USERNAME` and `DASHBOARD_PASSWORD` in `.env`.
+- `admin/admin` is intentionally rejected for security.
+- Dashboard CORS origins are controlled by `dashboard.cors_origins` (or `DASHBOARD_CORS_ORIGINS`).
+- WebSocket live updates require short-lived auth tokens.
+
## API
The dashboard communicates with the bot through a REST API:
@@ -53,9 +63,15 @@ The dashboard communicates with the bot through a REST API:
|----------|-------------|
| `GET /api/status` | Bot status and uptime |
| `GET /api/config` | Current configuration |
-| `POST /api/config` | Update configuration |
+| `PUT /api/config` | Update configuration |
| `GET /api/commands` | List all commands |
| `GET /api/groups` | List joined groups |
+| `GET /api/webhooks` | List webhooks + supported event names |
+| `POST /api/webhooks` | Create webhook endpoint |
+| `PUT /api/webhooks/{id}` | Update webhook endpoint |
+| `DELETE /api/webhooks/{id}` | Delete webhook endpoint |
+| `POST /api/webhooks/{id}/test` | Send test event to endpoint |
+| `GET /api/webhooks/{id}/deliveries` | Recent delivery attempts |
| `GET /api/groups/{group_id}/reports` | List report queue for a group |
| `PUT /api/groups/{group_id}/reports/{report_id}` | Update report status |
| `GET /api/groups/{group_id}/digest` | Get digest config + preview |
diff --git a/docs/features/webhooks.md b/docs/features/webhooks.md
new file mode 100644
index 0000000..0adb1e3
--- /dev/null
+++ b/docs/features/webhooks.md
@@ -0,0 +1,72 @@
+# Webhooks
+
+Zero Ichi can push bot and dashboard events to external services via HTTP webhooks.
+
+## Where to Configure
+
+Use the dashboard page:
+
+- `Dashboard -> Webhooks`
+
+Webhooks are stored in the runtime database (`SQLite` by default, or PostgreSQL when `DATABASE_URL` is set).
+
+## Supported Events
+
+Current event names include:
+
+- `new_message`
+- `command_executed`
+- `auto_download`
+- `command_update`
+- `config_update`
+- `group_update`
+- `report_update`
+- `digest_update`
+- `automation_update`
+- `automation_triggered`
+
+You can subscribe to specific events or use `*` to receive all.
+
+## Payload Format
+
+Each delivery sends JSON:
+
+```json
+{
+ "event": "command_executed",
+ "timestamp": "2026-03-14T20:40:00+00:00",
+ "data": {
+ "command": "help",
+ "user": "Alice",
+ "chat": "123456@g.us"
+ }
+}
+```
+
+## Security Headers
+
+Every request includes:
+
+- `X-ZeroIchi-Event`
+- `X-ZeroIchi-Timestamp`
+- `X-ZeroIchi-Signature`
+
+Signature format:
+
+```text
+sha256=
+```
+
+HMAC input string:
+
+```text
+.
+```
+
+Use your webhook secret to verify authenticity.
+
+## Delivery Behavior
+
+- Async queue worker (non-blocking for message pipeline)
+- Retry with exponential backoff
+- Delivery attempts are logged and visible in dashboard
diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md
index 67b02df..c86215d 100644
--- a/docs/getting-started/configuration.md
+++ b/docs/getting-started/configuration.md
@@ -2,7 +2,8 @@
The bot is configured through `config.json` with [JSON Schema](https://json-schema.org/) validation — your editor will provide autocomplete and inline docs automatically.
-Runtime changes from WhatsApp commands or Dashboard are persisted back into `config.json` (single source of truth).
+Configuration changes from WhatsApp commands or Dashboard are persisted back into `config.json`.
+Runtime state (stats, notes, tasks, reports, AI memory, etc.) is persisted in the database (`SQLite` by default, `PostgreSQL` optional via `DATABASE_URL`).
## Quick Start
@@ -360,18 +361,20 @@ Configure the web dashboard API.
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `enabled` | `boolean` | `false` | Enable the dashboard API server on startup |
+| `cors_origins` | `string[]` | `["http://localhost:3000", "http://127.0.0.1:3000"]` | Allowed origins for dashboard API CORS |
```json
{
"dashboard": {
- "enabled": false
+ "enabled": false,
+ "cors_origins": ["http://localhost:3000", "http://127.0.0.1:3000"]
}
}
```
::: note
The dashboard starts on port `8000` by default if enabled.
-This dashboard api is required to enable if you want to use the dashboard.
+Set `DASHBOARD_USERNAME` and `DASHBOARD_PASSWORD` in `.env` when enabling the dashboard.
:::
---
@@ -382,6 +385,9 @@ Store sensitive values in `.env` (never commit this file):
```bash
AI_API_KEY=your_api_key_here
+DATABASE_URL=
+DASHBOARD_USERNAME=change_me
+DASHBOARD_PASSWORD=change_me_too
YOUTUBE_COOKIES_PATH=data/cookies.txt
GALLERY_DL_CONFIG_FILE=data/gallery-dl.conf
GALLERY_DL_COOKIES_FILE=data/gallery-cookies.txt
@@ -400,6 +406,20 @@ If you are running the bot on a VPS, YouTube may block your requests with "Sign
Use `downloader.gallery_dl` in `config.json`, or override with env vars above.
+### Database Backend
+
+- Leave `DATABASE_URL` empty to use SQLite (`data/zeroichi.db`).
+- Set `DATABASE_URL` to use PostgreSQL, for example:
+
+```bash
+DATABASE_URL=postgresql://user:password@localhost:5432/zeroichi
+```
+
+### Webhooks
+
+Webhook endpoints are configured from the dashboard (`/webhooks`) and stored in the database.
+See [Webhooks](/features/webhooks) for payload format and security headers.
+
Examples:
- `downloader.gallery_dl.cookies_file`: pass Netscape cookies file via `--cookies`
diff --git a/docs/getting-started/first-run.md b/docs/getting-started/first-run.md
index 6b1c80c..111cc0f 100644
--- a/docs/getting-started/first-run.md
+++ b/docs/getting-started/first-run.md
@@ -6,6 +6,31 @@
uv run zero-ichi
```
+## CLI Arguments
+
+You can run the bot with flags:
+
+```bash
+uv run zero-ichi --debug --auto-reload
+```
+
+| Argument | Description | Example |
+|----------|-------------|---------|
+| `--debug` | Enable debug logging | `uv run zero-ichi --debug` |
+| `--qr` | Force QR login mode | `uv run zero-ichi --qr` |
+| `--phone NUMBER` | Force pair-code login with phone number | `uv run zero-ichi --phone 6281234567890` |
+| `--session NAME` | Override session name | `uv run zero-ichi --session mybot` |
+| `--auto-reload` | Enable auto-reload for development | `uv run zero-ichi --auto-reload` |
+| `--dashboard` | Enable dashboard API at startup | `uv run zero-ichi --dashboard` |
+
+### Update Command
+
+Use built-in update command:
+
+```bash
+uv run zero-ichi update
+```
+
## QR Code Login
On first launch, the bot will display a QR code in the terminal:
diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md
index 705caca..839ba0e 100644
--- a/docs/getting-started/installation.md
+++ b/docs/getting-started/installation.md
@@ -28,8 +28,7 @@ INSTALL_DIR=/opt/zero-ichi curl -fsSL https://raw.githubusercontent.com/MhankBar
- **Python 3.11+**
- **[uv](https://github.com/astral-sh/uv)** — fast Python package manager
- **FFmpeg** — required for audio/video processing
-- **[Bun](https://bun.sh)** — required for YouTube JS challenge solving (yt-dlp)
-- **Node.js 20+** — for the web dashboard (optional)
+- **[Bun](https://bun.sh)** — required for YouTube JS challenge solving (yt-dlp) and dashboard package scripts
## Manual Install
@@ -57,13 +56,27 @@ Edit `.env` with your values:
```bash
AI_API_KEY=your_api_key_here
+DATABASE_URL=
+DASHBOARD_USERNAME=change_me
+DASHBOARD_PASSWORD=change_me_too
```
::: tip
The AI API key is only required if you plan to use the [Agentic AI](/features/ai) feature.
+
+If `DATABASE_URL` is empty, Zero Ichi uses SQLite at `data/zeroichi.db`.
+Set a PostgreSQL URL to run on Postgres.
:::
## Next Steps
- [Configure the bot →](/getting-started/configuration)
- [Run the bot for the first time →](/getting-started/first-run)
+
+Quick run examples:
+
+```bash
+uv run zero-ichi --debug
+uv run zero-ichi --dashboard
+uv run zero-ichi --phone 6281234567890
+```
diff --git a/docs/index.md b/docs/index.md
index 4ca23d6..505aa3e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -99,6 +99,8 @@ irm https://raw.githubusercontent.com/MhankBarBar/zero-ichi/master/install.ps1 |
```bash
uv run zero-ichi
+# with args:
+uv run zero-ichi --debug --dashboard
```
See full installation guide →
diff --git a/pyproject.toml b/pyproject.toml
index 4797c2f..683c63b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,6 +19,9 @@ dependencies = [
"pydantic-ai>=1.48.0",
"python-dotenv>=1.2.1",
"jsonschema>=4.23.0",
+ "sqlalchemy>=2.0.43",
+ "httpx>=0.28.1",
+ "psycopg[binary]>=3.2.9",
"yt-dlp>=2026.03.03",
"gallery-dl>=1.31.7",
"Pillow>=10.0.0",
diff --git a/src/ai/memory.py b/src/ai/memory.py
index 7878deb..db43fce 100644
--- a/src/ai/memory.py
+++ b/src/ai/memory.py
@@ -1,17 +1,16 @@
-"""
-AI Memory module - Persistent conversation memory for AI agent.
+"""AI Memory module - Persistent conversation memory for AI agent.
Stores conversation history per chat with rich message context.
Supports TTL-based eviction to keep memory fresh and bounded.
"""
-import json
+from __future__ import annotations
+
from dataclasses import asdict, dataclass, field
from datetime import datetime
-from pathlib import Path
from typing import Literal
-from core.constants import MEMORY_DIR
+from core.db import kv_delete, kv_get_json, kv_set_json
from core.logger import log_debug, log_error
MAX_MESSAGES = 100
@@ -51,29 +50,31 @@ def __init__(self, chat_id: str, ttl_hours: float = DEFAULT_TTL_HOURS):
self._load()
@property
- def _file_path(self) -> Path:
- MEMORY_DIR.mkdir(parents=True, exist_ok=True)
- return MEMORY_DIR / f"{self._safe_id}.json"
+ def _scope(self) -> str:
+ return "ai_memory"
def _load(self) -> None:
- """Load memory from disk and evict expired entries."""
+ """Load memory from database and evict expired entries."""
try:
- if self._file_path.exists():
- data = json.loads(self._file_path.read_text(encoding="utf-8"))
- self._entries = [MemoryEntry(**entry) for entry in data]
- evicted = self._evict_expired()
- if evicted:
- log_debug(f"Evicted {evicted} expired entries for {self.chat_id}")
- log_debug(f"Loaded {len(self._entries)} memory entries for {self.chat_id}")
+ data = kv_get_json(self._scope, self._safe_id, default=[])
+ if isinstance(data, list):
+ self._entries = [MemoryEntry(**entry) for entry in data if isinstance(entry, dict)]
+ else:
+ self._entries = []
+
+ evicted = self._evict_expired()
+ if evicted:
+ log_debug(f"Evicted {evicted} expired entries for {self.chat_id}")
+ log_debug(f"Loaded {len(self._entries)} memory entries for {self.chat_id}")
except Exception as e:
log_error(f"Failed to load memory for {self.chat_id}: {e}")
self._entries = []
def _save(self) -> None:
- """Save memory to disk."""
+ """Save memory to database."""
try:
data = [asdict(entry) for entry in self._entries]
- self._file_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
+ kv_set_json(self._scope, self._safe_id, data)
except Exception as e:
log_error(f"Failed to save memory for {self.chat_id}: {e}")
@@ -145,12 +146,7 @@ def get_context_string(self, limit: int = MAX_MESSAGES) -> str:
return "\n".join(lines)
def to_message_history(self, limit: int = MAX_MESSAGES) -> list[dict]:
- """
- Convert memory to Pydantic AI-compatible message_history format.
-
- Returns a list of dicts with 'role' and 'content' that can be used
- directly with pydantic-ai's message_history parameter.
- """
+ """Convert memory to Pydantic AI-compatible message_history format."""
history = self.get_history(limit)
messages = []
for entry in history:
@@ -166,8 +162,7 @@ def to_message_history(self, limit: int = MAX_MESSAGES) -> list[dict]:
def clear(self) -> None:
"""Clear all memory for this chat."""
self._entries = []
- if self._file_path.exists():
- self._file_path.unlink()
+ kv_delete(self._scope, self._safe_id)
_memory_cache: dict[str, AIMemory] = {}
diff --git a/src/ai/token_tracker.py b/src/ai/token_tracker.py
index 3bb3d33..0c5faa3 100644
--- a/src/ai/token_tracker.py
+++ b/src/ai/token_tracker.py
@@ -1,57 +1,35 @@
-"""
-AI Token tracker.
+"""AI Token tracker.
Tracks token usage per user and per chat with configurable daily limits.
"""
-import json
-import os
-import tempfile
+from __future__ import annotations
+
import time
-from atexit import register as on_exit
from datetime import datetime
-from core.constants import DATA_DIR
+from core.db import kv_get_json, kv_set_json
from core.logger import log_debug
from core.runtime_config import runtime_config
-TOKEN_FILE = DATA_DIR / "ai_tokens.json"
SAVE_INTERVAL_SECONDS = 2.0
-def _atomic_write_json(file_path, data: dict) -> None:
- """Atomically write JSON payload to disk."""
- file_path.parent.mkdir(parents=True, exist_ok=True)
- fd, tmp_path = tempfile.mkstemp(dir=file_path.parent, suffix=".tmp")
- try:
- with os.fdopen(fd, "w", encoding="utf-8") as f:
- json.dump(data, f, indent=2)
- os.replace(tmp_path, file_path)
- except BaseException:
- try:
- os.unlink(tmp_path)
- except OSError:
- pass
- raise
-
-
class TokenTracker:
"""Track AI token usage per user and per chat with daily limits."""
def __init__(self):
+ self._scope = "ai_tokens"
+ self._key = "daily"
self._data: dict = {}
self._dirty = False
self._last_save_ts = 0.0
self._load()
- on_exit(self.flush)
def _load(self) -> None:
- """Load token data from disk."""
- try:
- if TOKEN_FILE.exists():
- self._data = json.loads(TOKEN_FILE.read_text(encoding="utf-8"))
- except Exception:
- self._data = {}
+ """Load token data from database."""
+ data = kv_get_json(self._scope, self._key, default={})
+ self._data = data if isinstance(data, dict) else {}
today = datetime.now().strftime("%Y-%m-%d")
if self._data.get("date") != today:
@@ -59,13 +37,13 @@ def _load(self) -> None:
self._schedule_save(force=True)
def _save(self) -> None:
- """Save token data to disk."""
- _atomic_write_json(TOKEN_FILE, self._data)
+ """Persist token data to database."""
+ kv_set_json(self._scope, self._key, self._data)
self._dirty = False
self._last_save_ts = time.time()
def _schedule_save(self, force: bool = False) -> None:
- """Persist token usage on interval to reduce disk writes."""
+ """Persist token usage on interval to reduce write volume."""
self._dirty = True
now = time.time()
if force or now - self._last_save_ts >= SAVE_INTERVAL_SECONDS:
@@ -100,11 +78,11 @@ def can_use(self, user_id: str, chat_id: str, estimated_tokens: int = 1000) -> b
user_used = self._data.get("users", {}).get(user_id, 0)
chat_used = self._data.get("chats", {}).get(chat_id, 0)
- if user_used + estimated_tokens > self._user_limit:
+ if self._user_limit > 0 and user_used + estimated_tokens > self._user_limit:
log_debug(f"Token limit: user {user_id} at {user_used}/{self._user_limit}")
return False
- if chat_used + estimated_tokens > self._chat_limit:
+ if self._chat_limit > 0 and chat_used + estimated_tokens > self._chat_limit:
log_debug(f"Token limit: chat {chat_id} at {chat_used}/{self._chat_limit}")
return False
@@ -133,10 +111,12 @@ def get_usage(self, user_id: str) -> dict:
"""Get usage info for a user."""
self._ensure_today()
used = self._data.get("users", {}).get(user_id, 0)
+ limit = self._user_limit
+ remaining = max(0, limit - used) if limit > 0 else 0
return {
"used": used,
- "limit": self._user_limit,
- "remaining": max(0, self._user_limit - used),
+ "limit": limit,
+ "remaining": remaining,
}
diff --git a/src/core/analytics.py b/src/core/analytics.py
index 34ad5a5..831477c 100644
--- a/src/core/analytics.py
+++ b/src/core/analytics.py
@@ -1,70 +1,46 @@
-"""
-Command usage analytics.
+"""Command usage analytics.
Tracks per-command usage with timestamps for dashboard charts.
"""
-import json
-import os
-import tempfile
+from __future__ import annotations
+
import time
-from atexit import register as on_exit
from datetime import datetime, timedelta
-from core.constants import DATA_DIR
+from core.db import kv_get_json, kv_set_json
from core.logger import log_debug
-ANALYTICS_FILE = DATA_DIR / "analytics.json"
-
DEFAULT_RETENTION_DAYS = 30
SAVE_INTERVAL_SECONDS = 2.0
-def _atomic_write_json(file_path, data: dict) -> None:
- """Atomically write JSON payload to disk."""
- file_path.parent.mkdir(parents=True, exist_ok=True)
- fd, tmp_path = tempfile.mkstemp(dir=file_path.parent, suffix=".tmp")
- try:
- with os.fdopen(fd, "w", encoding="utf-8") as f:
- json.dump(data, f, indent=2)
- os.replace(tmp_path, file_path)
- except BaseException:
- try:
- os.unlink(tmp_path)
- except OSError:
- pass
- raise
-
-
class CommandAnalytics:
"""Track and query command usage analytics."""
def __init__(self):
+ self._scope = "analytics"
+ self._key = "payload"
self._data: dict = {}
self._dirty = False
self._last_save_ts = 0.0
self._load()
- on_exit(self.flush)
def _load(self) -> None:
- """Load analytics data from disk."""
- try:
- if ANALYTICS_FILE.exists():
- self._data = json.loads(ANALYTICS_FILE.read_text(encoding="utf-8"))
- except Exception:
- self._data = {}
-
+ """Load analytics data from database."""
+ data = kv_get_json(self._scope, self._key, default={})
+ self._data = data if isinstance(data, dict) else {}
if "commands" not in self._data:
self._data["commands"] = {}
def _save(self) -> None:
- """Save analytics data to disk."""
- _atomic_write_json(ANALYTICS_FILE, self._data)
+ """Persist analytics data to database."""
+ kv_set_json(self._scope, self._key, self._data)
self._dirty = False
self._last_save_ts = time.time()
def _schedule_save(self, force: bool = False) -> None:
- """Persist analytics on interval to reduce disk writes."""
+ """Persist analytics on interval to reduce write volume."""
self._dirty = True
now = time.time()
if force or now - self._last_save_ts >= SAVE_INTERVAL_SECONDS:
diff --git a/src/core/db.py b/src/core/db.py
new file mode 100644
index 0000000..2dac6bd
--- /dev/null
+++ b/src/core/db.py
@@ -0,0 +1,628 @@
+"""Shared database layer for runtime persistence.
+
+Provides:
+- SQLite default storage (`data/zeroichi.db`)
+- Optional PostgreSQL via `DATABASE_URL`
+- Generic key/value JSON store APIs
+- Webhook and webhook delivery persistence
+- One-time migration from legacy JSON files
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import threading
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+from sqlalchemy import create_engine, text
+from sqlalchemy.engine import Engine
+
+from core.constants import DATA_DIR, LOCALES_DIR, MEMORY_DIR, TASKS_FILE
+
+_DEFAULT_DB_PATH = DATA_DIR / "zeroichi.db"
+_MIGRATION_FLAG_KEY = "legacy_json_migration_v1_done"
+
+_engine: Engine | None = None
+_init_lock = threading.Lock()
+_ready = False
+
+
+def _utcnow_iso() -> str:
+ return datetime.now(UTC).isoformat()
+
+
+def _normalize_database_url(url: str) -> str:
+ """Normalize env database URL for SQLAlchemy dialect handling."""
+ normalized = url.strip()
+ if normalized.startswith("postgres://"):
+ return "postgresql+psycopg://" + normalized[len("postgres://") :]
+ if normalized.startswith("postgresql://") and "+" not in normalized.split("://", 1)[0]:
+ return "postgresql+psycopg://" + normalized[len("postgresql://") :]
+ return normalized
+
+
+def get_database_url() -> str:
+ """Resolve database URL from environment with SQLite fallback."""
+ env_url = os.getenv("DATABASE_URL", "").strip()
+ if env_url:
+ return _normalize_database_url(env_url)
+
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
+ return f"sqlite:///{_DEFAULT_DB_PATH.as_posix()}"
+
+
+def get_engine() -> Engine:
+ """Get shared SQLAlchemy engine."""
+ global _engine
+ if _engine is not None:
+ return _engine
+
+ database_url = get_database_url()
+ kwargs: dict[str, Any] = {"future": True, "pool_pre_ping": True}
+ if database_url.startswith("sqlite"):
+ kwargs["connect_args"] = {"check_same_thread": False}
+
+ _engine = create_engine(database_url, **kwargs)
+ return _engine
+
+
+def _ensure_tables(engine: Engine) -> None:
+ """Create required runtime tables if they do not exist."""
+ dialect = engine.dialect.name
+ id_column = (
+ "BIGSERIAL PRIMARY KEY" if dialect == "postgresql" else "INTEGER PRIMARY KEY AUTOINCREMENT"
+ )
+ webhook_fk_type = "BIGINT" if dialect == "postgresql" else "INTEGER"
+
+ with engine.begin() as conn:
+ conn.execute(
+ text(
+ """
+ CREATE TABLE IF NOT EXISTS kv_store (
+ scope TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY (scope, key)
+ )
+ """
+ )
+ )
+
+ conn.execute(
+ text(
+ f"""
+ CREATE TABLE IF NOT EXISTS webhooks (
+ id {id_column},
+ name TEXT NOT NULL,
+ url TEXT NOT NULL,
+ events TEXT NOT NULL,
+ secret TEXT NOT NULL,
+ enabled INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ )
+ """
+ )
+ )
+
+ conn.execute(
+ text(
+ f"""
+ CREATE TABLE IF NOT EXISTS webhook_deliveries (
+ id {id_column},
+ webhook_id {webhook_fk_type} NOT NULL,
+ event_type TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ success INTEGER NOT NULL,
+ status_code INTEGER,
+ error TEXT,
+ attempt INTEGER NOT NULL,
+ response_body TEXT,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE
+ )
+ """
+ )
+ )
+
+ conn.execute(
+ text(
+ "CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook ON webhook_deliveries(webhook_id, id DESC)"
+ )
+ )
+
+
+def _safe_jid(jid: str) -> str:
+ return jid.replace(":", "_").replace("@", "_")
+
+
+def _guess_jid_from_safe(safe_jid: str) -> str | None:
+ """Best-effort reverse mapping from legacy safe folder name to jid."""
+ if "_" not in safe_jid:
+ return None
+ left, right = safe_jid.rsplit("_", 1)
+ if not left or not right:
+ return None
+ return f"{left}@{right}"
+
+
+def _read_json_file(file_path: Path, default: Any) -> Any:
+ if not file_path.exists():
+ return default
+ try:
+ with open(file_path, encoding="utf-8") as f:
+ return json.load(f)
+ except Exception:
+ return default
+
+
+def _kv_upsert(conn, scope: str, key: str, value: Any) -> None:
+ payload = json.dumps(value, ensure_ascii=False)
+ conn.execute(
+ text(
+ """
+ INSERT INTO kv_store(scope, key, value, updated_at)
+ VALUES (:scope, :key, :value, :updated_at)
+ ON CONFLICT(scope, key)
+ DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
+ """
+ ),
+ {
+ "scope": scope,
+ "key": key,
+ "value": payload,
+ "updated_at": _utcnow_iso(),
+ },
+ )
+
+
+def _kv_get(conn, scope: str, key: str) -> Any | None:
+ row = conn.execute(
+ text("SELECT value FROM kv_store WHERE scope = :scope AND key = :key"),
+ {"scope": scope, "key": key},
+ ).fetchone()
+ if not row:
+ return None
+ try:
+ return json.loads(str(row[0]))
+ except Exception:
+ return None
+
+
+def _migrate_legacy_json(engine: Engine) -> None:
+ """One-time migration from legacy JSON files into database storage."""
+ with engine.begin() as conn:
+ migrated = _kv_get(conn, "meta", _MIGRATION_FLAG_KEY)
+ if migrated:
+ return
+
+ stats = _read_json_file(DATA_DIR / "stats.json", {})
+ groups = _read_json_file(DATA_DIR / "groups.json", {})
+ scheduler_state = _read_json_file(TASKS_FILE, {"tasks": [], "counter": 0})
+ analytics = _read_json_file(DATA_DIR / "analytics.json", {})
+ ai_tokens = _read_json_file(DATA_DIR / "ai_tokens.json", {})
+ afk_state = _read_json_file(DATA_DIR / "afk.json", {})
+
+ chat_languages = _read_json_file(DATA_DIR / "chat_languages.json", {})
+ if not chat_languages:
+ chat_languages = _read_json_file(
+ LOCALES_DIR.parent / "data" / "chat_languages.json", {}
+ )
+
+ if isinstance(stats, dict) and stats:
+ _kv_upsert(conn, "global", "stats", stats)
+ if isinstance(groups, dict) and groups:
+ _kv_upsert(conn, "global", "groups", groups)
+ if isinstance(scheduler_state, dict) and scheduler_state:
+ _kv_upsert(conn, "scheduler", "state", scheduler_state)
+ if isinstance(analytics, dict) and analytics:
+ _kv_upsert(conn, "analytics", "payload", analytics)
+ if isinstance(ai_tokens, dict) and ai_tokens:
+ _kv_upsert(conn, "ai_tokens", "daily", ai_tokens)
+ if isinstance(afk_state, dict) and afk_state:
+ _kv_upsert(conn, "afk", "state", afk_state)
+ if isinstance(chat_languages, dict) and chat_languages:
+ _kv_upsert(conn, "i18n", "chat_languages", chat_languages)
+
+ group_map: dict[str, str] = {}
+ if isinstance(groups, dict):
+ for group_jid in groups.keys():
+ if isinstance(group_jid, str) and group_jid:
+ group_map[_safe_jid(group_jid)] = group_jid
+
+ group_keys = [
+ "settings",
+ "notes",
+ "filters",
+ "blacklist",
+ "warnings",
+ "welcome",
+ "goodbye",
+ "anti_link",
+ "warnings_config",
+ "reports",
+ "digest",
+ "automations",
+ "muted",
+ "mute",
+ ]
+
+ if DATA_DIR.exists():
+ for entry in DATA_DIR.iterdir():
+ if not entry.is_dir():
+ continue
+ group_jid = group_map.get(entry.name)
+ if not group_jid:
+ group_jid = _guess_jid_from_safe(entry.name)
+ if not group_jid:
+ continue
+
+ scope = f"group:{group_jid}"
+ for key in group_keys:
+ payload = _read_json_file(entry / f"{key}.json", None)
+ if payload is not None:
+ _kv_upsert(conn, scope, key, payload)
+
+ if MEMORY_DIR.exists():
+ for file_path in MEMORY_DIR.glob("*.json"):
+ payload = _read_json_file(file_path, None)
+ if payload is None:
+ continue
+ _kv_upsert(conn, "ai_memory", file_path.stem, payload)
+
+ _kv_upsert(conn, "meta", _MIGRATION_FLAG_KEY, True)
+ _kv_upsert(conn, "meta", "legacy_json_migration_v1_at", _utcnow_iso())
+
+
+def ensure_database_ready() -> None:
+ """Initialize database tables and run one-time migration."""
+ global _ready
+ if _ready:
+ return
+
+ with _init_lock:
+ if _ready:
+ return
+
+ engine = get_engine()
+ _ensure_tables(engine)
+ _migrate_legacy_json(engine)
+ _ready = True
+
+
+def kv_get_json(scope: str, key: str, default: Any = None) -> Any:
+ """Read JSON value from key-value storage."""
+ ensure_database_ready()
+ with get_engine().begin() as conn:
+ value = _kv_get(conn, scope, key)
+ if value is None:
+ return default
+ return value
+
+
+def kv_set_json(scope: str, key: str, value: Any) -> None:
+ """Write JSON value to key-value storage."""
+ ensure_database_ready()
+ with get_engine().begin() as conn:
+ _kv_upsert(conn, scope, key, value)
+
+
+def kv_delete(scope: str, key: str) -> None:
+ """Delete one key from key-value storage."""
+ ensure_database_ready()
+ with get_engine().begin() as conn:
+ conn.execute(
+ text("DELETE FROM kv_store WHERE scope = :scope AND key = :key"),
+ {"scope": scope, "key": key},
+ )
+
+
+def kv_list_scopes(prefix: str = "") -> list[str]:
+ """List scopes from key-value store, optionally filtered by prefix."""
+ ensure_database_ready()
+ with get_engine().begin() as conn:
+ if prefix:
+ rows = conn.execute(
+ text(
+ "SELECT DISTINCT scope FROM kv_store WHERE scope LIKE :prefix ORDER BY scope ASC"
+ ),
+ {"prefix": f"{prefix}%"},
+ ).fetchall()
+ else:
+ rows = conn.execute(
+ text("SELECT DISTINCT scope FROM kv_store ORDER BY scope ASC")
+ ).fetchall()
+ return [str(row[0]) for row in rows]
+
+
+def kv_get_scope_keys(scope: str) -> list[str]:
+ """List keys for a scope."""
+ ensure_database_ready()
+ with get_engine().begin() as conn:
+ rows = conn.execute(
+ text("SELECT key FROM kv_store WHERE scope = :scope ORDER BY key ASC"),
+ {"scope": scope},
+ ).fetchall()
+ return [str(row[0]) for row in rows]
+
+
+def _normalize_webhook_events(events: list[str]) -> list[str]:
+ cleaned = [str(event).strip() for event in events if str(event).strip()]
+ deduped: list[str] = []
+ for event in cleaned:
+ if event not in deduped:
+ deduped.append(event)
+ return deduped
+
+
+def list_webhooks(include_disabled: bool = True) -> list[dict[str, Any]]:
+ """List configured webhooks."""
+ ensure_database_ready()
+ query = (
+ "SELECT id, name, url, events, secret, enabled, created_at, updated_at FROM webhooks"
+ if include_disabled
+ else "SELECT id, name, url, events, secret, enabled, created_at, updated_at FROM webhooks WHERE enabled = 1"
+ )
+ query += " ORDER BY id DESC"
+
+ with get_engine().begin() as conn:
+ rows = conn.execute(text(query)).fetchall()
+
+ hooks: list[dict[str, Any]] = []
+ for row in rows:
+ try:
+ events = json.loads(str(row[3]))
+ except Exception:
+ events = []
+
+ hooks.append(
+ {
+ "id": int(row[0]),
+ "name": str(row[1]),
+ "url": str(row[2]),
+ "events": events if isinstance(events, list) else [],
+ "secret": str(row[4]),
+ "enabled": bool(row[5]),
+ "created_at": str(row[6]),
+ "updated_at": str(row[7]),
+ }
+ )
+ return hooks
+
+
+def get_webhook(webhook_id: int) -> dict[str, Any] | None:
+ """Get one webhook by id."""
+ for hook in list_webhooks(include_disabled=True):
+ if int(hook["id"]) == int(webhook_id):
+ return hook
+ return None
+
+
+def create_webhook(
+ *,
+ name: str,
+ url: str,
+ events: list[str],
+ secret: str,
+ enabled: bool,
+) -> dict[str, Any]:
+ """Create a webhook and return persisted object."""
+ ensure_database_ready()
+ now = _utcnow_iso()
+ normalized_events = _normalize_webhook_events(events)
+
+ with get_engine().begin() as conn:
+ params = {
+ "name": name.strip() or "Webhook",
+ "url": url.strip(),
+ "events": json.dumps(normalized_events, ensure_ascii=False),
+ "secret": secret,
+ "enabled": 1 if enabled else 0,
+ "created_at": now,
+ "updated_at": now,
+ }
+
+ if get_engine().dialect.name == "postgresql":
+ result = conn.execute(
+ text(
+ """
+ INSERT INTO webhooks(name, url, events, secret, enabled, created_at, updated_at)
+ VALUES (:name, :url, :events, :secret, :enabled, :created_at, :updated_at)
+ RETURNING id
+ """
+ ),
+ params,
+ )
+ webhook_id = int(result.scalar_one())
+ else:
+ result = conn.execute(
+ text(
+ """
+ INSERT INTO webhooks(name, url, events, secret, enabled, created_at, updated_at)
+ VALUES (:name, :url, :events, :secret, :enabled, :created_at, :updated_at)
+ """
+ ),
+ params,
+ )
+ webhook_id = int(result.lastrowid)
+
+ hook = get_webhook(webhook_id)
+ if hook is None:
+ raise RuntimeError("Failed to create webhook")
+ return hook
+
+
+def update_webhook(
+ webhook_id: int,
+ *,
+ name: str | None = None,
+ url: str | None = None,
+ events: list[str] | None = None,
+ secret: str | None = None,
+ enabled: bool | None = None,
+) -> dict[str, Any] | None:
+ """Update webhook fields and return updated object."""
+ existing = get_webhook(webhook_id)
+ if not existing:
+ return None
+
+ updates: dict[str, Any] = {
+ "name": existing["name"],
+ "url": existing["url"],
+ "events": existing["events"],
+ "secret": existing["secret"],
+ "enabled": existing["enabled"],
+ }
+
+ if name is not None:
+ updates["name"] = name.strip() or "Webhook"
+ if url is not None:
+ updates["url"] = url.strip()
+ if events is not None:
+ updates["events"] = _normalize_webhook_events(events)
+ if secret is not None:
+ updates["secret"] = secret
+ if enabled is not None:
+ updates["enabled"] = bool(enabled)
+
+ with get_engine().begin() as conn:
+ conn.execute(
+ text(
+ """
+ UPDATE webhooks
+ SET name = :name,
+ url = :url,
+ events = :events,
+ secret = :secret,
+ enabled = :enabled,
+ updated_at = :updated_at
+ WHERE id = :id
+ """
+ ),
+ {
+ "id": int(webhook_id),
+ "name": updates["name"],
+ "url": updates["url"],
+ "events": json.dumps(updates["events"], ensure_ascii=False),
+ "secret": updates["secret"],
+ "enabled": 1 if updates["enabled"] else 0,
+ "updated_at": _utcnow_iso(),
+ },
+ )
+
+ return get_webhook(webhook_id)
+
+
+def delete_webhook(webhook_id: int) -> bool:
+ """Delete webhook and associated deliveries."""
+ ensure_database_ready()
+ with get_engine().begin() as conn:
+ conn.execute(
+ text("DELETE FROM webhook_deliveries WHERE webhook_id = :id"),
+ {"id": int(webhook_id)},
+ )
+ result = conn.execute(
+ text("DELETE FROM webhooks WHERE id = :id"),
+ {"id": int(webhook_id)},
+ )
+ return result.rowcount > 0
+
+
+def get_active_webhooks_for_event(event_type: str) -> list[dict[str, Any]]:
+ """Return enabled webhooks that subscribe to the given event."""
+ active = list_webhooks(include_disabled=False)
+ matched: list[dict[str, Any]] = []
+ for hook in active:
+ events = hook.get("events", [])
+ if not isinstance(events, list):
+ continue
+ if "*" in events or event_type in events:
+ matched.append(hook)
+ return matched
+
+
+def record_webhook_delivery(
+ *,
+ webhook_id: int,
+ event_type: str,
+ payload: dict[str, Any],
+ success: bool,
+ attempt: int,
+ status_code: int | None = None,
+ error: str | None = None,
+ response_body: str | None = None,
+) -> None:
+ """Persist webhook delivery attempt."""
+ ensure_database_ready()
+ with get_engine().begin() as conn:
+ conn.execute(
+ text(
+ """
+ INSERT INTO webhook_deliveries(
+ webhook_id, event_type, payload, success, status_code,
+ error, attempt, response_body, created_at
+ )
+ VALUES (
+ :webhook_id, :event_type, :payload, :success, :status_code,
+ :error, :attempt, :response_body, :created_at
+ )
+ """
+ ),
+ {
+ "webhook_id": int(webhook_id),
+ "event_type": event_type,
+ "payload": json.dumps(payload, ensure_ascii=False),
+ "success": 1 if success else 0,
+ "status_code": status_code,
+ "error": (error or "")[:1000] or None,
+ "attempt": int(attempt),
+ "response_body": (response_body or "")[:2000] or None,
+ "created_at": _utcnow_iso(),
+ },
+ )
+
+
+def list_webhook_deliveries(webhook_id: int, limit: int = 50) -> list[dict[str, Any]]:
+ """List recent webhook delivery attempts."""
+ ensure_database_ready()
+ with get_engine().begin() as conn:
+ rows = conn.execute(
+ text(
+ """
+ SELECT id, webhook_id, event_type, payload, success, status_code,
+ error, attempt, response_body, created_at
+ FROM webhook_deliveries
+ WHERE webhook_id = :webhook_id
+ ORDER BY id DESC
+ LIMIT :limit
+ """
+ ),
+ {"webhook_id": int(webhook_id), "limit": int(limit)},
+ ).fetchall()
+
+ deliveries: list[dict[str, Any]] = []
+ for row in rows:
+ try:
+ payload = json.loads(str(row[3]))
+ except Exception:
+ payload = {}
+
+ deliveries.append(
+ {
+ "id": int(row[0]),
+ "webhook_id": int(row[1]),
+ "event_type": str(row[2]),
+ "payload": payload,
+ "success": bool(row[4]),
+ "status_code": int(row[5]) if row[5] is not None else None,
+ "error": str(row[6]) if row[6] is not None else None,
+ "attempt": int(row[7]),
+ "response_body": str(row[8]) if row[8] is not None else None,
+ "created_at": str(row[9]),
+ }
+ )
+ return deliveries
diff --git a/src/core/env.py b/src/core/env.py
index c7c3682..2b39a33 100644
--- a/src/core/env.py
+++ b/src/core/env.py
@@ -58,7 +58,11 @@ def validate_environment():
else:
log_bullet("Dashboard credentials are not set (dashboard disabled).")
else:
- log_success("Dashboard credentials configured via environment variables.")
+ if user == "admin" and password == "admin":
+ log_warning("Insecure dashboard credentials detected (admin/admin).")
+ log_bullet("Login will be rejected until credentials are changed.")
+ else:
+ log_success("Dashboard credentials configured via environment variables.")
ai_config = runtime_config.get("agentic_ai", {})
if ai_config.get("enabled"):
diff --git a/src/core/event_bus.py b/src/core/event_bus.py
index f50918e..81c2499 100644
--- a/src/core/event_bus.py
+++ b/src/core/event_bus.py
@@ -44,6 +44,13 @@ async def emit(self, event_type: str, data: dict[str, Any] | None = None) -> Non
for q in dead_queues:
self._subscribers.remove(q)
+ try:
+ from core.webhooks import dispatch_event
+
+ await dispatch_event(event_type, event["data"], event["timestamp"])
+ except Exception:
+ pass
+
@property
def subscriber_count(self) -> int:
return len(self._subscribers)
diff --git a/src/core/handlers/afk.py b/src/core/handlers/afk.py
index 024fec3..762911b 100644
--- a/src/core/handlers/afk.py
+++ b/src/core/handlers/afk.py
@@ -1,36 +1,30 @@
-"""
-AFK system handler.
+"""AFK system handler.
Tracks users who are AFK and notifies when they're mentioned.
-Uses file-based storage so AFK state persists across bot restarts.
+Uses database-backed storage so AFK state persists across restarts.
"""
-import json
+from __future__ import annotations
+
import time
-from pathlib import Path
from core import symbols as sym
+from core.db import kv_get_json, kv_set_json
from core.i18n import t
-_AFK_FILE = Path(__file__).parent.parent.parent.parent / "data" / "afk.json"
-_AFK_FILE.parent.mkdir(exist_ok=True)
+_AFK_SCOPE = "afk"
+_AFK_KEY = "state"
def _load_afk() -> dict:
- """Load AFK data from disk."""
- if not _AFK_FILE.exists():
- return {}
- try:
- with open(_AFK_FILE, encoding="utf-8") as f:
- return json.load(f)
- except (OSError, json.JSONDecodeError):
- return {}
+ """Load AFK data from database."""
+ data = kv_get_json(_AFK_SCOPE, _AFK_KEY, default={})
+ return data if isinstance(data, dict) else {}
def _save_afk(data: dict) -> None:
- """Save AFK data to disk."""
- with open(_AFK_FILE, "w", encoding="utf-8") as f:
- json.dump(data, f, ensure_ascii=False, indent=2)
+ """Save AFK data to database."""
+ kv_set_json(_AFK_SCOPE, _AFK_KEY, data)
def set_afk(user_jid: str, reason: str = "") -> None:
@@ -44,7 +38,7 @@ def set_afk(user_jid: str, reason: str = "") -> None:
def remove_afk(user_jid: str) -> dict | None:
- """Remove a user from AFK. Returns the afk data if they were AFK."""
+ """Remove a user from AFK. Returns the AFK data if they were AFK."""
data = _load_afk()
afk_info = data.pop(user_jid, None)
if afk_info:
@@ -70,19 +64,15 @@ def _format_duration(seconds: float) -> str:
if days > 0:
return f"{days}d {hours % 24}h"
- elif hours > 0:
+ if hours > 0:
return f"{hours}h {minutes % 60}m"
- elif minutes > 0:
+ if minutes > 0:
return f"{minutes}m"
- else:
- return f"{int(seconds)}s"
+ return f"{int(seconds)}s"
async def handle_afk_mentions(bot, msg) -> None:
- """
- Check if message mentions any AFK users and notify.
- Also check if sender is AFK and remove them.
- """
+ """Check AFK mentions and clear sender AFK if needed."""
afk_data = remove_afk(msg.sender_jid)
if afk_data:
duration = _format_duration(time.time() - afk_data["time"])
diff --git a/src/core/i18n.py b/src/core/i18n.py
index 6996952..ecf841b 100644
--- a/src/core/i18n.py
+++ b/src/core/i18n.py
@@ -6,10 +6,10 @@
import json
from contextvars import ContextVar
-from pathlib import Path
from core import symbols as sym
from core.constants import LOCALES_DIR
+from core.db import kv_get_json, kv_set_json
from core.runtime_config import runtime_config
_locales: dict[str, dict] = {}
@@ -92,30 +92,16 @@ def set_chat_language(chat_jid: str, lang: str) -> bool:
return True
-def _get_languages_file() -> Path:
- """Get path to chat languages file."""
- data_dir = LOCALES_DIR.parent / "data"
- data_dir.mkdir(exist_ok=True)
- return data_dir / "chat_languages.json"
-
-
def _save_chat_languages() -> None:
- """Save chat language preferences to file."""
- file = _get_languages_file()
- with open(file, "w", encoding="utf-8") as f:
- json.dump(_chat_languages, f, ensure_ascii=False, indent=2)
+ """Save chat language preferences to database."""
+ kv_set_json("i18n", "chat_languages", _chat_languages)
def load_chat_languages() -> None:
- """Load saved chat language preferences."""
+ """Load saved chat language preferences from database."""
global _chat_languages
- file = _get_languages_file()
- if file.exists():
- try:
- with open(file, encoding="utf-8") as f:
- _chat_languages = json.load(f)
- except (OSError, json.JSONDecodeError):
- _chat_languages = {}
+ data = kv_get_json("i18n", "chat_languages", default={})
+ _chat_languages = data if isinstance(data, dict) else {}
def t(key: str, chat_jid: str | None = None, **kwargs) -> str:
@@ -187,12 +173,16 @@ def init_i18n(lang: str | None = None) -> None:
"""
global _default_lang
- if lang is None:
- lang = runtime_config.get_nested("bot", "language", default="en")
+ lang_value = (
+ lang if lang is not None else runtime_config.get_nested("bot", "language", default="en")
+ )
+ lang_code = str(lang_value).strip() if isinstance(lang_value, str) else "en"
+ if not lang_code:
+ lang_code = "en"
- _default_lang = lang
+ _default_lang = lang_code
_load_available_languages()
- load_locale(lang)
+ load_locale(lang_code)
load_chat_languages()
diff --git a/src/core/reports.py b/src/core/reports.py
index c98af34..afb7087 100644
--- a/src/core/reports.py
+++ b/src/core/reports.py
@@ -2,11 +2,10 @@
from __future__ import annotations
-import json
from datetime import datetime
from typing import Any
-from core.constants import DATA_DIR
+from core.db import kv_get_json, kv_list_scopes
from core.storage import GroupData
@@ -117,23 +116,10 @@ def find_reports_by_id(report_id: str) -> list[tuple[str, dict[str, Any]]]:
return []
matches: list[tuple[str, dict[str, Any]]] = []
- if not DATA_DIR.exists():
- return matches
-
- for entry in DATA_DIR.iterdir():
- if not entry.is_dir():
- continue
-
- report_file = entry / "reports.json"
- if not report_file.exists():
- continue
-
- try:
- with open(report_file, encoding="utf-8") as f:
- payload = json.load(f)
- except Exception:
- continue
+ for scope in kv_list_scopes(prefix="group:"):
+ group_jid = scope[len("group:") :]
+ payload = kv_get_json(scope, "reports", default={"counter": 0, "items": []})
items = payload.get("items", []) if isinstance(payload, dict) else []
if not isinstance(items, list):
continue
@@ -144,10 +130,10 @@ def find_reports_by_id(report_id: str) -> list[tuple[str, dict[str, Any]]]:
if str(report.get("id", "")).upper() != rid:
continue
- group_jid = str(report.get("group_jid", "")).strip()
- if not group_jid:
+ resolved_group = str(report.get("group_jid", "")).strip() or group_jid
+ if not resolved_group:
continue
- matches.append((group_jid, report))
+ matches.append((resolved_group, report))
return matches
diff --git a/src/core/scheduler.py b/src/core/scheduler.py
index 7450f3b..e9a33bf 100644
--- a/src/core/scheduler.py
+++ b/src/core/scheduler.py
@@ -8,7 +8,6 @@
from __future__ import annotations
import base64
-import json
import uuid
from datetime import datetime
from pathlib import Path
@@ -20,7 +19,7 @@
from apscheduler.triggers.interval import IntervalTrigger
from neonize.proto.waE2E.WAWebProtobufsE2E_pb2 import Message
-from core.constants import DATA_DIR, TASKS_FILE
+from core.db import kv_get_json, kv_set_json
from core.logger import log_error, log_info, log_success, log_warning
if TYPE_CHECKING:
@@ -127,36 +126,35 @@ def __init__(self, bot: BotClient):
self._load_tasks()
def _load_tasks(self) -> None:
- """Load tasks from storage."""
- if not TASKS_FILE.exists():
- return
-
+ """Load tasks from database storage."""
try:
- with open(TASKS_FILE, encoding="utf-8") as f:
- data = json.load(f)
- for task_data in data.get("tasks", []):
- task = ScheduledTask.from_dict(task_data)
- self._tasks[task.task_id] = task
- self._task_counter = data.get("counter", 0)
+ data = kv_get_json("scheduler", "state", default={"tasks": [], "counter": 0})
+ if not isinstance(data, dict):
+ data = {"tasks": [], "counter": 0}
+
+ for task_data in data.get("tasks", []):
+ if not isinstance(task_data, dict):
+ continue
+ task = ScheduledTask.from_dict(task_data)
+ self._tasks[task.task_id] = task
+
+ self._task_counter = int(data.get("counter", 0) or 0)
log_info(f"Loaded {len(self._tasks)} scheduled tasks")
- except (OSError, json.JSONDecodeError) as e:
+ except Exception as e:
log_warning(f"Failed to load scheduled tasks: {e}")
def _save_tasks(self) -> None:
- """Save tasks to storage."""
- DATA_DIR.mkdir(parents=True, exist_ok=True)
+ """Save tasks to database storage."""
try:
- with open(TASKS_FILE, "w", encoding="utf-8") as f:
- json.dump(
- {
- "tasks": [t.to_dict() for t in self._tasks.values()],
- "counter": self._task_counter,
- },
- f,
- ensure_ascii=False,
- indent=2,
- )
- except OSError as e:
+ kv_set_json(
+ "scheduler",
+ "state",
+ {
+ "tasks": [t.to_dict() for t in self._tasks.values()],
+ "counter": self._task_counter,
+ },
+ )
+ except Exception as e:
log_error(f"Failed to save scheduled tasks: {e}")
def _generate_task_id(self) -> str:
diff --git a/src/core/storage.py b/src/core/storage.py
index c382866..1e1f3e3 100644
--- a/src/core/storage.py
+++ b/src/core/storage.py
@@ -1,133 +1,90 @@
-"""
-Per-group data storage system.
+"""Per-group and global data storage.
-Stores group-specific data (notes, filters, settings, etc.) in JSON files.
+Runtime data now uses the shared database layer (`core.db`) instead of JSON files.
"""
-import json
-import os
-import tempfile
-import threading
-import time
-from atexit import register as on_exit
-from pathlib import Path
+from __future__ import annotations
+
+from copy import deepcopy
from typing import Any
from core.constants import DATA_DIR
+from core.db import kv_get_json, kv_set_json
DATA_DIR.mkdir(exist_ok=True)
def safe_jid(jid: str) -> str:
- """Sanitize a JID for use in file/directory names."""
+ """Sanitize a JID for compatibility with legacy folder naming."""
return jid.replace(":", "_").replace("@", "_")
-def _atomic_write(file: Path, data: Any) -> None:
- """Write JSON data atomically using write-to-temp-then-rename.
-
- This prevents data corruption if the process crashes mid-write.
- """
- file.parent.mkdir(parents=True, exist_ok=True)
- fd, tmp_path = tempfile.mkstemp(dir=file.parent, suffix=".tmp")
- try:
- with os.fdopen(fd, "w", encoding="utf-8") as f:
- json.dump(data, f, ensure_ascii=False, indent=2)
- os.replace(tmp_path, file)
- except BaseException:
- try:
- os.unlink(tmp_path)
- except OSError:
- pass
- raise
-
-
class GroupData:
- """Manages per-group data storage."""
+ """Manages per-group structured data in database storage."""
def __init__(self, group_jid: str) -> None:
- """Initialize storage for a specific group."""
+ self.group_jid = group_jid
+ self.scope = f"group:{group_jid}"
+
self.group_dir = DATA_DIR / safe_jid(group_jid)
self.group_dir.mkdir(exist_ok=True)
- def _get_file(self, name: str) -> Path:
- """Get path to a data file."""
- return self.group_dir / f"{name}.json"
-
def load(self, name: str, default: Any = None) -> Any:
- """Load data from a JSON file."""
- file = self._get_file(name)
- if not file.exists():
- return default if default is not None else {}
-
- try:
- with open(file, encoding="utf-8") as f:
- return json.load(f)
- except (OSError, json.JSONDecodeError):
- return default if default is not None else {}
+ """Load data for a key from database."""
+ fallback = default if default is not None else {}
+ data = kv_get_json(self.scope, name, default=None)
+ if data is None:
+ return deepcopy(fallback)
+ return data
def save(self, name: str, data: Any) -> None:
- """Save data to a JSON file (atomic write)."""
- file = self._get_file(name)
- _atomic_write(file, data)
+ """Save data for a key in database."""
+ kv_set_json(self.scope, name, data)
@property
def settings(self) -> dict:
- """Get group settings."""
return self.load("settings", {})
def save_settings(self, settings: dict) -> None:
- """Save group settings."""
self.save("settings", settings)
@property
def notes(self) -> dict:
- """Get saved notes."""
return self.load("notes", {})
def save_notes(self, notes: dict) -> None:
- """Save notes."""
self.save("notes", notes)
@property
def filters(self) -> dict:
- """Get auto-reply filters."""
return self.load("filters", {})
def save_filters(self, filters: dict) -> None:
- """Save filters."""
self.save("filters", filters)
@property
def blacklist(self) -> list:
- """Get blacklisted words."""
return self.load("blacklist", [])
def save_blacklist(self, words: list) -> None:
- """Save blacklist."""
self.save("blacklist", words)
@property
def warnings(self) -> dict:
- """Get user warnings."""
return self.load("warnings", {})
def save_warnings(self, warnings: dict) -> None:
- """Save warnings."""
self.save("warnings", warnings)
@property
def welcome(self) -> dict:
- """Get welcome message config."""
return self.load("welcome", {"enabled": False, "message": ""})
def save_welcome(self, config: dict) -> None:
- """Save welcome config."""
self.save("welcome", config)
@property
def anti_link(self) -> dict:
- """Get anti-link settings for this group."""
config = self.load(
"anti_link",
{
@@ -144,12 +101,10 @@ def anti_link(self) -> dict:
return config
def save_anti_link(self, config: dict) -> None:
- """Save anti-link settings."""
self.save("anti_link", config)
@property
def warnings_config(self) -> dict:
- """Get warnings configuration for this group."""
config = self.load(
"warnings_config",
{
@@ -163,21 +118,17 @@ def warnings_config(self) -> dict:
return config
def save_warnings_config(self, config: dict) -> None:
- """Save warnings configuration."""
self.save("warnings_config", config)
@property
def reports(self) -> dict:
- """Get moderation reports payload."""
return self.load("reports", {"counter": 0, "items": []})
def save_reports(self, payload: dict) -> None:
- """Save moderation reports payload."""
self.save("reports", payload)
@property
def digest(self) -> dict:
- """Get digest settings for this group."""
return self.load(
"digest",
{
@@ -190,163 +141,91 @@ def digest(self) -> dict:
)
def save_digest(self, config: dict) -> None:
- """Save digest settings."""
self.save("digest", config)
@property
def automations(self) -> list:
- """Get automation rules for this group."""
rules = self.load("automations", [])
return rules if isinstance(rules, list) else []
def save_automations(self, rules: list) -> None:
- """Save automation rules for this group."""
self.save("automations", rules)
@property
def muted(self) -> list:
- """Get list of muted users."""
return self.load("muted", [])
def save_muted(self, users: list) -> None:
- """Save muted users."""
self.save("muted", users)
class Storage:
- """Global storage manager for dashboard API."""
-
- _cache_lock = threading.Lock()
- _stats_cache: dict[str, Any] | None = None
- _groups_cache: dict[str, Any] | None = None
- _stats_dirty = False
- _groups_dirty = False
- _last_flush_ts = 0.0
- _flush_interval_seconds = 2.0
- _atexit_registered = False
-
- def __init__(self) -> None:
- """Initialize storage manager."""
- self.stats_file = DATA_DIR / "stats.json"
- self.groups_file = DATA_DIR / "groups.json"
- if not Storage._atexit_registered:
- on_exit(self.flush)
- Storage._atexit_registered = True
-
- def _load_json(self, file: Path, default: Any = None) -> Any:
- """Load JSON file."""
- if not file.exists():
- return default if default is not None else {}
- try:
- with open(file, encoding="utf-8") as f:
- return json.load(f)
- except (OSError, json.JSONDecodeError):
- return default if default is not None else {}
-
- def _save_json(self, file: Path, data: Any) -> None:
- """Save JSON file (atomic write)."""
- _atomic_write(file, data)
-
- def _ensure_stats_cache(self) -> dict[str, Any]:
- """Ensure stats cache is loaded into memory."""
- if Storage._stats_cache is None:
- loaded = self._load_json(self.stats_file, {})
- Storage._stats_cache = loaded if isinstance(loaded, dict) else {}
- return Storage._stats_cache
-
- def _ensure_groups_cache(self) -> dict[str, Any]:
- """Ensure groups cache is loaded into memory."""
- if Storage._groups_cache is None:
- loaded = self._load_json(self.groups_file, {})
- Storage._groups_cache = loaded if isinstance(loaded, dict) else {}
- return Storage._groups_cache
-
- def _flush_if_needed(self, force: bool = False) -> None:
- """Flush dirty caches to disk on interval or force."""
- now = time.time()
- if not force and now - Storage._last_flush_ts < Storage._flush_interval_seconds:
- return
-
- if Storage._stats_dirty and Storage._stats_cache is not None:
- self._save_json(self.stats_file, Storage._stats_cache)
- Storage._stats_dirty = False
-
- if Storage._groups_dirty and Storage._groups_cache is not None:
- self._save_json(self.groups_file, Storage._groups_cache)
- Storage._groups_dirty = False
-
- Storage._last_flush_ts = now
+ """Global storage manager for dashboard/API counters and cached group metadata."""
- def flush(self, force: bool = True) -> None:
- """Flush in-memory dirty data to disk."""
- with Storage._cache_lock:
- self._flush_if_needed(force=force)
+ _SCOPE = "global"
+ _STATS_KEY = "stats"
+ _GROUPS_KEY = "groups"
def get_all_groups(self) -> dict:
- """Get all groups and their settings."""
- with Storage._cache_lock:
- groups = self._ensure_groups_cache()
- return groups.copy()
+ data = kv_get_json(self._SCOPE, self._GROUPS_KEY, default={})
+ return data if isinstance(data, dict) else {}
def get_group_settings(self, group_id: str) -> dict | None:
- """Get settings for a specific group."""
- with Storage._cache_lock:
- groups = self._ensure_groups_cache()
- settings = groups.get(group_id)
- if isinstance(settings, dict):
- return settings.copy()
- return settings
+ groups = self.get_all_groups()
+ settings = groups.get(group_id)
+ if isinstance(settings, dict):
+ return deepcopy(settings)
+ return settings
def set_group_settings(self, group_id: str, settings: dict) -> None:
- """Update settings for a group."""
- with Storage._cache_lock:
- groups = self._ensure_groups_cache()
- if group_id not in groups or not isinstance(groups[group_id], dict):
- groups[group_id] = {}
- groups[group_id].update(settings)
- Storage._groups_dirty = True
- self._flush_if_needed()
+ groups = self.get_all_groups()
+ existing = groups.get(group_id)
+ if not isinstance(existing, dict):
+ existing = {}
+ existing.update(settings)
+ groups[group_id] = existing
+ kv_set_json(self._SCOPE, self._GROUPS_KEY, groups)
def register_group(
self, group_id: str, name: str, member_count: int = 0, is_admin: bool = False
) -> None:
- """Register a new group or update existing."""
- with Storage._cache_lock:
- groups = self._ensure_groups_cache()
- if group_id not in groups or not isinstance(groups[group_id], dict):
- groups[group_id] = {
- "name": name,
- "member_count": member_count,
- "is_admin": is_admin,
- "antilink": False,
- "welcome": True,
- "mute": False,
- }
- else:
- groups[group_id]["name"] = name
- groups[group_id]["member_count"] = member_count
- groups[group_id]["is_admin"] = is_admin
- Storage._groups_dirty = True
- self._flush_if_needed()
+ groups = self.get_all_groups()
+ if group_id not in groups or not isinstance(groups[group_id], dict):
+ groups[group_id] = {
+ "name": name,
+ "member_count": member_count,
+ "is_admin": is_admin,
+ "antilink": False,
+ "welcome": True,
+ "mute": False,
+ }
+ else:
+ groups[group_id]["name"] = name
+ groups[group_id]["member_count"] = member_count
+ groups[group_id]["is_admin"] = is_admin
+
+ kv_set_json(self._SCOPE, self._GROUPS_KEY, groups)
def get_stat(self, key: str, default: Any = 0) -> Any:
- """Get a stat value."""
- with Storage._cache_lock:
- stats = self._ensure_stats_cache()
- return stats.get(key, default)
+ stats = kv_get_json(self._SCOPE, self._STATS_KEY, default={})
+ if not isinstance(stats, dict):
+ return default
+ return stats.get(key, default)
def set_stat(self, key: str, value: Any) -> None:
- """Set a stat value."""
- with Storage._cache_lock:
- stats = self._ensure_stats_cache()
- stats[key] = value
- Storage._stats_dirty = True
- self._flush_if_needed()
+ stats = kv_get_json(self._SCOPE, self._STATS_KEY, default={})
+ if not isinstance(stats, dict):
+ stats = {}
+ stats[key] = value
+ kv_set_json(self._SCOPE, self._STATS_KEY, stats)
def increment_stat(self, key: str, amount: int = 1) -> None:
- """Increment a stat value."""
- with Storage._cache_lock:
- stats = self._ensure_stats_cache()
- stats[key] = stats.get(key, 0) + amount
- Storage._stats_dirty = True
- self._flush_if_needed()
+ stats = kv_get_json(self._SCOPE, self._STATS_KEY, default={})
+ if not isinstance(stats, dict):
+ stats = {}
+ stats[key] = int(stats.get(key, 0) or 0) + amount
+ kv_set_json(self._SCOPE, self._STATS_KEY, stats)
+
+ def flush(self, force: bool = True) -> None:
+ """No-op kept for backward compatibility with previous buffered storage."""
+ return
diff --git a/src/core/webhooks.py b/src/core/webhooks.py
new file mode 100644
index 0000000..9666803
--- /dev/null
+++ b/src/core/webhooks.py
@@ -0,0 +1,204 @@
+"""Webhook dispatch service.
+
+Consumes bot/dashboard events and delivers them to configured webhook endpoints.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import hmac
+import json
+import secrets
+import time
+from dataclasses import dataclass
+from typing import Any
+
+import httpx
+
+from core.db import (
+ get_active_webhooks_for_event,
+ get_webhook,
+ record_webhook_delivery,
+)
+from core.logger import log_warning
+
+MAX_ATTEMPTS = 3
+BASE_RETRY_DELAY_SECONDS = 0.75
+REQUEST_TIMEOUT_SECONDS = 8.0
+
+
+@dataclass
+class WebhookEvent:
+ event_type: str
+ data: dict[str, Any]
+ timestamp: str
+
+
+class WebhookDispatcher:
+ """Asynchronous webhook delivery queue."""
+
+ def __init__(self) -> None:
+ self._queue: asyncio.Queue[WebhookEvent] = asyncio.Queue(maxsize=1000)
+ self._worker_task: asyncio.Task | None = None
+ self._client: httpx.AsyncClient | None = None
+ self._lock = asyncio.Lock()
+
+ async def _ensure_started(self) -> None:
+ async with self._lock:
+ if self._worker_task and not self._worker_task.done():
+ return
+
+ if self._client is None:
+ self._client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
+
+ self._worker_task = asyncio.create_task(self._worker(), name="webhook-dispatcher")
+
+ async def enqueue(self, event: WebhookEvent) -> None:
+ """Queue event for async webhook delivery."""
+ await self._ensure_started()
+ try:
+ self._queue.put_nowait(event)
+ except asyncio.QueueFull:
+ log_warning("Webhook queue full; dropping event")
+
+ def _build_signature(self, secret: str, timestamp: str, payload: str) -> str:
+ message = f"{timestamp}.{payload}".encode()
+ digest = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
+ return f"sha256={digest}"
+
+ async def _deliver_one(
+ self,
+ webhook: dict[str, Any],
+ event: WebhookEvent,
+ *,
+ allow_retry: bool = True,
+ ) -> dict[str, Any]:
+ """Deliver an event to one webhook endpoint."""
+ url = str(webhook.get("url", "")).strip()
+ secret = str(webhook.get("secret", "")).strip() or secrets.token_hex(16)
+ webhook_id = int(webhook["id"])
+
+ body_payload = {
+ "event": event.event_type,
+ "timestamp": event.timestamp,
+ "data": event.data,
+ }
+ body = json.dumps(body_payload, ensure_ascii=False)
+
+ max_attempts = MAX_ATTEMPTS if allow_retry else 1
+
+ for attempt in range(1, max_attempts + 1):
+ ts = str(int(time.time()))
+ signature = self._build_signature(secret, ts, body)
+ headers = {
+ "Content-Type": "application/json",
+ "User-Agent": "ZeroIchi-Webhook/1.0",
+ "X-ZeroIchi-Event": event.event_type,
+ "X-ZeroIchi-Timestamp": ts,
+ "X-ZeroIchi-Signature": signature,
+ }
+
+ try:
+ if self._client is None:
+ self._client = httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS)
+
+ response = await self._client.post(
+ url, content=body.encode("utf-8"), headers=headers
+ )
+ ok = 200 <= response.status_code < 300
+
+ record_webhook_delivery(
+ webhook_id=webhook_id,
+ event_type=event.event_type,
+ payload=body_payload,
+ success=ok,
+ attempt=attempt,
+ status_code=response.status_code,
+ response_body=response.text,
+ error=None if ok else f"HTTP {response.status_code}",
+ )
+
+ if ok:
+ return {
+ "success": True,
+ "status_code": response.status_code,
+ "attempt": attempt,
+ }
+
+ if attempt < max_attempts:
+ await asyncio.sleep(BASE_RETRY_DELAY_SECONDS * (2 ** (attempt - 1)))
+ except Exception as exc:
+ record_webhook_delivery(
+ webhook_id=webhook_id,
+ event_type=event.event_type,
+ payload=body_payload,
+ success=False,
+ attempt=attempt,
+ status_code=None,
+ response_body=None,
+ error=str(exc),
+ )
+ if attempt < max_attempts:
+ await asyncio.sleep(BASE_RETRY_DELAY_SECONDS * (2 ** (attempt - 1)))
+ else:
+ log_warning(f"Webhook delivery failed ({webhook_id}): {exc}")
+
+ return {"success": False}
+
+ async def _worker(self) -> None:
+ """Background delivery worker."""
+ while True:
+ event = await self._queue.get()
+ try:
+ hooks = get_active_webhooks_for_event(event.event_type)
+ for hook in hooks:
+ await self._deliver_one(hook, event, allow_retry=True)
+ except Exception as exc:
+ log_warning(f"Webhook worker error: {exc}")
+ finally:
+ self._queue.task_done()
+
+ async def send_test(self, webhook_id: int) -> dict[str, Any]:
+ """Send one immediate test event to a webhook."""
+ hook = get_webhook(webhook_id)
+ if not hook:
+ return {"success": False, "error": "Webhook not found"}
+
+ event = WebhookEvent(
+ event_type="webhook_test",
+ data={"message": "Zero Ichi test event"},
+ timestamp=time.strftime("%Y-%m-%dT%H:%M:%S"),
+ )
+ result = await self._deliver_one(hook, event, allow_retry=False)
+ return result
+
+
+_dispatcher = WebhookDispatcher()
+
+
+async def dispatch_event(event_type: str, data: dict[str, Any], timestamp: str) -> None:
+ """Queue an event for webhook delivery."""
+ await _dispatcher.enqueue(WebhookEvent(event_type=event_type, data=data, timestamp=timestamp))
+
+
+async def send_test_webhook(webhook_id: int) -> dict[str, Any]:
+ """Trigger a one-shot webhook test delivery."""
+ return await _dispatcher.send_test(webhook_id)
+
+
+def list_known_events() -> list[str]:
+ """Known event names exposed by current bot flows."""
+ return [
+ "new_message",
+ "command_executed",
+ "auto_download",
+ "command_update",
+ "config_update",
+ "group_update",
+ "report_update",
+ "digest_update",
+ "automation_update",
+ "automation_triggered",
+ "webhook_test",
+ ]
diff --git a/src/dashboard_api.py b/src/dashboard_api.py
index ac680c6..56662d6 100644
--- a/src/dashboard_api.py
+++ b/src/dashboard_api.py
@@ -40,6 +40,14 @@
from core.analytics import command_analytics
from core.automations import load_rules, next_rule_id, save_rules
from core.command import command_loader
+from core.db import (
+ create_webhook,
+ delete_webhook,
+ get_webhook,
+ list_webhook_deliveries,
+ list_webhooks,
+ update_webhook,
+)
from core.digest import apply_digest_schedule, build_digest_message, send_digest_now
from core.event_bus import event_bus
from core.handlers.welcome import (
@@ -55,6 +63,7 @@
from core.session import session_state
from core.shared import get_bot
from core.storage import GroupData, Storage
+from core.webhooks import list_known_events, send_test_webhook
BOT_START_TIME = datetime.now()
_DOTENV_PATH = Path(__file__).parent.parent / ".env"
@@ -361,6 +370,22 @@ class AutomationRuleUpdate(BaseModel):
enabled: bool | None = None
+class WebhookCreate(BaseModel):
+ name: str
+ url: str
+ events: list[str] = []
+ secret: str = ""
+ enabled: bool = True
+
+
+class WebhookUpdate(BaseModel):
+ name: str | None = None
+ url: str | None = None
+ events: list[str] | None = None
+ secret: str | None = None
+ enabled: bool | None = None
+
+
@_api.post("/api/send-message")
async def send_message(req: MessageRequest):
"""Send a message via the bot."""
@@ -906,6 +931,129 @@ async def update_rate_limit(settings: RateLimitSettings):
return {"success": True}
+@_api.get("/api/webhooks")
+async def get_webhooks():
+ """List configured webhooks."""
+ hooks = list_webhooks(include_disabled=True)
+ return {
+ "webhooks": [
+ {
+ "id": hook["id"],
+ "name": hook["name"],
+ "url": hook["url"],
+ "events": hook["events"],
+ "enabled": hook["enabled"],
+ "created_at": hook["created_at"],
+ "updated_at": hook["updated_at"],
+ "has_secret": bool(hook.get("secret")),
+ }
+ for hook in hooks
+ ],
+ "available_events": list_known_events(),
+ }
+
+
+@_api.post("/api/webhooks")
+async def create_webhook_endpoint(payload: WebhookCreate):
+ """Create a webhook endpoint."""
+ url = payload.url.strip()
+ if not url.startswith("http://") and not url.startswith("https://"):
+ raise HTTPException(
+ status_code=400, detail="Webhook URL must start with http:// or https://"
+ )
+
+ secret = payload.secret.strip() or secrets.token_urlsafe(24)
+ created = create_webhook(
+ name=payload.name,
+ url=url,
+ events=payload.events,
+ secret=secret,
+ enabled=payload.enabled,
+ )
+
+ return {
+ "success": True,
+ "webhook": {
+ "id": created["id"],
+ "name": created["name"],
+ "url": created["url"],
+ "events": created["events"],
+ "enabled": created["enabled"],
+ "created_at": created["created_at"],
+ "updated_at": created["updated_at"],
+ "has_secret": bool(created.get("secret")),
+ },
+ "secret": secret,
+ }
+
+
+@_api.put("/api/webhooks/{webhook_id}")
+async def update_webhook_endpoint(webhook_id: int, payload: WebhookUpdate):
+ """Update webhook endpoint settings."""
+ if payload.url is not None:
+ trimmed = payload.url.strip()
+ if not trimmed.startswith("http://") and not trimmed.startswith("https://"):
+ raise HTTPException(
+ status_code=400,
+ detail="Webhook URL must start with http:// or https://",
+ )
+
+ updated = update_webhook(
+ webhook_id,
+ name=payload.name,
+ url=payload.url,
+ events=payload.events,
+ secret=payload.secret,
+ enabled=payload.enabled,
+ )
+ if not updated:
+ raise HTTPException(status_code=404, detail="Webhook not found")
+
+ return {
+ "success": True,
+ "webhook": {
+ "id": updated["id"],
+ "name": updated["name"],
+ "url": updated["url"],
+ "events": updated["events"],
+ "enabled": updated["enabled"],
+ "created_at": updated["created_at"],
+ "updated_at": updated["updated_at"],
+ "has_secret": bool(updated.get("secret")),
+ },
+ }
+
+
+@_api.delete("/api/webhooks/{webhook_id}")
+async def delete_webhook_endpoint(webhook_id: int):
+ """Delete webhook endpoint."""
+ if not delete_webhook(webhook_id):
+ raise HTTPException(status_code=404, detail="Webhook not found")
+ return {"success": True}
+
+
+@_api.post("/api/webhooks/{webhook_id}/test")
+async def test_webhook_endpoint(webhook_id: int):
+ """Send one test payload to a webhook."""
+ hook = get_webhook(webhook_id)
+ if not hook:
+ raise HTTPException(status_code=404, detail="Webhook not found")
+
+ result = await send_test_webhook(webhook_id)
+ return {"success": bool(result.get("success")), "result": result}
+
+
+@_api.get("/api/webhooks/{webhook_id}/deliveries")
+async def get_webhook_deliveries_endpoint(webhook_id: int, limit: int = Query(50, ge=1, le=200)):
+ """Get recent webhook delivery attempts."""
+ hook = get_webhook(webhook_id)
+ if not hook:
+ raise HTTPException(status_code=404, detail="Webhook not found")
+
+ deliveries = list_webhook_deliveries(webhook_id, limit=limit)
+ return {"deliveries": deliveries, "count": len(deliveries)}
+
+
@_api.get("/api/groups/{group_id}/welcome")
async def get_welcome(group_id: str):
"""Get welcome settings for a group."""
diff --git a/src/main.py b/src/main.py
index 0ceff4b..e0cd8b3 100644
--- a/src/main.py
+++ b/src/main.py
@@ -32,6 +32,7 @@
from rich.console import Console
from watchfiles import awatch
+from core.db import ensure_database_ready
from core.handlers.welcome import handle_member_join, handle_member_leave
from core.i18n import init_i18n, reload_locales, t
from core.jid_resolver import get_user_part, jids_match, resolve_pair
@@ -105,6 +106,8 @@ def _run_update():
def _init_bot(args):
"""Initialize the bot infrastructure. Only called when actually running the bot."""
+ ensure_database_ready()
+
_ai_api_key = os.getenv("AI_API_KEY")
if _ai_api_key and not os.getenv("OPENAI_API_KEY"):
os.environ["OPENAI_API_KEY"] = str(_ai_api_key)
diff --git a/tests/test_db_webhooks.py b/tests/test_db_webhooks.py
new file mode 100644
index 0000000..9efa2b7
--- /dev/null
+++ b/tests/test_db_webhooks.py
@@ -0,0 +1,57 @@
+from pathlib import Path
+
+import core.db as db_module
+
+
+def _reset_db(tmp_path: Path, monkeypatch) -> None:
+ db_file = tmp_path / "test.db"
+ monkeypatch.setenv("DATABASE_URL", f"sqlite:///{db_file.as_posix()}")
+ db_module._engine = None
+ db_module._ready = False
+ db_module.ensure_database_ready()
+
+
+def test_kv_roundtrip(tmp_path, monkeypatch):
+ _reset_db(tmp_path, monkeypatch)
+
+ payload = {"count": 42, "items": ["a", "b"]}
+ db_module.kv_set_json("global", "stats", payload)
+
+ loaded = db_module.kv_get_json("global", "stats", default={})
+ assert loaded == payload
+
+
+def test_webhook_crud_and_delivery_log(tmp_path, monkeypatch):
+ _reset_db(tmp_path, monkeypatch)
+
+ hook = db_module.create_webhook(
+ name="CI",
+ url="https://example.com/hook",
+ events=["command_executed"],
+ secret="abc",
+ enabled=True,
+ )
+
+ assert hook["name"] == "CI"
+ assert hook["enabled"] is True
+
+ matches = db_module.get_active_webhooks_for_event("command_executed")
+ assert len(matches) == 1
+ assert matches[0]["id"] == hook["id"]
+
+ db_module.record_webhook_delivery(
+ webhook_id=hook["id"],
+ event_type="command_executed",
+ payload={"ok": True},
+ success=True,
+ attempt=1,
+ status_code=204,
+ )
+
+ deliveries = db_module.list_webhook_deliveries(hook["id"], limit=10)
+ assert len(deliveries) == 1
+ assert deliveries[0]["success"] is True
+ assert deliveries[0]["status_code"] == 204
+
+ assert db_module.delete_webhook(hook["id"])
+ assert db_module.get_webhook(hook["id"]) is None
diff --git a/uv.lock b/uv.lock
index 4d297bf..09d5dcd 100644
--- a/uv.lock
+++ b/uv.lock
@@ -973,6 +973,58 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
]
+[[package]]
+name = "greenlet"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" },
+ { url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" },
+ { url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
+ { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" },
+ { url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
+ { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
+ { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
+ { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
+ { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" },
+ { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
+ { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
+ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
+ { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
+]
+
[[package]]
name = "griffe"
version = "1.15.0"
@@ -2182,6 +2234,75 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/75/b1/1dc83c2c661b4c62d56cc081706ee33a4fc2835bd90f965baa2663ef7676/protobuf-6.33.4-py3-none-any.whl", hash = "sha256:1fe3730068fcf2e595816a6c34fe66eeedd37d51d0400b72fabc848811fdc1bc", size = 170532, upload-time = "2026-01-12T18:33:39.199Z" },
]
+[[package]]
+name = "psycopg"
+version = "3.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" },
+]
+
+[package.optional-dependencies]
+binary = [
+ { name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
+]
+
+[[package]]
+name = "psycopg-binary"
+version = "3.3.3"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/c0/b389119dd754483d316805260f3e73cdcad97925839107cc7a296f6132b1/psycopg_binary-3.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a89bb9ee11177b2995d87186b1d9fa892d8ea725e85eab28c6525e4cc14ee048", size = 4609740, upload-time = "2026-02-18T16:47:51.093Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/e3/9976eef20f61840285174d360da4c820a311ab39d6b82fa09fbb545be825/psycopg_binary-3.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f7d0cf072c6fbac3795b08c98ef9ea013f11db609659dcfc6b1f6cc31f9e181", size = 4676837, upload-time = "2026-02-18T16:47:55.523Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/f2/d28ba2f7404fd7f68d41e8a11df86313bd646258244cb12a8dd83b868a97/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:90eecd93073922f085967f3ed3a98ba8c325cbbc8c1a204e300282abd2369e13", size = 5497070, upload-time = "2026-02-18T16:47:59.929Z" },
+ { url = "https://files.pythonhosted.org/packages/de/2f/6c5c54b815edeb30a281cfcea96dc93b3bb6be939aea022f00cab7aa1420/psycopg_binary-3.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dac7ee2f88b4d7bb12837989ca354c38d400eeb21bce3b73dac02622f0a3c8d6", size = 5172410, upload-time = "2026-02-18T16:48:05.665Z" },
+ { url = "https://files.pythonhosted.org/packages/51/75/8206c7008b57de03c1ada46bd3110cc3743f3fd9ed52031c4601401d766d/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b62cf8784eb6d35beaee1056d54caf94ec6ecf2b7552395e305518ab61eb8fd2", size = 6763408, upload-time = "2026-02-18T16:48:13.541Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/5a/ea1641a1e6c8c8b3454b0fcb43c3045133a8b703e6e824fae134088e63bd/psycopg_binary-3.3.3-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a39f34c9b18e8f6794cca17bfbcd64572ca2482318db644268049f8c738f35a6", size = 5006255, upload-time = "2026-02-18T16:48:22.176Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/fb/538df099bf55ae1637d52d7ccb6b9620b535a40f4c733897ac2b7bb9e14c/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:883d68d48ca9ff3cb3d10c5fdebea02c79b48eecacdddbf7cce6e7cdbdc216b8", size = 4532694, upload-time = "2026-02-18T16:48:27.338Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/d1/00780c0e187ea3c13dfc53bd7060654b2232cd30df562aac91a5f1c545ac/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:cab7bc3d288d37a80aa8c0820033250c95e40b1c2b5c57cf59827b19c2a8b69d", size = 4222833, upload-time = "2026-02-18T16:48:31.221Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/34/a07f1ff713c51d64dc9f19f2c32be80299a2055d5d109d5853662b922cb4/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:56c767007ca959ca32f796b42379fc7e1ae2ed085d29f20b05b3fc394f3715cc", size = 3952818, upload-time = "2026-02-18T16:48:35.869Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/67/d33f268a7759b4445f3c9b5a181039b01af8c8263c865c1be7a6444d4749/psycopg_binary-3.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:da2f331a01af232259a21573a01338530c6016dcfad74626c01330535bcd8628", size = 4258061, upload-time = "2026-02-18T16:48:41.365Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/3b/0d8d2c5e8e29ccc07d28c8af38445d9d9abcd238d590186cac82ee71fc84/psycopg_binary-3.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:19f93235ece6dbfc4036b5e4f6d8b13f0b8f2b3eeb8b0bd2936d406991bcdd40", size = 3558915, upload-time = "2026-02-18T16:48:46.679Z" },
+ { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" },
+ { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" },
+ { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" },
+ { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" },
+ { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" },
+ { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" },
+ { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" },
+ { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" },
+ { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" },
+ { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" },
+ { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" },
+ { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" },
+ { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" },
+ { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" },
+]
+
[[package]]
name = "py-key-value-aio"
version = "0.3.0"
@@ -3143,6 +3264,59 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/f3/b67d6ea49ca9154453b6d70b34ea22f3996b9fa55da105a79d8732227adc/soupsieve-2.8.1-py3-none-any.whl", hash = "sha256:a11fe2a6f3d76ab3cf2de04eb339c1be5b506a8a47f2ceb6d139803177f85434", size = 36710, upload-time = "2025-12-18T13:50:33.267Z" },
]
+[[package]]
+name = "sqlalchemy"
+version = "2.0.48"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d7/6d/b8b78b5b80f3c3ab3f7fa90faa195ec3401f6d884b60221260fd4d51864c/sqlalchemy-2.0.48-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b4c575df7368b3b13e0cebf01d4679f9a28ed2ae6c1cd0b1d5beffb6b2007dc", size = 2157184, upload-time = "2026-03-02T15:38:28.161Z" },
+ { url = "https://files.pythonhosted.org/packages/21/4b/4f3d4a43743ab58b95b9ddf5580a265b593d017693df9e08bd55780af5bb/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e83e3f959aaa1c9df95c22c528096d94848a1bc819f5d0ebf7ee3df0ca63db6c", size = 3313555, upload-time = "2026-03-02T15:58:57.21Z" },
+ { url = "https://files.pythonhosted.org/packages/21/dd/3b7c53f1dbbf736fd27041aee68f8ac52226b610f914085b1652c2323442/sqlalchemy-2.0.48-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f7b7243850edd0b8b97043f04748f31de50cf426e939def5c16bedb540698f7", size = 3313057, upload-time = "2026-03-02T15:52:29.366Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/cc/3e600a90ae64047f33313d7d32e5ad025417f09d2ded487e8284b5e21a15/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82745b03b4043e04600a6b665cb98697c4339b24e34d74b0a2ac0a2488b6f94d", size = 3265431, upload-time = "2026-03-02T15:58:59.096Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/19/780138dacfe3f5024f4cf96e4005e91edf6653d53d3673be4844578faf1d/sqlalchemy-2.0.48-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5e088bf43f6ee6fec7dbf1ef7ff7774a616c236b5c0cb3e00662dd71a56b571", size = 3287646, upload-time = "2026-03-02T15:52:31.569Z" },
+ { url = "https://files.pythonhosted.org/packages/40/fd/f32ced124f01a23151f4777e4c705f3a470adc7bd241d9f36a7c941a33bf/sqlalchemy-2.0.48-cp311-cp311-win32.whl", hash = "sha256:9c7d0a77e36b5f4b01ca398482230ab792061d243d715299b44a0b55c89fe617", size = 2116956, upload-time = "2026-03-02T15:46:54.535Z" },
+ { url = "https://files.pythonhosted.org/packages/58/d5/dd767277f6feef12d05651538f280277e661698f617fa4d086cce6055416/sqlalchemy-2.0.48-cp311-cp311-win_amd64.whl", hash = "sha256:583849c743e0e3c9bb7446f5b5addeacedc168d657a69b418063dfdb2d90081c", size = 2141627, upload-time = "2026-03-02T15:46:55.849Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/91/a42ae716f8925e9659df2da21ba941f158686856107a61cc97a95e7647a3/sqlalchemy-2.0.48-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:348174f228b99f33ca1f773e85510e08927620caa59ffe7803b37170df30332b", size = 2155737, upload-time = "2026-03-02T15:49:13.207Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/52/f75f516a1f3888f027c1cfb5d22d4376f4b46236f2e8669dcb0cddc60275/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53667b5f668991e279d21f94ccfa6e45b4e3f4500e7591ae59a8012d0f010dcb", size = 3337020, upload-time = "2026-03-02T15:50:34.547Z" },
+ { url = "https://files.pythonhosted.org/packages/37/9a/0c28b6371e0cdcb14f8f1930778cb3123acfcbd2c95bb9cf6b4a2ba0cce3/sqlalchemy-2.0.48-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34634e196f620c7a61d18d5cf7dc841ca6daa7961aed75d532b7e58b309ac894", size = 3349983, upload-time = "2026-03-02T15:53:25.542Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/46/0aee8f3ff20b1dcbceb46ca2d87fcc3d48b407925a383ff668218509d132/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:546572a1793cc35857a2ffa1fe0e58571af1779bcc1ffa7c9fb0839885ed69a9", size = 3279690, upload-time = "2026-03-02T15:50:36.277Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/8c/a957bc91293b49181350bfd55e6dfc6e30b7f7d83dc6792d72043274a390/sqlalchemy-2.0.48-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07edba08061bc277bfdc772dd2a1a43978f5a45994dd3ede26391b405c15221e", size = 3314738, upload-time = "2026-03-02T15:53:27.519Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/44/1d257d9f9556661e7bdc83667cc414ba210acfc110c82938cb3611eea58f/sqlalchemy-2.0.48-cp312-cp312-win32.whl", hash = "sha256:908a3fa6908716f803b86896a09a2c4dde5f5ce2bb07aacc71ffebb57986ce99", size = 2115546, upload-time = "2026-03-02T15:54:31.591Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/af/c3c7e1f3a2b383155a16454df62ae8c62a30dd238e42e68c24cebebbfae6/sqlalchemy-2.0.48-cp312-cp312-win_amd64.whl", hash = "sha256:68549c403f79a8e25984376480959975212a670405e3913830614432b5daa07a", size = 2142484, upload-time = "2026-03-02T15:54:34.072Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/c6/569dc8bf3cd375abc5907e82235923e986799f301cd79a903f784b996fca/sqlalchemy-2.0.48-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e3070c03701037aa418b55d36532ecb8f8446ed0135acb71c678dbdf12f5b6e4", size = 2152599, upload-time = "2026-03-02T15:49:14.41Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/ff/f4e04a4bd5a24304f38cb0d4aa2ad4c0fb34999f8b884c656535e1b2b74c/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2645b7d8a738763b664a12a1542c89c940daa55196e8d73e55b169cc5c99f65f", size = 3278825, upload-time = "2026-03-02T15:50:38.269Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/88/cb59509e4668d8001818d7355d9995be90c321313078c912420603a7cb95/sqlalchemy-2.0.48-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b19151e76620a412c2ac1c6f977ab1b9fa7ad43140178345136456d5265b32ed", size = 3295200, upload-time = "2026-03-02T15:53:29.366Z" },
+ { url = "https://files.pythonhosted.org/packages/87/dc/1609a4442aefd750ea2f32629559394ec92e89ac1d621a7f462b70f736ff/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b193a7e29fd9fa56e502920dca47dffe60f97c863494946bd698c6058a55658", size = 3226876, upload-time = "2026-03-02T15:50:39.802Z" },
+ { url = "https://files.pythonhosted.org/packages/37/c3/6ae2ab5ea2fa989fbac4e674de01224b7a9d744becaf59bb967d62e99bed/sqlalchemy-2.0.48-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36ac4ddc3d33e852da9cb00ffb08cea62ca05c39711dc67062ca2bb1fae35fd8", size = 3265045, upload-time = "2026-03-02T15:53:31.421Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/82/ea4665d1bb98c50c19666e672f21b81356bd6077c4574e3d2bbb84541f53/sqlalchemy-2.0.48-cp313-cp313-win32.whl", hash = "sha256:389b984139278f97757ea9b08993e7b9d1142912e046ab7d82b3fbaeb0209131", size = 2113700, upload-time = "2026-03-02T15:54:35.825Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/2b/b9040bec58c58225f073f5b0c1870defe1940835549dafec680cbd58c3c3/sqlalchemy-2.0.48-cp313-cp313-win_amd64.whl", hash = "sha256:d612c976cbc2d17edfcc4c006874b764e85e990c29ce9bd411f926bbfb02b9a2", size = 2139487, upload-time = "2026-03-02T15:54:37.079Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/7b17bd50244b78a49d22cc63c969d71dc4de54567dc152a9b46f6fae40ce/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69f5bc24904d3bc3640961cddd2523e361257ef68585d6e364166dfbe8c78fae", size = 3558851, upload-time = "2026-03-02T15:57:48.607Z" },
+ { url = "https://files.pythonhosted.org/packages/20/0d/213668e9aca61d370f7d2a6449ea4ec699747fac67d4bda1bb3d129025be/sqlalchemy-2.0.48-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd08b90d211c086181caed76931ecfa2bdfc83eea3cfccdb0f82abc6c4b876cb", size = 3525525, upload-time = "2026-03-02T16:04:38.058Z" },
+ { url = "https://files.pythonhosted.org/packages/85/d7/a84edf412979e7d59c69b89a5871f90a49228360594680e667cb2c46a828/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1ccd42229aaac2df431562117ac7e667d702e8e44afdb6cf0e50fa3f18160f0b", size = 3466611, upload-time = "2026-03-02T15:57:50.759Z" },
+ { url = "https://files.pythonhosted.org/packages/86/55/42404ce5770f6be26a2b0607e7866c31b9a4176c819e9a7a5e0a055770be/sqlalchemy-2.0.48-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0dcbc588cd5b725162c076eb9119342f6579c7f7f55057bb7e3c6ff27e13121", size = 3475812, upload-time = "2026-03-02T16:04:40.092Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/ae/29b87775fadc43e627cf582fe3bda4d02e300f6b8f2747c764950d13784c/sqlalchemy-2.0.48-cp313-cp313t-win32.whl", hash = "sha256:9764014ef5e58aab76220c5664abb5d47d5bc858d9debf821e55cfdd0f128485", size = 2141335, upload-time = "2026-03-02T15:52:51.518Z" },
+ { url = "https://files.pythonhosted.org/packages/91/44/f39d063c90f2443e5b46ec4819abd3d8de653893aae92df42a5c4f5843de/sqlalchemy-2.0.48-cp313-cp313t-win_amd64.whl", hash = "sha256:e2f35b4cccd9ed286ad62e0a3c3ac21e06c02abc60e20aa51a3e305a30f5fa79", size = 2173095, upload-time = "2026-03-02T15:52:52.79Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" },
+ { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" },
+ { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" },
+ { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" },
+ { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" },
+]
+
[[package]]
name = "sse-starlette"
version = "3.2.0"
@@ -3732,13 +3906,16 @@ dependencies = [
{ name = "apscheduler" },
{ name = "fastapi" },
{ name = "gallery-dl" },
+ { name = "httpx" },
{ name = "jsonschema" },
{ name = "neonize" },
{ name = "pillow" },
+ { name = "psycopg", extra = ["binary"] },
{ name = "pydantic-ai" },
{ name = "python-dotenv" },
{ name = "qrcode" },
{ name = "rich" },
+ { name = "sqlalchemy" },
{ name = "uvicorn" },
{ name = "watchfiles" },
{ name = "yt-dlp" },
@@ -3756,13 +3933,16 @@ requires-dist = [
{ name = "apscheduler", specifier = ">=3.10.0" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "gallery-dl", specifier = ">=1.31.7" },
+ { name = "httpx", specifier = ">=0.28.1" },
{ name = "jsonschema", specifier = ">=4.23.0" },
{ name = "neonize", specifier = ">=0.3.14.post0" },
{ name = "pillow", specifier = ">=10.0.0" },
+ { name = "psycopg", extras = ["binary"], specifier = ">=3.2.9" },
{ name = "pydantic-ai", specifier = ">=1.48.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "qrcode", specifier = ">=7.4" },
{ name = "rich", specifier = ">=13.0" },
+ { name = "sqlalchemy", specifier = ">=2.0.43" },
{ name = "uvicorn", specifier = ">=0.34.0" },
{ name = "watchfiles", specifier = ">=1.1.1" },
{ name = "yt-dlp", specifier = ">=2026.3.3" },