#!/usr/bin/env python3 """scripts/chat.py — read and reply to newOS4 chat threads as Hermes. chat.py list [--json] # threads, counts, who spoke last chat.py show # full transcript chat.py reply "…" # append a Hermes reply (atomic, locked) Store: data/chat/.json — contract: _docs/chat-contract.md Thread id is 16 lowercase hex = sha256(browser token)[0:16]. """ import json, sys, os, re, datetime, secrets, fcntl CHAT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'data', 'chat') THREAD_RE = re.compile(r'^[a-f0-9]{16}$') def utc_iso(): # UTC, matching the PHP side. A local offset here breaks every comparison downstream. return datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%S+00:00') def now_ms(): return int(datetime.datetime.now(datetime.timezone.utc).timestamp() * 1000) def load(path): """Read a thread. Missing is empty; corrupt is an error. Returning [] for both let reply() rewrite a damaged thread with a single message — history gone, and nothing said so. """ if not os.path.exists(path): return [] with open(path) as f: raw = f.read() try: data = json.loads(raw) except json.JSONDecodeError as exc: raise SystemExit('thread file is corrupt (%s): %s' % (path, exc)) if not isinstance(data, list): raise SystemExit('thread file is not a list: ' + path) return data def threads(): out = [] if not os.path.isdir(CHAT_DIR): return out for name in sorted(os.listdir(CHAT_DIR)): if not name.endswith('.json') or name.startswith('.'): continue try: msgs = load(os.path.join(CHAT_DIR, name)) except SystemExit as exc: # one unreadable file must not make the whole listing fail: that is how # awaiting messages silently stop being listed out.append({'thread': name[:-5], 'count': -1, 'last_ts': '', 'last_author': 'corrupt', 'awaiting': True, 'preview': 'CORRUPT: ' + str(exc)[:90]}) continue if not msgs: continue last = msgs[-1] out.append({ 'thread': name[:-5], 'count': len(msgs), 'last_ts': last.get('ts', ''), 'last_author': last.get('author', ''), 'awaiting': last.get('author') != 'hermes', 'preview': (last.get('text') or '')[:100].replace('\n', ' '), }) return out def reply(thread, text): # A typo'd id must fail, not silently create a thread nobody reads. if not THREAD_RE.match(thread): raise SystemExit('invalid thread id (expected 16 lowercase hex chars)') path = os.path.join(CHAT_DIR, thread + '.json') if not os.path.exists(path): raise SystemExit('no such thread: ' + thread) lockpath = os.path.join(CHAT_DIR, '.lock-' + thread) with open(lockpath, 'a+') as lock: # created here as darkbot (0644 under umask 022); PHP opens the same lock as # www-data, so group write is mandatory or later posts to this thread 500 try: os.chmod(lockpath, 0o664) except OSError: pass fcntl.flock(lock, fcntl.LOCK_EX) try: msgs = load(path) msg = { 'id': 'MSG-' + datetime.datetime.now().strftime('%Y%m%d-%H%M%S') + '-' + secrets.token_hex(3), 'ts': utc_iso(), 'epoch': now_ms(), 'author': 'hermes', 'text': text, } msgs.append(msg) msgs = msgs[-200:] tmp = path + '.tmp-' + secrets.token_hex(4) with open(tmp, 'w') as fh: json.dump(msgs, fh, ensure_ascii=False) os.chmod(tmp, 0o664) # replace INSIDE the lock: doing it after unlock lets a second writer read # the pre-replace snapshot and rename over this message (lost update). os.replace(tmp, path) finally: fcntl.flock(lock, fcntl.LOCK_UN) return msg def main(): if len(sys.argv) < 2: print(__doc__) return 1 cmd = sys.argv[1] if cmd == 'list': rows = threads() if '--json' in sys.argv: print(json.dumps(rows, indent=2)) return 0 if not rows: print('(no threads)') return 0 for t in rows: flag = 'AWAITING' if t['awaiting'] else 'replied ' if t['count'] == -1: flag = 'CORRUPT ' print(f"{t['thread']} {flag} {max(t['count'], 0):>3} msg {t['last_ts']} {t['preview']}") return 0 if cmd == 'show' and len(sys.argv) >= 3: # reply() validated the id, show() did not: '../../x' joined straight onto CHAT_DIR if not THREAD_RE.match(sys.argv[2]): raise SystemExit('invalid thread id (expected 16 lowercase hex chars)') msgs = load(os.path.join(CHAT_DIR, sys.argv[2] + '.json')) if not msgs: print('(empty or unknown thread)') return 1 for m in msgs: print(f"[{m.get('ts','')}] {m.get('author','')}: {m.get('text','')}") return 0 if cmd == 'reply' and len(sys.argv) >= 4: msg = reply(sys.argv[2], ' '.join(sys.argv[3:])) print('replied', msg['id']) return 0 print(__doc__) return 1 if __name__ == '__main__': sys.exit(main())