# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """Family A: who uses which commands, how often, and which nobody uses. Counts include every source. "How many times was !papież used" is answered by all three classes of row -- a reconstructed invocation still happened. Only the questions that need an hour or a duration narrow to ``live``, and those live in ``hours`` and ``health``. """ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING, Final if TYPE_CHECKING: import sqlite3 from collections.abc import Sequence from signal_bot.analytics.scope import Scope @dataclass(frozen=True, slots=True) class CommandUse: """One command, and everything countable about it.""" command: str uses: int people: int ok: int refused: int errors: int first_day: str last_day: str @property def failure_rate(self) -> float: """Share of attempts that crashed. Refusals are not failures.""" return self.errors / self.uses if self.uses else 0.0 @property def refusal_rate(self) -> float: """Share of attempts a gate turned away. High is not popular, it is mis-tuned: it means people keep asking and keep being told no. """ return self.refused / self.uses if self.uses else 0.0 @dataclass(frozen=True, slots=True) class PersonUse: """One person's total, by uuid. Names are attached by the caller.""" uuid: str uses: int commands: int favourite: str BY_COMMAND_SQL: Final = """ SELECT command, COUNT(*) AS uses, COUNT(DISTINCT user_uuid) AS people, SUM(outcome = 'ok') AS ok, SUM(outcome LIKE 'refused_%') AS refused, SUM(outcome = 'error') AS errors, MIN(day) AS first_day, MAX(day) AS last_day FROM command_events WHERE (:group IS NULL OR group_id = :group) AND outcome != 'unknown_word' GROUP BY command ORDER BY uses DESC, command """ BY_PERSON_SQL: Final = """ SELECT user_uuid, COUNT(*) AS uses, COUNT(DISTINCT command) AS commands FROM command_events WHERE (:group IS NULL OR group_id = :group) AND outcome = 'ok' GROUP BY user_uuid ORDER BY uses DESC, user_uuid """ FAVOURITE_SQL: Final = """ SELECT user_uuid, command, COUNT(*) AS uses FROM command_events WHERE (:group IS NULL OR group_id = :group) AND outcome = 'ok' GROUP BY user_uuid, command ORDER BY user_uuid, uses DESC, command """ MATRIX_SQL: Final = """ SELECT user_uuid, command, COUNT(*) AS uses FROM command_events WHERE (:group IS NULL OR group_id = :group) AND outcome = 'ok' GROUP BY user_uuid, command ORDER BY user_uuid, command """ TOTALS_SQL: Final = """ SELECT COUNT(*) AS attempts, SUM(outcome = 'ok') AS ok, COUNT(DISTINCT user_uuid) AS people, COUNT(DISTINCT command) AS commands FROM command_events WHERE (:group IS NULL OR group_id = :group) AND outcome != 'unknown_word' """ def by_command(conn: sqlite3.Connection, scope: Scope) -> list[CommandUse]: """Every command that has ever been attempted, most used first.""" return [ CommandUse( command=str(row["command"]), uses=int(row["uses"]), people=int(row["people"]), ok=int(row["ok"] or 0), refused=int(row["refused"] or 0), errors=int(row["errors"] or 0), first_day=str(row["first_day"]), last_day=str(row["last_day"]), ) for row in conn.execute(BY_COMMAND_SQL, scope.params()) ] def _favourites(conn: sqlite3.Connection, scope: Scope) -> dict[str, str]: """Each person's most-used command. The query orders by uses within each person, so the first row seen for a uuid is the answer and the rest are skipped -- cheaper than a window function and portable to the ancient sqlite a container might carry. """ best: dict[str, str] = {} for row in conn.execute(FAVOURITE_SQL, scope.params()): best.setdefault(str(row["user_uuid"]), str(row["command"])) return best def by_person(conn: sqlite3.Connection, scope: Scope) -> list[PersonUse]: """Who uses the bot, by uuid and most active first. Deliberately returns uuids. Turning one into a name is the caller's job and a different rule in each place: the group chat may name only people whose display name has been seen, the management page may show everyone, and neither may print a raw uuid or a phone number. """ favourites = _favourites(conn, scope) return [ PersonUse( uuid=str(row["user_uuid"]), uses=int(row["uses"]), commands=int(row["commands"]), favourite=favourites.get(str(row["user_uuid"]), ""), ) for row in conn.execute(BY_PERSON_SQL, scope.params()) ] def matrix(conn: sqlite3.Connection, scope: Scope) -> list[tuple[str, str, int]]: """Every (person, command, count) triple, for the heatmap on the page. Never posted into a group -- a per-person breakdown of everybody's habits is a management-page thing. """ return [ (str(row["user_uuid"]), str(row["command"]), int(row["uses"])) for row in conn.execute(MATRIX_SQL, scope.params()) ] def totals(conn: sqlite3.Connection, scope: Scope) -> dict[str, int]: """Attempts, successes, distinct people and distinct commands.""" row = conn.execute(TOTALS_SQL, scope.params()).fetchone() return { "attempts": int(row["attempts"] or 0), "ok": int(row["ok"] or 0), "people": int(row["people"] or 0), "commands": int(row["commands"] or 0), } def never_used(known: Sequence[str], used: Sequence[CommandUse]) -> list[str]: """Registered commands with no recorded attempt at all. Takes the registry's list rather than reading it, because which commands exist depends on the group -- the album's are registered only where the album is -- and this module must not know that. """ seen = {use.command for use in used} return sorted(name for name in known if name not in seen)