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

+
+ setName(e.target.value)} + placeholder="Name" + className="rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm" + /> + setUrl(e.target.value)} + placeholder="https://example.com/webhook" + className="rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm" + /> + setSecret(e.target.value)} + placeholder="Secret (optional)" + className="rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm" + /> +
+ {selectedLabel} +
+
+ +
+ {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/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..4d2e275 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -184,6 +184,37 @@ 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; + } + 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 +228,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 +316,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 +364,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 +428,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`, @@ -514,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 83e7c99..683c63b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,10 @@ dependencies = [ "apscheduler>=3.10.0", "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", @@ -27,7 +31,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 +74,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/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 5ca78a1..0c5faa3 100644 --- a/src/ai/token_tracker.py +++ b/src/ai/token_tracker.py @@ -1,43 +1,58 @@ -""" -AI Token tracker. +"""AI Token tracker. Tracks token usage per user and per chat with configurable daily limits. """ -import json +from __future__ import annotations + +import time 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 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() 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: 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") + """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 write volume.""" + 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 +69,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.""" @@ -62,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 @@ -84,7 +100,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]}), " @@ -95,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 32a7162..831477c 100644 --- a/src/core/analytics.py +++ b/src/core/analytics.py @@ -1,42 +1,55 @@ -""" -Command usage analytics. +"""Command usage analytics. Tracks per-command usage with timestamps for dashboard charts. """ -import json +from __future__ import annotations + +import time 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 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() 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.""" - DATA_DIR.mkdir(parents=True, exist_ok=True) - ANALYTICS_FILE.write_text(json.dumps(self._data, indent=2), encoding="utf-8") + """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 write volume.""" + 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 +68,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/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 83f289c..2b39a33 100644 --- a/src/core/env.py +++ b/src/core/env.py @@ -49,23 +49,33 @@ 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.") + 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"): 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/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/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/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/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/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 72cff1f..1e1f3e3 100644 --- a/src/core/storage.py +++ b/src/core/storage.py @@ -1,130 +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 -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", { @@ -141,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", { @@ -160,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", { @@ -187,74 +141,56 @@ 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.""" - - def __init__(self) -> None: - """Initialize storage manager.""" - self.stats_file = DATA_DIR / "stats.json" - self.groups_file = DATA_DIR / "groups.json" - - 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) + """Global storage manager for dashboard/API counters and cached group metadata.""" + + _SCOPE = "global" + _STATS_KEY = "stats" + _GROUPS_KEY = "groups" def get_all_groups(self) -> dict: - """Get all groups and their settings.""" - return self._load_json(self.groups_file, {}) + 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.""" groups = self.get_all_groups() - return groups.get(group_id) + 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.""" 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) + 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.""" groups = self.get_all_groups() - if group_id not in groups: + if group_id not in groups or not isinstance(groups[group_id], dict): groups[group_id] = { "name": name, "member_count": member_count, @@ -267,21 +203,29 @@ def register_group( 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) + + kv_set_json(self._SCOPE, self._GROUPS_KEY, groups) def get_stat(self, key: str, default: Any = 0) -> Any: - """Get a stat value.""" - stats = self._load_json(self.stats_file, {}) + 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.""" - stats = self._load_json(self.stats_file, {}) + stats = kv_get_json(self._SCOPE, self._STATS_KEY, default={}) + if not isinstance(stats, dict): + stats = {} stats[key] = value - self._save_json(self.stats_file, stats) + kv_set_json(self._SCOPE, self._STATS_KEY, stats) 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) + 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 a3ce6f4..56662d6 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, @@ -39,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 ( @@ -47,15 +56,112 @@ 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 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" +_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 +197,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 +220,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=["*"], @@ -265,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.""" @@ -355,12 +476,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 +542,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 +585,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,28 +899,161 @@ 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} +@_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.""" @@ -1410,6 +1670,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 +1740,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/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_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_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/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..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" @@ -1173,6 +1225,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 +2090,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" @@ -2164,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" @@ -2548,6 +2687,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" @@ -3096,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" @@ -3685,12 +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" }, @@ -3698,6 +3923,8 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, ] @@ -3706,19 +3933,27 @@ 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" }, ] [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"