When signal has geo, show clickable Google Maps link instead of raw coordinates. Without geo, show "Гео нету". Added parse_mode=HTML to send_message for link rendering. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
145 lines
6 KiB
Python
145 lines
6 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
|
|
from backend import config, db
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_TELEGRAM_API = "https://api.telegram.org/bot{token}/{method}"
|
|
|
|
|
|
def _mask_token(token: str) -> str:
|
|
"""Return a safe representation of the bot token for logging."""
|
|
if not token or len(token) < 4:
|
|
return "***REDACTED***"
|
|
return f"***{token[-4:]}"
|
|
|
|
|
|
async def validate_bot_token() -> bool:
|
|
"""Validate BOT_TOKEN by calling getMe. Logs ERROR if invalid. Never raises."""
|
|
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="getMe")
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
try:
|
|
resp = await client.get(url)
|
|
if resp.status_code == 200:
|
|
bot_name = resp.json().get("result", {}).get("username", "?")
|
|
logger.info("Telegram token valid, bot: @%s", bot_name)
|
|
return True
|
|
logger.error(
|
|
"BOT_TOKEN invalid — getMe returned %s: %s", resp.status_code, resp.text
|
|
)
|
|
return False
|
|
except Exception as exc:
|
|
# Do not log `exc` directly — it may contain the API URL with the token
|
|
# embedded (httpx includes request URL in some exception types/versions).
|
|
logger.error(
|
|
"BOT_TOKEN validation failed (network error): %s — token ends with %s",
|
|
type(exc).__name__,
|
|
_mask_token(config.BOT_TOKEN),
|
|
)
|
|
return False
|
|
|
|
|
|
async def send_message(text: str) -> None:
|
|
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage")
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
for attempt in range(3):
|
|
resp = await client.post(url, json={"chat_id": config.CHAT_ID, "text": text, "parse_mode": "HTML"})
|
|
if resp.status_code == 429:
|
|
retry_after = resp.json().get("parameters", {}).get("retry_after", 30)
|
|
sleep = retry_after * (attempt + 1)
|
|
logger.warning("Telegram 429, sleeping %s sec (attempt %d)", sleep, attempt + 1)
|
|
await asyncio.sleep(sleep)
|
|
continue
|
|
if resp.status_code >= 500:
|
|
logger.error("Telegram 5xx: %s", resp.text)
|
|
await asyncio.sleep(30)
|
|
continue
|
|
elif resp.status_code != 200:
|
|
logger.error("Telegram error %s: %s", resp.status_code, resp.text)
|
|
break
|
|
else:
|
|
logger.error("Telegram send_message: all 3 attempts failed, message dropped")
|
|
|
|
|
|
async def send_registration_notification(
|
|
reg_id: int, login: str, email: str, created_at: str
|
|
) -> None:
|
|
"""Send registration request notification to admin with approve/reject inline buttons.
|
|
Swallows all errors — never raises."""
|
|
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="sendMessage")
|
|
text = (
|
|
f"📋 Новая заявка на регистрацию\n\n"
|
|
f"Login: {login}\nEmail: {email}\nДата: {created_at}"
|
|
)
|
|
reply_markup = {
|
|
"inline_keyboard": [[
|
|
{"text": "✅ Одобрить", "callback_data": f"approve:{reg_id}"},
|
|
{"text": "❌ Отклонить", "callback_data": f"reject:{reg_id}"},
|
|
]]
|
|
}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.post(
|
|
url,
|
|
json={
|
|
"chat_id": config.ADMIN_CHAT_ID,
|
|
"text": text,
|
|
"reply_markup": reply_markup,
|
|
},
|
|
)
|
|
if resp.status_code != 200:
|
|
logger.error(
|
|
"send_registration_notification failed %s: %s",
|
|
resp.status_code,
|
|
resp.text,
|
|
)
|
|
except Exception as exc:
|
|
# Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN
|
|
logger.error("send_registration_notification error: %s", type(exc).__name__)
|
|
|
|
|
|
async def answer_callback_query(callback_query_id: str) -> None:
|
|
"""Answer a Telegram callback query. Swallows all errors."""
|
|
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="answerCallbackQuery")
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.post(url, json={"callback_query_id": callback_query_id})
|
|
if resp.status_code != 200:
|
|
logger.error("answerCallbackQuery failed %s: %s", resp.status_code, resp.text)
|
|
except Exception as exc:
|
|
# Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN
|
|
logger.error("answerCallbackQuery error: %s", type(exc).__name__)
|
|
|
|
|
|
async def edit_message_text(chat_id: str | int, message_id: int, text: str) -> None:
|
|
"""Edit a Telegram message text. Swallows all errors."""
|
|
url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="editMessageText")
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.post(
|
|
url, json={"chat_id": chat_id, "message_id": message_id, "text": text}
|
|
)
|
|
if resp.status_code != 200:
|
|
logger.error("editMessageText failed %s: %s", resp.status_code, resp.text)
|
|
except Exception as exc:
|
|
# Do not log exc directly — httpx exceptions embed the full API URL with BOT_TOKEN
|
|
logger.error("editMessageText error: %s", type(exc).__name__)
|
|
|
|
|
|
async def set_webhook(url: str, secret: str) -> None:
|
|
api_url = _TELEGRAM_API.format(token=config.BOT_TOKEN, method="setWebhook")
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.post(
|
|
api_url, json={"url": url, "secret_token": secret}
|
|
)
|
|
if resp.status_code != 200 or not resp.json().get("result"):
|
|
raise RuntimeError(f"setWebhook failed: {resp.text}")
|
|
logger.info("Webhook registered: %s", url)
|
|
|