Source code for minimost.presence

"""
minimost.presence
=================

Real-time presence tracking, typing indicators, read receipts, message
reactions, and private channel membership — all backed by the shared
``presence.db`` SQLite database.

This module manages all **transient shared state** in MiniMost.  Because
MiniMost stores per-user message history in individual SQLite files, a
separate shared database is needed for data that all users must see
simultaneously: who is typing, who is online, who has read a message,
what reactions a message has received, and which users belong to each
private channel.

**``presence.db`` tables:**

.. list-table::
   :header-rows: 1
   :widths: 25 75

   * - Table
     - Purpose
   * - ``presence``
     - One row per user: ``last_seen`` (epoch) and ``state`` (active/idle/
       hidden/offline).  Updated on every presence heartbeat.
   * - ``typing``
     - One row per (user, channel) pair.  Timestamp is updated on each
       keystroke.  Rows older than 5 s are considered stale.
   * - ``read_receipts``
     - Permanent record of (channel, msg_ts, reader) triples written when a
       user calls ``/mark_read/<channel>``.
   * - ``message_reactions``
     - One row per (channel, msg_ts, emoji, reactor) combination.  Toggled
       atomically by ``/react/<msg_id>``.
   * - ``private_channels``
     - One row per private channel: ``name``, ``created_by``, and
       ``created_ts``.  The auto-increment ``id`` forms the channel
       identifier used throughout the app (``"private:<id>"``).
   * - ``private_channel_members``
     - One row per (channel_id, username) pair.  Records ``joined_ts`` and
       ``history_start_ts`` (the timestamp from which a member can see
       messages; ``NULL`` means from the beginning of the channel).

The tables are created at module import time by :func:`_init_tables`.

Module-level attributes
-----------------------
presence_bp : flask.Blueprint
    The Flask Blueprint for presence routes.  Registered in
    :func:`minimost.create_app`.

PRESENCE_DB : str
    Absolute path to the shared ``presence.db`` SQLite file.

_VALID_STATES : set of str
    Allowed presence state values: ``{"active", "idle", "hidden", "offline"}``.
"""

# From the python standard library
import time
import sqlite3
from pathlib import Path

# From Flask
from flask import session, request, Blueprint

presence_bp = Blueprint("presence", __name__)

_VALID_STATES = {"active", "idle", "hidden", "offline"}
_WAL = "PRAGMA journal_mode=WAL"

_HERE = Path(__file__).resolve().parent
_PROJECT_ROOT = _HERE.parent.parent
PRESENCE_DB = str(_PROJECT_ROOT / "presence.db")


[docs] def _init_tables(): """Create all required tables in ``presence.db`` if they do not exist. Called unconditionally at module import time. Uses ``CREATE TABLE IF NOT EXISTS`` throughout, so repeated calls are safe. **Tables created:** * ``presence`` — tracks each user's last-seen timestamp and state. * ``typing`` — records when a user was last observed typing in a channel. * ``read_receipts`` — permanent log of which users have read which messages. * ``message_reactions`` — stores each (channel, message, emoji, user) reaction tuple. * ``private_channels`` — one row per private channel with name, creator, and creation timestamp. * ``private_channel_members`` — one row per (channel_id, username) pair recording membership and join timestamp. :returns: None """ db = sqlite3.connect(PRESENCE_DB) db.execute("PRAGMA auto_vacuum = INCREMENTAL") db.execute(_WAL) db.execute(""" CREATE TABLE IF NOT EXISTS presence ( user TEXT PRIMARY KEY, last_seen INTEGER NOT NULL, state TEXT NOT NULL ) """) db.execute(""" CREATE TABLE IF NOT EXISTS typing ( user TEXT NOT NULL, channel TEXT NOT NULL, ts INTEGER NOT NULL, PRIMARY KEY (user, channel) ) """) db.execute(""" CREATE TABLE IF NOT EXISTS read_receipts ( channel TEXT NOT NULL, msg_ts REAL NOT NULL, reader TEXT NOT NULL, PRIMARY KEY (channel, msg_ts, reader) ) """) db.execute(""" CREATE TABLE IF NOT EXISTS message_reactions ( channel TEXT NOT NULL, msg_ts REAL NOT NULL, emoji TEXT NOT NULL, reactor TEXT NOT NULL, PRIMARY KEY (channel, msg_ts, emoji, reactor) ) """) db.execute(""" CREATE TABLE IF NOT EXISTS private_channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, created_by TEXT NOT NULL, created_ts REAL NOT NULL ) """) db.execute(""" CREATE TABLE IF NOT EXISTS private_channel_members ( channel_id INTEGER NOT NULL, username TEXT NOT NULL, joined_ts REAL NOT NULL, history_start_ts REAL, PRIMARY KEY (channel_id, username), FOREIGN KEY (channel_id) REFERENCES private_channels(id) ) """) db.execute(""" CREATE TABLE IF NOT EXISTS calls ( call_id TEXT PRIMARY KEY, channel TEXT NOT NULL, initiator TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'ringing', started_ts REAL NOT NULL, answered_ts REAL, ended_ts REAL, screenshare_user TEXT ) """) # Migration: add screenshare_user for existing databases that predate group calling try: db.execute("ALTER TABLE calls ADD COLUMN screenshare_user TEXT") except sqlite3.OperationalError: pass db.execute(""" CREATE TABLE IF NOT EXISTS call_participants ( call_id TEXT NOT NULL, username TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'participant', state TEXT NOT NULL DEFAULT 'pending', joined_ts REAL, left_ts REAL, PRIMARY KEY (call_id, username), FOREIGN KEY (call_id) REFERENCES calls(call_id) ) """) db.execute(""" CREATE TABLE IF NOT EXISTS call_signals ( id INTEGER PRIMARY KEY AUTOINCREMENT, call_id TEXT NOT NULL, from_user TEXT NOT NULL, to_user TEXT NOT NULL, signal_type TEXT NOT NULL, payload TEXT NOT NULL, ts REAL NOT NULL ) """) db.execute(""" CREATE TABLE IF NOT EXISTS call_media ( id INTEGER PRIMARY KEY AUTOINCREMENT, call_id TEXT NOT NULL, sender TEXT NOT NULL, is_init INTEGER NOT NULL DEFAULT 0, mime_type TEXT, data BLOB NOT NULL, ts REAL NOT NULL ) """) db.execute(""" CREATE INDEX IF NOT EXISTS call_media_idx ON call_media (call_id, sender, is_init, id) """) db.execute(""" CREATE TABLE IF NOT EXISTS screenshares ( share_id TEXT PRIMARY KEY, channel TEXT NOT NULL, sharer TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'active', started_ts REAL NOT NULL, ended_ts REAL ) """) db.execute(""" CREATE TABLE IF NOT EXISTS share_media ( id INTEGER PRIMARY KEY AUTOINCREMENT, share_id TEXT NOT NULL, is_init INTEGER NOT NULL DEFAULT 0, mime_type TEXT, data BLOB NOT NULL, ts REAL NOT NULL ) """) db.execute(""" CREATE INDEX IF NOT EXISTS share_media_idx ON share_media (share_id, is_init, id) """) db.commit() # One-time migration: if auto_vacuum was never enabled, VACUUM to compact # the database and permanently store the new auto_vacuum setting. if db.execute("PRAGMA auto_vacuum").fetchone()[0] == 0: db.execute("VACUUM") db.close()
_init_tables()
[docs] @presence_bp.route("/typing/<channel>", methods=["POST"]) def typing_start(channel): """Record that the current user is typing in a channel. Route: ``POST /typing/<channel>`` Does **not** require the ``@login_required`` decorator — if the session is missing, the request is silently dropped (``204 No Content``) rather than redirecting to the login page. This avoids a redirect loop when the client sends typing notifications for a brief period after a session expiry. The timestamp is written to the ``typing`` table using ``INSERT OR REPLACE``, so the row for ``(user, channel)`` is updated in place on each call. :param channel: The channel name or DM identifier. :type channel: str :returns: Empty body with HTTP 204 No Content. :rtype: flask.Response """ user = session.get("user") if user: now = int(time.time()) db = sqlite3.connect(PRESENCE_DB) db.execute(_WAL) db.execute( "INSERT OR REPLACE INTO typing (user, channel, ts) VALUES (?, ?, ?)", (user, channel, now), ) db.commit() db.close() return "", 204
[docs] @presence_bp.route("/typing/<channel>", methods=["GET"]) def typing_get(channel): """Return the list of users currently typing in a channel. Route: ``GET /typing/<channel>`` A user is considered to be "currently typing" if their ``ts`` in the ``typing`` table is within the last **5 seconds**. The current user is excluded from the result so they never see their own typing indicator. The client polls this endpoint every second and displays a ``"<user> is typing…"`` banner in the chat area. :param channel: The channel name or DM identifier. :type channel: str :returns: JSON array of usernames who are currently typing, e.g. ``["alice", "bob"]``. Returns ``[]`` if the session is missing or no one is typing. :rtype: flask.Response (application/json) """ user = session.get("user") if not user: return [] cutoff = int(time.time()) - 5 db = sqlite3.connect(PRESENCE_DB) db.execute(_WAL) rows = db.execute( "SELECT user FROM typing WHERE channel = ? AND ts >= ? AND user != ?", (channel, cutoff, user), ).fetchall() db.close() return [r[0] for r in rows]
[docs] @presence_bp.route("/presence", methods=["POST"]) def presence(): """Update the current user's presence state. Route: ``POST /presence`` Accepts a JSON body with a ``state`` key. Valid values are defined by :data:`_VALID_STATES`: ``"active"``, ``"idle"``, ``"hidden"``, ``"offline"``. The client sends this request: * Immediately when the page's ``visibilitychange`` event fires (``"hidden"`` when the tab goes to the background, ``"active"`` when it returns). * After detecting 5 minutes of keyboard/mouse inactivity (``"idle"``). * On a 30-second heartbeat to keep ``last_seen`` fresh. Silently returns ``204`` if the session is missing or the state is invalid. Request body (JSON): **state** (str): One of ``"active"``, ``"idle"``, ``"hidden"``, ``"offline"``. :returns: Empty body with HTTP 204 No Content. :rtype: flask.Response """ user = session.get("user") if user: data = request.get_json(silent=True) or {} state = data.get("state") update_presence(user, state) return "", 204
[docs] def reset_all_offline() -> None: """Set every user's presence state to ``"offline"`` in ``presence.db``. Called once at application startup so that stale presence records from a previous server run (e.g. users who were ``"active"`` or ``"hidden"`` when the server was stopped) do not mislead other users. :returns: None """ db = sqlite3.connect(PRESENCE_DB) db.execute(_WAL) db.execute("UPDATE presence SET state = 'offline'") db.commit() db.close()
[docs] def update_presence(user: str, state) -> None: """Write a presence record directly to ``presence.db``. This function is the shared implementation used by both the ``/presence`` HTTP route and the :func:`minimost.auth.logout` view (which sets the user's state to ``"offline"`` before clearing the session). If *state* is not in :data:`_VALID_STATES` the function returns immediately without writing anything. :param user: The username whose presence should be updated. :type user: str :param state: New presence state — one of ``"active"``, ``"idle"``, ``"hidden"``, ``"offline"``. :type state: str :returns: None """ if state not in _VALID_STATES: return now = int(time.time()) db = sqlite3.connect(PRESENCE_DB) db.execute(_WAL) db.execute( "INSERT OR REPLACE INTO presence (user, last_seen, state) VALUES (?, ?, ?)", (user, now, state), ) db.commit() db.close()