Add multi-account command ACL system
This commit is contained in:
220
app/commands/builtins.py
Normal file
220
app/commands/builtins.py
Normal 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)
|
||||
Reference in New Issue
Block a user