aboutsummaryrefslogtreecommitdiff
path: root/scripts/chat.py
diff options
context:
space:
mode:
authorAgando <roduccomm@gmail.com>2026-10-23 19:29:31 +0200
committerAgando <roduccomm@gmail.com>2026-10-23 19:29:31 +0200
commit313f13955ad9c72895b0914ac828c61b3e469715 (patch)
tree8d46bba5452cfe539348a2f1b3fd963b61b45ec4 /scripts/chat.py
parent1ff8df65ffefbd2eaa9100374fbcfb7828fdb717 (diff)
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.
Diffstat (limited to 'scripts/chat.py')
-rwxr-xr-xscripts/chat.py125
1 files changed, 125 insertions, 0 deletions
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 <thread> # full transcript
+ chat.py reply <thread> "…" # append a Hermes reply (atomic, locked)
+
+Store: data/chat/<thread>.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())