Compare commits
1 Commits
docs/secur
...
nexusburn/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66f8e61111 |
@@ -195,14 +195,29 @@ class ChatLog:
|
|||||||
self._history[room].append(entry)
|
self._history[room].append(entry)
|
||||||
if len(self._history[room]) > self._max_per_room:
|
if len(self._history[room]) > self._max_per_room:
|
||||||
self._history[room] = self._history[room][-self._max_per_room:]
|
self._history[room] = self._history[room][-self._max_per_room:]
|
||||||
# Persist to JSONL
|
# Persist to JSONL inside the lock to prevent interleaved writes
|
||||||
|
self._persist(entry)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def _persist(self, entry: dict):
|
||||||
|
"""Write a single entry to the JSONL log file."""
|
||||||
|
log_path = self._get_log_path()
|
||||||
|
if log_path is None:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
CHATLOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with open(CHATLOG_FILE, 'a') as f:
|
with open(log_path, 'a', encoding='utf-8') as fh:
|
||||||
f.write(json.dumps(entry) + '\n')
|
fh.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ChatLog] Persist failed: {e}")
|
print(f"[ChatLog] Persist failed: {e}")
|
||||||
return entry
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_log_path():
|
||||||
|
"""Resolve CHATLOG_FILE safely -- returns None if not available."""
|
||||||
|
try:
|
||||||
|
return CHATLOG_FILE
|
||||||
|
except NameError:
|
||||||
|
return None
|
||||||
|
|
||||||
def get_history(self, room: str, limit: int = 50, since: str = None) -> list[dict]:
|
def get_history(self, room: str, limit: int = 50, since: str = None) -> list[dict]:
|
||||||
"""Get recent chat history for a room.
|
"""Get recent chat history for a room.
|
||||||
@@ -2173,6 +2188,8 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
else:
|
else:
|
||||||
response = session.chat(message)
|
response = session.chat(message)
|
||||||
|
|
||||||
|
chat_log.log(room, 'ask', message, user_id=user_id, username=username)
|
||||||
|
|
||||||
# Auto-notify: crisis detection — scan response for crisis protocol keywords
|
# Auto-notify: crisis detection — scan response for crisis protocol keywords
|
||||||
crisis_keywords = ["988", "741741", "safe right now", "crisis", "Crisis Text Line"]
|
crisis_keywords = ["988", "741741", "safe right now", "crisis", "Crisis Text Line"]
|
||||||
if any(kw in response for kw in crisis_keywords):
|
if any(kw in response for kw in crisis_keywords):
|
||||||
@@ -2245,6 +2262,7 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
|
|
||||||
event = presence_manager.say(user_id, username, room, message)
|
event = presence_manager.say(user_id, username, room, message)
|
||||||
|
chat_log.log(room, 'say', message, user_id=user_id, username=username)
|
||||||
# Get list of players who should see it
|
# Get list of players who should see it
|
||||||
players = presence_manager.get_players_in_room(room)
|
players = presence_manager.get_players_in_room(room)
|
||||||
self._json_response({
|
self._json_response({
|
||||||
@@ -2616,6 +2634,7 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
if not arg:
|
if not arg:
|
||||||
return {"command": "say", "error": "Say what?"}
|
return {"command": "say", "error": "Say what?"}
|
||||||
event = presence_manager.say(user_id, username, room, arg)
|
event = presence_manager.say(user_id, username, room, arg)
|
||||||
|
chat_log.log(room, 'say', arg, user_id=user_id, username=username)
|
||||||
players = presence_manager.get_players_in_room(room)
|
players = presence_manager.get_players_in_room(room)
|
||||||
return {
|
return {
|
||||||
"command": "say",
|
"command": "say",
|
||||||
@@ -2634,6 +2653,7 @@ class BridgeHandler(BaseHTTPRequestHandler):
|
|||||||
not any(p["user_id"] == user_id for p in presence_manager.get_players_in_room(room)):
|
not any(p["user_id"] == user_id for p in presence_manager.get_players_in_room(room)):
|
||||||
presence_manager.enter_room(user_id, username, room)
|
presence_manager.enter_room(user_id, username, room)
|
||||||
response = session.chat(arg)
|
response = session.chat(arg)
|
||||||
|
chat_log.log(room, 'ask', arg, user_id=user_id, username=username)
|
||||||
return {
|
return {
|
||||||
"command": "ask",
|
"command": "ask",
|
||||||
"message": arg,
|
"message": arg,
|
||||||
|
|||||||
182
tests/test_chatlog_persistence.py
Normal file
182
tests/test_chatlog_persistence.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"""Tests for ChatLog persistence fix (#1349).
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- ChatLog.log() returns correct entry dict
|
||||||
|
- JSONL persistence writes to disk
|
||||||
|
- Unicode messages are preserved
|
||||||
|
- Rolling buffer limits per room
|
||||||
|
- Thread safety under concurrent writes
|
||||||
|
- Graceful degradation when CHATLOG_FILE is unavailable
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Ensure module path
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
from multi_user_bridge import ChatLog
|
||||||
|
|
||||||
|
|
||||||
|
class TestChatLogPersistence:
|
||||||
|
"""Core ChatLog.log() behavior."""
|
||||||
|
|
||||||
|
def test_log_returns_entry_dict(self, tmp_path):
|
||||||
|
log = ChatLog()
|
||||||
|
log_file = tmp_path / 'chat.jsonl'
|
||||||
|
with patch('multi_user_bridge.CHATLOG_FILE', log_file):
|
||||||
|
entry = log.log('room1', 'say', 'hello', user_id='u1', username='Alice')
|
||||||
|
assert entry['type'] == 'say'
|
||||||
|
assert entry['message'] == 'hello'
|
||||||
|
assert entry['user_id'] == 'u1'
|
||||||
|
assert entry['username'] == 'Alice'
|
||||||
|
assert entry['room'] == 'room1'
|
||||||
|
assert 'timestamp' in entry
|
||||||
|
|
||||||
|
def test_persist_creates_jsonl_file(self, tmp_path):
|
||||||
|
log = ChatLog()
|
||||||
|
log_file = tmp_path / 'subdir' / 'chat.jsonl'
|
||||||
|
with patch('multi_user_bridge.CHATLOG_FILE', log_file):
|
||||||
|
log.log('room1', 'say', 'msg1')
|
||||||
|
log.log('room1', 'say', 'msg2')
|
||||||
|
assert log_file.exists()
|
||||||
|
lines = log_file.read_text().strip().split('\n')
|
||||||
|
assert len(lines) == 2
|
||||||
|
entry1 = json.loads(lines[0])
|
||||||
|
assert entry1['message'] == 'msg1'
|
||||||
|
|
||||||
|
def test_unicode_preserved_in_jsonl(self, tmp_path):
|
||||||
|
log = ChatLog()
|
||||||
|
log_file = tmp_path / 'chat.jsonl'
|
||||||
|
msg = 'Привет мир 日本語 🎮'
|
||||||
|
with patch('multi_user_bridge.CHATLOG_FILE', log_file):
|
||||||
|
log.log('room1', 'say', msg)
|
||||||
|
lines = log_file.read_text().strip().split('\n')
|
||||||
|
entry = json.loads(lines[0])
|
||||||
|
assert entry['message'] == msg
|
||||||
|
|
||||||
|
def test_rolling_buffer_limits_per_room(self):
|
||||||
|
log = ChatLog(max_per_room=3)
|
||||||
|
for i in range(5):
|
||||||
|
log.log('room1', 'say', f'msg{i}')
|
||||||
|
history = log.get_history('room1')
|
||||||
|
assert len(history) == 3
|
||||||
|
assert history[0]['message'] == 'msg2'
|
||||||
|
assert history[2]['message'] == 'msg4'
|
||||||
|
|
||||||
|
def test_rooms_are_independent(self):
|
||||||
|
log = ChatLog(max_per_room=2)
|
||||||
|
log.log('roomA', 'say', 'a1')
|
||||||
|
log.log('roomB', 'say', 'b1')
|
||||||
|
log.log('roomA', 'say', 'a2')
|
||||||
|
log.log('roomA', 'say', 'a3')
|
||||||
|
assert len(log.get_history('roomA')) == 2
|
||||||
|
assert len(log.get_history('roomB')) == 1
|
||||||
|
|
||||||
|
def test_get_all_rooms(self):
|
||||||
|
log = ChatLog()
|
||||||
|
log.log('alpha', 'say', 'x')
|
||||||
|
log.log('beta', 'say', 'y')
|
||||||
|
rooms = log.get_all_rooms()
|
||||||
|
assert set(rooms) == {'alpha', 'beta'}
|
||||||
|
|
||||||
|
def test_get_history_with_since_filter(self):
|
||||||
|
log = ChatLog()
|
||||||
|
log.log('r', 'say', 'old')
|
||||||
|
import time; time.sleep(0.01)
|
||||||
|
from datetime import datetime
|
||||||
|
cutoff = datetime.now().isoformat()
|
||||||
|
time.sleep(0.01)
|
||||||
|
log.log('r', 'say', 'new')
|
||||||
|
result = log.get_history('r', since=cutoff)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]['message'] == 'new'
|
||||||
|
|
||||||
|
def test_thread_safety(self, tmp_path):
|
||||||
|
"""Multiple threads writing to same ChatLog should not corrupt JSONL."""
|
||||||
|
log = ChatLog()
|
||||||
|
log_file = tmp_path / 'threaded.jsonl'
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def writer(thread_id):
|
||||||
|
try:
|
||||||
|
for i in range(20):
|
||||||
|
with patch('multi_user_bridge.CHATLOG_FILE', log_file):
|
||||||
|
log.log('shared', 'say', f't{thread_id}_m{i}')
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(e)
|
||||||
|
|
||||||
|
threads = [threading.Thread(target=writer, args=(t,)) for t in range(4)]
|
||||||
|
for t in threads: t.start()
|
||||||
|
for t in threads: t.join()
|
||||||
|
|
||||||
|
assert not errors, f"Thread errors: {errors}"
|
||||||
|
# Buffer should have exactly max_per_room (50) entries, all from our writes
|
||||||
|
history = log.get_history('shared')
|
||||||
|
assert len(history) == 50
|
||||||
|
|
||||||
|
# JSONL should have ~80 entries (20*4) - allow off-by-one
|
||||||
|
# due to non-atomic append under contention
|
||||||
|
if log_file.exists():
|
||||||
|
lines = log_file.read_text().strip().split('\n')
|
||||||
|
assert len(lines) >= 78, f"Expected ~80 JSONL entries, got {len(lines)}"
|
||||||
|
# Verify every line is valid JSON
|
||||||
|
for line in lines:
|
||||||
|
parsed = json.loads(line)
|
||||||
|
assert parsed['room'] == 'shared'
|
||||||
|
|
||||||
|
def test_persist_graceful_when_no_path(self):
|
||||||
|
"""log() should not crash if CHATLOG_FILE is undefined."""
|
||||||
|
log = ChatLog()
|
||||||
|
with patch.object(ChatLog, '_get_log_path', return_value=None):
|
||||||
|
entry = log.log('r', 'say', 'test')
|
||||||
|
assert entry['message'] == 'test'
|
||||||
|
# In-memory buffer should still work
|
||||||
|
assert len(log.get_history('r')) == 1
|
||||||
|
|
||||||
|
def test_persist_handles_unwritable_dir(self, tmp_path, capsys):
|
||||||
|
"""log() should catch and report permission errors, not crash."""
|
||||||
|
log = ChatLog()
|
||||||
|
log_file = tmp_path / 'readonly' / 'chat.jsonl'
|
||||||
|
# Make parent dir read-only
|
||||||
|
ro_dir = tmp_path / 'readonly'
|
||||||
|
ro_dir.mkdir()
|
||||||
|
ro_dir.chmod(0o000)
|
||||||
|
try:
|
||||||
|
with patch('multi_user_bridge.CHATLOG_FILE', log_file):
|
||||||
|
entry = log.log('r', 'say', 'test')
|
||||||
|
assert entry['message'] == 'test'
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert 'Persist failed' in captured.out
|
||||||
|
finally:
|
||||||
|
ro_dir.chmod(0o755) # cleanup
|
||||||
|
|
||||||
|
def test_msg_type_ask_logged(self, tmp_path):
|
||||||
|
log = ChatLog()
|
||||||
|
log_file = tmp_path / 'chat.jsonl'
|
||||||
|
with patch('multi_user_bridge.CHATLOG_FILE', log_file):
|
||||||
|
log.log('r', 'ask', 'What is love?')
|
||||||
|
entry = json.loads(log_file.read_text().strip())
|
||||||
|
assert entry['type'] == 'ask'
|
||||||
|
|
||||||
|
def test_msg_type_system_logged(self, tmp_path):
|
||||||
|
log = ChatLog()
|
||||||
|
log_file = tmp_path / 'chat.jsonl'
|
||||||
|
with patch('multi_user_bridge.CHATLOG_FILE', log_file):
|
||||||
|
log.log('r', 'system', 'Server restarted')
|
||||||
|
entry = json.loads(log_file.read_text().strip())
|
||||||
|
assert entry['type'] == 'system'
|
||||||
|
|
||||||
|
def test_data_field_persisted(self, tmp_path):
|
||||||
|
log = ChatLog()
|
||||||
|
log_file = tmp_path / 'chat.jsonl'
|
||||||
|
with patch('multi_user_bridge.CHATLOG_FILE', log_file):
|
||||||
|
log.log('r', 'say', 'hello', data={'extra': 42})
|
||||||
|
entry = json.loads(log_file.read_text().strip())
|
||||||
|
assert entry['data'] == {'extra': 42}
|
||||||
Reference in New Issue
Block a user