From b142d1d5332485ddab5fb76e750a5904e4a460cd Mon Sep 17 00:00:00 2001 From: MhankBarBar Date: Mon, 16 Mar 2026 18:27:24 +0700 Subject: [PATCH 1/3] feat: live fun commands, richer automations/digest, /summarize, /whois, anti-spam middleware, PG tests --- alembic.ini | 36 + alembic/env.py | 62 ++ alembic/script.py.mako | 25 + alembic/versions/0001_runtime_baseline.py | 109 +++ config.schema.json | 40 + dashboard/src/app/audit/page.tsx | 85 +++ dashboard/src/app/webhooks/page.tsx | 263 ++++++- dashboard/src/components/dashboard-layout.tsx | 1 + dashboard/src/lib/api.ts | 87 +++ docs/.vitepress/config.mts | 2 + docs/commands/fun.md | 14 +- docs/commands/group.md | 23 + docs/commands/moderation.md | 22 +- docs/commands/utility.md | 18 + docs/development/architecture.md | 9 + docs/development/contributing.md | 14 + docs/features/anti-spam.md | 70 ++ docs/features/dashboard.md | 12 + docs/features/webhooks.md | 257 ++++++- pyproject.toml | 2 +- src/commands/fun/fact.py | 29 +- src/commands/fun/joke.py | 36 +- src/commands/fun/quote.py | 41 +- src/commands/group/whois.py | 77 ++ src/commands/moderation/automation.py | 4 +- src/commands/utility/summarize.py | 113 +++ src/core/automations.py | 17 +- src/core/db.py | 702 +++++++++++++++++- src/core/digest.py | 72 +- src/core/middlewares/__init__.py | 2 + src/core/middlewares/anti_spam.py | 77 ++ src/core/middlewares/automations.py | 20 +- src/core/runtime_config.py | 7 + src/core/webhooks.py | 53 ++ src/dashboard_api.py | 369 ++++++++- src/locales/en.json | 27 +- src/locales/id.json | 27 +- .../test_dashboard_security_and_ratelimit.py | 67 ++ tests/test_db_postgresql.py | 224 ++++++ tests/test_db_webhooks.py | 71 ++ uv.lock | 102 +++ 41 files changed, 3171 insertions(+), 117 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/0001_runtime_baseline.py create mode 100644 dashboard/src/app/audit/page.tsx create mode 100644 docs/features/anti-spam.md create mode 100644 src/commands/group/whois.py create mode 100644 src/commands/utility/summarize.py create mode 100644 src/core/middlewares/anti_spam.py create mode 100644 tests/test_db_postgresql.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..1060730 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +prepend_sys_path = . + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..d2e02c8 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import engine_from_config, pool + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SRC_DIR = PROJECT_ROOT / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = None + + +def _get_database_url() -> str: + from core.db import get_database_url + + return get_database_url() + + +def run_migrations_offline() -> None: + url = _get_database_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section) or {} + configuration["sqlalchemy.url"] = _get_database_url() + + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..8ddc6d9 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from __future__ import annotations + +from alembic import op + +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/0001_runtime_baseline.py b/alembic/versions/0001_runtime_baseline.py new file mode 100644 index 0000000..5752e33 --- /dev/null +++ b/alembic/versions/0001_runtime_baseline.py @@ -0,0 +1,109 @@ +"""runtime baseline + +Revision ID: 0001_runtime_baseline +Revises: +Create Date: 2026-03-16 00:00:00 +""" + +from __future__ import annotations + +from alembic import op + +revision = "0001_runtime_baseline" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + 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) + ) + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS webhooks ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL, + events TEXT NOT NULL, + secret TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + failure_count INTEGER NOT NULL DEFAULT 0, + max_failures INTEGER NOT NULL DEFAULT 10, + last_success_at TEXT, + last_error TEXT, + disabled_reason TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS webhook_deliveries ( + id INTEGER PRIMARY KEY, + webhook_id INTEGER 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, + request_headers TEXT, + created_at TEXT NOT NULL + ) + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS incoming_webhook_keys ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + allowed_actions TEXT NOT NULL, + rate_limit_per_minute INTEGER NOT NULL DEFAULT 30, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_used_at TEXT + ) + """ + ) + + op.execute( + """ + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY, + actor TEXT NOT NULL, + action TEXT NOT NULL, + resource TEXT NOT NULL, + details TEXT, + created_at TEXT NOT NULL + ) + """ + ) + + op.execute( + "CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook ON webhook_deliveries(webhook_id, id DESC)" + ) + op.execute("CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(id DESC)") + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS audit_logs") + op.execute("DROP TABLE IF EXISTS incoming_webhook_keys") + op.execute("DROP TABLE IF EXISTS webhook_deliveries") + op.execute("DROP TABLE IF EXISTS webhooks") + op.execute("DROP TABLE IF EXISTS kv_store") diff --git a/config.schema.json b/config.schema.json index 9d1fb35..eaca75c 100644 --- a/config.schema.json +++ b/config.schema.json @@ -230,6 +230,11 @@ "type": "boolean", "description": "Enable no-code automation rules", "default": true + }, + "anti_spam": { + "type": "boolean", + "description": "Enable anti-spam message flood protection", + "default": false } } }, @@ -474,6 +479,41 @@ "description": "List of command names that are disabled", "default": [] }, + "anti_spam": { + "type": "object", + "description": "Anti-spam flood protection settings", + "properties": { + "max_messages": { + "type": "integer", + "minimum": 2, + "maximum": 50, + "description": "Maximum messages allowed per user within the time window before action is taken", + "default": 5 + }, + "window_seconds": { + "type": "number", + "minimum": 1, + "maximum": 120, + "description": "Sliding time window in seconds for counting messages", + "default": 10 + }, + "action": { + "type": "string", + "enum": [ + "warn", + "mute", + "kick" + ], + "description": "Action to take when spam is detected", + "default": "warn" + }, + "whitelist_admins": { + "type": "boolean", + "description": "Exempt group admins from spam detection", + "default": true + } + } + }, "dashboard": { "type": "object", "description": "Dashboard API and UI settings", diff --git a/dashboard/src/app/audit/page.tsx b/dashboard/src/app/audit/page.tsx new file mode 100644 index 0000000..5ff7562 --- /dev/null +++ b/dashboard/src/app/audit/page.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { api, type AuditLogItem } from "@/lib/api"; +import { useCallback, useEffect, useState } from "react"; + +export default function AuditPage() { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [actionFilter, setActionFilter] = useState(""); + + const fetchLogs = useCallback(async () => { + setLoading(true); + setError(""); + try { + const res = await api.getAuditLogs(200, actionFilter.trim()); + setLogs(res.logs || []); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load audit logs"); + } finally { + setLoading(false); + } + }, [actionFilter]); + + useEffect(() => { + void fetchLogs(); + }, [fetchLogs]); + + return ( +
+
+

Audit Logs

+

+ Timeline of sensitive dashboard and webhook actions. +

+
+ +
+
+ setActionFilter(e.target.value)} + placeholder="Filter by action (e.g. webhook.update)" + className="min-w-[260px] flex-1 rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm" + /> + +
+ + {error ?

{error}

: null} + {loading ?

Loading...

: null} + + {!loading && logs.length === 0 ? ( +

No audit logs found.

+ ) : null} + +
+ {logs.map((log) => ( +
+
+ {log.action} + {log.created_at} +
+

+ actor: {log.actor} • resource: {log.resource} +

+ {Object.keys(log.details || {}).length > 0 ? ( +
+                                    {JSON.stringify(log.details, null, 2)}
+                                
+ ) : null} +
+ ))} +
+
+
+ ); +} diff --git a/dashboard/src/app/webhooks/page.tsx b/dashboard/src/app/webhooks/page.tsx index e985c46..9c2a199 100644 --- a/dashboard/src/app/webhooks/page.tsx +++ b/dashboard/src/app/webhooks/page.tsx @@ -1,8 +1,15 @@ "use client"; -import { api, type WebhookDelivery, type WebhookItem } from "@/lib/api"; +import { + api, + type IncomingWebhookKey, + type WebhookDelivery, + type WebhookItem, +} from "@/lib/api"; import { useEffect, useMemo, useState } from "react"; +const INCOMING_ACTIONS = ["send_message", "emit_event"]; + export default function WebhooksPage() { const [webhooks, setWebhooks] = useState([]); const [availableEvents, setAvailableEvents] = useState([]); @@ -10,6 +17,13 @@ export default function WebhooksPage() { const [name, setName] = useState("Main Webhook"); const [url, setUrl] = useState(""); const [secret, setSecret] = useState(""); + const [maxFailures, setMaxFailures] = useState(10); + + const [incomingKeys, setIncomingKeys] = useState([]); + const [incomingName, setIncomingName] = useState("Incoming Key"); + const [incomingRate, setIncomingRate] = useState(30); + const [incomingActions, setIncomingActions] = useState(["send_message"]); + const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); @@ -26,11 +40,15 @@ export default function WebhooksPage() { setLoading(true); setError(""); try { - const res = await api.getWebhooks(); - setWebhooks(res.webhooks || []); - setAvailableEvents(res.available_events || []); + const [hooksRes, incomingRes] = await Promise.all([ + api.getWebhooks(), + api.getIncomingWebhookKeys(), + ]); + setWebhooks(hooksRes.webhooks || []); + setAvailableEvents(hooksRes.available_events || []); + setIncomingKeys(incomingRes.keys || []); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load webhooks"); + setError(err instanceof Error ? err.message : "Failed to load webhook data"); } finally { setLoading(false); } @@ -46,11 +64,17 @@ export default function WebhooksPage() { ); }; + const toggleIncomingAction = (action: string) => { + setIncomingActions((prev) => + prev.includes(action) ? prev.filter((v) => v !== action) : [...prev, action], + ); + }; + const createWebhook = async () => { setError(""); setSuccess(""); if (!url.trim()) { - setError("URL is required"); + setError("Webhook URL is required"); return; } @@ -61,17 +85,39 @@ export default function WebhooksPage() { events: selectedEvents.length ? selectedEvents : ["*"], secret: secret.trim() || undefined, enabled: true, + max_failures: maxFailures, }); setSuccess(`Webhook created. Secret: ${res.secret}`); setUrl(""); setSecret(""); setSelectedEvents([]); + setMaxFailures(10); await loadWebhooks(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to create webhook"); } }; + const createIncomingKey = async () => { + setError(""); + setSuccess(""); + try { + const res = await api.createIncomingWebhookKey({ + name: incomingName.trim() || "Incoming Key", + allowed_actions: incomingActions.length ? incomingActions : ["send_message"], + rate_limit_per_minute: Math.max(1, incomingRate), + enabled: true, + }); + setSuccess(`Incoming webhook key created. Token: ${res.key.token}`); + setIncomingName("Incoming Key"); + setIncomingRate(30); + setIncomingActions(["send_message"]); + await loadWebhooks(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create incoming key"); + } + }; + const toggleWebhook = async (hook: WebhookItem) => { try { await api.updateWebhook(hook.id, { enabled: !hook.enabled }); @@ -98,6 +144,16 @@ export default function WebhooksPage() { } }; + const rotateWebhookSecret = async (hook: WebhookItem) => { + try { + const res = await api.rotateWebhookSecret(hook.id); + setSuccess(`New secret for ${hook.name}: ${res.secret}`); + await loadWebhooks(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to rotate webhook secret"); + } + }; + const testWebhook = async (hook: WebhookItem) => { try { await api.testWebhook(hook.id); @@ -117,18 +173,59 @@ export default function WebhooksPage() { } }; + const replayDelivery = async (webhookId: number, deliveryId: number) => { + try { + const result = await api.replayWebhookDelivery(webhookId, deliveryId); + setSuccess(result.success ? "Delivery replayed" : "Replay failed"); + await loadDeliveries(webhookId); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to replay delivery"); + } + }; + + const rotateIncomingKey = async (key: IncomingWebhookKey) => { + try { + const res = await api.rotateIncomingWebhookKey(key.id); + setSuccess(`New incoming token: ${res.token}`); + await loadWebhooks(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to rotate incoming key"); + } + }; + + const toggleIncomingKey = async (key: IncomingWebhookKey) => { + try { + await api.updateIncomingWebhookKey(key.id, { enabled: !key.enabled }); + await loadWebhooks(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update incoming key"); + } + }; + + const deleteIncomingKey = async (key: IncomingWebhookKey) => { + if (!confirm(`Delete incoming key \"${key.name}\"?`)) { + return; + } + try { + await api.deleteIncomingWebhookKey(key.id); + await loadWebhooks(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to delete incoming key"); + } + }; + return (

Webhooks

- Send bot events to external services (CI, Discord, Slack, custom apps). + Manage outgoing event webhooks and incoming trigger keys.

-

Create Webhook

-
+

Create Outgoing Webhook

+
setName(e.target.value)} @@ -147,7 +244,15 @@ export default function WebhooksPage() { placeholder="Secret (optional)" className="rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm" /> -
+ setMaxFailures(Math.max(1, Number(e.target.value) || 1))} + placeholder="Max Failures" + className="rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm" + /> +
{selectedLabel}
@@ -175,15 +280,97 @@ export default function WebhooksPage() { onClick={() => void createWebhook()} className="mt-4 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500" > - Create + Create Outgoing Webhook +
+ +
+

Incoming Webhook Keys

+ +
+ setIncomingName(e.target.value)} + placeholder="Key name" + className="rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm" + /> + setIncomingRate(Math.max(1, Number(e.target.value) || 1))} + placeholder="Rate/min" + className="rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm" + /> + +
+ +
+ {INCOMING_ACTIONS.map((actionName) => { + const active = incomingActions.includes(actionName); + return ( + + ); + })} +
- {error ?

{error}

: null} - {success ?

{success}

: null} +
+ {incomingKeys.map((key) => ( +
+
+

{key.name}

+

+ actions: {key.allowed_actions.join(", ")} • rate/min: {key.rate_limit_per_minute} +

+
+
+ + + +
+
+ ))} + {incomingKeys.length === 0 ? ( +

No incoming keys created yet.

+ ) : null} +
-

Configured Endpoints

+

Configured Outgoing Endpoints

{loading ?

Loading...

: null} {!loading && webhooks.length === 0 ? (

No webhooks yet.

@@ -202,8 +389,15 @@ export default function WebhooksPage() {

Events: {hook.events.join(", ") || "*"}

+

+ Failures: {hook.failure_count}/{hook.max_failures} + {hook.disabled_reason ? ` • ${hook.disabled_reason}` : ""} +

+ {hook.last_error ? ( +

Last error: {hook.last_error}

+ ) : null}
-
+
+ +
))}
@@ -258,6 +466,9 @@ export default function WebhooksPage() { ))}
+ + {error ?

{error}

: null} + {success ?

{success}

: null} ); } diff --git a/dashboard/src/components/dashboard-layout.tsx b/dashboard/src/components/dashboard-layout.tsx index 2dec3ef..08740c7 100644 --- a/dashboard/src/components/dashboard-layout.tsx +++ b/dashboard/src/components/dashboard-layout.tsx @@ -31,6 +31,7 @@ const navLinks = [ { label: "Send Message", href: "/send", icon: }, { label: "Configuration", href: "/config", icon: }, { label: "Webhooks", href: "/webhooks", icon: }, + { label: "Audit Logs", href: "/audit", icon: }, { label: "Commands", href: "/commands", icon: }, { label: "Groups", href: "/groups", icon: }, { label: "Notes", href: "/notes", icon: }, diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 4d2e275..154fba4 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -191,6 +191,11 @@ export interface WebhookItem { events: string[]; enabled: boolean; has_secret: boolean; + failure_count: number; + max_failures: number; + last_success_at: string | null; + last_error: string | null; + disabled_reason: string | null; created_at: string; updated_at: string; } @@ -205,6 +210,27 @@ export interface WebhookDelivery { error: string | null; attempt: number; response_body: string | null; + request_headers: Record; + created_at: string; +} + +export interface IncomingWebhookKey { + id: number; + name: string; + allowed_actions: string[]; + rate_limit_per_minute: number; + enabled: boolean; + created_at: string; + updated_at: string; + last_used_at: string | null; +} + +export interface AuditLogItem { + id: number; + actor: string; + action: string; + resource: string; + details: Record; created_at: string; } @@ -551,6 +577,7 @@ export const api = { events: string[]; secret?: string; enabled?: boolean; + max_failures?: number; }) => fetchAPI<{ success: boolean; webhook: WebhookItem; secret: string }>("/api/webhooks", { method: "POST", @@ -564,6 +591,7 @@ export const api = { events?: string[]; secret?: string; enabled?: boolean; + max_failures?: number; }, ) => fetchAPI<{ success: boolean; webhook: WebhookItem }>(`/api/webhooks/${webhookId}`, { @@ -581,10 +609,69 @@ export const api = { method: "POST", }, ), + rotateWebhookSecret: (webhookId: number) => + fetchAPI<{ success: boolean; secret: string }>(`/api/webhooks/${webhookId}/rotate-secret`, { + method: "POST", + }), getWebhookDeliveries: (webhookId: number, limit = 50) => fetchAPI<{ deliveries: WebhookDelivery[]; count: number }>( `/api/webhooks/${webhookId}/deliveries?limit=${limit}`, ), + replayWebhookDelivery: (webhookId: number, deliveryId: number) => + fetchAPI<{ success: boolean; result: { success: boolean; status_code?: number } }>( + `/api/webhooks/${webhookId}/deliveries/${deliveryId}/replay`, + { + method: "POST", + }, + ), + getIncomingWebhookKeys: () => fetchAPI<{ keys: IncomingWebhookKey[]; count: number }>("/api/incoming-webhook-keys"), + createIncomingWebhookKey: (payload: { + name: string; + allowed_actions: string[]; + rate_limit_per_minute: number; + enabled?: boolean; + }) => + fetchAPI<{ success: boolean; key: IncomingWebhookKey & { token: string } }>( + "/api/incoming-webhook-keys", + { + method: "POST", + body: JSON.stringify(payload), + }, + ), + updateIncomingWebhookKey: ( + keyId: number, + payload: { + name?: string; + allowed_actions?: string[]; + rate_limit_per_minute?: number; + enabled?: boolean; + }, + ) => + fetchAPI<{ success: boolean; key: IncomingWebhookKey }>( + `/api/incoming-webhook-keys/${keyId}`, + { + method: "PUT", + body: JSON.stringify(payload), + }, + ), + rotateIncomingWebhookKey: (keyId: number) => + fetchAPI<{ success: boolean; token: string }>(`/api/incoming-webhook-keys/${keyId}/rotate`, { + method: "POST", + }), + deleteIncomingWebhookKey: (keyId: number) => + fetchAPI<{ success: boolean }>(`/api/incoming-webhook-keys/${keyId}`, { + method: "DELETE", + }), + getAuditLogs: (limit = 100, action = "") => + fetchAPI<{ logs: AuditLogItem[]; count: number }>( + `/api/audit-logs?limit=${limit}${action ? `&action=${encodeURIComponent(action)}` : ""}`, + ), + getHealth: () => + fetchAPI<{ + status: string; + database: { ok: boolean; url: string; error?: string | null }; + webhooks: { running: boolean; queue_size: number }; + }>("/api/health"), getTopCommands: (days = 7, groupId?: string) => { const query = new URLSearchParams({ days: days.toString() }); diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 7b19e2b..cf7ffcd 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -32,6 +32,7 @@ export default defineConfig({ { text: 'Internationalization', link: '/features/i18n' }, { text: 'Web Dashboard', link: '/features/dashboard' }, { text: 'Webhooks', link: '/features/webhooks' }, + { text: 'Anti-Spam', link: '/features/anti-spam' }, ], }, { @@ -76,6 +77,7 @@ export default defineConfig({ { text: 'Internationalization', link: '/features/i18n' }, { text: 'Web Dashboard', link: '/features/dashboard' }, { text: 'Webhooks', link: '/features/webhooks' }, + { text: 'Anti-Spam', link: '/features/anti-spam' }, ], }, { diff --git a/docs/commands/fun.md b/docs/commands/fun.md index c8c106f..78e52be 100644 --- a/docs/commands/fun.md +++ b/docs/commands/fun.md @@ -4,20 +4,26 @@ Lighthearted commands for entertainment. ## /joke -Get a random joke. +Get a random joke. Fetches from [JokeAPI](https://v2.jokeapi.dev/) with a built-in static fallback if the API is unavailable. ``` /joke ``` +::: tip +Jokes are filtered to exclude NSFW, racist, and sexist content. +::: + ## /quote -Get a random inspirational quote. +Get a random inspirational quote. Fetches from [Quotable API](https://api.quotable.io/) with a static fallback. ``` /quote ``` +**Aliases:** `inspire`, `motivation` + ## /8ball Ask the Magic 8-Ball a yes/no question. @@ -61,8 +67,10 @@ Roll dice using standard dice notation. ## /fact -Get a random fun fact. +Get a random fun fact. Fetches from [Useless Facts API](https://uselessfacts.jsph.pl/) with a static fallback. ``` /fact ``` + +**Aliases:** `funfact`, `didyouknow` diff --git a/docs/commands/group.md b/docs/commands/group.md index 651806a..96aa1d6 100644 --- a/docs/commands/group.md +++ b/docs/commands/group.md @@ -125,3 +125,26 @@ Configure goodbye messages for leaving members. ``` Same placeholders as `/welcome`. + +## /whois + +Show information about a group member: user ID, JID, admin status, and warning count. + +``` +/whois # Reply to a message +/whois @user # Mention a user +``` + +**Aliases:** `userinfo` + +**Output includes:** +| Field | Description | +|-------|-------------| +| User ID | WhatsApp user identifier | +| JID | Full Jabber ID | +| Role | Member, Admin, or Super Admin | +| Warnings | Current warning count | + +::: info +This command is group-only and available to all members. +::: diff --git a/docs/commands/moderation.md b/docs/commands/moderation.md index 1000d9b..8e0ddc3 100644 --- a/docs/commands/moderation.md +++ b/docs/commands/moderation.md @@ -84,21 +84,37 @@ Admins can inspect and act from private chat using report ID: ## /automation -No-code moderation automation rules. +No-code moderation automation rules. Trigger on message content, exact text, media type, or links. ``` /automation list -/automation add => [response] +/automation add => [response] /automation toggle /automation remove ``` -**Example:** +**Trigger types:** + +| Type | Description | Example trigger value | +|------|-------------|----------------------| +| `contains` | Text contains substring | `discord.gg` | +| `starts_with` | Text starts with prefix | `!promo` | +| `exact_match` | Text matches exactly (case-insensitive) | `hello` | +| `regex` | Text matches regex pattern | `(?i)free\s+money` | +| `link` | Message contains any URL | _(no value needed)_ | +| `media_type` | Message is a specific media type | `image`, `video`, `audio`, `sticker`, `document` | + +**Actions:** `reply`, `warn`, `delete`, `kick`, `mute` + +**Examples:** ``` /automation add contains discord.gg => warn +/automation add starts_with !promo => delete +/automation add exact_match hello => reply Welcome! /automation add regex (?i)free\s+money => delete /automation add link x.com => reply External links are reviewed by admins. +/automation add media_type sticker => delete ``` ## /blacklist diff --git a/docs/commands/utility.md b/docs/commands/utility.md index 97728e4..a505183 100644 --- a/docs/commands/utility.md +++ b/docs/commands/utility.md @@ -112,3 +112,21 @@ Configure periodic group summaries (daily/weekly), or send one immediately. /digest on weekly sun 20:00 /digest now ``` + +## /summarize + +AI-powered text summarization. Requires the [Agentic AI](/features/ai) feature to be enabled with a valid API key. + +``` +/summarize # Summarize recent chat context +``` + +**Usage:** +- **Reply to a message**: summarizes the quoted text +- **No reply**: summarizes the last ~10 messages from AI memory + +**Aliases:** `tldr` + +::: info +This command has a 30-second cooldown to prevent abuse. +::: diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 67a38bc..57cdefa 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -158,6 +158,15 @@ Other runtime modules (`scheduler`, `analytics`, `token_tracker`, `afk`, `i18n` - HMAC signature headers - retry/backoff on failures - delivery logs stored in DB (`webhook_deliveries`) +- auto-disable after configurable failure threshold + +Incoming webhooks are exposed via dashboard API endpoint `POST /api/incoming-webhook/{token}` with: + +- HMAC signature validation +- per-key allowed actions +- per-key rate limits + +Audit entries for sensitive operations are stored in `audit_logs` and surfaced in dashboard. ### JID Resolver diff --git a/docs/development/contributing.md b/docs/development/contributing.md index 37e7d81..727e5b3 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -54,6 +54,20 @@ See [Architecture](/development/architecture) for a full breakdown. - Describe what you changed and why. - If you added a feature, include a screenshot or usage example. +## Database Migrations (Alembic) + +Runtime persistence uses SQLAlchemy with Alembic migration support. + +```bash +# Apply migrations +uv run alembic upgrade head + +# Create a new migration +uv run alembic revision -m "your migration message" +``` + +Default database is SQLite (`data/zeroichi.db`) unless `DATABASE_URL` is set. + ## Adding a Command Create a new file in the appropriate `src/commands//` directory: diff --git a/docs/features/anti-spam.md b/docs/features/anti-spam.md new file mode 100644 index 0000000..feb25f3 --- /dev/null +++ b/docs/features/anti-spam.md @@ -0,0 +1,70 @@ +# Anti-Spam + +Automatic flood protection that detects and acts on users sending messages too quickly in groups. + +## Overview + +The anti-spam system uses a sliding time window to track message frequency per user. When a user exceeds the configured threshold, the bot takes an automatic action (warn, mute, or kick). + +## Enable / Disable + +Anti-spam is controlled by the `anti_spam` feature flag, which is **off by default**. + +Toggle it with the existing config command: + +``` +/config toggle anti_spam +``` + +## Configuration + +All settings live in the `anti_spam` section of `config.json`: + +```jsonc +{ + "anti_spam": { + "max_messages": 5, // Messages allowed per window + "window_seconds": 10, // Sliding window duration + "action": "warn", // "warn", "mute", or "kick" + "whitelist_admins": true // Exempt group admins + } +} +``` + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `max_messages` | integer | `5` | Maximum messages a user can send within the time window before action triggers | +| `window_seconds` | number | `10` | Length of the sliding time window in seconds | +| `action` | string | `"warn"` | Action to take: `warn` (send warning), `mute` (mute + delete), `kick` (remove from group) | +| `whitelist_admins` | boolean | `true` | When enabled, group admins are exempt from spam detection | + +You can also edit these values with: + +``` +/config set anti_spam.max_messages 8 +/config set anti_spam.window_seconds 15 +/config set anti_spam.action mute +``` + +## How It Works + +1. Every incoming group message records a timestamp for the sender +2. Timestamps older than `window_seconds` are discarded +3. If the number of timestamps exceeds `max_messages`, the configured action fires +4. After an action triggers, the sender's window is reset to avoid repeated triggers + +## Actions + +| Action | Behavior | +|--------|----------| +| `warn` | Sends a public warning message mentioning the user | +| `mute` | Adds user to the mute list, deletes the spam message, and notifies the group | +| `kick` | Deletes the message and removes the user from the group | + +::: tip +Start with `warn` to see how it affects your group before escalating to `mute` or `kick`. +::: + +## Pipeline Position + +Anti-spam runs in the middleware pipeline after **mute** and before **features**, so muted users are already filtered out before spam checking occurs. diff --git a/docs/features/dashboard.md b/docs/features/dashboard.md index 7af10aa..5dc69e9 100644 --- a/docs/features/dashboard.md +++ b/docs/features/dashboard.md @@ -46,6 +46,8 @@ Open `http://localhost:3000` in your browser. - **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 +- **Incoming Keys** — create signed incoming webhook keys with allowed actions and rate limits +- **Audit Logs** — inspect security-sensitive config/webhook changes over time - **Statistics** — message counts, command usage, and more ## Security Notes @@ -71,7 +73,17 @@ The dashboard communicates with the bot through a REST API: | `PUT /api/webhooks/{id}` | Update webhook endpoint | | `DELETE /api/webhooks/{id}` | Delete webhook endpoint | | `POST /api/webhooks/{id}/test` | Send test event to endpoint | +| `POST /api/webhooks/{id}/rotate-secret` | Rotate webhook secret | | `GET /api/webhooks/{id}/deliveries` | Recent delivery attempts | +| `POST /api/webhooks/{id}/deliveries/{delivery_id}/replay` | Replay one delivery | +| `GET /api/incoming-webhook-keys` | List incoming webhook keys | +| `POST /api/incoming-webhook-keys` | Create incoming webhook key | +| `PUT /api/incoming-webhook-keys/{id}` | Update incoming webhook key | +| `POST /api/incoming-webhook-keys/{id}/rotate` | Rotate incoming key token | +| `DELETE /api/incoming-webhook-keys/{id}` | Delete incoming webhook key | +| `POST /api/incoming-webhook/{token}` | Execute incoming webhook action | +| `GET /api/audit-logs` | Audit timeline entries | +| `GET /api/health` | API + DB + webhook worker health | | `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 index 0adb1e3..86b4a8f 100644 --- a/docs/features/webhooks.md +++ b/docs/features/webhooks.md @@ -1,16 +1,33 @@ # Webhooks -Zero Ichi can push bot and dashboard events to external services via HTTP webhooks. +Zero Ichi has two webhook directions: -## Where to Configure +- **Outgoing webhooks**: Zero Ichi sends event payloads to your endpoint +- **Incoming webhooks**: your system calls Zero Ichi to execute allowed actions -Use the dashboard page: +## Quick View -- `Dashboard -> Webhooks` +| Type | Direction | Auth | Where to manage | +|------|-----------|------|-----------------| +| Outgoing | Zero Ichi -> your endpoint | HMAC signature (`X-ZeroIchi-Signature`) | `Dashboard -> Webhooks` | +| Incoming | your system -> Zero Ichi | token + HMAC + idempotency key | `Dashboard -> Webhooks` | -Webhooks are stored in the runtime database (`SQLite` by default, or PostgreSQL when `DATABASE_URL` is set). +Webhooks are stored in the runtime database (SQLite by default, PostgreSQL if `DATABASE_URL` is set). -## Supported Events +## Outgoing Webhooks + +### 1) Create an outgoing webhook + +In `Dashboard -> Webhooks`, define: + +- `name`: label used in dashboard +- `url`: destination endpoint (HTTPS recommended) +- `events`: list of events or `*` for all events +- `secret`: shared secret for HMAC verification +- `max_failures`: consecutive failure limit before auto-disable +- `enabled`: on/off toggle + +### 2) Subscribe to events Current event names include: @@ -25,11 +42,9 @@ Current event names include: - `automation_update` - `automation_triggered` -You can subscribe to specific events or use `*` to receive all. - -## Payload Format +### 3) Handle payloads on your endpoint -Each delivery sends JSON: +Example payload: ```json { @@ -43,9 +58,7 @@ Each delivery sends JSON: } ``` -## Security Headers - -Every request includes: +Every outgoing request includes: - `X-ZeroIchi-Event` - `X-ZeroIchi-Timestamp` @@ -63,10 +76,218 @@ HMAC input string: . ``` -Use your webhook secret to verify authenticity. +### 4) Verify signature (example) + +```python +import hashlib +import hmac + +def verify(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool: + digest = hmac.new(secret.encode("utf-8"), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest() + expected = f"sha256={digest}" + return hmac.compare_digest(expected, signature) +``` + +## Incoming Webhooks + +Incoming webhooks let external systems trigger safe, scoped actions in Zero Ichi. + +### 1) Create incoming key + +In `Dashboard -> Webhooks`, create an incoming key with: + +- `name` +- `allowed_actions` (for example `send_message`, `emit_event`) +- `rate_limit_per_minute` +- `enabled` + +You will get a token. Store it securely. + +### 2) Call incoming endpoint + +Endpoint: + +```text +POST /api/incoming-webhook/{token} +``` + +If your API is local default, full URL is: + +```text +http://localhost:8000/api/incoming-webhook/{token} +``` + +Required headers: + +- `X-ZeroIchi-Incoming-Timestamp` (unix seconds) +- `X-ZeroIchi-Incoming-Signature` (`sha256=`) +- `X-ZeroIchi-Incoming-Idempotency-Key` (unique per request) + +Signature HMAC input: + +```text +. +``` + +### 3) Send payload + +`send_message` example: + +```json +{ + "action": "send_message", + "data": { + "to": "1234567890@s.whatsapp.net", + "text": "Deploy complete" + } +} +``` + +`emit_event` example: + +```json +{ + "action": "emit_event", + "data": { + "event_type": "ci_pipeline_done", + "event_data": { + "project": "zero-ichi", + "status": "success" + } + } +} +``` + +### 4) Generate incoming signature and send (Python example) + +```python +import hashlib +import hmac +import json +import time +import uuid + +token = "" +timestamp = str(int(time.time())) +payload = { + "action": "emit_event", + "data": {"event_type": "ci_pipeline_done", "event_data": {"status": "success"}}, +} +raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") +digest = hmac.new(token.encode("utf-8"), f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest() +signature = f"sha256={digest}" +idempotency_key = str(uuid.uuid4()) +``` + +Use `timestamp`, `signature`, and `idempotency_key` in request headers. + +### 5) Reusable Python helper (recommended) + +Use this helper to generate signed headers once, then send with either stdlib (`urllib`) or `httpx`. + +```python +import hashlib +import hmac +import json +import time +import uuid + + +def build_incoming_request(token: str, payload: dict) -> tuple[str, bytes, dict[str, str]]: + timestamp = str(int(time.time())) + idempotency_key = str(uuid.uuid4()) + raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") + digest = hmac.new(token.encode("utf-8"), f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest() + signature = f"sha256={digest}" + + headers = { + "Content-Type": "application/json", + "X-ZeroIchi-Incoming-Timestamp": timestamp, + "X-ZeroIchi-Incoming-Signature": signature, + "X-ZeroIchi-Incoming-Idempotency-Key": idempotency_key, + } + return timestamp, raw, headers + + +token = "" +payload = { + "action": "emit_event", + "data": {"event_type": "ci_pipeline_done", "event_data": {"status": "success"}}, +} +_, raw, headers = build_incoming_request(token, payload) +url = f"http://localhost:8000/api/incoming-webhook/{token}" +``` + +### 6) Send using pure Python stdlib (`urllib`) + +```python +import urllib.request + +req = urllib.request.Request(url, data=raw, method="POST", headers=headers) +with urllib.request.urlopen(req, timeout=10) as resp: + print(resp.status) + print(resp.read().decode("utf-8")) +``` + +### 7) Send using `httpx` + +```python +import httpx + +response = httpx.post(url, content=raw, headers=headers, timeout=10) +print(response.status_code) +print(response.text) +``` + +Expected success response shape: + +```json +{"success": true, "action": "emit_event", "event_type": "ci_pipeline_done"} +``` + +## Validation and Error Codes + +Incoming requests are checked in this order: + +1. key exists and is enabled +2. signature + timestamp drift window +3. idempotency key is present and not reused +4. per-key rate limit +5. action is allowed for that key + +Common responses: + +| Status | Meaning | +|--------|---------| +| `200` | action accepted and executed | +| `400` | invalid JSON/payload or missing required header | +| `401` | invalid key or invalid signature | +| `403` | action not allowed for this key | +| `409` | duplicate idempotency key | +| `429` | key rate limit exceeded | +| `503` | bot not connected (for `send_message`) | + +## Reliability and Operations + +Outgoing webhooks include: + +- async dispatch queue (non-blocking) +- retry with exponential backoff +- delivery logs per webhook +- auto-disable after repeated failures (`max_failures`) +- rotate secret and replay selected deliveries from dashboard + +Useful endpoints: + +- `GET /api/webhooks/{id}/deliveries` +- `POST /api/webhooks/{id}/deliveries/{delivery_id}/replay` +- `GET /api/health` (authenticated API, DB, and webhook worker health) +- `GET /healthz` (public liveness) -## Delivery Behavior +## Troubleshooting -- Async queue worker (non-blocking for message pipeline) -- Retry with exponential backoff -- Delivery attempts are logged and visible in dashboard +- `401 Invalid signature`: token/secret mismatch, wrong body bytes, or stale timestamp. +- `409 Duplicate idempotency key`: reuse detected; send a new unique key. +- `429 Incoming webhook rate limit exceeded`: increase limit for that key or reduce caller burst. +- Outgoing webhook disabled unexpectedly: review failure history and `max_failures`, then re-enable after fixing endpoint. +- No deliveries visible: verify webhook is enabled and event subscription matches emitted event names. diff --git a/pyproject.toml b/pyproject.toml index 683c63b..52bab3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ zero-ichi = "main:main" [tool.uv] -dev-dependencies = ["ruff>=0.9.0", "pytest>=8.4.0", "pytest-asyncio>=1.1.0"] +dev-dependencies = ["ruff>=0.9.0", "pytest>=8.4.0", "pytest-asyncio>=1.1.0", "alembic>=1.14.0"] [tool.hatch.build.targets.wheel] packages = [ diff --git a/src/commands/fun/fact.py b/src/commands/fun/fact.py index eb72d4e..cc84bae 100644 --- a/src/commands/fun/fact.py +++ b/src/commands/fun/fact.py @@ -1,14 +1,16 @@ """ -Fact command - Random fun facts. +Fact command - Random fun facts with live API fetch + static fallback. """ import random +import httpx + from core import symbols as sym from core.command import Command, CommandContext from core.i18n import t -FACTS = [ +_FALLBACK_FACTS = [ "Honey never spoils. Archaeologists have found 3000-year-old honey in Egyptian tombs that was still edible.", "Octopuses have three hearts and blue blood.", "A group of flamingos is called a 'flamboyance'.", @@ -36,6 +38,23 @@ "The longest hiccuping spree lasted 68 years.", ] +_FACT_API = "https://uselessfacts.jsph.pl/random.json?language=en" + + +async def _fetch_live_fact() -> str | None: + """Fetch a random fact from Useless Facts API. Returns fact text or None.""" + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(_FACT_API) + resp.raise_for_status() + data = resp.json() + text = data.get("text", "").strip() + if text: + return text + except Exception: + pass + return None + class FactCommand(Command): name = "fact" @@ -45,8 +64,10 @@ class FactCommand(Command): category = "fun" async def execute(self, ctx: CommandContext) -> None: - """Send a random fun fact.""" - fact = random.choice(FACTS) + """Send a random fun fact (live API with static fallback).""" + fact = await _fetch_live_fact() + if fact is None: + fact = random.choice(_FALLBACK_FACTS) await ctx.client.reply( ctx.message, f"{sym.SEARCH} *{t('fact.title')}*\n\n{sym.ARROW} {fact}" diff --git a/src/commands/fun/joke.py b/src/commands/fun/joke.py index 6fe9867..3c924cf 100644 --- a/src/commands/fun/joke.py +++ b/src/commands/fun/joke.py @@ -1,13 +1,16 @@ """ -Joke command - Get a random joke. +Joke command - Get a random joke with live API fetch + static fallback. """ import random +import httpx + from core import symbols as sym from core.command import Command, CommandContext +from core.i18n import t -JOKES = [ +_FALLBACK_JOKES = [ ("Why don't scientists trust atoms?", "Because they make up everything!"), ("Why did the scarecrow win an award?", "He was outstanding in his field!"), ("I told my wife she was drawing her eyebrows too high.", "She looked surprised."), @@ -30,6 +33,22 @@ ("Why did the tomato turn red?", "Because it saw the salad dressing!"), ] +_JOKE_API = "https://v2.jokeapi.dev/joke/Any?blacklistFlags=nsfw,racist,sexist&type=twopart" + + +async def _fetch_live_joke() -> tuple[str, str] | None: + """Fetch a joke from JokeAPI. Returns (setup, punchline) or None on failure.""" + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(_JOKE_API) + resp.raise_for_status() + data = resp.json() + if data.get("type") == "twopart": + return data["setup"], data["delivery"] + except Exception: + pass + return None + class JokeCommand(Command): name = "joke" @@ -38,7 +57,14 @@ class JokeCommand(Command): category = "fun" async def execute(self, ctx: CommandContext) -> None: - """Send a random joke.""" - setup, punchline = random.choice(JOKES) + """Send a random joke (live API with static fallback).""" + result = await _fetch_live_joke() + if result is None: + result = random.choice(_FALLBACK_JOKES) - await ctx.client.reply(ctx.message, f"{sym.SPARKLE} *{setup}*\n\n{sym.ARROW} _{punchline}_") + setup, punchline = result + await ctx.client.reply( + ctx.message, + f"{sym.SPARKLE} *{t('joke.title')}*\n\n" + f"{sym.ARROW} *{setup}*\n\n{sym.DIAMOND} _{punchline}_", + ) diff --git a/src/commands/fun/quote.py b/src/commands/fun/quote.py index 6107c64..0b068f8 100644 --- a/src/commands/fun/quote.py +++ b/src/commands/fun/quote.py @@ -1,13 +1,16 @@ """ -Quote command - Get an inspirational quote. +Quote command - Get an inspirational quote with live API fetch + static fallback. """ import random +import httpx + from core import symbols as sym from core.command import Command, CommandContext +from core.i18n import t -QUOTES = [ +_FALLBACK_QUOTES = [ ("The only way to do great work is to love what you do.", "Steve Jobs"), ("Innovation distinguishes between a leader and a follower.", "Steve Jobs"), ("Stay hungry, stay foolish.", "Steve Jobs"), @@ -42,6 +45,28 @@ ), ] +_QUOTE_API = "https://api.quotable.io/quotes/random" + + +async def _fetch_live_quote() -> tuple[str, str] | None: + """Fetch a quote from Quotable API. Returns (content, author) or None.""" + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get(_QUOTE_API) + resp.raise_for_status() + data = resp.json() + if isinstance(data, list) and data: + entry = data[0] + else: + entry = data + content = entry.get("content", "").strip() + author = entry.get("author", "").strip() + if content and author: + return content, author + except Exception: + pass + return None + class QuoteCommand(Command): name = "quote" @@ -51,7 +76,13 @@ class QuoteCommand(Command): category = "fun" async def execute(self, ctx: CommandContext) -> None: - """Send a random inspirational quote.""" - quote, author = random.choice(QUOTES) + """Send a random inspirational quote (live API with static fallback).""" + result = await _fetch_live_quote() + if result is None: + result = random.choice(_FALLBACK_QUOTES) - await ctx.client.reply(ctx.message, f"{sym.QUOTE} _{quote}_\n\n{sym.DIAMOND} *{author}*") + quote, author = result + await ctx.client.reply( + ctx.message, + f"{sym.QUOTE} *{t('quote.title')}*\n\n_{quote}_\n\n{sym.DIAMOND} *{author}*", + ) diff --git a/src/commands/group/whois.py b/src/commands/group/whois.py new file mode 100644 index 0000000..79d3018 --- /dev/null +++ b/src/commands/group/whois.py @@ -0,0 +1,77 @@ +""" +Whois command - Show information about a group member. +""" + +from __future__ import annotations + +from core import symbols as sym +from core.command import Command, CommandContext +from core.i18n import t, t_error +from core.storage import GroupData + + +class WhoisCommand(Command): + name = "whois" + aliases = ["userinfo"] + description = "Show info about a group member" + usage = "whois (reply to a message) | whois @user" + category = "group" + group_only = True + + async def execute(self, ctx: CommandContext) -> None: + """Show user info: name, JID, admin status, warn count.""" + target_jid = "" + + quoted = ctx.message.quoted_message + if quoted and quoted.get("sender"): + target_jid = quoted["sender"] + + if not target_jid and ctx.message.mentions: + target_jid = ctx.message.mentions[0] + + if not target_jid: + await ctx.client.reply(ctx.message, t_error("errors.no_target")) + return + + target_user = target_jid.split("@")[0].split(":")[0] + + is_admin = False + is_superadmin = False + + try: + group_info = await ctx.client.raw.get_group_info( + ctx.client.to_jid(ctx.message.chat_jid) + ) + for participant in group_info.Participants: + if participant.JID.User == target_user: + is_admin = participant.IsAdmin + is_superadmin = participant.IsSuperAdmin + break + except Exception: + pass + + warnings_data = GroupData(ctx.message.chat_jid).warnings + warn_count = 0 + if isinstance(warnings_data, dict): + user_warns = warnings_data.get(target_user, {}) + if isinstance(user_warns, dict): + warn_count = user_warns.get("count", 0) + elif isinstance(user_warns, (int, float)): + warn_count = int(user_warns) + + if is_superadmin: + role = t("whois.superadmin") + elif is_admin: + role = t("whois.admin") + else: + role = t("whois.member") + + lines = [ + sym.status_line(t("whois.user_id"), f"`{target_user}`"), + sym.status_line(t("whois.jid"), f"`{target_jid}`"), + sym.status_line(t("whois.role"), role), + sym.status_line(t("whois.warnings"), f"`{warn_count}`"), + ] + + output = sym.box(t("whois.title"), lines) + await ctx.client.reply(ctx.message, output) diff --git a/src/commands/moderation/automation.py b/src/commands/moderation/automation.py index a815967..1d4d858 100644 --- a/src/commands/moderation/automation.py +++ b/src/commands/moderation/automation.py @@ -91,7 +91,7 @@ async def _add_rule(self, ctx: CommandContext) -> None: action_type = right_parts[0].lower() action_value = right_parts[1].strip() if len(right_parts) > 1 else "" - valid_trigger = {"contains", "regex", "link"} + valid_trigger = {"contains", "starts_with", "exact_match", "regex", "link", "media_type"} valid_action = {"reply", "warn", "delete", "kick", "mute"} if trigger_type not in valid_trigger: await ctx.client.reply(ctx.message, t_error("automation.invalid_trigger")) @@ -99,7 +99,7 @@ async def _add_rule(self, ctx: CommandContext) -> None: if action_type not in valid_action: await ctx.client.reply(ctx.message, t_error("automation.invalid_action")) return - if trigger_type != "link" and not trigger_value: + if trigger_type not in {"link"} and not trigger_value: await ctx.client.reply(ctx.message, t_error("automation.missing_trigger_value")) return diff --git a/src/commands/utility/summarize.py b/src/commands/utility/summarize.py new file mode 100644 index 0000000..98fc4f6 --- /dev/null +++ b/src/commands/utility/summarize.py @@ -0,0 +1,113 @@ +""" +Summarize command - AI-powered text summarization. + +Summarizes a quoted message or recent chat context using the AI agent. +""" + +from __future__ import annotations + +import os + +from pydantic_ai import Agent + +from core import symbols as sym +from core.command import Command, CommandContext +from core.i18n import t, t_error +from core.runtime_config import runtime_config + +_SUMMARIZE_PROMPT = """You are a concise summarizer. Summarize the following text in 2-4 bullet points. +Be clear, factual, and brief. Use plain language. Do not add opinions. + +Text to summarize: +{text}""" + + +def _get_ai_model() -> str: + """Build the model string from config.""" + provider = runtime_config.get_nested("agentic_ai", "provider", default="openai") + model = runtime_config.get_nested("agentic_ai", "model", default="gpt-5-mini") + return f"{provider}:{model}" + + +def _get_api_key() -> str: + """Get AI API key from env or config.""" + env_key = os.getenv("AI_API_KEY", "") + if env_key: + return env_key + return runtime_config.get_nested("agentic_ai", "api_key", default="") + + +class SummarizeCommand(Command): + name = "summarize" + aliases = ["tldr"] + description = "Summarize a message or recent chat context using AI" + usage = "summarize (reply to a message) | summarize" + category = "utility" + cooldown = 30 + + async def execute(self, ctx: CommandContext) -> None: + """Summarize quoted text or recent chat memory.""" + ai_enabled = runtime_config.get_nested("agentic_ai", "enabled", default=False) + if not ai_enabled: + await ctx.client.reply(ctx.message, t_error("summarize.ai_disabled")) + return + + api_key = _get_api_key() + if not api_key: + await ctx.client.reply(ctx.message, t_error("summarize.no_api_key")) + return + + text_to_summarize = "" + quoted = ctx.message.quoted_message + if quoted and quoted.get("text"): + text_to_summarize = quoted["text"] + else: + from ai.memory import get_memory + + memory = get_memory(ctx.message.chat_jid) + history = memory.get_history(limit=10) + if history: + lines = [] + for entry in history: + prefix = entry.sender_name or entry.role + lines.append(f"[{prefix}]: {entry.content}") + text_to_summarize = "\n".join(lines) + + if not text_to_summarize.strip(): + await ctx.client.reply(ctx.message, t_error("summarize.no_content")) + return + + progress_msg = await ctx.client.reply( + ctx.message, f"{sym.LOADING} {t('summarize.processing')}" + ) + + try: + model_str = _get_ai_model() + provider = runtime_config.get_nested("agentic_ai", "provider", default="openai") + + if provider == "openai": + os.environ["OPENAI_API_KEY"] = api_key + elif provider == "anthropic": + os.environ["ANTHROPIC_API_KEY"] = api_key + elif provider == "google": + os.environ["GOOGLE_API_KEY"] = api_key + os.environ["GEMINI_API_KEY"] = api_key + + agent = Agent(model_str, output_type=str) + prompt = _SUMMARIZE_PROMPT.format(text=text_to_summarize[:3000]) + result = await agent.run(prompt) + summary = result.output.strip() if result.output else "" + + if not summary: + await ctx.client.edit_message( + ctx.message.chat_jid, progress_msg.ID, t_error("summarize.failed") + ) + return + + output = sym.box(t("summarize.title"), [summary]) + await ctx.client.edit_message(ctx.message.chat_jid, progress_msg.ID, output) + + except Exception: + await ctx.client.edit_message( + ctx.message.chat_jid, progress_msg.ID, t_error("summarize.failed") + ) diff --git a/src/core/automations.py b/src/core/automations.py index 8b6b035..0b55985 100644 --- a/src/core/automations.py +++ b/src/core/automations.py @@ -49,16 +49,29 @@ def next_rule_id(rules: list[dict[str, Any]]) -> str: return f"A{max_idx + 1:03d}" -def rule_matches(rule: dict[str, Any], text: str) -> bool: - """Evaluate if a rule matches text.""" +def rule_matches(rule: dict[str, Any], text: str, media_type: str | None = None) -> bool: + """Evaluate if a rule matches text (or media type for media_type triggers).""" trigger_type = str(rule.get("trigger_type", "contains")).lower() trigger_value = str(rule.get("trigger_value", "")) + + if trigger_type == "media_type": + if not trigger_value or not media_type: + return False + return media_type.lower() == trigger_value.lower() + if not trigger_value and trigger_type != "link": return False + if not text: + return False + lower_text = text.lower() if trigger_type == "contains": return trigger_value.lower() in lower_text + if trigger_type == "exact_match": + return lower_text.strip() == trigger_value.lower().strip() + if trigger_type == "starts_with": + return lower_text.startswith(trigger_value.lower()) if trigger_type == "regex": try: return re.search(trigger_value, text, re.IGNORECASE) is not None diff --git a/src/core/db.py b/src/core/db.py index 2dac6bd..00eeec6 100644 --- a/src/core/db.py +++ b/src/core/db.py @@ -10,15 +10,19 @@ from __future__ import annotations +import hashlib import json import os +import secrets import threading +import time from datetime import UTC, datetime from pathlib import Path from typing import Any -from sqlalchemy import create_engine, text +from sqlalchemy import create_engine, inspect, text from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError from core.constants import DATA_DIR, LOCALES_DIR, MEMORY_DIR, TASKS_FILE @@ -69,6 +73,21 @@ def get_engine() -> Engine: return _engine +def _ensure_column(engine: Engine, table_name: str, column_name: str, column_sql: str) -> None: + """Add a column if it does not exist.""" + inspector = inspect(engine) + table_names = set(inspector.get_table_names()) + if table_name not in table_names: + return + + existing = {col["name"] for col in inspector.get_columns(table_name)} + if column_name in existing: + return + + with engine.begin() as conn: + conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_sql}")) + + def _ensure_tables(engine: Engine) -> None: """Create required runtime tables if they do not exist.""" dialect = engine.dialect.name @@ -102,6 +121,11 @@ def _ensure_tables(engine: Engine) -> None: events TEXT NOT NULL, secret TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, + failure_count INTEGER NOT NULL DEFAULT 0, + max_failures INTEGER NOT NULL DEFAULT 10, + last_success_at TEXT, + last_error TEXT, + disabled_reason TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) @@ -122,6 +146,7 @@ def _ensure_tables(engine: Engine) -> None: error TEXT, attempt INTEGER NOT NULL, response_body TEXT, + request_headers TEXT, created_at TEXT NOT NULL, FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE ) @@ -135,6 +160,50 @@ def _ensure_tables(engine: Engine) -> None: ) ) + conn.execute( + text( + f""" + CREATE TABLE IF NOT EXISTS incoming_webhook_keys ( + id {id_column}, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + allowed_actions TEXT NOT NULL, + rate_limit_per_minute INTEGER NOT NULL DEFAULT 30, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_used_at TEXT + ) + """ + ) + ) + + conn.execute( + text( + f""" + CREATE TABLE IF NOT EXISTS audit_logs ( + id {id_column}, + actor TEXT NOT NULL, + action TEXT NOT NULL, + resource TEXT NOT NULL, + details TEXT, + created_at TEXT NOT NULL + ) + """ + ) + ) + + conn.execute( + text("CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(id DESC)") + ) + + _ensure_column(engine, "webhooks", "failure_count", "failure_count INTEGER NOT NULL DEFAULT 0") + _ensure_column(engine, "webhooks", "max_failures", "max_failures INTEGER NOT NULL DEFAULT 10") + _ensure_column(engine, "webhooks", "last_success_at", "last_success_at TEXT") + _ensure_column(engine, "webhooks", "last_error", "last_error TEXT") + _ensure_column(engine, "webhooks", "disabled_reason", "disabled_reason TEXT") + _ensure_column(engine, "webhook_deliveries", "request_headers", "request_headers TEXT") + def _safe_jid(jid: str) -> str: return jid.replace(":", "_").replace("@", "_") @@ -363,9 +432,11 @@ 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" + "SELECT id, name, url, events, secret, enabled, failure_count, max_failures, " + "last_success_at, last_error, disabled_reason, 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" + else "SELECT id, name, url, events, secret, enabled, failure_count, max_failures, " + "last_success_at, last_error, disabled_reason, created_at, updated_at FROM webhooks WHERE enabled = 1" ) query += " ORDER BY id DESC" @@ -387,8 +458,13 @@ def list_webhooks(include_disabled: bool = True) -> list[dict[str, Any]]: "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]), + "failure_count": int(row[6]) if row[6] is not None else 0, + "max_failures": int(row[7]) if row[7] is not None else 10, + "last_success_at": str(row[8]) if row[8] is not None else None, + "last_error": str(row[9]) if row[9] is not None else None, + "disabled_reason": str(row[10]) if row[10] is not None else None, + "created_at": str(row[11]), + "updated_at": str(row[12]), } ) return hooks @@ -409,6 +485,7 @@ def create_webhook( events: list[str], secret: str, enabled: bool, + max_failures: int = 10, ) -> dict[str, Any]: """Create a webhook and return persisted object.""" ensure_database_ready() @@ -422,6 +499,7 @@ def create_webhook( "events": json.dumps(normalized_events, ensure_ascii=False), "secret": secret, "enabled": 1 if enabled else 0, + "max_failures": max(1, int(max_failures)), "created_at": now, "updated_at": now, } @@ -430,8 +508,12 @@ def create_webhook( 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) + INSERT INTO webhooks( + name, url, events, secret, enabled, max_failures, created_at, updated_at + ) + VALUES ( + :name, :url, :events, :secret, :enabled, :max_failures, :created_at, :updated_at + ) RETURNING id """ ), @@ -442,8 +524,12 @@ def create_webhook( 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) + INSERT INTO webhooks( + name, url, events, secret, enabled, max_failures, created_at, updated_at + ) + VALUES ( + :name, :url, :events, :secret, :enabled, :max_failures, :created_at, :updated_at + ) """ ), params, @@ -464,6 +550,7 @@ def update_webhook( events: list[str] | None = None, secret: str | None = None, enabled: bool | None = None, + max_failures: int | None = None, ) -> dict[str, Any] | None: """Update webhook fields and return updated object.""" existing = get_webhook(webhook_id) @@ -476,6 +563,7 @@ def update_webhook( "events": existing["events"], "secret": existing["secret"], "enabled": existing["enabled"], + "max_failures": existing.get("max_failures", 10), } if name is not None: @@ -488,6 +576,8 @@ def update_webhook( updates["secret"] = secret if enabled is not None: updates["enabled"] = bool(enabled) + if max_failures is not None: + updates["max_failures"] = max(1, int(max_failures)) with get_engine().begin() as conn: conn.execute( @@ -499,6 +589,7 @@ def update_webhook( events = :events, secret = :secret, enabled = :enabled, + max_failures = :max_failures, updated_at = :updated_at WHERE id = :id """ @@ -510,6 +601,7 @@ def update_webhook( "events": json.dumps(updates["events"], ensure_ascii=False), "secret": updates["secret"], "enabled": 1 if updates["enabled"] else 0, + "max_failures": int(updates["max_failures"]), "updated_at": _utcnow_iso(), }, ) @@ -555,35 +647,64 @@ def record_webhook_delivery( status_code: int | None = None, error: str | None = None, response_body: str | None = None, -) -> None: + request_headers: dict[str, str] | None = None, +) -> int: """Persist webhook delivery attempt.""" ensure_database_ready() + params = { + "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, + "request_headers": ( + json.dumps(request_headers, ensure_ascii=False) + if isinstance(request_headers, dict) + else None + ), + "created_at": _utcnow_iso(), + } + + if get_engine().dialect.name == "postgresql": + with get_engine().begin() as conn: + result = conn.execute( + text( + """ + INSERT INTO webhook_deliveries( + webhook_id, event_type, payload, success, status_code, + error, attempt, response_body, request_headers, created_at + ) + VALUES ( + :webhook_id, :event_type, :payload, :success, :status_code, + :error, :attempt, :response_body, :request_headers, :created_at + ) + RETURNING id + """ + ), + params, + ) + return int(result.scalar_one()) + with get_engine().begin() as conn: - conn.execute( + result = conn.execute( text( """ INSERT INTO webhook_deliveries( webhook_id, event_type, payload, success, status_code, - error, attempt, response_body, created_at + error, attempt, response_body, request_headers, created_at ) VALUES ( :webhook_id, :event_type, :payload, :success, :status_code, - :error, :attempt, :response_body, :created_at + :error, :attempt, :response_body, :request_headers, :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(), - }, + params, ) + return int(result.lastrowid) if result.lastrowid is not None else 0 def list_webhook_deliveries(webhook_id: int, limit: int = 50) -> list[dict[str, Any]]: @@ -594,7 +715,7 @@ def list_webhook_deliveries(webhook_id: int, limit: int = 50) -> list[dict[str, text( """ SELECT id, webhook_id, event_type, payload, success, status_code, - error, attempt, response_body, created_at + error, attempt, response_body, request_headers, created_at FROM webhook_deliveries WHERE webhook_id = :webhook_id ORDER BY id DESC @@ -622,7 +743,536 @@ def list_webhook_deliveries(webhook_id: int, limit: int = 50) -> list[dict[str, "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]), + "request_headers": ( + json.loads(str(row[9])) if row[9] is not None and str(row[9]).strip() else {} + ), + "created_at": str(row[10]), } ) return deliveries + + +def get_webhook_delivery(webhook_id: int, delivery_id: int) -> dict[str, Any] | None: + """Get one webhook delivery row by id.""" + ensure_database_ready() + with get_engine().begin() as conn: + row = conn.execute( + text( + """ + SELECT id, webhook_id, event_type, payload, success, status_code, + error, attempt, response_body, request_headers, created_at + FROM webhook_deliveries + WHERE webhook_id = :webhook_id AND id = :delivery_id + """ + ), + {"webhook_id": int(webhook_id), "delivery_id": int(delivery_id)}, + ).fetchone() + + if not row: + return None + + try: + payload = json.loads(str(row[3])) + except Exception: + payload = {} + + try: + request_headers = json.loads(str(row[9])) if row[9] is not None else {} + except Exception: + request_headers = {} + + return { + "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, + "request_headers": request_headers if isinstance(request_headers, dict) else {}, + "created_at": str(row[10]), + } + + +def mark_webhook_delivery_result(webhook_id: int, success: bool, error: str | None = None) -> None: + """Update webhook health status after a delivery cycle.""" + ensure_database_ready() + webhook = get_webhook(webhook_id) + if not webhook: + return + + failure_count = int(webhook.get("failure_count", 0) or 0) + max_failures = max(1, int(webhook.get("max_failures", 10) or 10)) + + with get_engine().begin() as conn: + if success: + conn.execute( + text( + """ + UPDATE webhooks + SET failure_count = 0, + last_success_at = :last_success_at, + last_error = NULL, + disabled_reason = NULL, + enabled = 1, + updated_at = :updated_at + WHERE id = :id + """ + ), + { + "id": int(webhook_id), + "last_success_at": _utcnow_iso(), + "updated_at": _utcnow_iso(), + }, + ) + return + + next_count = failure_count + 1 + should_disable = next_count >= max_failures + conn.execute( + text( + """ + UPDATE webhooks + SET failure_count = :failure_count, + last_error = :last_error, + disabled_reason = :disabled_reason, + enabled = :enabled, + updated_at = :updated_at + WHERE id = :id + """ + ), + { + "id": int(webhook_id), + "failure_count": next_count, + "last_error": (error or "delivery_failed")[:1000], + "disabled_reason": ( + f"auto_disabled_after_{next_count}_failures" if should_disable else None + ), + "enabled": 0 if should_disable else 1, + "updated_at": _utcnow_iso(), + }, + ) + + +def rotate_webhook_secret(webhook_id: int) -> str | None: + """Rotate webhook secret and return new value.""" + ensure_database_ready() + hook = get_webhook(webhook_id) + if not hook: + return None + + secret = secrets.token_urlsafe(24) + with get_engine().begin() as conn: + conn.execute( + text( + """ + UPDATE webhooks + SET secret = :secret, updated_at = :updated_at + WHERE id = :id + """ + ), + {"id": int(webhook_id), "secret": secret, "updated_at": _utcnow_iso()}, + ) + return secret + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def create_incoming_webhook_key( + *, + name: str, + allowed_actions: list[str], + rate_limit_per_minute: int = 30, + enabled: bool = True, +) -> dict[str, Any]: + """Create incoming webhook key. Returns metadata + plain token once.""" + ensure_database_ready() + token = secrets.token_urlsafe(32) + token_hash = _hash_token(token) + now = _utcnow_iso() + allowed = [str(v).strip() for v in allowed_actions if str(v).strip()] + if not allowed: + allowed = ["send_message"] + + params = { + "name": name.strip() or "Incoming Key", + "token_hash": token_hash, + "allowed_actions": json.dumps(allowed, ensure_ascii=False), + "rate_limit_per_minute": max(1, int(rate_limit_per_minute)), + "enabled": 1 if enabled else 0, + "created_at": now, + "updated_at": now, + } + + if get_engine().dialect.name == "postgresql": + with get_engine().begin() as conn: + result = conn.execute( + text( + """ + INSERT INTO incoming_webhook_keys( + name, token_hash, allowed_actions, rate_limit_per_minute, + enabled, created_at, updated_at + ) + VALUES( + :name, :token_hash, :allowed_actions, :rate_limit_per_minute, + :enabled, :created_at, :updated_at + ) + RETURNING id + """ + ), + params, + ) + key_id = int(result.scalar_one()) + else: + with get_engine().begin() as conn: + result = conn.execute( + text( + """ + INSERT INTO incoming_webhook_keys( + name, token_hash, allowed_actions, rate_limit_per_minute, + enabled, created_at, updated_at + ) + VALUES( + :name, :token_hash, :allowed_actions, :rate_limit_per_minute, + :enabled, :created_at, :updated_at + ) + """ + ), + params, + ) + key_id = int(result.lastrowid) if result.lastrowid is not None else 0 + return { + "id": key_id, + "name": name.strip() or "Incoming Key", + "allowed_actions": allowed, + "rate_limit_per_minute": max(1, int(rate_limit_per_minute)), + "enabled": bool(enabled), + "created_at": now, + "updated_at": now, + "last_used_at": None, + "token": token, + } + + +def list_incoming_webhook_keys() -> list[dict[str, Any]]: + """List incoming webhook key metadata (without token).""" + ensure_database_ready() + with get_engine().begin() as conn: + rows = conn.execute( + text( + """ + SELECT id, name, allowed_actions, rate_limit_per_minute, enabled, + created_at, updated_at, last_used_at + FROM incoming_webhook_keys + ORDER BY id DESC + """ + ) + ).fetchall() + + keys: list[dict[str, Any]] = [] + for row in rows: + try: + actions = json.loads(str(row[2])) + except Exception: + actions = [] + keys.append( + { + "id": int(row[0]), + "name": str(row[1]), + "allowed_actions": actions if isinstance(actions, list) else [], + "rate_limit_per_minute": int(row[3]), + "enabled": bool(row[4]), + "created_at": str(row[5]), + "updated_at": str(row[6]), + "last_used_at": str(row[7]) if row[7] is not None else None, + } + ) + return keys + + +def update_incoming_webhook_key( + key_id: int, + *, + name: str | None = None, + allowed_actions: list[str] | None = None, + rate_limit_per_minute: int | None = None, + enabled: bool | None = None, +) -> dict[str, Any] | None: + """Update incoming webhook key metadata.""" + existing = None + for row in list_incoming_webhook_keys(): + if int(row["id"]) == int(key_id): + existing = row + break + if not existing: + return None + + next_name = name.strip() if isinstance(name, str) and name.strip() else existing["name"] + next_actions = ( + [str(v).strip() for v in allowed_actions if str(v).strip()] + if allowed_actions is not None + else existing["allowed_actions"] + ) + if not next_actions: + next_actions = ["send_message"] + next_rate = ( + max(1, int(rate_limit_per_minute)) + if rate_limit_per_minute is not None + else int(existing["rate_limit_per_minute"]) + ) + next_enabled = bool(enabled) if enabled is not None else bool(existing["enabled"]) + + with get_engine().begin() as conn: + conn.execute( + text( + """ + UPDATE incoming_webhook_keys + SET name = :name, + allowed_actions = :allowed_actions, + rate_limit_per_minute = :rate, + enabled = :enabled, + updated_at = :updated_at + WHERE id = :id + """ + ), + { + "id": int(key_id), + "name": next_name, + "allowed_actions": json.dumps(next_actions, ensure_ascii=False), + "rate": next_rate, + "enabled": 1 if next_enabled else 0, + "updated_at": _utcnow_iso(), + }, + ) + + for row in list_incoming_webhook_keys(): + if int(row["id"]) == int(key_id): + return row + return None + + +def rotate_incoming_webhook_key(key_id: int) -> str | None: + """Rotate incoming webhook key token and return new token.""" + ensure_database_ready() + exists = any(int(row["id"]) == int(key_id) for row in list_incoming_webhook_keys()) + if not exists: + return None + + token = secrets.token_urlsafe(32) + with get_engine().begin() as conn: + conn.execute( + text( + """ + UPDATE incoming_webhook_keys + SET token_hash = :token_hash, + updated_at = :updated_at + WHERE id = :id + """ + ), + { + "id": int(key_id), + "token_hash": _hash_token(token), + "updated_at": _utcnow_iso(), + }, + ) + return token + + +def delete_incoming_webhook_key(key_id: int) -> bool: + """Delete incoming webhook key.""" + ensure_database_ready() + with get_engine().begin() as conn: + result = conn.execute( + text("DELETE FROM incoming_webhook_keys WHERE id = :id"), + {"id": int(key_id)}, + ) + return result.rowcount > 0 + + +def resolve_incoming_webhook_key(token: str) -> dict[str, Any] | None: + """Resolve and return key metadata by plain token.""" + ensure_database_ready() + token_hash = _hash_token(token) + with get_engine().begin() as conn: + row = conn.execute( + text( + """ + SELECT id, name, allowed_actions, rate_limit_per_minute, enabled, + created_at, updated_at, last_used_at + FROM incoming_webhook_keys + WHERE token_hash = :token_hash + """ + ), + {"token_hash": token_hash}, + ).fetchone() + + if not row: + return None + + try: + actions = json.loads(str(row[2])) + except Exception: + actions = [] + + return { + "id": int(row[0]), + "name": str(row[1]), + "allowed_actions": actions if isinstance(actions, list) else [], + "rate_limit_per_minute": int(row[3]), + "enabled": bool(row[4]), + "created_at": str(row[5]), + "updated_at": str(row[6]), + "last_used_at": str(row[7]) if row[7] is not None else None, + } + + +def touch_incoming_webhook_key(key_id: int) -> None: + """Update last_used_at for incoming webhook key.""" + ensure_database_ready() + with get_engine().begin() as conn: + conn.execute( + text( + """ + UPDATE incoming_webhook_keys + SET last_used_at = :last_used_at, + updated_at = :updated_at + WHERE id = :id + """ + ), + { + "id": int(key_id), + "last_used_at": _utcnow_iso(), + "updated_at": _utcnow_iso(), + }, + ) + + +def claim_incoming_idempotency( + key_id: int, idempotency_key: str, ttl_seconds: int = 86_400 +) -> bool: + """Atomically claim idempotency key usage for incoming webhooks. + + Returns True when key is first-seen within TTL window. + Returns False when key was already claimed. + """ + ensure_database_ready() + + raw = str(idempotency_key).strip() + if not raw: + return False + + now = time.time() + cutoff = now - max(60, int(ttl_seconds)) + key_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest() + scope = f"incoming_idem:{int(key_id)}" + + with get_engine().begin() as conn: + rows = conn.execute( + text("SELECT key, value FROM kv_store WHERE scope = :scope"), + {"scope": scope}, + ).fetchall() + + stale_keys: list[str] = [] + for row in rows: + try: + parsed = json.loads(str(row[1])) + ts = float(parsed.get("ts", 0.0)) if isinstance(parsed, dict) else 0.0 + except Exception: + ts = 0.0 + if ts < cutoff: + stale_keys.append(str(row[0])) + + if stale_keys: + for stale_key in stale_keys: + conn.execute( + text("DELETE FROM kv_store WHERE scope = :scope AND key = :key"), + {"scope": scope, "key": stale_key}, + ) + + try: + conn.execute( + text( + """ + INSERT INTO kv_store(scope, key, value, updated_at) + VALUES (:scope, :key, :value, :updated_at) + """ + ), + { + "scope": scope, + "key": key_hash, + "value": json.dumps({"ts": now}), + "updated_at": _utcnow_iso(), + }, + ) + return True + except IntegrityError: + return False + + +def add_audit_log( + *, actor: str, action: str, resource: str, details: dict[str, Any] | None = None +) -> None: + """Persist audit log entry.""" + ensure_database_ready() + with get_engine().begin() as conn: + conn.execute( + text( + """ + INSERT INTO audit_logs(actor, action, resource, details, created_at) + VALUES (:actor, :action, :resource, :details, :created_at) + """ + ), + { + "actor": actor.strip() or "system", + "action": action.strip() or "unknown", + "resource": resource.strip() or "unknown", + "details": json.dumps(details or {}, ensure_ascii=False), + "created_at": _utcnow_iso(), + }, + ) + + +def list_audit_logs(limit: int = 100, action: str = "") -> list[dict[str, Any]]: + """List recent audit logs.""" + ensure_database_ready() + params: dict[str, Any] = {"limit": int(limit)} + where = "" + if action.strip(): + where = "WHERE action = :action" + params["action"] = action.strip() + + with get_engine().begin() as conn: + rows = conn.execute( + text( + f""" + SELECT id, actor, action, resource, details, created_at + FROM audit_logs + {where} + ORDER BY id DESC + LIMIT :limit + """ + ), + params, + ).fetchall() + + out: list[dict[str, Any]] = [] + for row in rows: + try: + details = json.loads(str(row[4])) + except Exception: + details = {} + out.append( + { + "id": int(row[0]), + "actor": str(row[1]), + "action": str(row[2]), + "resource": str(row[3]), + "details": details if isinstance(details, dict) else {}, + "created_at": str(row[5]), + } + ) + return out diff --git a/src/core/digest.py b/src/core/digest.py index 5cfc637..29a157a 100644 --- a/src/core/digest.py +++ b/src/core/digest.py @@ -2,9 +2,12 @@ from __future__ import annotations +from collections import Counter from datetime import datetime, timedelta +from core import symbols as sym from core.analytics import command_analytics +from core.i18n import t from core.reports import list_reports from core.scheduler import get_scheduler from core.storage import GroupData @@ -20,30 +23,69 @@ } +def _get_top_active_users(days: int, chat_jid: str, limit: int = 5) -> tuple[list[dict], int]: + """Get top active users by command usage in the given time window.""" + cutoff = (datetime.now() - timedelta(days=days)).isoformat() + user_counts: Counter[str] = Counter() + unique_users: set[str] = set() + + for entries in command_analytics._data.get("commands", {}).values(): + for entry in entries: + if entry.get("ts", "") < cutoff: + continue + if chat_jid and entry.get("chat") != chat_jid: + continue + user = entry.get("user", "") + if user: + user_counts[user] += 1 + unique_users.add(user) + + top = user_counts.most_common(limit) + return [{"user": u, "count": c} for u, c in top], len(unique_users) + + def build_digest_message(group_jid: str, period: str = "daily") -> str: - """Build digest text for a group.""" + """Build rich digest text for a group using sym helpers.""" days = 1 if period == "daily" else 7 - top = command_analytics.get_top_commands(days=days, chat_jid=group_jid)[:5] + top_cmds = command_analytics.get_top_commands(days=days, chat_jid=group_jid)[:5] total = command_analytics.get_total_commands(days=days, chat_jid=group_jid) reports = list_reports(group_jid) open_reports = len([r for r in reports if str(r.get("status", "")).lower() == "open"]) + top_users, unique_count = _get_top_active_users(days, group_jid) + + period_label = t("digest.title") + " — " + ("Daily" if period == "daily" else "Weekly") - title = "Daily Digest" if period == "daily" else "Weekly Digest" - lines = [f"*{title}*", ""] - lines.append(f"Commands used: `{total}`") - lines.append(f"Open reports: `{open_reports}`") - lines.append("") - lines.append("*Top commands*:") + summary_lines = [ + sym.status_line(t("stats.commands_used"), f"`{total}`"), + sym.status_line("Open reports", f"`{open_reports}`"), + sym.status_line("Active users", f"`{unique_count}`"), + ] - if top: - for item in top: - lines.append(f"- `/{item['command']}`: {item['count']}") + cmd_items = [] + if top_cmds: + for i, item in enumerate(top_cmds, 1): + cmd_items.append(f"{i}. `/{item['command']}` {sym.ARROW} {item['count']}") else: - lines.append("- No command activity") + cmd_items.append("No command activity") - lines.append("") - lines.append(f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')}") - return "\n".join(lines) + user_items = [] + if top_users: + for i, entry in enumerate(top_users, 1): + user_items.append(f"{i}. @{entry['user']} {sym.ARROW} {entry['count']}") + else: + user_items.append("No user activity") + + parts = [ + sym.box(period_label, summary_lines), + "", + sym.section("Top Commands", cmd_items), + "", + sym.section("Top Active Users", user_items), + "", + f"{sym.TIME} {datetime.now().strftime('%Y-%m-%d %H:%M')}", + ] + + return "\n".join(parts) def _cron_for(period: str, time_str: str, day: str = "sun") -> str: diff --git a/src/core/middlewares/__init__.py b/src/core/middlewares/__init__.py index 7e13fb2..87e7c06 100644 --- a/src/core/middlewares/__init__.py +++ b/src/core/middlewares/__init__.py @@ -7,6 +7,7 @@ from core.middleware import MiddlewarePipeline from .ai import ai_middleware +from .anti_spam import anti_spam_middleware from .antidelete import antidelete_middleware from .antilink import antilink_middleware from .auto_actions import auto_actions_middleware @@ -31,6 +32,7 @@ def build_pipeline() -> MiddlewarePipeline: pipeline.use("blacklist", blacklist_middleware) pipeline.use("antilink", antilink_middleware) pipeline.use("mute", mute_middleware) + pipeline.use("anti_spam", anti_spam_middleware) pipeline.use("features", features_middleware) pipeline.use("automations", automations_middleware) pipeline.use("auto_download", auto_download_middleware) diff --git a/src/core/middlewares/anti_spam.py b/src/core/middlewares/anti_spam.py new file mode 100644 index 0000000..e09817e --- /dev/null +++ b/src/core/middlewares/anti_spam.py @@ -0,0 +1,77 @@ +"""Anti-spam middleware — rate-limit messages per user to prevent flooding.""" + +from __future__ import annotations + +import time + +from core import symbols as sym +from core.i18n import t +from core.moderation import execute_moderation_action, is_admin +from core.runtime_config import runtime_config + +_spam_windows: dict[str, list[float]] = {} + + +def _cleanup_window(timestamps: list[float], window: float, now: float) -> list[float]: + """Remove timestamps outside the current window.""" + cutoff = now - window + return [ts for ts in timestamps if ts > cutoff] + + +async def anti_spam_middleware(ctx, next): + """Detect and act on message spam based on configurable thresholds.""" + if not runtime_config.get_feature("anti_spam"): + await next() + return + + if not ctx.msg.is_group or ctx.msg.is_from_me: + await next() + return + + sender = ctx.msg.sender_jid + + whitelist_admins = runtime_config.get_nested("anti_spam", "whitelist_admins", default=True) + if whitelist_admins: + try: + if await is_admin(ctx.bot, ctx.msg.chat_jid, sender): + await next() + return + except Exception: + pass + + max_messages = runtime_config.get_nested("anti_spam", "max_messages", default=5) + window_seconds = runtime_config.get_nested("anti_spam", "window_seconds", default=10) + action = str(runtime_config.get_nested("anti_spam", "action", default="warn")).lower() + + now = time.time() + + timestamps = _spam_windows.get(sender, []) + timestamps = _cleanup_window(timestamps, window_seconds, now) + timestamps.append(now) + _spam_windows[sender] = timestamps + + if len(timestamps) <= max_messages: + await next() + return + + user_id = sender.split("@")[0].split(":")[0] + + if action == "mute": + from core.storage import GroupData + + data = GroupData(ctx.msg.chat_jid) + muted = data.muted + if user_id not in muted: + muted.append(user_id) + data.save_muted(muted) + await execute_moderation_action(ctx.bot, ctx.msg, "delete", "anti_spam") + await ctx.bot.send(ctx.msg.chat_jid, t("anti_spam.muted", user=user_id)) + elif action == "kick": + await execute_moderation_action(ctx.bot, ctx.msg, "kick", "anti_spam") + else: + await ctx.bot.send( + ctx.msg.chat_jid, + f"{sym.WARNING} {t('anti_spam.warn_message', user=user_id)}", + ) + + _spam_windows[sender] = [] diff --git a/src/core/middlewares/automations.py b/src/core/middlewares/automations.py index df6724f..169bb7a 100644 --- a/src/core/middlewares/automations.py +++ b/src/core/middlewares/automations.py @@ -4,13 +4,21 @@ from core.runtime_config import runtime_config +def _has_media_type_rules(rules: list[dict]) -> bool: + """Check if any enabled rules use the media_type trigger.""" + return any( + r.get("enabled", True) and str(r.get("trigger_type", "")).lower() == "media_type" + for r in rules + ) + + async def automations_middleware(ctx, next): """Evaluate automation rules for incoming group messages.""" if not runtime_config.get_nested("features", "automation_rules", default=True): await next() return - if not ctx.msg.is_group or not ctx.msg.text or ctx.msg.is_from_me: + if not ctx.msg.is_group or ctx.msg.is_from_me: await next() return @@ -20,10 +28,18 @@ async def automations_middleware(ctx, next): return text = ctx.msg.text + if not text and not _has_media_type_rules(rules): + await next() + return + + media_type = None + if not text or _has_media_type_rules(rules): + _, media_type = ctx.msg.get_media_message() + for rule in rules: if not rule.get("enabled", True): continue - if not rule_matches(rule, text): + if not rule_matches(rule, text or "", media_type=media_type): continue try: diff --git a/src/core/runtime_config.py b/src/core/runtime_config.py index 7d324d6..17232cb 100644 --- a/src/core/runtime_config.py +++ b/src/core/runtime_config.py @@ -54,6 +54,7 @@ "blacklist": True, "warnings": True, "automation_rules": True, + "anti_spam": False, }, "anti_delete": { "forward_to": "", @@ -116,6 +117,12 @@ "burst_window": 10.0, }, "disabled_commands": [], + "anti_spam": { + "max_messages": 5, + "window_seconds": 10, + "action": "warn", + "whitelist_admins": True, + }, "dashboard": { "enabled": False, "cors_origins": ["http://localhost:3000", "http://127.0.0.1:3000"], diff --git a/src/core/webhooks.py b/src/core/webhooks.py index 9666803..6bbedc1 100644 --- a/src/core/webhooks.py +++ b/src/core/webhooks.py @@ -19,6 +19,8 @@ from core.db import ( get_active_webhooks_for_event, get_webhook, + get_webhook_delivery, + mark_webhook_delivery_result, record_webhook_delivery, ) from core.logger import log_warning @@ -85,6 +87,7 @@ async def _deliver_one( "data": event.data, } body = json.dumps(body_payload, ensure_ascii=False) + last_error = "" max_attempts = MAX_ATTEMPTS if allow_retry else 1 @@ -117,18 +120,23 @@ async def _deliver_one( status_code=response.status_code, response_body=response.text, error=None if ok else f"HTTP {response.status_code}", + request_headers=headers, ) if ok: + mark_webhook_delivery_result(webhook_id, success=True) return { "success": True, "status_code": response.status_code, "attempt": attempt, } + last_error = f"HTTP {response.status_code}" + if attempt < max_attempts: await asyncio.sleep(BASE_RETRY_DELAY_SECONDS * (2 ** (attempt - 1))) except Exception as exc: + last_error = str(exc) record_webhook_delivery( webhook_id=webhook_id, event_type=event.event_type, @@ -138,12 +146,16 @@ async def _deliver_one( status_code=None, response_body=None, error=str(exc), + request_headers=headers, ) if attempt < max_attempts: await asyncio.sleep(BASE_RETRY_DELAY_SECONDS * (2 ** (attempt - 1))) else: log_warning(f"Webhook delivery failed ({webhook_id}): {exc}") + mark_webhook_delivery_result( + webhook_id, success=False, error=last_error or "delivery_failed" + ) return {"success": False} async def _worker(self) -> None: @@ -173,6 +185,37 @@ async def send_test(self, webhook_id: int) -> dict[str, Any]: result = await self._deliver_one(hook, event, allow_retry=False) return result + async def replay(self, webhook_id: int, delivery_id: int) -> dict[str, Any]: + """Replay a previously recorded webhook delivery.""" + hook = get_webhook(webhook_id) + if not hook: + return {"success": False, "error": "Webhook not found"} + + delivery = get_webhook_delivery(webhook_id, delivery_id) + if not delivery: + return {"success": False, "error": "Delivery not found"} + + payload = delivery.get("payload", {}) + event_type = str(delivery.get("event_type", "replay_event")) + event = WebhookEvent( + event_type=event_type, + data=payload.get("data", {}) if isinstance(payload, dict) else {}, + timestamp=( + str(payload.get("timestamp")) + if isinstance(payload, dict) and payload.get("timestamp") + else time.strftime("%Y-%m-%dT%H:%M:%S") + ), + ) + return await self._deliver_one(hook, event, allow_retry=False) + + def get_status(self) -> dict[str, Any]: + """Get worker status for health checks.""" + running = self._worker_task is not None and not self._worker_task.done() + return { + "running": running, + "queue_size": self._queue.qsize(), + } + _dispatcher = WebhookDispatcher() @@ -187,6 +230,16 @@ async def send_test_webhook(webhook_id: int) -> dict[str, Any]: return await _dispatcher.send_test(webhook_id) +async def replay_webhook_delivery(webhook_id: int, delivery_id: int) -> dict[str, Any]: + """Replay a recorded webhook delivery by id.""" + return await _dispatcher.replay(webhook_id, delivery_id) + + +def webhook_dispatcher_status() -> dict[str, Any]: + """Expose internal dispatcher status for health checks.""" + return _dispatcher.get_status() + + def list_known_events() -> list[str]: """Known event names exposed by current bot flows.""" return [ diff --git a/src/dashboard_api.py b/src/dashboard_api.py index 56662d6..d346b62 100644 --- a/src/dashboard_api.py +++ b/src/dashboard_api.py @@ -6,11 +6,14 @@ """ import base64 +import hashlib +import hmac import json import os import re import secrets import sys +import time from datetime import datetime from pathlib import Path from typing import Any @@ -41,11 +44,24 @@ from core.automations import load_rules, next_rule_id, save_rules from core.command import command_loader from core.db import ( + add_audit_log, + claim_incoming_idempotency, + create_incoming_webhook_key, create_webhook, + delete_incoming_webhook_key, delete_webhook, + ensure_database_ready, + get_database_url, get_webhook, + list_audit_logs, + list_incoming_webhook_keys, list_webhook_deliveries, list_webhooks, + resolve_incoming_webhook_key, + rotate_incoming_webhook_key, + rotate_webhook_secret, + touch_incoming_webhook_key, + update_incoming_webhook_key, update_webhook, ) from core.digest import apply_digest_schedule, build_digest_message, send_digest_now @@ -63,7 +79,12 @@ 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 +from core.webhooks import ( + list_known_events, + replay_webhook_delivery, + send_test_webhook, + webhook_dispatcher_status, +) BOT_START_TIME = datetime.now() _DOTENV_PATH = Path(__file__).parent.parent / ".env" @@ -76,6 +97,50 @@ ] WS_TOKEN_TTL_SECONDS = max(30, int(os.getenv("DASHBOARD_WS_TOKEN_TTL_SECONDS", "300"))) _ws_tokens: dict[str, dict[str, Any]] = {} +INCOMING_MAX_DRIFT_SECONDS = 300 +_incoming_rate_windows: dict[int, list[float]] = {} + + +def _audit(actor: str, action: str, resource: str, details: dict[str, Any] | None = None) -> None: + """Best-effort audit trail write.""" + try: + add_audit_log(actor=actor, action=action, resource=resource, details=details or {}) + except Exception: + pass + + +def _verify_incoming_signature(token: str, timestamp: str, signature: str, raw_body: bytes) -> bool: + """Verify incoming webhook HMAC signature and timestamp drift.""" + if not token or not timestamp or not signature: + return False + + try: + ts = int(timestamp) + except ValueError: + return False + + now = int(time.time()) + if abs(now - ts) > INCOMING_MAX_DRIFT_SECONDS: + return False + + message = f"{timestamp}.".encode() + raw_body + digest = hmac.new(token.encode("utf-8"), message, hashlib.sha256).hexdigest() + expected = f"sha256={digest}" + return secrets.compare_digest(expected, signature) + + +def _consume_incoming_rate_limit(key_id: int, per_minute: int) -> bool: + """Return True if key can proceed under current rate limit.""" + now = time.time() + window = _incoming_rate_windows.get(key_id, []) + fresh = [ts for ts in window if now - ts < 60.0] + if len(fresh) >= max(1, int(per_minute)): + _incoming_rate_windows[key_id] = fresh + return False + + fresh.append(now) + _incoming_rate_windows[key_id] = fresh + return True def _ensure_dotenv_loaded() -> None: @@ -376,6 +441,7 @@ class WebhookCreate(BaseModel): events: list[str] = [] secret: str = "" enabled: bool = True + max_failures: int = 10 class WebhookUpdate(BaseModel): @@ -384,6 +450,21 @@ class WebhookUpdate(BaseModel): events: list[str] | None = None secret: str | None = None enabled: bool | None = None + max_failures: int | None = None + + +class IncomingWebhookKeyCreate(BaseModel): + name: str = "Incoming Key" + allowed_actions: list[str] = ["send_message"] + rate_limit_per_minute: int = 30 + enabled: bool = True + + +class IncomingWebhookKeyUpdate(BaseModel): + name: str | None = None + allowed_actions: list[str] | None = None + rate_limit_per_minute: int | None = None + enabled: bool | None = None @_api.post("/api/send-message") @@ -545,6 +626,12 @@ async def update_config(update: ConfigUpdate): await event_bus.emit( "config_update", {"section": update.section, "key": update.key, "value": update.value} ) + _audit( + "dashboard", + "config.update", + f"{update.section}.{update.key}", + {"value": update.value}, + ) return {"success": True, "message": f"Updated {update.section}.{update.key}"} except Exception as e: raise HTTPException(status_code=400, detail=str(e)) from e @@ -586,6 +673,12 @@ async def toggle_command(name: str, toggle: CommandToggle): runtime_config.set("disabled_commands", disabled) await event_bus.emit("command_update", {"name": name, "enabled": toggle.enabled}) + _audit( + "dashboard", + "command.toggle", + f"command:{name}", + {"enabled": toggle.enabled}, + ) return {"success": True, "name": name, "enabled": toggle.enabled} @@ -927,6 +1020,7 @@ async def update_rate_limit(settings: RateLimitSettings): 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"}) + _audit("dashboard", "rate_limit.update", "rate_limit", rate_limit_config) return {"success": True} @@ -943,6 +1037,11 @@ async def get_webhooks(): "url": hook["url"], "events": hook["events"], "enabled": hook["enabled"], + "failure_count": hook.get("failure_count", 0), + "max_failures": hook.get("max_failures", 10), + "last_success_at": hook.get("last_success_at"), + "last_error": hook.get("last_error"), + "disabled_reason": hook.get("disabled_reason"), "created_at": hook["created_at"], "updated_at": hook["updated_at"], "has_secret": bool(hook.get("secret")), @@ -969,6 +1068,18 @@ async def create_webhook_endpoint(payload: WebhookCreate): events=payload.events, secret=secret, enabled=payload.enabled, + max_failures=payload.max_failures, + ) + + _audit( + "dashboard", + "webhook.create", + f"webhook:{created['id']}", + { + "name": created["name"], + "url": created["url"], + "events": created["events"], + }, ) return { @@ -979,6 +1090,11 @@ async def create_webhook_endpoint(payload: WebhookCreate): "url": created["url"], "events": created["events"], "enabled": created["enabled"], + "failure_count": created.get("failure_count", 0), + "max_failures": created.get("max_failures", 10), + "last_success_at": created.get("last_success_at"), + "last_error": created.get("last_error"), + "disabled_reason": created.get("disabled_reason"), "created_at": created["created_at"], "updated_at": created["updated_at"], "has_secret": bool(created.get("secret")), @@ -1005,10 +1121,23 @@ async def update_webhook_endpoint(webhook_id: int, payload: WebhookUpdate): events=payload.events, secret=payload.secret, enabled=payload.enabled, + max_failures=payload.max_failures, ) if not updated: raise HTTPException(status_code=404, detail="Webhook not found") + _audit( + "dashboard", + "webhook.update", + f"webhook:{webhook_id}", + { + "name": updated["name"], + "enabled": updated["enabled"], + "events": updated["events"], + "max_failures": updated.get("max_failures", 10), + }, + ) + return { "success": True, "webhook": { @@ -1017,6 +1146,11 @@ async def update_webhook_endpoint(webhook_id: int, payload: WebhookUpdate): "url": updated["url"], "events": updated["events"], "enabled": updated["enabled"], + "failure_count": updated.get("failure_count", 0), + "max_failures": updated.get("max_failures", 10), + "last_success_at": updated.get("last_success_at"), + "last_error": updated.get("last_error"), + "disabled_reason": updated.get("disabled_reason"), "created_at": updated["created_at"], "updated_at": updated["updated_at"], "has_secret": bool(updated.get("secret")), @@ -1029,6 +1163,7 @@ 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") + _audit("dashboard", "webhook.delete", f"webhook:{webhook_id}", {}) return {"success": True} @@ -1040,6 +1175,44 @@ async def test_webhook_endpoint(webhook_id: int): raise HTTPException(status_code=404, detail="Webhook not found") result = await send_test_webhook(webhook_id) + _audit( + "dashboard", + "webhook.test", + f"webhook:{webhook_id}", + {"success": bool(result.get("success"))}, + ) + return {"success": bool(result.get("success")), "result": result} + + +@_api.post("/api/webhooks/{webhook_id}/rotate-secret") +async def rotate_webhook_secret_endpoint(webhook_id: int): + """Rotate one webhook secret and return the new secret once.""" + hook = get_webhook(webhook_id) + if not hook: + raise HTTPException(status_code=404, detail="Webhook not found") + + secret = rotate_webhook_secret(webhook_id) + if not secret: + raise HTTPException(status_code=500, detail="Failed to rotate secret") + + _audit("dashboard", "webhook.rotate_secret", f"webhook:{webhook_id}", {}) + return {"success": True, "secret": secret} + + +@_api.post("/api/webhooks/{webhook_id}/deliveries/{delivery_id}/replay") +async def replay_webhook_delivery_endpoint(webhook_id: int, delivery_id: int): + """Replay one previously logged webhook delivery.""" + hook = get_webhook(webhook_id) + if not hook: + raise HTTPException(status_code=404, detail="Webhook not found") + + result = await replay_webhook_delivery(webhook_id, delivery_id) + _audit( + "dashboard", + "webhook.replay_delivery", + f"webhook:{webhook_id}", + {"delivery_id": delivery_id, "success": bool(result.get("success"))}, + ) return {"success": bool(result.get("success")), "result": result} @@ -1054,6 +1227,199 @@ async def get_webhook_deliveries_endpoint(webhook_id: int, limit: int = Query(50 return {"deliveries": deliveries, "count": len(deliveries)} +@_api.get("/api/incoming-webhook-keys") +async def list_incoming_webhook_keys_endpoint(): + """List incoming webhook keys (metadata only).""" + keys = list_incoming_webhook_keys() + return {"keys": keys, "count": len(keys)} + + +@_api.post("/api/incoming-webhook-keys") +async def create_incoming_webhook_key_endpoint(payload: IncomingWebhookKeyCreate): + """Create incoming webhook key and return token once.""" + created = create_incoming_webhook_key( + name=payload.name, + allowed_actions=payload.allowed_actions, + rate_limit_per_minute=payload.rate_limit_per_minute, + enabled=payload.enabled, + ) + _audit( + "dashboard", + "incoming_key.create", + f"incoming_key:{created['id']}", + { + "name": created["name"], + "allowed_actions": created["allowed_actions"], + "rate_limit_per_minute": created["rate_limit_per_minute"], + }, + ) + return {"success": True, "key": created} + + +@_api.put("/api/incoming-webhook-keys/{key_id}") +async def update_incoming_webhook_key_endpoint(key_id: int, payload: IncomingWebhookKeyUpdate): + """Update incoming webhook key metadata.""" + updated = update_incoming_webhook_key( + key_id, + name=payload.name, + allowed_actions=payload.allowed_actions, + rate_limit_per_minute=payload.rate_limit_per_minute, + enabled=payload.enabled, + ) + if not updated: + raise HTTPException(status_code=404, detail="Incoming webhook key not found") + + _audit( + "dashboard", + "incoming_key.update", + f"incoming_key:{key_id}", + { + "name": updated["name"], + "allowed_actions": updated["allowed_actions"], + "enabled": updated["enabled"], + }, + ) + return {"success": True, "key": updated} + + +@_api.post("/api/incoming-webhook-keys/{key_id}/rotate") +async def rotate_incoming_webhook_key_endpoint(key_id: int): + """Rotate incoming webhook key token and return new value once.""" + token = rotate_incoming_webhook_key(key_id) + if not token: + raise HTTPException(status_code=404, detail="Incoming webhook key not found") + + _audit("dashboard", "incoming_key.rotate", f"incoming_key:{key_id}", {}) + return {"success": True, "token": token} + + +@_api.delete("/api/incoming-webhook-keys/{key_id}") +async def delete_incoming_webhook_key_endpoint(key_id: int): + """Delete incoming webhook key.""" + if not delete_incoming_webhook_key(key_id): + raise HTTPException(status_code=404, detail="Incoming webhook key not found") + + _audit("dashboard", "incoming_key.delete", f"incoming_key:{key_id}", {}) + return {"success": True} + + +@app.post("/api/incoming-webhook/{token}") +async def incoming_webhook_endpoint(token: str, request: Request): + """Receive signed incoming webhook requests and execute allowed actions.""" + key_meta = resolve_incoming_webhook_key(token) + if not key_meta or not key_meta.get("enabled"): + raise HTTPException(status_code=401, detail="Invalid incoming webhook key") + + ts_header = request.headers.get("X-ZeroIchi-Incoming-Timestamp", "") + sig_header = request.headers.get("X-ZeroIchi-Incoming-Signature", "") + idem_header = request.headers.get("X-ZeroIchi-Incoming-Idempotency-Key", "").strip() + raw_body = await request.body() + if not _verify_incoming_signature(token, ts_header, sig_header, raw_body): + raise HTTPException(status_code=401, detail="Invalid signature") + + if not idem_header: + raise HTTPException(status_code=400, detail="Missing X-ZeroIchi-Incoming-Idempotency-Key") + + if not _consume_incoming_rate_limit( + int(key_meta["id"]), int(key_meta.get("rate_limit_per_minute", 30)) + ): + raise HTTPException(status_code=429, detail="Incoming webhook rate limit exceeded") + + if not claim_incoming_idempotency(int(key_meta["id"]), idem_header): + raise HTTPException(status_code=409, detail="Duplicate idempotency key") + + try: + payload = json.loads(raw_body.decode("utf-8")) + except Exception as exc: + raise HTTPException(status_code=400, detail="Invalid JSON payload") from exc + + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Payload must be a JSON object") + + action = str(payload.get("action", "")).strip() + data = payload.get("data", {}) + if not action: + raise HTTPException(status_code=400, detail="Missing action") + if not isinstance(data, dict): + raise HTTPException(status_code=400, detail="data must be an object") + + allowed_actions = key_meta.get("allowed_actions", []) + if not isinstance(allowed_actions, list): + allowed_actions = [] + if action not in allowed_actions: + raise HTTPException(status_code=403, detail=f"Action '{action}' not allowed") + + if action == "send_message": + to = str(data.get("to", "")).strip() + text_value = str(data.get("text", "")).strip() + if not to or not text_value: + raise HTTPException(status_code=400, detail="send_message requires 'to' and 'text'") + + bot = get_bot() + if not await check_bot_logged_in(bot) or bot is None: + raise HTTPException(status_code=503, detail="Bot not connected") + await bot.send(to, text_value) + result = {"success": True, "action": action, "sent_to": to} + + elif action == "emit_event": + event_type = str(data.get("event_type", "")).strip() + event_data = data.get("event_data", {}) + if not event_type: + raise HTTPException(status_code=400, detail="emit_event requires 'event_type'") + if not isinstance(event_data, dict): + raise HTTPException(status_code=400, detail="event_data must be an object") + await event_bus.emit(event_type, event_data) + result = {"success": True, "action": action, "event_type": event_type} + + else: + raise HTTPException(status_code=400, detail=f"Unsupported action '{action}'") + + touch_incoming_webhook_key(int(key_meta["id"])) + _audit( + f"incoming_key:{key_meta['id']}", + "incoming_webhook.execute", + action, + {"payload_keys": list(data.keys())[:10]}, + ) + return result + + +@app.get("/healthz") +async def healthz(): + """Public lightweight liveness endpoint.""" + return {"status": "ok"} + + +@_api.get("/api/health") +async def api_health(): + """Detailed health endpoint for operators.""" + db_ok = True + db_error = "" + try: + ensure_database_ready() + except Exception as exc: + db_ok = False + db_error = str(exc) + + webhook_status = webhook_dispatcher_status() + return { + "status": "ok" if db_ok else "degraded", + "database": { + "ok": db_ok, + "url": get_database_url(), + "error": db_error or None, + }, + "webhooks": webhook_status, + } + + +@_api.get("/api/audit-logs") +async def get_audit_logs(limit: int = Query(100, ge=1, le=500), action: str = Query("")): + """List audit log entries.""" + rows = list_audit_logs(limit=limit, action=action) + return {"logs": rows, "count": len(rows)} + + @_api.get("/api/groups/{group_id}/welcome") async def get_welcome(group_id: str): """Get welcome settings for a group.""" @@ -1750,6 +2116,7 @@ async def update_ai_config(config: AIConfigUpdate): await event_bus.emit( "config_update", {"section": "agentic_ai", "key": "all", "value": config.dict()} ) + _audit("dashboard", "ai_config.update", "agentic_ai", config.dict()) return {"success": True} diff --git a/src/locales/en.json b/src/locales/en.json index 5dc2a44..148e884 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -607,8 +607,8 @@ "none": "No automation rules configured.", "item": "`{id}` [{status}] if `{trigger_type}` = `{trigger_value}` => `{action}`", "usage": "Usage: `{prefix}automation list|add|remove|toggle`", - "add_usage": "Usage: `{prefix}automation add => [response]`", - "invalid_trigger": "Invalid trigger type. Use `contains`, `regex`, or `link`.", + "add_usage": "Usage: `{prefix}automation add => [response]`", + "invalid_trigger": "Invalid trigger type. Use `contains`, `starts_with`, `exact_match`, `regex`, `link`, or `media_type`.", "invalid_action": "Invalid action type. Use `reply`, `warn`, `delete`, `kick`, or `mute`.", "missing_trigger_value": "Trigger value is required unless trigger type is `link`.", "added": "Automation rule `{id}` added.", @@ -697,5 +697,28 @@ "failed": "Failed to leave group: {error}", "not_group": "Use this command in a group or provide a group JID.", "no_permission": "Only group admins or the owner can use this command." + }, + "summarize": { + "title": "Summary", + "processing": "Summarizing...", + "ai_disabled": "AI is not enabled. Enable it with `config ai on`.", + "no_api_key": "AI API key is not configured.", + "no_content": "Nothing to summarize. Reply to a message or have recent chat history.", + "failed": "Failed to generate summary." + }, + "whois": { + "title": "User Info", + "user_id": "User ID", + "jid": "JID", + "role": "Role", + "warnings": "Warnings", + "admin": "Admin", + "superadmin": "Super Admin", + "member": "Member" + }, + "anti_spam": { + "warn_message": "@{user}, slow down! You are sending messages too fast.", + "muted": "@{user} has been muted for spamming.", + "kicked": "@{user} was kicked for spamming." } } diff --git a/src/locales/id.json b/src/locales/id.json index 553bd99..ee90756 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -608,8 +608,8 @@ "none": "Belum ada aturan otomasi.", "item": "`{id}` [{status}] jika `{trigger_type}` = `{trigger_value}` => `{action}`", "usage": "Cara pake: `{prefix}automation list|add|remove|toggle`", - "add_usage": "Cara pake: `{prefix}automation add => [response]`", - "invalid_trigger": "Jenis trigger gak valid. Pake `contains`, `regex`, atau `link`.", + "add_usage": "Cara pake: `{prefix}automation add => [response]`", + "invalid_trigger": "Jenis trigger gak valid. Pake `contains`, `starts_with`, `exact_match`, `regex`, `link`, atau `media_type`.", "invalid_action": "Jenis action gak valid. Pake `reply`, `warn`, `delete`, `kick`, atau `mute`.", "missing_trigger_value": "Nilai trigger wajib diisi kecuali tipe trigger `link`.", "added": "Rule otomasi `{id}` ditambah.", @@ -698,5 +698,28 @@ "failed": "Gagal keluar dari grup: {error}", "not_group": "Pake command ini di grup atau kasih group JID.", "no_permission": "Cuma admin grup atau owner yang bisa pake command ini." + }, + "summarize": { + "title": "Ringkasan", + "processing": "Lagi merangkum...", + "ai_disabled": "AI belum diaktifin. Aktifin pake `config ai on`.", + "no_api_key": "API key AI belum di-set.", + "no_content": "Gak ada yang bisa dirangkum. Reply ke pesan atau punya riwayat chat.", + "failed": "Gagal bikin ringkasan." + }, + "whois": { + "title": "Info User", + "user_id": "User ID", + "jid": "JID", + "role": "Role", + "warnings": "Warnings", + "admin": "Admin", + "superadmin": "Super Admin", + "member": "Member" + }, + "anti_spam": { + "warn_message": "@{user}, pelan-pelan! Lu ngirim pesan terlalu cepet.", + "muted": "@{user} di-mute karena spam.", + "kicked": "@{user} di-kick karena spam." } } diff --git a/tests/test_dashboard_security_and_ratelimit.py b/tests/test_dashboard_security_and_ratelimit.py index aa023f6..691103e 100644 --- a/tests/test_dashboard_security_and_ratelimit.py +++ b/tests/test_dashboard_security_and_ratelimit.py @@ -1,3 +1,7 @@ +import hashlib +import hmac +import time + import pytest from fastapi import HTTPException @@ -57,3 +61,66 @@ async def fake_emit(event_type, payload): assert captured["rate_limit"]["burst_limit"] == 9 assert limiter["burst_limit"] == 9 assert emitted[0][0] == "config_update" + + +def test_incoming_signature_verification(): + token = "test-token" + timestamp = str(int(time.time())) + payload = b'{"action":"emit_event","data":{}}' + digest = hmac.new( + token.encode("utf-8"), f"{timestamp}.".encode() + payload, hashlib.sha256 + ).hexdigest() + signature = f"sha256={digest}" + + assert dashboard_api._verify_incoming_signature(token, timestamp, signature, payload) + assert not dashboard_api._verify_incoming_signature(token, timestamp, "sha256=bad", payload) + + +def test_incoming_rate_limiter_window(): + key_id = 9999 + dashboard_api._incoming_rate_windows.pop(key_id, None) + + assert dashboard_api._consume_incoming_rate_limit(key_id, 2) + assert dashboard_api._consume_incoming_rate_limit(key_id, 2) + assert not dashboard_api._consume_incoming_rate_limit(key_id, 2) + + +class _FakeRequest: + def __init__(self, headers: dict[str, str], payload: bytes): + self.headers = headers + self._payload = payload + + async def body(self) -> bytes: + return self._payload + + +@pytest.mark.asyncio +async def test_incoming_webhook_duplicate_idempotency_returns_409(monkeypatch): + payload = b'{"action":"emit_event","data":{"event_type":"ci_done","event_data":{}}}' + request = _FakeRequest( + headers={ + "X-ZeroIchi-Incoming-Timestamp": str(int(time.time())), + "X-ZeroIchi-Incoming-Signature": "sha256=test", + "X-ZeroIchi-Incoming-Idempotency-Key": "dup-1", + }, + payload=payload, + ) + + monkeypatch.setattr( + dashboard_api, + "resolve_incoming_webhook_key", + lambda _token: { + "id": 1, + "enabled": True, + "allowed_actions": ["emit_event"], + "rate_limit_per_minute": 30, + }, + ) + monkeypatch.setattr(dashboard_api, "_verify_incoming_signature", lambda *_: True) + monkeypatch.setattr(dashboard_api, "_consume_incoming_rate_limit", lambda *_: True) + monkeypatch.setattr(dashboard_api, "claim_incoming_idempotency", lambda *_: False) + + with pytest.raises(HTTPException) as exc: + await dashboard_api.incoming_webhook_endpoint("token", request) # type: ignore[arg-type] + + assert exc.value.status_code == 409 diff --git a/tests/test_db_postgresql.py b/tests/test_db_postgresql.py new file mode 100644 index 0000000..69b0db0 --- /dev/null +++ b/tests/test_db_postgresql.py @@ -0,0 +1,224 @@ +""" +PostgreSQL integration tests — run against a live PostgreSQL database. + +These tests exercise all db.py operations against a real PostgreSQL instance +to verify dialect-specific branching (BIGSERIAL, RETURNING, etc.) works. + +Requires DATABASE_URL env var pointing to a PostgreSQL database. +Skipped automatically if DATABASE_URL is not set or not postgresql. +""" + +import os + +import pytest +from dotenv import load_dotenv + +load_dotenv() + +import core.db as db_module # noqa: E402 + +PG_URL = os.getenv("DATABASE_URL", "") +_is_pg = "postgresql" in PG_URL or "postgres" in PG_URL + +pytestmark = pytest.mark.skipif(not _is_pg, reason="DATABASE_URL not set to PostgreSQL") + + +@pytest.fixture(autouse=True) +def _pg_engine(): + """Reset db module state and initialize against the real PostgreSQL.""" + db_module._engine = None + db_module._ready = False + db_module.ensure_database_ready() + yield + engine = db_module.get_engine() + with engine.begin() as conn: + from sqlalchemy import text + + conn.execute(text("DELETE FROM webhook_deliveries")) + conn.execute(text("DELETE FROM webhooks")) + conn.execute(text("DELETE FROM incoming_webhook_keys")) + conn.execute(text("DELETE FROM kv_store WHERE scope LIKE 'test_%'")) + conn.execute(text("DELETE FROM audit_logs")) + + +def test_pg_connection(): + """Verify we are actually connected to PostgreSQL.""" + engine = db_module.get_engine() + assert engine.dialect.name == "postgresql" + + +def test_pg_kv_roundtrip(): + """KV store read/write on PostgreSQL.""" + payload = {"count": 42, "items": ["a", "b"]} + db_module.kv_set_json("test_pg", "stats", payload) + loaded = db_module.kv_get_json("test_pg", "stats", default={}) + assert loaded == payload + + db_module.kv_set_json("test_pg", "stats", {"count": 99}) + updated = db_module.kv_get_json("test_pg", "stats", default={}) + assert updated["count"] == 99 + + db_module.kv_delete("test_pg", "stats") + gone = db_module.kv_get_json("test_pg", "stats", default=None) + assert gone is None + + +def test_pg_webhook_crud(): + """Webhook CRUD with BIGSERIAL id and RETURNING on PostgreSQL.""" + hook = db_module.create_webhook( + name="PG Test", + url="https://example.com/pg-hook", + events=["command_executed", "message_received"], + secret="pg_secret", + enabled=True, + ) + + assert hook["id"] > 0 + assert hook["name"] == "PG Test" + assert hook["enabled"] is True + + fetched = db_module.get_webhook(hook["id"]) + assert fetched is not None + assert fetched["url"] == "https://example.com/pg-hook" + + updated = db_module.update_webhook(hook["id"], enabled=False) + assert updated is not None + assert updated["enabled"] is False + + matches = db_module.get_active_webhooks_for_event("command_executed") + assert all(m["id"] != hook["id"] for m in matches) + + db_module.update_webhook(hook["id"], enabled=True) + matches = db_module.get_active_webhooks_for_event("command_executed") + assert any(m["id"] == hook["id"] for m in matches) + + assert db_module.delete_webhook(hook["id"]) + assert db_module.get_webhook(hook["id"]) is None + + +def test_pg_webhook_delivery_log(): + """Webhook delivery logging with BIGINT foreign key on PostgreSQL.""" + hook = db_module.create_webhook( + name="Delivery Test", + url="https://example.com/deliver", + events=["*"], + secret="s", + enabled=True, + ) + + db_module.record_webhook_delivery( + webhook_id=hook["id"], + event_type="test_event", + payload={"ok": True}, + success=True, + attempt=1, + status_code=200, + ) + + 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"] == 200 + + +def test_pg_webhook_auto_disable(): + """Auto-disable after max_failures on PostgreSQL.""" + hook = db_module.create_webhook( + name="Auto Disable PG", + url="https://example.com/fail", + events=["*"], + secret="s", + enabled=True, + max_failures=2, + ) + + db_module.mark_webhook_delivery_result(hook["id"], success=False, error="timeout") + h1 = db_module.get_webhook(hook["id"]) + assert h1["enabled"] is True + assert h1["failure_count"] == 1 + + db_module.mark_webhook_delivery_result(hook["id"], success=False, error="timeout") + h2 = db_module.get_webhook(hook["id"]) + assert h2["enabled"] is False + assert h2["failure_count"] == 2 + assert h2["disabled_reason"] is not None + + +def test_pg_incoming_webhook_key_crud(): + """Incoming webhook key CRUD with RETURNING on PostgreSQL.""" + created = db_module.create_incoming_webhook_key( + name="PG Incoming", + allowed_actions=["send_message", "emit_event"], + rate_limit_per_minute=30, + enabled=True, + ) + + assert created["id"] > 0 + assert created["token"] + + resolved = db_module.resolve_incoming_webhook_key(created["token"]) + assert resolved is not None + assert resolved["name"] == "PG Incoming" + assert resolved["rate_limit_per_minute"] == 30 + + new_token = db_module.rotate_incoming_webhook_key(created["id"]) + assert new_token + assert db_module.resolve_incoming_webhook_key(created["token"]) is None + assert db_module.resolve_incoming_webhook_key(new_token) is not None + + assert db_module.delete_incoming_webhook_key(created["id"]) + + +def test_pg_idempotency(): + """Idempotency claim deduplication on PostgreSQL.""" + key = db_module.create_incoming_webhook_key( + name="Idempotency PG", + allowed_actions=["emit_event"], + rate_limit_per_minute=10, + enabled=True, + ) + resolved = db_module.resolve_incoming_webhook_key(key["token"]) + assert resolved is not None + + first = db_module.claim_incoming_idempotency(int(resolved["id"]), "pg-dedup-123") + second = db_module.claim_incoming_idempotency(int(resolved["id"]), "pg-dedup-123") + + assert first is True + assert second is False + + third = db_module.claim_incoming_idempotency(int(resolved["id"]), "pg-dedup-456") + assert third is True + + +def test_pg_audit_log(): + """Audit log write/read on PostgreSQL.""" + db_module.add_audit_log( + action="test_action", + actor="test_user@s.whatsapp.net", + resource="test_resource", + details={"note": "PG audit test"}, + ) + + logs = db_module.list_audit_logs(limit=5) + assert len(logs) >= 1 + latest = logs[0] + assert latest["action"] == "test_action" + assert latest["actor"] == "test_user@s.whatsapp.net" + + +def test_pg_secret_rotation(): + """Webhook secret rotation on PostgreSQL.""" + hook = db_module.create_webhook( + name="Rotate PG", + url="https://example.com/rotate", + events=["*"], + secret="old_secret", + enabled=True, + ) + + new_secret = db_module.rotate_webhook_secret(hook["id"]) + assert new_secret + assert new_secret != "old_secret" + + fetched = db_module.get_webhook(hook["id"]) + assert fetched["secret"] == new_secret diff --git a/tests/test_db_webhooks.py b/tests/test_db_webhooks.py index 9efa2b7..8f815da 100644 --- a/tests/test_db_webhooks.py +++ b/tests/test_db_webhooks.py @@ -55,3 +55,74 @@ def test_webhook_crud_and_delivery_log(tmp_path, monkeypatch): assert db_module.delete_webhook(hook["id"]) assert db_module.get_webhook(hook["id"]) is None + + +def test_webhook_auto_disable_after_failures(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + + hook = db_module.create_webhook( + name="Auto Disable", + url="https://example.com/hook", + events=["*"], + secret="abc", + enabled=True, + max_failures=2, + ) + + db_module.mark_webhook_delivery_result(hook["id"], success=False, error="timeout") + first = db_module.get_webhook(hook["id"]) + assert first is not None + assert first["enabled"] is True + assert first["failure_count"] == 1 + + db_module.mark_webhook_delivery_result(hook["id"], success=False, error="timeout") + second = db_module.get_webhook(hook["id"]) + assert second is not None + assert second["enabled"] is False + assert second["failure_count"] == 2 + assert second["disabled_reason"] is not None + + +def test_incoming_webhook_key_crud(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + + created = db_module.create_incoming_webhook_key( + name="CI Trigger", + allowed_actions=["send_message", "emit_event"], + rate_limit_per_minute=25, + enabled=True, + ) + + assert created["id"] > 0 + assert created["token"] + + resolved = db_module.resolve_incoming_webhook_key(created["token"]) + assert resolved is not None + assert resolved["name"] == "CI Trigger" + assert resolved["rate_limit_per_minute"] == 25 + + rotated = db_module.rotate_incoming_webhook_key(created["id"]) + assert rotated + assert db_module.resolve_incoming_webhook_key(created["token"]) is None + assert db_module.resolve_incoming_webhook_key(rotated) is not None + + assert db_module.delete_incoming_webhook_key(created["id"]) + + +def test_claim_incoming_idempotency(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + + key = db_module.create_incoming_webhook_key( + name="CI Trigger", + allowed_actions=["emit_event"], + rate_limit_per_minute=10, + enabled=True, + ) + resolved = db_module.resolve_incoming_webhook_key(key["token"]) + assert resolved is not None + + first = db_module.claim_incoming_idempotency(int(resolved["id"]), "abc-123") + second = db_module.claim_incoming_idempotency(int(resolved["id"]), "abc-123") + + assert first is True + assert second is False diff --git a/uv.lock b/uv.lock index 09d5dcd..0cc2361 100644 --- a/uv.lock +++ b/uv.lock @@ -138,6 +138,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -1559,6 +1573,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" }, ] +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1571,6 +1597,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mcp" version = "1.26.0" @@ -3923,6 +4023,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "alembic" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -3950,6 +4051,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "alembic", specifier = ">=1.14.0" }, { name = "pytest", specifier = ">=8.4.0" }, { name = "pytest-asyncio", specifier = ">=1.1.0" }, { name = "ruff", specifier = ">=0.9.0" }, From fb8889b61af00546322a2fd34c53bb52b98730af Mon Sep 17 00:00:00 2001 From: MhankBarBar Date: Wed, 18 Mar 2026 06:21:10 +0700 Subject: [PATCH 2/3] fix config backfill merge --- src/core/runtime_config.py | 27 ++++- tests/test_runtime_config_validation.py | 136 ++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/src/core/runtime_config.py b/src/core/runtime_config.py index 17232cb..d794001 100644 --- a/src/core/runtime_config.py +++ b/src/core/runtime_config.py @@ -419,6 +419,7 @@ def _load(self) -> None: if not isinstance(loaded, dict): loaded = {} + merged_defaults = self._needs_default_backfill(loaded, DEFAULT_CONFIG) config = self._merge_defaults(loaded, DEFAULT_CONFIG) config, migrated = self._migrate_runtime_overrides(config) config, normalized = self._normalize_legacy_actions(config) @@ -427,13 +428,22 @@ def _load(self) -> None: self._config = config - if migrated or normalized or "$schema" not in loaded: + if migrated or normalized or "$schema" not in loaded or merged_defaults: self._save() except Exception as e: print(f"[CONFIG] Error loading config: {e}") - self._config = self._ensure_schema_key(deepcopy(DEFAULT_CONFIG)) - self._save() + fallback = ( + deepcopy(self._config) + if isinstance(self._config, dict) and self._config + else deepcopy(DEFAULT_CONFIG) + ) + fallback = self._ensure_schema_key(fallback) + try: + self._assert_valid_config(fallback) + self._config = fallback + except Exception: + self._config = self._ensure_schema_key(deepcopy(DEFAULT_CONFIG)) def _merge_defaults(self, config: dict, defaults: dict) -> dict: """Recursively merge defaults into config for missing keys.""" @@ -445,6 +455,17 @@ def _merge_defaults(self, config: dict, defaults: dict) -> dict: result[key] = value return result + def _needs_default_backfill(self, config: dict[str, Any], defaults: dict[str, Any]) -> bool: + """Check whether config is missing any keys present in defaults.""" + for key, default_value in defaults.items(): + if key not in config: + return True + current_value = config.get(key) + if isinstance(default_value, dict) and isinstance(current_value, dict): + if self._needs_default_backfill(current_value, default_value): + return True + return False + def _deep_merge(self, base: dict, overrides: dict) -> dict: """Deep merge overrides into base config.""" result = deepcopy(base) diff --git a/tests/test_runtime_config_validation.py b/tests/test_runtime_config_validation.py index 8ba0d82..7589ca5 100644 --- a/tests/test_runtime_config_validation.py +++ b/tests/test_runtime_config_validation.py @@ -44,3 +44,139 @@ def test_valid_schema_update_is_persisted(isolated_runtime_config): cfg.set_nested("rate_limit", "burst_limit", 9) assert cfg.get_nested("rate_limit", "burst_limit") == 9 + + +def test_missing_schema_is_merged_and_preserved(tmp_path, monkeypatch): + schema_path = Path(__file__).resolve().parents[1] / "config.schema.json" + config_path = tmp_path / "config.json" + config_path.write_text( + """ +{ + "bot": { + "name": "Custom Bot", + "owner_jid": "12345@s.whatsapp.net" + }, + "features": { + "notes": false + } +} +""".strip(), + encoding="utf-8", + ) + + monkeypatch.setattr(runtime_config_module, "CONFIG_FILE", config_path) + 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() + + assert cfg.get_nested("bot", "name") == "Custom Bot" + assert cfg.get_nested("bot", "owner_jid") == "12345@s.whatsapp.net" + assert cfg.get_nested("features", "notes") is False + assert cfg.get_nested("features", "anti_delete") is True + + persisted = runtime_config_module.jsonc.load(config_path) + assert persisted.get("$schema") == runtime_config_module.DEFAULT_SCHEMA_PATH + assert persisted.get("bot", {}).get("name") == "Custom Bot" + assert persisted.get("features", {}).get("notes") is False + + runtime_config_module.RuntimeConfig._instance = None + + +def test_invalid_config_does_not_overwrite_file(tmp_path, monkeypatch): + schema_path = Path(__file__).resolve().parents[1] / "config.schema.json" + config_path = tmp_path / "config.json" + invalid_content = """ +{ + "bot": "not-an-object" +} +""".strip() + config_path.write_text(invalid_content, encoding="utf-8") + + monkeypatch.setattr(runtime_config_module, "CONFIG_FILE", config_path) + 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() + + assert cfg.get_nested("bot", "name") == "Zero Ichi" + assert config_path.read_text(encoding="utf-8").strip() == invalid_content + + runtime_config_module.RuntimeConfig._instance = None + + +def test_missing_default_keys_are_persisted_with_existing_schema(tmp_path, monkeypatch): + schema_path = Path(__file__).resolve().parents[1] / "config.schema.json" + config_path = tmp_path / "config.json" + config_path.write_text( + """ +{ + "$schema": "./config.schema.json", + "bot": { + "name": "Custom Bot", + "prefix": "/", + "login_method": "QR", + "phone_number": "", + "owner_jid": "owner@s.whatsapp.net", + "auto_read": false, + "auto_reload": true, + "auto_react": false, + "auto_react_emoji": "", + "ignore_self_messages": true, + "self_mode": false + }, + "features": { + "anti_delete": true, + "anti_link": true, + "welcome": true, + "notes": false, + "filters": true, + "blacklist": true, + "warnings": true, + "automation_rules": true + } +} +""".strip(), + encoding="utf-8", + ) + + monkeypatch.setattr(runtime_config_module, "CONFIG_FILE", config_path) + 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() + + # Existing user values are preserved + assert cfg.get_nested("bot", "name") == "Custom Bot" + assert cfg.get_nested("features", "notes") is False + + # Newly added keys are merged and persisted + assert cfg.get_nested("features", "anti_spam") is False + persisted = runtime_config_module.jsonc.load(config_path) + assert persisted.get("features", {}).get("anti_spam") is False + + runtime_config_module.RuntimeConfig._instance = None From dd4a201b15d31974150bad29e2362ba94fb3f4c8 Mon Sep 17 00:00:00 2001 From: MhankBarBar Date: Wed, 18 Mar 2026 09:05:44 +0700 Subject: [PATCH 3/3] feat qol setup and permissions --- config.schema.json | 69 +++++ docs/commands/general.md | 18 ++ docs/commands/moderation.md | 10 + docs/commands/owner.md | 62 +++++ docs/commands/utility.md | 32 +++ docs/getting-started/first-run.md | 50 ++++ docs/getting-started/installation.md | 1 + docs/index.md | 1 + src/ai/agent.py | 18 +- src/ai/memory.py | 14 +- src/commands/general/status.py | 74 +++++ src/commands/moderation/automation.py | 122 ++++++++- src/commands/owner/config.py | 277 +++++++++++++++++-- src/commands/owner/permission.py | 177 ++++++++++++ src/commands/owner/privacy.py | 203 ++++++++++++++ src/commands/owner/setup.py | 287 ++++++++++++++++++++ src/commands/utility/_ai_text.py | 64 +++++ src/commands/utility/rewrite.py | 88 ++++++ src/commands/utility/summarize.py | 5 + src/commands/utility/translate.py | 74 +++++ src/core/analytics.py | 9 +- src/core/automations.py | 17 ++ src/core/middlewares/automations.py | 17 +- src/core/permissions.py | 101 ++++++- src/core/privacy.py | 87 ++++++ src/core/runtime_config.py | 248 ++++++++++++++++- src/locales/en.json | 137 +++++++++- src/locales/id.json | 137 +++++++++- src/main.py | 299 +++++++++++++++++++++ tests/test_automations.py | 62 +++++ tests/test_command_permission_overrides.py | 142 ++++++++++ tests/test_owner_bootstrap_permissions.py | 94 +++++++ tests/test_privacy_controls.py | 69 +++++ tests/test_runtime_config_history.py | 59 ++++ tests/test_runtime_config_validation.py | 19 ++ 35 files changed, 3092 insertions(+), 51 deletions(-) create mode 100644 src/commands/general/status.py create mode 100644 src/commands/owner/permission.py create mode 100644 src/commands/owner/privacy.py create mode 100644 src/commands/owner/setup.py create mode 100644 src/commands/utility/_ai_text.py create mode 100644 src/commands/utility/rewrite.py create mode 100644 src/commands/utility/translate.py create mode 100644 src/core/privacy.py create mode 100644 tests/test_automations.py create mode 100644 tests/test_command_permission_overrides.py create mode 100644 tests/test_owner_bootstrap_permissions.py create mode 100644 tests/test_privacy_controls.py create mode 100644 tests/test_runtime_config_history.py diff --git a/config.schema.json b/config.schema.json index eaca75c..98c9962 100644 --- a/config.schema.json +++ b/config.schema.json @@ -479,6 +479,75 @@ "description": "List of command names that are disabled", "default": [] }, + "command_permissions": { + "type": "object", + "description": "Role overrides for command access (global and per-group)", + "properties": { + "global": { + "type": "object", + "description": "Global role override per command name", + "additionalProperties": { + "type": "string", + "enum": [ + "member", + "admin", + "owner" + ] + }, + "default": {} + }, + "groups": { + "type": "object", + "description": "Per-group role override maps by group JID", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "string", + "enum": [ + "member", + "admin", + "owner" + ] + } + }, + "default": {} + } + }, + "default": { + "global": {}, + "groups": {} + } + }, + "privacy": { + "type": "object", + "description": "Privacy and data retention settings", + "properties": { + "analytics_retention_days": { + "type": "integer", + "minimum": 1, + "maximum": 365, + "description": "How many days of analytics history to keep", + "default": 30 + }, + "ai_memory_enabled": { + "type": "boolean", + "description": "Enable AI conversation memory globally", + "default": true + }, + "ai_memory_ttl_hours": { + "type": "number", + "minimum": 1, + "maximum": 720, + "description": "AI memory time-to-live in hours before eviction", + "default": 24 + } + }, + "default": { + "analytics_retention_days": 30, + "ai_memory_enabled": true, + "ai_memory_ttl_hours": 24 + } + }, "anti_spam": { "type": "object", "description": "Anti-spam flood protection settings", diff --git a/docs/commands/general.md b/docs/commands/general.md index f0071db..2a868a6 100644 --- a/docs/commands/general.md +++ b/docs/commands/general.md @@ -67,3 +67,21 @@ Show bot statistics including messages processed, commands used, uptime, and gro 🐍 Python: 3.11.5 💻 OS: Windows ``` + +## /status + +Quick health view for key runtime systems. + +``` +/status +``` + +Shows: +- uptime +- database connectivity +- webhook worker + queue size +- AI state/model +- rate limiter state +- loaded command count + +**Aliases:** `health` diff --git a/docs/commands/moderation.md b/docs/commands/moderation.md index 8e0ddc3..8e911a6 100644 --- a/docs/commands/moderation.md +++ b/docs/commands/moderation.md @@ -91,6 +91,9 @@ No-code moderation automation rules. Trigger on message content, exact text, med /automation add => [response] /automation toggle /automation remove +/automation simulate +/automation simulate --media +/automation dryrun ``` **Trigger types:** @@ -115,8 +118,15 @@ No-code moderation automation rules. Trigger on message content, exact text, med /automation add regex (?i)free\s+money => delete /automation add link x.com => reply External links are reviewed by admins. /automation add media_type sticker => delete +/automation simulate A001 this is a test message +/automation simulate A006 --media sticker +/automation dryrun on ``` +::: info +`dryrun` mode lets you test live traffic safely: matching rules will only send a preview message and won't execute moderation actions. +::: + ## /blacklist Add a word to the group blacklist. Messages containing blacklisted words are auto-deleted. diff --git a/docs/commands/owner.md b/docs/commands/owner.md index 200d7d1..a455559 100644 --- a/docs/commands/owner.md +++ b/docs/commands/owner.md @@ -2,6 +2,18 @@ Commands restricted to the bot owner. Set yourself as owner with `/config owner me`. +If no owner is configured yet, you can bootstrap ownership from a private chat with: + +``` +/config owner me +``` + +or: + +``` +/setup owner me +``` + ::: danger Owner Only These commands have full system access. Only the configured bot owner can use them. ::: @@ -14,6 +26,10 @@ Manage bot configuration live from WhatsApp. /config # Show current config overview /config owner me # Set yourself as owner /config prefix # Change command prefix +/config diff # Show config changes vs defaults +/config validate # Validate config against schema +/config history # Show recent config snapshots +/config rollback # Roll back to a previous snapshot /config ai # Show AI status /config ai on # Enable Agentic AI /config ai off # Disable Agentic AI @@ -22,6 +38,52 @@ Manage bot configuration live from WhatsApp. /config ai provider # Change AI provider ``` +## /setup + +Guided first-run setup wizard for common bot settings. + +``` +/setup start +/setup owner me +/setup prefix ! +/setup anti-link on warn +/setup anti-spam on warn +/setup ai-key +/setup ai on +/setup done +``` + +## /permission + +Manage role-based command access overrides globally or per group. + +``` +/permission list +/permission list here +/permission set warn admin global +/permission set quote member here +/permission reset quote here +``` + +Roles: +- `member` +- `admin` +- `owner` + +## /privacy + +Manage data retention and AI memory privacy controls. + +``` +/privacy status +/privacy retention analytics 30 +/privacy retention memory-ttl 24 +/privacy memory global on +/privacy memory off here +/privacy memory inherit here +/privacy memory clear here +``` + ## /autodl Configure automatic link download behavior (owner-only). diff --git a/docs/commands/utility.md b/docs/commands/utility.md index a505183..cb85e56 100644 --- a/docs/commands/utility.md +++ b/docs/commands/utility.md @@ -130,3 +130,35 @@ AI-powered text summarization. Requires the [Agentic AI](/features/ai) feature t ::: info This command has a 30-second cooldown to prevent abuse. ::: + +## /translate + +AI-powered translation. + +``` +/translate +``` + +You can also reply to a message: + +``` +/translate en +``` + +**Aliases:** `tr` + +## /rewrite + +AI-powered rewrite in a selected style. + +``` +/rewrite +``` + +You can also reply to a message: + +``` +/rewrite concise +``` + +**Aliases:** `rephrase` diff --git a/docs/getting-started/first-run.md b/docs/getting-started/first-run.md index 111cc0f..55b543c 100644 --- a/docs/getting-started/first-run.md +++ b/docs/getting-started/first-run.md @@ -6,6 +6,12 @@ uv run zero-ichi ``` +Optional: run interactive setup before first launch: + +```bash +uv run zero-ichi setup +``` + ## CLI Arguments You can run the bot with flags: @@ -23,6 +29,13 @@ uv run zero-ichi --debug --auto-reload | `--auto-reload` | Enable auto-reload for development | `uv run zero-ichi --auto-reload` | | `--dashboard` | Enable dashboard API at startup | `uv run zero-ichi --dashboard` | +## CLI Subcommands + +| Subcommand | Description | Example | +|------------|-------------|---------| +| `setup` | Interactive setup wizard (configures common settings) | `uv run zero-ichi setup` | +| `update` | Pull latest code and sync dependencies | `uv run zero-ichi update` | + ### Update Command Use built-in update command: @@ -31,6 +44,29 @@ Use built-in update command: uv run zero-ichi update ``` +### Setup Wizard Command + +Use built-in interactive setup wizard: + +```bash +uv run zero-ichi setup +``` + +The wizard guides common first-run settings: + +- session name (used for `.session` file) +- login method (QR or pair-code) + phone number +- owner JID +- command prefix +- auto-read / auto-react / self mode +- dashboard on/off +- anti-link on/off + action +- anti-spam on/off + action +- anti-spam thresholds +- AI on/off + API key +- AI provider/model/trigger mode/owner-only +- rate-limit controls + ## QR Code Login On first launch, the bot will display a QR code in the terminal: @@ -71,6 +107,20 @@ Send this message from your WhatsApp: /config owner me ``` +Or set owner during CLI setup with `uv run zero-ichi setup`. + +If owner is not configured yet, you can also bootstrap owner from a private chat using: + +``` +/config owner me +``` + +or: + +``` +/setup owner me +``` + This gives you access to [owner-only commands](/commands/owner) like `/eval`, `/config`, and `/addcommand`. ## Testing Commands diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 839ba0e..6d876dc 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -76,6 +76,7 @@ Set a PostgreSQL URL to run on Postgres. Quick run examples: ```bash +uv run zero-ichi setup 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 505aa3e..9e76339 100644 --- a/docs/index.md +++ b/docs/index.md @@ -98,6 +98,7 @@ irm https://raw.githubusercontent.com/MhankBarBar/zero-ichi/master/install.ps1 |

Then start the bot:

```bash +uv run zero-ichi setup uv run zero-ichi # with args: uv run zero-ichi --debug --dashboard diff --git a/src/ai/agent.py b/src/ai/agent.py index c7f7cf3..25ebda1 100644 --- a/src/ai/agent.py +++ b/src/ai/agent.py @@ -372,6 +372,7 @@ async def process(self, msg: MessageHelper, bot: BotClient) -> str | None: Returns the AI's text response, or None if no response. """ from ai.memory import get_memory + from core.privacy import is_chat_memory_enabled if not self.api_key: return None @@ -438,10 +439,13 @@ async def process(self, msg: MessageHelper, bot: BotClient) -> str | None: Note: When user mentions @{bot_jid} or @{bot_lid}, they are talking TO you, not asking you to mention yourself. """ - memory = get_memory(msg.chat_jid) - history_text = memory.get_context_string() - if history_text: - history_text = "\n\n" + history_text + memory_enabled = is_chat_memory_enabled(msg.chat_jid) + memory = get_memory(msg.chat_jid) if memory_enabled else None + history_text = "" + if memory: + history_text = memory.get_context_string() + if history_text: + history_text = "\n\n" + history_text try: skills_context = "" @@ -464,7 +468,7 @@ async def process(self, msg: MessageHelper, bot: BotClient) -> str | None: model=model_str, ) - if msg.text: + if msg.text and memory: memory.add( role="user", content=msg.text, @@ -473,7 +477,7 @@ async def process(self, msg: MessageHelper, bot: BotClient) -> str | None: is_reply=is_reply, reply_to=reply_to, ) - if result.output: + if result.output and memory: memory.add(role="assistant", content=result.output) try: @@ -491,7 +495,7 @@ async def process(self, msg: MessageHelper, bot: BotClient) -> str | None: error_str = str(e) if "expected a string, got null" in error_str: log_debug("AI completed tool execution (null response is expected)") - if msg.text: + if msg.text and memory: memory.add( role="user", content=msg.text, diff --git a/src/ai/memory.py b/src/ai/memory.py index db43fce..7629775 100644 --- a/src/ai/memory.py +++ b/src/ai/memory.py @@ -168,10 +168,20 @@ def clear(self) -> None: _memory_cache: dict[str, AIMemory] = {} -def get_memory(chat_id: str, ttl_hours: float = DEFAULT_TTL_HOURS) -> AIMemory: +def get_memory(chat_id: str, ttl_hours: float | None = None) -> AIMemory: """Get or create memory for a chat.""" + if ttl_hours is None: + try: + from core.privacy import get_ai_memory_ttl_hours + + ttl_hours = get_ai_memory_ttl_hours() + except Exception: + ttl_hours = DEFAULT_TTL_HOURS + if chat_id not in _memory_cache: _memory_cache[chat_id] = AIMemory(chat_id, ttl_hours) + elif _memory_cache[chat_id].ttl_hours != ttl_hours: + _memory_cache[chat_id].ttl_hours = ttl_hours return _memory_cache[chat_id] @@ -182,6 +192,8 @@ def clear_memory(chat_id: str | None = None) -> None: if chat_id in _memory_cache: _memory_cache[chat_id].clear() del _memory_cache[chat_id] + else: + AIMemory(chat_id).clear() else: for mem in _memory_cache.values(): mem.clear() diff --git a/src/commands/general/status.py b/src/commands/general/status.py new file mode 100644 index 0000000..d96bc38 --- /dev/null +++ b/src/commands/general/status.py @@ -0,0 +1,74 @@ +"""Status command - quick bot health and runtime status.""" + +from __future__ import annotations + +import time + +from sqlalchemy import text + +from core import symbols as sym +from core.command import Command, CommandContext, command_loader +from core.i18n import t +from core.runtime_config import runtime_config +from core.webhooks import webhook_dispatcher_status + + +def _format_uptime(seconds: float) -> str: + days = int(seconds // 86400) + hours = int((seconds % 86400) // 3600) + minutes = int((seconds % 3600) // 60) + parts = [] + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + parts.append(f"{minutes}m") + return " ".join(parts) + + +class StatusCommand(Command): + name = "status" + aliases = ["health"] + description = "Show runtime health and key subsystem status" + usage = "status" + category = "general" + + async def execute(self, ctx: CommandContext) -> None: + from commands.general.uptime import _start_time + from core.db import get_engine + + uptime = _format_uptime(time.time() - _start_time) + + db_ok = False + try: + engine = get_engine() + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + db_ok = True + except Exception: + db_ok = False + + webhook = webhook_dispatcher_status() + ai_enabled = runtime_config.get_nested("agentic_ai", "enabled", default=False) + ai_provider = runtime_config.get_nested("agentic_ai", "provider", default="openai") + ai_model = runtime_config.get_nested("agentic_ai", "model", default="gpt-5-mini") + rate_limit_enabled = runtime_config.get_nested("rate_limit", "enabled", default=True) + + lines = [ + sym.status_line(t("status.uptime"), uptime), + sym.status_line(t("status.db"), t("common.on") if db_ok else t("common.off")), + sym.status_line( + t("status.webhook_worker"), + t("common.on") if webhook.get("running") else t("common.off"), + ), + sym.status_line(t("status.webhook_queue"), str(webhook.get("queue_size", 0))), + sym.status_line(t("status.ai"), t("common.on") if ai_enabled else t("common.off")), + sym.status_line(t("status.ai_model"), f"{ai_provider}:{ai_model}"), + sym.status_line( + t("status.rate_limit"), + t("common.on") if rate_limit_enabled else t("common.off"), + ), + sym.status_line(t("status.commands"), str(len(command_loader.enabled_commands))), + ] + + await ctx.client.reply(ctx.message, sym.box(t("status.title"), lines)) diff --git a/src/commands/moderation/automation.py b/src/commands/moderation/automation.py index 1d4d858..23bde9a 100644 --- a/src/commands/moderation/automation.py +++ b/src/commands/moderation/automation.py @@ -2,7 +2,14 @@ from __future__ import annotations -from core.automations import load_rules, next_rule_id, save_rules +from core.automations import ( + get_automation_runtime, + load_rules, + next_rule_id, + rule_matches, + save_rules, + set_automation_dry_run, +) from core.command import Command, CommandContext from core.event_bus import event_bus from core.i18n import t, t_error, t_success @@ -13,7 +20,7 @@ class AutomationCommand(Command): name = "automation" aliases = ["automations", "rule", "ruleset"] description = "Manage automation rules" - usage = "automation list|add|remove|toggle" + usage = "automation list|add|remove|toggle|simulate|dryrun" group_only = True admin_only = True @@ -46,15 +53,33 @@ async def execute(self, ctx: CommandContext) -> None: await self._add_rule(ctx) return + if action == "simulate": + await self._simulate_rule(ctx, args[1:]) + return + + if action == "dryrun": + await self._set_dry_run(ctx, args[1:]) + return + await ctx.client.reply(ctx.message, t_error("automation.usage", prefix=ctx.prefix)) async def _list_rules(self, ctx: CommandContext) -> None: rules = load_rules(ctx.message.chat_jid) + runtime = get_automation_runtime(ctx.message.chat_jid) + dry_run = bool(runtime.get("dry_run", False)) + if not rules: - await ctx.client.reply(ctx.message, t("automation.none")) + await ctx.client.reply( + ctx.message, + f"{t('automation.none')}\n{t('automation.dryrun_status', status=t('common.on') if dry_run else t('common.off'))}", + ) return - lines = [f"*{t('automation.title')}*", ""] + lines = [ + f"*{t('automation.title')}*", + t("automation.dryrun_status", status=t("common.on") if dry_run else t("common.off")), + "", + ] for rule in rules: lines.append( t( @@ -161,3 +186,92 @@ async def _toggle_rule(self, ctx: CommandContext, rule_id: str) -> None: status=t("common.on") if found.get("enabled") else t("common.off"), ), ) + + async def _simulate_rule(self, ctx: CommandContext, args: list[str]) -> None: + """Simulate whether a rule would match sample input.""" + if not args: + await ctx.client.reply( + ctx.message, t_error("automation.simulate_usage", prefix=ctx.prefix) + ) + return + + rid = args[0].strip().upper() + rule = None + rules = load_rules(ctx.message.chat_jid) + for item in rules: + if str(item.get("id", "")).upper() == rid: + rule = item + break + + if not rule: + await ctx.client.reply(ctx.message, t_error("automation.not_found", id=rid)) + return + + media_type = "" + sample_tokens: list[str] = [] + idx = 1 + while idx < len(args): + token = args[idx] + if token.lower() == "--media" and idx + 1 < len(args): + media_type = args[idx + 1].lower().strip() + idx += 2 + continue + sample_tokens.append(token) + idx += 1 + + sample_text = " ".join(sample_tokens).strip() + trigger_type = str(rule.get("trigger_type", "contains")) + if trigger_type != "media_type" and not sample_text: + await ctx.client.reply( + ctx.message, t_error("automation.simulate_usage", prefix=ctx.prefix) + ) + return + + if trigger_type == "media_type" and not media_type: + media_type = str(rule.get("trigger_value", "")).lower() + + matched = rule_matches(rule, sample_text, media_type=media_type or None) + status = t("automation.simulate_match") if matched else t("automation.simulate_no_match") + sample_display = sample_text or "(empty)" + media_display = media_type or "-" + + lines = [ + t("automation.simulate_result", id=rid, status=status), + t( + "automation.simulate_rule", + trigger_type=trigger_type, + trigger_value=str(rule.get("trigger_value", "")), + action=str(rule.get("action_type", "reply")), + ), + t("automation.simulate_input", text=sample_display, media=media_display), + ] + await ctx.client.reply(ctx.message, "\n".join(lines)) + + async def _set_dry_run(self, ctx: CommandContext, args: list[str]) -> None: + """Set or show automation dry-run mode for this group.""" + if not args: + runtime = get_automation_runtime(ctx.message.chat_jid) + await ctx.client.reply( + ctx.message, + t( + "automation.dryrun_status", + status=t("common.on") if runtime.get("dry_run") else t("common.off"), + ), + ) + return + + mode = args[0].lower() + if mode not in {"on", "off"}: + await ctx.client.reply( + ctx.message, t_error("automation.dryrun_usage", prefix=ctx.prefix) + ) + return + + enabled = mode == "on" + set_automation_dry_run(ctx.message.chat_jid, enabled) + await ctx.client.reply( + ctx.message, + t_success( + "automation.dryrun_set", status=t("common.on") if enabled else t("common.off") + ), + ) diff --git a/src/commands/owner/config.py b/src/commands/owner/config.py index 163fab3..53ed400 100644 --- a/src/commands/owner/config.py +++ b/src/commands/owner/config.py @@ -2,10 +2,15 @@ Owner config command - Manage bot configuration at runtime. """ +from __future__ import annotations + +from copy import deepcopy +from typing import Any + from core import symbols as sym from core.command import Command, CommandContext, command_loader from core.i18n import t, t_error, t_info, t_success -from core.runtime_config import runtime_config +from core.runtime_config import DEFAULT_CONFIG, runtime_config class ConfigCommand(Command): @@ -40,6 +45,14 @@ async def execute(self, ctx: CommandContext) -> None: await self._handle_command(ctx, args[1:]) elif action == "all": await self._show_all(ctx) + elif action == "diff": + await self._show_diff(ctx) + elif action == "validate": + await self._validate_config(ctx) + elif action == "history": + await self._show_history(ctx, args[1:]) + elif action == "rollback": + await self._rollback(ctx, args[1:]) elif action in ("autoread", "ar"): await self._handle_autoread(ctx, args[1:]) elif action in ("autoreact", "react"): @@ -67,7 +80,11 @@ async def _show_help(self, ctx: CommandContext) -> None: - `{p}config selfmode [on/off]` - {t("config.selfmode_desc")} - `{p}config ai [on/off/key/mode]` - {t("config.ai_desc")} - `{p}config owner` - {t("config.show_owner")} -- `{p}config all` - {t("config.show_all")}""" +- `{p}config all` - {t("config.show_all")} +- `{p}config diff` - {t("config.show_diff")} +- `{p}config validate` - {t("config.validate_desc")} +- `{p}config history [limit]` - {t("config.history_desc")} +- `{p}config rollback ` - {t("config.rollback_desc")}""" await ctx.client.reply(ctx.message, help_text) @@ -103,7 +120,11 @@ async def _toggle_feature(self, ctx: CommandContext, args: list[str]) -> None: current = all_features[feature_name] new_value = not current - runtime_config.set_feature(feature_name, new_value) + if not await self._apply_change( + ctx, + lambda: runtime_config.set_feature(feature_name, new_value), + ): + return status = ( f"{sym.ON} {t('common.enabled')}" if new_value else f"{sym.OFF} {t('common.disabled')}" @@ -124,7 +145,13 @@ async def _handle_command(self, ctx: CommandContext, args: list[str]) -> None: await self._list_commands(ctx) elif action == "enable" and len(args) >= 2: cmd_name = args[1].lower() - if runtime_config.enable_command(cmd_name): + changed = await self._apply_change( + ctx, + lambda: runtime_config.enable_command(cmd_name), + ) + if changed is None: + return + if changed: await ctx.client.reply(ctx.message, t_success("config.cmd_enabled", name=cmd_name)) else: await ctx.client.reply( @@ -135,7 +162,13 @@ async def _handle_command(self, ctx: CommandContext, args: list[str]) -> None: if cmd_name in ["config", "cfg", "settings"]: await ctx.client.reply(ctx.message, t_error("config.cannot_disable_config")) return - if runtime_config.disable_command(cmd_name): + changed = await self._apply_change( + ctx, + lambda: runtime_config.disable_command(cmd_name), + ) + if changed is None: + return + if changed: await ctx.client.reply(ctx.message, t_success("config.cmd_disabled", name=cmd_name)) else: await ctx.client.reply( @@ -173,13 +206,16 @@ async def _handle_get(self, ctx: CommandContext, args: list[str]) -> None: await ctx.client.reply(ctx.message, t_error("config.get_usage")) return - key = args[0] - value = runtime_config.get(key) + key_name = args[0] + value = runtime_config.get(key_name) if value is None: - await ctx.client.reply(ctx.message, t_error("config.key_not_found", key=key)) + await ctx.client.reply( + ctx.message, + t_error("config.key_not_found", **{"key": key_name}), + ) else: - await ctx.client.reply(ctx.message, f"*{key}*: `{value}`") + await ctx.client.reply(ctx.message, f"*{key_name}*: `{value}`") async def _handle_set(self, ctx: CommandContext, args: list[str]) -> None: """Set a config value.""" @@ -187,7 +223,7 @@ async def _handle_set(self, ctx: CommandContext, args: list[str]) -> None: await ctx.client.reply(ctx.message, t_error("config.set_usage")) return - key = args[0] + key_name = args[0] value_str = " ".join(args[1:]) if value_str.lower() == "true": @@ -199,8 +235,12 @@ async def _handle_set(self, ctx: CommandContext, args: list[str]) -> None: else: value = value_str - runtime_config.set(key, value) - await ctx.client.reply(ctx.message, t_success("config.value_set", key=key, value=value)) + if not await self._apply_change(ctx, lambda: runtime_config.set(key_name, value)): + return + await ctx.client.reply( + ctx.message, + t_success("config.value_set", value=value, **{"key": key_name}), + ) async def _handle_owner(self, ctx: CommandContext, args: list[str]) -> None: """Handle owner subcommand.""" @@ -214,10 +254,15 @@ async def _handle_owner(self, ctx: CommandContext, args: list[str]) -> None: if args[0].lower() == "set" and len(args) >= 2: new_owner = args[1] - runtime_config.set_owner_jid(new_owner) + if not await self._apply_change(ctx, lambda: runtime_config.set_owner_jid(new_owner)): + return await ctx.client.reply(ctx.message, t_success("config.owner_set", owner=new_owner)) elif args[0].lower() == "me": - runtime_config.set_owner_jid(ctx.message.sender_jid) + if not await self._apply_change( + ctx, + lambda: runtime_config.set_owner_jid(ctx.message.sender_jid), + ): + return await ctx.client.reply(ctx.message, t_success("config.owner_is_you")) else: await ctx.client.reply(ctx.message, t_error("config.owner_usage")) @@ -245,6 +290,165 @@ async def _show_all(self, ctx: CommandContext) -> None: await ctx.client.reply(ctx.message, "\n".join(lines)) + async def _show_diff(self, ctx: CommandContext) -> None: + """Show diff between runtime config and defaults.""" + current = deepcopy(runtime_config.all_config()) + current.pop("$schema", None) + defaults = deepcopy(DEFAULT_CONFIG) + + diffs = self._collect_diff(defaults, current) + if not diffs: + await ctx.client.reply(ctx.message, t_info("config.diff_no_changes")) + return + + lines = [f"*{t('config.diff_title')}*", ""] + for item in diffs[:50]: + kind = item["kind"] + path = item["path"] + if kind == "changed": + lines.append( + f"{sym.BULLET} `~ {path}`: `{self._fmt(item['default'])}` {sym.ARROW} `{self._fmt(item['current'])}`" + ) + elif kind == "custom": + lines.append(f"{sym.BULLET} `+ {path}`: `{self._fmt(item['current'])}`") + elif kind == "missing": + lines.append(f"{sym.BULLET} `- {path}`: `{self._fmt(item['default'])}`") + + if len(diffs) > 50: + lines.append(t("config.diff_truncated", count=str(len(diffs) - 50))) + + await ctx.client.reply(ctx.message, "\n".join(lines)) + + async def _validate_config(self, ctx: CommandContext) -> None: + """Validate current runtime config against schema.""" + ok, details = runtime_config.validate_current() + if ok: + await ctx.client.reply(ctx.message, t_success("config.validate_ok")) + return + await ctx.client.reply(ctx.message, t_error("config.validate_failed", details=details)) + + async def _show_history(self, ctx: CommandContext, args: list[str] | None = None) -> None: + """Show recent config history entries.""" + limit = 10 + if args: + raw = str(args[0]).strip() + if raw.isdigit(): + limit = max(1, min(50, int(raw))) + + entries = runtime_config.list_config_history(limit=limit) + if not entries: + await ctx.client.reply(ctx.message, t_info("config.history_empty")) + return + + lines = [f"*{t('config.history_title')}*", ""] + for item in entries: + lines.append( + t( + "config.history_item", + id=item.get("id", ""), + ts=item.get("ts", ""), + reason=item.get("reason", "update"), + ) + ) + + lines.append("") + lines.append(t("config.rollback_hint", prefix=ctx.prefix)) + await ctx.client.reply(ctx.message, "\n".join(lines)) + + async def _rollback(self, ctx: CommandContext, args: list[str]) -> None: + """Rollback config to a snapshot id.""" + if not args: + await ctx.client.reply(ctx.message, t_error("config.rollback_usage", prefix=ctx.prefix)) + return + + snapshot_id = args[0].strip().upper() + if not snapshot_id: + await ctx.client.reply(ctx.message, t_error("config.rollback_usage", prefix=ctx.prefix)) + return + + rolled_back = runtime_config.rollback_config(snapshot_id) + if not rolled_back: + await ctx.client.reply( + ctx.message, t_error("config.rollback_not_found", id=snapshot_id) + ) + return + + await ctx.client.reply( + ctx.message, + t_success( + "config.rollback_done", + id=rolled_back.get("id", snapshot_id), + ts=rolled_back.get("ts", ""), + ), + ) + + def _collect_diff( + self, + defaults: dict[str, Any], + current: dict[str, Any], + parent: str = "", + ) -> list[dict[str, Any]]: + """Collect nested config differences.""" + diffs: list[dict[str, Any]] = [] + keys = sorted(set(defaults.keys()) | set(current.keys())) + + for key in keys: + path = f"{parent}.{key}" if parent else key + in_defaults = key in defaults + in_current = key in current + + if not in_defaults: + diffs.append( + {"kind": "custom", "path": path, "default": None, "current": current[key]} + ) + continue + + if not in_current: + diffs.append( + {"kind": "missing", "path": path, "default": defaults[key], "current": None} + ) + continue + + default_val = defaults[key] + current_val = current[key] + if isinstance(default_val, dict) and isinstance(current_val, dict): + diffs.extend(self._collect_diff(default_val, current_val, path)) + continue + + if default_val != current_val: + diffs.append( + { + "kind": "changed", + "path": path, + "default": default_val, + "current": current_val, + } + ) + + return diffs + + def _fmt(self, value: Any) -> str: + """Format diff values for compact output.""" + text = str(value) + return text if len(text) <= 80 else text[:77] + "..." + + async def _apply_change(self, ctx: CommandContext, operation) -> Any: + """Run preflight validation then apply a config mutation safely.""" + ok, details = runtime_config.validate_current() + if not ok: + await ctx.client.reply(ctx.message, t_error("config.preflight_failed", details=details)) + return None + + try: + result = operation() + return True if result is None else result + except ValueError as e: + await ctx.client.reply(ctx.message, t_error("config.validation_failed", details=str(e))) + return None + except Exception as e: + await ctx.client.reply(ctx.message, t_error("config.update_failed", error=str(e))) + return None + async def _handle_autoread(self, ctx: CommandContext, args: list[str]) -> None: """Handle auto-read configuration.""" current = runtime_config.get_nested("bot", "auto_read", default=False) @@ -257,10 +461,17 @@ async def _handle_autoread(self, ctx: CommandContext, args: list[str]) -> None: action = args[0].lower() if action in ("on", "enable", "1", "true"): - runtime_config.set_nested("bot", "auto_read", True) + if not await self._apply_change( + ctx, lambda: runtime_config.set_nested("bot", "auto_read", True) + ): + return await ctx.client.reply(ctx.message, t_success("config.autoread_enabled")) elif action in ("off", "disable", "0", "false"): - runtime_config.set_nested("bot", "auto_read", False) + if not await self._apply_change( + ctx, + lambda: runtime_config.set_nested("bot", "auto_read", False), + ): + return await ctx.client.reply(ctx.message, t_success("config.autoread_disabled")) else: await ctx.client.reply(ctx.message, t_error("config.autoread_usage")) @@ -281,12 +492,22 @@ async def _handle_autoreact(self, ctx: CommandContext, args: list[str]) -> None: action = args[0] if action.lower() in ("off", "disable", "0", "false"): - runtime_config.set_nested("bot", "auto_react", False) + if not await self._apply_change( + ctx, + lambda: runtime_config.set_nested("bot", "auto_react", False), + ): + return await ctx.client.reply(ctx.message, t_success("config.autoreact_disabled")) else: emoji = action - runtime_config.set_nested("bot", "auto_react_emoji", emoji) - runtime_config.set_nested("bot", "auto_react", True) + if not await self._apply_change( + ctx, + lambda: ( + runtime_config.set_nested("bot", "auto_react_emoji", emoji), + runtime_config.set_nested("bot", "auto_react", True), + ), + ): + return await ctx.client.reply(ctx.message, t_success("config.autoreact_enabled", emoji=emoji)) async def _handle_selfmode(self, ctx: CommandContext, args: list[str]) -> None: @@ -301,10 +522,12 @@ async def _handle_selfmode(self, ctx: CommandContext, args: list[str]) -> None: action = args[0].lower() if action in ("on", "enable", "1", "true"): - runtime_config.set_self_mode(True) + if not await self._apply_change(ctx, lambda: runtime_config.set_self_mode(True)): + return await ctx.client.reply(ctx.message, t_success("config.selfmode_enabled")) elif action in ("off", "disable", "0", "false"): - runtime_config.set_self_mode(False) + if not await self._apply_change(ctx, lambda: runtime_config.set_self_mode(False)): + return await ctx.client.reply(ctx.message, t_success("config.selfmode_disabled")) else: await ctx.client.reply(ctx.message, t_error("config.selfmode_usage")) @@ -342,24 +565,28 @@ async def _handle_ai(self, ctx: CommandContext, args: list[str]) -> None: if not agentic_ai.api_key: await ctx.client.reply(ctx.message, t_error("config.ai_no_key")) return - agentic_ai.set_enabled(True) + if not await self._apply_change(ctx, lambda: agentic_ai.set_enabled(True)): + return await ctx.client.reply( ctx.message, t_success("config.ai_enabled", mode=agentic_ai.trigger_mode) ) elif action in ("off", "disable", "0", "false"): - agentic_ai.set_enabled(False) + if not await self._apply_change(ctx, lambda: agentic_ai.set_enabled(False)): + return await ctx.client.reply(ctx.message, t_success("config.ai_disabled")) elif action == "key" and len(args) >= 2: key = args[1] - agentic_ai.set_api_key(key) + if not await self._apply_change(ctx, lambda: agentic_ai.set_api_key(key)): + return await ctx.client.reply(ctx.message, t_success("config.ai_key_updated")) elif action == "mode" and len(args) >= 2: mode = args[1].lower() if mode in ("always", "mention", "reply"): - agentic_ai.set_trigger_mode(mode) + if not await self._apply_change(ctx, lambda: agentic_ai.set_trigger_mode(mode)): + return await ctx.client.reply(ctx.message, t_success("config.ai_mode_set", mode=mode)) else: await ctx.client.reply(ctx.message, t_error("config.ai_invalid_mode")) diff --git a/src/commands/owner/permission.py b/src/commands/owner/permission.py new file mode 100644 index 0000000..8289e8f --- /dev/null +++ b/src/commands/owner/permission.py @@ -0,0 +1,177 @@ +"""Owner command - manage role-based command permission overrides.""" + +from __future__ import annotations + +from core.command import Command, CommandContext, command_loader +from core.i18n import t, t_error, t_info, t_success +from core.runtime_config import runtime_config + + +class PermissionCommand(Command): + name = "permission" + aliases = ["permissions", "perm"] + description = "Manage role overrides for command access" + usage = "permission list|set|reset" + owner_only = True + + async def execute(self, ctx: CommandContext) -> None: + args = ctx.args + if not args: + await ctx.client.reply(ctx.message, t_error("permission.usage", prefix=ctx.prefix)) + return + + action = args[0].lower() + if action == "list": + await self._list_overrides(ctx, args[1:]) + return + if action == "set": + await self._set_override(ctx, args[1:]) + return + if action == "reset": + await self._reset_override(ctx, args[1:]) + return + + await ctx.client.reply(ctx.message, t_error("permission.usage", prefix=ctx.prefix)) + + async def _list_overrides(self, ctx: CommandContext, args: list[str]) -> None: + perms = runtime_config.get_command_permissions() + global_map = perms.get("global", {}) + groups_map = perms.get("groups", {}) + + scope = args[0].lower() if args else "" + if scope and scope not in {"global", "here"} and "@g.us" not in scope: + await ctx.client.reply(ctx.message, t_error("permission.invalid_scope")) + return + lines = [f"*{t('permission.title')}*", ""] + + if scope in {"", "global"}: + lines.append(f"*{t('permission.global_scope')}*") + if global_map: + for cmd_name in sorted(global_map.keys()): + lines.append( + t( + "permission.item", + command=cmd_name, + role=global_map[cmd_name], + ) + ) + else: + lines.append(t("permission.none")) + + if scope == "global": + await ctx.client.reply(ctx.message, "\n".join(lines)) + return + + group_jid = self._scope_to_group_jid(ctx, scope) + if group_jid: + lines.append("") + lines.append(f"*{t('permission.group_scope', group=group_jid)}*") + group_map = groups_map.get(group_jid, {}) + if group_map: + for cmd_name in sorted(group_map.keys()): + lines.append( + t( + "permission.item", + command=cmd_name, + role=group_map[cmd_name], + ) + ) + else: + lines.append(t("permission.none")) + + await ctx.client.reply(ctx.message, "\n".join(lines)) + + async def _set_override(self, ctx: CommandContext, args: list[str]) -> None: + if len(args) < 2: + await ctx.client.reply(ctx.message, t_error("permission.set_usage", prefix=ctx.prefix)) + return + + cmd_name = args[0].lower().strip() + role = args[1].lower().strip() + if role not in {"member", "admin", "owner"}: + await ctx.client.reply(ctx.message, t_error("permission.invalid_role")) + return + + cmd = command_loader.get(cmd_name) + if not cmd: + await ctx.client.reply(ctx.message, t_error("errors.not_found", command=cmd_name)) + return + + canonical = cmd.name.lower() + scope = args[2].lower() if len(args) >= 3 else "" + if scope and scope not in {"global", "here"} and "@g.us" not in scope: + await ctx.client.reply(ctx.message, t_error("permission.invalid_scope")) + return + + group_jid = self._scope_to_group_jid(ctx, scope) + if scope == "here" and not ctx.message.is_group: + await ctx.client.reply(ctx.message, t_error("permission.here_group_only")) + return + + try: + runtime_config.set_command_role_override(canonical, role, group_jid=group_jid) + except ValueError as e: + await ctx.client.reply(ctx.message, t_error("config.validation_failed", details=str(e))) + return + + scope_text = ( + t("permission.scope_group", group=group_jid) + if group_jid + else t("permission.scope_global") + ) + await ctx.client.reply( + ctx.message, + t_success( + "permission.set_done", + command=canonical, + role=role, + scope=scope_text, + ), + ) + + async def _reset_override(self, ctx: CommandContext, args: list[str]) -> None: + if not args: + await ctx.client.reply( + ctx.message, t_error("permission.reset_usage", prefix=ctx.prefix) + ) + return + + cmd_name = args[0].lower().strip() + cmd = command_loader.get(cmd_name) + if not cmd: + await ctx.client.reply(ctx.message, t_error("errors.not_found", command=cmd_name)) + return + + canonical = cmd.name.lower() + scope = args[1].lower() if len(args) >= 2 else "" + if scope and scope not in {"global", "here"} and "@g.us" not in scope: + await ctx.client.reply(ctx.message, t_error("permission.invalid_scope")) + return + + group_jid = self._scope_to_group_jid(ctx, scope) + if scope == "here" and not ctx.message.is_group: + await ctx.client.reply(ctx.message, t_error("permission.here_group_only")) + return + + removed = runtime_config.reset_command_role_override(canonical, group_jid=group_jid) + if not removed: + await ctx.client.reply(ctx.message, t_info("permission.no_override", command=canonical)) + return + + scope_text = ( + t("permission.scope_group", group=group_jid) + if group_jid + else t("permission.scope_global") + ) + await ctx.client.reply( + ctx.message, + t_success("permission.reset_done", command=canonical, scope=scope_text), + ) + + def _scope_to_group_jid(self, ctx: CommandContext, scope: str) -> str | None: + """Resolve scope token into optional group JID.""" + if not scope or scope == "global": + return None + if scope == "here": + return ctx.message.chat_jid if ctx.message.is_group else None + return scope if "@g.us" in scope else None diff --git a/src/commands/owner/privacy.py b/src/commands/owner/privacy.py new file mode 100644 index 0000000..98e4bd7 --- /dev/null +++ b/src/commands/owner/privacy.py @@ -0,0 +1,203 @@ +"""Owner privacy command - retention and AI memory controls.""" + +from __future__ import annotations + +from ai.memory import clear_memory +from core import symbols as sym +from core.analytics import command_analytics +from core.command import Command, CommandContext +from core.i18n import t, t_error, t_info, t_success +from core.privacy import ( + clear_chat_memory_override, + get_ai_memory_ttl_hours, + get_analytics_retention_days, + get_chat_memory_override, + is_chat_memory_enabled, + set_chat_memory_enabled, +) +from core.runtime_config import runtime_config + + +class PrivacyCommand(Command): + name = "privacy" + description = "Manage privacy and retention settings" + usage = "privacy status|retention|memory" + owner_only = True + + async def execute(self, ctx: CommandContext) -> None: + args = ctx.args + if not args: + await ctx.client.reply(ctx.message, t_error("privacy.usage", prefix=ctx.prefix)) + return + + action = args[0].lower() + if action == "status": + await self._status(ctx, args[1:]) + return + if action == "retention": + await self._retention(ctx, args[1:]) + return + if action == "memory": + await self._memory(ctx, args[1:]) + return + + await ctx.client.reply(ctx.message, t_error("privacy.usage", prefix=ctx.prefix)) + + async def _status(self, ctx: CommandContext, args: list[str]) -> None: + chat_jid = self._scope_to_chat_jid(ctx, args[0] if args else "here") + if not chat_jid: + await ctx.client.reply(ctx.message, t_error("privacy.invalid_scope")) + return + + override = get_chat_memory_override(chat_jid) + override_label = ( + t("privacy.inherit") + if override is None + else t("common.on") + if override + else t("common.off") + ) + lines = [ + sym.status_line(t("privacy.analytics_retention"), f"{get_analytics_retention_days()}d"), + sym.status_line( + t("privacy.ai_memory_global"), + t("common.on") + if runtime_config.get_nested("privacy", "ai_memory_enabled", default=True) + else t("common.off"), + ), + sym.status_line(t("privacy.ai_memory_ttl"), f"{get_ai_memory_ttl_hours()}h"), + sym.status_line(t("privacy.chat"), chat_jid), + sym.status_line( + t("privacy.chat_memory_effective"), + t("common.on") if is_chat_memory_enabled(chat_jid) else t("common.off"), + ), + sym.status_line(t("privacy.chat_memory_override"), override_label), + ] + await ctx.client.reply(ctx.message, sym.box(t("privacy.title"), lines)) + + async def _retention(self, ctx: CommandContext, args: list[str]) -> None: + if len(args) < 2: + await ctx.client.reply( + ctx.message, t_error("privacy.retention_usage", prefix=ctx.prefix) + ) + return + + target = args[0].lower() + value_raw = args[1] + if target == "analytics": + if not value_raw.isdigit(): + await ctx.client.reply(ctx.message, t_error("privacy.retention_range")) + return + days = int(value_raw) + if days < 1 or days > 365: + await ctx.client.reply(ctx.message, t_error("privacy.retention_range")) + return + runtime_config.set_nested("privacy", "analytics_retention_days", days) + command_analytics.apply_retention_now() + await ctx.client.reply( + ctx.message, + t_success("privacy.analytics_retention_set", days=str(days)), + ) + return + + if target == "memory-ttl": + try: + hours = float(value_raw) + except ValueError: + await ctx.client.reply(ctx.message, t_error("privacy.memory_ttl_range")) + return + + if hours < 1 or hours > 720: + await ctx.client.reply(ctx.message, t_error("privacy.memory_ttl_range")) + return + + runtime_config.set_nested("privacy", "ai_memory_ttl_hours", hours) + await ctx.client.reply( + ctx.message, + t_success("privacy.memory_ttl_set", hours=str(hours)), + ) + return + + await ctx.client.reply(ctx.message, t_error("privacy.retention_usage", prefix=ctx.prefix)) + + async def _memory(self, ctx: CommandContext, args: list[str]) -> None: + if not args: + await ctx.client.reply(ctx.message, t_error("privacy.memory_usage", prefix=ctx.prefix)) + return + + action = args[0].lower() + + if action in {"on", "off"}: + scope = args[1] if len(args) > 1 else "here" + chat_jid = self._scope_to_chat_jid(ctx, scope) + if not chat_jid: + await ctx.client.reply(ctx.message, t_error("privacy.invalid_scope")) + return + enabled = action == "on" + set_chat_memory_enabled(chat_jid, enabled) + await ctx.client.reply( + ctx.message, + t_success( + "privacy.memory_set", + status=t("common.on") if enabled else t("common.off"), + chat=chat_jid, + ), + ) + return + + if action == "clear": + scope = args[1] if len(args) > 1 else "here" + if scope.lower() == "all": + clear_memory() + await ctx.client.reply(ctx.message, t_success("privacy.memory_cleared_all")) + return + + chat_jid = self._scope_to_chat_jid(ctx, scope) + if not chat_jid: + await ctx.client.reply(ctx.message, t_error("privacy.invalid_scope")) + return + clear_memory(chat_jid) + await ctx.client.reply(ctx.message, t_success("privacy.memory_cleared", chat=chat_jid)) + return + + if action == "inherit": + scope = args[1] if len(args) > 1 else "here" + chat_jid = self._scope_to_chat_jid(ctx, scope) + if not chat_jid: + await ctx.client.reply(ctx.message, t_error("privacy.invalid_scope")) + return + removed = clear_chat_memory_override(chat_jid) + if not removed: + await ctx.client.reply(ctx.message, t_info("privacy.no_override", chat=chat_jid)) + return + await ctx.client.reply( + ctx.message, t_success("privacy.override_cleared", chat=chat_jid) + ) + return + + if action == "global" and len(args) >= 2: + mode = args[1].lower() + if mode not in {"on", "off"}: + await ctx.client.reply( + ctx.message, t_error("privacy.memory_global_usage", prefix=ctx.prefix) + ) + return + runtime_config.set_nested("privacy", "ai_memory_enabled", mode == "on") + await ctx.client.reply( + ctx.message, + t_success( + "privacy.memory_global_set", + status=t("common.on") if mode == "on" else t("common.off"), + ), + ) + return + + await ctx.client.reply(ctx.message, t_error("privacy.memory_usage", prefix=ctx.prefix)) + + def _scope_to_chat_jid(self, ctx: CommandContext, scope: str) -> str | None: + token = str(scope).strip().lower() + if token in {"", "here"}: + return ctx.message.chat_jid + if "@" in token: + return token + return None diff --git a/src/commands/owner/setup.py b/src/commands/owner/setup.py new file mode 100644 index 0000000..0dcc944 --- /dev/null +++ b/src/commands/owner/setup.py @@ -0,0 +1,287 @@ +"""Owner setup wizard command - guided first-run configuration.""" + +from __future__ import annotations + +from core import symbols as sym +from core.command import Command, CommandContext +from core.i18n import t, t_error, t_success +from core.runtime_config import runtime_config + + +class SetupCommand(Command): + name = "setup" + description = "Guided setup wizard for common bot settings" + usage = ( + "setup|start|status|done | setup owner me| | setup prefix | " + "setup anti-link [warn|delete|kick] | " + "setup anti-spam [warn|mute|kick] | " + "setup ai | setup ai-key " + ) + owner_only = True + + async def execute(self, ctx: CommandContext) -> None: + args = ctx.args + if not args: + await self._show_status(ctx, started=False) + return + + action = args[0].lower() + if action in {"start", "status"}: + await self._show_status(ctx, started=action == "start") + return + if action == "owner": + await self._set_owner(ctx, args[1:]) + return + if action == "prefix": + await self._set_prefix(ctx, args[1:]) + return + if action == "anti-link": + await self._set_anti_link(ctx, args[1:]) + return + if action == "anti-spam": + await self._set_anti_spam(ctx, args[1:]) + return + if action == "ai": + await self._set_ai(ctx, args[1:]) + return + if action == "ai-key": + await self._set_ai_key(ctx, args[1:]) + return + if action == "done": + await self._show_done(ctx) + return + + await ctx.client.reply(ctx.message, t_error("setup.usage", prefix=ctx.prefix)) + + async def _apply_change(self, ctx: CommandContext, operation) -> bool: + """Apply a setup mutation with validation-friendly errors.""" + try: + operation() + return True + except ValueError as e: + await ctx.client.reply(ctx.message, t_error("config.validation_failed", details=str(e))) + return False + except Exception as e: + await ctx.client.reply(ctx.message, t_error("config.update_failed", error=str(e))) + return False + + async def _show_status(self, ctx: CommandContext, *, started: bool) -> None: + owner = runtime_config.get_owner_jid() + prefix = runtime_config.prefix + anti_link = runtime_config.get_feature("anti_link") + anti_spam = runtime_config.get_feature("anti_spam") + ai_enabled = runtime_config.get_nested("agentic_ai", "enabled", default=False) + ai_key = runtime_config.get_nested("agentic_ai", "api_key", default="") + + lines = [ + sym.status_line(t("setup.owner"), t("setup.set") if owner else t("setup.not_set")), + sym.status_line(t("setup.prefix"), f"`{prefix}`"), + sym.status_line(t("setup.anti_link"), t("common.on") if anti_link else t("common.off")), + sym.status_line(t("setup.anti_spam"), t("common.on") if anti_spam else t("common.off")), + sym.status_line(t("setup.ai"), t("common.on") if ai_enabled else t("common.off")), + sym.status_line(t("setup.ai_key"), t("setup.set") if ai_key else t("setup.not_set")), + ] + + commands = [ + f"`{ctx.prefix}setup owner me`", + f"`{ctx.prefix}setup prefix !`", + f"`{ctx.prefix}setup anti-link on warn`", + f"`{ctx.prefix}setup anti-spam on warn`", + f"`{ctx.prefix}setup ai-key `", + f"`{ctx.prefix}setup ai on`", + f"`{ctx.prefix}setup done`", + ] + + parts = [] + if started: + parts.append(f"{sym.SPARKLE} {t('setup.started')}") + parts.append("") + + parts.append(sym.box(t("setup.title"), lines)) + parts.append("") + parts.append(sym.section(t("setup.next_steps"), commands)) + await ctx.client.reply(ctx.message, "\n".join(parts)) + + async def _set_owner(self, ctx: CommandContext, args: list[str]) -> None: + if not args: + await ctx.client.reply(ctx.message, t_error("setup.owner_usage", prefix=ctx.prefix)) + return + value = args[0] + owner_jid = ctx.message.sender_jid if value.lower() == "me" else value + if not await self._apply_change(ctx, lambda: runtime_config.set_owner_jid(owner_jid)): + return + await ctx.client.reply(ctx.message, t_success("setup.owner_set", owner=owner_jid)) + + async def _set_prefix(self, ctx: CommandContext, args: list[str]) -> None: + if not args: + await ctx.client.reply(ctx.message, t_error("setup.prefix_usage", prefix=ctx.prefix)) + return + prefix = args[0].strip() + if not prefix: + await ctx.client.reply(ctx.message, t_error("setup.prefix_usage", prefix=ctx.prefix)) + return + if not await self._apply_change( + ctx, lambda: runtime_config.set_nested("bot", "prefix", prefix) + ): + return + await ctx.client.reply(ctx.message, t_success("setup.prefix_set", prefix=prefix)) + + async def _set_anti_link(self, ctx: CommandContext, args: list[str]) -> None: + if not args: + await ctx.client.reply( + ctx.message, + t_error("setup.anti_link_usage", prefix=ctx.prefix), + ) + return + + state = args[0].lower() + if state not in {"on", "off"}: + await ctx.client.reply( + ctx.message, + t_error("setup.anti_link_usage", prefix=ctx.prefix), + ) + return + + if not await self._apply_change( + ctx, lambda: runtime_config.set_feature("anti_link", state == "on") + ): + return + + if len(args) > 1: + action = args[1].lower() + if action not in {"warn", "delete", "kick"}: + await ctx.client.reply(ctx.message, t_error("setup.anti_link_action_usage")) + return + if not await self._apply_change( + ctx, + lambda: runtime_config.set_nested("anti_link", "action", action), + ): + return + await ctx.client.reply( + ctx.message, + t_success( + "setup.anti_link_set", + status=t("common.on") if state == "on" else t("common.off"), + action=action, + ), + ) + return + + await ctx.client.reply( + ctx.message, + t_success( + "setup.anti_link_set", + status=t("common.on") if state == "on" else t("common.off"), + action=runtime_config.get_nested("anti_link", "action", default="warn"), + ), + ) + + async def _set_anti_spam(self, ctx: CommandContext, args: list[str]) -> None: + if not args: + await ctx.client.reply( + ctx.message, + t_error("setup.anti_spam_usage", prefix=ctx.prefix), + ) + return + + state = args[0].lower() + if state not in {"on", "off"}: + await ctx.client.reply( + ctx.message, + t_error("setup.anti_spam_usage", prefix=ctx.prefix), + ) + return + + if not await self._apply_change( + ctx, lambda: runtime_config.set_feature("anti_spam", state == "on") + ): + return + + if len(args) > 1: + action = args[1].lower() + if action not in {"warn", "mute", "kick"}: + await ctx.client.reply(ctx.message, t_error("setup.anti_spam_action_usage")) + return + if not await self._apply_change( + ctx, + lambda: runtime_config.set_nested("anti_spam", "action", action), + ): + return + await ctx.client.reply( + ctx.message, + t_success( + "setup.anti_spam_set", + status=t("common.on") if state == "on" else t("common.off"), + action=action, + ), + ) + return + + await ctx.client.reply( + ctx.message, + t_success( + "setup.anti_spam_set", + status=t("common.on") if state == "on" else t("common.off"), + action=runtime_config.get_nested("anti_spam", "action", default="warn"), + ), + ) + + async def _set_ai(self, ctx: CommandContext, args: list[str]) -> None: + if not args: + await ctx.client.reply(ctx.message, t_error("setup.ai_usage", prefix=ctx.prefix)) + return + + state = args[0].lower() + if state not in {"on", "off"}: + await ctx.client.reply(ctx.message, t_error("setup.ai_usage", prefix=ctx.prefix)) + return + + if state == "on": + key = runtime_config.get_nested("agentic_ai", "api_key", default="") + if not key: + await ctx.client.reply( + ctx.message, + t_error("setup.ai_missing_key", prefix=ctx.prefix), + ) + return + + if not await self._apply_change( + ctx, + lambda: runtime_config.set_nested("agentic_ai", "enabled", state == "on"), + ): + return + await ctx.client.reply( + ctx.message, + t_success("setup.ai_set", status=t("common.on") if state == "on" else t("common.off")), + ) + + async def _set_ai_key(self, ctx: CommandContext, args: list[str]) -> None: + if not args: + await ctx.client.reply(ctx.message, t_error("setup.ai_key_usage", prefix=ctx.prefix)) + return + + key = " ".join(args).strip() + if not key: + await ctx.client.reply(ctx.message, t_error("setup.ai_key_usage", prefix=ctx.prefix)) + return + + if not await self._apply_change( + ctx, + lambda: runtime_config.set_nested("agentic_ai", "api_key", key), + ): + return + await ctx.client.reply(ctx.message, t_success("setup.ai_key_set")) + + async def _show_done(self, ctx: CommandContext) -> None: + owner = runtime_config.get_owner_jid() + anti_spam = runtime_config.get_feature("anti_spam") + anti_link = runtime_config.get_feature("anti_link") + ai_enabled = runtime_config.get_nested("agentic_ai", "enabled", default=False) + + lines = [ + sym.status_line(t("setup.owner"), t("setup.set") if owner else t("setup.not_set")), + sym.status_line(t("setup.anti_link"), t("common.on") if anti_link else t("common.off")), + sym.status_line(t("setup.anti_spam"), t("common.on") if anti_spam else t("common.off")), + sym.status_line(t("setup.ai"), t("common.on") if ai_enabled else t("common.off")), + ] + await ctx.client.reply(ctx.message, sym.box(t("setup.completed"), lines)) diff --git a/src/commands/utility/_ai_text.py b/src/commands/utility/_ai_text.py new file mode 100644 index 0000000..b65c745 --- /dev/null +++ b/src/commands/utility/_ai_text.py @@ -0,0 +1,64 @@ +"""Shared helpers for AI-powered text utility commands.""" + +from __future__ import annotations + +import os + +from pydantic_ai import Agent + +from core.command import CommandContext +from core.i18n import t_error +from core.runtime_config import runtime_config + + +def get_ai_model() -> str: + """Build provider:model string from runtime config.""" + provider = runtime_config.get_nested("agentic_ai", "provider", default="openai") + model = runtime_config.get_nested("agentic_ai", "model", default="gpt-5-mini") + return f"{provider}:{model}" + + +def get_api_key() -> str: + """Get AI API key from env first, then runtime config.""" + env_key = os.getenv("AI_API_KEY", "") + if env_key: + return env_key + return runtime_config.get_nested("agentic_ai", "api_key", default="") + + +def ensure_provider_key(provider: str, api_key: str) -> None: + """Set provider-specific API key env vars for pydantic-ai.""" + if provider == "openai": + os.environ["OPENAI_API_KEY"] = api_key + elif provider == "anthropic": + os.environ["ANTHROPIC_API_KEY"] = api_key + elif provider == "google": + os.environ["GOOGLE_API_KEY"] = api_key + os.environ["GEMINI_API_KEY"] = api_key + elif provider == "groq": + os.environ["GROQ_API_KEY"] = api_key + + +async def ensure_ai_ready_or_reply(ctx: CommandContext, disabled_key: str) -> bool: + """Validate AI enabled + API key; reply with localized error when invalid.""" + ai_enabled = runtime_config.get_nested("agentic_ai", "enabled", default=False) + if not ai_enabled: + await ctx.client.reply(ctx.message, t_error(disabled_key)) + return False + + api_key = get_api_key() + if not api_key: + await ctx.client.reply(ctx.message, t_error("summarize.no_api_key")) + return False + return True + + +async def run_text_prompt(prompt: str) -> str: + """Execute a single text prompt with configured AI provider/model.""" + api_key = get_api_key() + provider = runtime_config.get_nested("agentic_ai", "provider", default="openai") + ensure_provider_key(provider, api_key) + + agent = Agent(get_ai_model(), output_type=str) + result = await agent.run(prompt) + return result.output.strip() if result.output else "" diff --git a/src/commands/utility/rewrite.py b/src/commands/utility/rewrite.py new file mode 100644 index 0000000..89ae59d --- /dev/null +++ b/src/commands/utility/rewrite.py @@ -0,0 +1,88 @@ +"""Rewrite command - AI-powered text rewrite in different styles.""" + +from __future__ import annotations + +from core import symbols as sym +from core.command import Command, CommandContext +from core.i18n import t, t_error + +from ._ai_text import ensure_ai_ready_or_reply, run_text_prompt + +_ALLOWED_STYLES = { + "formal", + "casual", + "concise", + "professional", + "friendly", +} + + +class RewriteCommand(Command): + name = "rewrite" + aliases = ["rephrase"] + description = "Rewrite text in a selected style" + usage = "rewrite [text]" + category = "utility" + cooldown = 10 + + async def execute(self, ctx: CommandContext) -> None: + if not await ensure_ai_ready_or_reply(ctx, "rewrite.ai_disabled"): + return + + if not ctx.args: + await ctx.client.reply(ctx.message, t_error("rewrite.usage", prefix=ctx.prefix)) + return + + style = ctx.args[0].strip().lower() + if style not in _ALLOWED_STYLES: + await ctx.client.reply( + ctx.message, + t_error("rewrite.invalid_style", styles=", ".join(sorted(_ALLOWED_STYLES))), + ) + return + + source_text = "" + quoted = ctx.message.quoted_message + if quoted and quoted.get("text"): + source_text = quoted["text"] + elif len(ctx.args) > 1: + source_text = " ".join(ctx.args[1:]).strip() + + if not source_text: + await ctx.client.reply(ctx.message, t_error("rewrite.no_text", prefix=ctx.prefix)) + return + + progress = await ctx.client.reply(ctx.message, f"{sym.LOADING} {t('rewrite.processing')}") + + prompt = ( + "You are a writing assistant. Rewrite the following text in a " + f"{style} style. Preserve the original meaning. " + "Return ONLY the rewritten text, with no explanation.\n\n" + f"Text:\n{source_text[:3000]}" + ) + + try: + rewritten = await run_text_prompt(prompt) + if not rewritten: + await ctx.client.edit_message( + ctx.message.chat_jid, + progress.ID, + t_error("rewrite.failed"), + ) + return + + output = sym.box( + t("rewrite.title"), + [ + sym.status_line(t("rewrite.style"), style), + "", + rewritten, + ], + ) + await ctx.client.edit_message(ctx.message.chat_jid, progress.ID, output) + except Exception: + await ctx.client.edit_message( + ctx.message.chat_jid, + progress.ID, + t_error("rewrite.failed"), + ) diff --git a/src/commands/utility/summarize.py b/src/commands/utility/summarize.py index 98fc4f6..3cb6f32 100644 --- a/src/commands/utility/summarize.py +++ b/src/commands/utility/summarize.py @@ -13,6 +13,7 @@ from core import symbols as sym from core.command import Command, CommandContext from core.i18n import t, t_error +from core.privacy import is_chat_memory_enabled from core.runtime_config import runtime_config _SUMMARIZE_PROMPT = """You are a concise summarizer. Summarize the following text in 2-4 bullet points. @@ -62,6 +63,10 @@ async def execute(self, ctx: CommandContext) -> None: if quoted and quoted.get("text"): text_to_summarize = quoted["text"] else: + if not is_chat_memory_enabled(ctx.message.chat_jid): + await ctx.client.reply(ctx.message, t_error("summarize.memory_disabled")) + return + from ai.memory import get_memory memory = get_memory(ctx.message.chat_jid) diff --git a/src/commands/utility/translate.py b/src/commands/utility/translate.py new file mode 100644 index 0000000..f344c33 --- /dev/null +++ b/src/commands/utility/translate.py @@ -0,0 +1,74 @@ +"""Translate command - AI-powered translation.""" + +from __future__ import annotations + +from core import symbols as sym +from core.command import Command, CommandContext +from core.i18n import t, t_error + +from ._ai_text import ensure_ai_ready_or_reply, run_text_prompt + + +class TranslateCommand(Command): + name = "translate" + aliases = ["tr"] + description = "Translate text using AI" + usage = "translate [text] (or reply to message)" + category = "utility" + cooldown = 10 + + async def execute(self, ctx: CommandContext) -> None: + if not await ensure_ai_ready_or_reply(ctx, "translate.ai_disabled"): + return + + if not ctx.args: + await ctx.client.reply(ctx.message, t_error("translate.usage", prefix=ctx.prefix)) + return + + target_language = ctx.args[0].strip() + source_text = "" + + quoted = ctx.message.quoted_message + if quoted and quoted.get("text"): + source_text = quoted["text"] + elif len(ctx.args) > 1: + source_text = " ".join(ctx.args[1:]).strip() + + if not source_text: + await ctx.client.reply(ctx.message, t_error("translate.no_text", prefix=ctx.prefix)) + return + + progress = await ctx.client.reply(ctx.message, f"{sym.LOADING} {t('translate.processing')}") + + prompt = ( + "You are a professional translator. Translate the following text into " + f"{target_language}. Preserve meaning and tone. " + "Return ONLY the translated text, with no explanation.\n\n" + f"Text:\n{source_text[:3000]}" + ) + + try: + translated = await run_text_prompt(prompt) + if not translated: + await ctx.client.edit_message( + ctx.message.chat_jid, + progress.ID, + t_error("translate.failed"), + ) + return + + output = sym.box( + t("translate.title"), + [ + sym.status_line(t("translate.target"), target_language), + "", + translated, + ], + ) + await ctx.client.edit_message(ctx.message.chat_jid, progress.ID, output) + except Exception: + await ctx.client.edit_message( + ctx.message.chat_jid, + progress.ID, + t_error("translate.failed"), + ) diff --git a/src/core/analytics.py b/src/core/analytics.py index 831477c..0424bf1 100644 --- a/src/core/analytics.py +++ b/src/core/analytics.py @@ -10,6 +10,7 @@ from core.db import kv_get_json, kv_set_json from core.logger import log_debug +from core.privacy import get_analytics_retention_days DEFAULT_RETENTION_DAYS = 30 SAVE_INTERVAL_SECONDS = 2.0 @@ -73,7 +74,8 @@ def record_command(self, name: str, user_jid: str = "", chat_jid: str = "") -> N def _prune(self) -> None: """Remove entries older than retention period.""" - cutoff = (datetime.now() - timedelta(days=DEFAULT_RETENTION_DAYS)).isoformat() + retention_days = get_analytics_retention_days() + cutoff = (datetime.now() - timedelta(days=retention_days)).isoformat() for cmd_name in list(self._data.get("commands", {})): entries = self._data["commands"][cmd_name] @@ -81,6 +83,11 @@ def _prune(self) -> None: if not self._data["commands"][cmd_name]: del self._data["commands"][cmd_name] + def apply_retention_now(self) -> None: + """Apply retention policy immediately and persist.""" + self._prune() + self._schedule_save(force=True) + def get_top_commands(self, days: int = 7, chat_jid: str = "") -> list[dict]: """Get top commands by usage in the last N days, optionally filtered by chat.""" cutoff = (datetime.now() - timedelta(days=days)).isoformat() diff --git a/src/core/automations.py b/src/core/automations.py index 0b55985..a9dc62d 100644 --- a/src/core/automations.py +++ b/src/core/automations.py @@ -39,6 +39,23 @@ def save_rules(group_jid: str, rules: list[dict[str, Any]]) -> None: GroupData(group_jid).save_automations(rules) +def get_automation_runtime(group_jid: str) -> dict[str, Any]: + """Get per-group automation runtime settings.""" + data = GroupData(group_jid).load("automation_runtime", {"dry_run": False}) + if not isinstance(data, dict): + data = {"dry_run": False} + data.setdefault("dry_run", False) + data["dry_run"] = bool(data.get("dry_run", False)) + return data + + +def set_automation_dry_run(group_jid: str, enabled: bool) -> None: + """Set per-group automation dry-run mode.""" + runtime = get_automation_runtime(group_jid) + runtime["dry_run"] = bool(enabled) + GroupData(group_jid).save("automation_runtime", runtime) + + def next_rule_id(rules: list[dict[str, Any]]) -> str: """Generate next rule id like A001.""" max_idx = 0 diff --git a/src/core/middlewares/automations.py b/src/core/middlewares/automations.py index 169bb7a..5c84ba9 100644 --- a/src/core/middlewares/automations.py +++ b/src/core/middlewares/automations.py @@ -1,6 +1,7 @@ """Automation middleware — evaluate and execute group automation rules.""" -from core.automations import execute_rule, load_rules, rule_matches +from core.automations import execute_rule, get_automation_runtime, load_rules, rule_matches +from core.i18n import t from core.runtime_config import runtime_config @@ -27,6 +28,9 @@ async def automations_middleware(ctx, next): await next() return + runtime = get_automation_runtime(ctx.msg.chat_jid) + dry_run = bool(runtime.get("dry_run", False)) + text = ctx.msg.text if not text and not _has_media_type_rules(rules): await next() @@ -43,6 +47,17 @@ async def automations_middleware(ctx, next): continue try: + if dry_run: + await ctx.bot.reply( + ctx.msg, + t( + "automation.dryrun_preview", + id=rule.get("id", ""), + action=rule.get("action_type", "reply"), + ), + ) + return + executed = await execute_rule(rule, ctx.bot, ctx.msg) if executed: return diff --git a/src/core/permissions.py b/src/core/permissions.py index a7e7854..2148b8d 100644 --- a/src/core/permissions.py +++ b/src/core/permissions.py @@ -86,6 +86,45 @@ def __bool__(self) -> bool: return self.allowed +_ROLE_RANK = { + "member": 0, + "admin": 1, + "owner": 2, +} + + +def _base_required_role(cmd: Command) -> str: + """Get intrinsic role requirement from command flags.""" + if cmd.owner_only: + return "owner" + if cmd.admin_only: + return "admin" + return "member" + + +def _normalize_role(role: str | None) -> str | None: + """Normalize a role string to member/admin/owner.""" + if not role: + return None + value = str(role).strip().lower() + if value in _ROLE_RANK: + return value + return None + + +def _resolve_required_role(cmd: Command, chat_jid: str) -> str: + """Resolve effective required role using overrides.""" + intrinsic = _base_required_role(cmd) + override = _normalize_role(runtime_config.get_command_role_override(cmd.name, chat_jid)) + if override is None: + return intrinsic + + # Never allow overriding an intrinsic owner-only command to lower role. + if intrinsic == "owner" and override != "owner": + return "owner" + return override + + async def check_command_permissions( cmd: Command, msg: MessageHelper, bot: BotClient ) -> PermissionResult: @@ -108,14 +147,30 @@ async def check_command_permissions( return PermissionResult(False, t_error("errors.private_only")) return PermissionResult(False, None) - if cmd.owner_only: + chat_jid = getattr(msg, "chat_jid", "") + required_role = _resolve_required_role(cmd, chat_jid) + + owner = runtime_config.get_owner_jid().strip() + if required_role == "owner" and not owner: + if await _allow_owner_bootstrap(cmd, msg): + return PermissionResult(True) + return PermissionResult(False, t_error("errors.owner_only")) + + is_owner = False + if owner: is_owner = await runtime_config.is_owner_async(msg.sender_jid, bot) - if not is_owner: - return PermissionResult(False, None) - if cmd.admin_only and msg.is_group: - if not await check_admin_permission(bot, msg.chat_jid, msg.sender_jid): + is_admin = False + if msg.is_group and not is_owner: + is_admin = await check_admin_permission(bot, msg.chat_jid, msg.sender_jid) + + current_role = "owner" if is_owner else "admin" if is_admin else "member" + if _ROLE_RANK[current_role] < _ROLE_RANK[required_role]: + if required_role == "owner": + return PermissionResult(False, t_error("errors.owner_only")) + if required_role == "admin": return PermissionResult(False, t_error("errors.admin_required")) + return PermissionResult(False, None) if cmd.bot_admin_required and msg.is_group: if not await check_bot_admin(bot, msg.chat_jid): @@ -134,3 +189,39 @@ async def is_owner_for_bypass(msg: MessageHelper, bot: BotClient) -> bool: This uses the JID resolver for accurate PN/LID comparison. """ return await runtime_config.is_owner_async(msg.sender_jid, bot) + + +async def _allow_owner_bootstrap(cmd: Command, msg: MessageHelper) -> bool: + """Allow limited owner bootstrap commands when owner_jid is not configured.""" + if msg.is_group: + return False + + text = (msg.text or "").strip().lower() + if not text: + return False + + parts = text.split() + if len(parts) < 2: + return False + + command_name = cmd.name.lower() + # /config owner me|set + if command_name == "config": + if len(parts) >= 3 and parts[1] == "owner": + if parts[2] == "me": + return True + if parts[2] == "set" and len(parts) >= 4: + return True + return False + + # /setup status|start|owner me|set + if command_name == "setup": + if parts[1] in {"status", "start"}: + return True + if len(parts) >= 3 and parts[1] == "owner": + if parts[2] == "me": + return True + if parts[2] == "set" and len(parts) >= 4: + return True + + return False diff --git a/src/core/privacy.py b/src/core/privacy.py new file mode 100644 index 0000000..40201d9 --- /dev/null +++ b/src/core/privacy.py @@ -0,0 +1,87 @@ +"""Privacy settings helpers for retention and AI memory controls.""" + +from __future__ import annotations + +from core.db import kv_get_json, kv_set_json +from core.runtime_config import runtime_config + +_SCOPE = "privacy" +_MEMORY_OVERRIDES_KEY = "memory_chat_overrides" + + +def get_analytics_retention_days() -> int: + """Get analytics retention in days from runtime config.""" + value = runtime_config.get_nested("privacy", "analytics_retention_days", default=30) + try: + days = int(value) + except (TypeError, ValueError): + return 30 + return max(1, min(365, days)) + + +def get_ai_memory_ttl_hours() -> float: + """Get AI memory TTL (hours) from runtime config.""" + value = runtime_config.get_nested("privacy", "ai_memory_ttl_hours", default=24) + try: + hours = float(value) + except (TypeError, ValueError): + return 24.0 + return max(1.0, min(720.0, hours)) + + +def _load_memory_overrides() -> dict[str, bool]: + """Load per-chat AI memory overrides from storage.""" + raw = kv_get_json(_SCOPE, _MEMORY_OVERRIDES_KEY, default={}) + if not isinstance(raw, dict): + return {} + result: dict[str, bool] = {} + for chat_id, enabled in raw.items(): + result[str(chat_id)] = bool(enabled) + return result + + +def _save_memory_overrides(overrides: dict[str, bool]) -> None: + """Save per-chat AI memory overrides.""" + kv_set_json(_SCOPE, _MEMORY_OVERRIDES_KEY, overrides) + + +def set_chat_memory_enabled(chat_jid: str, enabled: bool) -> None: + """Set per-chat AI memory enabled override.""" + chat = str(chat_jid).strip() + if not chat: + return + overrides = _load_memory_overrides() + overrides[chat] = bool(enabled) + _save_memory_overrides(overrides) + + +def clear_chat_memory_override(chat_jid: str) -> bool: + """Clear per-chat memory override. Returns True if removed.""" + chat = str(chat_jid).strip() + if not chat: + return False + overrides = _load_memory_overrides() + if chat not in overrides: + return False + overrides.pop(chat, None) + _save_memory_overrides(overrides) + return True + + +def get_chat_memory_override(chat_jid: str) -> bool | None: + """Get per-chat memory override (None means inherit global setting).""" + overrides = _load_memory_overrides() + chat = str(chat_jid).strip() + if not chat: + return None + if chat in overrides: + return bool(overrides[chat]) + return None + + +def is_chat_memory_enabled(chat_jid: str) -> bool: + """Resolve effective AI memory enabled value for a chat.""" + override = get_chat_memory_override(chat_jid) + if override is not None: + return override + return bool(runtime_config.get_nested("privacy", "ai_memory_enabled", default=True)) diff --git a/src/core/runtime_config.py b/src/core/runtime_config.py index d794001..867cc7a 100644 --- a/src/core/runtime_config.py +++ b/src/core/runtime_config.py @@ -10,6 +10,7 @@ import json import re from copy import deepcopy +from datetime import datetime from pathlib import Path from typing import Any @@ -24,6 +25,8 @@ Path(__file__).parent.parent.parent / "data" / ".runtime_overrides_migrated" ) DEFAULT_SCHEMA_PATH = "./config.schema.json" +HISTORY_FILE = Path(__file__).parent.parent.parent / "data" / "config_history.json" +MAX_CONFIG_HISTORY = 50 DEFAULT_CONFIG = { "bot": { @@ -116,6 +119,15 @@ "burst_limit": 5, "burst_window": 10.0, }, + "command_permissions": { + "global": {}, + "groups": {}, + }, + "privacy": { + "analytics_retention_days": 30, + "ai_memory_enabled": True, + "ai_memory_ttl_hours": 24, + }, "disabled_commands": [], "anti_spam": { "max_messages": 5, @@ -478,15 +490,134 @@ def _deep_merge(self, base: dict, overrides: dict) -> dict: def _save(self) -> None: """Persist full runtime config into config.json.""" - self._save_candidate(self._config) - - def _save_candidate(self, candidate: dict[str, Any]) -> None: + self._save_candidate(self._config, record_history=False) + + def _save_candidate( + self, + candidate: dict[str, Any], + *, + reason: str = "update", + record_history: bool = True, + ) -> None: """Validate and persist a candidate runtime config.""" normalized = self._ensure_schema_key(candidate) self._assert_valid_config(normalized) + + if ( + record_history + and isinstance(self._config, dict) + and self._config + and self._config != normalized + ): + self._record_history_snapshot(self._config, reason) + self._config = normalized jsonc.dump(self._config, CONFIG_FILE, indent=2) + def _load_history_entries(self) -> list[dict[str, Any]]: + """Load config history entries from disk.""" + try: + data = jsonc.load(HISTORY_FILE) + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + except Exception: + pass + return [] + + def _save_history_entries(self, entries: list[dict[str, Any]]) -> None: + """Persist config history entries to disk.""" + HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True) + jsonc.dump(entries, HISTORY_FILE, indent=2) + + def _next_history_id(self, entries: list[dict[str, Any]]) -> str: + """Generate next history id in H0001 format.""" + max_idx = 0 + for item in entries: + hid = str(item.get("id", "")) + if len(hid) == 5 and hid[0].upper() == "H" and hid[1:].isdigit(): + max_idx = max(max_idx, int(hid[1:])) + return f"H{max_idx + 1:04d}" + + def _record_history_snapshot(self, config: dict[str, Any], reason: str = "update") -> None: + """Record a full config snapshot before mutation.""" + entries = self._load_history_entries() + entries.append( + { + "id": self._next_history_id(entries), + "ts": datetime.now().isoformat(timespec="seconds"), + "reason": reason, + "config": deepcopy(config), + } + ) + if len(entries) > MAX_CONFIG_HISTORY: + entries = entries[-MAX_CONFIG_HISTORY:] + self._save_history_entries(entries) + + def validate_current(self) -> tuple[bool, str]: + """Validate current in-memory config and return status + details.""" + try: + current = self._ensure_schema_key(deepcopy(self._config)) + self._assert_valid_config(current) + return True, "" + except Exception as e: + return False, str(e) + + def validate_candidate(self, candidate: dict[str, Any]) -> tuple[bool, str]: + """Validate an arbitrary candidate config and return status + details.""" + try: + normalized = self._ensure_schema_key(deepcopy(candidate)) + self._assert_valid_config(normalized) + return True, "" + except Exception as e: + return False, str(e) + + def replace_config(self, candidate: dict[str, Any]) -> None: + """Atomically replace runtime config with a validated candidate.""" + self._save_candidate(candidate, reason="replace") + + def list_config_history(self, limit: int = 20) -> list[dict[str, Any]]: + """List config history metadata, newest first.""" + entries = self._load_history_entries() + meta = [] + for item in reversed(entries): + meta.append( + { + "id": str(item.get("id", "")), + "ts": str(item.get("ts", "")), + "reason": str(item.get("reason", "update")), + } + ) + if len(meta) >= max(1, int(limit)): + break + return meta + + def rollback_config(self, snapshot_id: str) -> dict[str, Any] | None: + """Rollback config to a snapshot id. Returns snapshot metadata or None.""" + sid = str(snapshot_id).strip().upper() + if not sid: + return None + + entries = self._load_history_entries() + target = None + for item in entries: + if str(item.get("id", "")).strip().upper() == sid: + target = item + break + + if not target: + return None + + candidate = target.get("config") + if not isinstance(candidate, dict): + return None + + self._save_candidate(candidate, reason=f"rollback:{sid}") + return { + "id": str(target.get("id", "")), + "ts": str(target.get("ts", "")), + "reason": str(target.get("reason", "update")), + } + def reload(self) -> None: """Reload configuration from file.""" self._load() @@ -664,6 +795,117 @@ def disable_command(self, command_name: str) -> bool: return True return False + def get_command_permissions(self) -> dict[str, Any]: + """Get command permission override maps.""" + raw = self.get("command_permissions", {}) + if not isinstance(raw, dict): + return {"global": {}, "groups": {}} + + global_map = raw.get("global", {}) + groups_map = raw.get("groups", {}) + if not isinstance(global_map, dict): + global_map = {} + if not isinstance(groups_map, dict): + groups_map = {} + return { + "global": {str(k).lower(): str(v).lower() for k, v in global_map.items()}, + "groups": { + str(g): { + str(k).lower(): str(v).lower() + for k, v in rules.items() + if isinstance(rules, dict) + } + for g, rules in groups_map.items() + }, + } + + def get_command_role_override( + self, command_name: str, group_jid: str | None = None + ) -> str | None: + """Get role override for command (group override first, then global).""" + name = command_name.lower().strip() + if not name: + return None + + perms = self.get_command_permissions() + if group_jid: + group_map = perms.get("groups", {}).get(group_jid, {}) + if isinstance(group_map, dict): + role = str(group_map.get(name, "")).lower().strip() + if role: + return role + + role = str(perms.get("global", {}).get(name, "")).lower().strip() + return role or None + + def set_command_role_override( + self, + command_name: str, + role: str, + group_jid: str | None = None, + ) -> None: + """Set role override for a command globally or for a specific group.""" + name = command_name.lower().strip() + normalized_role = role.lower().strip() + if normalized_role not in {"member", "admin", "owner"}: + raise ValueError(f"invalid role: {role}") + if not name: + raise ValueError("command name is required") + + updated = deepcopy(self._config) + perms = updated.get("command_permissions") + if not isinstance(perms, dict): + perms = {"global": {}, "groups": {}} + updated["command_permissions"] = perms + + if "global" not in perms or not isinstance(perms["global"], dict): + perms["global"] = {} + if "groups" not in perms or not isinstance(perms["groups"], dict): + perms["groups"] = {} + + if group_jid: + groups = perms["groups"] + group_map = groups.get(group_jid) + if not isinstance(group_map, dict): + group_map = {} + group_map[name] = normalized_role + groups[group_jid] = group_map + else: + perms["global"][name] = normalized_role + + self._save_candidate(updated) + + def reset_command_role_override(self, command_name: str, group_jid: str | None = None) -> bool: + """Remove role override for a command. Returns True if removed.""" + name = command_name.lower().strip() + if not name: + return False + + updated = deepcopy(self._config) + perms = updated.get("command_permissions") + if not isinstance(perms, dict): + return False + + changed = False + if group_jid: + groups = perms.get("groups") + if isinstance(groups, dict): + group_map = groups.get(group_jid) + if isinstance(group_map, dict) and name in group_map: + group_map.pop(name, None) + changed = True + if not group_map: + groups.pop(group_jid, None) + else: + global_map = perms.get("global") + if isinstance(global_map, dict) and name in global_map: + global_map.pop(name, None) + changed = True + + if changed: + self._save_candidate(updated) + return changed + def get(self, key: str, default: Any = None) -> Any: """Get a top-level config value.""" return self._config.get(key, default) diff --git a/src/locales/en.json b/src/locales/en.json index 148e884..8537cdb 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -318,7 +318,56 @@ "ai_key_updated": "API key updated.", "ai_mode_set": "Trigger mode set to `{mode}`.", "ai_invalid_mode": "Invalid mode. Use: `always`, `mention`, or `reply`", - "ai_unknown": "Unknown AI command. Use `config ai` for help." + "ai_unknown": "Unknown AI command. Use `config ai` for help.", + "show_diff": "Show differences from default config", + "validate_desc": "Validate current config against schema", + "history_desc": "Show recent config snapshots", + "rollback_desc": "Rollback config to a previous snapshot", + "diff_title": "Config Differences", + "diff_no_changes": "No differences from default config.", + "diff_truncated": "... and {count} more differences", + "validate_ok": "Config is valid.", + "validate_failed": "Config validation failed: {details}", + "preflight_failed": "Pre-check failed. Fix config first: {details}", + "validation_failed": "Update rejected by schema validation: {details}", + "update_failed": "Failed to update config: {error}", + "history_title": "Config History", + "history_empty": "No config history entries yet.", + "history_item": "`{id}` · `{ts}` · reason: `{reason}`", + "rollback_hint": "Use `{prefix}config rollback ` to restore a snapshot.", + "rollback_usage": "Usage: `{prefix}config rollback `", + "rollback_not_found": "Config snapshot `{id}` not found.", + "rollback_done": "Config rolled back to `{id}` (snapshot time: `{ts}`)." + }, + "setup": { + "title": "Setup Wizard", + "started": "Setup wizard started.", + "next_steps": "Next Steps", + "completed": "Setup Completed", + "usage": "Usage: `{prefix}setup [start|status|owner|prefix|anti-link|anti-spam|ai|ai-key|done]`", + "owner": "Owner", + "prefix": "Prefix", + "anti_link": "Anti-Link", + "anti_spam": "Anti-Spam", + "ai": "AI", + "ai_key": "AI Key", + "set": "Set", + "not_set": "Not set", + "owner_usage": "Usage: `{prefix}setup owner me|`", + "owner_set": "Owner set to `{owner}`.", + "prefix_usage": "Usage: `{prefix}setup prefix `", + "prefix_set": "Prefix set to `{prefix}`.", + "anti_link_usage": "Usage: `{prefix}setup anti-link [warn|delete|kick]`", + "anti_link_action_usage": "Anti-link action must be one of: `warn`, `delete`, `kick`.", + "anti_link_set": "Anti-link set to {status} with action `{action}`.", + "anti_spam_usage": "Usage: `{prefix}setup anti-spam [warn|mute|kick]`", + "anti_spam_action_usage": "Anti-spam action must be one of: `warn`, `mute`, `kick`.", + "anti_spam_set": "Anti-spam set to {status} with action `{action}`.", + "ai_usage": "Usage: `{prefix}setup ai `", + "ai_missing_key": "Set AI key first with `{prefix}setup ai-key `.", + "ai_set": "AI set to {status}.", + "ai_key_usage": "Usage: `{prefix}setup ai-key `", + "ai_key_set": "AI key updated." }, "eval": { "usage": "Usage: eval \n\nExample: eval 1 + 1", @@ -606,8 +655,13 @@ "title": "Automation Rules", "none": "No automation rules configured.", "item": "`{id}` [{status}] if `{trigger_type}` = `{trigger_value}` => `{action}`", - "usage": "Usage: `{prefix}automation list|add|remove|toggle`", + "usage": "Usage: `{prefix}automation list|add|remove|toggle|simulate|dryrun`", "add_usage": "Usage: `{prefix}automation add => [response]`", + "simulate_usage": "Usage: `{prefix}automation simulate ` or `{prefix}automation simulate --media `", + "dryrun_usage": "Usage: `{prefix}automation dryrun `", + "dryrun_status": "Dry-run mode: `{status}`", + "dryrun_set": "Automation dry-run is now `{status}`.", + "dryrun_preview": "[dry-run] Rule `{id}` matched. Would execute action: `{action}`.", "invalid_trigger": "Invalid trigger type. Use `contains`, `starts_with`, `exact_match`, `regex`, `link`, or `media_type`.", "invalid_action": "Invalid action type. Use `reply`, `warn`, `delete`, `kick`, or `mute`.", "missing_trigger_value": "Trigger value is required unless trigger type is `link`.", @@ -615,6 +669,11 @@ "removed": "Automation rule `{id}` removed.", "toggled": "Automation rule `{id}` is now `{status}`.", "not_found": "Automation rule `{id}` not found.", + "simulate_result": "Simulation for `{id}`: {status}", + "simulate_rule": "Rule: `{trigger_type}` = `{trigger_value}` => `{action}`", + "simulate_input": "Input: text=`{text}`, media=`{media}`", + "simulate_match": "MATCH", + "simulate_no_match": "NO MATCH", "default_reply": "Automation rule triggered.", "warn_message": "@{user}, your message triggered an automation warning.", "kicked": "@{user} was kicked by automation rule.", @@ -703,9 +762,40 @@ "processing": "Summarizing...", "ai_disabled": "AI is not enabled. Enable it with `config ai on`.", "no_api_key": "AI API key is not configured.", + "memory_disabled": "Chat memory is disabled for this chat. Reply to a message to summarize specific text.", "no_content": "Nothing to summarize. Reply to a message or have recent chat history.", "failed": "Failed to generate summary." }, + "translate": { + "title": "Translation", + "processing": "Translating...", + "ai_disabled": "AI is not enabled. Enable it with `config ai on`.", + "usage": "Usage: `{prefix}translate [text]` or reply to a message.", + "no_text": "Provide text after the language or reply to a message.", + "target": "Target language", + "failed": "Failed to translate text." + }, + "rewrite": { + "title": "Rewrite", + "processing": "Rewriting...", + "ai_disabled": "AI is not enabled. Enable it with `config ai on`.", + "usage": "Usage: `{prefix}rewrite [text]` or reply to a message.", + "invalid_style": "Invalid style. Choose one of: {styles}", + "no_text": "Provide text to rewrite or reply to a message.", + "style": "Style", + "failed": "Failed to rewrite text." + }, + "status": { + "title": "System Status", + "uptime": "Uptime", + "db": "Database", + "webhook_worker": "Webhook worker", + "webhook_queue": "Webhook queue", + "ai": "AI", + "ai_model": "AI model", + "rate_limit": "Rate limiter", + "commands": "Commands" + }, "whois": { "title": "User Info", "user_id": "User ID", @@ -716,6 +806,49 @@ "superadmin": "Super Admin", "member": "Member" }, + "permission": { + "title": "Command Permissions", + "usage": "Usage: `{prefix}permission list [global|here|] | {prefix}permission set [global|here|] | {prefix}permission reset [global|here|]`", + "set_usage": "Usage: `{prefix}permission set [global|here|]`", + "reset_usage": "Usage: `{prefix}permission reset [global|here|]`", + "invalid_role": "Invalid role. Use `member`, `admin`, or `owner`.", + "invalid_scope": "Invalid scope. Use `global`, `here`, or a group JID ending with `@g.us`.", + "here_group_only": "`here` scope can only be used in group chats.", + "global_scope": "Global overrides", + "group_scope": "Group overrides ({group})", + "item": "• `{command}` => `{role}`", + "none": "No overrides.", + "scope_global": "global", + "scope_group": "group `{group}`", + "set_done": "Permission for `{command}` set to `{role}` in {scope}.", + "reset_done": "Permission override for `{command}` removed from {scope}.", + "no_override": "No override found for `{command}` in that scope." + }, + "privacy": { + "title": "Privacy Settings", + "usage": "Usage: `{prefix}privacy status [here|] | {prefix}privacy retention | {prefix}privacy memory [here||all]`", + "invalid_scope": "Invalid scope. Use `here` or a full chat JID.", + "inherit": "inherit global", + "analytics_retention": "Analytics retention", + "analytics_retention_set": "Analytics retention set to `{days}` days.", + "retention_usage": "Usage: `{prefix}privacy retention `", + "retention_range": "Analytics retention must be between 1 and 365 days.", + "ai_memory_global": "AI memory (global)", + "ai_memory_ttl": "AI memory TTL", + "memory_ttl_range": "AI memory TTL must be between 1 and 720 hours.", + "memory_ttl_set": "AI memory TTL set to `{hours}` hours.", + "chat": "Chat", + "chat_memory_effective": "Chat memory (effective)", + "chat_memory_override": "Chat memory override", + "memory_usage": "Usage: `{prefix}privacy memory [here||all]`", + "memory_global_usage": "Usage: `{prefix}privacy memory global `", + "memory_global_set": "Global AI memory is now `{status}`.", + "memory_set": "Chat memory is now `{status}` for `{chat}`.", + "memory_cleared": "AI memory cleared for `{chat}`.", + "memory_cleared_all": "AI memory cleared for all cached chats.", + "override_cleared": "Memory override removed for `{chat}`.", + "no_override": "No override found for `{chat}`." + }, "anti_spam": { "warn_message": "@{user}, slow down! You are sending messages too fast.", "muted": "@{user} has been muted for spamming.", diff --git a/src/locales/id.json b/src/locales/id.json index ee90756..983082d 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -318,7 +318,56 @@ "ai_key_updated": "API key updated.", "ai_mode_set": "Trigger mode di-set ke `{mode}`.", "ai_invalid_mode": "Invalid mode. Pilih: `always`, `mention`, atau `reply`", - "ai_unknown": "Unknown AI command. Pake `config ai` buat help." + "ai_unknown": "Unknown AI command. Pake `config ai` buat help.", + "show_diff": "Liat perbedaan sama default config", + "validate_desc": "Validasi config sekarang ke schema", + "history_desc": "Liat snapshot config terbaru", + "rollback_desc": "Balikin config ke snapshot sebelumnya", + "diff_title": "Perbedaan Config", + "diff_no_changes": "Gak ada perbedaan dari default config.", + "diff_truncated": "... dan {count} perbedaan lagi", + "validate_ok": "Config valid.", + "validate_failed": "Validasi config gagal: {details}", + "preflight_failed": "Cek awal gagal. Benerin config dulu: {details}", + "validation_failed": "Update ditolak schema validation: {details}", + "update_failed": "Gagal update config: {error}", + "history_title": "Riwayat Config", + "history_empty": "Belum ada riwayat config.", + "history_item": "`{id}` · `{ts}` · alasan: `{reason}`", + "rollback_hint": "Pake `{prefix}config rollback ` buat balikin snapshot.", + "rollback_usage": "Cara pake: `{prefix}config rollback `", + "rollback_not_found": "Snapshot config `{id}` gak ketemu.", + "rollback_done": "Config dibalikin ke `{id}` (waktu snapshot: `{ts}`)." + }, + "setup": { + "title": "Setup Wizard", + "started": "Setup wizard dimulai.", + "next_steps": "Langkah Berikutnya", + "completed": "Setup Selesai", + "usage": "Cara pake: `{prefix}setup [start|status|owner|prefix|anti-link|anti-spam|ai|ai-key|done]`", + "owner": "Owner", + "prefix": "Prefix", + "anti_link": "Anti-Link", + "anti_spam": "Anti-Spam", + "ai": "AI", + "ai_key": "AI Key", + "set": "Udah di-set", + "not_set": "Belum di-set", + "owner_usage": "Cara pake: `{prefix}setup owner me|`", + "owner_set": "Owner di-set ke `{owner}`.", + "prefix_usage": "Cara pake: `{prefix}setup prefix `", + "prefix_set": "Prefix di-set ke `{prefix}`.", + "anti_link_usage": "Cara pake: `{prefix}setup anti-link [warn|delete|kick]`", + "anti_link_action_usage": "Aksi anti-link harus salah satu: `warn`, `delete`, `kick`.", + "anti_link_set": "Anti-link di-set ke {status} dengan aksi `{action}`.", + "anti_spam_usage": "Cara pake: `{prefix}setup anti-spam [warn|mute|kick]`", + "anti_spam_action_usage": "Aksi anti-spam harus salah satu: `warn`, `mute`, `kick`.", + "anti_spam_set": "Anti-spam di-set ke {status} dengan aksi `{action}`.", + "ai_usage": "Cara pake: `{prefix}setup ai `", + "ai_missing_key": "Set dulu AI key pake `{prefix}setup ai-key `.", + "ai_set": "AI di-set ke {status}.", + "ai_key_usage": "Cara pake: `{prefix}setup ai-key `", + "ai_key_set": "AI key di-update." }, "eval": { "usage": "Usage: eval \n\nExample: eval 1 + 1", @@ -607,8 +656,13 @@ "title": "Aturan Otomasi", "none": "Belum ada aturan otomasi.", "item": "`{id}` [{status}] jika `{trigger_type}` = `{trigger_value}` => `{action}`", - "usage": "Cara pake: `{prefix}automation list|add|remove|toggle`", + "usage": "Cara pake: `{prefix}automation list|add|remove|toggle|simulate|dryrun`", "add_usage": "Cara pake: `{prefix}automation add => [response]`", + "simulate_usage": "Cara pake: `{prefix}automation simulate ` atau `{prefix}automation simulate --media `", + "dryrun_usage": "Cara pake: `{prefix}automation dryrun `", + "dryrun_status": "Mode dry-run: `{status}`", + "dryrun_set": "Dry-run otomasi sekarang `{status}`.", + "dryrun_preview": "[dry-run] Rule `{id}` cocok. Aksi yang akan dijalankan: `{action}`.", "invalid_trigger": "Jenis trigger gak valid. Pake `contains`, `starts_with`, `exact_match`, `regex`, `link`, atau `media_type`.", "invalid_action": "Jenis action gak valid. Pake `reply`, `warn`, `delete`, `kick`, atau `mute`.", "missing_trigger_value": "Nilai trigger wajib diisi kecuali tipe trigger `link`.", @@ -616,6 +670,11 @@ "removed": "Rule otomasi `{id}` dihapus.", "toggled": "Rule otomasi `{id}` sekarang `{status}`.", "not_found": "Rule otomasi `{id}` gak ketemu.", + "simulate_result": "Simulasi untuk `{id}`: {status}", + "simulate_rule": "Rule: `{trigger_type}` = `{trigger_value}` => `{action}`", + "simulate_input": "Input: teks=`{text}`, media=`{media}`", + "simulate_match": "COCOK", + "simulate_no_match": "GAK COCOK", "default_reply": "Aturan otomasi aktif.", "warn_message": "@{user}, pesan kamu kena peringatan aturan otomasi.", "kicked": "@{user} di-kick oleh aturan otomasi.", @@ -704,9 +763,40 @@ "processing": "Lagi merangkum...", "ai_disabled": "AI belum diaktifin. Aktifin pake `config ai on`.", "no_api_key": "API key AI belum di-set.", + "memory_disabled": "Memori chat dimatiin buat chat ini. Reply pesan tertentu buat dirangkum.", "no_content": "Gak ada yang bisa dirangkum. Reply ke pesan atau punya riwayat chat.", "failed": "Gagal bikin ringkasan." }, + "translate": { + "title": "Terjemahan", + "processing": "Lagi nerjemahin...", + "ai_disabled": "AI belum diaktifin. Aktifin pake `config ai on`.", + "usage": "Cara pake: `{prefix}translate [teks]` atau reply pesan.", + "no_text": "Kasih teks setelah bahasa tujuan atau reply pesan.", + "target": "Bahasa tujuan", + "failed": "Gagal nerjemahin teks." + }, + "rewrite": { + "title": "Tulis Ulang", + "processing": "Lagi nulis ulang...", + "ai_disabled": "AI belum diaktifin. Aktifin pake `config ai on`.", + "usage": "Cara pake: `{prefix}rewrite [teks]` atau reply pesan.", + "invalid_style": "Style gak valid. Pilih salah satu: {styles}", + "no_text": "Kasih teks yang mau ditulis ulang atau reply pesan.", + "style": "Style", + "failed": "Gagal nulis ulang teks." + }, + "status": { + "title": "Status Sistem", + "uptime": "Uptime", + "db": "Database", + "webhook_worker": "Worker webhook", + "webhook_queue": "Antrian webhook", + "ai": "AI", + "ai_model": "Model AI", + "rate_limit": "Rate limiter", + "commands": "Command" + }, "whois": { "title": "Info User", "user_id": "User ID", @@ -717,6 +807,49 @@ "superadmin": "Super Admin", "member": "Member" }, + "permission": { + "title": "Permission Command", + "usage": "Cara pake: `{prefix}permission list [global|here|] | {prefix}permission set [global|here|] | {prefix}permission reset [global|here|]`", + "set_usage": "Cara pake: `{prefix}permission set [global|here|]`", + "reset_usage": "Cara pake: `{prefix}permission reset [global|here|]`", + "invalid_role": "Role gak valid. Pake `member`, `admin`, atau `owner`.", + "invalid_scope": "Scope gak valid. Pake `global`, `here`, atau group JID yang diakhiri `@g.us`.", + "here_group_only": "Scope `here` cuma bisa dipake di grup.", + "global_scope": "Override global", + "group_scope": "Override grup ({group})", + "item": "• `{command}` => `{role}`", + "none": "Belum ada override.", + "scope_global": "global", + "scope_group": "grup `{group}`", + "set_done": "Permission `{command}` di-set ke `{role}` di {scope}.", + "reset_done": "Override permission `{command}` dihapus dari {scope}.", + "no_override": "Gak ada override buat `{command}` di scope itu." + }, + "privacy": { + "title": "Pengaturan Privasi", + "usage": "Cara pake: `{prefix}privacy status [here|] | {prefix}privacy retention | {prefix}privacy memory [here||all]`", + "invalid_scope": "Scope gak valid. Pake `here` atau chat JID lengkap.", + "inherit": "ikut global", + "analytics_retention": "Retensi analytics", + "analytics_retention_set": "Retensi analytics di-set ke `{days}` hari.", + "retention_usage": "Cara pake: `{prefix}privacy retention `", + "retention_range": "Retensi analytics harus 1 sampe 365 hari.", + "ai_memory_global": "Memori AI (global)", + "ai_memory_ttl": "TTL memori AI", + "memory_ttl_range": "TTL memori AI harus 1 sampe 720 jam.", + "memory_ttl_set": "TTL memori AI di-set ke `{hours}` jam.", + "chat": "Chat", + "chat_memory_effective": "Memori chat (efektif)", + "chat_memory_override": "Override memori chat", + "memory_usage": "Cara pake: `{prefix}privacy memory [here||all]`", + "memory_global_usage": "Cara pake: `{prefix}privacy memory global `", + "memory_global_set": "Memori AI global sekarang `{status}`.", + "memory_set": "Memori chat sekarang `{status}` buat `{chat}`.", + "memory_cleared": "Memori AI buat `{chat}` udah dikosongin.", + "memory_cleared_all": "Memori AI buat semua chat cache udah dikosongin.", + "override_cleared": "Override memori buat `{chat}` udah dihapus.", + "no_override": "Gak ada override buat `{chat}`." + }, "anti_spam": { "warn_message": "@{user}, pelan-pelan! Lu ngirim pesan terlalu cepet.", "muted": "@{user} di-mute karena spam.", diff --git a/src/main.py b/src/main.py index e0cd8b3..1aa1b6c 100644 --- a/src/main.py +++ b/src/main.py @@ -12,6 +12,7 @@ import subprocess import sys import traceback +from copy import deepcopy from pathlib import Path import segno @@ -70,6 +71,7 @@ def _parse_args(): ) sub = parser.add_subparsers(dest="command") sub.add_parser("update", help="Pull latest code and sync dependencies") + sub.add_parser("setup", help="Run interactive setup wizard") parser.add_argument("--debug", action="store_true", help="Enable debug logging") parser.add_argument("--qr", action="store_true", help="Force QR code login") @@ -104,6 +106,299 @@ def _run_update(): console.print("\n[bold green]✅ Update complete![/bold green]") +def _prompt_text(label: str, default: str = "") -> str: + """Prompt for text input with optional default value.""" + suffix = f" [{default}]" if default else "" + value = input(f"{label}{suffix}: ").strip() + return value if value else default + + +def _prompt_yes_no(label: str, default: bool) -> bool: + """Prompt for yes/no input with default choice.""" + hint = "Y/n" if default else "y/N" + while True: + value = input(f"{label} ({hint}): ").strip().lower() + if not value: + return default + if value in {"y", "yes", "1", "true", "on"}: + return True + if value in {"n", "no", "0", "false", "off"}: + return False + print("Please answer with y or n.") + + +def _prompt_choice(label: str, options: list[str], default: str) -> str: + """Prompt for one value from a list of options.""" + opts = "/".join(options) + while True: + value = input(f"{label} ({opts}) [{default}]: ").strip().lower() + selected = value or default + if selected in options: + return selected + print(f"Please choose one of: {opts}") + + +def _prompt_int( + label: str, default: int, min_value: int | None = None, max_value: int | None = None +) -> int: + """Prompt for integer input with optional range bounds.""" + while True: + raw = input(f"{label} [{default}]: ").strip() + if not raw: + value = default + else: + try: + value = int(raw) + except ValueError: + print("Please enter a valid integer.") + continue + + if min_value is not None and value < min_value: + print(f"Value must be >= {min_value}.") + continue + if max_value is not None and value > max_value: + print(f"Value must be <= {max_value}.") + continue + return value + + +def _prompt_float( + label: str, + default: float, + min_value: float | None = None, + max_value: float | None = None, +) -> float: + """Prompt for float input with optional range bounds.""" + while True: + raw = input(f"{label} [{default}]: ").strip() + if not raw: + value = default + else: + try: + value = float(raw) + except ValueError: + print("Please enter a valid number.") + continue + + if min_value is not None and value < min_value: + print(f"Value must be >= {min_value}.") + continue + if max_value is not None and value > max_value: + print(f"Value must be <= {max_value}.") + continue + return value + + +def _run_setup(): + """Run interactive setup wizard without starting the bot runtime.""" + current = deepcopy(runtime_config.all_config()) + candidate = deepcopy(current) + + bot_cfg = candidate.setdefault("bot", {}) + features = candidate.setdefault("features", {}) + anti_link = candidate.setdefault("anti_link", {}) + anti_spam = candidate.setdefault("anti_spam", {}) + ai_cfg = candidate.setdefault("agentic_ai", {}) + dashboard_cfg = candidate.setdefault("dashboard", {}) + rate_limit_cfg = candidate.setdefault("rate_limit", {}) + + console.print("[bold cyan]Zero Ichi Setup Wizard[/bold cyan]") + console.print("Configure key settings. Press Enter to keep current values.\n") + + bot_cfg["name"] = _prompt_text("Session name", str(bot_cfg.get("name", "zero_ichi_bot"))) + + owner = _prompt_text("Owner JID", str(bot_cfg.get("owner_jid", ""))) + bot_cfg["owner_jid"] = owner + + prefix = _prompt_text("Command prefix", str(bot_cfg.get("prefix", "/"))) + bot_cfg["prefix"] = prefix or "/" + + login_method = _prompt_choice( + "Login method", + ["qr", "pair_code"], + str(bot_cfg.get("login_method", "QR")).lower(), + ) + bot_cfg["login_method"] = login_method.upper() + if login_method == "pair_code": + bot_cfg["phone_number"] = _prompt_text( + "Pair-code phone number", + str(bot_cfg.get("phone_number", "")), + ) + else: + bot_cfg["phone_number"] = "" + + bot_cfg["auto_read"] = _prompt_yes_no( + "Enable auto-read", + bool(bot_cfg.get("auto_read", False)), + ) + bot_cfg["self_mode"] = _prompt_yes_no( + "Enable self mode", + bool(bot_cfg.get("self_mode", False)), + ) + + auto_react = _prompt_yes_no( + "Enable auto-react", + bool(bot_cfg.get("auto_react", False)), + ) + bot_cfg["auto_react"] = auto_react + if auto_react: + bot_cfg["auto_react_emoji"] = _prompt_text( + "Auto-react emoji", + str(bot_cfg.get("auto_react_emoji", "👍")) or "👍", + ) + else: + bot_cfg["auto_react_emoji"] = "" + + dashboard_cfg["enabled"] = _prompt_yes_no( + "Enable dashboard API", + bool(dashboard_cfg.get("enabled", False)), + ) + + anti_link_enabled = _prompt_yes_no( + "Enable anti-link", + bool(features.get("anti_link", True)), + ) + features["anti_link"] = anti_link_enabled + anti_link["action"] = _prompt_choice( + "Anti-link action", + ["warn", "delete", "kick"], + str(anti_link.get("action", "warn")), + ) + + anti_spam_enabled = _prompt_yes_no( + "Enable anti-spam", + bool(features.get("anti_spam", False)), + ) + features["anti_spam"] = anti_spam_enabled + anti_spam["action"] = _prompt_choice( + "Anti-spam action", + ["warn", "mute", "kick"], + str(anti_spam.get("action", "warn")), + ) + anti_spam["max_messages"] = _prompt_int( + "Anti-spam max messages in window", + int(anti_spam.get("max_messages", 5)), + min_value=2, + max_value=50, + ) + anti_spam["window_seconds"] = _prompt_int( + "Anti-spam window seconds", + int(anti_spam.get("window_seconds", 10)), + min_value=1, + max_value=120, + ) + anti_spam["whitelist_admins"] = _prompt_yes_no( + "Whitelist admins for anti-spam", + bool(anti_spam.get("whitelist_admins", True)), + ) + + ai_enabled = _prompt_yes_no("Enable AI", bool(ai_cfg.get("enabled", False))) + features.setdefault("automation_rules", True) + ai_cfg["enabled"] = ai_enabled + + if ai_enabled: + ai_cfg["provider"] = _prompt_choice( + "AI provider", + ["openai", "google", "anthropic", "groq"], + str(ai_cfg.get("provider", "openai")).lower(), + ) + ai_cfg["model"] = _prompt_text( + "AI model", + str(ai_cfg.get("model", "gpt-5-mini")), + ) + ai_cfg["trigger_mode"] = _prompt_choice( + "AI trigger mode", + ["mention", "reply", "always"], + str(ai_cfg.get("trigger_mode", "mention")).lower(), + ) + ai_cfg["owner_only"] = _prompt_yes_no( + "AI owner-only mode", + bool(ai_cfg.get("owner_only", True)), + ) + + current_key = str(ai_cfg.get("api_key", "")) + if ai_enabled and not current_key: + key_input = _prompt_text("AI API key") + ai_cfg["api_key"] = key_input + elif ai_enabled and current_key: + if _prompt_yes_no("Update AI API key", False): + ai_cfg["api_key"] = _prompt_text("AI API key", current_key) + + rate_limit_cfg["enabled"] = _prompt_yes_no( + "Enable rate limiter", + bool(rate_limit_cfg.get("enabled", True)), + ) + rate_limit_cfg["user_cooldown"] = _prompt_float( + "Rate limit user cooldown (seconds)", + float(rate_limit_cfg.get("user_cooldown", 3.0)), + min_value=0.0, + max_value=60.0, + ) + rate_limit_cfg["command_cooldown"] = _prompt_float( + "Rate limit command cooldown (seconds)", + float(rate_limit_cfg.get("command_cooldown", 2.0)), + min_value=0.0, + max_value=60.0, + ) + rate_limit_cfg["burst_limit"] = _prompt_int( + "Rate limit burst count", + int(rate_limit_cfg.get("burst_limit", 5)), + min_value=1, + max_value=50, + ) + rate_limit_cfg["burst_window"] = _prompt_float( + "Rate limit burst window (seconds)", + float(rate_limit_cfg.get("burst_window", 10.0)), + min_value=1.0, + max_value=120.0, + ) + + ok, details = runtime_config.validate_candidate(candidate) + if not ok: + console.print(f"\n[bold red]Configuration invalid:[/bold red] {details}") + return + + console.print("\n[bold]Summary[/bold]") + console.print(f"- session_name: {bot_cfg.get('name')}") + console.print(f"- owner_jid: {bot_cfg.get('owner_jid') or '(not set)'}") + console.print(f"- prefix: {bot_cfg.get('prefix')}") + console.print(f"- login_method: {bot_cfg.get('login_method')}") + if bot_cfg.get("login_method") == "PAIR_CODE": + console.print(f"- phone_number: {bot_cfg.get('phone_number') or '(not set)'}") + console.print(f"- auto_read: {'on' if bot_cfg.get('auto_read') else 'off'}") + console.print(f"- self_mode: {'on' if bot_cfg.get('self_mode') else 'off'}") + console.print(f"- auto_react: {'on' if bot_cfg.get('auto_react') else 'off'}") + if bot_cfg.get("auto_react"): + console.print(f"- auto_react_emoji: {bot_cfg.get('auto_react_emoji')}") + console.print(f"- dashboard: {'on' if dashboard_cfg.get('enabled') else 'off'}") + console.print( + f"- anti_link: {'on' if anti_link_enabled else 'off'} ({anti_link.get('action')})" + ) + console.print( + f"- anti_spam: {'on' if anti_spam_enabled else 'off'} ({anti_spam.get('action')})" + ) + console.print( + f"- anti_spam limits: {anti_spam.get('max_messages')} msgs / {anti_spam.get('window_seconds')}s" + ) + console.print(f"- ai: {'on' if ai_enabled else 'off'}") + if ai_enabled: + console.print( + f"- ai provider/model/mode: {ai_cfg.get('provider')} / {ai_cfg.get('model')} / {ai_cfg.get('trigger_mode')}" + ) + console.print( + f"- rate_limit: {'on' if rate_limit_cfg.get('enabled') else 'off'} " + f"(u:{rate_limit_cfg.get('user_cooldown')}s, c:{rate_limit_cfg.get('command_cooldown')}s)" + ) + + if not _prompt_yes_no("Apply these changes", True): + console.print("[yellow]Setup cancelled.[/yellow]") + return + + runtime_config.replace_config(candidate) + console.print("\n[bold green]Setup complete.[/bold green]") + console.print("You can now run: [bold]uv run zero-ichi[/bold]") + + def _init_bot(args): """Initialize the bot infrastructure. Only called when actually running the bot.""" ensure_database_ready() @@ -645,6 +940,10 @@ def main(): _run_update() return + if args.command == "setup": + _run_setup() + return + _init_bot(args) diff --git a/tests/test_automations.py b/tests/test_automations.py new file mode 100644 index 0000000..8a2f2e1 --- /dev/null +++ b/tests/test_automations.py @@ -0,0 +1,62 @@ +from pathlib import Path + +import core.db as db_module +from core.automations import ( + get_automation_runtime, + rule_matches, + set_automation_dry_run, +) + + +def _reset_db(tmp_path: Path, monkeypatch) -> None: + db_file = tmp_path / "automations.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_rule_matches_text_variants(): + assert rule_matches({"trigger_type": "contains", "trigger_value": "hello"}, "hello world") + assert rule_matches({"trigger_type": "starts_with", "trigger_value": "!promo"}, "!promo now") + assert rule_matches({"trigger_type": "exact_match", "trigger_value": "Hello"}, " hello ") + assert rule_matches({"trigger_type": "regex", "trigger_value": r"free\s+money"}, "FREE money") + assert rule_matches({"trigger_type": "link", "trigger_value": ""}, "visit https://example.com") + + assert not rule_matches( + {"trigger_type": "starts_with", "trigger_value": "!promo"}, + "check !promo", + ) + assert not rule_matches( + {"trigger_type": "exact_match", "trigger_value": "hello"}, + "hello world", + ) + + +def test_rule_matches_media_type(): + assert rule_matches( + {"trigger_type": "media_type", "trigger_value": "image"}, + "", + media_type="image", + ) + assert not rule_matches( + {"trigger_type": "media_type", "trigger_value": "image"}, + "", + media_type="video", + ) + + +def test_automation_dry_run_runtime_roundtrip(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + group = "12345@g.us" + + runtime = get_automation_runtime(group) + assert runtime["dry_run"] is False + + set_automation_dry_run(group, True) + runtime = get_automation_runtime(group) + assert runtime["dry_run"] is True + + set_automation_dry_run(group, False) + runtime = get_automation_runtime(group) + assert runtime["dry_run"] is False diff --git a/tests/test_command_permission_overrides.py b/tests/test_command_permission_overrides.py new file mode 100644 index 0000000..4749269 --- /dev/null +++ b/tests/test_command_permission_overrides.py @@ -0,0 +1,142 @@ +import pytest + +import core.permissions as permissions_module +from core.permissions import check_command_permissions +from core.types import ChatType + + +class DummyCommand: + def __init__( + self, + name: str, + *, + owner_only: bool = False, + admin_only: bool = False, + ): + self.name = name + self.owner_only = owner_only + self.admin_only = admin_only + self.bot_admin_required = False + self.group_only = False + self.private_only = False + + def can_execute(self, chat_type): + return chat_type in {ChatType.PRIVATE, ChatType.GROUP} + + +class DummyMessage: + def __init__( + self, + text: str, + *, + sender_jid: str = "111@s.whatsapp.net", + chat_jid: str = "123@g.us", + is_group: bool = True, + ): + self.text = text + self.sender_jid = sender_jid + self.chat_jid = chat_jid + self.is_group = is_group + self.chat_type = ChatType.GROUP if is_group else ChatType.PRIVATE + + +async def _owner_false(_sender_jid, _bot): + return False + + +@pytest.mark.asyncio +async def test_global_admin_override_blocks_member(monkeypatch): + async def _is_admin(_bot, _group_jid, _user_jid): + return False + + monkeypatch.setattr( + permissions_module.runtime_config, "get_owner_jid", lambda: "owner@s.whatsapp.net" + ) + monkeypatch.setattr(permissions_module.runtime_config, "is_owner_async", _owner_false) + monkeypatch.setattr(permissions_module, "check_admin_permission", _is_admin) + monkeypatch.setattr( + permissions_module.runtime_config, + "get_command_role_override", + lambda name, group_jid=None: "admin" if name == "ping" else None, + ) + monkeypatch.setattr(permissions_module.runtime_config, "is_command_enabled", lambda _name: True) + + cmd = DummyCommand("ping") + msg = DummyMessage("/ping", is_group=True) + + result = await check_command_permissions(cmd, msg, bot=object()) + assert result.allowed is False + + +@pytest.mark.asyncio +async def test_global_admin_override_allows_admin(monkeypatch): + async def _is_admin(_bot, _group_jid, _user_jid): + return True + + monkeypatch.setattr( + permissions_module.runtime_config, "get_owner_jid", lambda: "owner@s.whatsapp.net" + ) + monkeypatch.setattr(permissions_module.runtime_config, "is_owner_async", _owner_false) + monkeypatch.setattr(permissions_module, "check_admin_permission", _is_admin) + monkeypatch.setattr( + permissions_module.runtime_config, + "get_command_role_override", + lambda name, group_jid=None: "admin" if name == "ping" else None, + ) + monkeypatch.setattr(permissions_module.runtime_config, "is_command_enabled", lambda _name: True) + + cmd = DummyCommand("ping") + msg = DummyMessage("/ping", is_group=True) + + result = await check_command_permissions(cmd, msg, bot=object()) + assert result.allowed is True + + +@pytest.mark.asyncio +async def test_group_override_can_relax_admin_to_member(monkeypatch): + async def _is_admin(_bot, _group_jid, _user_jid): + return False + + monkeypatch.setattr( + permissions_module.runtime_config, "get_owner_jid", lambda: "owner@s.whatsapp.net" + ) + monkeypatch.setattr(permissions_module.runtime_config, "is_owner_async", _owner_false) + monkeypatch.setattr(permissions_module, "check_admin_permission", _is_admin) + monkeypatch.setattr( + permissions_module.runtime_config, + "get_command_role_override", + lambda name, group_jid=None: "member" + if name == "warn" and group_jid == "123@g.us" + else None, + ) + monkeypatch.setattr(permissions_module.runtime_config, "is_command_enabled", lambda _name: True) + + cmd = DummyCommand("warn", admin_only=True) + msg = DummyMessage("/warn @user") + + result = await check_command_permissions(cmd, msg, bot=object()) + assert result.allowed is True + + +@pytest.mark.asyncio +async def test_owner_only_cannot_be_relaxed(monkeypatch): + async def _is_admin(_bot, _group_jid, _user_jid): + return True + + monkeypatch.setattr( + permissions_module.runtime_config, "get_owner_jid", lambda: "owner@s.whatsapp.net" + ) + monkeypatch.setattr(permissions_module.runtime_config, "is_owner_async", _owner_false) + monkeypatch.setattr(permissions_module, "check_admin_permission", _is_admin) + monkeypatch.setattr( + permissions_module.runtime_config, + "get_command_role_override", + lambda name, group_jid=None: "member" if name == "eval" else None, + ) + monkeypatch.setattr(permissions_module.runtime_config, "is_command_enabled", lambda _name: True) + + cmd = DummyCommand("eval", owner_only=True) + msg = DummyMessage("/eval 1+1") + + result = await check_command_permissions(cmd, msg, bot=object()) + assert result.allowed is False diff --git a/tests/test_owner_bootstrap_permissions.py b/tests/test_owner_bootstrap_permissions.py new file mode 100644 index 0000000..3364118 --- /dev/null +++ b/tests/test_owner_bootstrap_permissions.py @@ -0,0 +1,94 @@ +import pytest + +import core.permissions as permissions_module +from core.permissions import check_command_permissions +from core.types import ChatType + + +class DummyCommand: + def __init__(self, name: str, owner_only: bool = True): + self.name = name + self.owner_only = owner_only + self.admin_only = False + self.bot_admin_required = False + self.group_only = False + self.private_only = False + + def can_execute(self, chat_type): + return chat_type in {ChatType.PRIVATE, ChatType.GROUP} + + +class DummyMessage: + def __init__(self, text: str, is_group: bool = False): + self.text = text + self.is_group = is_group + self.chat_type = ChatType.GROUP if is_group else ChatType.PRIVATE + self.sender_jid = "12345@s.whatsapp.net" + + +async def _false_owner(_sender_jid, _bot): + return False + + +@pytest.mark.asyncio +async def test_config_owner_me_allowed_when_owner_not_set(monkeypatch): + monkeypatch.setattr(permissions_module.runtime_config, "get_owner_jid", lambda: "") + monkeypatch.setattr(permissions_module.runtime_config, "is_owner_async", _false_owner) + + cmd = DummyCommand("config", owner_only=True) + msg = DummyMessage("/config owner me") + + result = await check_command_permissions(cmd, msg, bot=object()) + assert result.allowed is True + + +@pytest.mark.asyncio +async def test_config_all_blocked_when_owner_not_set(monkeypatch): + monkeypatch.setattr(permissions_module.runtime_config, "get_owner_jid", lambda: "") + monkeypatch.setattr(permissions_module.runtime_config, "is_owner_async", _false_owner) + + cmd = DummyCommand("config", owner_only=True) + msg = DummyMessage("/config all") + + result = await check_command_permissions(cmd, msg, bot=object()) + assert result.allowed is False + + +@pytest.mark.asyncio +async def test_setup_start_allowed_when_owner_not_set(monkeypatch): + monkeypatch.setattr(permissions_module.runtime_config, "get_owner_jid", lambda: "") + monkeypatch.setattr(permissions_module.runtime_config, "is_owner_async", _false_owner) + + cmd = DummyCommand("setup", owner_only=True) + msg = DummyMessage("/setup start") + + result = await check_command_permissions(cmd, msg, bot=object()) + assert result.allowed is True + + +@pytest.mark.asyncio +async def test_setup_write_blocked_when_owner_not_set(monkeypatch): + monkeypatch.setattr(permissions_module.runtime_config, "get_owner_jid", lambda: "") + monkeypatch.setattr(permissions_module.runtime_config, "is_owner_async", _false_owner) + + cmd = DummyCommand("setup", owner_only=True) + msg = DummyMessage("/setup anti-link on warn") + + result = await check_command_permissions(cmd, msg, bot=object()) + assert result.allowed is False + + +@pytest.mark.asyncio +async def test_owner_only_still_blocked_when_owner_set(monkeypatch): + monkeypatch.setattr( + permissions_module.runtime_config, + "get_owner_jid", + lambda: "owner@s.whatsapp.net", + ) + monkeypatch.setattr(permissions_module.runtime_config, "is_owner_async", _false_owner) + + cmd = DummyCommand("config", owner_only=True) + msg = DummyMessage("/config owner me") + + result = await check_command_permissions(cmd, msg, bot=object()) + assert result.allowed is False diff --git a/tests/test_privacy_controls.py b/tests/test_privacy_controls.py new file mode 100644 index 0000000..b0a3e00 --- /dev/null +++ b/tests/test_privacy_controls.py @@ -0,0 +1,69 @@ +from pathlib import Path + +import ai.memory as memory_module +import core.db as db_module +from ai.memory import clear_memory, get_memory +from core.privacy import ( + clear_chat_memory_override, + get_analytics_retention_days, + get_chat_memory_override, + is_chat_memory_enabled, + set_chat_memory_enabled, +) + + +def _reset_db(tmp_path: Path, monkeypatch) -> None: + db_file = tmp_path / "privacy_controls.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_chat_memory_override_roundtrip(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + + chat = "123@g.us" + assert get_chat_memory_override(chat) is None + + set_chat_memory_enabled(chat, False) + assert get_chat_memory_override(chat) is False + assert is_chat_memory_enabled(chat) is False + + set_chat_memory_enabled(chat, True) + assert get_chat_memory_override(chat) is True + assert is_chat_memory_enabled(chat) is True + + assert clear_chat_memory_override(chat) is True + assert get_chat_memory_override(chat) is None + + +def test_analytics_retention_days_clamped(monkeypatch): + monkeypatch.setattr( + "core.privacy.runtime_config.get_nested", + lambda *_args, **_kwargs: 999, + ) + assert get_analytics_retention_days() == 365 + + monkeypatch.setattr( + "core.privacy.runtime_config.get_nested", + lambda *_args, **_kwargs: -5, + ) + assert get_analytics_retention_days() == 1 + + +def test_clear_memory_for_uncached_chat(tmp_path, monkeypatch): + _reset_db(tmp_path, monkeypatch) + + chat = "987@g.us" + mem = get_memory(chat, ttl_hours=24) + mem.add(role="user", content="hello") + assert len(mem.get_history()) == 1 + + # Simulate uncached chat memory object + memory_module._memory_cache.pop(chat, None) + + clear_memory(chat) + + reloaded = get_memory(chat, ttl_hours=24) + assert len(reloaded.get_history()) == 0 diff --git a/tests/test_runtime_config_history.py b/tests/test_runtime_config_history.py new file mode 100644 index 0000000..0d48884 --- /dev/null +++ b/tests/test_runtime_config_history.py @@ -0,0 +1,59 @@ +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", + ) + monkeypatch.setattr(runtime_config_module, "HISTORY_FILE", tmp_path / "config_history.json") + runtime_config_module.RuntimeConfig._instance = None + + cfg = runtime_config_module.RuntimeConfig() + yield cfg + + runtime_config_module.RuntimeConfig._instance = None + + +def test_history_records_on_config_updates(isolated_runtime_config): + cfg = isolated_runtime_config + + cfg.set_nested("bot", "prefix", "!") + + entries = cfg.list_config_history(limit=5) + assert entries + assert entries[0]["id"].startswith("H") + assert entries[0]["reason"] == "update" + + +def test_rollback_restores_snapshot_config(isolated_runtime_config): + cfg = isolated_runtime_config + + cfg.set_nested("bot", "prefix", "!") + cfg.set_nested("bot", "prefix", "#") + assert cfg.get_nested("bot", "prefix") == "#" + + result = cfg.rollback_config("H0001") + assert result is not None + assert cfg.get_nested("bot", "prefix") == "/" + + +def test_rollback_unknown_id_returns_none(isolated_runtime_config): + cfg = isolated_runtime_config + + cfg.set_nested("bot", "prefix", "!") + result = cfg.rollback_config("H9999") + assert result is None diff --git a/tests/test_runtime_config_validation.py b/tests/test_runtime_config_validation.py index 7589ca5..4a026cd 100644 --- a/tests/test_runtime_config_validation.py +++ b/tests/test_runtime_config_validation.py @@ -19,6 +19,7 @@ def isolated_runtime_config(tmp_path, monkeypatch): "OVERRIDES_MIGRATION_MARKER", tmp_path / ".runtime_overrides_migrated", ) + monkeypatch.setattr(runtime_config_module, "HISTORY_FILE", tmp_path / "config_history.json") runtime_config_module.RuntimeConfig._instance = None cfg = runtime_config_module.RuntimeConfig() @@ -74,6 +75,7 @@ def test_missing_schema_is_merged_and_preserved(tmp_path, monkeypatch): "OVERRIDES_MIGRATION_MARKER", tmp_path / ".runtime_overrides_migrated", ) + monkeypatch.setattr(runtime_config_module, "HISTORY_FILE", tmp_path / "config_history.json") runtime_config_module.RuntimeConfig._instance = None cfg = runtime_config_module.RuntimeConfig() @@ -111,6 +113,7 @@ def test_invalid_config_does_not_overwrite_file(tmp_path, monkeypatch): "OVERRIDES_MIGRATION_MARKER", tmp_path / ".runtime_overrides_migrated", ) + monkeypatch.setattr(runtime_config_module, "HISTORY_FILE", tmp_path / "config_history.json") runtime_config_module.RuntimeConfig._instance = None cfg = runtime_config_module.RuntimeConfig() @@ -166,6 +169,7 @@ def test_missing_default_keys_are_persisted_with_existing_schema(tmp_path, monke "OVERRIDES_MIGRATION_MARKER", tmp_path / ".runtime_overrides_migrated", ) + monkeypatch.setattr(runtime_config_module, "HISTORY_FILE", tmp_path / "config_history.json") runtime_config_module.RuntimeConfig._instance = None cfg = runtime_config_module.RuntimeConfig() @@ -180,3 +184,18 @@ def test_missing_default_keys_are_persisted_with_existing_schema(tmp_path, monke assert persisted.get("features", {}).get("anti_spam") is False runtime_config_module.RuntimeConfig._instance = None + + +def test_command_role_override_roundtrip(isolated_runtime_config): + cfg = isolated_runtime_config + + assert cfg.get_command_role_override("warn") is None + cfg.set_command_role_override("warn", "admin") + assert cfg.get_command_role_override("warn") == "admin" + + cfg.set_command_role_override("quote", "owner", group_jid="123@g.us") + assert cfg.get_command_role_override("quote", group_jid="123@g.us") == "owner" + + assert cfg.reset_command_role_override("warn") is True + assert cfg.get_command_role_override("warn") is None + assert cfg.reset_command_role_override("warn") is False