Add multi-account command ACL system

This commit is contained in:
Codex
2026-05-13 22:19:27 +02:00
commit 27b2760079
14 changed files with 1296 additions and 0 deletions

34
app/commands/__init__.py Normal file
View File

@@ -0,0 +1,34 @@
from app.commands.acl import RuntimeAclStore
from app.commands.builtins import DEFAULT_REGISTRY, command_registry
from app.commands.core import (
CheckResult,
CommandBuilder,
CommandCheck,
CommandContext,
CommandDefinition,
CommandRegistry,
combine_checks,
owner_only_check,
require_args_check,
require_reply_check,
shared_access_check,
)
from app.commands.dispatcher import CommandDispatcher
__all__ = [
"CheckResult",
"CommandBuilder",
"CommandCheck",
"CommandContext",
"CommandDefinition",
"CommandDispatcher",
"CommandRegistry",
"DEFAULT_REGISTRY",
"RuntimeAclStore",
"command_registry",
"combine_checks",
"owner_only_check",
"require_args_check",
"require_reply_check",
"shared_access_check",
]

161
app/commands/acl.py Normal file
View File

@@ -0,0 +1,161 @@
from __future__ import annotations
from dataclasses import dataclass, field
import json
import logging
from pathlib import Path
from tempfile import NamedTemporaryFile
from threading import RLock
log = logging.getLogger(__name__)
def _normalize_command(value: str) -> str:
token = value.strip().lower()
token = token.lstrip(".")
if "@" in token:
token = token.split("@", 1)[0]
return token
@dataclass(slots=True)
class RuntimeAclStore:
path: Path
data: dict[str, dict[str, set[str]]] = field(init=False)
_lock: RLock = field(init=False, repr=False)
def __post_init__(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = RLock()
self.data = {"allow": {}, "deny": {}}
self.load()
def load(self) -> None:
with self._lock:
if not self.path.exists():
return
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
log.warning("Ignoring malformed ACL file: %s", self.path)
return
if not isinstance(raw, dict):
log.warning("Ignoring ACL file with unexpected root type: %s", self.path)
return
allow_raw = raw.get("allow", {})
deny_raw = raw.get("deny", {})
self.data = {
"allow": {
str(user_id): {str(command) for command in commands}
for user_id, commands in allow_raw.items()
},
"deny": {
str(user_id): {str(command) for command in commands}
for user_id, commands in deny_raw.items()
},
}
def save(self) -> None:
with self._lock:
payload = {
"version": 1,
"allow": {
user_id: sorted(commands)
for user_id, commands in sorted(self.data["allow"].items())
if commands
},
"deny": {
user_id: sorted(commands)
for user_id, commands in sorted(self.data["deny"].items())
if commands
},
}
serialized = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
with NamedTemporaryFile(
"w",
encoding="utf-8",
delete=False,
dir=self.path.parent,
prefix=self.path.name,
suffix=".tmp",
) as handle:
handle.write(serialized)
temp_path = Path(handle.name)
temp_path.replace(self.path)
def _allow_set(self, user_id: int) -> set[str]:
return self.data["allow"].setdefault(str(user_id), set())
def _deny_set(self, user_id: int) -> set[str]:
return self.data["deny"].setdefault(str(user_id), set())
def allow(self, user_id: int, command: str) -> bool:
with self._lock:
command_name = _normalize_command(command)
if not command_name:
return False
allow_set = self._allow_set(user_id)
deny_set = self._deny_set(user_id)
changed = command_name not in allow_set or command_name in deny_set
allow_set.add(command_name)
deny_set.discard(command_name)
if changed:
self.save()
return changed
def deny(self, user_id: int, command: str) -> bool:
with self._lock:
command_name = _normalize_command(command)
if not command_name:
return False
allow_set = self._allow_set(user_id)
deny_set = self._deny_set(user_id)
changed = command_name not in deny_set or command_name in allow_set
deny_set.add(command_name)
allow_set.discard(command_name)
if changed:
self.save()
return changed
def is_allowed(self, user_id: int, command: str) -> bool:
with self._lock:
command_name = _normalize_command(command)
user_key = str(user_id)
deny_set = self.data["deny"].get(user_key, set())
if command_name in deny_set:
return False
allow_set = self.data["allow"].get(user_key, set())
return command_name in allow_set
def list_allowed(self, user_id: int) -> list[str]:
with self._lock:
return sorted(self.data["allow"].get(str(user_id), set()))
def list_denied(self, user_id: int) -> list[str]:
with self._lock:
return sorted(self.data["deny"].get(str(user_id), set()))
def count_allowed_users(self, command: str) -> int:
with self._lock:
command_name = _normalize_command(command)
return sum(1 for commands in self.data["allow"].values() if command_name in commands)
def count_denied_users(self, command: str) -> int:
with self._lock:
command_name = _normalize_command(command)
return sum(1 for commands in self.data["deny"].values() if command_name in commands)
def stats(self) -> dict[str, int]:
with self._lock:
return {
"allowed_users": len(self.data["allow"]),
"denied_users": len(self.data["deny"]),
"allow_rules": sum(len(commands) for commands in self.data["allow"].values()),
"deny_rules": sum(len(commands) for commands in self.data["deny"].values()),
}

220
app/commands/builtins.py Normal file
View File

@@ -0,0 +1,220 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from app.commands.core import CommandRegistry, normalize_command_name
if TYPE_CHECKING:
from app.commands.core import CommandContext
command_registry = CommandRegistry()
DEFAULT_REGISTRY = command_registry
def _user_label(user: Any | None) -> str:
if user is None:
return "unknown"
parts = [getattr(user, "first_name", None), getattr(user, "last_name", None)]
full_name = " ".join(part for part in parts if part)
username = getattr(user, "username", None)
if full_name and username:
return f"{full_name} (@{username})"
if full_name:
return full_name
if username:
return f"@{username}"
return str(getattr(user, "id", "unknown"))
def _display_command(command: str) -> str:
return f"..{command}"
def _format_permissions(ctx: "CommandContext") -> str:
store = ctx.acl_store
target = ctx.replied_user
if store is None:
return "Permission store is not initialized"
if target is None:
return "Reply to a user's message first"
allowed = store.list_allowed(target.id)
denied = store.list_denied(target.id)
lines = [f"Permissions for {_user_label(target)} ({target.id}):"]
lines.append(
"allowed: "
+ (", ".join(_display_command(command) for command in sorted(allowed)) if allowed else "none")
)
lines.append(
"denied: "
+ (", ".join(_display_command(command) for command in sorted(denied)) if denied else "none")
)
return "\n".join(lines)
def _resolve_target_commands(ctx: "CommandContext") -> tuple[list[str], list[str]]:
commands: list[str] = []
errors: list[str] = []
seen: set[str] = set()
for token in ctx.argv:
for part in (piece.strip() for piece in token.split(",")):
normalized = normalize_command_name(part)
if not normalized:
continue
definition = ctx.manager.command_registry.resolve(normalized)
if definition is None:
errors.append(f"Unknown command: {part}")
continue
if not definition.is_shared:
errors.append(f"{definition.display_name} is owner-only and cannot be granted")
continue
if definition.name in seen:
continue
seen.add(definition.name)
commands.append(definition.name)
return commands, errors
async def _change_permissions(ctx: "CommandContext", *, allow: bool) -> None:
target = ctx.replied_user
if target is None:
await ctx.reply("Reply to the user's message first")
return
owner_id = ctx.owner_id
if owner_id is not None and target.id == owner_id:
await ctx.reply("Owner access cannot be changed")
return
commands, errors = _resolve_target_commands(ctx)
if not commands and not errors:
await ctx.reply("Provide at least one command name")
return
store = ctx.acl_store
if store is None:
await ctx.reply("Permission store is not initialized")
return
changed: list[str] = []
unchanged: list[str] = []
for command in commands:
did_change = store.allow(target.id, command) if allow else store.deny(target.id, command)
if did_change:
changed.append(command)
else:
unchanged.append(command)
lines = [
f"Target: {_user_label(target)} ({target.id})",
f"Action: {'allow' if allow else 'deny'}",
]
if changed:
lines.append("Changed: " + ", ".join(_display_command(command) for command in sorted(changed)))
if unchanged:
lines.append(
"Already set: " + ", ".join(_display_command(command) for command in sorted(unchanged))
)
if errors:
lines.append("Errors:")
lines.extend(f"- {error}" for error in errors)
await ctx.reply("\n".join(lines))
@command_registry.command(
"ping",
"Check that the account is alive",
usage=".ping",
category="system",
)
async def ping(ctx: "CommandContext") -> None:
me = ctx.account.me
await ctx.reply(
"\n".join(
[
"pong",
f"account: {ctx.account.config.name}",
f"session: {ctx.account.config.session}",
f"user_id: {getattr(me, 'id', 'unknown')}",
]
)
)
@command_registry.command(
"status",
"Show account and ACL state",
usage=".status",
category="system",
)
async def status(ctx: "CommandContext") -> None:
lines = [ctx.manager.build_status_report()]
store = ctx.acl_store
if store is not None:
stats = store.stats()
lines.append(
"ACL stats: "
f"allowed_users={stats['allowed_users']}, "
f"denied_users={stats['denied_users']}, "
f"allow_rules={stats['allow_rules']}, "
f"deny_rules={stats['deny_rules']}"
)
await ctx.reply("\n".join(lines))
@command_registry.command(
"help",
"Show command catalog",
usage=".help",
category="system",
)
async def help_command(ctx: "CommandContext") -> None:
await ctx.reply(ctx.manager.command_registry.render_help(ctx))
@command_registry.command(
"rights",
"Show ACL state for the replied user",
usage=".rights",
category="access",
).alias("acl").require_reply()
async def rights(ctx: "CommandContext") -> None:
await ctx.reply(_format_permissions(ctx))
@command_registry.command(
"allow",
"Grant a command to the replied user",
usage=".allow <command> [more...]",
category="access",
).require_reply().require_args()
async def allow(ctx: "CommandContext") -> None:
await _change_permissions(ctx, allow=True)
@command_registry.command(
"deny",
"Revoke a command from the replied user",
usage=".deny <command> [more...]",
category="access",
).require_reply().require_args()
async def deny(ctx: "CommandContext") -> None:
await _change_permissions(ctx, allow=False)
@command_registry.command(
"echo",
"Echo text for granted users",
usage="..echo <text>",
category="shared",
).shared()
async def echo(ctx: "CommandContext") -> None:
text = ctx.args.strip()
if not text:
await ctx.reply("Usage: ..echo <text>")
return
await ctx.reply(text)

347
app/commands/core.py Normal file
View File

@@ -0,0 +1,347 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, field
import inspect
import shlex
from typing import TYPE_CHECKING, Any, Awaitable, Callable
if TYPE_CHECKING:
from app.commands.acl import RuntimeAclStore
from app.manager import AccountManager, AccountState
from pyrogram import Client
from pyrogram.types import Message
CommandHandler = Callable[["CommandContext"], Awaitable[None]]
CommandCheckFn = Callable[["CommandContext"], Any]
def normalize_command_name(value: str) -> str:
token = value.strip().lower()
token = token.lstrip(".")
if "@" in token:
token = token.split("@", 1)[0]
return token
@dataclass(slots=True)
class CheckResult:
allowed: bool
reason: str | None = None
@classmethod
def allow(cls) -> "CheckResult":
return cls(True, None)
@classmethod
def deny(cls, reason: str) -> "CheckResult":
return cls(False, reason)
@dataclass(slots=True)
class CommandCheck:
name: str
callback: CommandCheckFn
description: str = ""
async def run(self, ctx: "CommandContext") -> CheckResult:
result = self.callback(ctx)
if inspect.isawaitable(result):
result = await result
if isinstance(result, CheckResult):
return result
if result is None or result is True:
return CheckResult.allow()
if result is False:
return CheckResult.deny(f"Check failed: {self.name}")
if isinstance(result, str):
return CheckResult.deny(result)
raise TypeError(f"Unsupported check result from {self.name}: {type(result)!r}")
def owner_only_check() -> CommandCheck:
async def _check(ctx: "CommandContext") -> CheckResult:
if ctx.owner_id is None:
return CheckResult.deny("Account owner is not available yet")
if ctx.actor_id != ctx.owner_id:
return CheckResult.deny("Only the account owner can use this command")
return CheckResult.allow()
return CommandCheck("owner_only", _check, "Allow only the account owner")
def shared_access_check() -> CommandCheck:
async def _check(ctx: "CommandContext") -> CheckResult:
if ctx.owner_id is not None and ctx.actor_id == ctx.owner_id:
return CheckResult.allow()
if ctx.actor_id is None:
return CheckResult.deny("This command requires a Telegram user identity")
store = ctx.acl_store
if store is None:
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}")
return CommandCheck("shared_access", _check, "Allow owner or ACL-granted users")
def require_reply_check() -> CommandCheck:
async def _check(ctx: "CommandContext") -> CheckResult:
if ctx.replied_user_id is None:
return CheckResult.deny("Reply to a user's message first")
return CheckResult.allow()
return CommandCheck("require_reply", _check, "Require a replied-to user message")
def require_args_check() -> CommandCheck:
async def _check(ctx: "CommandContext") -> CheckResult:
if not ctx.args.strip():
return CheckResult.deny("This command expects arguments")
return CheckResult.allow()
return CommandCheck("require_args", _check, "Require non-empty arguments")
def combine_checks(*checks: CommandCheck) -> CommandCheck:
async def _check(ctx: "CommandContext") -> CheckResult:
for check in checks:
result = await check.run(ctx)
if not result.allowed:
return result
return CheckResult.allow()
names = "+".join(check.name for check in checks) or "combined"
return CommandCheck(names, _check, "Combined checks")
@dataclass(slots=True)
class CommandContext:
client: "Client"
manager: "AccountManager"
account: "AccountState"
message: "Message"
command: "CommandDefinition"
prefix: str
raw_text: str
args: str
alias: str
@property
def actor(self) -> Any | None:
return getattr(self.message, "from_user", None)
@property
def actor_id(self) -> int | None:
actor = self.actor
return getattr(actor, "id", None)
@property
def owner_id(self) -> int | None:
me = self.account.me
return getattr(me, "id", None)
@property
def is_owner(self) -> bool:
return self.owner_id is not None and self.actor_id == self.owner_id
@property
def acl_store(self) -> RuntimeAclStore | None:
return self.account.acl_store
@property
def replied_message(self) -> Any | None:
return getattr(self.message, "reply_to_message", None)
@property
def replied_user(self) -> Any | None:
reply = self.replied_message
if reply is None:
return None
return getattr(reply, "from_user", None)
@property
def replied_user_id(self) -> int | None:
user = self.replied_user
return getattr(user, "id", None)
@property
def argv(self) -> list[str]:
if not self.args.strip():
return []
try:
return shlex.split(self.args)
except ValueError:
return self.args.split()
async def reply(self, text: str, **kwargs: Any) -> Any:
return await self.message.reply_text(text, **kwargs)
@dataclass(slots=True)
class CommandDefinition:
name: str
description: str
handler: CommandHandler
prefix: str = "."
aliases: tuple[str, ...] = ()
usage: str | None = None
category: str = "general"
checks: tuple[CommandCheck, ...] = ()
@property
def is_shared(self) -> bool:
return self.prefix == ".."
@property
def display_name(self) -> str:
return f"{self.prefix}{self.name}"
def help_line(self, ctx: CommandContext | None = None) -> str:
usage = self.usage or self.display_name
details: list[str] = []
if self.aliases:
details.append(f"aliases: {', '.join(self.aliases)}")
details.append("shared" if self.is_shared else "owner-only")
if ctx is not None and self.is_shared and ctx.acl_store is not None:
allowed = ctx.acl_store.count_allowed_users(self.name)
denied = ctx.acl_store.count_denied_users(self.name)
details.append(f"allow={allowed}")
details.append(f"deny={denied}")
suffix = f" ({'; '.join(details)})" if details else ""
return f"{usage} - {self.description}{suffix}"
class CommandBuilder:
def __init__(
self,
registry: "CommandRegistry",
name: str,
description: str,
*,
aliases: tuple[str, ...] = (),
usage: str | None = None,
category: str = "general",
) -> None:
self.registry = registry
self.name = normalize_command_name(name)
self.description = description.strip()
self.aliases = [normalize_command_name(alias) for alias in aliases]
self.usage = usage
self.category = category
self.prefix = "."
self.checks: list[CommandCheck] = []
def private(self) -> "CommandBuilder":
self.prefix = "."
return self
def shared(self) -> "CommandBuilder":
self.prefix = ".."
return self
def alias(self, *aliases: str) -> "CommandBuilder":
for alias in aliases:
normalized = normalize_command_name(alias)
if normalized and normalized not in self.aliases and normalized != self.name:
self.aliases.append(normalized)
return self
def check(self, *checks: CommandCheck) -> "CommandBuilder":
self.checks.extend(checks)
return self
def owner_only(self) -> "CommandBuilder":
return self.check(owner_only_check())
def require_reply(self) -> "CommandBuilder":
return self.check(require_reply_check())
def require_args(self) -> "CommandBuilder":
return self.check(require_args_check())
def build(self, handler: CommandHandler) -> CommandDefinition:
default_checks: list[CommandCheck] = []
if self.prefix == "..":
default_checks.append(shared_access_check())
else:
default_checks.append(owner_only_check())
return CommandDefinition(
name=self.name,
description=self.description,
handler=handler,
prefix=self.prefix,
aliases=tuple(self.aliases),
usage=self.usage,
category=self.category,
checks=tuple(default_checks + self.checks),
)
def __call__(self, handler: CommandHandler) -> CommandHandler:
self.registry.register(self.build(handler))
return handler
@dataclass(slots=True)
class CommandRegistry:
_commands: dict[str, CommandDefinition] = field(default_factory=dict)
_aliases: dict[str, str] = field(default_factory=dict)
_order: list[str] = field(default_factory=list)
def command(
self,
name: str,
description: str,
*,
aliases: tuple[str, ...] = (),
usage: str | None = None,
category: str = "general",
) -> CommandBuilder:
return CommandBuilder(
self,
name,
description,
aliases=aliases,
usage=usage,
category=category,
)
def register(self, definition: CommandDefinition) -> None:
if definition.name in self._commands:
raise ValueError(f"Command already registered: {definition.name}")
for alias in definition.aliases:
if alias in self._aliases or alias in self._commands:
raise ValueError(f"Command alias already registered: {alias}")
self._commands[definition.name] = definition
self._order.append(definition.name)
for alias in definition.aliases:
self._aliases[alias] = definition.name
def resolve(self, token: str) -> CommandDefinition | None:
normalized = normalize_command_name(token)
canonical = self._aliases.get(normalized, normalized)
return self._commands.get(canonical)
def has_name(self, token: str) -> bool:
return self.resolve(token) is not None
def iter_commands(self) -> list[CommandDefinition]:
return [self._commands[name] for name in self._order]
def render_help(self, ctx: CommandContext) -> str:
grouped: dict[str, list[CommandDefinition]] = defaultdict(list)
for definition in self.iter_commands():
grouped[definition.category].append(definition)
lines: list[str] = ["Available commands:"]
for category in sorted(grouped):
lines.append(f"[{category}]")
for definition in grouped[category]:
lines.append(f"- {definition.help_line(ctx)}")
return "\n".join(lines)

101
app/commands/dispatcher.py Normal file
View File

@@ -0,0 +1,101 @@
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING
from app.commands.core import (
CommandContext,
CommandRegistry,
normalize_command_name,
)
if TYPE_CHECKING:
from app.manager import AccountManager, AccountState
from pyrogram import Client
from pyrogram.types import Message
log = logging.getLogger(__name__)
@dataclass(slots=True)
class ParsedCommand:
prefix: str
name: str
args: str
alias: str
def parse_command(text: str) -> ParsedCommand | None:
stripped = text.lstrip()
if not stripped.startswith("."):
return None
prefix = ".." if stripped.startswith("..") else "."
payload = stripped[len(prefix) :]
if not payload.strip():
return None
parts = payload.strip().split(maxsplit=1)
if not parts:
return None
alias = parts[0]
name = normalize_command_name(alias)
args = parts[1] if len(parts) > 1 else ""
return ParsedCommand(prefix=prefix, name=name, args=args, alias=alias)
class CommandDispatcher:
def __init__(self, registry: CommandRegistry) -> None:
self.registry = registry
async def dispatch(
self,
client: "Client",
message: "Message",
manager: "AccountManager",
account: "AccountState",
) -> bool:
text = getattr(message, "text", None)
if not isinstance(text, str):
return False
parsed = parse_command(text)
if parsed is None:
return False
definition = self.registry.resolve(parsed.name)
if definition is None:
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")
return True
ctx = CommandContext(
client=client,
manager=manager,
account=account,
message=message,
command=definition,
prefix=parsed.prefix,
raw_text=text,
args=parsed.args,
alias=parsed.alias,
)
for check in definition.checks:
result = await check.run(ctx)
if not result.allowed:
await message.reply_text(result.reason or "Access denied")
return True
try:
await definition.handler(ctx)
except Exception as exc: # pragma: no cover - defensive logging
log.exception("Command %s failed: %s", definition.display_name, exc)
await ctx.reply("Command failed with an internal error")
return True