from __future__ import annotations import argparse import base64 import hashlib import hmac import os import shlex import sys from getpass import getpass from typing import Iterable, List, Optional, Tuple import readline import httpx from rich.console import Console from rich.panel import Panel from rich.table import Table class CLIError(Exception): """Generic CLI error with a human-readable message.""" def _hkdf_extract(salt: bytes, ikm: bytes) -> bytes: return hmac.new(salt, ikm, hashlib.sha256).digest() def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes: blocks: list[bytes] = [] previous = b"" counter = 1 while len(b"".join(blocks)) < length: previous = hmac.new(prk, previous + info + bytes([counter]), hashlib.sha256).digest() blocks.append(previous) counter += 1 return b"".join(blocks)[:length] def derive_auth_secret(username: str, password: str) -> str: salt = f"fromchat.user:{username}".encode("utf-8") prk = _hkdf_extract(salt, password.encode("utf-8")) okm = _hkdf_expand(prk, b"auth-secret", 32) return base64.b64encode(okm).decode("utf-8") def _read_single_key() -> str: try: # Windows import msvcrt # type: ignore ch = msvcrt.getch() return ch.decode("utf-8", errors="ignore").lower() except ImportError: import termios import tty fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: tty.setraw(fd) ch = sys.stdin.read(1) finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) return ch.lower() class AdminCLI: def __init__(self, api_url: str) -> None: self.console = Console() self.api_url = api_url.rstrip("/") self.client = httpx.Client(base_url=self.api_url, timeout=30.0) self.username: Optional[str] = None self.token: Optional[str] = None # --------------------------- HTTP helpers --------------------------- # def _auth_headers(self) -> dict: headers: dict = {} if self.token: headers["Authorization"] = f"Bearer {self.token}" return headers def _request(self, method: str, path: str, *, auth: bool = True, **kwargs) -> httpx.Response: rel_path = path.lstrip("/") headers = kwargs.pop("headers", {}) if auth: headers.update(self._auth_headers()) response = self.client.request(method, rel_path, headers=headers, **kwargs) if response.status_code >= 400: detail = "" try: payload = response.json() if isinstance(payload, dict): detail = payload.get("detail") or payload.get("message") or "" except Exception: detail = response.text message = f"{response.status_code} {response.reason_phrase}" if detail: message = f"{message}: {detail}" raise CLIError(message.strip()) return response # --------------------------- CLI primitives ------------------------- # def _require_auth(self) -> None: if not self.token: raise CLIError("You must login before running this command.") def _resolve_user(self, identifier: str) -> dict: self._require_auth() if identifier.isdigit(): response = self._request("GET", f"user/id/{identifier}") else: response = self._request("GET", f"user/{identifier.replace('@', '')}") return response.json() def _confirm(self, prompt: str) -> bool: self.console.print(f"[bold yellow]{prompt}[/] [green](y)[/] / [red](n)[/]: ", end="") choice = _read_single_key() self.console.print("") # move to next line return choice == "y" def _render_user(self, user: dict) -> None: table = Table(show_header=False) table.add_row("ID", str(user.get("id"))) table.add_row("Username", user.get("username", "")) table.add_row("Display name", user.get("display_name", "")) table.add_row("Verified", "✅" if user.get("verified") else "❌") if user.get("suspended"): table.add_row("Suspended", f"🚫 ({user.get('suspension_reason') or 'no reason'})") else: table.add_row("Suspended", "✅ Active") self.console.print(table) # --------------------------- Commands ------------------------------- # def cmd_login(self, args: List[str]) -> None: if args: username = args[0] else: username = self.console.input("[bold cyan]Username[/]: ").strip() if not username: raise CLIError("Username is required.") password = getpass("Password: ") derived_password = derive_auth_secret(username, password) payload = {"username": username, "password": derived_password} response = self._request("POST", "login", json=payload, auth=False) body = response.json() token = body.get("token") if not token: raise CLIError("Authentication succeeded but token was not returned.") self.token = token self.username = username self.console.print("[bold green]Login successful.[/]") def cmd_suspend(self, args: List[str]) -> None: if not args: raise CLIError("Usage: suspend ") identifier = args[0] user = self._resolve_user(identifier) self.console.print(Panel.fit("[bold red]Suspend user[/]", style="red")) self._render_user(user) reason = self.console.input("[bold yellow]Reason (press Enter to leave empty)[/]: ").strip() if not self._confirm(f"Confirm suspension of {user.get('username')}?"): self.console.print("[yellow]Suspension cancelled.[/]") return payload = {"reason": reason} self._request("POST", f"user/{user['id']}/suspend", json=payload) log_reason = reason or "no reason provided" self.console.print(f"[bold red]User {user['username']} suspended ({log_reason}).[/]") def cmd_unsuspend(self, args: List[str]) -> None: if not args: raise CLIError("Usage: unsuspend ") identifier = args[0] user = self._resolve_user(identifier) self.console.print(Panel.fit("[bold green]Unsuspend user[/]", style="green")) self._render_user(user) if not self._confirm(f"Unsuspend {user.get('username')}?"): self.console.print("[yellow]Unsuspension cancelled.[/]") return self._request("POST", f"user/{user['id']}/unsuspend") self.console.print(f"[bold green]User {user['username']} unsuspended.[/]") def cmd_block_word(self, args: List[str]) -> None: if not args: raise CLIError("Usage: block-word [additional words...]") self._require_auth() words = args response = self._request("POST", "moderation/blocklist", json={"words": words}) data = response.json() added = data.get("added", []) current = data.get("words", []) if added: self.console.print(f"[bold green]Added {len(added)} entr{'y' if len(added)==1 else 'ies'} to blocklist.[/]") else: self.console.print("[yellow]No new words added.[/]") self.console.print(f"Blocklist size: {len(current)}") def cmd_list_users(self) -> None: self._require_auth() payload = self._request("GET", "user/list").json() users = payload.get("users", []) table = Table(title="Users", show_lines=False) table.add_column("ID") table.add_column("Username") table.add_column("Display name") table.add_column("Suspended") for user in users: table.add_row( str(user.get("id")), user.get("username", ""), user.get("display_name", ""), "🚫" if user.get("suspended") else "✅", ) self.console.print(table) def cmd_user(self, args: List[str]) -> None: if not args: raise CLIError("Usage: user ") user = self._resolve_user(args[0]) self._render_user(user) def cmd_delete(self, args: List[str]) -> None: if not args: raise CLIError("Usage: delete ") user = self._resolve_user(args[0]) self.console.print(Panel.fit("[bold red]Delete user[/]", style="red")) self._render_user(user) if not self._confirm(f"Permanently delete {user.get('username')}?"): self.console.print("[yellow]Deletion cancelled.[/]") return self._request("POST", f"user/{user['id']}/delete") self.console.print(f"[bold red]User {user['username']} deleted.[/]") def cmd_unblock_word(self, args: List[str]) -> None: if not args: raise CLIError("Usage: unblock-word [additional words...]") self._require_auth() response = self._request("DELETE", "moderation/blocklist", json={"words": args}) data = response.json() removed = data.get("removed", []) current = data.get("words", []) if removed: self.console.print(f"[bold green]Removed {len(removed)} entr{'y' if len(removed)==1 else 'ies'} from blocklist.[/]") else: self.console.print("[yellow]No matching words removed.[/]") self.console.print(f"Blocklist size: {len(current)}") def cmd_verify(self, args: List[str]) -> None: if not args: raise CLIError("Usage: verify ") user = self._resolve_user(args[0]) if user.get("verified"): self.console.print(f"[yellow]{user['username']} is already verified.[/]") return self._request("POST", f"user/{user['id']}/verify") self.console.print(f"[bold green]{user['username']} marked as verified.[/]") def cmd_unverify(self, args: List[str]) -> None: if not args: raise CLIError("Usage: unverify ") user = self._resolve_user(args[0]) if not user.get("verified"): self.console.print(f"[yellow]{user['username']} is already unverified.[/]") return self._request("POST", f"user/{user['id']}/verify") self.console.print(f"[bold green]{user['username']} is now unverified.[/]") def cmd_list_blocklist(self) -> None: self._require_auth() response = self._request("GET", "moderation/blocklist") words = response.json().get("words", []) if not words: self.console.print("[cyan]Blocklist is empty.[/]") return table = Table(title="Blocked Words", show_lines=True) table.add_column("Word / Phrase") for entry in words: table.add_row(entry) self.console.print(table) def cmd_block_user_agent(self, args: List[str]) -> None: if not args: raise CLIError("Usage: block-user-agent [additional patterns...]") self._require_auth() patterns = args response = self._request("POST", "moderation/user-agent-blocklist", json={"words": patterns}) data = response.json() added = data.get("added", []) current = data.get("patterns", []) if added: self.console.print(f"[bold green]Added {len(added)} pattern{'s' if len(added) != 1 else ''} to user agent blocklist.[/]") else: self.console.print("[yellow]No new patterns added.[/]") self.console.print(f"Blocklist size: {len(current)}") def cmd_unblock_user_agent(self, args: List[str]) -> None: if not args: raise CLIError("Usage: unblock-user-agent [additional patterns...]") self._require_auth() response = self._request("DELETE", "moderation/user-agent-blocklist", json={"words": args}) data = response.json() removed = data.get("removed", []) current = data.get("patterns", []) if removed: self.console.print(f"[bold green]Removed {len(removed)} pattern{'s' if len(removed) != 1 else ''} from user agent blocklist.[/]") else: self.console.print("[yellow]No matching patterns removed.[/]") self.console.print(f"Blocklist size: {len(current)}") def cmd_list_user_agent_blocklist(self) -> None: self._require_auth() response = self._request("GET", "moderation/user-agent-blocklist") data = response.json() static = data.get("static", []) external = data.get("external", []) if not static and not external: self.console.print("[cyan]User agent blocklist is empty.[/]") return if static: table_static = Table(title="Static Blocked User Agent Patterns", show_lines=True) table_static.add_column("Pattern", style="yellow") for entry in static: table_static.add_row(entry) self.console.print(table_static) if external: table_external = Table(title="External Blocked User Agent Patterns", show_lines=True) table_external.add_column("Pattern", style="cyan") for entry in external: table_external.add_row(entry) self.console.print(table_external) if not external: self.console.print("[dim]No external patterns. Use 'block-user-agent' to add patterns.[/]") def cmd_help(self) -> None: cmds = { "login [username]": "Authenticate as owner/admin.", "suspend ": "Suspend account (alias: ban).", "unsuspend ": "Unsuspend account (alias: unban).", "delete ": "Permanently delete the user account.", "verify ": "Mark user as verified.", "unverify ": "Remove verification flag.", "block-word ": "Add words/phrases to chat filter.", "unblock-word ": "Remove words/phrases from filter.", "blocklist": "Show current blocklist.", "block-user-agent ": "Add user agent patterns to blocklist.", "unblock-user-agent ": "Remove user agent patterns from blocklist.", "user-agent-blocklist": "Show current user agent blocklist.", "list": "List all users.", "user ": "Show detailed user information.", "whoami": "Display current session context.", "help": "Show this help panel.", "exit": "Quit the CLI.", } table = Table(title="Available Commands") table.add_column("Command", style="cyan") table.add_column("Description", style="white") for cmd, desc in cmds.items(): table.add_row(cmd, desc) self.console.print(table) def cmd_whoami(self) -> None: if not self.token: self.console.print("[yellow]Not authenticated.[/]") return self.console.print(f"[green]Logged in as[/] [bold]{self.username}[/] ({self.api_url})") # --------------------------- Main loop ------------------------------ # def run(self) -> None: self.console.print(Panel.fit("[bold magenta]FromChat Admin CLI[/]", style="magenta")) while True: prompt_identity = self.username or "guest" try: prompt_str = f"\033[36m{prompt_identity}\033[0m \033[1m>\033[0m " raw = input(prompt_str).strip() except (KeyboardInterrupt, EOFError): self.console.print("\n[red]Exiting...[/]") break if not raw: continue try: parts = shlex.split(raw) except ValueError as exc: self.console.print(f"[red]Parse error:[/] {exc}") continue command = parts[0].lstrip("/").lower() args = parts[1:] if command in {"exit", "quit"}: self.console.print("[red]Goodbye.[/]") break try: if command == "login": self.cmd_login(args) elif command in {"suspend", "ban"}: self.cmd_suspend(args) elif command in {"unsuspend", "unban"}: self.cmd_unsuspend(args) elif command == "block-word": self.cmd_block_word(args) elif command == "unblock-word": self.cmd_unblock_word(args) elif command == "blocklist": self.cmd_list_blocklist() elif command == "block-user-agent": self.cmd_block_user_agent(args) elif command == "unblock-user-agent": self.cmd_unblock_user_agent(args) elif command == "user-agent-blocklist": self.cmd_list_user_agent_blocklist() elif command == "verify": self.cmd_verify(args) elif command == "unverify": self.cmd_unverify(args) elif command in {"delete", "remove"}: self.cmd_delete(args) elif command == "list": self.cmd_list_users() elif command == "user": self.cmd_user(args) elif command == "help": self.cmd_help() elif command == "whoami": self.cmd_whoami() else: self.console.print("[yellow]Unknown command. Type /help for a list of commands.[/]") except CLIError as err: self.console.print(f"[red]Error:[/] {err}") except httpx.RequestError as err: self.console.print(f"[red]Network error:[/] {err}") self.client.close() def main(argv: Optional[Iterable[str]] = None) -> None: parser = argparse.ArgumentParser(description="FromChat Emergency Admin CLI") parser.add_argument( "--api-url", default=os.getenv("FC_ADMIN_API_URL", "http://127.0.0.1:8300"), help="Base API URL for the FromChat backend (default: %(default)s).", ) args = parser.parse_args(list(argv) if argv is not None else None) cli = AdminCLI(args.api_url) cli.run() if __name__ == "__main__": main()