Bot commands were the only user-initiated path that didn't surface in
the dashboard. They now produce ``command_handled`` /
``command_rate_limited`` / ``command_failed`` rows in ``EventLog``
alongside tracker and action events.
Backend
- ``EventLog`` gains nullable ``command_tracker_id`` / ``telegram_bot_id``
FKs plus deletion-snapshot name columns (idempotent migration).
- New ``_log_command_event`` helper emits one row per invocation at the
three branches in ``handle_command``. Logging failures are swallowed
so they cannot block the user-visible reply.
- Telegram ``from`` is captured in poller and webhook, whitelisted to
identity fields by ``_normalize_issuer`` (drops ``language_code`` and
any future PII), persisted under ``details.issuer``.
- ``/api/status`` resolves live ``CommandTracker`` / ``TelegramBot``
names (mirroring the action pattern) and exposes ``tracker_id``,
``command_tracker_id``, ``telegram_bot_id`` so the frontend can
deep-link.
Frontend
- Event rows are now clickable and open a detail modal with full
provenance (bot → chat → issuer → provider), raw ``details`` JSON,
and per-entity action buttons.
- Buttons use the existing ``requestHighlight`` + ``goto`` crosslink
pattern, so clicking lands on the entity's list page with that
specific card scrolled into view and pulsing.
- Auto-refresh dropdown (Off / 10s / 30s / 1m / 5m) persisted in
``localStorage``; ticker pauses while the tab is hidden.
- Event-type filter, dashboard verb labels, and gradients extended for
the three new ``command_*`` types.
- Filled in pre-existing missing i18n keys (``common.hide`` /
``common.show`` / ``commandConfig.noCommandsForProvider``).
Tests
- New ``test_command_event_logging.py`` covers subject formatting,
issuer normalization, the three event branches, and graceful failure
when the DB is unreachable. ``pytest packages/server/tests/`` → 96/96.
Two related Telegram changes:
1. Per-chat command localization. setMyCommands now accepts a scope
(BotCommandScopeChat) and deleteMyCommands clears scoped bindings.
Command registration runs three tiers: default → per-language
(Telegram client language) → per-chat (UI override). Saving a
chat's language_override or commands_enabled toggle pushes the
binding to Telegram inline rather than waiting on the 30s
debounced bot-wide sync.
2. Unified Telegram locale resolution. Three test paths (bot test_chat,
target receiver test, target-level fan-out) used to disagree on
locale priority — the target receiver test in particular only
consulted receiver.locale and ignored the chat's language_override.
Introduced pick_telegram_locale (pure) and
resolve_telegram_chat_locale (async DB lookup) in services/notifier
so all three paths share one priority order:
receiver.locale → chat.language_override → chat.language_code → fallback
Fan-out keeps batch-loading TelegramChat rows for efficiency, just
runs them through the same priority function now.
Boot-time logging was a three-line basicConfig stub with no timestamps, no
correlation, and silent drops at every layer of the Telegram send path — a
/random command that delivered text but no media left zero evidence in the
log. This replaces the setup and closes every silent drop encountered end-to-end.
New infrastructure:
- notify_bridge_core.log_context: request_id/command/chat_id/bot_id/dispatch_id
ContextVars with a bind_log_context() context manager so deep call sites
(TelegramClient, NotificationDispatcher) inherit the correlation tag without
threading args through.
- notify_bridge_server.logging_setup: dictConfig-based setup with a
LogRecordFactory that tags every record, a SecretMaskingFilter that redacts
/botN:TOKEN plus Authorization/x-api-key/password/secret in messages AND
tracebacks, a JSON formatter for aggregators, text formatter with grep-friendly
[req=... cmd=... bot=... chat=... disp=...] prefix, and default dampening
for sqlalchemy/aiohttp/apscheduler/urllib3/PIL.
Runtime control:
- NOTIFY_BRIDGE_LOG_LEVEL / _FORMAT / _LEVELS env vars (boot).
- DB-backed log_level / log_format / log_levels AppSettings, applied on
boot after migrations and live via apply_log_levels() when edited in
the settings UI (format still requires restart, logs a WARN).
- Frontend settings page gains a Logging card (level dropdown, format
dropdown, per-module overrides); en/ru i18n keys added.
Call-site fixes (/random media-group blind spot and adjacent):
- TelegramClient._fetch_asset: every silent drop now WARN-logs with reason
(missing url, HTTP non-200, size/dimension limits, ClientError).
- TelegramClient._send_media_group: WARN on "chunk had N items but 0 usable",
ERROR on sendMediaGroup non-ok/transport with full context; returns
success=False + "no_items_delivered" instead of success=True with an empty
message_ids list so callers can distinguish.
- TelegramClient.send_message / _upload_media / _send_from_cache: ERROR on
non-ok + transport failures with status/code/desc; DEBUG for cache-hit
fallbacks.
- NotificationDispatcher.dispatch: generates a dispatch_id, binds it, logs
start/finish with failure count, uses exc_info for target failures.
- commands/handler: missing/failed templates -> ERROR + exc_info; send_reply
and send_media_group errors upgraded WARNING -> ERROR with chat/error_code
context; rate-limit and truncation cases logged with full context.
- commands/webhook and services/telegram_poller: bind_log_context(request_id
=tg:<update_id>, command, chat_id, bot_id), INFO on receive/dispatch/
completion with duration, exc_info on raise, INFO when commands disabled.
- commands/immich: INFO when album scope is empty; WARN per asset dropped
from media payload and a summary WARN when "N assets in, 0 out".
Slow bot commands (/latest, /random, /favorites, /memory, /search,
/find, /person, /place, /summary) spend most of their wall time
fetching assets from the service provider, not uploading to Telegram.
Telegram chat actions expire after ~5s, so the previous one-shot hint
vanished long before media arrived — users saw nothing happening.
- TelegramClient.start_chat_action_keepalive: promoted from private
helper to public API, posts the action every 4s until cancelled.
- telegram_send.telegram_chat_action: async context manager that
starts the keep-alive task on enter and cancels + awaits it on
exit. A None action makes it a no-op so callers don't branch.
- classify_command_chat_action: maps command name to the right
Telegram action (upload_photo for media-returning commands, typing
for /summary, None for fast DB-only commands like /status /events).
- webhook.py + telegram_poller.py: wrap handle_command in the context
manager so the hint persists through the whole fetch+upload window
in both webhook and long-poll modes.
- Route cache_key values that look like asset UUIDs through asset_cache
in TelegramClient._get_cache_and_key. Single-asset sends previously
stored file_ids in url_cache while the media-group path stored them
in asset_cache, so repeat sends never hit.
- Extract build_asset_media_urls so the notification dispatcher
(asset_to_media) and the bot command handlers (common._format_assets)
share one rule for /video/playback vs thumbnail URLs.
- Add services/telegram_send.py as the single factory for constructing
a TelegramClient. It always wires the shared aiohttp session and both
file caches, so commands now reuse file_ids populated by notification
dispatches (and vice versa) instead of re-uploading the same bytes.
- send_reply / send_media_group in commands/handler.py now delegate to
the factory rather than constructing their own uncached clients.
- UI: command-template-configs now resolves slot variables against the
active provider first (varsRef[provider_type][slot]) before falling back
to shared entries, so provider-specific slots like /search, /status,
/repos, /issues, /boards show the Variables button and autocomplete.
- Backend: /search, /find, /person, /place now normalize raw Immich API
responses through build_asset_dict, extracting city/country from
exifInfo and mapping isFavorite -> is_favorite so templates render
location and favorite indicators.
- Telegram: extract build_telegram_asset_entry into a shared helper so
the notification dispatcher and command media groups agree on video
typing and /video/playback URLs; videos no longer render as still
thumbnails in /latest /random /favorites media mode.
- Commands: send_media_group now reuses the same Telegram file_id caches
as the notification dispatcher, avoiding re-upload churn for repeated
commands.
The "per-chat album scope" feature stored on CommandTrackerListener was
really per-bot: listener_id = bot.id, and every chat that bot served
shared the same scope. Commands like /albums, /random, /status,
/events leaked the full provider catalog into chats that were never
wired up to receive notifications from those trackers.
New model: the album scope for /commands in a given chat is derived
from the notification-routing graph. For a (provider, bot, chat_id)
triple we walk TargetReceiver (chat_id match, enabled) →
NotificationTarget (telegram or broadcast parent) →
NotificationTrackerTarget → NotificationTracker (provider match) and
union their collection_ids. That's the natural "what does this chat
get notifications about" set, and it becomes the command scope.
- New helper: command_utils.resolve_chat_album_scope(provider_id,
bot_id, chat_id) -> set[str]. Empty set is the default for chats
with no routing — commands return nothing rather than leaking the
provider's catalog.
- Dispatcher computes the scope per (tracker, bot, chat) and threads
it through handler.handle(..., allowed_album_ids=...). Explicit
CommandTrackerListener.allowed_album_ids override, when set, still
wins verbatim (kept as an escape hatch for users who want a divergent
scope for a whole bot).
- /status, /albums, /events, and all /_cmd_immich-routed commands
(/random, /search, /find, /latest, /memory, /summary, /favorites,
/place, /person) now intersect with the resolved scope.
- UI scope modal relabeled: it's an explicit *override for this bot*,
not a per-chat setting. Default is "derive from notification
routing", which matches what users already configured elsewhere.
Also:
- /search, /find, /person, /place — _enrich_assets return value was
discarded, dropping public_url enrichment. Assign the return value.
- search_smart / search_metadata — consolidated into _search_items
helper that logs non-200 responses and transport errors instead of
silently returning []. Makes "always no results" bugs actually
diagnosable. Also accepts the alternate {"assets": [...]} flat-list
shape from older Immich versions.
- Immich search error bodies go through _redact_body so credentials
echoed by authenticating proxies don't land in server logs.
- /albums ignored CommandTrackerListener.allowed_album_ids and listed
every album tracked by the provider — scoped chats saw neighbours'
albums. Thread the listener through _cmd_albums and apply the same
intersect filter the media commands already use in _cmd_immich.
- Command text replies are listings (albums, events, people, ...) that
embed multiple links; Telegram's default behavior of rendering a
preview for the first URL is never useful here and ignored the
"Disable link previews" toggle operators set on their target. Always
pass disable_web_page_preview=True from send_reply.
Backend
- Per-chat album scope for Immich commands (search/latest/memory/...): new
allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs
through ProviderCommandHandler.handle; PATCH listener-scope endpoint.
- /search and /find accept a trailing page number; Immich client search_smart
/ search_metadata take a page param.
- Immich person-asset lookup switched from removed GET /api/people/{id}/assets
to POST /api/search/metadata with personIds (fixes /person command and
auto_organize rules silently returning zero candidates on Immich 1.106+).
- Auto_organize rule now sets the target album's thumbnail to the first added
image when missing (falls back to any asset type); failures do not fail the
rule. add_assets_to_album surfaces the Immich error body on non-2xx.
- EventLog.user_id / action_id / action_name columns with defensive migration
+ backfill. Status query filters by user_id directly; Immich/webhook paths
emit user_id explicitly. action_runner writes an action_success/partial/
failed event on each non-dry-run.
- Dashboard DELETE /api/status/events (scoped to user_id) + rendering live
tracker/provider/action names via FK join with snapshot fallback.
- PATCH /api/users/{id} for username/role change with last-admin guard.
- Deletion protection returns structured {message, entity, blocked_by}
(ApiError carries .blockedBy; frontend opens BlockedByModal).
- Backup prepare-restore → AppSetting markers + atomic write of
pending_restore.json; lifespan hook applies on next startup and archives
under data/applied_restores/. apply-restart sends SIGTERM so the lifespan
shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button.
Manual POST /api/backup/files (same format as scheduled).
- New periodic-summary test path reuses shared collect_scheduled_assets
(limit=0) so test and future production code go through one primitive.
- Per-receiver locale for Telegram test messages (resolves
TelegramChat.language_override per chat instead of applying the first
receiver's locale to everyone).
- Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data
and _refresh_telegram_chat_titles; chat title sweep extended to 24h since
save_chat_from_webhook covers active chats opportunistically.
- Telegram poller detects the \"webhook is active\" 409 and auto-calls
deleteWebhook for bots whose DB update_mode is polling (throttled per bot).
- TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added.
- Seeds: rename \"Default Commands\" → \"Default Immich Commands\";
track_assets_removed default False.
Frontend
- Global provider selector visible when there is only one provider.
- Clear-events button + i18n + ConfirmModal on the dashboard; new icons/
labels/filters/colors for action_success / action_partial / action_failed.
- Auto-select first available tracking/template/command/config + bot on
create forms (trackers, command-trackers, targets, template/command
configs).
- Telegram target disable_url_preview defaults to true.
- BlockedByModal wired into 8 deletion flows; fetchAuth helper for
multipart/binary calls (reuses api()'s refresh + ApiError mapping).
- Immich tracker 'Checking links' parallelised (concurrency cap 6).
- Backup page: pending-restore banner + Apply-now / Apply-later modal,
restarting overlay polling /api/health, manual 'Create backup' button.
- Command-trackers listener row gets an 'Edit album scope' modal with
inherit/explicit multiselect.
- Users page: Edit user modal (username + role).
- parseDate helper for consistent UTC date rendering.
Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
notification_tracker).
- command_tracker_listener: + allowed_album_ids.
- Locale-aware templates: CommandTemplateSlot now has a locale column,
allowing each slot to have per-language variants (EN/RU). Templates
are resolved at runtime from the Telegram user's language_code.
- Merged system configs: "Default Commands (EN)" and "(RU)" merged
into a single "Default Commands" config with locale-aware slots.
Migration handles existing data automatically.
- Configurable command descriptions: hardcoded COMMAND_DESCRIPTIONS
replaced with desc_* template slots (desc_status, desc_help, etc.)
that users can customize per locale. setMyCommands registers all
locales explicitly.
- Removed locale from CommandConfig: no longer needed since locale
is derived from the Telegram user's language at runtime.
- Debounced command auto-sync: after command config/tracker changes,
affected bots are marked dirty and synced after a 30s debounce
window. Manual "Sync with Telegram" button still works.
- Entity pickers in LinkedTargetsSection: replaced 6 plain <select>
elements with EntitySelect components (search, icons, keyboard nav).
Added onselect callback and size="sm" props to EntitySelect.
- Remove ALL hardcoded EN/RU fallback strings from handler.py — every
command response now renders through CommandTemplateSlot templates
- _render_cmd_template now returns error placeholder instead of None
when template is missing, ensuring no silent failures
- Fix register_commands_with_telegram tuple unpacking bug (was ignoring
cmd_template_slots from _resolve_command_context)
- Auto-assign system default template (matching locale) on command
config creation when none specified
- Add command_template_config_id to CommandConfigCreate model
- Remove "no template" option from frontend dropdown — template is
now required for command configs
- Auto-select first matching template when creating new command config
- Fix || vs ?? for command_template_config_id, default_count, and
rate_limits in frontend edit function (0 was treated as falsy)
- Discord, Slack, ntfy, Matrix notification target types with clients and dispatch
- MatrixBot model + API + frontend in Bots tab
- Command template system fully wired into all handler commands
- Default command templates seeded (EN/RU, 14 slots each)
- Command template editor with variables reference including child fields
- Delete protection on all 10 entity types (409 with consumer details)
- Provider type selector on template config forms
- Target type selector as dropdown with all 7 types
- Response template selector on command config form
- CLAUDE.md: mandatory server restart rule, child properties rule
Rework entity schema: rename Tracker→NotificationTracker, add CommandConfig/
CommandTracker/CommandTrackerListener entities for decoupled command handling.
Commands now resolve through CommandTracker→CommandConfig instead of
TelegramBot.commands_config. Smart ref-counted bot polling based on active
listeners. Add chat_action to telegram targets. Full frontend CRUD pages
for command configs and command trackers. Idempotent SQLite migrations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds telegram bot command system with 13 commands (search, latest, random, etc.),
webhook/polling handlers, rate limiting, app settings page, and various UI/UX
improvements across all entity pages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>