Compare commits

...

2 Commits

Author SHA1 Message Date
alexei.dolgolyov aa9548d884 chore: release v0.6.4
Release / release (push) Successful in 1m41s
2026-04-27 18:27:09 +03:00
alexei.dolgolyov 72dd611f8c fix(telegram): respect chat_action UI choice, drop phantom indicator
chat_action was stored in two places — the model column and config JSON —
and dispatch_helpers unconditionally overrode the config value with the
column. The frontend only ever wrote the JSON path, so the UI choice
silently had no effect on outgoing chat actions.

Make the column the single source of truth: frontend sends chat_action
top-level, dispatch_helpers reads from the column, and a one-time
backfill migrates existing config values to the column and strips the
legacy key.

Also fix a long-standing race where the keepalive's bare sleep(4) +
finally cancel could fire one last sendChatAction after the response
already arrived, leaving a phantom indicator for ~5s. Replace with a
stop event + wait_for so callers can signal stop cleanly via the new
stop_keepalive helper.
2026-04-27 18:20:50 +03:00
11 changed files with 251 additions and 124 deletions
+8 -26
View File
@@ -1,45 +1,27 @@
# v0.6.3 (2026-04-27)
# v0.6.4 (2026-04-27)
Adds user filters for the Gitea tracker, makes the dashboard navigable, removes leftover webhook polling, and fixes the theme/sidebar flash on hard reload.
Fixes Telegram chat actions: the indicator the user picks in the UI is now actually sent, and the phantom "typing…" bubble that lingered for ~5s after a notification arrived is gone.
## User-facing changes
### Features
- **Gitea — sender filters:** `NotificationTracker` now exposes `sender_allowlist` and `sender_blocklist` via `MultiEntitySelect`. The picker is populated from `Gitea /users/search` merged with past `EventLog` senders, so it is useful even before the first webhook arrives ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
- **Dashboard navigability:** stat cards are now `<a>` links that route to providers, trackers, targets, command-trackers, or scroll to the events panel. Provider deck rows highlight the target provider on click ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
- **Command trackers / configs:** auto-reselect the matching config when the provider type changes, matching notification-tracker behaviour ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
- **Webhook providers (gitea, planka, webhook):** stop scheduling interval polling jobs on tracker create/update/startup, and hide the misleading "every Xs" indicator in the tracker list — webhook trackers do not poll ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
### Bug Fixes
- **Theme FOUC on hard reload:** an inline blocking script in `app.html` now resolves the theme from `localStorage` (or `prefers-color-scheme`) and sets `data-theme` on `<html>` before first paint, eliminating the dark→light flash users saw when the light theme was selected ([b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01))
- **Sidebar jump on reload:** sidebar collapsed state and expanded nav groups now hydrate synchronously in their `$state` initialisers instead of inside `onMount`, so the sidebar no longer snaps from expanded→collapsed and groups no longer slide open after mount ([b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01))
- **Provider-filter row pop-in:** the global provider-filter row now stays rendered while `providersCache.fetchedAt === 0`, so it no longer pops in mid-paint and pushes the nav down once the cache resolves ([b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01))
- **Telegram chat action UI choice now respected:** `chat_action` was stored in two places — the model column and the config JSON — and dispatch unconditionally overrode the config value with the column. The frontend only ever wrote the JSON path, so picking "upload_photo" / "record_voice" / etc. in the UI silently had no effect on outgoing chat actions. The column is now the single source of truth: the frontend sends `chat_action` top-level, dispatch reads from the column, and a one-time migration backfills existing config values into the column and strips the legacy key ([72dd611](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/72dd611))
- **Phantom Telegram chat-action indicator:** a long-standing race in the keepalive loop (bare `sleep(4)` + `finally cancel`) could fire one last `sendChatAction` after the response had already arrived, leaving a "typing…" / "uploading…" bubble in the chat for ~5 seconds. Replaced with a stop event + `wait_for` so callers signal stop cleanly via the new `stop_keepalive` helper ([72dd611](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/72dd611))
## Development / Internal
### Build
- **Build-time app version:** `vite.config.ts` now reads `frontend/package.json` and exposes its version as an `__APP_VERSION__` global via Vite's `define`, with an ambient declaration in `app.d.ts` so the layout's brand version badge type-checks ([4307955](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4307955))
### Database
- **Migration:** drop legacy `batch_duration` column from `notification_tracker` — the field had been removed from the model but its `NOT NULL` constraint still blocked inserts on older DBs ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
### Documentation
- Refresh `.claude/docs/entity-relationships.md` with current `NotificationTracker` fields (filters, `adaptive_max_skip`, `default_*_config_id`) ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
- **Migration:** new one-shot migration moves `chat_action` from `notification_target.config` JSON into the dedicated column on existing rows, then deletes the legacy key from config. No action required — runs automatically on backend start ([72dd611](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/72dd611))
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [4307955](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4307955) | feat(frontend): inject `__APP_VERSION__` from package.json at build time | alexei.dolgolyov |
| [b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01) | fix(redesign): prevent theme FOUC and sidebar jump on hard reload | alexei.dolgolyov |
| [42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6) | feat(trackers): user filters for Gitea, webhook polling cleanup, dashboard navigability | alexei.dolgolyov |
| Hash | Message | Author |
|------------------------------------------------------------------------------------------|----------------------------------------------------------------------|------------------|
| [72dd611](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/72dd611) | fix(telegram): respect chat_action UI choice, drop phantom indicator | alexei.dolgolyov |
</details>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.6.3",
"version": "0.6.4",
"type": "module",
"scripts": {
"dev": "vite dev",
+1
View File
@@ -106,6 +106,7 @@ export interface NotificationTarget {
name: string;
icon: string;
config: Record<string, any>;
chat_action?: string | null;
chat_name?: string;
receiver_count: number;
receivers: TargetReceiver[];
+6 -4
View File
@@ -229,7 +229,7 @@
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
ai_captions: c.ai_captions ?? false, chat_action: c.chat_action ?? 'typing',
ai_captions: c.ai_captions ?? false, chat_action: tgt.chat_action ?? c.chat_action ?? 'typing',
// discord/slack
username: c.username || '',
// ntfy
@@ -268,7 +268,7 @@
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
ai_captions: form.ai_captions, chat_action: form.chat_action || undefined,
ai_captions: form.ai_captions,
};
} else if (formType === 'webhook') {
config = { ai_captions: form.ai_captions };
@@ -284,10 +284,12 @@
config = { child_target_ids: form.child_target_ids };
}
const body: Record<string, any> = { name: form.name, icon: form.icon, config };
if (formType === 'telegram') body.chat_action = form.chat_action || null;
if (editing) {
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify({ name: form.name, icon: form.icon, config }) });
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
} else {
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, name: form.name, icon: form.icon, config }) });
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, ...body }) });
}
showForm = false;
editing = null;
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-core"
version = "0.6.3"
version = "0.6.4"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
@@ -6,12 +6,46 @@ import asyncio
import json
import logging
import mimetypes
import re
from dataclasses import dataclass
from typing import Any, Callable
import aiohttp
from aiohttp import FormData
# Telegram 429 / flood-control retry settings. Telegram returns
# ``parameters.retry_after`` for rate limits; we honor it up to a cap so a
# pathological value can't park the dispatcher for minutes.
_TG_429_MAX_ATTEMPTS = 4
_TG_429_MAX_WAIT_S = 60
_RETRY_AFTER_RE = re.compile(r"retry after (\d+)", re.IGNORECASE)
def _extract_retry_after(result: dict[str, Any]) -> int | None:
"""Return the retry_after seconds from a Telegram error response.
Prefers the structured ``parameters.retry_after`` field; falls back to
parsing the human-readable description (``"Too Many Requests: retry
after N"``) which Telegram has been known to return without the
structured field on some endpoints.
"""
params = result.get("parameters") or {}
ra = params.get("retry_after")
if isinstance(ra, (int, float)) and ra > 0:
return int(ra)
desc = str(result.get("description", ""))
m = _RETRY_AFTER_RE.search(desc)
if m:
try:
return int(m.group(1))
except ValueError:
return None
return None
def _is_rate_limited(status: int, result: dict[str, Any]) -> bool:
return status == 429 or result.get("error_code") == 429
from .cache import TelegramFileCache
from .media import (
TELEGRAM_API_BASE_URL,
@@ -193,40 +227,58 @@ class TelegramClient:
thumbhash: str | None,
) -> NotificationResult:
"""Multipart-upload ``data`` to Telegram and cache the returned file_id."""
form = FormData()
form.add_field("chat_id", chat_id)
form.add_field(kind.form_field, data, filename=filename, content_type=content_type)
form.add_field("parse_mode", parse_mode)
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
def _build_form() -> FormData:
f = FormData()
f.add_field("chat_id", chat_id)
f.add_field(kind.form_field, data, filename=filename, content_type=content_type)
f.add_field("parse_mode", parse_mode)
if caption:
f.add_field("caption", caption)
if reply_to_message_id:
f.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
return f
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/{kind.api_method}"
try:
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
res = result.get("result", {})
file_id = kind.file_id_from_result(res)
if file_id and cache and cache_key:
await cache.async_set(
cache_key, file_id, kind.cache_type,
thumbhash=thumbhash, size=len(data),
for attempt in range(1, _TG_429_MAX_ATTEMPTS + 1):
try:
async with self._session.post(telegram_url, data=_build_form()) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
res = result.get("result", {})
file_id = kind.file_id_from_result(res)
if file_id and cache and cache_key:
await cache.async_set(
cache_key, file_id, kind.cache_type,
thumbhash=thumbhash, size=len(data),
)
return {"success": True, "message_id": res.get("message_id")}
if _is_rate_limited(response.status, result) and attempt < _TG_429_MAX_ATTEMPTS:
retry_after = _extract_retry_after(result) or 1
wait_s = min(retry_after + 1, _TG_429_MAX_WAIT_S)
_LOGGER.warning(
"Telegram %s 429 (retry_after=%ds, attempt %d/%d) bytes=%d — sleeping %ds",
kind.api_method, retry_after, attempt, _TG_429_MAX_ATTEMPTS,
len(data), wait_s,
)
return {"success": True, "message_id": res.get("message_id")}
await asyncio.sleep(wait_s)
continue
_LOGGER.error(
"Telegram %s failed: status=%s code=%s desc=%r bytes=%d",
kind.api_method, response.status, result.get("error_code"),
result.get("description", "Unknown"), len(data),
)
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
_LOGGER.error(
"Telegram %s failed: status=%s code=%s desc=%r bytes=%d",
kind.api_method, response.status, result.get("error_code"),
result.get("description", "Unknown"), len(data),
"Telegram %s transport error (bytes=%d): %s",
kind.api_method, len(data), err, exc_info=True,
)
return {"success": False, "error": result.get("description", "Unknown Telegram error")}
except aiohttp.ClientError as err:
_LOGGER.error(
"Telegram %s transport error (bytes=%d): %s",
kind.api_method, len(data), err, exc_info=True,
)
return {"success": False, "error": str(err)}
return {"success": False, "error": str(err)}
# All attempts exhausted via 429 — should be unreachable, but keep
# an explicit error path so we never return None.
return {"success": False, "error": "Telegram rate limit: max retries exhausted"}
async def send_notification(
self,
@@ -299,12 +351,7 @@ class TelegramClient:
send_large_photos_as_documents,
)
finally:
if typing_task:
typing_task.cancel()
try:
await typing_task
except asyncio.CancelledError:
pass
await self.stop_keepalive(typing_task)
async def send_message(
self,
@@ -368,20 +415,53 @@ class TelegramClient:
return False
def start_chat_action_keepalive(self, chat_id: str, action: str = "typing") -> asyncio.Task:
"""Repeatedly post ``action`` every 4s until the returned task is cancelled.
"""Repeatedly post ``action`` every 4s until stopped.
Telegram chat actions expire after ~5s, so callers that want the hint
to persist through longer work (fetching assets, multi-chunk uploads)
need a keep-alive. Cancel the task in a ``finally`` to stop it.
need a keep-alive.
The returned task carries an attached ``stop_event`` (``asyncio.Event``).
Stop cleanly via :meth:`stop_keepalive` — setting the event before
cancellation prevents the loop from firing one last ``sendChatAction``
after the caller's final user-visible message, which would otherwise
leave a phantom indicator hanging for ~5s.
"""
stop_event = asyncio.Event()
async def action_loop() -> None:
try:
while True:
while not stop_event.is_set():
await self.send_chat_action(chat_id, action)
await asyncio.sleep(4)
try:
await asyncio.wait_for(stop_event.wait(), timeout=4)
except asyncio.TimeoutError:
pass # 4s elapsed, refresh the action
except asyncio.CancelledError:
pass
return asyncio.create_task(action_loop())
task: asyncio.Task = asyncio.create_task(action_loop())
task.stop_event = stop_event # type: ignore[attr-defined]
return task
@staticmethod
async def stop_keepalive(task: asyncio.Task | None) -> None:
"""Stop a keepalive task started by :meth:`start_chat_action_keepalive`.
Sets the attached stop event before cancelling so the loop won't
fire another ``sendChatAction`` after the caller's final message
landed at Telegram.
"""
if task is None:
return
stop_event = getattr(task, "stop_event", None)
if stop_event is not None:
stop_event.set()
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
async def _send_photo(
self, chat_id: str, url: str | None, caption: str | None = None,
@@ -526,12 +606,10 @@ class TelegramClient:
all_message_ids.append(result.get("message_id"))
continue
# Multi-item: download all, build form, send media group
form = FormData()
form.add_field("chat_id", chat_id)
if reply_to_message_id and chunk_idx == 0:
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
# Multi-item: download all, build form, send media group.
# Attachments are recorded separately so we can rebuild FormData on
# 429 retry — aiohttp.FormData is single-use after a request.
attachments: list[tuple[str, bytes, str, str]] = [] # (name, data, filename, content_type)
media_json = []
upload_idx = 0
# Track cache info per media_json entry (in order) so we can map
@@ -646,7 +724,7 @@ class TelegramClient:
attach_name = f"file{upload_idx}"
ct = item.get("content_type") or ("image/jpeg" if media_type == "photo" else "video/mp4")
ext = "jpg" if media_type == "photo" else "mp4"
form.add_field(attach_name, data, filename=f"media_{idx}.{ext}", content_type=ct)
attachments.append((attach_name, data, f"media_{idx}.{ext}", ct))
mij = {"type": media_type, "media": f"attach://{attach_name}"}
upload_idx += 1
# Record cache key so we can store file_id from response
@@ -674,59 +752,86 @@ class TelegramClient:
)
continue
form.add_field("media", json.dumps(media_json))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendMediaGroup"
try:
async with self._session.post(telegram_url, data=form) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
result_msgs = result.get("result", [])
all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
def _build_form() -> FormData:
f = FormData()
f.add_field("chat_id", chat_id)
if reply_to_message_id and chunk_idx == 0:
f.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
for name, payload, filename, ct in attachments:
f.add_field(name, payload, filename=filename, content_type=ct)
f.add_field("media", json.dumps(media_json))
return f
chunk_failed_result: dict[str, Any] | None = None
for attempt in range(1, _TG_429_MAX_ATTEMPTS + 1):
try:
async with self._session.post(telegram_url, data=_build_form()) as response:
result = await response.json()
if response.status == 200 and result.get("ok"):
result_msgs = result.get("result", [])
all_message_ids.extend(msg.get("message_id") for msg in result_msgs)
# Cache file_ids from response — map by position
cache_entries: list[tuple[str, str, str, str | None, int | None]] = []
for i, msg in enumerate(result_msgs):
if i >= len(media_cache_info):
break
info = media_cache_info[i]
if info is None:
continue # was a cache hit, skip
ck, mt, th, sz = info
file_id = None
if msg.get("photo"):
file_id = msg["photo"][-1].get("file_id")
elif msg.get("video"):
file_id = msg["video"].get("file_id")
elif msg.get("document"):
file_id = msg["document"].get("file_id")
if file_id:
cache_entries.append((ck, file_id, mt, th, sz))
if cache_entries:
# All entries in a chunk share the same cache backend
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
if eff_cache:
await eff_cache.async_set_many(cache_entries)
break # chunk succeeded
if _is_rate_limited(response.status, result) and attempt < _TG_429_MAX_ATTEMPTS:
retry_after = _extract_retry_after(result) or 1
wait_s = min(retry_after + 1, _TG_429_MAX_WAIT_S)
_LOGGER.warning(
"Telegram sendMediaGroup 429 (retry_after=%ds, attempt %d/%d) chunk=%d/%d items=%d — sleeping %ds",
retry_after, attempt, _TG_429_MAX_ATTEMPTS,
chunk_idx + 1, len(chunks), len(media_json), wait_s,
)
await asyncio.sleep(wait_s)
continue
# Cache file_ids from response — map by position
cache_entries: list[tuple[str, str, str, str | None, int | None]] = []
for i, msg in enumerate(result_msgs):
if i >= len(media_cache_info):
break
info = media_cache_info[i]
if info is None:
continue # was a cache hit, skip
ck, mt, th, sz = info
file_id = None
if msg.get("photo"):
file_id = msg["photo"][-1].get("file_id")
elif msg.get("video"):
file_id = msg["video"].get("file_id")
elif msg.get("document"):
file_id = msg["document"].get("file_id")
if file_id:
cache_entries.append((ck, file_id, mt, th, sz))
if cache_entries:
# All entries in a chunk share the same cache backend
eff_cache = self._get_cache_for_key(cache_entries[0][0], is_asset_cache_key(cache_entries[0][0]))
if eff_cache:
await eff_cache.async_set_many(cache_entries)
else:
_LOGGER.error(
"Telegram sendMediaGroup failed: status=%s code=%s desc=%r chunk=%d/%d items=%d",
response.status, result.get("error_code"),
result.get("description", "Unknown"),
chunk_idx + 1, len(chunks), len(media_json),
)
return {
chunk_failed_result = {
"success": False,
"error": result.get("description", "Unknown"),
"error_code": result.get("error_code"),
"failed_at_chunk": chunk_idx + 1,
}
except aiohttp.ClientError as err:
_LOGGER.error(
"Telegram sendMediaGroup transport error on chunk %d/%d (%d items): %s",
chunk_idx + 1, len(chunks), len(media_json), err,
exc_info=True,
)
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
break
except aiohttp.ClientError as err:
_LOGGER.error(
"Telegram sendMediaGroup transport error on chunk %d/%d (%d items): %s",
chunk_idx + 1, len(chunks), len(media_json), err,
exc_info=True,
)
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
if chunk_failed_result is not None:
return chunk_failed_result
# Distinguish "posted something" from "posted nothing" so the caller
# can surface an ERROR when a command produced a caption reply but no
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-server"
version = "0.6.3"
version = "0.6.4"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12"
dependencies = [
@@ -1391,6 +1391,40 @@ async def migrate_performance_indexes(engine: AsyncEngine) -> None:
)
async def migrate_chat_action_to_column(engine: AsyncEngine) -> None:
"""Move ``chat_action`` from ``config`` JSON to the dedicated column.
Earlier versions of the frontend stored ``chat_action`` inside
``notification_target.config``; the dedicated ``chat_action`` column
was rarely set or held a stale default. The dispatcher's resolver
overrode the config value with the (stale) column, so a user's UI
choice silently had no effect on outgoing chat actions.
This backfill takes the config value as authoritative (it's what the
UI was writing) and copies it to the column, then strips it from
config so the column becomes the single source of truth. Idempotent:
a second run finds nothing to migrate.
"""
async with engine.begin() as conn:
if not await _has_table(conn, "notification_target"):
return
if not await _has_column(conn, "notification_target", "chat_action"):
return
# Copy config["chat_action"] → column where present.
await conn.execute(text(
"UPDATE notification_target "
"SET chat_action = json_extract(config, '$.chat_action') "
"WHERE json_extract(config, '$.chat_action') IS NOT NULL"
))
# Strip the legacy key so the column is unambiguous going forward.
await conn.execute(text(
"UPDATE notification_target "
"SET config = json_remove(config, '$.chat_action') "
"WHERE json_extract(config, '$.chat_action') IS NOT NULL"
))
logger.info("Migrated chat_action from config JSON to column where present")
# ---------------------------------------------------------------------------
# Schema version tracking — lightweight alternative to Alembic while the
# hand-rolled idempotent migrations remain the source of truth. Gives
@@ -75,6 +75,7 @@ async def lifespan(app: FastAPI):
migrate_notification_slot_locale,
migrate_user_token_version,
migrate_performance_indexes,
migrate_chat_action_to_column,
migrate_schema_version,
)
from .database.snapshot import snapshot_and_prune
@@ -98,6 +99,7 @@ async def lifespan(app: FastAPI):
await migrate_notification_slot_locale(engine)
await migrate_user_token_version(engine)
await migrate_performance_indexes(engine)
await migrate_chat_action_to_column(engine)
await migrate_schema_version(engine)
from .database.seeds import seed_all
await seed_all()
@@ -326,7 +326,11 @@ async def _resolve_target(
receivers.append(build_receiver(target.type, dict(r.config), locale))
target_config = dict(target.config)
# Inject chat_action for Telegram targets
# chat_action lives on the model column — single source of truth.
# Strip any legacy/stale value from config so an old config-stored value
# can't shadow the user's UI choice. When the column is unset, leave the
# key absent so the dispatcher's "typing" fallback applies.
target_config.pop("chat_action", None)
if hasattr(target, 'chat_action') and target.chat_action:
target_config["chat_action"] = target.chat_action
# Inject bot credentials for bot-backed target types
@@ -19,7 +19,6 @@ this module just guarantees every caller gets a properly-wired client.
from __future__ import annotations
import asyncio
import contextlib
from typing import Any, AsyncIterator, Callable
@@ -144,6 +143,4 @@ async def telegram_chat_action(
try:
yield
finally:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
await client.stop_keepalive(task)