feat: telegram commands, app settings, bot polling, webhook handling, UI improvements
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>
This commit is contained in:
@@ -1 +1,17 @@
|
||||
"""Business logic services — scheduler, watcher, notifier."""
|
||||
"""Shared service utilities."""
|
||||
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
|
||||
from ..database.models import ServiceProvider
|
||||
|
||||
|
||||
def make_immich_provider(http_session, provider: ServiceProvider) -> ImmichServiceProvider:
|
||||
"""Create an ImmichServiceProvider from a DB provider model."""
|
||||
config = provider.config or {}
|
||||
return ImmichServiceProvider(
|
||||
http_session,
|
||||
config.get("url", ""),
|
||||
config.get("api_key", ""),
|
||||
config.get("external_domain"),
|
||||
provider.name,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test notification sender."""
|
||||
"""Notification sender — unified send logic for all paths (dispatch + test)."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
@@ -25,41 +26,69 @@ def _get_test_message(locale: str, target_type: str) -> str:
|
||||
return msgs.get(target_type, msgs.get("webhook", "Test"))
|
||||
|
||||
|
||||
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
"""Send a simple test message to a notification target."""
|
||||
async def send_to_target(target: NotificationTarget, message: str) -> dict:
|
||||
"""Send a message to a target, respecting all target config settings.
|
||||
|
||||
This is the SINGLE send path used by dispatch, test, and real-data notifications.
|
||||
"""
|
||||
try:
|
||||
if target.type == "telegram":
|
||||
return await _test_telegram(target, locale)
|
||||
return await _send_telegram(target, message)
|
||||
elif target.type == "webhook":
|
||||
return await _test_webhook(target, locale)
|
||||
return await _send_webhook(target, message)
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
except Exception as e:
|
||||
_LOGGER.error("Test notification failed: %s", e)
|
||||
_LOGGER.error("Send failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _test_telegram(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
async def _send_telegram(target: NotificationTarget, message: str) -> dict:
|
||||
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||
|
||||
bot_token = target.config.get("bot_token")
|
||||
chat_id = target.config.get("chat_id")
|
||||
disable_preview = target.config.get("disable_url_preview", False)
|
||||
|
||||
if not bot_token or not chat_id:
|
||||
return {"success": False, "error": "Missing bot_token or chat_id"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = TelegramClient(session, bot_token)
|
||||
return await client.send_notification(
|
||||
return await client.send_message(
|
||||
chat_id=str(chat_id),
|
||||
caption=_get_test_message(locale, "telegram"),
|
||||
text=message,
|
||||
disable_web_page_preview=bool(disable_preview),
|
||||
)
|
||||
|
||||
|
||||
async def _send_webhook(target: NotificationTarget, message: str, event_type: str = "notification") -> dict:
|
||||
from notify_bridge_core.notifications.webhook.client import WebhookClient
|
||||
|
||||
url = target.config.get("url")
|
||||
headers = target.config.get("headers", {})
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing url in target config"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = WebhookClient(session, url, headers)
|
||||
return await client.send({"message": message, "event_type": event_type})
|
||||
|
||||
|
||||
# --- Public API used by routes ---
|
||||
|
||||
|
||||
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
"""Send a simple test message."""
|
||||
message = _get_test_message(locale, target.type)
|
||||
return await send_to_target(target, message)
|
||||
|
||||
|
||||
async def send_test_template_notification(
|
||||
target: NotificationTarget, slot: str, template_str: str
|
||||
) -> dict:
|
||||
"""Render a template slot with sample data and send it to a target."""
|
||||
"""Render a template slot with sample data and send."""
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from ..api.template_configs import _SAMPLE_CONTEXT
|
||||
from .sample_context import _SAMPLE_CONTEXT
|
||||
|
||||
if not template_str:
|
||||
return await send_test_notification(target)
|
||||
@@ -71,53 +100,7 @@ async def send_test_template_notification(
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Template render error: {e}"}
|
||||
|
||||
try:
|
||||
if target.type == "telegram":
|
||||
return await _test_telegram_with_message(target, message)
|
||||
elif target.type == "webhook":
|
||||
return await _test_webhook_with_message(target, message)
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
except Exception as e:
|
||||
_LOGGER.error("Test template notification failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
|
||||
async def _test_telegram_with_message(target: NotificationTarget, message: str) -> dict:
|
||||
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||
bot_token = target.config.get("bot_token")
|
||||
chat_id = target.config.get("chat_id")
|
||||
if not bot_token or not chat_id:
|
||||
return {"success": False, "error": "Missing bot_token or chat_id"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = TelegramClient(session, bot_token)
|
||||
return await client.send_notification(chat_id=str(chat_id), caption=message)
|
||||
|
||||
|
||||
async def _test_webhook_with_message(target: NotificationTarget, message: str) -> dict:
|
||||
from notify_bridge_core.notifications.webhook.client import WebhookClient
|
||||
url = target.config.get("url")
|
||||
headers = target.config.get("headers", {})
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing url in target config"}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = WebhookClient(session, url, headers)
|
||||
return await client.send({"message": message, "event_type": "test_template"})
|
||||
|
||||
|
||||
async def _test_webhook(target: NotificationTarget, locale: str = "en") -> dict:
|
||||
from notify_bridge_core.notifications.webhook.client import WebhookClient
|
||||
|
||||
url = target.config.get("url")
|
||||
headers = target.config.get("headers", {})
|
||||
if not url:
|
||||
return {"success": False, "error": "Missing url in target config"}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
client = WebhookClient(session, url, headers)
|
||||
return await client.send({
|
||||
"message": _get_test_message(locale, "webhook"),
|
||||
"event_type": "test",
|
||||
})
|
||||
return await send_to_target(target, message)
|
||||
|
||||
|
||||
async def send_real_data_notification(
|
||||
@@ -129,20 +112,19 @@ async def send_real_data_notification(
|
||||
collection_ids: list[str],
|
||||
date_format: str = "%d.%m.%Y, %H:%M UTC",
|
||||
date_only_format: str = "%d.%m.%Y",
|
||||
memory_source: str = "albums",
|
||||
) -> dict:
|
||||
"""Fetch real data from provider, render template, and send notification."""
|
||||
from datetime import datetime, timezone
|
||||
"""Fetch real data from provider, render template, and send."""
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
|
||||
if not template_str:
|
||||
return {"success": False, "error": f"No template configured for {test_type}"}
|
||||
|
||||
# Fetch real data from provider
|
||||
ctx: dict = {}
|
||||
try:
|
||||
ctx = await _build_real_context(
|
||||
provider_type, provider_config, collection_ids,
|
||||
test_type, date_format, date_only_format,
|
||||
memory_source=memory_source,
|
||||
)
|
||||
except Exception as e:
|
||||
_LOGGER.error("Failed to fetch real data for test: %s", e)
|
||||
@@ -152,7 +134,6 @@ async def send_real_data_notification(
|
||||
ctx["date_format"] = date_format
|
||||
ctx["date_only_format"] = date_only_format
|
||||
|
||||
# Render template
|
||||
try:
|
||||
env = SandboxedEnvironment(autoescape=False)
|
||||
tmpl = env.from_string(template_str)
|
||||
@@ -160,16 +141,7 @@ async def send_real_data_notification(
|
||||
except Exception as e:
|
||||
return {"success": False, "error": f"Template render error: {e}"}
|
||||
|
||||
# Send
|
||||
try:
|
||||
if target.type == "telegram":
|
||||
return await _test_telegram_with_message(target, message)
|
||||
elif target.type == "webhook":
|
||||
return await _test_webhook_with_message(target, message)
|
||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||
except Exception as e:
|
||||
_LOGGER.error("Test notification failed: %s", e)
|
||||
return {"success": False, "error": str(e)}
|
||||
return await send_to_target(target, message)
|
||||
|
||||
|
||||
async def _build_real_context(
|
||||
@@ -179,6 +151,7 @@ async def _build_real_context(
|
||||
test_type: str,
|
||||
date_format: str,
|
||||
date_only_format: str,
|
||||
memory_source: str = "albums",
|
||||
) -> dict:
|
||||
"""Build template context from real provider data."""
|
||||
from datetime import datetime, timezone
|
||||
@@ -200,16 +173,77 @@ async def _build_real_context(
|
||||
if not connected:
|
||||
raise RuntimeError("Failed to connect to Immich")
|
||||
|
||||
# Fetch album data for all tracked collections
|
||||
collections = []
|
||||
all_assets = []
|
||||
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
|
||||
|
||||
# --- Native Immich memories ---
|
||||
if test_type == "memory" and memory_source == "native":
|
||||
memories = await immich.client.get_memories()
|
||||
all_assets: list[dict[str, Any]] = []
|
||||
tracked_ids = set(collection_ids) if collection_ids else None
|
||||
for mem in memories:
|
||||
for raw_asset in mem.get("assets", []):
|
||||
asset_id = raw_asset.get("id", "")
|
||||
# Optional album filtering: keep only assets in tracked albums
|
||||
if tracked_ids:
|
||||
asset_albums = raw_asset.get("albums", [])
|
||||
if not any(a.get("id") in tracked_ids for a in asset_albums):
|
||||
continue
|
||||
exif = raw_asset.get("exifInfo") or {}
|
||||
people_raw = raw_asset.get("people", [])
|
||||
all_assets.append({
|
||||
"id": asset_id,
|
||||
"filename": raw_asset.get("originalFileName", ""),
|
||||
"type": (raw_asset.get("type") or "IMAGE").upper(),
|
||||
"created_at": raw_asset.get("fileCreatedAt", raw_asset.get("createdAt", "")),
|
||||
"owner": "",
|
||||
"description": exif.get("description", "") or raw_asset.get("description", "") or "",
|
||||
"people": [p.get("name", "") for p in people_raw if p.get("name")],
|
||||
"is_favorite": raw_asset.get("isFavorite", False),
|
||||
"rating": exif.get("rating"),
|
||||
"city": exif.get("city", "") or "",
|
||||
"state": exif.get("state", "") or "",
|
||||
"country": exif.get("country", "") or "",
|
||||
"public_url": "",
|
||||
"url": f"{ext_domain.rstrip('/')}/api/assets/{asset_id}/original",
|
||||
"photo_url": f"{ext_domain.rstrip('/')}/api/assets/{asset_id}/thumbnail",
|
||||
"year": mem.get("data", {}).get("year"),
|
||||
})
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
ctx: dict[str, Any] = {
|
||||
"date": now.strftime(date_only_format),
|
||||
"timestamp": now.isoformat(),
|
||||
"service_name": "Immich",
|
||||
"service_type": "immich",
|
||||
"collections": [],
|
||||
"albums": [],
|
||||
"assets": all_assets,
|
||||
"common_date": "",
|
||||
"common_location": "",
|
||||
"collection_name": "", "album_name": "",
|
||||
"public_url": "", "album_url": "",
|
||||
"shared": False, "photo_count": 0, "video_count": 0, "owner": "",
|
||||
}
|
||||
people: set[str] = set()
|
||||
for a in all_assets:
|
||||
people.update(a.get("people", []))
|
||||
ctx["people"] = list(people)
|
||||
ctx["has_videos"] = any(a.get("type") == "VIDEO" for a in all_assets)
|
||||
ctx["has_photos"] = any(a.get("type") == "IMAGE" for a in all_assets)
|
||||
ctx["added_count"] = len(all_assets)
|
||||
ctx["added_assets"] = all_assets
|
||||
ctx["protected_url"] = ""
|
||||
return ctx
|
||||
|
||||
# --- Album-based asset collection (default path) ---
|
||||
collections: list[dict[str, Any]] = []
|
||||
all_assets: list[dict[str, Any]] = []
|
||||
|
||||
for album_id in collection_ids:
|
||||
album = await immich.client.get_album(album_id)
|
||||
if not album:
|
||||
continue
|
||||
|
||||
# Get shared link for public URL
|
||||
shared_links = await immich.client.get_shared_links(album_id)
|
||||
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
|
||||
album_public_url = ""
|
||||
@@ -229,7 +263,6 @@ async def _build_real_context(
|
||||
"owner": album.owner,
|
||||
})
|
||||
|
||||
# Collect assets (limited sample)
|
||||
for asset_id, asset in list(album.assets.items())[:10]:
|
||||
asset_public_url = f"{album_public_url}/photos/{asset_id}" if album_public_url else ""
|
||||
all_assets.append({
|
||||
@@ -250,60 +283,42 @@ async def _build_real_context(
|
||||
"photo_url": f"{ext_domain.rstrip('/')}/api/assets/{asset.id}/thumbnail",
|
||||
})
|
||||
|
||||
# Build context based on test type
|
||||
now = datetime.now(timezone.utc)
|
||||
ctx: dict = {
|
||||
ctx: dict[str, Any] = {
|
||||
"date": now.strftime(date_only_format),
|
||||
"timestamp": now.isoformat(),
|
||||
"service_name": "Immich",
|
||||
"service_type": "immich",
|
||||
"collections": collections,
|
||||
"albums": collections, # alias
|
||||
"albums": collections,
|
||||
"assets": all_assets,
|
||||
"common_date": "",
|
||||
"common_location": "",
|
||||
}
|
||||
|
||||
# Common date/location for assets
|
||||
if len(all_assets) > 1:
|
||||
dates = set()
|
||||
for a in all_assets:
|
||||
ca = a.get("created_at", "")
|
||||
if ca:
|
||||
dates.add(ca[:10])
|
||||
dates = {a.get("created_at", "")[:10] for a in all_assets if a.get("created_at")}
|
||||
if len(dates) == 1:
|
||||
try:
|
||||
ctx["common_date"] = datetime.fromisoformat(dates.pop()).strftime(date_only_format)
|
||||
except (ValueError, TypeError):
|
||||
ctx["common_date"] = ""
|
||||
else:
|
||||
ctx["common_date"] = ""
|
||||
pass
|
||||
|
||||
locations = set()
|
||||
for a in all_assets:
|
||||
city = a.get("city", "")
|
||||
country = a.get("country", "")
|
||||
if city:
|
||||
locations.add(f"{city}, {country}" if country else city)
|
||||
else:
|
||||
locations.add("")
|
||||
locations.add(f"{city}, {country}" if city and country else city or "")
|
||||
if len(locations) == 1 and "" not in locations:
|
||||
ctx["common_location"] = locations.pop()
|
||||
else:
|
||||
ctx["common_location"] = ""
|
||||
else:
|
||||
ctx["common_date"] = ""
|
||||
ctx["common_location"] = ""
|
||||
|
||||
# Add first collection details as top-level for periodic-style templates
|
||||
if collections:
|
||||
first = collections[0]
|
||||
ctx.update({
|
||||
"collection_name": first["name"],
|
||||
"album_name": first["name"],
|
||||
"public_url": first.get("public_url", ""),
|
||||
"album_url": first.get("url", ""),
|
||||
"collection_name": first["name"], "album_name": first["name"],
|
||||
"public_url": first.get("public_url", ""), "album_url": first.get("url", ""),
|
||||
"shared": first.get("shared", False),
|
||||
"photo_count": first.get("photo_count", 0),
|
||||
"video_count": first.get("video_count", 0),
|
||||
"photo_count": first.get("photo_count", 0), "video_count": first.get("video_count", 0),
|
||||
"owner": first.get("owner", ""),
|
||||
})
|
||||
else:
|
||||
@@ -313,8 +328,7 @@ async def _build_real_context(
|
||||
"shared": False, "photo_count": 0, "video_count": 0, "owner": "",
|
||||
})
|
||||
|
||||
# People across all assets
|
||||
people = set()
|
||||
people: set[str] = set()
|
||||
for a in all_assets:
|
||||
people.update(a.get("people", []))
|
||||
ctx["people"] = list(people)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Sample template context for previews and test notifications."""
|
||||
|
||||
# Sample asset matching what build_asset_detail() actually returns
|
||||
_SAMPLE_ASSET = {
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"filename": "IMG_001.jpg",
|
||||
"type": "IMAGE",
|
||||
"created_at": "2026-03-19T10:30:00",
|
||||
"owner": "Alice",
|
||||
"owner_id": "user-uuid-1",
|
||||
"description": "Family picnic",
|
||||
"people": ["Alice", "Bob"],
|
||||
"is_favorite": True,
|
||||
"rating": 5,
|
||||
"latitude": 48.8566,
|
||||
"longitude": 2.3522,
|
||||
"city": "Paris",
|
||||
"state": "Ile-de-France",
|
||||
"country": "France",
|
||||
"url": "https://immich.example.com/photos/abc123",
|
||||
"public_url": "https://immich.example.com/share/abc123/photos/a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"download_url": "https://immich.example.com/api/assets/abc123/original",
|
||||
"photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
|
||||
}
|
||||
|
||||
_SAMPLE_VIDEO_ASSET = {
|
||||
**_SAMPLE_ASSET,
|
||||
"id": "d4e5f6a7-b8c9-0123-defg-456789abcdef",
|
||||
"filename": "VID_002.mp4",
|
||||
"type": "VIDEO",
|
||||
"is_favorite": False,
|
||||
"rating": None,
|
||||
"photo_url": None,
|
||||
"public_url": "https://immich.example.com/share/abc123/photos/d4e5f6a7-b8c9-0123-defg-456789abcdef",
|
||||
"playback_url": "https://immich.example.com/api/assets/def456/video",
|
||||
}
|
||||
|
||||
_SAMPLE_COLLECTION = {
|
||||
"name": "Family Photos",
|
||||
"url": "https://immich.example.com/share/abc123",
|
||||
"public_url": "https://immich.example.com/share/abc123",
|
||||
"asset_count": 42,
|
||||
"shared": True,
|
||||
}
|
||||
|
||||
# Full context covering ALL possible template variables
|
||||
_SAMPLE_CONTEXT = {
|
||||
# Core event fields (always present)
|
||||
"collection_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
|
||||
"collection_name": "Family Photos",
|
||||
"collection_url": "https://immich.example.com/share/abc123",
|
||||
"event_type": "assets_added",
|
||||
"timestamp": "2026-03-19T10:30:00+00:00",
|
||||
"service_name": "Immich",
|
||||
"service_type": "immich",
|
||||
# Immich aliases (always present alongside collection_*)
|
||||
"album_name": "Family Photos",
|
||||
"album_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
|
||||
"old_album_name": "Old Album",
|
||||
"new_album_name": "New Album",
|
||||
"change_type": "assets_added",
|
||||
"added_count": 3,
|
||||
"removed_count": 1,
|
||||
"added_assets": [_SAMPLE_ASSET, _SAMPLE_VIDEO_ASSET],
|
||||
"removed_assets": ["asset-id-1", "asset-id-2"],
|
||||
"people": ["Alice", "Bob"],
|
||||
"shared": True,
|
||||
"target_type": "telegram",
|
||||
"has_videos": True,
|
||||
"has_photos": True,
|
||||
# Rename fields (always present, empty for non-rename events)
|
||||
"old_name": "Old Album",
|
||||
"new_name": "New Album",
|
||||
"old_shared": False,
|
||||
"new_shared": True,
|
||||
# Public share URLs (may be empty if no shared link exists)
|
||||
"public_url": "https://immich.example.com/share/abc123",
|
||||
"protected_url": "",
|
||||
"album_url": "https://immich.example.com/albums/b2eeeaa4",
|
||||
# Common date/location (set when all assets share the same value)
|
||||
"common_date": "19.03.2026",
|
||||
"common_location": "Paris, France",
|
||||
# Date format strings (from template config)
|
||||
"date_format": "%d.%m.%Y, %H:%M UTC",
|
||||
"date_only_format": "%d.%m.%Y",
|
||||
# Scheduled/periodic variables (for those templates)
|
||||
"collections": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
|
||||
"albums": [_SAMPLE_COLLECTION, {**_SAMPLE_COLLECTION, "name": "Vacation 2025", "asset_count": 120}],
|
||||
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "id": "x1y2z3", "filename": "IMG_002.jpg", "city": "London", "country": "UK", "public_url": "https://immich.example.com/share/abc123/photos/x1y2z3"}],
|
||||
"date": "2026-03-19",
|
||||
"photo_count": 30,
|
||||
"video_count": 5,
|
||||
"owner": "Alice",
|
||||
}
|
||||
@@ -26,6 +26,10 @@ async def start_scheduler() -> None:
|
||||
|
||||
await _load_tracker_jobs()
|
||||
|
||||
# Start Telegram bot polling for bots in polling mode
|
||||
from .telegram_poller import start_bot_polling
|
||||
await start_bot_polling()
|
||||
|
||||
|
||||
async def _load_tracker_jobs() -> None:
|
||||
"""Load enabled trackers and schedule polling jobs."""
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Telegram service utilities — chat persistence helpers."""
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..database.models import TelegramChat
|
||||
|
||||
|
||||
async def save_chat_from_webhook(
|
||||
session: AsyncSession, bot_id: int, chat_data: dict
|
||||
) -> None:
|
||||
"""Save or update a chat entry from an incoming webhook message.
|
||||
|
||||
Called by the webhook handler to auto-persist chats.
|
||||
"""
|
||||
chat_id = str(chat_data.get("id", ""))
|
||||
if not chat_id:
|
||||
return
|
||||
|
||||
result = await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_id,
|
||||
TelegramChat.chat_id == chat_id,
|
||||
)
|
||||
)
|
||||
existing = result.first()
|
||||
|
||||
title = chat_data.get("title") or (
|
||||
chat_data.get("first_name", "") + (" " + chat_data.get("last_name", "")).strip()
|
||||
)
|
||||
|
||||
if existing:
|
||||
existing.title = title
|
||||
existing.username = chat_data.get("username", existing.username)
|
||||
session.add(existing)
|
||||
else:
|
||||
session.add(TelegramChat(
|
||||
bot_id=bot_id,
|
||||
chat_id=chat_id,
|
||||
title=title,
|
||||
chat_type=chat_data.get("type", "private"),
|
||||
username=chat_data.get("username", ""),
|
||||
))
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Telegram long-polling service for bots in polling mode.
|
||||
|
||||
Uses APScheduler to run getUpdates periodically for each bot
|
||||
with update_mode == "polling". Processes updates identically
|
||||
to the webhook handler (auto-save chat, dispatch commands).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_URL
|
||||
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import TelegramBot
|
||||
from ..services.telegram import save_chat_from_webhook
|
||||
from .scheduler import get_scheduler
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Track last update_id per bot to use as offset
|
||||
_last_update_id: dict[int, int] = {}
|
||||
|
||||
|
||||
async def start_bot_polling() -> None:
|
||||
"""Schedule polling jobs for all bots with update_mode == 'polling'."""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
select(TelegramBot).where(TelegramBot.update_mode == "polling")
|
||||
)
|
||||
bots = result.all()
|
||||
|
||||
for bot in bots:
|
||||
schedule_bot_polling(bot.id)
|
||||
|
||||
|
||||
def schedule_bot_polling(bot_id: int) -> None:
|
||||
"""Add a polling job for a bot (idempotent)."""
|
||||
scheduler = get_scheduler()
|
||||
job_id = f"telegram_poll_{bot_id}"
|
||||
if scheduler.get_job(job_id):
|
||||
return
|
||||
scheduler.add_job(
|
||||
_poll_bot,
|
||||
"interval",
|
||||
seconds=3,
|
||||
id=job_id,
|
||||
args=[bot_id],
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
)
|
||||
_LOGGER.info("Started polling for bot %d", bot_id)
|
||||
|
||||
|
||||
def unschedule_bot_polling(bot_id: int) -> None:
|
||||
"""Remove polling job for a bot."""
|
||||
scheduler = get_scheduler()
|
||||
job_id = f"telegram_poll_{bot_id}"
|
||||
if scheduler.get_job(job_id):
|
||||
scheduler.remove_job(job_id)
|
||||
_LOGGER.info("Stopped polling for bot %d", bot_id)
|
||||
|
||||
|
||||
async def _poll_bot(bot_id: int) -> None:
|
||||
"""Fetch updates from Telegram and process them."""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
bot = await session.get(TelegramBot, bot_id)
|
||||
if not bot or bot.update_mode != "polling":
|
||||
unschedule_bot_polling(bot_id)
|
||||
return
|
||||
|
||||
offset = _last_update_id.get(bot_id, 0)
|
||||
params: dict[str, Any] = {
|
||||
"timeout": 0,
|
||||
"limit": 50,
|
||||
"allowed_updates": '["message"]',
|
||||
}
|
||||
if offset:
|
||||
params["offset"] = offset + 1
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.get(
|
||||
f"{TELEGRAM_API_BASE_URL}{bot.token}/getUpdates",
|
||||
params=params,
|
||||
timeout=aiohttp.ClientTimeout(total=10),
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
if not data.get("ok"):
|
||||
return
|
||||
updates = data.get("result", [])
|
||||
except Exception as e:
|
||||
_LOGGER.debug("Polling error for bot %d: %s", bot_id, e)
|
||||
return
|
||||
|
||||
if not updates:
|
||||
return
|
||||
|
||||
# Update offset to latest
|
||||
_last_update_id[bot_id] = updates[-1]["update_id"]
|
||||
|
||||
# Process each update
|
||||
from ..commands.handler import handle_command, send_media_group
|
||||
|
||||
for update in updates:
|
||||
message = update.get("message")
|
||||
if not message:
|
||||
continue
|
||||
|
||||
chat_info = message.get("chat", {})
|
||||
chat_id = str(chat_info.get("id", ""))
|
||||
text = message.get("text", "")
|
||||
|
||||
if not chat_id:
|
||||
continue
|
||||
|
||||
# Auto-persist chat
|
||||
try:
|
||||
async with AsyncSession(engine) as save_session:
|
||||
await save_chat_from_webhook(save_session, bot.id, chat_info)
|
||||
await save_session.commit()
|
||||
except Exception:
|
||||
_LOGGER.debug("Failed to auto-save chat %s", chat_id, exc_info=True)
|
||||
|
||||
# Dispatch commands
|
||||
if text and text.startswith("/"):
|
||||
try:
|
||||
cmd_response = await handle_command(bot, chat_id, text)
|
||||
if cmd_response is not None:
|
||||
if isinstance(cmd_response, list):
|
||||
await send_media_group(bot.token, chat_id, cmd_response)
|
||||
else:
|
||||
await _send_reply(bot.token, chat_id, cmd_response)
|
||||
except Exception:
|
||||
_LOGGER.error("Error handling command from bot %d", bot_id, exc_info=True)
|
||||
|
||||
|
||||
async def _send_reply(bot_token: str, chat_id: str, text: str) -> None:
|
||||
"""Send a text reply via Telegram Bot API."""
|
||||
async with aiohttp.ClientSession() as http:
|
||||
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/sendMessage"
|
||||
payload: dict[str, Any] = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
|
||||
try:
|
||||
async with http.post(url, json=payload) as resp:
|
||||
if resp.status != 200:
|
||||
result = await resp.json()
|
||||
if "parse" in str(result.get("description", "")).lower():
|
||||
payload.pop("parse_mode", None)
|
||||
await http.post(url, json=payload)
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Failed to send Telegram reply: %s", err)
|
||||
@@ -12,7 +12,8 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from notify_bridge_core.models.events import ServiceEvent
|
||||
from notify_bridge_core.notifications.dispatcher import NotificationDispatcher, TargetConfig
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
from notify_bridge_core.notifications.telegram.cache import TelegramFileCache
|
||||
from notify_bridge_core.storage import JsonFileBackend
|
||||
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import (
|
||||
@@ -28,6 +29,29 @@ from ..database.models import (
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Module-level Telegram file caches — shared across dispatches for reuse
|
||||
_url_cache: TelegramFileCache | None = None
|
||||
_asset_cache: TelegramFileCache | None = None
|
||||
|
||||
|
||||
async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFileCache | None]:
|
||||
"""Lazily initialize shared Telegram file caches using NOTIFY_BRIDGE_DATA_DIR."""
|
||||
global _url_cache, _asset_cache
|
||||
if _url_cache is not None:
|
||||
return _url_cache, _asset_cache
|
||||
import os
|
||||
from pathlib import Path
|
||||
data_dir = os.environ.get("NOTIFY_BRIDGE_DATA_DIR")
|
||||
if not data_dir:
|
||||
return None, None
|
||||
cache_dir = Path(data_dir) / "cache"
|
||||
_url_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_url_cache.json"))
|
||||
_asset_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_asset_cache.json"))
|
||||
await _url_cache.async_load()
|
||||
await _asset_cache.async_load()
|
||||
_LOGGER.info("Initialized Telegram file caches in %s", cache_dir)
|
||||
return _url_cache, _asset_cache
|
||||
|
||||
|
||||
def _in_quiet_hours(start: str | None, end: str | None) -> bool:
|
||||
"""Check if the current UTC time is within the quiet hours window."""
|
||||
@@ -131,6 +155,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
new_state: dict[str, Any] = {}
|
||||
|
||||
if provider_type == "immich":
|
||||
from notify_bridge_core.providers.immich import ImmichServiceProvider
|
||||
async with aiohttp.ClientSession() as http_session:
|
||||
immich = ImmichServiceProvider(
|
||||
http_session,
|
||||
@@ -208,7 +233,8 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
if events and link_data:
|
||||
dispatcher = NotificationDispatcher()
|
||||
url_cache, asset_cache = await _get_telegram_caches()
|
||||
dispatcher = NotificationDispatcher(url_cache=url_cache, asset_cache=asset_cache)
|
||||
for event in events:
|
||||
_LOGGER.info(
|
||||
"Dispatching event %s for %s (added=%d removed=%d)",
|
||||
@@ -239,7 +265,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
config=ld["target_config"],
|
||||
template_slots=slots,
|
||||
date_format=tmpl.date_format if tmpl else "%d.%m.%Y, %H:%M UTC",
|
||||
date_only_format=tmpl.date_only_format if tmpl and hasattr(tmpl, "date_only_format") else "%d.%m.%Y",
|
||||
date_only_format=tmpl.date_only_format if tmpl and tmpl.date_only_format else "%d.%m.%Y",
|
||||
provider_api_key=provider_config.get("api_key"),
|
||||
provider_internal_url=provider_config.get("url", ""),
|
||||
provider_external_url=provider_config.get("external_domain", ""),
|
||||
|
||||
Reference in New Issue
Block a user