feat: chat language display, disabled EntitySelect items, dev scripts
Chat language: - Added language_code field to TelegramChat model + migration - Saved from message.from.language_code on webhook/polling - Displayed as badge on bot chat cards and target receiver items - Resolved from DB in target API response (works for existing receivers) - Shown in chat picker dropdown (desc includes language) EntitySelect improvements: - Tracker-target link selector shows all targets, already-linked ones appear disabled with "Already linked" hint - Receiver chat picker shows already-added chats as disabled Dev scripts: - scripts/restart-backend.sh and restart-frontend.sh - Updated .claude/docs/dev-servers.md to reference scripts
This commit is contained in:
@@ -1,13 +1,15 @@
|
|||||||
# Development Servers
|
# Development Servers
|
||||||
|
|
||||||
**MANDATORY**: You MUST restart the backend server IMMEDIATELY after ANY backend code change (files in `packages/server/` or `packages/core/`). Do NOT wait for the user to ask — restart automatically every time. Failure to restart means the user will test against stale code and encounter bugs that don't exist. Use this one-liner:
|
**MANDATORY**: You MUST restart the backend server IMMEDIATELY after ANY backend code change (files in `packages/server/` or `packages/core/`). Do NOT wait for the user to ask — restart automatically every time. Failure to restart means the user will test against stale code and encounter bugs that don't exist.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PID=$(netstat -ano 2>/dev/null | grep ':8420.*LISTENING' | awk '{print $5}' | head -1) && [ -n "$PID" ] && taskkill //F //PID $PID 2>/dev/null; sleep 1 && cd packages/server && pip install -e . 2>&1 | tail -1 && cd ../.. && 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
|
bash scripts/restart-backend.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
**IMPORTANT**: When the user requests it, restart the frontend dev server using this one-liner:
|
**IMPORTANT**: When the user requests it, restart the frontend dev server:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
PID=$(netstat -ano 2>/dev/null | grep ':5173.*LISTENING' | awk '{print $5}' | head -1) && [ -n "$PID" ] && taskkill //F //PID $PID 2>/dev/null; sleep 1 && cd frontend && npx vite dev --port 5173 --host > /dev/null 2>&1 & sleep 4 && curl -s -o /dev/null -w "Frontend: %{http_code}" http://localhost:5173/
|
bash scripts/restart-frontend.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test Credentials
|
## Test Credentials
|
||||||
|
|||||||
@@ -176,6 +176,7 @@
|
|||||||
"linkedTargets": "targets",
|
"linkedTargets": "targets",
|
||||||
"noLinkedTargets": "No targets linked. Add a target below.",
|
"noLinkedTargets": "No targets linked. Add a target below.",
|
||||||
"addTarget": "Add target",
|
"addTarget": "Add target",
|
||||||
|
"alreadyLinked": "Already linked",
|
||||||
"testBasic": "Send test message",
|
"testBasic": "Send test message",
|
||||||
"testPeriodic": "Test periodic summary",
|
"testPeriodic": "Test periodic summary",
|
||||||
"testScheduled": "Test scheduled assets",
|
"testScheduled": "Test scheduled assets",
|
||||||
|
|||||||
@@ -176,6 +176,7 @@
|
|||||||
"linkedTargets": "получатели",
|
"linkedTargets": "получатели",
|
||||||
"noLinkedTargets": "Нет привязанных получателей. Добавьте получателя ниже.",
|
"noLinkedTargets": "Нет привязанных получателей. Добавьте получателя ниже.",
|
||||||
"addTarget": "Добавить получателя",
|
"addTarget": "Добавить получателя",
|
||||||
|
"alreadyLinked": "Уже привязан",
|
||||||
"testBasic": "Отправить тестовое сообщение",
|
"testBasic": "Отправить тестовое сообщение",
|
||||||
"testPeriodic": "Тест периодической сводки",
|
"testPeriodic": "Тест периодической сводки",
|
||||||
"testScheduled": "Тест запланированных фото",
|
"testScheduled": "Тест запланированных фото",
|
||||||
|
|||||||
@@ -306,6 +306,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-medium">{chat.title || chat.username || 'Unknown'}</span>
|
<span class="font-medium">{chat.title || chat.username || 'Unknown'}</span>
|
||||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
|
||||||
|
{#if chat.language_code}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chat.language_code.toUpperCase()}</span>{/if}
|
||||||
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
@@ -429,7 +429,7 @@
|
|||||||
{tracker}
|
{tracker}
|
||||||
{trackingConfigs}
|
{trackingConfigs}
|
||||||
{templateConfigs}
|
{templateConfigs}
|
||||||
unlinkedTargets={getUnlinkedTargets(tracker)}
|
unlinkedTargets={targets}
|
||||||
newLinkTargetId={newLinkTargetId[tracker.id] || 0}
|
newLinkTargetId={newLinkTargetId[tracker.id] || 0}
|
||||||
newLinkTrackingConfigId={newLinkTrackingConfigId[tracker.id] || 0}
|
newLinkTrackingConfigId={newLinkTrackingConfigId[tracker.id] || 0}
|
||||||
newLinkTemplateConfigId={newLinkTemplateConfigId[tracker.id] || 0}
|
newLinkTemplateConfigId={newLinkTemplateConfigId[tracker.id] || 0}
|
||||||
|
|||||||
@@ -57,11 +57,14 @@
|
|||||||
|
|
||||||
const trackingConfigItems = $derived(toItems(trackingConfigs));
|
const trackingConfigItems = $derived(toItems(trackingConfigs));
|
||||||
const templateConfigItems = $derived(toItems(templateConfigs));
|
const templateConfigItems = $derived(toItems(templateConfigs));
|
||||||
|
const linkedTargetIds = $derived(new Set((tracker.tracker_targets || []).map((tt: any) => tt.target_id)));
|
||||||
const targetItems = $derived<EntityItem[]>(unlinkedTargets.map(tgt => ({
|
const targetItems = $derived<EntityItem[]>(unlinkedTargets.map(tgt => ({
|
||||||
value: tgt.id,
|
value: tgt.id,
|
||||||
label: tgt.name,
|
label: tgt.name,
|
||||||
icon: tgt.icon || (tgt.type === 'telegram' ? 'mdiSend' : 'mdiWebhook'),
|
icon: tgt.icon || (tgt.type === 'telegram' ? 'mdiSend' : 'mdiWebhook'),
|
||||||
desc: tgt.type,
|
desc: tgt.type,
|
||||||
|
disabled: linkedTargetIds.has(tgt.id),
|
||||||
|
disabledHint: linkedTargetIds.has(tgt.id) ? t('notificationTracker.alreadyLinked') : undefined,
|
||||||
})));
|
})));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -311,7 +311,19 @@
|
|||||||
receiverSubmitting = true;
|
receiverSubmitting = true;
|
||||||
receiverHeadersError = '';
|
receiverHeadersError = '';
|
||||||
try {
|
try {
|
||||||
const config = { ...receiverForm };
|
const config: Record<string, any> = { ...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
|
// Parse headers JSON for webhook
|
||||||
if ('headers' in config && typeof config.headers === 'string') {
|
if ('headers' in config && typeof config.headers === 'string') {
|
||||||
if (config.headers) {
|
if (config.headers) {
|
||||||
@@ -535,6 +547,9 @@
|
|||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<MdiIcon name={TYPE_ICONS[target.type] || 'mdiTarget'} size={14} />
|
<MdiIcon name={TYPE_ICONS[target.type] || 'mdiTarget'} size={14} />
|
||||||
<span class="text-sm truncate">{receiverLabel(target, recv)}</span>
|
<span class="text-sm truncate">{receiverLabel(target, recv)}</span>
|
||||||
|
{#if (recv as any).language_code || recv.config?.language_code}
|
||||||
|
<span class="text-xs px-1 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{((recv as any).language_code || recv.config.language_code).toUpperCase()}</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<IconButton icon="mdiSend" title={t('targets.test')}
|
<IconButton icon="mdiSend" title={t('targets.test')}
|
||||||
@@ -563,11 +578,11 @@
|
|||||||
{#if target.type === 'telegram'}
|
{#if target.type === 'telegram'}
|
||||||
{@const botId = target.config?.bot_id}
|
{@const botId = target.config?.bot_id}
|
||||||
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
|
{@const existingKeys = new Set((target.receivers || []).map((r: TargetReceiver) => r.receiver_key))}
|
||||||
{@const chatItems = (receiverBotChats[botId] || []).map((c: TelegramChat) => ({
|
{@const chatItems = (receiverBotChats[botId] || []).map((c: any) => ({
|
||||||
value: c.chat_id,
|
value: c.chat_id,
|
||||||
label: c.title || c.username || c.chat_id,
|
label: c.title || c.username || c.chat_id,
|
||||||
icon: c.type === 'private' ? 'mdiAccount' : c.type === 'channel' ? 'mdiBullhorn' : 'mdiAccountGroup',
|
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),
|
disabled: existingKeys.has(c.chat_id),
|
||||||
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
|
disabledHint: existingKeys.has(c.chat_id) ? t('targets.alreadyAdded') : undefined,
|
||||||
}))}
|
}))}
|
||||||
|
|||||||
@@ -87,8 +87,9 @@ async def list_targets(
|
|||||||
)
|
)
|
||||||
target_receivers[tgt.id] = list(recv_result.all())
|
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_names: dict[str, str] = {}
|
||||||
|
chat_languages: dict[str, str] = {}
|
||||||
for tgt in targets:
|
for tgt in targets:
|
||||||
if tgt.type == "telegram":
|
if tgt.type == "telegram":
|
||||||
bot_id = tgt.config.get("bot_id")
|
bot_id = tgt.config.get("bot_id")
|
||||||
@@ -106,9 +107,12 @@ 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 ''
|
||||||
|
if lang:
|
||||||
|
chat_languages[f"{bot_id}_{chat_id}"] = lang
|
||||||
|
|
||||||
return [
|
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
|
for t in targets
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -257,6 +261,7 @@ def _target_response(
|
|||||||
target: NotificationTarget,
|
target: NotificationTarget,
|
||||||
chat_names: dict[str, str] | None = None,
|
chat_names: dict[str, str] | None = None,
|
||||||
receivers: list[TargetReceiver] | None = None,
|
receivers: list[TargetReceiver] | None = None,
|
||||||
|
chat_languages: dict[str, str] | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
recv_list = receivers or []
|
recv_list = receivers or []
|
||||||
resp = {
|
resp = {
|
||||||
@@ -287,6 +292,8 @@ def _target_response(
|
|||||||
key = f"{bot_id}_{chat_id}"
|
key = f"{bot_id}_{chat_id}"
|
||||||
if key in chat_names:
|
if key in chat_names:
|
||||||
recv_resp["chat_name"] = chat_names[key]
|
recv_resp["chat_name"] = chat_names[key]
|
||||||
|
if chat_languages and key in chat_languages:
|
||||||
|
recv_resp["language_code"] = chat_languages[key]
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
|
|
||||||
import aiohttp
|
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 ..auth.dependencies import get_current_user
|
||||||
from ..commands.handler import register_commands_with_telegram
|
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."""
|
"""Send a test message to a chat via the bot."""
|
||||||
bot = await _get_user_bot(session, bot_id, user.id)
|
bot = await _get_user_bot(session, bot_id, user.id)
|
||||||
message = _get_test_message(locale, "telegram")
|
message = _get_test_message(locale, "telegram")
|
||||||
try:
|
async with aiohttp.ClientSession() as http:
|
||||||
async with aiohttp.ClientSession() as http:
|
client = TelegramClient(http, bot.token)
|
||||||
async with http.post(
|
return await client.send_message(chat_id, message)
|
||||||
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)}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
@@ -328,57 +315,42 @@ async def delete_chat(
|
|||||||
# --- Helpers ---
|
# --- Helpers ---
|
||||||
|
|
||||||
async def _get_webhook_info(token: str) -> dict | None:
|
async def _get_webhook_info(token: str) -> dict | None:
|
||||||
"""Call Telegram getWebhookInfo to check current webhook state."""
|
"""Call Telegram getWebhookInfo via TelegramClient."""
|
||||||
try:
|
async with aiohttp.ClientSession() as http:
|
||||||
async with aiohttp.ClientSession() as http:
|
client = TelegramClient(http, token)
|
||||||
async with http.get(f"{TELEGRAM_API_BASE_URL}{token}/getWebhookInfo") as resp:
|
result = await client.get_webhook_info()
|
||||||
data = await resp.json()
|
return result.get("result") if result.get("success") else None
|
||||||
if data.get("ok"):
|
|
||||||
return data.get("result", {})
|
|
||||||
except aiohttp.ClientError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_me(token: str) -> dict | None:
|
async def _get_me(token: str) -> dict | None:
|
||||||
"""Call Telegram getMe to validate token and get bot info."""
|
"""Call Telegram getMe via TelegramClient."""
|
||||||
try:
|
async with aiohttp.ClientSession() as http:
|
||||||
async with aiohttp.ClientSession() as http:
|
client = TelegramClient(http, token)
|
||||||
async with http.get(f"{TELEGRAM_API_BASE_URL}{token}/getMe") as resp:
|
result = await client.get_me()
|
||||||
data = await resp.json()
|
return result.get("result") if result.get("success") else None
|
||||||
if data.get("ok"):
|
|
||||||
return data.get("result", {})
|
|
||||||
except aiohttp.ClientError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_chats_from_telegram(token: str) -> list[dict]:
|
async def _fetch_chats_from_telegram(token: str) -> list[dict]:
|
||||||
"""Fetch chats from Telegram getUpdates API."""
|
"""Fetch chats from Telegram getUpdates via TelegramClient."""
|
||||||
seen: dict[int, dict] = {}
|
async with aiohttp.ClientSession() as http:
|
||||||
try:
|
client = TelegramClient(http, token)
|
||||||
async with aiohttp.ClientSession() as http:
|
result = await client.get_updates(limit=100)
|
||||||
async with http.get(
|
if not result.get("success"):
|
||||||
f"{TELEGRAM_API_BASE_URL}{token}/getUpdates",
|
return []
|
||||||
params={"limit": 100, "allowed_updates": '["message"]'},
|
|
||||||
) as resp:
|
seen: dict[int, dict] = {}
|
||||||
data = await resp.json()
|
for update in result.get("result", []):
|
||||||
if not data.get("ok"):
|
msg = update.get("message", {})
|
||||||
return []
|
chat = msg.get("chat", {})
|
||||||
for update in data.get("result", []):
|
chat_id = chat.get("id")
|
||||||
msg = update.get("message", {})
|
if chat_id and chat_id not in seen:
|
||||||
chat = msg.get("chat", {})
|
seen[chat_id] = {
|
||||||
chat_id = chat.get("id")
|
"id": chat_id,
|
||||||
if chat_id and chat_id not in seen:
|
"title": chat.get("title") or (chat.get("first_name", "") + (" " + chat.get("last_name", "")).strip()),
|
||||||
seen[chat_id] = {
|
"type": chat.get("type", "private"),
|
||||||
"id": chat_id,
|
"username": chat.get("username", ""),
|
||||||
"title": chat.get("title") or (chat.get("first_name", "") + (" " + chat.get("last_name", "")).strip()),
|
}
|
||||||
"type": chat.get("type", "private"),
|
return list(seen.values())
|
||||||
"username": chat.get("username", ""),
|
|
||||||
}
|
|
||||||
except aiohttp.ClientError:
|
|
||||||
pass
|
|
||||||
return list(seen.values())
|
|
||||||
|
|
||||||
|
|
||||||
def _chat_response(c: TelegramChat) -> dict:
|
def _chat_response(c: TelegramChat) -> dict:
|
||||||
@@ -388,6 +360,7 @@ def _chat_response(c: TelegramChat) -> dict:
|
|||||||
"title": c.title,
|
"title": c.title,
|
||||||
"type": c.chat_type,
|
"type": c.chat_type,
|
||||||
"username": c.username,
|
"username": c.username,
|
||||||
|
"language_code": getattr(c, 'language_code', '') or '',
|
||||||
"discovered_at": c.discovered_at.isoformat(),
|
"discovered_at": c.discovered_at.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
|||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
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.engine import get_session
|
||||||
from ..database.models import TelegramBot
|
from ..database.models import TelegramBot
|
||||||
@@ -68,50 +68,37 @@ async def telegram_webhook(
|
|||||||
return {"ok": True, "skipped": "empty"}
|
return {"ok": True, "skipped": "empty"}
|
||||||
|
|
||||||
# Auto-persist chat from incoming message
|
# Auto-persist chat from incoming message
|
||||||
|
from_user = message.get("from", {})
|
||||||
|
msg_language = from_user.get("language_code", "")
|
||||||
try:
|
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()
|
await session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.warning("Failed to auto-save chat %s", chat_id, exc_info=True)
|
_LOGGER.warning("Failed to auto-save chat %s", chat_id, exc_info=True)
|
||||||
|
|
||||||
# Handle commands
|
# Handle commands
|
||||||
if text.startswith("/"):
|
if text.startswith("/"):
|
||||||
language_code = message.get("from", {}).get("language_code", "")
|
message_id = message.get("message_id")
|
||||||
cmd_response = await handle_command(bot, chat_id, text, language_code=language_code)
|
cmd_response = await handle_command(bot, chat_id, text, language_code=msg_language)
|
||||||
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)
|
await send_media_group(bot.token, chat_id, cmd_response, reply_to_message_id=message_id)
|
||||||
else:
|
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}
|
||||||
|
|
||||||
return {"ok": True, "skipped": "not_a_command"}
|
return {"ok": True, "skipped": "not_a_command"}
|
||||||
|
|
||||||
|
|
||||||
async def register_webhook(bot_token: str, webhook_url: str, secret: str | None = None) -> dict:
|
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:
|
async with aiohttp.ClientSession() as http:
|
||||||
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/setWebhook"
|
client = TelegramClient(http, bot_token)
|
||||||
payload: dict[str, Any] = {"url": webhook_url}
|
return await client.set_webhook(webhook_url, secret=secret)
|
||||||
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)}
|
|
||||||
|
|
||||||
|
|
||||||
async def unregister_webhook(bot_token: str) -> dict:
|
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:
|
async with aiohttp.ClientSession() as http:
|
||||||
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/deleteWebhook"
|
client = TelegramClient(http, bot_token)
|
||||||
try:
|
return await client.delete_webhook()
|
||||||
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)}
|
|
||||||
|
|||||||
@@ -173,6 +173,14 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
|||||||
)
|
)
|
||||||
logger.info("Added shared column to %s table", state_table)
|
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)
|
# Legacy tracker_target migration (pre-Phase 1)
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ 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="")
|
||||||
discovered_at: datetime = Field(default_factory=_utcnow)
|
discovered_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from ..database.models import TelegramChat
|
|||||||
|
|
||||||
|
|
||||||
async def save_chat_from_webhook(
|
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:
|
) -> None:
|
||||||
"""Save or update a chat entry from an incoming webhook message.
|
"""Save or update a chat entry from an incoming webhook message.
|
||||||
|
|
||||||
@@ -32,6 +33,8 @@ async def save_chat_from_webhook(
|
|||||||
if existing:
|
if existing:
|
||||||
existing.title = title
|
existing.title = title
|
||||||
existing.username = chat_data.get("username", existing.username)
|
existing.username = chat_data.get("username", existing.username)
|
||||||
|
if language_code:
|
||||||
|
existing.language_code = language_code
|
||||||
session.add(existing)
|
session.add(existing)
|
||||||
else:
|
else:
|
||||||
session.add(TelegramChat(
|
session.add(TelegramChat(
|
||||||
@@ -40,4 +43,5 @@ async def save_chat_from_webhook(
|
|||||||
title=title,
|
title=title,
|
||||||
chat_type=chat_data.get("type", "private"),
|
chat_type=chat_data.get("type", "private"),
|
||||||
username=chat_data.get("username", ""),
|
username=chat_data.get("username", ""),
|
||||||
|
language_code=language_code,
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import aiohttp
|
|||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
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.engine import get_engine
|
||||||
from ..database.models import CommandTracker, CommandTrackerListener, TelegramBot
|
from ..database.models import CommandTracker, CommandTrackerListener, TelegramBot
|
||||||
@@ -150,25 +150,16 @@ async def _poll_bot(bot_id: int) -> None:
|
|||||||
bot_obj = bot
|
bot_obj = bot
|
||||||
|
|
||||||
offset = _last_update_id.get(bot_id, 0)
|
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:
|
try:
|
||||||
async with aiohttp.ClientSession() as http:
|
async with aiohttp.ClientSession() as http:
|
||||||
async with http.get(
|
client = TelegramClient(http, bot_token)
|
||||||
f"{TELEGRAM_API_BASE_URL}{bot_token}/getUpdates",
|
result = await client.get_updates(
|
||||||
params=params,
|
offset=offset + 1 if offset else None, limit=50,
|
||||||
timeout=aiohttp.ClientTimeout(total=10),
|
)
|
||||||
) as resp:
|
if not result.get("success"):
|
||||||
data = await resp.json()
|
return
|
||||||
if not data.get("ok"):
|
updates = result.get("result", [])
|
||||||
return
|
|
||||||
updates = data.get("result", [])
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_LOGGER.debug("Polling error for bot %d: %s", bot_id, e)
|
_LOGGER.debug("Polling error for bot %d: %s", bot_id, e)
|
||||||
return
|
return
|
||||||
@@ -190,6 +181,8 @@ async def _poll_bot(bot_id: int) -> None:
|
|||||||
chat_info = message.get("chat", {})
|
chat_info = message.get("chat", {})
|
||||||
chat_id = str(chat_info.get("id", ""))
|
chat_id = str(chat_info.get("id", ""))
|
||||||
text = message.get("text", "")
|
text = message.get("text", "")
|
||||||
|
from_user = message.get("from", {})
|
||||||
|
msg_language = from_user.get("language_code", "")
|
||||||
|
|
||||||
if not chat_id:
|
if not chat_id:
|
||||||
continue
|
continue
|
||||||
@@ -197,7 +190,7 @@ async def _poll_bot(bot_id: int) -> None:
|
|||||||
# Auto-persist chat (fresh session per save)
|
# Auto-persist chat (fresh session per save)
|
||||||
try:
|
try:
|
||||||
async with AsyncSession(engine) as save_session:
|
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()
|
await save_session.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.debug("Failed to auto-save chat %s", chat_id, exc_info=True)
|
_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
|
# Dispatch commands
|
||||||
if text and text.startswith("/"):
|
if text and text.startswith("/"):
|
||||||
try:
|
try:
|
||||||
language_code = message.get("from", {}).get("language_code", "")
|
message_id = message.get("message_id")
|
||||||
cmd_response = await handle_command(bot_obj, chat_id, text, language_code=language_code)
|
cmd_response = await handle_command(bot_obj, chat_id, text, language_code=msg_language)
|
||||||
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)
|
await send_media_group(bot_token, chat_id, cmd_response, reply_to_message_id=message_id)
|
||||||
else:
|
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:
|
except Exception:
|
||||||
_LOGGER.error("Error handling command from bot %d", bot_id, exc_info=True)
|
_LOGGER.error("Error handling command from bot %d", bot_id, exc_info=True)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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/
|
||||||
Reference in New Issue
Block a user