From 313f13955ad9c72895b0914ac828c61b3e469715 Mon Sep 17 00:00:00 2001 From: Agando Date: Fri, 23 Oct 2026 19:29:31 +0200 Subject: feat(chat): token-scoped chat endpoints, contract, host CLI, old POST retired (v4.6.71 phase B) - api/chat/{lib,post,poll}.php: thread = sha256(token)[0:16]; flock read-modify-write; tmp+rename; ms epoch cursor; per-thread + per-IP windows with retry_after_ms; 401/413/507/405 carry a machine code, only rate_limited is retryable - _docs/chat-contract.md: frozen shapes, incl. why the cursor is ms and not ts - scripts/chat.py: list/show/reply, UTC, locked, atomic replace - api/debug/agent-replies.php POST -> 410 Gone with pointers (one write path) Measured: 401 no-token, 201+id, poll 1 msg, poll>since 1 new, data/chat 403, 4001 chars 413 (codepoints), 429 with Retry-After 576, round trip via chat.py. --- scripts/chat.py | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100755 scripts/chat.py (limited to 'scripts/chat.py') diff --git a/scripts/chat.py b/scripts/chat.py new file mode 100755 index 0000000..e19fb6b --- /dev/null +++ b/scripts/chat.py @@ -0,0 +1,125 @@ +#!/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) + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + os.replace(tmp, path) # atomic: a poll never sees a half-written file + 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()) -- cgit v1.2.3