feat: Receiver OOP hierarchy with per-receiver locale resolution
- Introduce Receiver base class + typed subclasses (TelegramReceiver, WebhookReceiver, EmailReceiver, etc.) in core/notifications/receiver.py - Dispatcher uses typed Receiver objects instead of raw dicts, with per-receiver locale-aware template rendering - load_link_data resolves locale from TelegramChat.language_override at load time: TargetReceiver.locale || chat.language_override || chat.language_code - Add language_override field to TelegramChat (separate from auto-detected language_code), with per-chat commands toggle and command dispatch using override language - Add locale field to TargetReceiver for explicit per-receiver overrides
This commit is contained in:
@@ -17,3 +17,10 @@ Detailed context is split into focused documents under `.claude/docs/`. Read the
|
|||||||
3. **Template variables** must be updated in 6 files simultaneously — see [template-system.md](.claude/docs/template-system.md).
|
3. **Template variables** must be updated in 6 files simultaneously — see [template-system.md](.claude/docs/template-system.md).
|
||||||
4. **Entity cache** — shared entities use `$state`-based caches in `frontend/src/lib/stores/caches.svelte.ts`. Always use cache for cross-page data; invalidate after mutations — see [frontend-architecture.md](.claude/docs/frontend-architecture.md).
|
4. **Entity cache** — shared entities use `$state`-based caches in `frontend/src/lib/stores/caches.svelte.ts`. Always use cache for cross-page data; invalidate after mutations — see [frontend-architecture.md](.claude/docs/frontend-architecture.md).
|
||||||
5. **Telegram API** — ALL Telegram Bot API calls (sendMessage, sendPhoto, sendMediaGroup, etc.) MUST go through `TelegramClient` in `packages/core/src/notify_bridge_core/notifications/telegram/client.py`. NEVER duplicate sending logic in command handlers, API routes, or services. If `TelegramClient` lacks a method you need, add it there.
|
5. **Telegram API** — ALL Telegram Bot API calls (sendMessage, sendPhoto, sendMediaGroup, etc.) MUST go through `TelegramClient` in `packages/core/src/notify_bridge_core/notifications/telegram/client.py`. NEVER duplicate sending logic in command handlers, API routes, or services. If `TelegramClient` lacks a method you need, add it there.
|
||||||
|
6. **Service provider defaults** — when implementing a new service provider, ALWAYS create default notification and command templates and configs. This requires changes across all of these locations:
|
||||||
|
- Jinja2 notification templates for each locale in `packages/core/src/notify_bridge_core/templates/defaults/{en,ru}/`
|
||||||
|
- Jinja2 command templates for each locale in `packages/core/src/notify_bridge_core/templates/command_defaults/{en,ru}/{provider}/`
|
||||||
|
- Notification slot mapping in `packages/core/src/notify_bridge_core/templates/defaults/loader.py` (`PROVIDER_SLOT_FILE_MAP`)
|
||||||
|
- Command slot mapping in `packages/core/src/notify_bridge_core/templates/command_defaults/loader.py` (`PROVIDER_COMMAND_SLOTS`)
|
||||||
|
- Provider capabilities in `packages/core/src/notify_bridge_core/providers/capabilities.py`
|
||||||
|
- Seed functions in `packages/server/src/notify_bridge_server/database/seeds.py` (notification templates, command templates, tracking configs, command configs)
|
||||||
|
|||||||
@@ -338,6 +338,7 @@
|
|||||||
"chatName": "Name",
|
"chatName": "Name",
|
||||||
"chatType": "Type",
|
"chatType": "Type",
|
||||||
"chatLang": "Lang",
|
"chatLang": "Lang",
|
||||||
|
"langOverride": "Override",
|
||||||
"cmds": "Cmds",
|
"cmds": "Cmds",
|
||||||
"commandsToggle": "Toggle command listening for this chat",
|
"commandsToggle": "Toggle command listening for this chat",
|
||||||
"chatId": "Chat ID",
|
"chatId": "Chat ID",
|
||||||
|
|||||||
@@ -338,6 +338,7 @@
|
|||||||
"chatName": "Имя",
|
"chatName": "Имя",
|
||||||
"chatType": "Тип",
|
"chatType": "Тип",
|
||||||
"chatLang": "Язык",
|
"chatLang": "Язык",
|
||||||
|
"langOverride": "Переопр.",
|
||||||
"cmds": "Команды",
|
"cmds": "Команды",
|
||||||
"commandsToggle": "Включить/выключить команды для этого чата",
|
"commandsToggle": "Включить/выключить команды для этого чата",
|
||||||
"chatId": "ID чата",
|
"chatId": "ID чата",
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface TelegramChat {
|
|||||||
type: string;
|
type: string;
|
||||||
username: string;
|
username: string;
|
||||||
language_code?: string;
|
language_code?: string;
|
||||||
|
language_override?: string;
|
||||||
commands_enabled: boolean;
|
commands_enabled: boolean;
|
||||||
discovered_at: string;
|
discovered_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,10 +129,10 @@
|
|||||||
try {
|
try {
|
||||||
await api(`/telegram-bots/${botId}/chats/${chat.id}`, {
|
await api(`/telegram-bots/${botId}/chats/${chat.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ language_code: lang }),
|
body: JSON.stringify({ language_override: lang }),
|
||||||
});
|
});
|
||||||
chats[botId] = (chats[botId] || []).map(c =>
|
chats[botId] = (chats[botId] || []).map(c =>
|
||||||
c.id === chat.id ? { ...c, language_code: lang } : c
|
c.id === chat.id ? { ...c, language_override: lang } : c
|
||||||
);
|
);
|
||||||
snackSuccess(t('telegramBot.languageUpdated'));
|
snackSuccess(t('telegramBot.languageUpdated'));
|
||||||
} catch (err: any) { snackError(err.message); }
|
} catch (err: any) { snackError(err.message); }
|
||||||
@@ -362,13 +362,14 @@
|
|||||||
{:else if (chats[bot.id] || []).length === 0}
|
{:else if (chats[bot.id] || []).length === 0}
|
||||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
||||||
{:else}
|
{:else}
|
||||||
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
|
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 40px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
|
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
|
||||||
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
|
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
|
||||||
<span>{t('telegramBot.chatName')}</span>
|
<span>{t('telegramBot.chatName')}</span>
|
||||||
<span style="text-align:center">{t('telegramBot.chatType')}</span>
|
<span style="text-align:center">{t('telegramBot.chatType')}</span>
|
||||||
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
|
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
|
||||||
|
<span style="text-align:center">{t('telegramBot.langOverride')}</span>
|
||||||
<span style="text-align:center">{t('telegramBot.cmds')}</span>
|
<span style="text-align:center">{t('telegramBot.cmds')}</span>
|
||||||
<span style="text-align:center">{t('telegramBot.chatId')}</span>
|
<span style="text-align:center">{t('telegramBot.chatId')}</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
@@ -382,10 +383,11 @@
|
|||||||
role="button" tabindex="0">
|
role="button" tabindex="0">
|
||||||
<span class="font-medium truncate">{chat.title || chat.username || 'Unknown'}</span>
|
<span class="font-medium truncate">{chat.title || chat.username || 'Unknown'}</span>
|
||||||
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||||
|
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)]">{(chat.language_code || '—').toUpperCase()}</span>
|
||||||
<div style="justify-self:center" onclick={(e: MouseEvent) => e.stopPropagation()}>
|
<div style="justify-self:center" onclick={(e: MouseEvent) => e.stopPropagation()}>
|
||||||
<EntitySelect
|
<EntitySelect
|
||||||
items={LANG_ITEMS}
|
items={LANG_ITEMS}
|
||||||
value={chat.language_code || ''}
|
value={chat.language_override || ''}
|
||||||
size="sm"
|
size="sm"
|
||||||
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
|
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ from notify_bridge_core.models.events import ServiceEvent
|
|||||||
from notify_bridge_core.templates.context import build_template_context
|
from notify_bridge_core.templates.context import build_template_context
|
||||||
from notify_bridge_core.templates.renderer import render_template
|
from notify_bridge_core.templates.renderer import render_template
|
||||||
|
|
||||||
|
from .receiver import (
|
||||||
|
Receiver,
|
||||||
|
TelegramReceiver,
|
||||||
|
WebhookReceiver,
|
||||||
|
EmailReceiver,
|
||||||
|
DiscordReceiver,
|
||||||
|
SlackReceiver,
|
||||||
|
NtfyReceiver,
|
||||||
|
MatrixReceiver,
|
||||||
|
)
|
||||||
from .telegram.cache import TelegramFileCache
|
from .telegram.cache import TelegramFileCache
|
||||||
from .telegram.client import TelegramClient
|
from .telegram.client import TelegramClient
|
||||||
from .webhook.client import WebhookClient
|
from .webhook.client import WebhookClient
|
||||||
@@ -28,14 +38,13 @@ class TargetConfig:
|
|||||||
type: str # "telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix"
|
type: str # "telegram", "webhook", "email", "discord", "slack", "ntfy", "matrix"
|
||||||
config: dict[str, Any] # target-level config (bot_token, settings, etc.)
|
config: dict[str, Any] # target-level config (bot_token, settings, etc.)
|
||||||
template_slots: dict[str, dict[str, str]] | None = None # event_type -> {locale -> template}
|
template_slots: dict[str, dict[str, str]] | None = None # event_type -> {locale -> template}
|
||||||
locale: str = "en" # preferred locale for template resolution
|
locale: str = "en" # default locale for template resolution
|
||||||
date_format: str = "%d.%m.%Y, %H:%M UTC"
|
date_format: str = "%d.%m.%Y, %H:%M UTC"
|
||||||
date_only_format: str = "%d.%m.%Y"
|
date_only_format: str = "%d.%m.%Y"
|
||||||
provider_api_key: str | None = None # API key for downloading assets from provider
|
provider_api_key: str | None = None # API key for downloading assets from provider
|
||||||
provider_internal_url: str | None = None # Internal provider URL for API key scoping
|
provider_internal_url: str | None = None # Internal provider URL for API key scoping
|
||||||
provider_external_url: str | None = None # External domain for API key scoping
|
provider_external_url: str | None = None # External domain for API key scoping
|
||||||
# Broadcast receivers — if non-empty, sends to each receiver instead of config
|
receivers: list[Receiver] = field(default_factory=list)
|
||||||
receivers: list[dict[str, Any]] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationDispatcher:
|
class NotificationDispatcher:
|
||||||
@@ -69,24 +78,43 @@ class NotificationDispatcher:
|
|||||||
results.append({"success": False, "error": str(e)})
|
results.append({"success": False, "error": str(e)})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def _send_to_target(
|
def _resolve_template(
|
||||||
self, event: ServiceEvent, target: TargetConfig
|
self, event: ServiceEvent, target: TargetConfig, locale: str,
|
||||||
) -> dict[str, Any]:
|
) -> str:
|
||||||
"""Send event to a single target (potentially multiple receivers)."""
|
"""Resolve template string for an event, with locale fallback."""
|
||||||
# Select template with locale fallback
|
|
||||||
template_str = DEFAULT_TEMPLATE
|
template_str = DEFAULT_TEMPLATE
|
||||||
if target.template_slots:
|
if target.template_slots:
|
||||||
locale_map = target.template_slots.get(event.event_type.value)
|
locale_map = target.template_slots.get(event.event_type.value)
|
||||||
if locale_map:
|
if locale_map:
|
||||||
template_str = locale_map.get(target.locale) or locale_map.get("en") or template_str
|
template_str = locale_map.get(locale) or locale_map.get("en") or template_str
|
||||||
|
return template_str
|
||||||
|
|
||||||
# Build context and render ONCE
|
def _render_message(
|
||||||
|
self, event: ServiceEvent, target: TargetConfig, locale: str,
|
||||||
|
) -> str:
|
||||||
|
"""Resolve template and render message for a given locale."""
|
||||||
|
template_str = self._resolve_template(event, target, locale)
|
||||||
ctx = build_template_context(
|
ctx = build_template_context(
|
||||||
event, target_type=target.type,
|
event, target_type=target.type,
|
||||||
date_format=target.date_format,
|
date_format=target.date_format,
|
||||||
date_only_format=target.date_only_format,
|
date_only_format=target.date_only_format,
|
||||||
)
|
)
|
||||||
message = render_template(template_str, ctx)
|
return render_template(template_str, ctx)
|
||||||
|
|
||||||
|
def _message_for_receiver(
|
||||||
|
self, receiver: Receiver, default_message: str,
|
||||||
|
event: ServiceEvent, target: TargetConfig,
|
||||||
|
) -> str:
|
||||||
|
"""Return per-receiver message, re-rendering if receiver has a different locale."""
|
||||||
|
if receiver.locale and receiver.locale != target.locale:
|
||||||
|
return self._render_message(event, target, receiver.locale)
|
||||||
|
return default_message
|
||||||
|
|
||||||
|
async def _send_to_target(
|
||||||
|
self, event: ServiceEvent, target: TargetConfig
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Send event to a single target (potentially multiple receivers)."""
|
||||||
|
default_message = self._render_message(event, target, target.locale)
|
||||||
|
|
||||||
send_method = {
|
send_method = {
|
||||||
"telegram": self._send_telegram,
|
"telegram": self._send_telegram,
|
||||||
@@ -98,11 +126,11 @@ class NotificationDispatcher:
|
|||||||
"matrix": self._send_matrix,
|
"matrix": self._send_matrix,
|
||||||
}.get(target.type)
|
}.get(target.type)
|
||||||
if send_method:
|
if send_method:
|
||||||
return await send_method(target, message, event)
|
return await send_method(target, default_message, event)
|
||||||
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
return {"success": False, "error": f"Unknown target type: {target.type}"}
|
||||||
|
|
||||||
async def _send_telegram(
|
async def _send_telegram(
|
||||||
self, target: TargetConfig, message: str, event: ServiceEvent
|
self, target: TargetConfig, default_message: str, event: ServiceEvent
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
bot_token = target.config.get("bot_token")
|
bot_token = target.config.get("bot_token")
|
||||||
disable_preview = target.config.get("disable_url_preview", False)
|
disable_preview = target.config.get("disable_url_preview", False)
|
||||||
@@ -120,7 +148,6 @@ class NotificationDispatcher:
|
|||||||
|
|
||||||
if not target.receivers:
|
if not target.receivers:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
receivers = target.receivers
|
|
||||||
|
|
||||||
# Prepare assets list once (shared across receivers)
|
# Prepare assets list once (shared across receivers)
|
||||||
provider_urls = []
|
provider_urls = []
|
||||||
@@ -146,28 +173,27 @@ class NotificationDispatcher:
|
|||||||
asset_cache=self._asset_cache,
|
asset_cache=self._asset_cache,
|
||||||
)
|
)
|
||||||
|
|
||||||
for receiver in receivers:
|
for receiver in target.receivers:
|
||||||
chat_id = receiver.get("chat_id")
|
if not isinstance(receiver, TelegramReceiver) or not receiver.chat_id:
|
||||||
if not chat_id:
|
results.append({"success": False, "error": "Invalid telegram receiver"})
|
||||||
results.append({"success": False, "error": "Missing chat_id in receiver"})
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Step 1: Send the text message
|
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||||
|
|
||||||
text_result = await client.send_message(
|
text_result = await client.send_message(
|
||||||
chat_id=str(chat_id),
|
chat_id=receiver.chat_id,
|
||||||
text=message,
|
text=message,
|
||||||
disable_web_page_preview=bool(disable_preview),
|
disable_web_page_preview=bool(disable_preview),
|
||||||
)
|
)
|
||||||
if not text_result.get("success"):
|
if not text_result.get("success"):
|
||||||
_LOGGER.warning("Failed to send to chat %s: %s", chat_id, text_result.get("error"))
|
_LOGGER.warning("Failed to send to chat %s: %s", receiver.chat_id, text_result.get("error"))
|
||||||
results.append(text_result)
|
results.append(text_result)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Step 2: Send assets as reply
|
|
||||||
if assets:
|
if assets:
|
||||||
reply_to = text_result.get("message_id")
|
reply_to = text_result.get("message_id")
|
||||||
media_result = await client.send_notification(
|
media_result = await client.send_notification(
|
||||||
chat_id=str(chat_id),
|
chat_id=receiver.chat_id,
|
||||||
assets=assets,
|
assets=assets,
|
||||||
reply_to_message_id=reply_to,
|
reply_to_message_id=reply_to,
|
||||||
max_group_size=max_group,
|
max_group_size=max_group,
|
||||||
@@ -177,19 +203,25 @@ class NotificationDispatcher:
|
|||||||
chat_action=chat_action or None,
|
chat_action=chat_action or None,
|
||||||
)
|
)
|
||||||
if not media_result.get("success"):
|
if not media_result.get("success"):
|
||||||
_LOGGER.warning("Text sent OK but media failed for chat %s: %s", chat_id, media_result.get("error"))
|
_LOGGER.warning("Text sent OK but media failed for chat %s: %s", receiver.chat_id, media_result.get("error"))
|
||||||
|
|
||||||
results.append(text_result)
|
results.append(text_result)
|
||||||
|
|
||||||
return self._aggregate_results(results)
|
return self._aggregate_results(results)
|
||||||
|
|
||||||
async def _send_webhook(
|
async def _send_webhook(
|
||||||
self, target: TargetConfig, message: str, event: ServiceEvent
|
self, target: TargetConfig, default_message: str, event: ServiceEvent
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not target.receivers:
|
if not target.receivers:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
receivers = target.receivers
|
|
||||||
|
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
for receiver in target.receivers:
|
||||||
|
if not isinstance(receiver, WebhookReceiver) or not receiver.url:
|
||||||
|
results.append({"success": False, "error": "Invalid webhook receiver"})
|
||||||
|
continue
|
||||||
|
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||||
payload = {
|
payload = {
|
||||||
"message": message,
|
"message": message,
|
||||||
"event_type": event.event_type.value,
|
"event_type": event.event_type.value,
|
||||||
@@ -198,22 +230,13 @@ class NotificationDispatcher:
|
|||||||
"collection_id": event.collection_id,
|
"collection_id": event.collection_id,
|
||||||
"timestamp": event.timestamp.isoformat(),
|
"timestamp": event.timestamp.isoformat(),
|
||||||
}
|
}
|
||||||
|
client = WebhookClient(session, receiver.url, receiver.headers)
|
||||||
results: list[dict[str, Any]] = []
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
for receiver in receivers:
|
|
||||||
url = receiver.get("url")
|
|
||||||
headers = receiver.get("headers", {})
|
|
||||||
if not url:
|
|
||||||
results.append({"success": False, "error": "Missing url in receiver"})
|
|
||||||
continue
|
|
||||||
client = WebhookClient(session, url, headers)
|
|
||||||
results.append(await client.send(payload))
|
results.append(await client.send(payload))
|
||||||
|
|
||||||
return self._aggregate_results(results)
|
return self._aggregate_results(results)
|
||||||
|
|
||||||
async def _send_email(
|
async def _send_email(
|
||||||
self, target: TargetConfig, message: str, event: ServiceEvent
|
self, target: TargetConfig, default_message: str, event: ServiceEvent
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
from .email.client import EmailClient, SmtpConfig
|
from .email.client import EmailClient, SmtpConfig
|
||||||
|
|
||||||
@@ -233,71 +256,68 @@ class NotificationDispatcher:
|
|||||||
|
|
||||||
if not target.receivers:
|
if not target.receivers:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
receivers = target.receivers
|
|
||||||
subject = f"[Notify Bridge] {event.event_type.value}: {event.collection_name}"
|
subject = f"[Notify Bridge] {event.event_type.value}: {event.collection_name}"
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
for receiver in receivers:
|
for receiver in target.receivers:
|
||||||
email = receiver.get("email")
|
if not isinstance(receiver, EmailReceiver) or not receiver.email:
|
||||||
if not email:
|
results.append({"success": False, "error": "Invalid email receiver"})
|
||||||
results.append({"success": False, "error": "Missing email in receiver"})
|
|
||||||
continue
|
continue
|
||||||
|
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||||
result = await client.send(
|
result = await client.send(
|
||||||
to_email=email,
|
to_email=receiver.email,
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body_text=message,
|
body_text=message,
|
||||||
to_name=receiver.get("name", ""),
|
to_name=receiver.name,
|
||||||
)
|
)
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|
||||||
return self._aggregate_results(results)
|
return self._aggregate_results(results)
|
||||||
|
|
||||||
async def _send_discord(
|
async def _send_discord(
|
||||||
self, target: TargetConfig, message: str, event: ServiceEvent
|
self, target: TargetConfig, default_message: str, event: ServiceEvent
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
from .discord.client import DiscordClient
|
from .discord.client import DiscordClient
|
||||||
|
|
||||||
if not target.receivers:
|
if not target.receivers:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
receivers = target.receivers
|
|
||||||
username = target.config.get("username")
|
username = target.config.get("username")
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
client = DiscordClient(session)
|
client = DiscordClient(session)
|
||||||
for receiver in receivers:
|
for receiver in target.receivers:
|
||||||
webhook_url = receiver.get("webhook_url")
|
if not isinstance(receiver, DiscordReceiver) or not receiver.webhook_url:
|
||||||
if not webhook_url:
|
results.append({"success": False, "error": "Invalid discord receiver"})
|
||||||
results.append({"success": False, "error": "Missing webhook_url"})
|
|
||||||
continue
|
continue
|
||||||
results.append(await client.send(webhook_url, message, username=username))
|
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||||
|
results.append(await client.send(receiver.webhook_url, message, username=username))
|
||||||
|
|
||||||
return self._aggregate_results(results)
|
return self._aggregate_results(results)
|
||||||
|
|
||||||
async def _send_slack(
|
async def _send_slack(
|
||||||
self, target: TargetConfig, message: str, event: ServiceEvent
|
self, target: TargetConfig, default_message: str, event: ServiceEvent
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
from .slack.client import SlackClient
|
from .slack.client import SlackClient
|
||||||
|
|
||||||
if not target.receivers:
|
if not target.receivers:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
receivers = target.receivers
|
|
||||||
username = target.config.get("username")
|
username = target.config.get("username")
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
client = SlackClient(session)
|
client = SlackClient(session)
|
||||||
for receiver in receivers:
|
for receiver in target.receivers:
|
||||||
webhook_url = receiver.get("webhook_url")
|
if not isinstance(receiver, SlackReceiver) or not receiver.webhook_url:
|
||||||
if not webhook_url:
|
results.append({"success": False, "error": "Invalid slack receiver"})
|
||||||
results.append({"success": False, "error": "Missing webhook_url"})
|
|
||||||
continue
|
continue
|
||||||
results.append(await client.send(webhook_url, message, username=username))
|
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||||
|
results.append(await client.send(receiver.webhook_url, message, username=username))
|
||||||
|
|
||||||
return self._aggregate_results(results)
|
return self._aggregate_results(results)
|
||||||
|
|
||||||
async def _send_ntfy(
|
async def _send_ntfy(
|
||||||
self, target: TargetConfig, message: str, event: ServiceEvent
|
self, target: TargetConfig, default_message: str, event: ServiceEvent
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
from .ntfy.client import NtfyClient
|
from .ntfy.client import NtfyClient
|
||||||
|
|
||||||
@@ -305,28 +325,26 @@ class NotificationDispatcher:
|
|||||||
auth_token = target.config.get("auth_token")
|
auth_token = target.config.get("auth_token")
|
||||||
if not target.receivers:
|
if not target.receivers:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
receivers = target.receivers
|
|
||||||
|
|
||||||
title = f"{event.event_type.value}: {event.collection_name}"
|
title = f"{event.event_type.value}: {event.collection_name}"
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
client = NtfyClient(session)
|
client = NtfyClient(session)
|
||||||
for receiver in receivers:
|
for receiver in target.receivers:
|
||||||
topic = receiver.get("topic")
|
if not isinstance(receiver, NtfyReceiver) or not receiver.topic:
|
||||||
if not topic:
|
results.append({"success": False, "error": "Invalid ntfy receiver"})
|
||||||
results.append({"success": False, "error": "Missing topic"})
|
|
||||||
continue
|
continue
|
||||||
priority = receiver.get("priority", 3)
|
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||||
results.append(await client.send(
|
results.append(await client.send(
|
||||||
server_url, topic, message,
|
server_url, receiver.topic, message,
|
||||||
title=title, priority=priority, auth_token=auth_token,
|
title=title, priority=receiver.priority, auth_token=auth_token,
|
||||||
))
|
))
|
||||||
|
|
||||||
return self._aggregate_results(results)
|
return self._aggregate_results(results)
|
||||||
|
|
||||||
async def _send_matrix(
|
async def _send_matrix(
|
||||||
self, target: TargetConfig, message: str, event: ServiceEvent
|
self, target: TargetConfig, default_message: str, event: ServiceEvent
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
from .matrix.client import MatrixClient
|
from .matrix.client import MatrixClient
|
||||||
|
|
||||||
@@ -337,18 +355,17 @@ class NotificationDispatcher:
|
|||||||
|
|
||||||
if not target.receivers:
|
if not target.receivers:
|
||||||
return {"success": False, "error": "No receivers configured"}
|
return {"success": False, "error": "No receivers configured"}
|
||||||
receivers = target.receivers
|
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
client = MatrixClient(session, homeserver, access_token)
|
client = MatrixClient(session, homeserver, access_token)
|
||||||
for receiver in receivers:
|
for receiver in target.receivers:
|
||||||
room_id = receiver.get("room_id")
|
if not isinstance(receiver, MatrixReceiver) or not receiver.room_id:
|
||||||
if not room_id:
|
results.append({"success": False, "error": "Invalid matrix receiver"})
|
||||||
results.append({"success": False, "error": "Missing room_id"})
|
|
||||||
continue
|
continue
|
||||||
|
message = self._message_for_receiver(receiver, default_message, event, target)
|
||||||
results.append(await client.send_message(
|
results.append(await client.send_message(
|
||||||
room_id, message, html_message=message,
|
receiver.room_id, message, html_message=message,
|
||||||
))
|
))
|
||||||
|
|
||||||
return self._aggregate_results(results)
|
return self._aggregate_results(results)
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Receiver hierarchy — typed delivery endpoints with locale resolution."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Receiver:
|
||||||
|
"""Base receiver with locale and raw config."""
|
||||||
|
|
||||||
|
locale: str = ""
|
||||||
|
config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Return raw dict for backward compat (includes locale)."""
|
||||||
|
return {**self.config, "locale": self.locale}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TelegramReceiver(Receiver):
|
||||||
|
"""Telegram chat receiver."""
|
||||||
|
|
||||||
|
chat_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WebhookReceiver(Receiver):
|
||||||
|
"""Webhook URL receiver."""
|
||||||
|
|
||||||
|
url: str = ""
|
||||||
|
headers: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EmailReceiver(Receiver):
|
||||||
|
"""Email receiver."""
|
||||||
|
|
||||||
|
email: str = ""
|
||||||
|
name: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DiscordReceiver(Receiver):
|
||||||
|
"""Discord webhook receiver."""
|
||||||
|
|
||||||
|
webhook_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SlackReceiver(Receiver):
|
||||||
|
"""Slack webhook receiver."""
|
||||||
|
|
||||||
|
webhook_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NtfyReceiver(Receiver):
|
||||||
|
"""ntfy topic receiver."""
|
||||||
|
|
||||||
|
topic: str = ""
|
||||||
|
priority: int = 3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatrixReceiver(Receiver):
|
||||||
|
"""Matrix room receiver."""
|
||||||
|
|
||||||
|
room_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def build_receiver(target_type: str, config: dict[str, Any], locale: str = "") -> Receiver:
|
||||||
|
"""Factory: build typed Receiver from target type and config dict."""
|
||||||
|
if target_type == "telegram":
|
||||||
|
return TelegramReceiver(
|
||||||
|
locale=locale,
|
||||||
|
config=config,
|
||||||
|
chat_id=str(config.get("chat_id", "")),
|
||||||
|
)
|
||||||
|
if target_type == "webhook":
|
||||||
|
return WebhookReceiver(
|
||||||
|
locale=locale,
|
||||||
|
config=config,
|
||||||
|
url=config.get("url", ""),
|
||||||
|
headers=config.get("headers", {}),
|
||||||
|
)
|
||||||
|
if target_type == "email":
|
||||||
|
return EmailReceiver(
|
||||||
|
locale=locale,
|
||||||
|
config=config,
|
||||||
|
email=config.get("email", ""),
|
||||||
|
name=config.get("name", ""),
|
||||||
|
)
|
||||||
|
if target_type == "discord":
|
||||||
|
return DiscordReceiver(
|
||||||
|
locale=locale,
|
||||||
|
config=config,
|
||||||
|
webhook_url=config.get("webhook_url", ""),
|
||||||
|
)
|
||||||
|
if target_type == "slack":
|
||||||
|
return SlackReceiver(
|
||||||
|
locale=locale,
|
||||||
|
config=config,
|
||||||
|
webhook_url=config.get("webhook_url", ""),
|
||||||
|
)
|
||||||
|
if target_type == "ntfy":
|
||||||
|
return NtfyReceiver(
|
||||||
|
locale=locale,
|
||||||
|
config=config,
|
||||||
|
topic=config.get("topic", ""),
|
||||||
|
priority=config.get("priority", 3),
|
||||||
|
)
|
||||||
|
if target_type == "matrix":
|
||||||
|
return MatrixReceiver(
|
||||||
|
locale=locale,
|
||||||
|
config=config,
|
||||||
|
room_id=config.get("room_id", ""),
|
||||||
|
)
|
||||||
|
return Receiver(locale=locale, config=config)
|
||||||
@@ -15,6 +15,7 @@ from ..database.models import (
|
|||||||
NotificationTracker,
|
NotificationTracker,
|
||||||
NotificationTrackerTarget,
|
NotificationTrackerTarget,
|
||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
|
TargetReceiver,
|
||||||
TemplateConfig,
|
TemplateConfig,
|
||||||
TemplateSlot,
|
TemplateSlot,
|
||||||
TrackingConfig,
|
TrackingConfig,
|
||||||
@@ -206,8 +207,39 @@ async def test_notification_tracker_target(
|
|||||||
if not target:
|
if not target:
|
||||||
raise HTTPException(status_code=404, detail="Target not found")
|
raise HTTPException(status_code=404, detail="Target not found")
|
||||||
|
|
||||||
|
# Resolve effective locale from first receiver (explicit locale → TelegramChat lang)
|
||||||
|
effective_locale = locale
|
||||||
|
recv_result = await session.exec(
|
||||||
|
select(TargetReceiver).where(
|
||||||
|
TargetReceiver.target_id == target.id,
|
||||||
|
TargetReceiver.enabled == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
first_recv = recv_result.first()
|
||||||
|
if first_recv:
|
||||||
|
recv_locale = getattr(first_recv, 'locale', '') or ''
|
||||||
|
if not recv_locale and target.type == "telegram":
|
||||||
|
# Resolve from TelegramChat language_override/language_code
|
||||||
|
from ..database.models import TelegramBot, TelegramChat
|
||||||
|
chat_id = str(first_recv.config.get("chat_id", ""))
|
||||||
|
bot_id = target.config.get("bot_id")
|
||||||
|
if chat_id and bot_id:
|
||||||
|
chat_row = (await session.exec(
|
||||||
|
select(TelegramChat).where(
|
||||||
|
TelegramChat.bot_id == bot_id,
|
||||||
|
TelegramChat.chat_id == chat_id,
|
||||||
|
)
|
||||||
|
)).first()
|
||||||
|
if chat_row:
|
||||||
|
recv_locale = (
|
||||||
|
getattr(chat_row, 'language_override', '') or
|
||||||
|
getattr(chat_row, 'language_code', '') or ''
|
||||||
|
)
|
||||||
|
if recv_locale:
|
||||||
|
effective_locale = recv_locale[:2].lower()
|
||||||
|
|
||||||
if test_type == "basic":
|
if test_type == "basic":
|
||||||
r = await send_test_notification(target, locale=locale)
|
r = await send_test_notification(target, locale=effective_locale)
|
||||||
return {"target": target.name, **r}
|
return {"target": target.name, **r}
|
||||||
|
|
||||||
# For periodic/scheduled/memory — fetch real data from provider
|
# For periodic/scheduled/memory — fetch real data from provider
|
||||||
@@ -226,7 +258,7 @@ async def test_notification_tracker_target(
|
|||||||
select(TemplateSlot).where(
|
select(TemplateSlot).where(
|
||||||
TemplateSlot.config_id == template_config.id,
|
TemplateSlot.config_id == template_config.id,
|
||||||
TemplateSlot.slot_name == slot_name,
|
TemplateSlot.slot_name == slot_name,
|
||||||
TemplateSlot.locale == "en",
|
TemplateSlot.locale == effective_locale,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
slot = slot_result.first()
|
slot = slot_result.first()
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ router = APIRouter(prefix="/api/targets/{target_id}/receivers", tags=["target-re
|
|||||||
class ReceiverCreate(BaseModel):
|
class ReceiverCreate(BaseModel):
|
||||||
name: str = ""
|
name: str = ""
|
||||||
config: dict[str, Any] = {}
|
config: dict[str, Any] = {}
|
||||||
|
locale: str = ""
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class ReceiverUpdate(BaseModel):
|
class ReceiverUpdate(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
config: dict[str, Any] | None = None
|
config: dict[str, Any] | None = None
|
||||||
|
locale: str | None = None
|
||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ async def create_receiver(
|
|||||||
name=body.name,
|
name=body.name,
|
||||||
config=body.config,
|
config=body.config,
|
||||||
receiver_key=key,
|
receiver_key=key,
|
||||||
|
locale=body.locale,
|
||||||
enabled=body.enabled,
|
enabled=body.enabled,
|
||||||
)
|
)
|
||||||
session.add(receiver)
|
session.add(receiver)
|
||||||
@@ -133,7 +136,8 @@ async def test_receiver(
|
|||||||
raise HTTPException(status_code=404, detail="Receiver not found")
|
raise HTTPException(status_code=404, detail="Receiver not found")
|
||||||
|
|
||||||
from ..services.notifier import _get_test_message
|
from ..services.notifier import _get_test_message
|
||||||
message = _get_test_message(locale, target.type)
|
effective_locale = getattr(receiver, 'locale', '') or locale
|
||||||
|
message = _get_test_message(effective_locale, target.type)
|
||||||
return await send_to_receiver(target, dict(receiver.config), message)
|
return await send_to_receiver(target, dict(receiver.config), message)
|
||||||
|
|
||||||
|
|
||||||
@@ -159,6 +163,7 @@ def _response(r: TargetReceiver) -> dict:
|
|||||||
"name": r.name,
|
"name": r.name,
|
||||||
"config": dict(r.config),
|
"config": dict(r.config),
|
||||||
"receiver_key": r.receiver_key,
|
"receiver_key": r.receiver_key,
|
||||||
|
"locale": getattr(r, 'locale', '') or '',
|
||||||
"enabled": r.enabled,
|
"enabled": r.enabled,
|
||||||
"created_at": r.created_at.isoformat(),
|
"created_at": r.created_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ async def list_targets(
|
|||||||
chat = chat_result.first()
|
chat = chat_result.first()
|
||||||
if chat:
|
if chat:
|
||||||
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
|
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
|
||||||
lang = getattr(chat, 'language_code', '') or ''
|
lang = getattr(chat, 'language_override', '') or getattr(chat, 'language_code', '') or ''
|
||||||
if lang:
|
if lang:
|
||||||
chat_languages[f"{bot_id}_{chat_id}"] = lang
|
chat_languages[f"{bot_id}_{chat_id}"] = lang
|
||||||
|
|
||||||
@@ -278,6 +278,7 @@ def _target_response(
|
|||||||
"name": r.name,
|
"name": r.name,
|
||||||
"config": dict(r.config),
|
"config": dict(r.config),
|
||||||
"receiver_key": r.receiver_key,
|
"receiver_key": r.receiver_key,
|
||||||
|
"locale": getattr(r, 'locale', '') or '',
|
||||||
"enabled": r.enabled,
|
"enabled": r.enabled,
|
||||||
}
|
}
|
||||||
for r in recv_list
|
for r in recv_list
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from ..auth.dependencies import get_current_user
|
|||||||
from ..commands.handler import register_commands_with_telegram
|
from ..commands.handler import register_commands_with_telegram
|
||||||
from ..commands.webhook import register_webhook, unregister_webhook
|
from ..commands.webhook import register_webhook, unregister_webhook
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import AppSetting, TelegramBot, TelegramChat, User
|
from ..database.models import AppSetting, NotificationTarget, TargetReceiver, TelegramBot, TelegramChat, User
|
||||||
from ..services.notifier import _get_test_message
|
from ..services.notifier import _get_test_message
|
||||||
from ..services.telegram_poller import schedule_bot_polling, unschedule_bot_polling
|
from ..services.telegram_poller import schedule_bot_polling, unschedule_bot_polling
|
||||||
from .app_settings import get_setting
|
from .app_settings import get_setting
|
||||||
@@ -297,7 +297,7 @@ async def test_chat(
|
|||||||
|
|
||||||
|
|
||||||
class ChatUpdate(BaseModel):
|
class ChatUpdate(BaseModel):
|
||||||
language_code: str | None = None
|
language_override: str | None = None
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
commands_enabled: bool | None = None
|
commands_enabled: bool | None = None
|
||||||
|
|
||||||
@@ -389,6 +389,7 @@ def _chat_response(c: TelegramChat) -> dict:
|
|||||||
"type": c.chat_type,
|
"type": c.chat_type,
|
||||||
"username": c.username,
|
"username": c.username,
|
||||||
"language_code": getattr(c, 'language_code', '') or '',
|
"language_code": getattr(c, 'language_code', '') or '',
|
||||||
|
"language_override": getattr(c, 'language_override', '') or '',
|
||||||
"commands_enabled": getattr(c, 'commands_enabled', False),
|
"commands_enabled": getattr(c, 'commands_enabled', False),
|
||||||
"discovered_at": c.discovered_at.isoformat(),
|
"discovered_at": c.discovered_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ async def telegram_webhook(
|
|||||||
)).first()
|
)).first()
|
||||||
if not chat_row or not chat_row.commands_enabled:
|
if not chat_row or not chat_row.commands_enabled:
|
||||||
return {"ok": True, "skipped": "commands_disabled"}
|
return {"ok": True, "skipped": "commands_disabled"}
|
||||||
|
effective_lang = chat_row.language_override or msg_language
|
||||||
message_id = message.get("message_id")
|
message_id = message.get("message_id")
|
||||||
cmd_response = await handle_command(bot, chat_id, text, language_code=msg_language)
|
cmd_response = await handle_command(bot, chat_id, text, language_code=effective_lang)
|
||||||
if cmd_response is not None:
|
if cmd_response is not None:
|
||||||
if isinstance(cmd_response, list):
|
if isinstance(cmd_response, list):
|
||||||
await send_media_group(bot.token, chat_id, cmd_response, reply_to_message_id=message_id)
|
await send_media_group(bot.token, chat_id, cmd_response, reply_to_message_id=message_id)
|
||||||
|
|||||||
@@ -207,6 +207,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
|||||||
)
|
)
|
||||||
logger.info("Added language_code column to telegram_chat table")
|
logger.info("Added language_code column to telegram_chat table")
|
||||||
|
|
||||||
|
# Add language_override to telegram_chat if missing
|
||||||
|
if not await _has_column(conn, "telegram_chat", "language_override"):
|
||||||
|
await conn.execute(
|
||||||
|
text("ALTER TABLE telegram_chat ADD COLUMN language_override TEXT DEFAULT ''")
|
||||||
|
)
|
||||||
|
logger.info("Added language_override column to telegram_chat table")
|
||||||
|
|
||||||
|
# Add locale to target_receiver if missing
|
||||||
|
if await _has_table(conn, "target_receiver"):
|
||||||
|
if not await _has_column(conn, "target_receiver", "locale"):
|
||||||
|
await conn.execute(
|
||||||
|
text("ALTER TABLE target_receiver ADD COLUMN locale TEXT DEFAULT ''")
|
||||||
|
)
|
||||||
|
logger.info("Added locale column to target_receiver table")
|
||||||
|
|
||||||
# Add commands_enabled to telegram_chat if missing (default disabled)
|
# Add commands_enabled to telegram_chat if missing (default disabled)
|
||||||
if not await _has_column(conn, "telegram_chat", "commands_enabled"):
|
if not await _has_column(conn, "telegram_chat", "commands_enabled"):
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ class TelegramChat(SQLModel, table=True):
|
|||||||
title: str = Field(default="")
|
title: str = Field(default="")
|
||||||
chat_type: str = Field(default="private")
|
chat_type: str = Field(default="private")
|
||||||
username: str = Field(default="")
|
username: str = Field(default="")
|
||||||
language_code: str = Field(default="")
|
language_code: str = Field(default="") # auto-detected from Telegram
|
||||||
|
language_override: str = Field(default="") # manual override set by user
|
||||||
commands_enabled: bool = Field(default=False)
|
commands_enabled: bool = Field(default=False)
|
||||||
discovered_at: datetime = Field(default_factory=_utcnow)
|
discovered_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
@@ -279,6 +280,7 @@ class TargetReceiver(SQLModel, table=True):
|
|||||||
name: str = Field(default="")
|
name: str = Field(default="")
|
||||||
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
|
||||||
receiver_key: str = Field(default="") # dedup key (e.g. chat_id, url, email)
|
receiver_key: str = Field(default="") # dedup key (e.g. chat_id, url, email)
|
||||||
|
locale: str = Field(default="") # e.g. "en", "ru"; empty = use target default
|
||||||
enabled: bool = Field(default=True)
|
enabled: bool = Field(default=True)
|
||||||
created_at: datetime = Field(default_factory=_utcnow)
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlmodel import select
|
|||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
from notify_bridge_core.models.events import ServiceEvent
|
from notify_bridge_core.models.events import ServiceEvent
|
||||||
|
from notify_bridge_core.notifications.receiver import Receiver, build_receiver
|
||||||
|
|
||||||
from ..database.models import (
|
from ..database.models import (
|
||||||
EmailBot,
|
EmailBot,
|
||||||
@@ -17,6 +18,8 @@ from ..database.models import (
|
|||||||
NotificationTarget,
|
NotificationTarget,
|
||||||
NotificationTrackerTarget,
|
NotificationTrackerTarget,
|
||||||
TargetReceiver,
|
TargetReceiver,
|
||||||
|
TelegramBot,
|
||||||
|
TelegramChat,
|
||||||
TemplateConfig,
|
TemplateConfig,
|
||||||
TemplateSlot,
|
TemplateSlot,
|
||||||
TrackingConfig,
|
TrackingConfig,
|
||||||
@@ -115,14 +118,44 @@ async def load_link_data(
|
|||||||
if not target:
|
if not target:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Load receivers
|
# Load receivers as typed Receiver objects
|
||||||
recv_result = await session.exec(
|
recv_result = await session.exec(
|
||||||
select(TargetReceiver).where(
|
select(TargetReceiver).where(
|
||||||
TargetReceiver.target_id == target.id,
|
TargetReceiver.target_id == target.id,
|
||||||
TargetReceiver.enabled == True,
|
TargetReceiver.enabled == True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
receivers = [dict(r.config) for r in recv_result.all()]
|
recv_rows = recv_result.all()
|
||||||
|
|
||||||
|
# For Telegram targets, resolve locale from TelegramChat
|
||||||
|
chat_locale_map: dict[str, str] = {}
|
||||||
|
if target.type == "telegram":
|
||||||
|
bot_id = target.config.get("bot_id")
|
||||||
|
if bot_id:
|
||||||
|
chat_ids = [str(r.config.get("chat_id", "")) for r in recv_rows if r.config.get("chat_id")]
|
||||||
|
if chat_ids:
|
||||||
|
chat_result = await session.exec(
|
||||||
|
select(TelegramChat).where(
|
||||||
|
TelegramChat.bot_id == bot_id,
|
||||||
|
TelegramChat.chat_id.in_(chat_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for chat in chat_result.all():
|
||||||
|
resolved = (
|
||||||
|
getattr(chat, 'language_override', '') or
|
||||||
|
getattr(chat, 'language_code', '') or ''
|
||||||
|
)
|
||||||
|
if resolved:
|
||||||
|
chat_locale_map[chat.chat_id] = resolved[:2].lower()
|
||||||
|
|
||||||
|
receivers: list[Receiver] = []
|
||||||
|
for r in recv_rows:
|
||||||
|
explicit_locale = getattr(r, 'locale', '') or ''
|
||||||
|
locale = explicit_locale
|
||||||
|
if not locale and target.type == "telegram":
|
||||||
|
chat_id = str(r.config.get("chat_id", ""))
|
||||||
|
locale = chat_locale_map.get(chat_id, "")
|
||||||
|
receivers.append(build_receiver(target.type, dict(r.config), locale))
|
||||||
|
|
||||||
tracking_config = None
|
tracking_config = None
|
||||||
if tt.tracking_config_id:
|
if tt.tracking_config_id:
|
||||||
|
|||||||
@@ -207,8 +207,9 @@ async def _poll_bot(bot_id: int) -> None:
|
|||||||
)).first()
|
)).first()
|
||||||
if not chat_row or not chat_row.commands_enabled:
|
if not chat_row or not chat_row.commands_enabled:
|
||||||
continue
|
continue
|
||||||
|
effective_lang = chat_row.language_override or msg_language
|
||||||
message_id = message.get("message_id")
|
message_id = message.get("message_id")
|
||||||
cmd_response = await handle_command(bot_obj, chat_id, text, language_code=msg_language)
|
cmd_response = await handle_command(bot_obj, chat_id, text, language_code=effective_lang)
|
||||||
if cmd_response is not None:
|
if cmd_response is not None:
|
||||||
if isinstance(cmd_response, list):
|
if isinstance(cmd_response, list):
|
||||||
await send_media_group(bot_token, chat_id, cmd_response, reply_to_message_id=message_id)
|
await send_media_group(bot_token, chat_id, cmd_response, reply_to_message_id=message_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user