# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. """!analityka -- what the group has actually been asking the bot for. Registered here rather than in ``stats/commands.py``, which is a few lines under the 250 cap already, and because this reads a different table: ``stats`` is the *message* history, this is the *command* history. The reply is text only, in Polish, capped at twelve lines. The charts live on the management page; a rendered image in the chat would mean a new dependency and, per ``DOCS-latency.md``, would turn a 250 ms text reply into a three-second one for a picture nobody can zoom into. Like ``!pomoc``, the registry is read when the command *runs* rather than when it is registered -- at registration time the album's commands do not exist yet, and those are exactly the triggers a near miss might be against. """ from __future__ import annotations from typing import TYPE_CHECKING, Final from signal_bot.analytics import adoption, demand, hours, render, trend, usage from signal_bot.analytics.scope import Scope, window from signal_bot.animals import load_aliases if TYPE_CHECKING: from collections.abc import Sequence from signal_bot.context import CommandContext from signal_bot.decorators import Decorator from signal_bot.registry import Command, CommandRegistry, Handler COMMAND_NAME: Final = "command_stats" DESCRIPTION: Final = "kto ile używa bota i które komendy żyją" def triggers_for(commands: Sequence[Command], group_id: str) -> tuple[str, ...]: """Every trigger this group could plausibly have meant. Scoped to the group, and that scoping is the point: the album's triggers are real people's names and exist only where the album does. Reporting a near miss against one outside its group would announce that the album exists, which is the same leak the dispatcher refuses at resolution. """ return tuple( trigger for command in commands if command.allowed_in(group_id) for trigger in command.triggers ) def summarise(ctx: CommandContext, commands: Sequence[Command]) -> render.Summary: """Gather every number the reply needs, from the group that asked. Scoped to one group like every other statistic here: a test group's numbers must never turn up in the production group's answer. """ conn = ctx.store.commands.connection scope = Scope(ctx.group_id) measured = window(conn, scope) series = trend.dense_days(trend.by_day(conn, scope)) share = adoption.share_of_chat(conn, scope) return render.Summary( measured=measured, uses=usage.by_command(conn, scope), people=usage.by_person(conn, scope), # Only people the bot has actually seen a name for. Everyone else is # counted and left unnamed: the roster knows them as a raw uuid or a # phone number, and printing either into a chat is a leak. names={ person.uuid: person.display_name for person in ctx.store.events.people(ctx.group_id) }, peak=hours.peak(hours.by_hour(conn, scope)), direction=trend.direction(series, measured), demand=demand.collect(conn, scope, triggers_for(commands, ctx.group_id)), asker=ctx.message.source_uuid, commands=share.commands, messages=share.messages, ) def register_analytics_command( registry: CommandRegistry, *, decorate: Decorator | None = None, aliases: dict[str, list[str]] | None = None, ) -> None: """Register !analityka under every trigger aliases.json gives it.""" table = load_aliases() if aliases is None else aliases async def handler(ctx: CommandContext) -> None: await ctx.reply(render.render(summarise(ctx, registry.commands))) wrapped: Handler = decorate(handler) if decorate else handler registry.register(COMMAND_NAME, table[COMMAND_NAME], wrapped, DESCRIPTION)