= { ...receiverForm };
+ // Enrich Telegram receiver with chat metadata
+ if (config.chat_id && addingReceiverForTarget) {
+ const target = allTargets.find(t => t.id === addingReceiverForTarget);
+ const botId = target?.config?.bot_id || target?.config?.telegram_bot_id;
+ if (botId && receiverBotChats[botId]) {
+ const chat = receiverBotChats[botId].find((c: any) => String(c.chat_id) === String(config.chat_id));
+ if (chat) {
+ config.chat_name = chat.title || chat.username || '';
+ if (chat.language_code) config.language_code = chat.language_code;
+ }
+ }
+ }
// Parse headers JSON for webhook
if ('headers' in config && typeof config.headers === 'string') {
if (config.headers) {
@@ -535,6 +547,9 @@
{receiverLabel(target, recv)}
+ {#if (recv as any).language_code || recv.config?.language_code}
+ {((recv as any).language_code || recv.config.language_code).toUpperCase()}
+ {/if}
r.receiver_key))}
- {@const chatItems = (receiverBotChats[botId] || []).map((c: TelegramChat) => ({
+ {@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
value: c.chat_id,
label: c.title || c.username || c.chat_id,
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
- desc: `${c.type} · ${c.chat_id}`,
+ desc: `${c.type}${c.language_code ? ' · ' + c.language_code.toUpperCase() : ''} · ${c.chat_id}`,
disabled: existingKeys.has(c.chat_id),
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
}))}
diff --git a/packages/server/src/notify_bridge_server/api/targets.py b/packages/server/src/notify_bridge_server/api/targets.py
index 2680760..24301d5 100644
--- a/packages/server/src/notify_bridge_server/api/targets.py
+++ b/packages/server/src/notify_bridge_server/api/targets.py
@@ -87,8 +87,9 @@ async def list_targets(
)
target_receivers[tgt.id] = list(recv_result.all())
- # Resolve chat names from receivers for telegram targets
+ # Resolve chat names and languages from receivers for telegram targets
chat_names: dict[str, str] = {}
+ chat_languages: dict[str, str] = {}
for tgt in targets:
if tgt.type == "telegram":
bot_id = tgt.config.get("bot_id")
@@ -106,9 +107,12 @@ async def list_targets(
chat = chat_result.first()
if chat:
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
+ lang = getattr(chat, 'language_code', '') or ''
+ if lang:
+ chat_languages[f"{bot_id}_{chat_id}"] = lang
return [
- _target_response(t, chat_names, target_receivers.get(t.id, []))
+ _target_response(t, chat_names, target_receivers.get(t.id, []), chat_languages)
for t in targets
]
@@ -257,6 +261,7 @@ def _target_response(
target: NotificationTarget,
chat_names: dict[str, str] | None = None,
receivers: list[TargetReceiver] | None = None,
+ chat_languages: dict[str, str] | None = None,
) -> dict:
recv_list = receivers or []
resp = {
@@ -287,6 +292,8 @@ def _target_response(
key = f"{bot_id}_{chat_id}"
if key in chat_names:
recv_resp["chat_name"] = chat_names[key]
+ if chat_languages and key in chat_languages:
+ recv_resp["language_code"] = chat_languages[key]
return resp
diff --git a/packages/server/src/notify_bridge_server/api/telegram_bots.py b/packages/server/src/notify_bridge_server/api/telegram_bots.py
index 2ab3173..5069992 100644
--- a/packages/server/src/notify_bridge_server/api/telegram_bots.py
+++ b/packages/server/src/notify_bridge_server/api/telegram_bots.py
@@ -9,7 +9,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
import aiohttp
-from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_URL
+from notify_bridge_core.notifications.telegram.client import TelegramClient
from ..auth.dependencies import get_current_user
from ..commands.handler import register_commands_with_telegram
@@ -291,22 +291,9 @@ async def test_chat(
"""Send a test message to a chat via the bot."""
bot = await _get_user_bot(session, bot_id, user.id)
message = _get_test_message(locale, "telegram")
- try:
- async with aiohttp.ClientSession() as http:
- async with http.post(
- f"{TELEGRAM_API_BASE_URL}{bot.token}/sendMessage",
- json={
- "chat_id": chat_id,
- "text": message,
- "parse_mode": "HTML",
- },
- ) as resp:
- data = await resp.json()
- if data.get("ok"):
- return {"success": True}
- return {"success": False, "error": data.get("description", "Unknown error")}
- except aiohttp.ClientError as e:
- return {"success": False, "error": str(e)}
+ async with aiohttp.ClientSession() as http:
+ client = TelegramClient(http, bot.token)
+ return await client.send_message(chat_id, message)
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -328,57 +315,42 @@ async def delete_chat(
# --- Helpers ---
async def _get_webhook_info(token: str) -> dict | None:
- """Call Telegram getWebhookInfo to check current webhook state."""
- try:
- async with aiohttp.ClientSession() as http:
- async with http.get(f"{TELEGRAM_API_BASE_URL}{token}/getWebhookInfo") as resp:
- data = await resp.json()
- if data.get("ok"):
- return data.get("result", {})
- except aiohttp.ClientError:
- pass
- return None
+ """Call Telegram getWebhookInfo via TelegramClient."""
+ async with aiohttp.ClientSession() as http:
+ client = TelegramClient(http, token)
+ result = await client.get_webhook_info()
+ return result.get("result") if result.get("success") else None
async def _get_me(token: str) -> dict | None:
- """Call Telegram getMe to validate token and get bot info."""
- try:
- async with aiohttp.ClientSession() as http:
- async with http.get(f"{TELEGRAM_API_BASE_URL}{token}/getMe") as resp:
- data = await resp.json()
- if data.get("ok"):
- return data.get("result", {})
- except aiohttp.ClientError:
- pass
- return None
+ """Call Telegram getMe via TelegramClient."""
+ async with aiohttp.ClientSession() as http:
+ client = TelegramClient(http, token)
+ result = await client.get_me()
+ return result.get("result") if result.get("success") else None
async def _fetch_chats_from_telegram(token: str) -> list[dict]:
- """Fetch chats from Telegram getUpdates API."""
- seen: dict[int, dict] = {}
- try:
- async with aiohttp.ClientSession() as http:
- async with http.get(
- f"{TELEGRAM_API_BASE_URL}{token}/getUpdates",
- params={"limit": 100, "allowed_updates": '["message"]'},
- ) as resp:
- data = await resp.json()
- if not data.get("ok"):
- return []
- for update in data.get("result", []):
- msg = update.get("message", {})
- chat = msg.get("chat", {})
- chat_id = chat.get("id")
- if chat_id and chat_id not in seen:
- seen[chat_id] = {
- "id": chat_id,
- "title": chat.get("title") or (chat.get("first_name", "") + (" " + chat.get("last_name", "")).strip()),
- "type": chat.get("type", "private"),
- "username": chat.get("username", ""),
- }
- except aiohttp.ClientError:
- pass
- return list(seen.values())
+ """Fetch chats from Telegram getUpdates via TelegramClient."""
+ async with aiohttp.ClientSession() as http:
+ client = TelegramClient(http, token)
+ result = await client.get_updates(limit=100)
+ if not result.get("success"):
+ return []
+
+ seen: dict[int, dict] = {}
+ for update in result.get("result", []):
+ msg = update.get("message", {})
+ chat = msg.get("chat", {})
+ chat_id = chat.get("id")
+ if chat_id and chat_id not in seen:
+ seen[chat_id] = {
+ "id": chat_id,
+ "title": chat.get("title") or (chat.get("first_name", "") + (" " + chat.get("last_name", "")).strip()),
+ "type": chat.get("type", "private"),
+ "username": chat.get("username", ""),
+ }
+ return list(seen.values())
def _chat_response(c: TelegramChat) -> dict:
@@ -388,6 +360,7 @@ def _chat_response(c: TelegramChat) -> dict:
"title": c.title,
"type": c.chat_type,
"username": c.username,
+ "language_code": getattr(c, 'language_code', '') or '',
"discovered_at": c.discovered_at.isoformat(),
}
diff --git a/packages/server/src/notify_bridge_server/commands/webhook.py b/packages/server/src/notify_bridge_server/commands/webhook.py
index 99833da..66f2f62 100644
--- a/packages/server/src/notify_bridge_server/commands/webhook.py
+++ b/packages/server/src/notify_bridge_server/commands/webhook.py
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Request
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
-from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_URL
+from notify_bridge_core.notifications.telegram.client import TelegramClient
from ..database.engine import get_session
from ..database.models import TelegramBot
@@ -68,50 +68,37 @@ async def telegram_webhook(
return {"ok": True, "skipped": "empty"}
# Auto-persist chat from incoming message
+ from_user = message.get("from", {})
+ msg_language = from_user.get("language_code", "")
try:
- await save_chat_from_webhook(session, bot.id, chat_info)
+ await save_chat_from_webhook(session, bot.id, chat_info, language_code=msg_language)
await session.commit()
except Exception:
_LOGGER.warning("Failed to auto-save chat %s", chat_id, exc_info=True)
# Handle commands
if text.startswith("/"):
- language_code = message.get("from", {}).get("language_code", "")
- cmd_response = await handle_command(bot, chat_id, text, language_code=language_code)
+ message_id = message.get("message_id")
+ cmd_response = await handle_command(bot, chat_id, text, language_code=msg_language)
if cmd_response is not None:
if isinstance(cmd_response, list):
- await send_media_group(bot.token, chat_id, cmd_response)
+ await send_media_group(bot.token, chat_id, cmd_response, reply_to_message_id=message_id)
else:
- await send_reply(bot.token, chat_id, cmd_response)
+ await send_reply(bot.token, chat_id, cmd_response, reply_to_message_id=message_id)
return {"ok": True}
return {"ok": True, "skipped": "not_a_command"}
async def register_webhook(bot_token: str, webhook_url: str, secret: str | None = None) -> dict:
- """Register webhook URL with Telegram Bot API."""
+ """Register webhook URL with Telegram Bot API via TelegramClient."""
async with aiohttp.ClientSession() as http:
- url = f"{TELEGRAM_API_BASE_URL}{bot_token}/setWebhook"
- payload: dict[str, Any] = {"url": webhook_url}
- if secret:
- payload["secret_token"] = secret
- try:
- async with http.post(url, json=payload) as resp:
- result = await resp.json()
- if result.get("ok"):
- return {"success": True}
- return {"success": False, "error": result.get("description")}
- except aiohttp.ClientError as err:
- return {"success": False, "error": str(err)}
+ client = TelegramClient(http, bot_token)
+ return await client.set_webhook(webhook_url, secret=secret)
async def unregister_webhook(bot_token: str) -> dict:
- """Remove webhook from Telegram Bot API."""
+ """Remove webhook from Telegram Bot API via TelegramClient."""
async with aiohttp.ClientSession() as http:
- url = f"{TELEGRAM_API_BASE_URL}{bot_token}/deleteWebhook"
- try:
- async with http.post(url) as resp:
- result = await resp.json()
- return {"success": result.get("ok", False)}
- except aiohttp.ClientError as err:
- return {"success": False, "error": str(err)}
+ client = TelegramClient(http, bot_token)
+ return await client.delete_webhook()
diff --git a/packages/server/src/notify_bridge_server/database/migrations.py b/packages/server/src/notify_bridge_server/database/migrations.py
index 1ce0715..a54180c 100644
--- a/packages/server/src/notify_bridge_server/database/migrations.py
+++ b/packages/server/src/notify_bridge_server/database/migrations.py
@@ -173,6 +173,14 @@ async def migrate_schema(engine: AsyncEngine) -> None:
)
logger.info("Added shared column to %s table", state_table)
+ # Add language_code to telegram_chat if missing
+ if await _has_table(conn, "telegram_chat"):
+ if not await _has_column(conn, "telegram_chat", "language_code"):
+ await conn.execute(
+ text("ALTER TABLE telegram_chat ADD COLUMN language_code TEXT DEFAULT ''")
+ )
+ logger.info("Added language_code column to telegram_chat table")
+
# ---------------------------------------------------------------------------
# Legacy tracker_target migration (pre-Phase 1)
diff --git a/packages/server/src/notify_bridge_server/database/models.py b/packages/server/src/notify_bridge_server/database/models.py
index 03a77be..df35b98 100644
--- a/packages/server/src/notify_bridge_server/database/models.py
+++ b/packages/server/src/notify_bridge_server/database/models.py
@@ -95,6 +95,7 @@ class TelegramChat(SQLModel, table=True):
title: str = Field(default="")
chat_type: str = Field(default="private")
username: str = Field(default="")
+ language_code: str = Field(default="")
discovered_at: datetime = Field(default_factory=_utcnow)
diff --git a/packages/server/src/notify_bridge_server/services/telegram.py b/packages/server/src/notify_bridge_server/services/telegram.py
index 2943cf9..8216640 100644
--- a/packages/server/src/notify_bridge_server/services/telegram.py
+++ b/packages/server/src/notify_bridge_server/services/telegram.py
@@ -7,7 +7,8 @@ from ..database.models import TelegramChat
async def save_chat_from_webhook(
- session: AsyncSession, bot_id: int, chat_data: dict
+ session: AsyncSession, bot_id: int, chat_data: dict,
+ language_code: str = "",
) -> None:
"""Save or update a chat entry from an incoming webhook message.
@@ -32,6 +33,8 @@ async def save_chat_from_webhook(
if existing:
existing.title = title
existing.username = chat_data.get("username", existing.username)
+ if language_code:
+ existing.language_code = language_code
session.add(existing)
else:
session.add(TelegramChat(
@@ -40,4 +43,5 @@ async def save_chat_from_webhook(
title=title,
chat_type=chat_data.get("type", "private"),
username=chat_data.get("username", ""),
+ language_code=language_code,
))
diff --git a/packages/server/src/notify_bridge_server/services/telegram_poller.py b/packages/server/src/notify_bridge_server/services/telegram_poller.py
index 1203d51..d0ea1a4 100644
--- a/packages/server/src/notify_bridge_server/services/telegram_poller.py
+++ b/packages/server/src/notify_bridge_server/services/telegram_poller.py
@@ -17,7 +17,7 @@ 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 notify_bridge_core.notifications.telegram.client import TelegramClient
from ..database.engine import get_engine
from ..database.models import CommandTracker, CommandTrackerListener, TelegramBot
@@ -150,25 +150,16 @@ async def _poll_bot(bot_id: int) -> None:
bot_obj = bot
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", [])
+ client = TelegramClient(http, bot_token)
+ result = await client.get_updates(
+ offset=offset + 1 if offset else None, limit=50,
+ )
+ if not result.get("success"):
+ return
+ updates = result.get("result", [])
except Exception as e:
_LOGGER.debug("Polling error for bot %d: %s", bot_id, e)
return
@@ -190,6 +181,8 @@ async def _poll_bot(bot_id: int) -> None:
chat_info = message.get("chat", {})
chat_id = str(chat_info.get("id", ""))
text = message.get("text", "")
+ from_user = message.get("from", {})
+ msg_language = from_user.get("language_code", "")
if not chat_id:
continue
@@ -197,7 +190,7 @@ async def _poll_bot(bot_id: int) -> None:
# Auto-persist chat (fresh session per save)
try:
async with AsyncSession(engine) as save_session:
- await save_chat_from_webhook(save_session, bot_obj.id, chat_info)
+ await save_chat_from_webhook(save_session, bot_obj.id, chat_info, language_code=msg_language)
await save_session.commit()
except Exception:
_LOGGER.debug("Failed to auto-save chat %s", chat_id, exc_info=True)
@@ -205,13 +198,13 @@ async def _poll_bot(bot_id: int) -> None:
# Dispatch commands
if text and text.startswith("/"):
try:
- language_code = message.get("from", {}).get("language_code", "")
- cmd_response = await handle_command(bot_obj, chat_id, text, language_code=language_code)
+ message_id = message.get("message_id")
+ cmd_response = await handle_command(bot_obj, chat_id, text, language_code=msg_language)
if cmd_response is not None:
if isinstance(cmd_response, list):
- await send_media_group(bot_token, chat_id, cmd_response)
+ await send_media_group(bot_token, chat_id, cmd_response, reply_to_message_id=message_id)
else:
- await send_reply(bot_token, chat_id, cmd_response)
+ await send_reply(bot_token, chat_id, cmd_response, reply_to_message_id=message_id)
except Exception:
_LOGGER.error("Error handling command from bot %d", bot_id, exc_info=True)
diff --git a/scripts/restart-backend.sh b/scripts/restart-backend.sh
new file mode 100644
index 0000000..7add044
--- /dev/null
+++ b/scripts/restart-backend.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# Restart the backend dev server.
+# Usage: bash scripts/restart-backend.sh
+
+set -e
+cd "$(dirname "$0")/.."
+
+# Kill existing backend
+PID=$(netstat -ano 2>/dev/null | grep ':8420.*LISTENING' | awk '{print $5}' | head -1)
+if [ -n "$PID" ]; then
+ taskkill //F //PID "$PID" 2>/dev/null || true
+ sleep 1
+fi
+
+# Start backend
+NOTIFY_BRIDGE_DATA_DIR=./test-data \
+NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32chars \
+nohup python -m uvicorn notify_bridge_server.main:app \
+ --host 0.0.0.0 --port 8420 > /dev/null 2>&1 &
+
+sleep 3
+curl -s http://localhost:8420/api/health
diff --git a/scripts/restart-frontend.sh b/scripts/restart-frontend.sh
new file mode 100644
index 0000000..016c5d1
--- /dev/null
+++ b/scripts/restart-frontend.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# Restart the frontend dev server.
+# Usage: bash scripts/restart-frontend.sh
+
+set -e
+cd "$(dirname "$0")/.."
+
+# Kill existing frontend
+PID=$(netstat -ano 2>/dev/null | grep ':5173.*LISTENING' | awk '{print $5}' | head -1)
+if [ -n "$PID" ]; then
+ taskkill //F //PID "$PID" 2>/dev/null || true
+ sleep 1
+fi
+
+# Start frontend
+cd frontend
+npx vite dev --port 5173 --host > /dev/null 2>&1 &
+cd ..
+
+sleep 4
+curl -s -o /dev/null -w "Frontend: %{http_code}\n" http://localhost:5173/