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

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)