Chat
This commit is contained in:
@@ -34,6 +34,9 @@ PBKDF2_ITERATIONS = 600_000
|
||||
MAX_SESSIONS_PER_PLAYER = 10
|
||||
LOGIN_MAX_FAILURES = 5
|
||||
LOGIN_LOCKOUT_MS = 15 * 60 * 1000
|
||||
MAX_CHAT_LENGTH = 200
|
||||
CHAT_HISTORY_LIMIT = 100
|
||||
CHAT_COOLDOWN_MS = 2000
|
||||
|
||||
# Ordered from a freshly reset countdown to an almost-expired one.
|
||||
# "share" is each zone's relative slice of the timer, so high-value zones are brief.
|
||||
@@ -186,6 +189,14 @@ def load_state():
|
||||
zone TEXT NOT NULL,
|
||||
points INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY,
|
||||
player_id TEXT NOT NULL REFERENCES players(id) DEFERRABLE INITIALLY DEFERRED,
|
||||
name TEXT NOT NULL,
|
||||
at INTEGER NOT NULL,
|
||||
message TEXT NOT NULL CHECK (length(message) BETWEEN 1 AND 200)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS chat_player_messages ON chat_messages (player_id, id);
|
||||
""")
|
||||
# Serialize initialization. A committed game row is the migration marker,
|
||||
# not merely the existence of a possibly empty or interrupted database.
|
||||
@@ -235,6 +246,15 @@ token_index = {th: pid for pid, p in state["players"].items() for th in p["token
|
||||
failed_logins = {} # lowercase name -> (failure count, first failure ms)
|
||||
|
||||
|
||||
def chat_messages():
|
||||
with database() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT id, name, at, message FROM chat_messages ORDER BY id DESC LIMIT ?",
|
||||
(CHAT_HISTORY_LIMIT,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in reversed(rows)]
|
||||
|
||||
|
||||
def find_player_by_name(name):
|
||||
for pid, p in state["players"].items():
|
||||
if p["name"].lower() == name.lower():
|
||||
@@ -413,6 +433,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path == "/api/leaderboard":
|
||||
with lock:
|
||||
return self.send_json(HTTPStatus.OK, {"leaderboard": public_leaderboard()})
|
||||
if path == "/api/chat":
|
||||
return self.send_json(HTTPStatus.OK, {"messages": chat_messages()})
|
||||
if path == "/api/me":
|
||||
with lock:
|
||||
pid = self.authed_player()
|
||||
@@ -444,6 +466,33 @@ class Handler(BaseHTTPRequestHandler):
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Invalid JSON body"})
|
||||
|
||||
if path == "/api/chat":
|
||||
with lock:
|
||||
pid = self.authed_player()
|
||||
if pid is None:
|
||||
return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Log in to send messages"})
|
||||
message = data.get("message")
|
||||
if not isinstance(message, str) or not (1 <= len(message.strip()) <= MAX_CHAT_LENGTH):
|
||||
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": f"Message must be 1-{MAX_CHAT_LENGTH} characters"})
|
||||
message = message.strip()
|
||||
if not message.isprintable():
|
||||
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Message contains invalid characters"})
|
||||
with database() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
t = now_ms()
|
||||
last = connection.execute(
|
||||
"SELECT at FROM chat_messages WHERE player_id = ? ORDER BY id DESC LIMIT 1", (pid,),
|
||||
).fetchone()
|
||||
if last is not None and t - last["at"] < CHAT_COOLDOWN_MS:
|
||||
return self.send_json(HTTPStatus.TOO_MANY_REQUESTS, {"error": "Please wait two seconds between messages"})
|
||||
name = state["players"][pid]["name"]
|
||||
cursor = connection.execute(
|
||||
"INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)",
|
||||
(pid, name, t, message),
|
||||
)
|
||||
posted = {"id": cursor.lastrowid, "name": name, "at": t, "message": message}
|
||||
return self.send_json(HTTPStatus.CREATED, posted)
|
||||
|
||||
if path == "/api/register":
|
||||
name, password, error = validate_credentials(data)
|
||||
if error:
|
||||
|
||||
Reference in New Issue
Block a user