Add dual-prefix AI command

This commit is contained in:
Codex
2026-05-14 13:24:47 +02:00
parent 27b2760079
commit 2a731600e0
16 changed files with 607 additions and 16 deletions

9
app/ai/__init__.py Normal file
View File

@@ -0,0 +1,9 @@
from app.ai.parser import AIInvocation, parse_ai_invocation
from app.ai.service import AIResponse, AIService
__all__ = [
"AIInvocation",
"AIResponse",
"AIService",
"parse_ai_invocation",
]

44
app/ai/parser.py Normal file
View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from dataclasses import dataclass
import shlex
@dataclass(slots=True)
class AIInvocation:
context_messages: int
model_flags: list[str]
prompt: str
def parse_ai_invocation(text: str, *, default_context_messages: int) -> AIInvocation:
try:
tokens = shlex.split(text)
except ValueError:
tokens = text.split()
context_messages = default_context_messages
model_flags: list[str] = []
if tokens:
first = tokens[0]
if first.isdigit():
context_messages = int(tokens.pop(0))
while tokens and tokens[0].startswith("--"):
flag = tokens.pop(0)[2:]
if "=" in flag:
flag = flag.split("=", 1)[0]
flag = flag.strip().lower()
if flag:
model_flags.append(flag)
prompt = " ".join(tokens).strip()
if not prompt:
raise ValueError("Usage: ..ai [N] [--model] prompt")
return AIInvocation(
context_messages=context_messages,
model_flags=model_flags,
prompt=prompt,
)

37
app/ai/prompts.py Normal file
View File

@@ -0,0 +1,37 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
DEFAULT_SYSTEM_PROMPT = "You are a concise, accurate Telegram assistant."
@dataclass(slots=True)
class PromptBook:
root: Path
system_file: str = "system.md"
parts_dir: str = "parts"
def _read_file(self, path: Path) -> str:
return path.read_text(encoding="utf-8").strip()
def load_system_prompt(self) -> str:
sections: list[str] = []
system_path = self.root / self.system_file
if system_path.exists():
text = self._read_file(system_path)
if text:
sections.append(text)
parts_root = self.root / self.parts_dir
if parts_root.exists():
for path in sorted(parts_root.rglob("*.md")):
if path.is_file():
text = self._read_file(path)
if text:
sections.append(text)
if not sections:
return DEFAULT_SYSTEM_PROMPT
return "\n\n".join(sections).strip()

192
app/ai/service.py Normal file
View File

@@ -0,0 +1,192 @@
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING, Any
from openai import AsyncOpenAI
from app.ai.parser import AIInvocation
from app.ai.prompts import PromptBook
from app.config import AIConfig, AIModelConfig, AIProviderConfig
if TYPE_CHECKING:
from app.commands.core import CommandContext
log = logging.getLogger(__name__)
@dataclass(slots=True)
class AIResponse:
text: str
model: AIModelConfig
provider: AIProviderConfig
@dataclass(slots=True)
class ResolvedAIModel:
alias: str
provider: AIProviderConfig
model: AIModelConfig
class AIService:
def __init__(self, config: AIConfig) -> None:
self.config = config
self.prompt_book = PromptBook(config.prompt.root)
self._clients: dict[str, AsyncOpenAI] = {}
self._system_prompt: str | None = None
@property
def enabled(self) -> bool:
return True
def available_model_aliases(self) -> list[str]:
return sorted(self.config.models.keys())
def describe_models(self) -> str:
lines = [f"default={self.config.default_model}"]
for alias in self.available_model_aliases():
model = self.config.models[alias]
provider = self.config.providers[model.provider]
lines.append(f"{alias} -> {provider.name}:{model.model}")
return "; ".join(lines)
def select_model_alias(self, model_flags: list[str]) -> str:
for flag in model_flags:
if flag in self.config.models:
return flag
if model_flags:
raise ValueError(
"Unknown model flag: " + ", ".join(f"--{flag}" for flag in model_flags)
)
return self.config.default_model
def resolve_model(self, alias: str | None) -> ResolvedAIModel:
selected_alias = alias or self.config.default_model
model = self.config.models.get(selected_alias)
if model is None:
raise ValueError(f"Unknown AI model alias: {selected_alias}")
provider = self.config.providers.get(model.provider)
if provider is None:
raise ValueError(
f"AI model {selected_alias} references unknown provider: {model.provider}"
)
return ResolvedAIModel(alias=selected_alias, provider=provider, model=model)
def _get_client(self, provider: AIProviderConfig) -> AsyncOpenAI:
client = self._clients.get(provider.name)
if client is None:
client = AsyncOpenAI(
api_key=provider.api_key,
base_url=provider.base_url,
timeout=provider.timeout,
)
self._clients[provider.name] = client
return client
def load_system_prompt(self) -> str:
if self._system_prompt is None:
self._system_prompt = self.prompt_book.load_system_prompt()
return self._system_prompt
async def build_messages(
self,
ctx: "CommandContext",
*,
prompt: str,
context_messages: int,
) -> list[dict[str, str]]:
messages: list[dict[str, str]] = [{"role": "system", "content": self.load_system_prompt()}]
history = await self.collect_history(ctx, limit=context_messages)
messages.extend(history)
messages.append({"role": "user", "content": prompt})
return messages
async def collect_history(self, ctx: "CommandContext", *, limit: int) -> list[dict[str, str]]:
if limit <= 0:
return []
collected: list[dict[str, str]] = []
current_message_id = getattr(ctx.message, "id", 0)
owner_id = ctx.owner_id
async for message in ctx.client.get_chat_history(
ctx.message.chat.id,
limit=limit + 5,
offset_id=current_message_id,
):
message_id = getattr(message, "id", 0)
if message_id >= current_message_id:
continue
text = self._extract_message_text(message)
if not text:
continue
role = "assistant" if self._is_own_message(message, owner_id) else "user"
collected.append({"role": role, "content": text})
if len(collected) >= limit:
break
collected.reverse()
return collected
def _extract_message_text(self, message: Any) -> str:
text = getattr(message, "text", None) or getattr(message, "caption", None)
if not isinstance(text, str):
return ""
return text.strip()
def _is_own_message(self, message: Any, owner_id: int | None) -> bool:
user = getattr(message, "from_user", None)
user_id = getattr(user, "id", None)
if owner_id is None:
return bool(getattr(message, "outgoing", False))
return user_id == owner_id or bool(getattr(message, "outgoing", False))
async def generate(self, ctx: "CommandContext", invocation: AIInvocation) -> AIResponse:
model = self.resolve_model(invocation.model_flags[0] if invocation.model_flags else None)
client = self._get_client(model.provider)
messages = await self.build_messages(
ctx,
prompt=invocation.prompt,
context_messages=invocation.context_messages,
)
try:
response = await client.chat.completions.create(
model=model.model.model,
messages=messages,
)
except Exception as exc:
log.exception("AI request failed for model %s", model.alias)
raise RuntimeError(f"AI request failed: {exc}") from exc
text = self._extract_response_text(response)
if not text:
raise RuntimeError("AI model returned an empty response")
return AIResponse(text=text, model=model.model, provider=model.provider)
def _extract_response_text(self, response: Any) -> str:
choices = getattr(response, "choices", None) or []
if not choices:
return ""
choice = choices[0]
message = getattr(choice, "message", None)
if message is None:
return ""
content = getattr(message, "content", None)
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts = []
for part in content:
text = getattr(part, "text", None)
if isinstance(text, str):
parts.append(text)
return "\n".join(parts).strip()
return ""

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
from textwrap import wrap
from typing import TYPE_CHECKING, Any
from app.ai import AIInvocation, parse_ai_invocation
from app.commands.core import CommandRegistry, normalize_command_name
if TYPE_CHECKING:
@@ -31,6 +33,58 @@ def _display_command(command: str) -> str:
return f"..{command}"
def _chunk_text(text: str, limit: int = 3900) -> list[str]:
if len(text) <= limit:
return [text]
chunks: list[str] = []
current: list[str] = []
current_len = 0
for paragraph in text.split("\n"):
paragraph = paragraph.rstrip()
piece = paragraph if not current else "\n" + paragraph
if current_len + len(piece) <= limit:
current.append(piece.lstrip("\n") if not current else piece)
current_len += len(piece)
continue
if current:
chunks.append("".join(current))
current = []
current_len = 0
if len(paragraph) <= limit:
current = [paragraph]
current_len = len(paragraph)
continue
for line in wrap(paragraph, width=limit):
if len(line) <= limit:
chunks.append(line)
else:
chunks.extend([line[i : i + limit] for i in range(0, len(line), limit)])
if current:
chunks.append("".join(current))
return [chunk for chunk in chunks if chunk]
async def _reply_long(ctx: "CommandContext", text: str) -> None:
chunks = _chunk_text(text)
if not chunks:
await ctx.reply("")
return
first = await ctx.reply(chunks[0])
for chunk in chunks[1:]:
await ctx.client.send_message(
ctx.message.chat.id,
chunk,
reply_to_message_id=getattr(first, "id", None),
)
def _format_ai_help(ctx: "CommandContext") -> str:
ai_service = ctx.manager.ai_service
if ai_service is None:
return ""
return "AI models: " + ai_service.describe_models()
def _format_permissions(ctx: "CommandContext") -> str:
store = ctx.acl_store
target = ctx.replied_user
@@ -163,6 +217,9 @@ async def status(ctx: "CommandContext") -> None:
f"allow_rules={stats['allow_rules']}, "
f"deny_rules={stats['deny_rules']}"
)
ai_service = ctx.manager.ai_service
if ai_service is not None:
lines.append("AI: " + ai_service.describe_models())
await ctx.reply("\n".join(lines))
@@ -173,7 +230,12 @@ async def status(ctx: "CommandContext") -> None:
category="system",
)
async def help_command(ctx: "CommandContext") -> None:
await ctx.reply(ctx.manager.command_registry.render_help(ctx))
lines = [ctx.manager.command_registry.render_help(ctx)]
ai_help = _format_ai_help(ctx)
if ai_help:
lines.append("")
lines.append(ai_help)
await ctx.reply("\n".join(lines))
@command_registry.command(
@@ -209,12 +271,48 @@ async def deny(ctx: "CommandContext") -> None:
@command_registry.command(
"echo",
"Echo text for granted users",
usage="..echo <text>",
usage=".echo / ..echo <text>",
category="shared",
).shared()
async def echo(ctx: "CommandContext") -> None:
text = ctx.args.strip()
if not text:
await ctx.reply("Usage: ..echo <text>")
await ctx.reply("Usage: .echo / ..echo <text>")
return
await ctx.reply(text)
@command_registry.command(
"ai",
"Ask a configurable AI model",
usage=".ai / ..ai [N] [--model] prompt",
category="ai",
).shared()
async def ai(ctx: "CommandContext") -> None:
ai_service = ctx.manager.ai_service
if ai_service is None or ctx.manager.config.ai is None:
await ctx.reply("AI is not configured")
return
try:
invocation = parse_ai_invocation(
ctx.args,
default_context_messages=ctx.manager.config.ai.context_messages,
)
selected_alias = ai_service.select_model_alias(invocation.model_flags)
response = await ai_service.generate(
ctx,
AIInvocation(
context_messages=invocation.context_messages,
model_flags=[selected_alias],
prompt=invocation.prompt,
),
)
except ValueError as exc:
await ctx.reply(str(exc))
return
except RuntimeError as exc:
await ctx.reply(str(exc))
return
await _reply_long(ctx, response.text)

View File

@@ -82,7 +82,8 @@ def shared_access_check() -> CommandCheck:
return CheckResult.deny("Permission store is not initialized")
if store.is_allowed(ctx.actor_id, ctx.command.name):
return CheckResult.allow()
return CheckResult.deny(f"No access to {ctx.command.prefix}{ctx.command.name}")
required_prefix = ctx.command.expected_prefix(ctx.is_owner)
return CheckResult.deny(f"No access to {required_prefix}{ctx.command.name}")
return CommandCheck("shared_access", _check, "Allow owner or ACL-granted users")
@@ -185,7 +186,8 @@ class CommandDefinition:
name: str
description: str
handler: CommandHandler
prefix: str = "."
owner_prefix: str = "."
guest_prefix: str | None = None
aliases: tuple[str, ...] = ()
usage: str | None = None
category: str = "general"
@@ -193,11 +195,21 @@ class CommandDefinition:
@property
def is_shared(self) -> bool:
return self.prefix == ".."
return self.guest_prefix is not None
@property
def display_name(self) -> str:
return f"{self.prefix}{self.name}"
if self.guest_prefix is None:
return f"{self.owner_prefix}{self.name}"
return f"{self.owner_prefix}{self.name} / {self.guest_prefix}{self.name}"
def expected_prefix(self, is_owner: bool) -> str:
if self.guest_prefix is None:
return self.owner_prefix
return self.owner_prefix if is_owner else self.guest_prefix
def accepts_prefix(self, prefix: str, *, is_owner: bool) -> bool:
return prefix == self.expected_prefix(is_owner)
def help_line(self, ctx: CommandContext | None = None) -> str:
usage = self.usage or self.display_name
@@ -231,15 +243,16 @@ class CommandBuilder:
self.aliases = [normalize_command_name(alias) for alias in aliases]
self.usage = usage
self.category = category
self.prefix = "."
self.owner_prefix = "."
self.guest_prefix: str | None = None
self.checks: list[CommandCheck] = []
def private(self) -> "CommandBuilder":
self.prefix = "."
self.guest_prefix = None
return self
def shared(self) -> "CommandBuilder":
self.prefix = ".."
self.guest_prefix = ".."
return self
def alias(self, *aliases: str) -> "CommandBuilder":
@@ -264,7 +277,7 @@ class CommandBuilder:
def build(self, handler: CommandHandler) -> CommandDefinition:
default_checks: list[CommandCheck] = []
if self.prefix == "..":
if self.guest_prefix is not None:
default_checks.append(shared_access_check())
else:
default_checks.append(owner_only_check())
@@ -273,7 +286,8 @@ class CommandBuilder:
name=self.name,
description=self.description,
handler=handler,
prefix=self.prefix,
owner_prefix=self.owner_prefix,
guest_prefix=self.guest_prefix,
aliases=tuple(self.aliases),
usage=self.usage,
category=self.category,

View File

@@ -71,8 +71,18 @@ class CommandDispatcher:
await message.reply_text(f"Unknown command: {parsed.prefix}{parsed.alias}")
return True
if parsed.prefix != definition.prefix:
await message.reply_text(f"Use {definition.display_name} for this command")
actor_id = getattr(getattr(message, "from_user", None), "id", None)
owner_id = getattr(getattr(account, "me", None), "id", None)
is_owner = owner_id is not None and actor_id == owner_id
expected_prefix = definition.expected_prefix(is_owner)
if parsed.prefix != expected_prefix:
if definition.is_shared:
await message.reply_text(
f"Use {expected_prefix}{definition.name} for this account role"
)
else:
await message.reply_text(f"Use {definition.owner_prefix}{definition.name} for this command")
return True
ctx = CommandContext(

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import json
import os
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@@ -28,12 +28,45 @@ class AccountConfig:
hide_password: bool = False
@dataclass(slots=True)
class AIProviderConfig:
name: str
api_key: str
base_url: str
timeout: float | None = None
@dataclass(slots=True)
class AIModelConfig:
alias: str
provider: str
model: str
description: str | None = None
@dataclass(slots=True)
class AIPromptConfig:
root: Path
system_file: str = "system.md"
parts_dir: str = "parts"
@dataclass(slots=True)
class AIConfig:
default_model: str
context_messages: int
prompt: AIPromptConfig
providers: dict[str, AIProviderConfig] = field(default_factory=dict)
models: dict[str, AIModelConfig] = field(default_factory=dict)
@dataclass(slots=True)
class AppConfig:
api_id: int | None
api_hash: str | None
session_dir: Path
accounts: list[AccountConfig]
ai: AIConfig | None = None
def _read_json_file(path: Path) -> dict[str, Any]:
@@ -47,6 +80,21 @@ def _parse_int(value: Any, *, field_name: str) -> int:
raise ValueError(f"{field_name} must be an integer") from exc
def _parse_float(value: Any, *, field_name: str) -> float:
try:
return float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field_name} must be a number") from exc
def _parse_mapping(value: Any, *, field_name: str) -> dict[str, Any]:
if value is None:
return {}
if not isinstance(value, dict):
raise ValueError(f"{field_name} must be an object")
return value
def _parse_accounts(raw_accounts: Any, *, default_api_id: int | None, default_api_hash: str | None, default_session_dir: Path) -> list[AccountConfig]:
if raw_accounts is None:
return []
@@ -112,6 +160,90 @@ def _parse_accounts(raw_accounts: Any, *, default_api_id: int | None, default_ap
return accounts
def _parse_ai_config(raw_ai: Any, *, root: Path) -> AIConfig | None:
if raw_ai is None:
return None
if not isinstance(raw_ai, dict):
raise ValueError("ai must be an object")
prompt_raw = _parse_mapping(raw_ai.get("prompt"), field_name="ai.prompt")
prompt_root = Path(str(prompt_raw.get("root", "prompts/ai"))).expanduser()
if not prompt_root.is_absolute():
prompt_root = (root / prompt_root).resolve()
prompt = AIPromptConfig(
root=prompt_root,
system_file=str(prompt_raw.get("system_file", "system.md")),
parts_dir=str(prompt_raw.get("parts_dir", "parts")),
)
default_model = str(raw_ai.get("default_model", "")).strip()
if not default_model:
raise ValueError("ai.default_model is required")
context_messages = _parse_int(raw_ai.get("context_messages", 12), field_name="ai.context_messages")
if context_messages < 0:
raise ValueError("ai.context_messages must be >= 0")
providers_raw = _parse_mapping(raw_ai.get("providers"), field_name="ai.providers")
models_raw = _parse_mapping(raw_ai.get("models"), field_name="ai.models")
providers: dict[str, AIProviderConfig] = {}
for provider_name, provider_config in providers_raw.items():
if not isinstance(provider_config, dict):
raise ValueError(f"ai.providers.{provider_name} must be an object")
api_key = str(provider_config.get("api_key", "")).strip()
base_url = str(provider_config.get("base_url", "")).strip()
if not api_key:
raise ValueError(f"ai.providers.{provider_name}.api_key is required")
if not base_url:
raise ValueError(f"ai.providers.{provider_name}.base_url is required")
timeout_value = provider_config.get("timeout")
timeout = None
if timeout_value is not None:
timeout = _parse_float(timeout_value, field_name=f"ai.providers.{provider_name}.timeout")
providers[provider_name] = AIProviderConfig(
name=provider_name,
api_key=api_key,
base_url=base_url,
timeout=timeout,
)
models: dict[str, AIModelConfig] = {}
for alias, model_config in models_raw.items():
if not isinstance(model_config, dict):
raise ValueError(f"ai.models.{alias} must be an object")
provider = str(model_config.get("provider", "")).strip()
model = str(model_config.get("model", "")).strip()
if not provider:
raise ValueError(f"ai.models.{alias}.provider is required")
if not model:
raise ValueError(f"ai.models.{alias}.model is required")
description = model_config.get("description")
models[alias] = AIModelConfig(
alias=alias,
provider=provider,
model=model,
description=str(description).strip() if description is not None else None,
)
if default_model not in models:
raise ValueError("ai.default_model must reference one of ai.models")
for alias, model_config in models.items():
if model_config.provider not in providers:
raise ValueError(
f"ai.models.{alias}.provider references unknown provider: {model_config.provider}"
)
return AIConfig(
default_model=default_model,
context_messages=context_messages,
prompt=prompt,
providers=providers,
models=models,
)
def _accounts_from_env() -> list[dict[str, Any]] | None:
raw_accounts = os.environ.get("ACCOUNTS_JSON")
if raw_accounts:
@@ -154,6 +286,7 @@ def load_app_config(root: Path) -> AppConfig:
env_api_id = os.environ.get("TG_API_ID")
env_api_hash = os.environ.get("TG_API_HASH")
env_session_dir = os.environ.get("SESSION_DIR")
env_ai_config = os.environ.get("AI_CONFIG")
if env_api_id is not None:
raw["api_id"] = env_api_id
@@ -161,6 +294,8 @@ def load_app_config(root: Path) -> AppConfig:
raw["api_hash"] = env_api_hash
if env_session_dir is not None:
raw["session_dir"] = env_session_dir
if env_ai_config:
raw["ai"] = json.loads(env_ai_config)
env_accounts = _accounts_from_env()
if env_accounts is not None:
@@ -184,6 +319,8 @@ def load_app_config(root: Path) -> AppConfig:
default_session_dir=session_dir,
)
ai = _parse_ai_config(raw.get("ai"), root=root)
if not accounts:
raise ValueError("At least one account must be configured")
@@ -202,4 +339,5 @@ def load_app_config(root: Path) -> AppConfig:
api_hash=api_hash,
session_dir=session_dir,
accounts=accounts,
ai=ai,
)

View File

@@ -7,6 +7,7 @@ from typing import Any
from pyrogram import Client, idle
from app.ai import AIService
from app.commands import DEFAULT_REGISTRY, CommandRegistry, RuntimeAclStore
from app.config import AccountConfig, AppConfig
from app.handlers import register_common_handlers
@@ -28,11 +29,13 @@ class AccountState:
@dataclass(slots=True)
class AccountManager:
config: AppConfig
ai_service: AIService | None = field(init=False)
command_registry: CommandRegistry = field(init=False)
states: list[AccountState] = field(init=False)
def __post_init__(self) -> None:
self.command_registry = DEFAULT_REGISTRY
self.ai_service = AIService(self.config.ai) if self.config.ai is not None else None
self.states = [AccountState(config=account) for account in self.config.accounts]
def _make_client(self, account: AccountConfig) -> Client:
@@ -121,6 +124,8 @@ class AccountManager:
parts.append(
f"acl={stats['allow_rules']} allow / {stats['deny_rules']} deny"
)
if self.ai_service is not None and self.config.ai is not None:
parts.append(f"ai={self.config.ai.default_model}")
if state.me is not None:
display_name = (
getattr(state.me, "full_name", None)