From 4aad9c3c76205835a2f444f8588006a903484072 Mon Sep 17 00:00:00 2001 From: MhankBarBar Date: Thu, 19 Mar 2026 13:41:18 +0700 Subject: [PATCH 1/2] refactor dry core flows --- docs/commands/owner.md | 4 +- src/ai/agent.py | 15 +- src/commands/general/status.py | 49 +++--- src/commands/general/uptime.py | 21 +-- src/commands/moderation/automation.py | 10 +- src/commands/owner/callguard.py | 61 +++++-- src/commands/owner/config.py | 223 ++++++++++++++++++++------ src/commands/owner/permission.py | 38 ++++- src/commands/owner/privacy.py | 54 ++++++- src/commands/owner/setup.py | 41 +++-- src/commands/utility/_ai_text.py | 63 +++++--- src/commands/utility/autodl.py | 58 +++++-- src/commands/utility/rewrite.py | 42 ++--- src/commands/utility/summarize.py | 74 ++------- src/commands/utility/translate.py | 43 ++--- src/core/ai_runtime.py | 43 +++++ src/core/automations.py | 43 +++-- src/core/config_ops.py | 33 ++++ src/core/id_utils.py | 28 ++++ src/core/middlewares/auto_download.py | 2 +- src/core/presentation.py | 53 ++++++ src/core/runtime_config.py | 8 +- src/core/timefmt.py | 23 +++ src/core/url_patterns.py | 7 + src/locales/en.json | 1 + src/locales/id.json | 1 + tests/test_config_diff_image.py | 34 ++++ tests/test_id_utils.py | 11 ++ tests/test_presentation.py | 20 +++ tests/test_timefmt.py | 11 ++ 30 files changed, 810 insertions(+), 304 deletions(-) create mode 100644 src/core/ai_runtime.py create mode 100644 src/core/config_ops.py create mode 100644 src/core/id_utils.py create mode 100644 src/core/presentation.py create mode 100644 src/core/timefmt.py create mode 100644 src/core/url_patterns.py create mode 100644 tests/test_config_diff_image.py create mode 100644 tests/test_id_utils.py create mode 100644 tests/test_presentation.py create mode 100644 tests/test_timefmt.py diff --git a/docs/commands/owner.md b/docs/commands/owner.md index a455559..4222ad6 100644 --- a/docs/commands/owner.md +++ b/docs/commands/owner.md @@ -26,7 +26,8 @@ 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 diff # Show config changes vs defaults (image) +/config diff text # Show config changes as plain text /config validate # Validate config against schema /config history # Show recent config snapshots /config rollback # Roll back to a previous snapshot @@ -70,6 +71,7 @@ Roles: - `admin` - `owner` + ## /privacy Manage data retention and AI memory privacy controls. diff --git a/src/ai/agent.py b/src/ai/agent.py index 25ebda1..9dbf297 100644 --- a/src/ai/agent.py +++ b/src/ai/agent.py @@ -7,7 +7,6 @@ from __future__ import annotations -import os import re from typing import TYPE_CHECKING @@ -15,6 +14,7 @@ from ai.context import BotDependencies from ai.token_tracker import token_tracker +from core.ai_runtime import apply_provider_env, resolve_api_key from core.command import CommandContext, command_loader from core.logger import log_debug, log_error, log_info, log_warning from core.permissions import check_command_permissions @@ -227,10 +227,7 @@ def enabled(self) -> bool: @property def api_key(self) -> str: """Get the API key (from env var AI_API_KEY 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="") + return resolve_api_key() @property def provider(self) -> str: @@ -383,13 +380,7 @@ async def process(self, msg: MessageHelper, bot: BotClient) -> str | None: log_info(f"AI token limit reached for user={user_id} chat={chat_id}") return "⏳ AI daily limit reached. Try again tomorrow!" - if self.provider == "openai": - os.environ["OPENAI_API_KEY"] = self.api_key - elif self.provider == "anthropic": - os.environ["ANTHROPIC_API_KEY"] = self.api_key - elif self.provider == "google": - os.environ["GOOGLE_API_KEY"] = self.api_key - os.environ["GEMINI_API_KEY"] = self.api_key + apply_provider_env(self.provider, self.api_key) model_str = f"{self.provider}:{self.model}" log_info(f"AI processing with model: {model_str}") diff --git a/src/commands/general/status.py b/src/commands/general/status.py index d96bc38..e470a6f 100644 --- a/src/commands/general/status.py +++ b/src/commands/general/status.py @@ -9,23 +9,12 @@ from core import symbols as sym from core.command import Command, CommandContext, command_loader from core.i18n import t +from core.presentation import format_command_card from core.runtime_config import runtime_config +from core.timefmt import format_uptime 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"] @@ -37,7 +26,7 @@ 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) + uptime = format_uptime(time.time() - _start_time) db_ok = False try: @@ -55,20 +44,22 @@ async def execute(self, ctx: CommandContext) -> None: 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))), + f"{sym.BULLET} *{t('status.uptime')}:* {uptime}", + f"{sym.BULLET} *{t('status.db')}:* {t('common.on') if db_ok else t('common.off')}", + f"{sym.BULLET} *{t('status.webhook_worker')}:* {t('common.on') if webhook.get('running') else t('common.off')}", + f"{sym.BULLET} *{t('status.webhook_queue')}:* {webhook.get('queue_size', 0)}", + f"{sym.BULLET} *{t('status.ai')}:* {t('common.on') if ai_enabled else t('common.off')}", + f"{sym.BULLET} *{t('status.ai_model')}:* {ai_provider}:{ai_model}", + f"{sym.BULLET} *{t('status.rate_limit')}:* {t('common.on') if rate_limit_enabled else t('common.off')}", + f"{sym.BULLET} *{t('status.commands')}:* {len(command_loader.enabled_commands)}", ] - await ctx.client.reply(ctx.message, sym.box(t("status.title"), lines)) + header = format_command_card( + ctx.prefix, + self.name, + self.description, + self.get_usage(ctx.prefix), + aliases=self.aliases, + category=self.category, + ) + await ctx.client.reply(ctx.message, header + "\n\n" + "\n".join(lines)) diff --git a/src/commands/general/uptime.py b/src/commands/general/uptime.py index ff6b2ad..a854012 100644 --- a/src/commands/general/uptime.py +++ b/src/commands/general/uptime.py @@ -7,28 +7,11 @@ from core import symbols as sym from core.command import Command, CommandContext from core.i18n import t +from core.timefmt import format_uptime _start_time = time.time() -def _format_uptime(seconds: float) -> str: - """Format seconds to human-readable uptime.""" - days = int(seconds // 86400) - hours = int((seconds % 86400) // 3600) - minutes = int((seconds % 3600) // 60) - secs = int(seconds % 60) - - parts = [] - if days: - parts.append(f"{days}d") - if hours: - parts.append(f"{hours}h") - if minutes: - parts.append(f"{minutes}m") - parts.append(f"{secs}s") - return " ".join(parts) - - class UptimeCommand(Command): name = "uptime" aliases = ["up"] @@ -39,6 +22,6 @@ class UptimeCommand(Command): async def execute(self, ctx: CommandContext) -> None: """Show bot uptime.""" elapsed = time.time() - _start_time - uptime_str = _format_uptime(elapsed) + uptime_str = format_uptime(elapsed, include_seconds=True) await ctx.client.reply(ctx.message, f"{sym.CLOCK} *{t('uptime.title')}:* {uptime_str}") diff --git a/src/commands/moderation/automation.py b/src/commands/moderation/automation.py index 23bde9a..017d92d 100644 --- a/src/commands/moderation/automation.py +++ b/src/commands/moderation/automation.py @@ -4,8 +4,11 @@ from core.automations import ( get_automation_runtime, + is_valid_action, + is_valid_trigger, load_rules, next_rule_id, + normalize_action, rule_matches, save_rules, set_automation_dry_run, @@ -116,14 +119,13 @@ 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", "starts_with", "exact_match", "regex", "link", "media_type"} - valid_action = {"reply", "warn", "delete", "kick", "mute"} - if trigger_type not in valid_trigger: + if not is_valid_trigger(trigger_type): await ctx.client.reply(ctx.message, t_error("automation.invalid_trigger")) return - if action_type not in valid_action: + if not is_valid_action(action_type): await ctx.client.reply(ctx.message, t_error("automation.invalid_action")) return + action_type = normalize_action(action_type) 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/owner/callguard.py b/src/commands/owner/callguard.py index e7dc5d7..9c1c8c6 100644 --- a/src/commands/owner/callguard.py +++ b/src/commands/owner/callguard.py @@ -4,6 +4,7 @@ from core import symbols as sym from core.command import Command, CommandContext +from core.config_ops import apply_config_operation from core.i18n import t, t_error, t_success from core.runtime_config import runtime_config @@ -17,6 +18,9 @@ class CallGuardCommand(Command): ) owner_only = True + async def _apply_change(self, ctx: CommandContext, operation): + return await apply_config_operation(ctx, operation) + async def execute(self, ctx: CommandContext) -> None: args = ctx.args @@ -27,14 +31,28 @@ async def execute(self, ctx: CommandContext) -> None: action = args[0].lower() if action == "on": - runtime_config.set_nested("call_guard", "enabled", True) - runtime_config.set_nested("call_guard", "action", "block") + changed = await self._apply_change( + ctx, + lambda: ( + runtime_config.set_nested("call_guard", "enabled", True), + runtime_config.set_nested("call_guard", "action", "block"), + ), + ) + if changed is None: + return await ctx.client.reply(ctx.message, t_success("callguard.enabled")) return if action == "off": - runtime_config.set_nested("call_guard", "enabled", False) - runtime_config.set_nested("call_guard", "action", "off") + changed = await self._apply_change( + ctx, + lambda: ( + runtime_config.set_nested("call_guard", "enabled", False), + runtime_config.set_nested("call_guard", "action", "off"), + ), + ) + if changed is None: + return await ctx.client.reply(ctx.message, t_success("callguard.disabled")) return @@ -50,7 +68,12 @@ async def execute(self, ctx: CommandContext) -> None: await ctx.client.reply(ctx.message, t_error("callguard.delay_range")) return - runtime_config.set_nested("call_guard", "delay_seconds", delay) + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_nested("call_guard", "delay_seconds", delay), + ) + if changed is None: + return await ctx.client.reply(ctx.message, t_success("callguard.delay_set", seconds=delay)) return @@ -63,7 +86,12 @@ async def execute(self, ctx: CommandContext) -> None: return enabled = args[1].lower() == "on" - runtime_config.set_nested("call_guard", "notify_caller", enabled) + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_nested("call_guard", "notify_caller", enabled), + ) + if changed is None: + return await ctx.client.reply( ctx.message, t_success( @@ -82,7 +110,12 @@ async def execute(self, ctx: CommandContext) -> None: return enabled = args[1].lower() == "on" - runtime_config.set_nested("call_guard", "notify_owner", enabled) + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_nested("call_guard", "notify_owner", enabled), + ) + if changed is None: + return await ctx.client.reply( ctx.message, t_success( @@ -193,7 +226,12 @@ async def _handle_whitelist(self, ctx: CommandContext) -> None: return whitelist.append(jid) - runtime_config.set_nested("call_guard", "whitelist", whitelist) + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_nested("call_guard", "whitelist", whitelist), + ) + if changed is None: + return await ctx.client.reply(ctx.message, t_success("callguard.whitelist_added", jid=jid)) return @@ -215,7 +253,12 @@ async def _handle_whitelist(self, ctx: CommandContext) -> None: ) return - runtime_config.set_nested("call_guard", "whitelist", filtered) + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_nested("call_guard", "whitelist", filtered), + ) + if changed is None: + return await ctx.client.reply(ctx.message, t_success("callguard.whitelist_removed", jid=jid)) return diff --git a/src/commands/owner/config.py b/src/commands/owner/config.py index 53ed400..0052530 100644 --- a/src/commands/owner/config.py +++ b/src/commands/owner/config.py @@ -5,13 +5,122 @@ from __future__ import annotations from copy import deepcopy +from io import BytesIO from typing import Any +from PIL import Image, ImageDraw, ImageFont + from core import symbols as sym from core.command import Command, CommandContext, command_loader +from core.config_ops import apply_config_operation from core.i18n import t, t_error, t_info, t_success +from core.presentation import format_command_card from core.runtime_config import DEFAULT_CONFIG, runtime_config +_SENSITIVE_KEYWORDS = { + "api_key", + "key", + "token", + "secret", + "password", + "pass", + "auth", + "credential", +} + + +def _is_sensitive_path(path: str) -> bool: + lowered = path.lower() + for part in lowered.replace("-", "_").split("."): + if any(k in part for k in _SENSITIVE_KEYWORDS): + return True + return False + + +def _mask_value(path: str, value: Any) -> str: + if _is_sensitive_path(path): + return "[redacted]" + text = str(value) + return text if len(text) <= 120 else text[:117] + "..." + + +def _resolve_diff_mode(args: list[str] | None) -> str: + """Resolve config diff mode (image by default, text if explicitly requested).""" + if args and args[0].strip().lower() in {"text", "txt"}: + return "text" + return "image" + + +def _load_mono_font(size: int = 16): + candidates = [ + "C:/Windows/Fonts/consola.ttf", + "C:/Windows/Fonts/Consolas.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/System/Library/Fonts/Menlo.ttc", + ] + for path in candidates: + try: + return ImageFont.truetype(path, size) + except Exception: + continue + return ImageFont.load_default() + + +def render_diff_image(title: str, rows: list[tuple[str, str]]) -> bytes: + """Render colored diff lines into PNG bytes.""" + palette = { + "header": (232, 234, 237), + "meta": (156, 163, 175), + "changed": (245, 158, 11), + "added": (34, 197, 94), + "missing": (239, 68, 68), + "default": (229, 231, 235), + } + + font = _load_mono_font(16) + padding_x = 28 + padding_y = 24 + line_gap = 8 + bbox = font.getbbox("Ag") + line_height = (bbox[3] - bbox[1]) + line_gap + + wrapped: list[tuple[str, str]] = [] + max_chars = 100 + for text, tone in rows: + chunks = [text[i : i + max_chars] for i in range(0, len(text), max_chars)] or [""] + for chunk in chunks: + wrapped.append((chunk, tone)) + + canvas_rows = [ + (title, "header"), + ("Legend: ~ changed | + custom | - missing", "meta"), + ("", "default"), + *wrapped, + ] + + max_width = 700 + for text, _ in canvas_rows: + sample_w = int(font.getlength(text)) + if sample_w > max_width: + max_width = sample_w + + width = max_width + (padding_x * 2) + height = (len(canvas_rows) * line_height) + (padding_y * 2) + + bg_color: Any = (17, 24, 39) + image = Image.new("RGB", (width, height), bg_color) + draw = ImageDraw.Draw(image) + + y = padding_y + for text, tone in canvas_rows: + color = palette.get(tone, palette["default"]) + draw.text((padding_x, y), text, fill=color, font=font) + y += line_height + + buffer = BytesIO() + image.save(buffer, format="PNG", optimize=True) + return buffer.getvalue() + class ConfigCommand(Command): """Manage bot configuration at runtime.""" @@ -46,7 +155,7 @@ async def execute(self, ctx: CommandContext) -> None: elif action == "all": await self._show_all(ctx) elif action == "diff": - await self._show_diff(ctx) + await self._show_diff(ctx, args[1:]) elif action == "validate": await self._validate_config(ctx) elif action == "history": @@ -67,26 +176,36 @@ async def execute(self, ctx: CommandContext) -> None: async def _show_help(self, ctx: CommandContext) -> None: """Show config command help.""" p = ctx.prefix - help_text = f"""*{t("config.title")}* - -*{t("config.usage_label")}:* -- `{p}config features` - {t("config.show_features")} -- `{p}config toggle ` - {t("config.toggle_feature")} -- `{p}config cmd list` - {t("config.list_commands")} -- `{p}config cmd enable ` - {t("config.enable_command")} -- `{p}config cmd disable ` - {t("config.disable_command")} -- `{p}config autoread [on/off]` - {t("config.autoread_desc")} -- `{p}config react [emoji/off]` - {t("config.react_desc")} -- `{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 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) + card = format_command_card( + p, + self.name, + self.description, + self.get_usage(p), + aliases=self.aliases, + category="owner", + restrictions=["Owner only"], + ) + actions = [ + f"`{p}config features` - {t('config.show_features')}", + f"`{p}config toggle ` - {t('config.toggle_feature')}", + f"`{p}config cmd list` - {t('config.list_commands')}", + f"`{p}config cmd enable ` - {t('config.enable_command')}", + f"`{p}config cmd disable ` - {t('config.disable_command')}", + f"`{p}config autoread [on/off]` - {t('config.autoread_desc')}", + f"`{p}config react [emoji/off]` - {t('config.react_desc')}", + f"`{p}config selfmode [on/off]` - {t('config.selfmode_desc')}", + f"`{p}config ai [on/off/key/mode]` - {t('config.ai_desc')}", + f"`{p}config owner` - {t('config.show_owner')}", + f"`{p}config all` - {t('config.show_all')}", + f"`{p}config diff [image|text]` - {t('config.show_diff')}", + f"`{p}config validate` - {t('config.validate_desc')}", + f"`{p}config history [limit]` - {t('config.history_desc')}", + f"`{p}config rollback ` - {t('config.rollback_desc')}", + ] + await ctx.client.reply( + ctx.message, + card + "\n\n" + sym.section(t("config.usage_label"), actions), + ) async def _show_features(self, ctx: CommandContext) -> None: """Show all feature flags.""" @@ -290,34 +409,59 @@ 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.""" + async def _show_diff(self, ctx: CommandContext, args: list[str] | None = None) -> None: + """Show diff between runtime config and defaults (image by default).""" current = deepcopy(runtime_config.all_config()) current.pop("$schema", None) defaults = deepcopy(DEFAULT_CONFIG) + mode = _resolve_diff_mode(args) + 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]: + rows: list[tuple[str, str]] = [] + text_lines = [f"*{t('config.diff_title')}*", ""] + for item in diffs[:200]: 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'])}`" + default_val = _mask_value(path, item["default"]) + current_val = _mask_value(path, item["current"]) + text_lines.append( + f"{sym.BULLET} `~ {path}`: `{default_val}` {sym.ARROW} `{current_val}`" ) + rows.append((f"~ {path}: {default_val} -> {current_val}", "changed")) elif kind == "custom": - lines.append(f"{sym.BULLET} `+ {path}`: `{self._fmt(item['current'])}`") + current_val = _mask_value(path, item["current"]) + text_lines.append(f"{sym.BULLET} `+ {path}`: `{current_val}`") + rows.append((f"+ {path}: {current_val}", "added")) elif kind == "missing": - lines.append(f"{sym.BULLET} `- {path}`: `{self._fmt(item['default'])}`") + default_val = _mask_value(path, item["default"]) + text_lines.append(f"{sym.BULLET} `- {path}`: `{default_val}`") + rows.append((f"- {path}: {default_val}", "missing")) - if len(diffs) > 50: - lines.append(t("config.diff_truncated", count=str(len(diffs) - 50))) + if len(diffs) > 200: + extra = len(diffs) - 200 + text_lines.append(t("config.diff_truncated", count=str(extra))) + rows.append((f"... and {extra} more differences", "meta")) - await ctx.client.reply(ctx.message, "\n".join(lines)) + if mode == "text": + await ctx.client.reply(ctx.message, "\n".join(text_lines)) + return + + try: + image_bytes = render_diff_image(t("config.diff_title"), rows) + await ctx.client.send_image( + to=ctx.message.chat_jid, + file=image_bytes, + caption=t("config.diff_image_caption"), + quoted=ctx.message.event, + ) + except Exception: + await ctx.client.reply(ctx.message, "\n".join(text_lines)) async def _validate_config(self, ctx: CommandContext) -> None: """Validate current runtime config against schema.""" @@ -434,20 +578,7 @@ def _fmt(self, value: Any) -> str: 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 + return await apply_config_operation(ctx, operation) async def _handle_autoread(self, ctx: CommandContext, args: list[str]) -> None: """Handle auto-read configuration.""" diff --git a/src/commands/owner/permission.py b/src/commands/owner/permission.py index 8289e8f..2ff41f1 100644 --- a/src/commands/owner/permission.py +++ b/src/commands/owner/permission.py @@ -3,7 +3,9 @@ from __future__ import annotations from core.command import Command, CommandContext, command_loader +from core.config_ops import apply_config_operation from core.i18n import t, t_error, t_info, t_success +from core.presentation import format_command_card from core.runtime_config import runtime_config @@ -14,10 +16,13 @@ class PermissionCommand(Command): usage = "permission list|set|reset" owner_only = True + async def _apply_change(self, ctx: CommandContext, operation): + return await apply_config_operation(ctx, operation) + 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)) + await self._show_help(ctx) return action = args[0].lower() @@ -31,7 +36,20 @@ async def execute(self, ctx: CommandContext) -> None: await self._reset_override(ctx, args[1:]) return - await ctx.client.reply(ctx.message, t_error("permission.usage", prefix=ctx.prefix)) + await self._show_help(ctx) + + async def _show_help(self, ctx: CommandContext) -> None: + """Show permission command usage in unified command-card style.""" + text = format_command_card( + ctx.prefix, + self.name, + self.description, + self.get_usage(ctx.prefix), + aliases=self.aliases, + category="owner", + restrictions=["Owner only"], + ) + await ctx.client.reply(ctx.message, text) async def _list_overrides(self, ctx: CommandContext, args: list[str]) -> None: perms = runtime_config.get_command_permissions() @@ -108,10 +126,11 @@ async def _set_override(self, ctx: CommandContext, args: list[str]) -> None: 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))) + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_command_role_override(canonical, role, group_jid=group_jid), + ) + if changed is None: return scope_text = ( @@ -153,7 +172,12 @@ async def _reset_override(self, ctx: CommandContext, args: list[str]) -> None: 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) + removed = await self._apply_change( + ctx, + lambda: runtime_config.reset_command_role_override(canonical, group_jid=group_jid), + ) + if removed is None: + return if not removed: await ctx.client.reply(ctx.message, t_info("permission.no_override", command=canonical)) return diff --git a/src/commands/owner/privacy.py b/src/commands/owner/privacy.py index 98e4bd7..06ff1c4 100644 --- a/src/commands/owner/privacy.py +++ b/src/commands/owner/privacy.py @@ -6,7 +6,9 @@ from core import symbols as sym from core.analytics import command_analytics from core.command import Command, CommandContext +from core.config_ops import apply_config_operation from core.i18n import t, t_error, t_info, t_success +from core.presentation import format_command_card from core.privacy import ( clear_chat_memory_override, get_ai_memory_ttl_hours, @@ -24,10 +26,13 @@ class PrivacyCommand(Command): usage = "privacy status|retention|memory" owner_only = True + async def _apply_change(self, ctx: CommandContext, operation): + return await apply_config_operation(ctx, operation) + 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)) + await self._show_help(ctx) return action = args[0].lower() @@ -41,7 +46,31 @@ async def execute(self, ctx: CommandContext) -> None: await self._memory(ctx, args[1:]) return - await ctx.client.reply(ctx.message, t_error("privacy.usage", prefix=ctx.prefix)) + await self._show_help(ctx) + + async def _show_help(self, ctx: CommandContext) -> None: + """Show privacy command usage in command-card style.""" + card = format_command_card( + ctx.prefix, + self.name, + self.description, + self.get_usage(ctx.prefix), + category="owner", + restrictions=["Owner only"], + ) + actions = [ + f"`{ctx.prefix}privacy status`", + f"`{ctx.prefix}privacy retention analytics 30`", + f"`{ctx.prefix}privacy retention memory-ttl 24`", + f"`{ctx.prefix}privacy memory global on`", + f"`{ctx.prefix}privacy memory off here`", + f"`{ctx.prefix}privacy memory inherit here`", + f"`{ctx.prefix}privacy memory clear here`", + ] + await ctx.client.reply( + ctx.message, + card + "\n\n" + sym.section(t("headers.list"), actions), + ) async def _status(self, ctx: CommandContext, args: list[str]) -> None: chat_jid = self._scope_to_chat_jid(ctx, args[0] if args else "here") @@ -92,7 +121,12 @@ async def _retention(self, ctx: CommandContext, args: list[str]) -> None: 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) + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_nested("privacy", "analytics_retention_days", days), + ) + if changed is None: + return command_analytics.apply_retention_now() await ctx.client.reply( ctx.message, @@ -111,7 +145,12 @@ async def _retention(self, ctx: CommandContext, args: list[str]) -> None: await ctx.client.reply(ctx.message, t_error("privacy.memory_ttl_range")) return - runtime_config.set_nested("privacy", "ai_memory_ttl_hours", hours) + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_nested("privacy", "ai_memory_ttl_hours", hours), + ) + if changed is None: + return await ctx.client.reply( ctx.message, t_success("privacy.memory_ttl_set", hours=str(hours)), @@ -182,7 +221,12 @@ async def _memory(self, ctx: CommandContext, args: list[str]) -> None: ctx.message, t_error("privacy.memory_global_usage", prefix=ctx.prefix) ) return - runtime_config.set_nested("privacy", "ai_memory_enabled", mode == "on") + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_nested("privacy", "ai_memory_enabled", mode == "on"), + ) + if changed is None: + return await ctx.client.reply( ctx.message, t_success( diff --git a/src/commands/owner/setup.py b/src/commands/owner/setup.py index 0dcc944..119ec79 100644 --- a/src/commands/owner/setup.py +++ b/src/commands/owner/setup.py @@ -4,7 +4,9 @@ from core import symbols as sym from core.command import Command, CommandContext +from core.config_ops import apply_config_operation from core.i18n import t, t_error, t_success +from core.presentation import format_command_card from core.runtime_config import runtime_config @@ -22,7 +24,7 @@ class SetupCommand(Command): async def execute(self, ctx: CommandContext) -> None: args = ctx.args if not args: - await self._show_status(ctx, started=False) + await self._show_help(ctx) return action = args[0].lower() @@ -51,19 +53,36 @@ async def execute(self, ctx: CommandContext) -> None: await self._show_done(ctx) return - await ctx.client.reply(ctx.message, t_error("setup.usage", prefix=ctx.prefix)) + await self._show_help(ctx) + + async def _show_help(self, ctx: CommandContext) -> None: + """Show setup command help using command-card style.""" + card = format_command_card( + ctx.prefix, + self.name, + self.description, + self.get_usage(ctx.prefix), + category="owner", + restrictions=["Owner only"], + ) + lines = [ + f"`{ctx.prefix}setup start`", + f"`{ctx.prefix}setup status`", + 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`", + ] + await ctx.client.reply( + ctx.message, card + "\n\n" + sym.section(t("setup.next_steps"), lines) + ) 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 + return bool(await apply_config_operation(ctx, operation)) async def _show_status(self, ctx: CommandContext, *, started: bool) -> None: owner = runtime_config.get_owner_jid() diff --git a/src/commands/utility/_ai_text.py b/src/commands/utility/_ai_text.py index b65c745..6d64b59 100644 --- a/src/commands/utility/_ai_text.py +++ b/src/commands/utility/_ai_text.py @@ -2,41 +2,33 @@ from __future__ import annotations -import os - from pydantic_ai import Agent +from core import symbols as sym +from core.ai_runtime import ( + apply_provider_env, + resolve_api_key, + resolve_model_name, + resolve_provider, +) from core.command import CommandContext -from core.i18n import t_error +from core.i18n import t, 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}" + return resolve_model_name() 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="") + return resolve_api_key() 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 + apply_provider_env(provider, api_key) async def ensure_ai_ready_or_reply(ctx: CommandContext, disabled_key: str) -> bool: @@ -56,9 +48,40 @@ async def ensure_ai_ready_or_reply(ctx: CommandContext, disabled_key: str) -> bo 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") + provider = resolve_provider() 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 "" + + +def extract_text_from_quoted_or_args(ctx: CommandContext, args: list[str], start: int = 0) -> str: + """Extract source text from quoted message or command args.""" + quoted = ctx.message.quoted_message + if quoted and quoted.get("text"): + return str(quoted["text"]) + if len(args) > start: + return " ".join(args[start:]).strip() + return "" + + +async def run_prompt_with_progress( + ctx: CommandContext, + *, + processing_key: str, + failure_key: str, + prompt: str, + render_output, +) -> None: + """Run an AI prompt and edit one progress message with result or error.""" + progress = await ctx.client.reply(ctx.message, f"{sym.LOADING} {t(processing_key)}") + + try: + output = await run_text_prompt(prompt) + if not output: + await ctx.client.edit_message(ctx.message.chat_jid, progress.ID, t_error(failure_key)) + return + await ctx.client.edit_message(ctx.message.chat_jid, progress.ID, render_output(output)) + except Exception: + await ctx.client.edit_message(ctx.message.chat_jid, progress.ID, t_error(failure_key)) diff --git a/src/commands/utility/autodl.py b/src/commands/utility/autodl.py index 841553b..2d59f66 100644 --- a/src/commands/utility/autodl.py +++ b/src/commands/utility/autodl.py @@ -4,6 +4,7 @@ from core import symbols as sym from core.command import Command, CommandContext +from core.config_ops import apply_config_operation from core.i18n import t, t_error, t_success from core.runtime_config import runtime_config @@ -14,6 +15,9 @@ class AutoDlCommand(Command): usage = "autodl [status | on | off | mode | cooldown | maxlinks | album | photolimit ]" owner_only = True + async def _apply_change(self, ctx: CommandContext, operation): + return await apply_config_operation(ctx, operation) + async def execute(self, ctx: CommandContext) -> None: args = ctx.args cfg = runtime_config.get_nested("downloader", "auto_link_download", default={}) @@ -59,7 +63,14 @@ async def execute(self, ctx: CommandContext) -> None: action = args[0].lower() if action in {"on", "off"}: - runtime_config.set_nested("downloader", "auto_link_download", "enabled", action == "on") + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_nested( + "downloader", "auto_link_download", "enabled", action == "on" + ), + ) + if changed is None: + return await ctx.client.reply( ctx.message, t_success("autodl.enabled" if action == "on" else "autodl.disabled"), @@ -70,7 +81,14 @@ async def execute(self, ctx: CommandContext) -> None: if len(args) < 2 or args[1].lower() not in {"auto", "audio", "video", "photo"}: await ctx.client.reply(ctx.message, t_error("autodl.mode_usage", prefix=ctx.prefix)) return - runtime_config.set_nested("downloader", "auto_link_download", "mode", args[1].lower()) + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_nested( + "downloader", "auto_link_download", "mode", args[1].lower() + ), + ) + if changed is None: + return await ctx.client.reply(ctx.message, t_success("autodl.mode_set", mode=args[1].lower())) return @@ -80,9 +98,14 @@ async def execute(self, ctx: CommandContext) -> None: ctx.message, t_error("autodl.cooldown_usage", prefix=ctx.prefix) ) return - runtime_config.set_nested( - "downloader", "auto_link_download", "cooldown_seconds", int(args[1]) + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_nested( + "downloader", "auto_link_download", "cooldown_seconds", int(args[1]) + ), ) + if changed is None: + return await ctx.client.reply(ctx.message, t_success("autodl.cooldown_set", seconds=args[1])) return @@ -92,9 +115,14 @@ async def execute(self, ctx: CommandContext) -> None: ctx.message, t_error("autodl.maxlinks_usage", prefix=ctx.prefix) ) return - runtime_config.set_nested( - "downloader", "auto_link_download", "max_links_per_message", int(args[1]) + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_nested( + "downloader", "auto_link_download", "max_links_per_message", int(args[1]) + ), ) + if changed is None: + return await ctx.client.reply(ctx.message, t_success("autodl.maxlinks_set", count=args[1])) return @@ -108,9 +136,14 @@ async def execute(self, ctx: CommandContext) -> None: if count < 2 or count > 30: await ctx.client.reply(ctx.message, t_error("autodl.album_range")) return - runtime_config.set_nested( - "downloader", "auto_link_download", "photo", "max_images_per_album", count + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_nested( + "downloader", "auto_link_download", "photo", "max_images_per_album", count + ), ) + if changed is None: + return await ctx.client.reply(ctx.message, t_success("autodl.album_set", count=count)) return @@ -124,9 +157,14 @@ async def execute(self, ctx: CommandContext) -> None: if count < 1 or count > 100: await ctx.client.reply(ctx.message, t_error("autodl.photolimit_range")) return - runtime_config.set_nested( - "downloader", "auto_link_download", "photo", "max_images_per_link", count + changed = await self._apply_change( + ctx, + lambda: runtime_config.set_nested( + "downloader", "auto_link_download", "photo", "max_images_per_link", count + ), ) + if changed is None: + return await ctx.client.reply(ctx.message, t_success("autodl.photolimit_set", count=count)) return diff --git a/src/commands/utility/rewrite.py b/src/commands/utility/rewrite.py index 89ae59d..892b059 100644 --- a/src/commands/utility/rewrite.py +++ b/src/commands/utility/rewrite.py @@ -6,7 +6,11 @@ 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 +from ._ai_text import ( + ensure_ai_ready_or_reply, + extract_text_from_quoted_or_args, + run_prompt_with_progress, +) _ALLOWED_STYLES = { "formal", @@ -41,19 +45,12 @@ async def execute(self, ctx: CommandContext) -> None: ) 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() + source_text = extract_text_from_quoted_or_args(ctx, ctx.args, start=1) 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. " @@ -61,16 +58,7 @@ async def execute(self, ctx: CommandContext) -> None: 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 - + def _render(rewritten: str) -> str: output = sym.box( t("rewrite.title"), [ @@ -79,10 +67,12 @@ async def execute(self, ctx: CommandContext) -> None: 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"), - ) + return output + + await run_prompt_with_progress( + ctx, + processing_key="rewrite.processing", + failure_key="rewrite.failed", + prompt=prompt, + render_output=_render, + ) diff --git a/src/commands/utility/summarize.py b/src/commands/utility/summarize.py index 3cb6f32..4b2cfab 100644 --- a/src/commands/utility/summarize.py +++ b/src/commands/utility/summarize.py @@ -6,15 +6,12 @@ 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.privacy import is_chat_memory_enabled -from core.runtime_config import runtime_config + +from ._ai_text import ensure_ai_ready_or_reply, run_prompt_with_progress _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. @@ -23,21 +20,6 @@ {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"] @@ -48,14 +30,7 @@ class SummarizeCommand(Command): 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")) + if not await ensure_ai_ready_or_reply(ctx, "summarize.ai_disabled"): return text_to_summarize = "" @@ -82,37 +57,16 @@ async def execute(self, ctx: CommandContext) -> None: 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 + prompt = _SUMMARIZE_PROMPT.format(text=text_to_summarize[:3000]) + def _render(summary: str) -> str: 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") - ) + return output + + await run_prompt_with_progress( + ctx, + processing_key="summarize.processing", + failure_key="summarize.failed", + prompt=prompt, + render_output=_render, + ) diff --git a/src/commands/utility/translate.py b/src/commands/utility/translate.py index f344c33..1d86fac 100644 --- a/src/commands/utility/translate.py +++ b/src/commands/utility/translate.py @@ -6,7 +6,11 @@ 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 +from ._ai_text import ( + ensure_ai_ready_or_reply, + extract_text_from_quoted_or_args, + run_prompt_with_progress, +) class TranslateCommand(Command): @@ -26,20 +30,12 @@ async def execute(self, ctx: CommandContext) -> None: 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() + source_text = extract_text_from_quoted_or_args(ctx, ctx.args, start=1) 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. " @@ -47,16 +43,7 @@ async def execute(self, ctx: CommandContext) -> None: 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 - + def _render(translated: str) -> str: output = sym.box( t("translate.title"), [ @@ -65,10 +52,12 @@ async def execute(self, ctx: CommandContext) -> None: 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"), - ) + return output + + await run_prompt_with_progress( + ctx, + processing_key="translate.processing", + failure_key="translate.failed", + prompt=prompt, + render_output=_render, + ) diff --git a/src/core/ai_runtime.py b/src/core/ai_runtime.py new file mode 100644 index 0000000..fe84726 --- /dev/null +++ b/src/core/ai_runtime.py @@ -0,0 +1,43 @@ +"""Shared AI runtime helpers for provider/model/key resolution.""" + +from __future__ import annotations + +import os + +from core.runtime_config import runtime_config + + +def resolve_api_key() -> str: + """Resolve API key from env var or 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 resolve_provider() -> str: + """Resolve configured AI provider name.""" + return runtime_config.get_nested("agentic_ai", "provider", default="openai") + + +def resolve_model() -> str: + """Resolve configured AI model name.""" + return runtime_config.get_nested("agentic_ai", "model", default="gpt-5-mini") + + +def resolve_model_name() -> str: + """Resolve full provider:model name used by pydantic-ai.""" + return f"{resolve_provider()}:{resolve_model()}" + + +def apply_provider_env(provider: str, api_key: str) -> None: + """Set provider-specific API key env vars for SDK compatibility.""" + 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 diff --git a/src/core/automations.py b/src/core/automations.py index a9dc62d..2958d6a 100644 --- a/src/core/automations.py +++ b/src/core/automations.py @@ -7,10 +7,21 @@ from core.event_bus import event_bus from core.i18n import t +from core.id_utils import next_prefixed_id from core.moderation import execute_moderation_action from core.storage import GroupData +from core.url_patterns import URL_PATTERN -URL_PATTERN = re.compile(r'https?://[^\s<>"{}|\\^`\[\]]+', re.IGNORECASE) +TRIGGER_TYPES = { + "contains", + "starts_with", + "exact_match", + "regex", + "link", + "media_type", +} +ACTION_TYPES = {"reply", "warn", "delete", "kick", "mute"} +ACTION_ALIASES = {"ban": "kick"} def load_rules(group_jid: str) -> list[dict[str, Any]]: @@ -58,12 +69,23 @@ def set_automation_dry_run(group_jid: str, enabled: bool) -> None: def next_rule_id(rules: list[dict[str, Any]]) -> str: """Generate next rule id like A001.""" - max_idx = 0 - for rule in rules: - rid = str(rule.get("id", "")) - if len(rid) == 4 and rid[0].upper() == "A" and rid[1:].isdigit(): - max_idx = max(max_idx, int(rid[1:])) - return f"A{max_idx + 1:03d}" + return next_prefixed_id(rules, prefix="A", width=3) + + +def is_valid_trigger(trigger_type: str) -> bool: + """Check if trigger type is supported.""" + return str(trigger_type).lower() in TRIGGER_TYPES + + +def normalize_action(action_type: str) -> str: + """Normalize action aliases into canonical action type.""" + raw = str(action_type).lower().strip() + return ACTION_ALIASES.get(raw, raw) + + +def is_valid_action(action_type: str) -> bool: + """Check if action type is supported (including aliases).""" + return normalize_action(action_type) in ACTION_TYPES def rule_matches(rule: dict[str, Any], text: str, media_type: str | None = None) -> bool: @@ -101,14 +123,13 @@ def rule_matches(rule: dict[str, Any], text: str, media_type: str | None = None) async def execute_rule(rule: dict[str, Any], bot, msg) -> bool: """Execute one automation rule. Returns True if an action was executed.""" - action_type = str(rule.get("action_type", "reply")).lower() + action_type = normalize_action(str(rule.get("action_type", "reply")).lower()) action_value = str(rule.get("action_value", "")).strip() if action_type == "reply": await bot.reply(msg, action_value or t("automation.default_reply")) - elif action_type in {"warn", "delete", "kick", "ban"}: - normalized = "kick" if action_type == "ban" else action_type - await execute_moderation_action(bot, msg, normalized, "automation") + elif action_type in {"warn", "delete", "kick"}: + await execute_moderation_action(bot, msg, action_type, "automation") elif action_type == "mute": data = GroupData(msg.chat_jid) muted = data.muted diff --git a/src/core/config_ops.py b/src/core/config_ops.py new file mode 100644 index 0000000..219073f --- /dev/null +++ b/src/core/config_ops.py @@ -0,0 +1,33 @@ +"""Shared helpers for safe runtime config mutations in commands.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from core.i18n import t_error +from core.runtime_config import runtime_config + + +async def apply_config_operation( + ctx: Any, + operation: Callable[[], Any], + *, + preflight: bool = True, +) -> Any: + """Run a config mutation with optional preflight schema validation and unified errors.""" + if preflight: + 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 diff --git a/src/core/id_utils.py b/src/core/id_utils.py new file mode 100644 index 0000000..4edcad2 --- /dev/null +++ b/src/core/id_utils.py @@ -0,0 +1,28 @@ +"""ID generation helpers.""" + +from __future__ import annotations + +from typing import Any + + +def next_prefixed_id( + entries: list[dict[str, Any]], + *, + prefix: str, + width: int, + key: str = "id", +) -> str: + """Generate next sequential prefixed id (e.g. A001, H0001).""" + normalized_prefix = str(prefix).upper() + max_idx = 0 + for item in entries: + raw = str(item.get(key, "")).strip().upper() + if len(raw) != len(normalized_prefix) + width: + continue + if not raw.startswith(normalized_prefix): + continue + number_part = raw[len(normalized_prefix) :] + if not number_part.isdigit(): + continue + max_idx = max(max_idx, int(number_part)) + return f"{normalized_prefix}{max_idx + 1:0{width}d}" diff --git a/src/core/middlewares/auto_download.py b/src/core/middlewares/auto_download.py index 2269d77..373e2b2 100644 --- a/src/core/middlewares/auto_download.py +++ b/src/core/middlewares/auto_download.py @@ -20,8 +20,8 @@ send_photo_items, ) from core.runtime_config import runtime_config +from core.url_patterns import URL_PATTERN -URL_PATTERN = re.compile(r'https?://[^\s<>"{}|\\^`\[\]]+', re.IGNORECASE) APPLE_MUSIC_URL_PATTERN = re.compile( r"https?://(?:music\.apple\.com|embed\.music\.apple\.com)/", re.IGNORECASE ) diff --git a/src/core/presentation.py b/src/core/presentation.py new file mode 100644 index 0000000..622fa81 --- /dev/null +++ b/src/core/presentation.py @@ -0,0 +1,53 @@ +"""Presentation helpers for consistent command output formatting.""" + +from __future__ import annotations + +from core import symbols as sym + +CATEGORY_ICONS = { + "general": sym.INFO, + "admin": sym.USER, + "group": sym.GROUP, + "owner": sym.SETTINGS, + "moderation": sym.WARNING, + "content": sym.SPARKLE, + "utility": sym.COMMAND, +} + + +def format_command_card( + prefix: str, + name: str, + description: str, + usage: str, + *, + aliases: list[str] | None = None, + category: str | None = None, + restrictions: list[str] | None = None, +) -> str: + """Render a command information card using the house style.""" + safe_prefix = prefix or "/" + aliases = aliases or [] + restrictions = restrictions or [] + + lines = [ + f"{sym.HEADER_L} `{safe_prefix}{name}` {sym.HEADER_R}", + "", + f"{sym.QUOTE} {description}", + "", + f"{sym.BULLET} *Usage:* `{usage}`", + ] + + if aliases: + alias_text = ", ".join(f"`{safe_prefix}{alias}`" for alias in aliases) + lines.append(f"{sym.BULLET} *Aliases:* {alias_text}") + + if category: + icon = CATEGORY_ICONS.get(category.lower(), sym.DIAMOND) + lines.append(f"{sym.BULLET} *Category:* {icon} {category.title()}") + + if restrictions: + lines.append("") + lines.append(f"{sym.WARNING} *Restrictions:* {', '.join(restrictions)}") + + return "\n".join(lines) diff --git a/src/core/runtime_config.py b/src/core/runtime_config.py index 867cc7a..64fe037 100644 --- a/src/core/runtime_config.py +++ b/src/core/runtime_config.py @@ -17,6 +17,7 @@ from jsonschema import Draft7Validator from core import jsonc +from core.id_utils import next_prefixed_id CONFIG_FILE = Path(__file__).parent.parent.parent / "config.json" SCHEMA_FILE = Path(__file__).parent.parent.parent / "config.schema.json" @@ -531,12 +532,7 @@ def _save_history_entries(self, entries: list[dict[str, Any]]) -> None: 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}" + return next_prefixed_id(entries, prefix="H", width=4) def _record_history_snapshot(self, config: dict[str, Any], reason: str = "update") -> None: """Record a full config snapshot before mutation.""" diff --git a/src/core/timefmt.py b/src/core/timefmt.py new file mode 100644 index 0000000..db7f305 --- /dev/null +++ b/src/core/timefmt.py @@ -0,0 +1,23 @@ +"""Time formatting helpers.""" + +from __future__ import annotations + + +def format_uptime(seconds: float, *, include_seconds: bool = False) -> str: + """Format uptime seconds as a compact human-readable string.""" + total = max(0, int(seconds)) + days = total // 86400 + hours = (total % 86400) // 3600 + minutes = (total % 3600) // 60 + secs = total % 60 + + parts: list[str] = [] + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + if minutes: + parts.append(f"{minutes}m") + if include_seconds or not parts: + parts.append(f"{secs}s") + return " ".join(parts) diff --git a/src/core/url_patterns.py b/src/core/url_patterns.py new file mode 100644 index 0000000..1d0edef --- /dev/null +++ b/src/core/url_patterns.py @@ -0,0 +1,7 @@ +"""Shared URL regex patterns.""" + +from __future__ import annotations + +import re + +URL_PATTERN = re.compile(r'https?://[^\s<>"{}|\\^`\[\]]+', re.IGNORECASE) diff --git a/src/locales/en.json b/src/locales/en.json index 8537cdb..8b4c9ac 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -325,6 +325,7 @@ "rollback_desc": "Rollback config to a previous snapshot", "diff_title": "Config Differences", "diff_no_changes": "No differences from default config.", + "diff_image_caption": "Config diff (image mode). Use `config diff text` for plain text.", "diff_truncated": "... and {count} more differences", "validate_ok": "Config is valid.", "validate_failed": "Config validation failed: {details}", diff --git a/src/locales/id.json b/src/locales/id.json index 983082d..ee7c69c 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -325,6 +325,7 @@ "rollback_desc": "Balikin config ke snapshot sebelumnya", "diff_title": "Perbedaan Config", "diff_no_changes": "Gak ada perbedaan dari default config.", + "diff_image_caption": "Diff config (mode gambar). Pake `config diff text` buat teks biasa.", "diff_truncated": "... dan {count} perbedaan lagi", "validate_ok": "Config valid.", "validate_failed": "Validasi config gagal: {details}", diff --git a/tests/test_config_diff_image.py b/tests/test_config_diff_image.py new file mode 100644 index 0000000..14fd30d --- /dev/null +++ b/tests/test_config_diff_image.py @@ -0,0 +1,34 @@ +from commands.owner.config import ( + _is_sensitive_path, + _mask_value, + _resolve_diff_mode, + render_diff_image, +) + + +def test_sensitive_path_detection_and_masking(): + assert _is_sensitive_path("agentic_ai.api_key") + assert _is_sensitive_path("dashboard.password") + assert not _is_sensitive_path("bot.prefix") + + assert _mask_value("agentic_ai.api_key", "sk-123") == "[redacted]" + assert _mask_value("bot.prefix", "!") == "!" + + +def test_render_diff_image_returns_png_bytes(): + rows = [ + ("~ bot.prefix: / -> !", "changed"), + ("+ privacy.ai_memory_enabled: True", "added"), + ("- features.old_flag: False", "missing"), + ] + data = render_diff_image("Config Differences", rows) + assert isinstance(data, bytes) + assert data.startswith(b"\x89PNG\r\n\x1a\n") + + +def test_resolve_diff_mode_defaults_image_and_supports_text(): + assert _resolve_diff_mode(None) == "image" + assert _resolve_diff_mode([]) == "image" + assert _resolve_diff_mode(["text"]) == "text" + assert _resolve_diff_mode(["txt"]) == "text" + assert _resolve_diff_mode(["image"]) == "image" diff --git a/tests/test_id_utils.py b/tests/test_id_utils.py new file mode 100644 index 0000000..4d2b2b6 --- /dev/null +++ b/tests/test_id_utils.py @@ -0,0 +1,11 @@ +from core.id_utils import next_prefixed_id + + +def test_next_prefixed_id_basic_sequence(): + rows = [{"id": "A001"}, {"id": "A003"}, {"id": "A002"}] + assert next_prefixed_id(rows, prefix="A", width=3) == "A004" + + +def test_next_prefixed_id_ignores_invalid_values(): + rows = [{"id": "X999"}, {"id": "AAB"}, {"id": ""}, {}] + assert next_prefixed_id(rows, prefix="A", width=3) == "A001" diff --git a/tests/test_presentation.py b/tests/test_presentation.py new file mode 100644 index 0000000..5964b7f --- /dev/null +++ b/tests/test_presentation.py @@ -0,0 +1,20 @@ +from core.presentation import format_command_card + + +def test_format_command_card_contains_expected_sections(): + text = format_command_card( + ".", + "permission", + "Manage role overrides for command access", + ".permission list | set | reset", + aliases=["permissions", "perm"], + category="owner", + restrictions=["Owner only"], + ) + + assert "「 `.permission` 」" in text + assert "» Manage role overrides for command access" in text + assert "• *Usage:* `.permission list | set | reset`" in text + assert "• *Aliases:* `.permissions`, `.perm`" in text + assert "• *Category:* ⛯ Owner" in text + assert "⊘ *Restrictions:* Owner only" in text diff --git a/tests/test_timefmt.py b/tests/test_timefmt.py new file mode 100644 index 0000000..6ebecd3 --- /dev/null +++ b/tests/test_timefmt.py @@ -0,0 +1,11 @@ +from core.timefmt import format_uptime + + +def test_format_uptime_without_seconds(): + assert format_uptime(18 * 60) == "18m" + assert format_uptime(2 * 3600 + 5 * 60) == "2h 5m" + + +def test_format_uptime_with_seconds(): + assert format_uptime(59, include_seconds=True) == "59s" + assert format_uptime(61, include_seconds=True) == "1m 1s" From 2834c52b516b549275fde8c00a10781d3463d1cd Mon Sep 17 00:00:00 2001 From: MhankBarBar Date: Sat, 28 Mar 2026 19:41:21 +0700 Subject: [PATCH 2/2] fix addskill input handling Fixes #12 --- docs/commands/owner.md | 9 +++-- docs/features/ai.md | 26 +++++++++--- src/commands/owner/addskill.py | 72 ++++++++++++++++++++++++++++++--- src/core/constants.py | 1 + tests/test_addskill.py | 20 +++++++++ tests/test_message_constants.py | 5 +++ tests/test_privacy_controls.py | 1 - 7 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 tests/test_addskill.py create mode 100644 tests/test_message_constants.py diff --git a/docs/commands/owner.md b/docs/commands/owner.md index 4222ad6..4db4bb4 100644 --- a/docs/commands/owner.md +++ b/docs/commands/owner.md @@ -199,14 +199,15 @@ List all dynamically created commands. Add an AI skill — custom instructions that the AI follows. ``` -/addskill - +/addskill +/addskill +/addskill # with attached .md file +/addskill # when replying to text ``` **Example:** ``` -/addskill translator -Always translate messages to English when asked. +/addskill translator Always translate messages to English when asked. ``` ## /delskill diff --git a/docs/features/ai.md b/docs/features/ai.md index 8571c4e..afe2021 100644 --- a/docs/features/ai.md +++ b/docs/features/ai.md @@ -80,17 +80,31 @@ Skills are custom instructions that the AI follows. They let you customize the A ### Adding a Skill ``` -/addskill - +/addskill ``` **Example:** ``` -/addskill translator -When someone asks you to translate, translate the text -to the requested language. If no language is specified, -translate to English. +/addskill translator When someone asks you to translate, translate to requested language. +``` + +Alternative methods: + +``` +/addskill +``` + +or attach a `.md` skill file and send: + +``` +/addskill +``` + +You can also reply to a text message and send: + +``` +/addskill ``` ### Managing Skills diff --git a/src/commands/owner/addskill.py b/src/commands/owner/addskill.py index 4cd9a0e..8e148ed 100644 --- a/src/commands/owner/addskill.py +++ b/src/commands/owner/addskill.py @@ -2,15 +2,53 @@ Add skill command - Add AI skills from URL or file. """ +from __future__ import annotations + +from typing import Any + from core import symbols as sym from core.command import Command, CommandContext +def build_inline_skill(raw_args: str, quoted_text: str = "") -> dict[str, Any] | None: + """Build inline skill payload from command args/quoted text. + + Supports: + - addskill + - addskill (with quoted message text as instructions) + """ + raw = (raw_args or "").strip() + if not raw: + return None + + parts = raw.split(maxsplit=1) + if not parts: + return None + + name = parts[0].strip().lower() + if not name: + return None + + content = parts[1].strip() if len(parts) > 1 else "" + if not content: + content = (quoted_text or "").strip() + if not content: + return None + + return { + "name": name, + "description": f"Inline skill: {name}", + "trigger": "always", + "priority": 10, + "content": content, + } + + class AddSkillCommand(Command): name = "addskill" aliases = ["skill"] - description = "Add an AI skill from URL or attached file" - usage = "addskill or attach .md file" + description = "Add an AI skill from inline text, URL, or attached file" + usage = "addskill | addskill | attach .md file" category = "owner" owner_only = True @@ -24,9 +62,9 @@ async def execute(self, ctx: CommandContext) -> None: ) if ctx.args: - url = ctx.args[0] - if url.startswith("http://") or url.startswith("https://"): - skill = await load_skill_from_url(url) + first_arg = ctx.args[0] + if first_arg.startswith("http://") or first_arg.startswith("https://"): + skill = await load_skill_from_url(first_arg) if skill: save_skill_to_file(skill) agentic_ai.add_skill( @@ -83,10 +121,34 @@ async def execute(self, ctx: CommandContext) -> None: ) return + quoted_text = "" + if ctx.message.quoted_message and ctx.message.quoted_message.get("text"): + quoted_text = str(ctx.message.quoted_message.get("text") or "") + + inline_skill = build_inline_skill(ctx.raw_args, quoted_text) + if inline_skill: + save_skill_to_file(inline_skill) + agentic_ai.add_skill( + inline_skill["name"], + inline_skill["content"], + inline_skill["description"], + inline_skill["trigger"], + ) + await ctx.client.reply( + ctx.message, + f"{sym.SUCCESS} *Skill Added*\n\n" + f"*Name:* `{inline_skill['name']}`\n" + f"*Description:* {inline_skill['description']}\n" + f"*Trigger:* {inline_skill['trigger']}", + ) + return + await ctx.client.reply( ctx.message, f"{sym.INFO} *Add AI Skill*\n\n" f"Usage:\n" + f"• `/addskill ` - Inline skill\n" + f"• Reply to text with `/addskill `\n" f"• `/addskill ` - Load from URL\n" f"• Attach a `.md` file and send `/addskill`\n\n" f"*Skill Format:*\n" diff --git a/src/core/constants.py b/src/core/constants.py index ebffa70..f715429 100644 --- a/src/core/constants.py +++ b/src/core/constants.py @@ -43,6 +43,7 @@ ("extendedTextMessage", "text"), ("imageMessage", "caption"), ("videoMessage", "caption"), + ("documentMessage", "caption"), ) PHOTO_IMAGE_EXTENSIONS = frozenset( diff --git a/tests/test_addskill.py b/tests/test_addskill.py new file mode 100644 index 0000000..4d3250e --- /dev/null +++ b/tests/test_addskill.py @@ -0,0 +1,20 @@ +from commands.owner.addskill import build_inline_skill + + +def test_build_inline_skill_from_inline_text(): + skill = build_inline_skill("translator Always translate to English") + assert skill is not None + assert skill["name"] == "translator" + assert "Always translate" in skill["content"] + assert skill["trigger"] == "always" + + +def test_build_inline_skill_from_quoted_text(): + skill = build_inline_skill("translator", quoted_text="Translate to Indonesian") + assert skill is not None + assert skill["name"] == "translator" + assert skill["content"] == "Translate to Indonesian" + + +def test_build_inline_skill_requires_content(): + assert build_inline_skill("translator") is None diff --git a/tests/test_message_constants.py b/tests/test_message_constants.py new file mode 100644 index 0000000..849aa3c --- /dev/null +++ b/tests/test_message_constants.py @@ -0,0 +1,5 @@ +from core.constants import TEXT_SOURCES + + +def test_document_caption_is_in_text_sources(): + assert ("documentMessage", "caption") in TEXT_SOURCES diff --git a/tests/test_privacy_controls.py b/tests/test_privacy_controls.py index b0a3e00..897b2d0 100644 --- a/tests/test_privacy_controls.py +++ b/tests/test_privacy_controls.py @@ -60,7 +60,6 @@ def test_clear_memory_for_uncached_chat(tmp_path, monkeypatch): 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)