Skip to content

Telegram Parent Controls — /status /extend /release /quietnow /limit + Multi-Chat#153

Open
wowa1990 wants to merge 20 commits into
splitti:mainfrom
wowa1990:pr-telegram-parent-controls
Open

Telegram Parent Controls — /status /extend /release /quietnow /limit + Multi-Chat#153
wowa1990 wants to merge 20 commits into
splitti:mainfrom
wowa1990:pr-telegram-parent-controls

Conversation

@wowa1990

Copy link
Copy Markdown

Was es macht

  • Telegram-Bot-Commands: /status (verbleibende Zeit + aktive Slots), /extend <minuten> (heutigen Cap verlängern), /release [allowUntil HH:MM] (Quiet-Slot temporär aufheben), /quietnow [bis HH:MM] (Slot ad-hoc setzen), /limit set <weekday> <min> (Cap permanent ändern)
  • Multi-Chat-Support: mehrere Eltern können denselben Bot bedienen, jede chatId muss in der Whitelist stehen
  • Cap-reached-Notifications an alle berechtigten Chats
  • Admin-UI-Editor für chatId-Whitelist + Bot-Token
  • Range-Check für /vol und /sleep (AR5-2 / AR5-7 Hörschutz-Bypass-Fix — vorher konntest du via Bot Volume jenseits der UI-Caps setzen)

Architektur (kurz)

Eigener Telegram-Bot-Service im Backend (kein Polling im Player), kommuniziert mit dem Playtime/Quiet-State über die existierenden APIs. Whitelist-Check vor jedem Command. Notifications sind one-shot beim State-Übergang, kein Spam.

Test in Codespaces / lokal

  1. Branch auschecken (basiert auf Playtime + Quiet-Hours)
  2. Setup wie üblich
  3. Bot-Token + erste chatId in Admin-UI hinterlegen
  4. Konkrete Tests:
    • /status an Bot → erwartet aktuelle Min/Cap/Slot
    • /extend 10 → Frontend-Chip aktualisiert sich, Counter +10 Min für heute
    • /release allowUntil 21:00 während aktivem Quiet-Slot → Overlay weg bis 21:00
    • /vol 200 → erwartet Ablehnung mit Range-Hinweis (nicht „erfolgreich"!)
    • chatId, die NICHT in Whitelist steht → erwartet Bot ignoriert/lehnt ab

Hinweis: Voller End-to-End nur mit echtem Telegram-Bot-Token testbar.

Abhängigkeit

Stacked auf #151 (Playtime) + #152 (Quiet Hours) — bitte in dieser Reihenfolge mergen.

wowa1990 added 20 commits May 16, 2026 15:22
Adds a per-weekday daily listening cap to the player. When the limit
is reached, playback is stopped and new play/resume/skip commands are
rejected with HTTP 423. Pause/stop/volume/system commands stay
available so the box remains usable.

Why: enables parents to cap daily listening time (and set 0 minutes
for specific days, e.g. no usage on Sunday) — a feature many parent
boxes need but the upstream UI doesn't expose yet.

Implementation:
- Counter increments once per second when isActuallyPlaying() is true
  (covers both Spotify and mplayer playback paths).
- Working state in /tmp/playtime.json (tmpfs — no SD wear).
- Persistent checkpoint in configBasePath, written at most every 60s
  on change, plus on day rollover and just-blocked transition.
- Logical day shifted by configurable resetHour so e.g. listening
  past midnight still counts against the previous day.
- Config under "playtimeLimit" in mupiboxconfig.json; missing block
  defaults to enabled=false so existing installs are unaffected.

Config template gets the new block with safe defaults (60 min/day,
disabled by default). API endpoint and frontend integration follow
in subsequent commits.
Adds a read-only GET /api/playtime endpoint that surfaces the
playtime counter (date, used/remaining seconds, blocked flag, limit
for current weekday, etc.) so the frontend can show a remaining-time
indicator and a blocked-overlay.

The endpoint reads /tmp/playtime.json which the player rewrites once
per second on tmpfs. Missing/unreadable file is treated as
{ enabled: false } — the frontend then simply hides the UI. No
direct coupling to the player process: the file acts as a one-way
status feed.

Also tweaks the player so it always writes the working file (even
when disabled), so a config change from enabled to disabled is
reflected immediately on the next tick rather than leaving a stale
file around.

Adds a typed PlaytimeStatus model (discriminated by `enabled`) and
extends MupiboxConfig with a typed playtimeLimit field for use by
later admin/frontend work.
Adds two UI elements driven by GET /api/playtime polling (every 30s):

- Remaining-time chip in the home page header (next to status icons).
  Self-hides when the feature is disabled or the limit is hit. Color
  shifts at 30 min (yellow) and 10 min (red) so the kid notices time
  running out before the overlay kicks in.
- Full-screen blocked overlay rendered at app level when blocked is
  true. Uses the same overlay pattern as the existing monitor blocker.
  Friendly German message ("Heute war genug Musik / Morgen geht's
  weiter") so it reads as a gentle stop, not a punishment.

Settings UI stays out of the touch frontend on purpose — playtime
limits will be edited only via the AdminInterface so the kid can't
change them.

PlaytimeService is the single source of truth: HTTP-polls /api/playtime
into a Signal that both components read. catchError keeps the UI
working when the API momentarily isn't reachable (treat as disabled).
Adds a new "Daily playtime limit" details section to mupi.php
between the existing Timer and System settings. Lets the parent
configure:
- enabled / disabled
- reset hour (0-23, controls when "today" resets — 4 helps with
  late-evening listening on weekends)
- minutes per weekday (Mon-Sun, 0-1440 each, where 0 fully blocks
  that day)

Save uses the existing $change=2 flow (write mupiboxconfig.json +
setting_update.sh) and additionally restarts the player via
`pm2 restart spotify-control`, since spotify-control.js loads
mupiboxconfig.json once via require() at startup. Without the
restart the player would keep using the previous limits.

Falls back to safe defaults (60 min/day, disabled) when the
playtimeLimit block is missing from an existing installation, so
boxes upgraded in place don't need a manual config edit before
opening the page.
Previously hitting the daily limit ripped the music away mid-track.
Now the limit-reached transition enters a "grace" state instead of
stopping immediately:

- mplayer (local files / radio / RSS): playback continues until the
  next track-change OR playlist-finish event, whichever comes first;
  the player stops at that natural break point.
- Spotify: there is no track-change event from the Web API without
  extra polling, so for Spotify the grace timeout is the only stop
  trigger.
- Hard cap: in either case, after maxOverrunMinutes (default 10,
  configurable 0-60 in mupiboxconfig.json under playtimeLimit) the
  player force-stops regardless of where playback is.

Schema change: PlaytimeStatus now exposes a state field
('normal' | 'grace' | 'blocked') instead of a boolean blocked. The
frontend chip stays visible only in 'normal'; the full-screen overlay
shows only in 'blocked' (during grace, the kid still hears music
finishing — overlay would be confusing).

The catch-all command handler still refuses new play/resume/skip
during grace too — only the *currently playing* track is allowed to
keep going.

Setting it to 0 reproduces the previous behaviour (immediate hard
stop at the limit) for parents who prefer that.

Admin UI: new "Grace period (minutes)" field in mupi.php.
The existing resume save in PlayerPage runs once every 30 seconds,
which is fine for normal listening but means the recorded resume
position can be up to half a minute behind the actual stop point
when the playtime limit cuts in.

Now the player page also:
- saves immediately when the playtime state transitions into 'grace'
  (so the resume entry reflects roughly where the limit was hit), and
  again on the transition into 'blocked' (capturing the actual final
  stop position after the grace period or hard cap)
- saves every 5 seconds in the last minute before the limit, so the
  grace-entry save above isn't itself ~30 s stale

Only kicks in while the player page is open. For background playback
from the home page, the existing 30 s cadence still applies (same
behavior as before).
The chip was scoped to the home toolbar, so during playback (player
page, medialist, etc.) the kid had no remaining-time indicator at all.

Changes:
- Move <mupi-playtime-chip> from home.page.html into the AppComponent
  so it renders on every page.
- Switch chip CSS from inline-flex to position:fixed top-right with a
  high-but-below-overlay z-index (9000 vs overlay's 9999) and
  pointer-events: none so it never intercepts touches on whatever
  page is underneath.
- Drop poll interval from 30s to 5s. The endpoint just reads a tmpfs
  file so the extra requests are essentially free, and 30s was
  visibly stale around limit transitions (chip would briefly show
  the old "1 min" remaining after the player had already stopped).
Top-right was occupied on the home page (cloud/battery icons) and on
the player page (volume + track counter). Drop the chip 64px to sit
just under the typical ion-toolbar — gives it a clear lane on every
page without colliding with toolbar contents.
The player's logger filters by config.server.logLevel, default 'error'.
That swallowed every log.info() call I added — making it impossible
to see when grace started/ended without first toggling Controller
Debugging in the admin UI. Switch the limit-reached / finalize /
day-rollover / startup-resume / rejected-command messages to
console.log/console.error so they always appear regardless of level.
log.debug() messages stay where they are (still gated, as intended
for verbose tracing).

Also fix the music-note emoji in the blocked overlay rendering as a
hollow rectangle on the kiosk Chromium (no color-emoji font on the
box). Replace the inline 🎶 with two ion-icon "musical-notes-outline"
glyphs flanking the message — same icon family that already works
elsewhere, so guaranteed to render.
saveResumeFiles() lived only in player.page.ts, so when playtime or
quiet-hours stopped playback while the user was on the home page (player
page unmounted), no resume entry was written and "weiterhören" silently
did nothing.

Lift the trigger to AppComponent (root, alive for the kiosk's lifetime):
- CurrentMediaService holds the most recently played Media, set by
  PlayerService.{playMedia,resumeMedia}.
- buildResumeMedia() centralises the spotify/library/rss field mapping
  that was inline in player.page so both savers produce identical entries.
- An effect in AppComponent watches PlaytimeService.status() and writes a
  resume entry on the normal -> grace/blocked transition.

Player.page's in-page saver is left intact as a second safety net (30s
cadence, on-leave save, last-minute boost). Backend's composite-key dedup
makes overlapping writes safe.
updateProgress() and saveResumeFiles() each re-subscribed to
mediaService.current$/local$ on every call without ever unsubscribing,
so a 60-min listen accrued ~120 dangling subscriptions. The values were
already being kept fresh by ngOnInit subscriptions — drop the redundant
re-subscribes and bind the ngOnInit ones to the component's lifetime via
takeUntilDestroyed(DestroyRef) so they tear down cleanly on navigation.

Also collapsed the two ngOnInit current$ subscriptions into one (they
both wrote to fields read elsewhere; no need for a second pass).
The historical 30s guard in player.page.ionViewWillLeave was the right
intent — don't pollute the resume swiper with "kid touched the wrong
cover" misclicks — but it was applied inconsistently (transition saves
and the new global cap saver in AppComponent didn't enforce it) and it
counted wall-clock time, so a paused player still ticked the threshold.

Lift the threshold into CurrentMediaService: an internal 1s ticker
increments only while playback is actually running (current$.is_playing
or local$.playing) and flips a worthResume flag at 30s. The flag resets
on every set() — i.e. every new playMedia/resumeMedia.

shouldPersistResume() is the single gate consulted by every save path:
  - player.page.saveResumeFiles (early-return covers the 30s cadence,
    the cap-transition save, and the on-leave save)
  - AppComponent's global cap-transition effect

The redundant resumeTimer > 30 check in ionViewWillLeave is gone.
Adds a second restriction mechanism alongside the daily playtime cap.
While playtime is duration-based, quiet hours are time-window-based:
each weekday has its own list of windows ("from HH:MM to HH:MM",
optional label), and playback is blocked while "now" falls inside any
of them. Multiple windows per day are supported, so a typical schedule
might combine homework time, mealtime and bedtime.

Schema (mupiboxconfig.json):
  "quietHours": {
    "enabled": false,
    "maxOverrunMinutes": 10,
    "schedule": {
      "mon": [
        { "from": "14:00", "to": "16:00", "label": "Hausaufgaben" },
        { "from": "20:00", "to": "06:00", "label": "Schlafenszeit" }
      ],
      ...
    }
  }
Windows belong to the day they start on; a from > to entry spans
midnight (e.g. 20:00 → 06:00 covers the night).

State machine: same 'normal | grace | blocked' as playtime, but
purely time-driven (no counter, no checkpoint). Grace lets the
current track finish when a window starts, with a configurable cap.
mplayer track-change / playlist-finish events finalize quiet grace
the same way they finalize playtime grace; for Spotify only the
maxOverrunMinutes timeout fires.

When a window ends, playback is *not* auto-started — the kid taps
play to resume. When the feature is disabled mid-window, the box is
released immediately (same idea: don't auto-start, just unblock).

API surface change: GET /api/playtime now returns nested sub-objects
playtime.* and quiet.*, plus combined top-level state and blockSource.
Frontend updated to read from the nested structure. Chip stays bound
to the playtime sub-status only (quiet has no countdown). Overlay
picks heading/icon/subheading based on blockSource and the active
window's label.

Admin UI: new "Quiet hours" section in mupi.php with a tiny JS-driven
multi-window editor per day (add/remove rows). Empty rows are dropped
on save. Save triggers pm2 restart of the player like the playtime
save does, since both are loaded once via require() at startup.
Real user feedback after deploying: a single Monday window of
20:00 → 08:00 covers Monday-evening AND Tuesday-morning, but the
existing description was too terse to make that obvious — first
question was "do I need a second entry for Tuesday?".

Replace the brief "set from later than to to span midnight" sentence
with a bulleted explanation including a concrete worked example, so
the answer is right there in the admin UI.

No code/schema change.
…rride (AR5-14)

When a parent grants playback via /release allowUntil, the existing
guards in finalizeQuietHoursBlock (line 668) and isBlockActive
(line 635) correctly stop NEW blocking events from firing during the
override. But quietHoursTickStep itself still mutated quietHoursState
when a window started or ended mid-override:

  16:00  parent runs /release allowUntil 20:00  (kid plays through dinner)
  18:00  quiet window 18-20 starts. tickStep enters its `state==='normal'
         && window matches` branch, sets state='grace' with maxOverrun grace
  18:30  grace expires → state='blocked'
  20:00  override expires. Next isBlockActive() call: state is already
         'blocked', kid gets cut off with zero grace at the very moment
         the parent expected playback to gracefully wind down.

Fix: gate the entire tickStep behind isAllowOverrideActive(). State
stays on 'normal' / activeWindow stays null for the whole override
window. The first tick after the override expires sees the current
time-in-quiet-window properly and walks the normal → grace → blocked
transitions from scratch — so the kid actually gets the configured
maxOverrunMinutes of grace.

playtimeTickStep is intentionally left alone — playtime accounting
needs to keep ticking through an override so the daily counter
reflects real usage; only its BLOCKING side is suppressed, which
the existing isAllowOverrideActive guards already handle.
The receiver previously responded to commands from any chat that
messaged the bot. Anyone who discovered the bot username (e.g. via
Telegram search, by being in a shared group, or by guessing) could
send /shutdown, /reboot, /vol, /sleep, /screen — with the bot token
as the *only* barrier. That is too thin a defense for a parent's
network-exposed device.

Adds an is_authorized() check that compares the inbound chat_id /
user_id against the chatId configured in mupiboxconfig.json. An
empty chatId is treated as "deny all" — the user has to configure
one anyway for outbound notifications, so requiring it here costs
nothing.

Applies to both on_chat_message and on_callback_query (the inline
keyboard) so neither path is privileged.

This fix can be backported to any existing MuPiBox installation that
runs telegram_receiver.py — no other changes required, no schema
changes, no service config changes.
…n save)

Replaces the require()-cached mupiboxconfig.json with a fs.watch-based
live reloader. Admin saves of playtime/quiet-hours now take effect
within ~50ms instead of triggering a 3-5s pm2 restart of the player
(which dropped audio and disconnected librespot).

How it works:
- readMupiBoxConfigFromDisk() reads + JSON.parses on demand. Failures
  log and return null so partial-write events don't replace good
  state with garbage.
- muPiBoxConfig is now a `let` binding, atomically reassigned on each
  successful reload. All access patterns (`muPiBoxConfig.telegram.X`,
  `muPiBoxConfig?.playtimeLimit`, etc.) dereference per-access, so
  swapping the binding is transparent.
- The file at the local path is typically a symlink to
  /etc/mupibox/mupiboxconfig.json on the box. We resolve the symlink
  via realpathSync and watch the *resolved directory* — admin uses
  atomic `mv` rename, which fires directory-level events that
  watching the symlink directly would miss.
- fs.watch uses inotify on Linux; near-zero idle CPU and no polling.

config.json (Spotify creds, log level, port) intentionally NOT
live-reloaded: those values are sealed into spotifyApi/log/server.listen()
at startup, so changing them still requires a pm2 restart anyway.

Admin: drop the `pm2 restart spotify-control` block that ran after
playtime/quiet-hours saves; with live-reload it's redundant. Update
the success messages to reflect the new behavior.
Adds remote parent control commands to the Telegram bot, backed by
new backend-api endpoints and player overrides. Authorization is
already in place from the prior commit.

Three config-driven mechanisms wire it up end-to-end:

1. playtimeLimit.todayBonus { date, minutes }
   Bonus minutes added to today's cap, auto-resetting at day rollover
   (the player checks if the stored date matches the current logical
   day). Used by /extend so giving extra time today doesn't change
   the persistent weekday limit.

2. playbackOverride.allowUntil  (epoch ms)
   While now < allowUntil, isPlaybackBlocked() returns false and
   finalizePlaytimeBlock / finalizeQuietHoursBlock short-circuit —
   parent has explicitly green-lit playback. Used by /release.

3. playbackOverride.forceBlockUntil  (epoch ms)
   While now < forceBlockUntil, the combined tick short-circuits to
   'blocked' and stop() is called once on entry. New blockSource
   value 'override' lets the frontend show a distinct overlay if it
   wants. Used by /quietnow ("instant bedtime").

API additions in backend-api:
- POST /api/playtime/extend    body: { minutes }
- POST /api/playtime/release   body: { minutes? = 60 }
- POST /api/quiethours/now     body: { minutes? = 60 }

All three reach into mupiboxconfig.json via a small atomic helper
(updateMupiboxConfig) that mutates an in-memory copy and `sudo cp`s
it over the live file. The player picks up the change ~50 ms later
via fs.watch (already in place from the live-reload commit).

Telegram receiver:
- New /status command formats the /api/playtime payload into a
  human-readable summary (used minutes, remaining, quiet-window
  active, override countdowns, etc.).
- New /extend, /release, /quietnow commands with sensible defaults
  (30, 60, 60 minutes respectively).
- Inline keyboard /help gets quick buttons for the four new actions.

Schema changes are additive; existing installations with no
playbackOverride or todayBonus keys are treated as defaults (no
override active, no bonus). The override pair both default to 0,
which is always in the past — so "no override" is the natural state.
…admin-UI editor

Three coordinated Telegram improvements that all share the chatId
data-format change.

T1 — multi-chat-auth + admin-UI editor

  - telegram.chatId in mupiboxconfig.json now accepts an array of
    {id, label?} objects in addition to the legacy single string. New
    shared helper scripts/telegram/telegram_chats.py centralises the
    normalisation (legacy string, single number, array of strings, or
    the new object array), with send_to_all() / photo_to_all() helpers.
    All ten on-box telegram_*.py scripts loop over every configured
    chat, so notifications reach the family group + each parent's DM.
  - telegram_receiver.py: ALLOWED_CHAT_IDS is now a set built from the
    same normalisation, so any configured chat can issue parent-control
    commands. The receiver's "reply to whoever asked" semantics are
    unchanged.
  - spotify-control.js: hasConfiguredTelegram() helper replaces the
    eight scattered (active && token.length>1 && chatId.length>1)
    checks. Necessary because chatId.length on the new array form means
    "number of entries", not "string length", and could falsely
    short-circuit notifications.
  - AdminInterface smart.php: replaces the single Chat ID input with a
    multi-row editor (id + optional label per row, add/remove buttons),
    parallel-array form fields parsed back into the object array on
    save. "Generate Telegram Chat ID" now appends the freshly detected
    chat to the list instead of clobbering it. Legacy single-string
    values are migrated to the new format on first save.

T2 — /limit set <day> <minutes> bot command

  - New /api/playtime/limit endpoint mutates
    playtimeLimit.limitsMinutes.<day> via updateMupiboxConfig — same
    pattern as /extend, /release, /quietnow. Live-reload picks it up.
  - Receiver gets a /limit command with usage hints; /command help
    text updated.

T3 — cap-reached push notifications

  - finalizePlaytimeBlock fires "Hörzeit aufgebraucht heute" via
    telegram_send_message.py.
  - finalizeQuietHoursBlock fires "Ruhezeit gestartet" (with the
    window's label appended if set, e.g. "Ruhezeit gestartet:
    Hausaufgaben").
  - Both go through the shared sender, so every configured parent
    chat is notified.
Two combined bugs in telegram_receiver.py:

AR5-2 (Schutzfunktions-Bypass): /vol <X> spliced the raw POST argument
into "<X>%" and called amixer directly. The maxVolume cap from MED-1
(Hörschutz) only fires through the backend-player setVolume() flow —
the Telegram path was an unauthenticated bypass for any whitelisted
chat. Now: parse → clamp_volume() → max(0, min(v, mupibox.maxVolume)).

AR5-7 (Receiver-Thread crash): /vol or /sleep without an argument
hit `int(split_cmd[1])` and raised IndexError. telepot's MessageLoop
doesn't recover from that — the bot stayed deaf until pm2/systemd
restart. mupi_telegram.service has no Restart= directive (AR5-8),
so a malformed message could brick the bot until reboot. Now:
parse_int_arg with default=None, explicit usage-message on bad input.

Same clamp applied to vol_*/sleep_* inline-keyboard callbacks for
defense-in-depth — the current button values are hardcoded so this
is preventive, not actively exploitable.
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