#!/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): try: with open(path) as f: return json.load(f) except Exception: return [] 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 msgs = load(os.path.join(CHAT_DIR, name)) 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: 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 ' print(f"{t['thread']} {flag} {t['count']:>3} msg {t['last_ts']} {t['preview']}") return 0 if cmd == 'show' and len(sys.argv) >= 3: 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())