Skip to content

feat: monthly active users per platform, and Discord commands to feed and run jobs - #71

Draft
Bilb wants to merge 1 commit into
mainfrom
feat/platform-stats
Draft

Bilb wants to merge 1 commit into
mainfrom
feat/platform-stats

Conversation

@Bilb

@Bilb Bilb commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Four Discord slash commands: /mau for monthly active users per platform, /mau-upload to hand it the store exports, /run to start a job now, and /versions for each store's Session version against GitHub's.

/mau: monthly active users

No timer: mau runs only when someone asks with /mau (optionally month:YYYY-MM). The silence checker leaves it alone.

Answers

  • Both stores have a final figure for the month's last day: the figures are posted in the channel (a follow-up to the interaction), and the asker sees "Posted in the channel."
  • Otherwise, only the asker sees which exports are missing and how to get them; before about the 9th it also says when Play will have the month's end. A month that has not ended is refused the same way.
  • A failure is told to the asker alone and alerted to alerts.env's channel, a systemd stop at the 5-minute timeout included.
  • Asked again, it answers again.

Sources

  • Android, Play and iOS: neither store has an API for active users. Someone exports Play's MAU report and App Store Connect's Active in Last 30 Days (both daily CSVs) and hands them to /mau-upload.
  • iOS counts only devices sharing analytics, so it is divided by the month's opt-in rate from the App Store Connect API's App Opt In report (a Sales and Reports key, loaded as a systemd credential). The answer names the report, the rate and its counts. The API's App Sessions report is not used: summing its rows counts a device once per app version, OS and territory, 5% to 20% too many.
  • Android outside Play: the latest stable release's GitHub APK downloads, plus an F-Droid estimate of 10% of every other figure, since F-Droid publishes no counts.
  • Desktop: the latest stable release's downloads per platform, Flathub's installs included. Desktop has no active-user count.
  • release-stats is removed: this is now the one place Desktop downloads are counted.

Flow

  • The relay validates the month, then, under its start lock, writes inbox/request.json (the interaction's application id and token, and the month) and asks for the unit as /run does. It acknowledges Discord with an ephemeral deferral, and removes request.json if the unit did not start.
  • mau removes request.json, checks its shape (digits for the id, no / in the token), merges every export in inbox/ into a daily history.json and files them in done/, then answers by editing the acknowledgement and, for the figures, posting a follow-up. It retries a 404, since its first edit can reach Discord before the relay's acknowledgement. The token never reaches an alert or the journal.
  • A changed figure for the month before's end, the one the last answer showed, is listed once, then forgotten.

Discord commands

  • /run job:<job> starts session-ops@<job>.service by writing /run/session-ops/requests/<job>, which the job's path unit watches with PathChanged=, then waits up to 1.5 s for the unit's start time to change. Offered for the discord = true jobs other than mau: crowdin-sync and crowdin-duplicates for now. Refused while that job or a queued one is running or waiting, or when the queue's next run is sooner than the job's timeout. The check and the start share one lock. The queue timer loses its random delay, so its next run is the scheduled time.
  • /mau-upload file:<csv> downloads the attachment from Discord's CDN, checks it with mau.parse_export, and only then renames it into mau's inbox. It replies to the uploader alone, saying whether the export has last month's final figure.
  • /versions replies in the channel with one line per store-versions platform from its state.json, which store-versions now writes 0644 for the relay. Read-only, so it is visible to everyone and skips the user and role allowlist.

What the app sees: HTTP interactions only, applications.commands scope, no gateway. /run, /mau and /mau-upload are registered hidden from everyone but server administrators until Server Settings → Integrations grants them to a role.

Gates

  • Ed25519 signature within a 5-minute window, 60 s of forward skew.
  • DISCORD_GUILD_ID (DMs refused), and the channel in ALLOWED_CHANNEL_IDS when that is set.
  • ALLOWED_USER_IDS / ALLOWED_ROLE_IDS; both empty refuses everybody. /versions skips this check.
  • The relay runs as opsbot, which cannot start a unit. It can write only /run/session-ops/requests (root:opsbot 0770), where each discord = true job has a PathChanged= watch and nothing else, and mau's inbox (sessionops:opsbot 0770). No polkit.

Registry and install

  • jobs.toml: discord, and enabled (default true; a job set false gets no units and is offered nowhere).
  • session-ops list --ready covers jobs a timer, the queue or a path unit starts; --timers lists those with a timer of their own, which is all install.sh enables timers for.
  • install.sh: the opsbot account, mau's inbox, the requests directory, the path units of the ready discord = true jobs, and session-ops-discord.service once discord.env has content.

Deploy

  • /etc/session-ops/mau.env: ASC_ISSUER_ID, ASC_KEY_ID; /etc/session-ops/asc-key.p8, mode 600 (install.sh creates it empty, and an empty one fails the run naming it).
  • /etc/session-ops/discord.env, then the location = /discord/interactions block from nginx-webhooks.conf copied into the live file by hand, since certbot owns it, then session-ops discord-register once. Steps in deploy/README.md under "Discord commands".

Not yet done: the Discord side ran end to end against a test app (verification, registration, the gates, /run replies, /mau-upload refusals), before /mau answered through the interaction. Nothing has run on the host. After deploying:

  • An unsigned POST gets 401.
  • systemctl list-units 'session-ops@*.path' lists crowdin-duplicates, crowdin-sync and mau, active.
  • /run crowdin-duplicates starts the job and its post appears.
  • /versions lists the seven platforms.
  • /mau with no exports tells only you what is missing; with both, the figures appear in the channel.

Tests

uv run --locked python -m unittest discover -s tests -t . (1008 OK, 1 skipped), ruff check . clean.

@Bilb Bilb changed the title feat: per-platform usage stats: Desktop downloads and Android MAU feat: monthly active users per platform: Android, iOS and Desktop Oct 9, 2026
@Bilb Bilb changed the title feat: monthly active users per platform: Android, iOS and Desktop feat: monthly active users per platform, and Discord commands to feed and run jobs Oct 9, 2026
session-ops-discord.service answers four slash commands over Discord's HTTP
interactions, behind nginx at /discord/interactions:

- /run job:<job> starts a job jobs.toml marks `discord = true` by writing
  /run/session-ops/requests/<job>, which the job's path unit watches. It is refused
  while that job or a queued one is running or waiting, or when the queue would start
  before the job's timeout.
- /mau [month:YYYY-MM] starts mau the same way, leaving it the interaction to answer
  in its inbox. The figures go to the channel as a follow-up; a missing export, a bad
  month or a failure is told to the person asking alone.
- /mau-upload file:<csv> checks a Play Console or App Store Connect export and puts it
  in mau's inbox, telling the uploader alone whether it has last month's final figure.
- /versions shows store-versions' last read of each platform, to everyone.

Each interaction is signature-checked, then refused outside DISCORD_GUILD_ID, outside
ALLOWED_CHANNEL_IDS when set, and, /versions aside, for anyone not in
ALLOWED_USER_IDS or ALLOWED_ROLE_IDS. The relay runs as opsbot, which starts no unit
and can write only the requests directory and mau's inbox.

mau merges every export into a daily history.json and, once both stores have a final
figure for the month's last day, answers with Android (Play, plus GitHub APKs and an
F-Droid estimate), iOS scaled by Apple's opt-in rate from the App Store Connect API,
Desktop downloads from GitHub and Flathub, and the total. It has no timer, so the
silence checker leaves it alone. release-stats is folded into it.

jobs.toml gains `discord` and `enabled` (default true); `session-ops list --ready`
covers jobs a path unit starts, and `--timers` the ones with a timer of their own. The
queue timer loses its random delay, so /run knows when the queue next starts.
@Bilb
Bilb force-pushed the feat/platform-stats branch from f24e162 to 53eee94 Compare October 16, 2026 08:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant