feat: comprehensive code review fixes + receivers-only architecture

Security:
- Refuse startup with default secret_key in production (was just logging)
- Settings endpoint now requires admin role
- Password validation on initial setup
- DOM-based HTML sanitizer replaces regex in template previews
- Add *.log to .gitignore

Performance & reliability:
- Token refresh deduplication prevents race condition on concurrent 401s
- Theme media query listener registered once (no leak)
- IconPicker uses $derived instead of function call per render
- Snackbar uses single-batch state update instead of while loop
- Replace 11 inline hover handlers with CSS :hover in layout

Architecture - receivers-only:
- Delivery endpoints (chat_id, email, url, room_id, topic) now stored
  exclusively in TargetReceiver rows, never in target.config
- Migration extracts existing delivery fields to receiver rows
- Notifier and dispatcher remove all config fallbacks
- Frontend targets page shows receivers list per target with
  add/remove/toggle/test per receiver
- Single-receiver test endpoint: POST /targets/{id}/receivers/{id}/test

Code quality:
- Extract AuthLayout.svelte from login/setup (150 lines CSS dedup)
- Split telegram-bots page (754→51 lines + 3 tab components)
- Split notification-trackers page (547→432 lines + 4 components)
- Deduplicate _send_reply into shared handler.send_reply()
- Add locale column to template models, replace name-based detection
- Fix delete_notification_tracker dead protection check
- Fix check_telegram_bot query (filter by type, remove bogus OR)
- Add graceful scheduler shutdown in lifespan
- Consistent /bots?tab=telegram URLs across all nav links

i18n:
- Error page, chat actions, target types, provider types internationalized
- All new receiver UI strings in EN + RU
This commit is contained in:
2026-03-22 02:19:31 +03:00
parent b525e3e7f4
commit 751097b347
43 changed files with 2584 additions and 1685 deletions
@@ -8,7 +8,7 @@ from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..auth.dependencies import require_admin
from ..database.engine import get_session
from ..database.models import AppSetting, TelegramBot, User
@@ -51,7 +51,7 @@ class SettingsUpdate(BaseModel):
@router.get("")
async def get_settings(
user: User = Depends(get_current_user),
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Return all app settings."""
@@ -64,7 +64,7 @@ async def get_settings(
@router.put("")
async def update_settings(
body: SettingsUpdate,
user: User = Depends(get_current_user),
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
@@ -173,7 +173,11 @@ async def _find_system_default_template(
)
)
templates = result.all()
# Match by locale suffix in name, e.g. "(EN)" or "(RU)"
# Match by locale column first, fall back to name suffix
locale_lower = locale_upper.lower()
for tpl in templates:
if tpl.locale == locale_lower:
return tpl
for tpl in templates:
if f"({locale_upper})" in tpl.name:
return tpl
@@ -47,12 +47,12 @@ async def check_telegram_bot(session: AsyncSession, bot_id: int) -> list[str]:
"""Check if a TelegramBot is used by any targets or command listeners."""
consumers = []
# Check notification targets with this bot in config
result = await session.exec(select(NotificationTarget))
result = await session.exec(
select(NotificationTarget).where(NotificationTarget.type == "telegram")
)
for t in result.all():
if t.config.get("bot_id") == bot_id or t.config.get("bot_token"):
# Need to verify it's actually this bot
if t.config.get("bot_id") == bot_id:
consumers.append(f"Target: {t.name}")
if t.config.get("bot_id") == bot_id:
consumers.append(f"Target: {t.name}")
# Check command tracker listeners
result = await session.exec(
select(CommandTrackerListener).where(
@@ -111,9 +111,7 @@ async def delete_notification_tracker(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
from .delete_protection import check_notification_tracker, raise_if_used
tracker = await _get_user_tracker(session, tracker_id, user.id)
raise_if_used(await check_notification_tracker(session, tracker.id), tracker.name)
# Delete associated tracker-target links
result = await session.exec(
select(NotificationTrackerTarget).where(NotificationTrackerTarget.tracker_id == tracker_id)
@@ -3,7 +3,7 @@
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -11,6 +11,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, TargetReceiver, User
from ..services.notifier import send_to_receiver
_LOGGER = logging.getLogger(__name__)
@@ -117,6 +118,25 @@ async def update_receiver(
return _response(receiver)
@router.post("/{receiver_id}/test")
async def test_receiver(
target_id: int,
receiver_id: int,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test notification to a single receiver."""
target = await _get_user_target(session, target_id, user.id)
receiver = await session.get(TargetReceiver, receiver_id)
if not receiver or receiver.target_id != target_id:
raise HTTPException(status_code=404, detail="Receiver not found")
from ..services.notifier import _get_test_message
message = _get_test_message(locale, target.type)
return await send_to_receiver(target, dict(receiver.config), message)
@router.delete("/{receiver_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_receiver(
target_id: int,
@@ -12,11 +12,46 @@ from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, NotificationTrackerTarget, TargetReceiver, TelegramBot, TelegramChat, User
from ..services.notifier import send_test_notification
from .target_receivers import _receiver_key
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/targets", tags=["targets"])
# Delivery fields that belong in TargetReceiver, NOT in target.config
_DELIVERY_FIELDS: dict[str, str] = {
"telegram": "chat_id",
"webhook": "url",
"email": "email",
"discord": "webhook_url",
"slack": "webhook_url",
"ntfy": "topic",
"matrix": "room_id",
}
def _extract_delivery_fields(target_type: str, config: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
"""Split config into (clean_config, receiver_config).
Returns the target config with delivery fields removed,
and a receiver config dict (empty if no delivery field found).
"""
field = _DELIVERY_FIELDS.get(target_type)
if not field:
return dict(config), {}
clean = dict(config)
receiver_cfg: dict[str, Any] = {}
value = clean.pop(field, None)
if value:
receiver_cfg[field] = value
# For webhook, also move headers to receiver config
if target_type == "webhook" and "headers" in clean:
receiver_cfg["headers"] = clean.pop("headers")
return clean, receiver_cfg
class TargetCreate(BaseModel):
type: str # "telegram" or "webhook"
@@ -44,32 +79,38 @@ async def list_targets(
)
targets = result.all()
# Resolve chat names for telegram targets
chat_names: dict[str, str] = {}
for tgt in targets:
if tgt.type == "telegram" and tgt.config.get("chat_id"):
bot_id = tgt.config.get("bot_id")
chat_id = str(tgt.config["chat_id"])
if bot_id:
chat_result = await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id == chat_id,
)
)
chat = chat_result.first()
if chat:
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
# Load receiver counts
receiver_counts: dict[int, int] = {}
# Load receivers for each target
target_receivers: dict[int, list[TargetReceiver]] = {}
for tgt in targets:
recv_result = await session.exec(
select(TargetReceiver).where(TargetReceiver.target_id == tgt.id)
)
receiver_counts[tgt.id] = len(recv_result.all())
target_receivers[tgt.id] = list(recv_result.all())
return [_target_response(t, chat_names, receiver_counts.get(t.id, 0)) for t in targets]
# Resolve chat names from receivers for telegram targets
chat_names: dict[str, str] = {}
for tgt in targets:
if tgt.type == "telegram":
bot_id = tgt.config.get("bot_id")
if not bot_id:
continue
for recv in target_receivers.get(tgt.id, []):
chat_id = str(recv.config.get("chat_id", ""))
if chat_id:
chat_result = await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id == chat_id,
)
)
chat = chat_result.first()
if chat:
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
return [
_target_response(t, chat_names, target_receivers.get(t.id, []))
for t in targets
]
@router.post("", status_code=status.HTTP_201_CREATED)
@@ -85,15 +126,33 @@ async def create_target(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Type must be one of: {', '.join(valid_types)}",
)
# Extract delivery fields from config — they go into a TargetReceiver
clean_config, receiver_cfg = _extract_delivery_fields(body.type, body.config)
target = NotificationTarget(
user_id=user.id,
type=body.type,
name=body.name,
icon=body.icon,
config=body.config,
config=clean_config,
chat_action=body.chat_action,
)
session.add(target)
await session.flush() # get target.id
# Auto-create a receiver if delivery fields were present
if receiver_cfg:
key = _receiver_key(body.type, receiver_cfg)
receiver = TargetReceiver(
target_id=target.id,
name=body.name,
config=receiver_cfg,
receiver_key=key,
enabled=True,
)
session.add(receiver)
await session.commit()
await session.refresh(target)
return {"id": target.id, "type": target.type, "name": target.name}
@@ -107,7 +166,11 @@ async def get_target(
):
"""Get a specific notification target."""
target = await _get_user_target(session, target_id, user.id)
return _target_response(target)
recv_result = await session.exec(
select(TargetReceiver).where(TargetReceiver.target_id == target.id)
)
receivers = list(recv_result.all())
return _target_response(target, receivers=receivers)
@router.put("/{target_id}")
@@ -119,8 +182,38 @@ async def update_target(
):
"""Update a notification target."""
target = await _get_user_target(session, target_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(target, field, value)
updates = body.model_dump(exclude_unset=True)
# If config is being updated, extract any delivery fields
if "config" in updates and updates["config"] is not None:
clean_config, receiver_cfg = _extract_delivery_fields(target.type, updates["config"])
updates["config"] = clean_config
# Update or create receiver if delivery fields were present
if receiver_cfg:
key = _receiver_key(target.type, receiver_cfg)
existing_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.receiver_key == key,
)
)
existing_recv = existing_result.first()
if existing_recv:
existing_recv.config = receiver_cfg
session.add(existing_recv)
else:
receiver = TargetReceiver(
target_id=target.id,
name=target.name,
config=receiver_cfg,
receiver_key=key,
enabled=True,
)
session.add(receiver)
for field_name, value in updates.items():
setattr(target, field_name, value)
session.add(target)
await session.commit()
await session.refresh(target)
@@ -160,7 +253,12 @@ async def test_target(
return result
def _target_response(target: NotificationTarget, chat_names: dict[str, str] | None = None, receiver_count: int = 0) -> dict:
def _target_response(
target: NotificationTarget,
chat_names: dict[str, str] | None = None,
receivers: list[TargetReceiver] | None = None,
) -> dict:
recv_list = receivers or []
resp = {
"id": target.id,
"type": target.type,
@@ -168,16 +266,27 @@ def _target_response(target: NotificationTarget, chat_names: dict[str, str] | No
"icon": target.icon,
"config": _safe_config(target),
"chat_action": target.chat_action,
"receiver_count": receiver_count,
"receiver_count": len(recv_list),
"receivers": [
{
"id": r.id,
"name": r.name,
"config": dict(r.config),
"receiver_key": r.receiver_key,
"enabled": r.enabled,
}
for r in recv_list
],
"created_at": target.created_at.isoformat(),
}
# Attach resolved chat name for telegram targets
# Attach resolved chat names from receivers for telegram targets
if target.type == "telegram" and chat_names:
bot_id = target.config.get("bot_id")
chat_id = str(target.config.get("chat_id", ""))
key = f"{bot_id}_{chat_id}"
if key in chat_names:
resp["chat_name"] = chat_names[key]
for recv_resp in resp["receivers"]:
chat_id = str(recv_resp["config"].get("chat_id", ""))
key = f"{bot_id}_{chat_id}"
if key in chat_names:
recv_resp["chat_name"] = chat_names[key]
return resp
@@ -56,6 +56,8 @@ async def setup(body: SetupRequest, session: AsyncSession = Depends(get_session)
if count > 0:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Setup already completed.")
if len(body.password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
user = User(username=body.username, hashed_password=_hash_password(body.password), role="admin")
session.add(user)
await session.commit()
@@ -541,6 +541,25 @@ def _format_assets(
})
async def send_reply(bot_token: str, chat_id: str, text: str) -> None:
"""Send a text reply via Telegram Bot API, retrying without HTML on parse failure."""
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()
_LOGGER.debug("Telegram reply failed: %s", result.get("description"))
if "parse" in str(result.get("description", "")).lower():
payload.pop("parse_mode", None)
async with http.post(url, json=payload) as retry_resp:
if retry_resp.status != 200:
_LOGGER.warning("Telegram reply failed on retry")
except aiohttp.ClientError as err:
_LOGGER.error("Failed to send Telegram reply: %s", err)
async def send_media_group(
bot_token: str, chat_id: str, media_items: list[dict[str, Any]],
) -> None:
@@ -15,7 +15,7 @@ from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_UR
from ..database.engine import get_session
from ..database.models import TelegramBot
from ..services.telegram import save_chat_from_webhook
from .handler import handle_command, send_media_group
from .handler import handle_command, send_media_group, send_reply
_LOGGER = logging.getLogger(__name__)
@@ -81,32 +81,12 @@ async def telegram_webhook(
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)
await send_reply(bot.token, chat_id, cmd_response)
return {"ok": True}
return {"ok": True, "skipped": "not_a_command"}
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_session:
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_session.post(url, json=payload) as resp:
if resp.status != 200:
result = await resp.json()
_LOGGER.debug("Telegram reply failed: %s", result.get("description"))
# Retry without parse_mode if HTML fails
if "parse" in str(result.get("description", "")).lower():
payload.pop("parse_mode", None)
async with http_session.post(url, json=payload) as retry_resp:
if retry_resp.status != 200:
_LOGGER.warning("Telegram reply failed on retry")
except aiohttp.ClientError as err:
_LOGGER.error("Failed to send Telegram reply: %s", err)
async def register_webhook(bot_token: str, webhook_url: str, secret: str | None = None) -> dict:
"""Register webhook URL with Telegram Bot API."""
async with aiohttp.ClientSession() as http:
@@ -15,9 +15,8 @@ class Settings(BaseSettings):
def model_post_init(self, __context: Any) -> None:
if self.secret_key == "change-me-in-production" and not self.debug:
import logging
logging.getLogger(__name__).critical(
"SECURITY: Using default secret_key! "
raise ValueError(
"SECURITY: Cannot start with default secret_key in production. "
"Set NOTIFY_BRIDGE_SECRET_KEY environment variable."
)
@@ -6,6 +6,7 @@ and the Phase 1 entity refactor (tracker → notification_tracker, etc.).
import json
import logging
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
@@ -713,3 +714,139 @@ async def migrate_target_receivers(engine: AsyncEngine) -> None:
if migrated:
logger.info("Migrated %d target receivers from legacy config", migrated)
async def migrate_receivers_from_config(engine: AsyncEngine) -> None:
"""Extract delivery endpoint fields from target.config into TargetReceiver rows.
For each NotificationTarget that still has a delivery field (chat_id, url,
webhook_url, email, topic, room_id) in its config JSON:
1. Create a TargetReceiver row (if one with the same key doesn't exist)
2. Remove the delivery field(s) from the config JSON
Idempotent: checks for existing receiver before creating; only strips fields
that are still present in config.
"""
# Mapping: target_type -> (delivery field in config, receiver config builder)
_DELIVERY_FIELDS: dict[str, dict[str, str]] = {
"telegram": {"chat_id": "chat_id"},
"webhook": {"url": "url"},
"email": {"email": "email"},
"discord": {"webhook_url": "webhook_url"},
"slack": {"webhook_url": "webhook_url"},
"ntfy": {"topic": "topic"},
"matrix": {"room_id": "room_id"},
}
async with engine.begin() as conn:
if not await _has_table(conn, "notification_target"):
return
if not await _has_table(conn, "target_receiver"):
return
targets = (await conn.execute(
text("SELECT id, type, config FROM notification_target")
)).fetchall()
created = 0
cleaned = 0
for row in targets:
target_id, target_type, raw_config = row[0], row[1], row[2]
try:
cfg = json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {})
except (json.JSONDecodeError, TypeError):
cfg = {}
field_map = _DELIVERY_FIELDS.get(target_type, {})
if not field_map:
continue
# Check if any delivery field is present in config
delivery_field = list(field_map.keys())[0] # e.g. "chat_id", "url"
delivery_value = cfg.get(delivery_field)
if not delivery_value:
continue
# Build receiver config
receiver_config: dict[str, Any] = {delivery_field: delivery_value}
# For webhook, also move headers to receiver config
if target_type == "webhook" and "headers" in cfg:
receiver_config["headers"] = cfg["headers"]
receiver_key = str(delivery_value)
# Check if receiver already exists
existing = (await conn.execute(
text(
"SELECT id FROM target_receiver "
"WHERE target_id = :tid AND receiver_key = :rk"
),
{"tid": target_id, "rk": receiver_key},
)).fetchone()
if not existing:
# Derive a name for the receiver
if target_type == "telegram":
name = f"Chat {delivery_value}"
elif target_type == "webhook":
name = str(delivery_value)[:50]
elif target_type == "email":
name = str(delivery_value)
else:
name = str(delivery_value)[:50]
await conn.execute(
text(
"INSERT INTO target_receiver "
"(target_id, name, config, receiver_key, enabled, created_at) "
"VALUES (:tid, :name, :cfg, :rk, 1, CURRENT_TIMESTAMP)"
),
{
"tid": target_id,
"name": name,
"cfg": json.dumps(receiver_config),
"rk": receiver_key,
},
)
created += 1
# Remove delivery fields from config
new_cfg = dict(cfg)
new_cfg.pop(delivery_field, None)
# For webhook, also remove headers (moved to receiver)
if target_type == "webhook":
new_cfg.pop("headers", None)
if new_cfg != cfg:
await conn.execute(
text(
"UPDATE notification_target SET config = :cfg WHERE id = :tid"
),
{"cfg": json.dumps(new_cfg), "tid": target_id},
)
cleaned += 1
if created:
logger.info("Created %d receiver rows from target config delivery fields", created)
if cleaned:
logger.info("Cleaned delivery fields from %d target configs", cleaned)
async def migrate_template_locale(engine: AsyncEngine) -> None:
"""Add locale column to template_config and command_template_config.
Backfill locale from name: "(RU)" -> "ru", else "en" for system-owned rows.
"""
async with engine.begin() as conn:
for table in ("template_config", "command_template_config"):
if await _has_column(conn, table, "locale"):
continue
logger.info("Adding locale column to %s", table)
await conn.execute(text(f"ALTER TABLE {table} ADD COLUMN locale TEXT DEFAULT ''"))
# Backfill system-owned rows
await conn.execute(text(
f"UPDATE {table} SET locale = 'ru' WHERE user_id = 0 AND name LIKE '%(RU)%'"
))
await conn.execute(text(
f"UPDATE {table} SET locale = 'en' WHERE user_id = 0 AND locale = ''"
))
@@ -170,6 +170,7 @@ class TemplateConfig(SQLModel, table=True):
name: str
description: str = Field(default="")
icon: str = Field(default="")
locale: str = Field(default="") # e.g. "en", "ru"; empty = unspecified
date_format: str = Field(default="%d.%m.%Y, %H:%M UTC")
date_only_format: str = Field(default="%d.%m.%Y")
@@ -330,6 +331,7 @@ class CommandTemplateConfig(SQLModel, table=True):
name: str
description: str = Field(default="")
icon: str = Field(default="")
locale: str = Field(default="") # e.g. "en", "ru"; empty = unspecified
created_at: datetime = Field(default_factory=_utcnow)
@@ -39,13 +39,15 @@ async def lifespan(app: FastAPI):
await init_db()
# Run data migrations (idempotent)
from .database.engine import get_engine
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers
from .database.migrations import migrate_schema, migrate_tracker_targets, migrate_entity_refactor, migrate_template_slots, migrate_target_receivers, migrate_template_locale, migrate_receivers_from_config
engine = get_engine()
await migrate_schema(engine)
await migrate_tracker_targets(engine)
await migrate_entity_refactor(engine)
await migrate_template_slots(engine)
await migrate_target_receivers(engine)
await migrate_template_locale(engine)
await migrate_receivers_from_config(engine)
await _seed_default_templates()
await _seed_default_command_templates()
# Configure webhook secret from DB setting (falls back to env var)
@@ -54,9 +56,13 @@ async def lifespan(app: FastAPI):
async with _AS(engine) as _session:
_secret = await _get_setting(_session, "telegram_webhook_secret")
set_webhook_secret(_secret or None)
from .services.scheduler import start_scheduler
from .services.scheduler import start_scheduler, get_scheduler
await start_scheduler()
yield
# Graceful shutdown
scheduler = get_scheduler()
if scheduler.running:
scheduler.shutdown()
app = FastAPI(title="Notify Bridge", version="0.1.0", lifespan=lifespan)
@@ -108,7 +114,8 @@ async def _seed_default_templates():
)
system_configs = result.all()
existing_locales = {
"ru" if "(RU)" in c.name else "en": c for c in system_configs
(c.locale if c.locale else ("ru" if "(RU)" in c.name else "en")): c
for c in system_configs
}
for locale in ("en", "ru"):
@@ -144,6 +151,8 @@ async def _seed_default_templates():
values[col] = "%d.%m.%Y, %H:%M UTC"
elif col == "date_only_format":
values[col] = "%d.%m.%Y"
elif col == "locale":
values[col] = locale
else:
values[col] = "" # empty string for legacy columns
cols_str = ", ".join(values.keys())
@@ -211,6 +220,7 @@ async def _seed_default_command_templates():
provider_type="immich",
name=name,
description=f"Default Immich command templates ({locale.upper()})",
locale=locale,
)
session.add(config)
await session.flush()
@@ -227,7 +237,7 @@ async def _seed_default_command_templates():
)
system_configs = result.all()
for config in system_configs:
locale = "ru" if "(RU)" in config.name else "en"
locale = config.locale if config.locale else ("ru" if "(RU)" in config.name else "en")
slots = load_default_command_templates(locale)
if not slots:
continue
@@ -86,13 +86,8 @@ async def _send_telegram_broadcast(target: NotificationTarget, message: str, rec
if not bot_token:
return {"success": False, "error": "Missing bot_token"}
# Fall back to legacy chat_id if no receivers
if not receivers:
chat_id = target.config.get("chat_id")
if chat_id:
receivers = [{"chat_id": str(chat_id)}]
else:
return {"success": False, "error": "No receivers configured"}
return {"success": False, "error": "No receivers configured"}
results: list[dict] = []
async with aiohttp.ClientSession() as session:
@@ -121,14 +116,8 @@ async def _send_telegram_broadcast(target: NotificationTarget, message: str, rec
async def _send_webhook_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
from notify_bridge_core.notifications.webhook.client import WebhookClient
# Fall back to legacy url if no receivers
if not receivers:
url = target.config.get("url")
headers = target.config.get("headers", {})
if url:
receivers = [{"url": url, "headers": headers}]
else:
return {"success": False, "error": "No receivers configured"}
return {"success": False, "error": "No receivers configured"}
results: list[dict] = []
async with aiohttp.ClientSession() as session:
@@ -206,11 +195,7 @@ async def _send_email_broadcast(target: NotificationTarget, message: str, receiv
async def _send_webhook_like_broadcast(target: NotificationTarget, message: str, receivers: list[dict]) -> dict:
"""Broadcast for Discord and Slack — both use webhook URLs as receivers."""
if not receivers:
webhook_url = target.config.get("webhook_url")
if webhook_url:
receivers = [{"webhook_url": webhook_url}]
else:
return {"success": False, "error": "No receivers configured"}
return {"success": False, "error": "No receivers configured"}
results: list[dict] = []
async with aiohttp.ClientSession() as session:
@@ -238,11 +223,7 @@ async def _send_ntfy_broadcast(target: NotificationTarget, message: str, receive
auth_token = target.config.get("auth_token")
if not receivers:
topic = target.config.get("topic")
if topic:
receivers = [{"topic": topic}]
else:
return {"success": False, "error": "No receivers configured"}
return {"success": False, "error": "No receivers configured"}
from notify_bridge_core.notifications.ntfy.client import NtfyClient
results: list[dict] = []
@@ -307,6 +288,26 @@ def _aggregate(results: list[dict]) -> dict:
# --- Public API used by routes ---
async def send_to_receiver(target: NotificationTarget, receiver_config: dict, message: str) -> dict:
"""Send a message to a single receiver of a target."""
try:
send_fn = {
"telegram": _send_telegram_broadcast,
"webhook": _send_webhook_broadcast,
"email": _send_email_broadcast,
"discord": _send_webhook_like_broadcast,
"slack": _send_webhook_like_broadcast,
"ntfy": _send_ntfy_broadcast,
"matrix": _send_matrix_broadcast,
}.get(target.type)
if send_fn:
return await send_fn(target, message, [receiver_config])
return {"success": False, "error": f"Unknown target type: {target.type}"}
except Exception as e:
_LOGGER.error("Send to receiver failed: %s", e)
return {"success": False, "error": str(e)}
async def send_test_notification(target: NotificationTarget, locale: str = "en") -> dict:
"""Send a simple test message."""
message = _get_test_message(locale, target.type)
@@ -180,7 +180,7 @@ async def _poll_bot(bot_id: int) -> None:
_last_update_id[bot_id] = updates[-1]["update_id"]
# Process each update
from ..commands.handler import handle_command, send_media_group
from ..commands.handler import handle_command, send_media_group, send_reply
for update in updates:
message = update.get("message")
@@ -210,22 +210,8 @@ async def _poll_bot(bot_id: int) -> 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)
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)