Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions docs/commands/owner.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ Manage bot configuration live from WhatsApp.
/config # Show current config overview
/config owner me # Set yourself as owner
/config prefix <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 <id> # Roll back to a previous snapshot
Expand Down Expand Up @@ -70,6 +71,7 @@ Roles:
- `admin`
- `owner`


## /privacy

Manage data retention and AI memory privacy controls.
Expand Down Expand Up @@ -197,14 +199,15 @@ List all dynamically created commands.
Add an AI skill — custom instructions that the AI follows.

```
/addskill <name>
<instructions>
/addskill <name> <instructions>
/addskill <url>
/addskill # with attached .md file
/addskill <name> # when replying to text
```

**Example:**
```
/addskill translator
Always translate messages to English when asked.
/addskill translator Always translate messages to English when asked.
```

## /delskill
Expand Down
26 changes: 20 additions & 6 deletions docs/features/ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,31 @@ Skills are custom instructions that the AI follows. They let you customize the A
### Adding a Skill

```
/addskill <name>
<instructions>
/addskill <name> <instructions>
```

**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 <url-to-markdown-skill>
```

or attach a `.md` skill file and send:

```
/addskill
```

You can also reply to a text message and send:

```
/addskill <name>
```

### Managing Skills
Expand Down
15 changes: 3 additions & 12 deletions src/ai/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@

from __future__ import annotations

import os
import re
from typing import TYPE_CHECKING

from pydantic_ai import Agent, BinaryContent, RunContext

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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}")
Expand Down
49 changes: 20 additions & 29 deletions src/commands/general/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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:
Expand All @@ -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))
21 changes: 2 additions & 19 deletions src/commands/general/uptime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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}")
10 changes: 6 additions & 4 deletions src/commands/moderation/automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
72 changes: 67 additions & 5 deletions src/commands/owner/addskill.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> <instructions>
- addskill <name> (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 <url> or attach .md file"
description = "Add an AI skill from inline text, URL, or attached file"
usage = "addskill <name> <instructions> | addskill <url> | attach .md file"
category = "owner"
owner_only = True

Expand All @@ -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(
Expand Down Expand Up @@ -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 <name> <instructions>` - Inline skill\n"
f"• Reply to text with `/addskill <name>`\n"
f"• `/addskill <url>` - Load from URL\n"
f"• Attach a `.md` file and send `/addskill`\n\n"
f"*Skill Format:*\n"
Expand Down
Loading
Loading