runv-bulletin (6045B)
1 #!/usr/bin/env python3 2 """Mural comunitário simples da runv.club.""" 3 4 from __future__ import annotations 5 6 import argparse 7 import json 8 import os 9 import secrets 10 import sys 11 from datetime import datetime, timezone 12 from pathlib import Path 13 from typing import Any 14 15 sys.tracebacklimit = 0 16 17 18 def _bootstrap() -> None: 19 installed = Path("/usr/local/share/runv/lib") 20 candidates = [installed] 21 script = Path(__file__).resolve() 22 if script.parent.name == "bin": 23 candidates.insert(0, script.parent.parent / "lib") 24 for c in candidates: 25 if (c / "runv_community.py").is_file() and str(c) not in sys.path: 26 sys.path.insert(0, str(c)) 27 return 28 29 30 _bootstrap() 31 import runv_community as rc # noqa: E402 32 33 DEFAULT_BULLETIN_PATH = Path("/var/lib/runv/bulletin/posts.ndjson") 34 MAX_BODY_LEN = 500 35 DEFAULT_LIST_LIMIT = 20 36 37 38 def bulletin_paths() -> tuple[Path, Path]: 39 raw = os.environ.get("RUNV_BULLETIN_PATH", "").strip() 40 ndjson = Path(raw) if raw else DEFAULT_BULLETIN_PATH 41 lock = ndjson.parent / "posts.lock" 42 return ndjson, lock 43 44 45 def current_username() -> str: 46 import pwd 47 48 try: 49 name = pwd.getpwuid(os.getuid()).pw_name 50 except (KeyError, OSError) as e: 51 rc.friendly_exit(f"não foi possível determinar o utilizador: {e}") 52 return rc.validate_username(name) 53 54 55 def new_post_id(username: str) -> str: 56 ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") 57 suffix = secrets.token_hex(3) 58 return f"{ts}-{username}-{suffix}" 59 60 61 def append_post(body: str) -> dict[str, Any]: 62 username = current_username() 63 clean = body.strip() 64 if not clean: 65 rc.friendly_exit("mensagem obrigatória.") 66 if "\x00" in clean: 67 rc.friendly_exit("mensagem inválida (caracter NUL).") 68 if len(clean) > MAX_BODY_LEN: 69 rc.friendly_exit(f"mensagem demasiado longa (máximo {MAX_BODY_LEN} caracteres).") 70 71 post = { 72 "id": new_post_id(username), 73 "username": username, 74 "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), 75 "body": clean, 76 } 77 line = json.dumps(post, ensure_ascii=False) + "\n" 78 79 ndjson_path, lock_path = bulletin_paths() 80 try: 81 ndjson_path.parent.mkdir(parents=True, exist_ok=True) 82 except OSError as e: 83 rc.friendly_exit(f"não foi possível criar o diretório do mural: {e}") 84 85 try: 86 import fcntl 87 88 lock_path.parent.mkdir(parents=True, exist_ok=True) 89 with open(lock_path, "a+", encoding="utf-8") as lock_f: 90 fcntl.flock(lock_f.fileno(), fcntl.LOCK_EX) 91 try: 92 with open(ndjson_path, "a", encoding="utf-8") as out: 93 out.write(line) 94 finally: 95 fcntl.flock(lock_f.fileno(), fcntl.LOCK_UN) 96 except PermissionError: 97 rc.friendly_exit( 98 f"sem permissão para escrever em {ndjson_path}\n" 99 "peça ao admin para criar o diretório com permissões de grupo para membros" 100 ) 101 except OSError as e: 102 rc.friendly_exit(f"erro ao escrever no mural: {e}") 103 104 return post 105 106 107 def read_posts(limit: int) -> list[dict[str, Any]]: 108 ndjson_path, _lock_path = bulletin_paths() 109 if not ndjson_path.is_file(): 110 return [] 111 try: 112 raw = ndjson_path.read_text(encoding="utf-8") 113 except OSError: 114 return [] 115 posts: list[dict[str, Any]] = [] 116 for line in raw.splitlines(): 117 line = line.strip() 118 if not line: 119 continue 120 try: 121 item = json.loads(line) 122 except json.JSONDecodeError: 123 continue 124 if isinstance(item, dict) and isinstance(item.get("id"), str): 125 posts.append(item) 126 return posts[-limit:] 127 128 129 def format_post_line(post: dict[str, Any]) -> str: 130 created = post.get("created_at", "") 131 display_time = created 132 try: 133 if isinstance(created, str) and created: 134 dt = datetime.fromisoformat(created.replace("Z", "+00:00")) 135 display_time = dt.astimezone().strftime("%Y-%m-%d %H:%M") 136 except ValueError: 137 pass 138 user = post.get("username", "?") 139 body = post.get("body", "") 140 return f"[{display_time}] {user}\n {body}" 141 142 143 def cmd_list(limit: int, as_json: bool) -> int: 144 posts = read_posts(limit) 145 if as_json: 146 print(json.dumps(posts, ensure_ascii=False, indent=2)) 147 return 0 148 if not posts: 149 print("mural vazio ainda.") 150 return 0 151 print("Mural runv.club\n") 152 for post in posts: 153 print() 154 print(format_post_line(post)) 155 print() 156 return 0 157 158 159 def build_parser() -> argparse.ArgumentParser: 160 p = argparse.ArgumentParser( 161 prog="runv-bulletin", 162 description="Mural comunitário simples em terminal.", 163 ) 164 p.add_argument("--json", action="store_true", help="saída em JSON (list)") 165 p.add_argument( 166 "--limit", 167 type=int, 168 default=None, 169 metavar="N", 170 help=f"últimos N posts (padrão {DEFAULT_LIST_LIMIT} em list)", 171 ) 172 sub = p.add_subparsers(dest="command") 173 sub.add_parser("list", help="listar posts recentes") 174 post_p = sub.add_parser("post", help="publicar mensagem") 175 post_p.add_argument("message", help="texto do post") 176 return p 177 178 179 def main(argv: list[str] | None = None) -> int: 180 try: 181 args = build_parser().parse_args(argv) 182 if args.command is None: 183 args.command = "list" 184 185 limit = args.limit if args.limit is not None else DEFAULT_LIST_LIMIT 186 if limit < 1: 187 rc.friendly_exit("--limit deve ser um inteiro positivo.") 188 189 if args.command == "post": 190 append_post(args.message) 191 print("post publicado.") 192 return 0 193 194 return cmd_list(limit, bool(args.json)) 195 except KeyboardInterrupt: 196 print("\nInterrompido.", file=sys.stderr) 197 return 130 198 except SystemExit: 199 raise 200 except Exception as e: 201 rc.friendly_exit(f"erro: {e}") 202 203 204 if __name__ == "__main__": 205 raise SystemExit(main())