1158 lines
44 KiB
Python
1158 lines
44 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
"""
|
|||
|
|
Telegram Bot — SeaFare Montana maritime AI agent on Telegram.
|
|||
|
|
Polling-based, runs as standalone systemd service (seafare-telegram.service).
|
|||
|
|
|
|||
|
|
Features:
|
|||
|
|
- Full AI agent access (26 tools)
|
|||
|
|
- Group chat support (@mention / reply / commands)
|
|||
|
|
- Vessel watch alerts (/watch, /unwatch, /watches)
|
|||
|
|
- Auto language detection (EN/RU/ES)
|
|||
|
|
|
|||
|
|
Run: python telegram_bot.py
|
|||
|
|
"""
|
|||
|
|
import os
|
|||
|
|
import re
|
|||
|
|
import math
|
|||
|
|
import logging
|
|||
|
|
import threading
|
|||
|
|
import time
|
|||
|
|
from collections import defaultdict
|
|||
|
|
from datetime import datetime, timedelta
|
|||
|
|
|
|||
|
|
import config
|
|||
|
|
|
|||
|
|
logger = logging.getLogger('telegram_bot')
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# CONFIGURATION
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
RATE_LIMIT_MAX = config.TELEGRAM_RATE_LIMIT_MAX
|
|||
|
|
RATE_LIMIT_PERIOD = config.TELEGRAM_RATE_LIMIT_PERIOD
|
|||
|
|
MAX_HISTORY = config.TELEGRAM_MAX_HISTORY
|
|||
|
|
POLL_TIMEOUT = config.TELEGRAM_POLL_TIMEOUT
|
|||
|
|
BOT_USERNAME = config.TELEGRAM_BOT_USERNAME
|
|||
|
|
WATCH_MAX = config.TELEGRAM_WATCH_MAX_PER_USER
|
|||
|
|
WATCH_CHECK_INTERVAL = config.TELEGRAM_WATCH_CHECK_INTERVAL
|
|||
|
|
WATCH_EXPIRY_HOURS = config.TELEGRAM_WATCH_EXPIRY_HOURS
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# IN-MEMORY STATE (per-process)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
_conversations = defaultdict(list) # chat_id -> [{role, message}]
|
|||
|
|
_user_langs = {} # chat_id -> 'en'|'ru'|'es'
|
|||
|
|
_linked_users = {} # chat_id -> user dict (cached)
|
|||
|
|
_rate_buckets = defaultdict(list) # rate_key -> [timestamp, ...]
|
|||
|
|
_watches = defaultdict(list) # chat_id -> [watch_dict, ...]
|
|||
|
|
_bot_user_id = None # set at startup via bot.get_me()
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# i18n
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
_WELCOME = {
|
|||
|
|
'en': (
|
|||
|
|
"<b>SeaFare Montana</b> — Maritime freight operations specialist.\n\n"
|
|||
|
|
"Ask me anything about vessels, routes, freight rates, or sanctions.\n"
|
|||
|
|
"Just type naturally in English, Russian, or Spanish.\n\n"
|
|||
|
|
"Tap a button below to get started:"
|
|||
|
|
),
|
|||
|
|
'ru': (
|
|||
|
|
"<b>SeaFare Montana</b> — Специалист по морским грузоперевозкам.\n\n"
|
|||
|
|
"Спросите о судах, маршрутах, фрахте или санкциях.\n"
|
|||
|
|
"Пишите на русском, английском или испанском.\n\n"
|
|||
|
|
"Нажмите кнопку чтобы начать:"
|
|||
|
|
),
|
|||
|
|
'es': (
|
|||
|
|
"<b>SeaFare Montana</b> — Especialista en operaciones marítimas.\n\n"
|
|||
|
|
"Pregunte sobre buques, rutas, fletes o sanciones.\n"
|
|||
|
|
"Escribe en español, inglés o ruso.\n\n"
|
|||
|
|
"Pulse un botón para empezar:"
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_HELP = {
|
|||
|
|
'en': (
|
|||
|
|
"<b>Commands:</b>\n"
|
|||
|
|
"/start — Welcome\n"
|
|||
|
|
"/help — This help\n"
|
|||
|
|
"/lang en|ru|es — Switch language\n"
|
|||
|
|
"/watch VESSEL to PORT — Alert on arrival\n"
|
|||
|
|
"/watch VESSEL status — Alert on status change\n"
|
|||
|
|
"/watches — List active watches\n"
|
|||
|
|
"/unwatch N — Remove watch #N\n\n"
|
|||
|
|
"<b>Just type naturally:</b>\n"
|
|||
|
|
'- "Track EVER GIVEN"\n'
|
|||
|
|
'- "Vessels near Rotterdam"\n'
|
|||
|
|
'- "Route from Singapore to Rotterdam"\n'
|
|||
|
|
'- "Freight rate 50k DWT bulk Shanghai to Santos"\n\n'
|
|||
|
|
"<b>In groups:</b> mention @seafare_montana_bot or reply to my message"
|
|||
|
|
),
|
|||
|
|
'ru': (
|
|||
|
|
"<b>Команды:</b>\n"
|
|||
|
|
"/start — Приветствие\n"
|
|||
|
|
"/help — Эта справка\n"
|
|||
|
|
"/lang en|ru|es — Сменить язык\n"
|
|||
|
|
"/watch СУДНО to ПОРТ — Оповестить о приходе\n"
|
|||
|
|
"/watch СУДНО status — Оповестить о смене статуса\n"
|
|||
|
|
"/watches — Список отслеживаний\n"
|
|||
|
|
"/unwatch N — Удалить отслеживание #N\n\n"
|
|||
|
|
"<b>Просто пишите:</b>\n"
|
|||
|
|
'- "Найди EVER GIVEN"\n'
|
|||
|
|
'- "Суда в порту Роттердам"\n'
|
|||
|
|
'- "Маршрут из Сингапура в Роттердам"\n'
|
|||
|
|
'- "Фрахт 50т DWT балк Шанхай - Сантос"\n\n'
|
|||
|
|
"<b>В группах:</b> упомяните @seafare_montana_bot или ответьте на моё сообщение"
|
|||
|
|
),
|
|||
|
|
'es': (
|
|||
|
|
"<b>Comandos:</b>\n"
|
|||
|
|
"/start — Bienvenida\n"
|
|||
|
|
"/help — Esta ayuda\n"
|
|||
|
|
"/lang en|ru|es — Cambiar idioma\n"
|
|||
|
|
"/watch BUQUE to PUERTO — Alerta de llegada\n"
|
|||
|
|
"/watch BUQUE status — Alerta de cambio de estado\n"
|
|||
|
|
"/watches — Lista de seguimientos\n"
|
|||
|
|
"/unwatch N — Eliminar seguimiento #N\n\n"
|
|||
|
|
"<b>Escribe naturalmente:</b>\n"
|
|||
|
|
'- "Rastrear EVER GIVEN"\n'
|
|||
|
|
'- "Buques cerca de Rotterdam"\n'
|
|||
|
|
'- "Ruta de Singapur a Rotterdam"\n'
|
|||
|
|
'- "Flete 50k DWT bulk Shanghai a Santos"\n\n'
|
|||
|
|
"<b>En grupos:</b> menciona @seafare_montana_bot o responde a mi mensaje"
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_RATE_LIMITED = {
|
|||
|
|
'en': "Too many messages. Please wait a moment.",
|
|||
|
|
'ru': "Слишком много сообщений. Подождите немного.",
|
|||
|
|
'es': "Demasiados mensajes. Espere un momento.",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_ERROR = {
|
|||
|
|
'en': "Service is temporarily busy. Please try again.",
|
|||
|
|
'ru': "Сервис временно занят. Попробуйте ещё раз.",
|
|||
|
|
'es': "El servicio está temporalmente ocupado. Inténtelo de nuevo.",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Hints shown when user taps an inline button (teaches by example)
|
|||
|
|
_HINTS = {
|
|||
|
|
'hint_track': {
|
|||
|
|
'en': 'Type a vessel name, IMO or MMSI. Example:\n\n<code>Track EVER GIVEN</code>',
|
|||
|
|
'ru': 'Введите имя судна, IMO или MMSI. Пример:\n\n<code>Найди EVER GIVEN</code>',
|
|||
|
|
'es': 'Escriba nombre, IMO o MMSI. Ejemplo:\n\n<code>Rastrear EVER GIVEN</code>',
|
|||
|
|
},
|
|||
|
|
'hint_nearby': {
|
|||
|
|
'en': 'Type a port name. Example:\n\n<code>Vessels near Rotterdam</code>',
|
|||
|
|
'ru': 'Введите название порта. Пример:\n\n<code>Суда в порту Роттердам</code>',
|
|||
|
|
'es': 'Escriba un puerto. Ejemplo:\n\n<code>Buques cerca de Rotterdam</code>',
|
|||
|
|
},
|
|||
|
|
'hint_route': {
|
|||
|
|
'en': 'Example:\n\n<code>Route from Singapore to Rotterdam</code>',
|
|||
|
|
'ru': 'Пример:\n\n<code>Маршрут из Сингапура в Роттердам</code>',
|
|||
|
|
'es': 'Ejemplo:\n\n<code>Ruta de Singapur a Rotterdam</code>',
|
|||
|
|
},
|
|||
|
|
'hint_freight': {
|
|||
|
|
'en': 'Example:\n\n<code>Freight rate 50k DWT bulk Shanghai to Santos</code>',
|
|||
|
|
'ru': 'Пример:\n\n<code>Фрахт 50т DWT балк Шанхай - Сантос</code>',
|
|||
|
|
'es': 'Ejemplo:\n\n<code>Flete 50k DWT bulk Shanghai a Santos</code>',
|
|||
|
|
},
|
|||
|
|
'hint_watch': {
|
|||
|
|
'en': 'Example:\n\n<code>/watch EVER GIVEN to Rotterdam</code>\n<code>/watch 9811000 status</code>',
|
|||
|
|
'ru': 'Пример:\n\n<code>/watch EVER GIVEN to Rotterdam</code>\n<code>/watch 9811000 status</code>',
|
|||
|
|
'es': 'Ejemplo:\n\n<code>/watch EVER GIVEN to Rotterdam</code>\n<code>/watch 9811000 status</code>',
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# TELEGRAM ACCOUNT LINKING (website-first model)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
_REGISTER_FIRST = {
|
|||
|
|
'en': (
|
|||
|
|
"Welcome to <b>SeaFare Montana</b>!\n\n"
|
|||
|
|
"To use this bot, please register at:\n"
|
|||
|
|
"https://seafare.efir.org\n\n"
|
|||
|
|
"Then link your Telegram in Profile settings."
|
|||
|
|
),
|
|||
|
|
'ru': (
|
|||
|
|
"Добро пожаловать в <b>SeaFare Montana</b>!\n\n"
|
|||
|
|
"Для работы с ботом зарегистрируйтесь на сайте:\n"
|
|||
|
|
"https://seafare.efir.org\n\n"
|
|||
|
|
"Затем привяжите Telegram в настройках профиля."
|
|||
|
|
),
|
|||
|
|
'es': (
|
|||
|
|
"Bienvenido a <b>SeaFare Montana</b>!\n\n"
|
|||
|
|
"Para usar este bot, regístrese en:\n"
|
|||
|
|
"https://seafare.efir.org\n\n"
|
|||
|
|
"Luego vincule su Telegram en la configuración del perfil."
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_LINK_SUCCESS = {
|
|||
|
|
'en': "Account linked successfully!",
|
|||
|
|
'ru': "Аккаунт успешно привязан!",
|
|||
|
|
'es': "Cuenta vinculada exitosamente!",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_LINK_FAILED = {
|
|||
|
|
'en': "Invalid or expired link. Please generate a new one on the website.",
|
|||
|
|
'ru': "Недействительная или просроченная ссылка. Создайте новую на сайте.",
|
|||
|
|
'es': "Enlace inválido o expirado. Genere uno nuevo en el sitio web.",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_linked_user(chat_id: int):
|
|||
|
|
"""Get website user linked to this chat_id (with cache)."""
|
|||
|
|
if chat_id in _linked_users:
|
|||
|
|
return _linked_users[chat_id]
|
|||
|
|
import maritime_db as mdb
|
|||
|
|
user = mdb.get_user_by_telegram_chat_id(chat_id)
|
|||
|
|
if user:
|
|||
|
|
_linked_users[chat_id] = user
|
|||
|
|
return user
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _handle_start(bot, message):
|
|||
|
|
"""Handle /start with optional deep-link payload."""
|
|||
|
|
import maritime_db as mdb
|
|||
|
|
chat_id = message.chat.id
|
|||
|
|
text = (message.text or '').strip()
|
|||
|
|
|
|||
|
|
# 1. Deep-link verification: /start link_XXXX
|
|||
|
|
if ' ' in text:
|
|||
|
|
payload = text.split(' ', 1)[1].strip()
|
|||
|
|
if payload.startswith('link_'):
|
|||
|
|
code = payload[5:]
|
|||
|
|
user = mdb.verify_telegram_link(code, chat_id)
|
|||
|
|
if user:
|
|||
|
|
_linked_users[chat_id] = user
|
|||
|
|
_user_langs[chat_id] = user.get('lang', 'en')
|
|||
|
|
lang = user.get('lang', 'en')
|
|||
|
|
bot.send_message(chat_id,
|
|||
|
|
f"<b>{_LINK_SUCCESS.get(lang, _LINK_SUCCESS['en'])}</b>",
|
|||
|
|
parse_mode='HTML')
|
|||
|
|
_send_lang_picker(bot, chat_id)
|
|||
|
|
else:
|
|||
|
|
bot.send_message(chat_id,
|
|||
|
|
_LINK_FAILED.get('en', _LINK_FAILED['en']),
|
|||
|
|
parse_mode='HTML')
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 2. Already linked?
|
|||
|
|
user = _get_linked_user(chat_id)
|
|||
|
|
if user:
|
|||
|
|
_user_langs[chat_id] = user.get('lang', 'en')
|
|||
|
|
_send_lang_picker(bot, chat_id)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# 3. Not linked — register first
|
|||
|
|
bot.send_message(chat_id, _REGISTER_FIRST.get('en', _REGISTER_FIRST['en']),
|
|||
|
|
parse_mode='HTML')
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# LANGUAGE PICKER (first step on /start)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
def _send_lang_picker(bot, chat_id: int):
|
|||
|
|
"""Send language selection keyboard as first /start step."""
|
|||
|
|
from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton
|
|||
|
|
|
|||
|
|
kb = InlineKeyboardMarkup(row_width=3)
|
|||
|
|
kb.add(
|
|||
|
|
InlineKeyboardButton("English", callback_data="lang_en"),
|
|||
|
|
InlineKeyboardButton("Русский", callback_data="lang_ru"),
|
|||
|
|
InlineKeyboardButton("Español", callback_data="lang_es"),
|
|||
|
|
)
|
|||
|
|
bot.send_message(
|
|||
|
|
chat_id,
|
|||
|
|
"<b>SeaFare Montana</b>\n\nChoose language / Выберите язык / Elija idioma:",
|
|||
|
|
parse_mode='HTML',
|
|||
|
|
reply_markup=kb,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# INLINE KEYBOARD (shown on /start)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
def _start_keyboard(lang: str):
|
|||
|
|
"""Build inline keyboard for /start message."""
|
|||
|
|
from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton, WebAppInfo
|
|||
|
|
|
|||
|
|
kb = InlineKeyboardMarkup(row_width=2)
|
|||
|
|
|
|||
|
|
# Mini App button (top row, full width)
|
|||
|
|
from config import APP_VERSION
|
|||
|
|
webapp_url = f'https://seafare.efir.org/miniapp?v={APP_VERSION}'
|
|||
|
|
app_label = {'en': 'Open Mini App', 'ru': 'Открыть приложение', 'es': 'Abrir aplicación'}
|
|||
|
|
kb.add(InlineKeyboardButton(
|
|||
|
|
app_label.get(lang, app_label['en']),
|
|||
|
|
web_app=WebAppInfo(url=webapp_url)))
|
|||
|
|
|
|||
|
|
labels = {
|
|||
|
|
'en': [("Track vessel", "hint_track"), ("Vessels nearby", "hint_nearby"),
|
|||
|
|
("Route", "hint_route"), ("Freight rate", "hint_freight"),
|
|||
|
|
("Watch vessel", "hint_watch"), ("Help", "hint_help")],
|
|||
|
|
'ru': [("Найти судно", "hint_track"), ("Суда в порту", "hint_nearby"),
|
|||
|
|
("Маршрут", "hint_route"), ("Фрахт", "hint_freight"),
|
|||
|
|
("Следить за судном", "hint_watch"), ("Помощь", "hint_help")],
|
|||
|
|
'es': [("Rastrear buque", "hint_track"), ("Buques en puerto", "hint_nearby"),
|
|||
|
|
("Ruta", "hint_route"), ("Flete", "hint_freight"),
|
|||
|
|
("Seguir buque", "hint_watch"), ("Ayuda", "hint_help")],
|
|||
|
|
}
|
|||
|
|
btns = labels.get(lang, labels['en'])
|
|||
|
|
for i in range(0, len(btns), 2):
|
|||
|
|
row = [InlineKeyboardButton(b[0], callback_data=b[1]) for b in btns[i:i+2]]
|
|||
|
|
kb.add(*row)
|
|||
|
|
return kb
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# RATE LIMITING
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
def _check_rate_limit(rate_key: int) -> bool:
|
|||
|
|
"""Returns True if request is allowed."""
|
|||
|
|
now = time.time()
|
|||
|
|
bucket = _rate_buckets[rate_key]
|
|||
|
|
_rate_buckets[rate_key] = [t for t in bucket if now - t < RATE_LIMIT_PERIOD]
|
|||
|
|
if len(_rate_buckets[rate_key]) >= RATE_LIMIT_MAX:
|
|||
|
|
return False
|
|||
|
|
_rate_buckets[rate_key].append(now)
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_rate_key(message) -> int:
|
|||
|
|
"""Per-user rate limit in groups, per-chat in private."""
|
|||
|
|
if message.chat.type in ('group', 'supergroup'):
|
|||
|
|
return message.from_user.id if message.from_user else message.chat.id
|
|||
|
|
return message.chat.id
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# LANGUAGE DETECTION
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
_CYRILLIC_RE = re.compile(r'[а-яёА-ЯЁ]')
|
|||
|
|
_SPANISH_WORDS = {'hola', 'buenos', 'buenas', 'buscar', 'buque', 'puerto', 'flete'}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _detect_lang(text: str, chat_id: int) -> str:
|
|||
|
|
"""Auto-detect language from message text. Remembers per chat."""
|
|||
|
|
if _CYRILLIC_RE.search(text):
|
|||
|
|
_user_langs[chat_id] = 'ru'
|
|||
|
|
elif any(w in text.lower().split() for w in _SPANISH_WORDS):
|
|||
|
|
_user_langs[chat_id] = 'es'
|
|||
|
|
return _user_langs.get(chat_id, 'en')
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# GROUP CHAT SUPPORT
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
def _should_respond(message) -> bool:
|
|||
|
|
"""Determine if bot should respond to this message."""
|
|||
|
|
# Always respond in private chats
|
|||
|
|
if message.chat.type == 'private':
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
text = message.text or ''
|
|||
|
|
|
|||
|
|
# /commands in groups
|
|||
|
|
if text.startswith('/'):
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
# Reply to bot's own message
|
|||
|
|
if (message.reply_to_message
|
|||
|
|
and message.reply_to_message.from_user
|
|||
|
|
and _bot_user_id
|
|||
|
|
and message.reply_to_message.from_user.id == _bot_user_id):
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
# @mention in entities
|
|||
|
|
if message.entities:
|
|||
|
|
for entity in message.entities:
|
|||
|
|
if entity.type == 'mention':
|
|||
|
|
mention = text[entity.offset:entity.offset + entity.length]
|
|||
|
|
if mention.lower() == f'@{BOT_USERNAME}'.lower():
|
|||
|
|
return True
|
|||
|
|
elif entity.type == 'text_mention' and entity.user:
|
|||
|
|
if _bot_user_id and entity.user.id == _bot_user_id:
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
# Plain text @mention fallback
|
|||
|
|
if f'@{BOT_USERNAME}'.lower() in text.lower():
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _clean_message_text(text: str) -> str:
|
|||
|
|
"""Remove bot @mention from message text."""
|
|||
|
|
return re.sub(rf'@{re.escape(BOT_USERNAME)}\s*', '', text, flags=re.IGNORECASE).strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# RESPONSE FORMATTING (Telegram-specific)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
_SHOWMAP_RE = re.compile(r'\{\{SHOWMAP~([^~]+)~([^~]+)~([^~]+)~([^}]+)\}\}')
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _format_for_telegram(text: str):
|
|||
|
|
"""Convert agent response to Telegram HTML format.
|
|||
|
|
|
|||
|
|
Returns: (html_text, [(lat, lon, label), ...])
|
|||
|
|
"""
|
|||
|
|
locations = []
|
|||
|
|
|
|||
|
|
def _extract_map(match):
|
|||
|
|
lat_s, lon_s, _zoom, label = match.group(1), match.group(2), match.group(3), match.group(4)
|
|||
|
|
try:
|
|||
|
|
locations.append((float(lat_s), float(lon_s), label.strip()))
|
|||
|
|
except ValueError:
|
|||
|
|
pass
|
|||
|
|
return label.strip()
|
|||
|
|
|
|||
|
|
text = _SHOWMAP_RE.sub(_extract_map, text)
|
|||
|
|
|
|||
|
|
# Markdown → HTML (safe subset)
|
|||
|
|
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
|||
|
|
text = re.sub(r'(?<!\w)_([^_]+)_(?!\w)', r'<i>\1</i>', text)
|
|||
|
|
text = re.sub(r'`([^`]+)`', r'<code>\1</code>', text)
|
|||
|
|
|
|||
|
|
# Markdown tables → <pre> (Telegram has no table rendering)
|
|||
|
|
lines = text.split('\n')
|
|||
|
|
result = []
|
|||
|
|
table_lines = []
|
|||
|
|
in_table = False
|
|||
|
|
for line in lines:
|
|||
|
|
stripped = line.strip()
|
|||
|
|
if stripped.startswith('|') and stripped.endswith('|'):
|
|||
|
|
if re.match(r'^\|[\s\-:|]+\|$', stripped):
|
|||
|
|
continue # skip separator row
|
|||
|
|
if not in_table:
|
|||
|
|
in_table = True
|
|||
|
|
table_lines = []
|
|||
|
|
table_lines.append(stripped)
|
|||
|
|
else:
|
|||
|
|
if in_table:
|
|||
|
|
result.append('<pre>' + '\n'.join(table_lines) + '</pre>')
|
|||
|
|
in_table = False
|
|||
|
|
table_lines = []
|
|||
|
|
result.append(line)
|
|||
|
|
if in_table:
|
|||
|
|
result.append('<pre>' + '\n'.join(table_lines) + '</pre>')
|
|||
|
|
text = '\n'.join(result)
|
|||
|
|
|
|||
|
|
# Telegram message limit
|
|||
|
|
if len(text) > 4000:
|
|||
|
|
text = text[:3990] + '\n\n... (truncated)'
|
|||
|
|
|
|||
|
|
return text, locations
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# VESSEL WATCH — COMMANDS
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
def _cmd_watch(bot, message, text: str, lang: str):
|
|||
|
|
"""Handle /watch command. Creates arrival or status watch."""
|
|||
|
|
import maritime_db as db
|
|||
|
|
from marinetraffic_parser import resolve_port
|
|||
|
|
|
|||
|
|
chat_id = message.chat.id
|
|||
|
|
|
|||
|
|
# Check watch limit
|
|||
|
|
active = _watches.get(chat_id, [])
|
|||
|
|
active = [w for w in active if not w.get('notified')]
|
|||
|
|
if len(active) >= WATCH_MAX:
|
|||
|
|
msgs = {
|
|||
|
|
'en': f"Maximum {WATCH_MAX} active watches. Use /unwatch to remove one.",
|
|||
|
|
'ru': f"Максимум {WATCH_MAX} активных отслеживаний. Используйте /unwatch.",
|
|||
|
|
'es': f"Máximo {WATCH_MAX} seguimientos activos. Use /unwatch para eliminar uno.",
|
|||
|
|
}
|
|||
|
|
bot.send_message(chat_id, msgs.get(lang, msgs['en']))
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# Parse: /watch VESSEL to PORT or /watch VESSEL status
|
|||
|
|
parts = text.split(maxsplit=1)
|
|||
|
|
if len(parts) < 2:
|
|||
|
|
msgs = {
|
|||
|
|
'en': "Usage:\n/watch VESSEL to PORT — arrival alert\n/watch VESSEL status — status change alert",
|
|||
|
|
'ru': "Использование:\n/watch СУДНО to ПОРТ — оповещение о приходе\n/watch СУДНО status — смена статуса",
|
|||
|
|
'es': "Uso:\n/watch BUQUE to PUERTO — alerta de llegada\n/watch BUQUE status — cambio de estado",
|
|||
|
|
}
|
|||
|
|
bot.send_message(chat_id, msgs.get(lang, msgs['en']))
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
args = parts[1].strip()
|
|||
|
|
|
|||
|
|
# Determine watch type
|
|||
|
|
watch_type = None
|
|||
|
|
vessel_query = None
|
|||
|
|
port_query = None
|
|||
|
|
|
|||
|
|
if args.lower().endswith(' status'):
|
|||
|
|
watch_type = 'status'
|
|||
|
|
vessel_query = args[:-7].strip()
|
|||
|
|
elif ' to ' in args.lower():
|
|||
|
|
watch_type = 'arrival'
|
|||
|
|
idx = args.lower().index(' to ')
|
|||
|
|
vessel_query = args[:idx].strip()
|
|||
|
|
port_query = args[idx + 4:].strip()
|
|||
|
|
else:
|
|||
|
|
msgs = {
|
|||
|
|
'en': "Use: /watch VESSEL to PORT or /watch VESSEL status",
|
|||
|
|
'ru': "Формат: /watch СУДНО to ПОРТ или /watch СУДНО status",
|
|||
|
|
'es': "Formato: /watch BUQUE to PUERTO o /watch BUQUE status",
|
|||
|
|
}
|
|||
|
|
bot.send_message(chat_id, msgs.get(lang, msgs['en']))
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
if not vessel_query:
|
|||
|
|
bot.send_message(chat_id, "Vessel name required.")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
bot.send_chat_action(chat_id, 'typing')
|
|||
|
|
|
|||
|
|
# Resolve vessel
|
|||
|
|
vessel = None
|
|||
|
|
vessels = db.search_vessels(vessel_query, limit=1)
|
|||
|
|
if vessels:
|
|||
|
|
vessel = vessels[0]
|
|||
|
|
else:
|
|||
|
|
# Try live lookup
|
|||
|
|
try:
|
|||
|
|
from ais_provider import get_provider
|
|||
|
|
pos = get_provider().get_vessel_position(name=vessel_query)
|
|||
|
|
if pos and pos.get('mmsi'):
|
|||
|
|
vessel = db.get_vessel(mmsi=pos['mmsi'])
|
|||
|
|
if not vessel:
|
|||
|
|
vessel = {'mmsi': pos['mmsi'], 'name': vessel_query.upper()}
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[Telegram] Watch vessel lookup error: {e}")
|
|||
|
|
|
|||
|
|
if not vessel or not vessel.get('mmsi'):
|
|||
|
|
msgs = {
|
|||
|
|
'en': f"Vessel not found: {vessel_query}. Try exact name, IMO, or MMSI.",
|
|||
|
|
'ru': f"Судно не найдено: {vessel_query}. Попробуйте точное имя, IMO или MMSI.",
|
|||
|
|
'es': f"Buque no encontrado: {vessel_query}. Pruebe nombre exacto, IMO o MMSI.",
|
|||
|
|
}
|
|||
|
|
bot.send_message(chat_id, msgs.get(lang, msgs['en']))
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
mmsi = vessel['mmsi']
|
|||
|
|
vessel_name = vessel.get('name', vessel_query.upper())
|
|||
|
|
|
|||
|
|
# For arrival watches: resolve port
|
|||
|
|
dest_port = None
|
|||
|
|
dest_lat = None
|
|||
|
|
dest_lon = None
|
|||
|
|
port_radius = 15.0
|
|||
|
|
|
|||
|
|
if watch_type == 'arrival':
|
|||
|
|
if not port_query:
|
|||
|
|
bot.send_message(chat_id, "Port name required after 'to'.")
|
|||
|
|
return
|
|||
|
|
port = resolve_port(port_query)
|
|||
|
|
if not port:
|
|||
|
|
msgs = {
|
|||
|
|
'en': f"Port not found: {port_query}",
|
|||
|
|
'ru': f"Порт не найден: {port_query}",
|
|||
|
|
'es': f"Puerto no encontrado: {port_query}",
|
|||
|
|
}
|
|||
|
|
bot.send_message(chat_id, msgs.get(lang, msgs['en']))
|
|||
|
|
return
|
|||
|
|
dest_port = port.get('name', port_query)
|
|||
|
|
dest_lat = port['lat']
|
|||
|
|
dest_lon = port['lon']
|
|||
|
|
port_radius = port.get('radius_nm', 15.0)
|
|||
|
|
|
|||
|
|
# For status watches: get current status
|
|||
|
|
last_status = None
|
|||
|
|
if watch_type == 'status':
|
|||
|
|
pos = db.get_last_position(mmsi)
|
|||
|
|
if pos:
|
|||
|
|
last_status = pos.get('status')
|
|||
|
|
|
|||
|
|
# Save to DB
|
|||
|
|
watch_id = db.add_vessel_watch(
|
|||
|
|
chat_id=chat_id, mmsi=mmsi, vessel_name=vessel_name,
|
|||
|
|
watch_type=watch_type, destination_port=dest_port,
|
|||
|
|
destination_lat=dest_lat, destination_lon=dest_lon,
|
|||
|
|
port_radius_nm=port_radius, last_status=last_status
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Save to memory
|
|||
|
|
watch_dict = {
|
|||
|
|
'id': watch_id, 'mmsi': mmsi, 'vessel_name': vessel_name,
|
|||
|
|
'watch_type': watch_type, 'destination_port': dest_port,
|
|||
|
|
'dest_lat': dest_lat, 'dest_lon': dest_lon,
|
|||
|
|
'port_radius_nm': port_radius, 'last_status': last_status,
|
|||
|
|
'last_lat': None, 'last_lon': None, 'notified': False,
|
|||
|
|
'created_at': time.time(),
|
|||
|
|
}
|
|||
|
|
_watches[chat_id].append(watch_dict)
|
|||
|
|
|
|||
|
|
# Subscribe to AISStream for fresh data
|
|||
|
|
try:
|
|||
|
|
from ais_provider import get_provider
|
|||
|
|
prov = get_provider()
|
|||
|
|
if hasattr(prov, 'aisstream') and prov.aisstream:
|
|||
|
|
prov.aisstream.subscribe_mmsi(mmsi)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# Confirm
|
|||
|
|
if watch_type == 'arrival':
|
|||
|
|
msgs = {
|
|||
|
|
'en': f"Watching <b>{vessel_name}</b> — will notify when it arrives at <b>{dest_port}</b>.",
|
|||
|
|
'ru': f"Отслеживаю <b>{vessel_name}</b> — оповещу когда придёт в <b>{dest_port}</b>.",
|
|||
|
|
'es': f"Siguiendo <b>{vessel_name}</b> — notificaré cuando llegue a <b>{dest_port}</b>.",
|
|||
|
|
}
|
|||
|
|
else:
|
|||
|
|
status_str = last_status or 'unknown'
|
|||
|
|
msgs = {
|
|||
|
|
'en': f"Watching <b>{vessel_name}</b> status (current: {status_str}).",
|
|||
|
|
'ru': f"Отслеживаю статус <b>{vessel_name}</b> (текущий: {status_str}).",
|
|||
|
|
'es': f"Siguiendo estado de <b>{vessel_name}</b> (actual: {status_str}).",
|
|||
|
|
}
|
|||
|
|
bot.send_message(chat_id, msgs.get(lang, msgs['en']), parse_mode='HTML')
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _cmd_watches(bot, message, lang: str):
|
|||
|
|
"""Handle /watches — list active watches."""
|
|||
|
|
chat_id = message.chat.id
|
|||
|
|
active = [w for w in _watches.get(chat_id, []) if not w.get('notified')]
|
|||
|
|
|
|||
|
|
if not active:
|
|||
|
|
msgs = {
|
|||
|
|
'en': "No active watches. Use /watch to create one.",
|
|||
|
|
'ru': "Нет активных отслеживаний. Используйте /watch.",
|
|||
|
|
'es': "Sin seguimientos activos. Use /watch para crear uno.",
|
|||
|
|
}
|
|||
|
|
bot.send_message(chat_id, msgs.get(lang, msgs['en']))
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
headers = {'en': '<b>Active watches:</b>', 'ru': '<b>Отслеживания:</b>', 'es': '<b>Seguimientos:</b>'}
|
|||
|
|
lines = [headers.get(lang, headers['en'])]
|
|||
|
|
for i, w in enumerate(active, 1):
|
|||
|
|
if w['watch_type'] == 'arrival':
|
|||
|
|
lines.append(f"{i}. {w['vessel_name']} → {w['destination_port']}")
|
|||
|
|
else:
|
|||
|
|
lines.append(f"{i}. {w['vessel_name']} (status)")
|
|||
|
|
bot.send_message(chat_id, '\n'.join(lines), parse_mode='HTML')
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _cmd_unwatch(bot, message, text: str, lang: str):
|
|||
|
|
"""Handle /unwatch N — remove watch by number."""
|
|||
|
|
import maritime_db as db
|
|||
|
|
|
|||
|
|
chat_id = message.chat.id
|
|||
|
|
active = [w for w in _watches.get(chat_id, []) if not w.get('notified')]
|
|||
|
|
|
|||
|
|
parts = text.split()
|
|||
|
|
if len(parts) < 2:
|
|||
|
|
if not active:
|
|||
|
|
msgs = {'en': "No active watches.", 'ru': "Нет отслеживаний.", 'es': "Sin seguimientos."}
|
|||
|
|
bot.send_message(chat_id, msgs.get(lang, msgs['en']))
|
|||
|
|
else:
|
|||
|
|
_cmd_watches(bot, message, lang)
|
|||
|
|
bot.send_message(chat_id, "Usage: /unwatch N")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
idx = int(parts[1]) - 1
|
|||
|
|
except ValueError:
|
|||
|
|
bot.send_message(chat_id, "Usage: /unwatch N (number)")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
if idx < 0 or idx >= len(active):
|
|||
|
|
bot.send_message(chat_id, f"Invalid number. You have {len(active)} watches.")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
watch = active[idx]
|
|||
|
|
db.delete_watch(watch['id'], chat_id)
|
|||
|
|
_watches[chat_id] = [w for w in _watches[chat_id] if w['id'] != watch['id']]
|
|||
|
|
|
|||
|
|
msgs = {
|
|||
|
|
'en': f"Removed: {watch['vessel_name']}",
|
|||
|
|
'ru': f"Удалено: {watch['vessel_name']}",
|
|||
|
|
'es': f"Eliminado: {watch['vessel_name']}",
|
|||
|
|
}
|
|||
|
|
bot.send_message(chat_id, msgs.get(lang, msgs['en']))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# VESSEL WATCH — BACKGROUND CHECKER
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
def _load_watches_from_db():
|
|||
|
|
"""Load active watches from DB into memory (called at startup)."""
|
|||
|
|
try:
|
|||
|
|
import maritime_db as db
|
|||
|
|
all_watches = db.get_active_watches()
|
|||
|
|
for w in all_watches:
|
|||
|
|
watch_dict = {
|
|||
|
|
'id': w['id'], 'mmsi': w['mmsi'],
|
|||
|
|
'vessel_name': w.get('vessel_name', ''),
|
|||
|
|
'watch_type': w['watch_type'],
|
|||
|
|
'destination_port': w.get('destination_port'),
|
|||
|
|
'dest_lat': w.get('destination_lat'),
|
|||
|
|
'dest_lon': w.get('destination_lon'),
|
|||
|
|
'port_radius_nm': w.get('port_radius_nm', 15),
|
|||
|
|
'last_status': w.get('last_status'),
|
|||
|
|
'last_lat': w.get('last_lat'),
|
|||
|
|
'last_lon': w.get('last_lon'),
|
|||
|
|
'notified': False,
|
|||
|
|
'created_at': time.time(),
|
|||
|
|
}
|
|||
|
|
_watches[w['chat_id']].append(watch_dict)
|
|||
|
|
total = sum(len(v) for v in _watches.values())
|
|||
|
|
print(f"[Telegram] Loaded {total} active watches from DB")
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[Telegram] Failed to load watches: {e}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _subscribe_watched_vessels():
|
|||
|
|
"""Subscribe all watched MMSIs to AISStream."""
|
|||
|
|
try:
|
|||
|
|
from ais_provider import get_provider
|
|||
|
|
prov = get_provider()
|
|||
|
|
if not (hasattr(prov, 'aisstream') and prov.aisstream):
|
|||
|
|
return
|
|||
|
|
mmsis = set()
|
|||
|
|
for watches in _watches.values():
|
|||
|
|
for w in watches:
|
|||
|
|
mmsis.add(w['mmsi'])
|
|||
|
|
for mmsi in mmsis:
|
|||
|
|
prov.aisstream.subscribe_mmsi(mmsi)
|
|||
|
|
if mmsis:
|
|||
|
|
print(f"[Telegram] Subscribed {len(mmsis)} watched MMSIs to AISStream")
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[Telegram] AISStream subscribe error: {e}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _calc_distance_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|||
|
|
"""Simple flat-earth distance in nautical miles."""
|
|||
|
|
dlat = abs(lat2 - lat1) * 60
|
|||
|
|
dlon = abs(lon2 - lon1) * 60 * max(math.cos(math.radians(lat1)), 0.01)
|
|||
|
|
return math.sqrt(dlat ** 2 + dlon ** 2)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _check_arrival(bot, chat_id: int, watch: dict, pos: dict):
|
|||
|
|
"""Check if vessel has arrived at destination port."""
|
|||
|
|
lat = pos.get('latitude')
|
|||
|
|
lon = pos.get('longitude')
|
|||
|
|
if lat is None or lon is None or watch.get('dest_lat') is None:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
dist = _calc_distance_nm(lat, lon, watch['dest_lat'], watch['dest_lon'])
|
|||
|
|
if dist <= watch.get('port_radius_nm', 15):
|
|||
|
|
lang = _user_langs.get(chat_id, 'en')
|
|||
|
|
msgs = {
|
|||
|
|
'en': f"<b>Vessel Alert:</b> <b>{watch['vessel_name']}</b> arrived at <b>{watch['destination_port']}</b> ({dist:.1f} NM)",
|
|||
|
|
'ru': f"<b>Оповещение:</b> <b>{watch['vessel_name']}</b> прибыл в <b>{watch['destination_port']}</b> ({dist:.1f} NM)",
|
|||
|
|
'es': f"<b>Alerta:</b> <b>{watch['vessel_name']}</b> llegó a <b>{watch['destination_port']}</b> ({dist:.1f} NM)",
|
|||
|
|
}
|
|||
|
|
try:
|
|||
|
|
from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton
|
|||
|
|
kb = InlineKeyboardMarkup()
|
|||
|
|
kb.add(InlineKeyboardButton(
|
|||
|
|
f"\U0001F4CD {watch['vessel_name']}",
|
|||
|
|
url=f"https://www.google.com/maps?q={lat},{lon}"
|
|||
|
|
))
|
|||
|
|
bot.send_message(chat_id, msgs.get(lang, msgs['en']),
|
|||
|
|
parse_mode='HTML', reply_markup=kb)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[Telegram] Arrival notification failed: {e}")
|
|||
|
|
|
|||
|
|
watch['notified'] = True
|
|||
|
|
try:
|
|||
|
|
import maritime_db as db
|
|||
|
|
db.mark_watch_notified(watch['id'])
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _check_status_change(bot, chat_id: int, watch: dict, pos: dict):
|
|||
|
|
"""Check if vessel nav status has changed."""
|
|||
|
|
current_status = pos.get('status')
|
|||
|
|
if not current_status or not watch.get('last_status'):
|
|||
|
|
if current_status and not watch.get('last_status'):
|
|||
|
|
watch['last_status'] = current_status
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
if current_status != watch['last_status']:
|
|||
|
|
lang = _user_langs.get(chat_id, 'en')
|
|||
|
|
old = watch['last_status']
|
|||
|
|
msgs = {
|
|||
|
|
'en': f"<b>Status Alert:</b> <b>{watch['vessel_name']}</b> changed: {old} → {current_status}",
|
|||
|
|
'ru': f"<b>Статус:</b> <b>{watch['vessel_name']}</b> изменился: {old} → {current_status}",
|
|||
|
|
'es': f"<b>Estado:</b> <b>{watch['vessel_name']}</b> cambió: {old} → {current_status}",
|
|||
|
|
}
|
|||
|
|
try:
|
|||
|
|
reply_kb = None
|
|||
|
|
lat = pos.get('latitude')
|
|||
|
|
lon = pos.get('longitude')
|
|||
|
|
if lat and lon:
|
|||
|
|
from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton
|
|||
|
|
reply_kb = InlineKeyboardMarkup()
|
|||
|
|
reply_kb.add(InlineKeyboardButton(
|
|||
|
|
f"\U0001F4CD {watch['vessel_name']}",
|
|||
|
|
url=f"https://www.google.com/maps?q={lat},{lon}"
|
|||
|
|
))
|
|||
|
|
bot.send_message(chat_id, msgs.get(lang, msgs['en']),
|
|||
|
|
parse_mode='HTML', reply_markup=reply_kb)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[Telegram] Status notification failed: {e}")
|
|||
|
|
|
|||
|
|
watch['last_status'] = current_status
|
|||
|
|
try:
|
|||
|
|
import maritime_db as db
|
|||
|
|
db.update_watch_position(
|
|||
|
|
watch['id'], last_status=current_status,
|
|||
|
|
last_lat=pos.get('latitude'), last_lon=pos.get('longitude')
|
|||
|
|
)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _watch_checker_loop(bot):
|
|||
|
|
"""Background thread: check all active watches periodically."""
|
|||
|
|
import maritime_db as db
|
|||
|
|
|
|||
|
|
print(f"[Telegram] Watch checker started (interval={WATCH_CHECK_INTERVAL}s)")
|
|||
|
|
|
|||
|
|
while True:
|
|||
|
|
time.sleep(WATCH_CHECK_INTERVAL)
|
|||
|
|
try:
|
|||
|
|
checked = 0
|
|||
|
|
expired = 0
|
|||
|
|
now = time.time()
|
|||
|
|
|
|||
|
|
for chat_id in list(_watches.keys()):
|
|||
|
|
watches = _watches[chat_id]
|
|||
|
|
for watch in watches:
|
|||
|
|
if watch.get('notified'):
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Auto-expire old watches
|
|||
|
|
age_hours = (now - watch.get('created_at', now)) / 3600
|
|||
|
|
if age_hours > WATCH_EXPIRY_HOURS:
|
|||
|
|
watch['notified'] = True
|
|||
|
|
try:
|
|||
|
|
db.mark_watch_notified(watch['id'])
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
expired += 1
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
pos = db.get_last_position(watch['mmsi'])
|
|||
|
|
if not pos:
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Update position in watch
|
|||
|
|
watch['last_lat'] = pos.get('latitude')
|
|||
|
|
watch['last_lon'] = pos.get('longitude')
|
|||
|
|
|
|||
|
|
if watch['watch_type'] == 'arrival':
|
|||
|
|
_check_arrival(bot, chat_id, watch, pos)
|
|||
|
|
elif watch['watch_type'] == 'status':
|
|||
|
|
_check_status_change(bot, chat_id, watch, pos)
|
|||
|
|
|
|||
|
|
checked += 1
|
|||
|
|
|
|||
|
|
# Cleanup notified watches from memory
|
|||
|
|
_watches[chat_id] = [w for w in watches if not w.get('notified')]
|
|||
|
|
|
|||
|
|
if checked > 0 or expired > 0:
|
|||
|
|
print(f"[Telegram] Watch check: {checked} checked, {expired} expired")
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[Telegram] Watch checker error: {e}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# MESSAGE HANDLER
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
def _handle_message(bot, message):
|
|||
|
|
"""Process incoming Telegram message (private + group)."""
|
|||
|
|
# Group filtering
|
|||
|
|
if not _should_respond(message):
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
chat_id = message.chat.id
|
|||
|
|
text = (message.text or '').strip()
|
|||
|
|
if not text:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
is_group = message.chat.type in ('group', 'supergroup')
|
|||
|
|
|
|||
|
|
# Clean @mention from text in groups
|
|||
|
|
if is_group:
|
|||
|
|
text = _clean_message_text(text)
|
|||
|
|
if not text:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# --- Commands ---
|
|||
|
|
if text.startswith('/start'):
|
|||
|
|
_handle_start(bot, message)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
if text.startswith('/help'):
|
|||
|
|
lang = _user_langs.get(chat_id, 'en')
|
|||
|
|
bot.send_message(chat_id, _HELP.get(lang, _HELP['en']), parse_mode='HTML')
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
if text.startswith('/lang'):
|
|||
|
|
parts = text.split()
|
|||
|
|
if len(parts) >= 2 and parts[1] in ('en', 'ru', 'es'):
|
|||
|
|
_user_langs[chat_id] = parts[1]
|
|||
|
|
ack = {'en': 'Language set to English', 'ru': 'Язык: Русский', 'es': 'Idioma: Español'}
|
|||
|
|
bot.send_message(chat_id, ack[parts[1]])
|
|||
|
|
else:
|
|||
|
|
bot.send_message(chat_id, 'Usage: /lang en|ru|es')
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
lang = _detect_lang(text, chat_id)
|
|||
|
|
|
|||
|
|
if text.startswith('/watch'):
|
|||
|
|
_cmd_watch(bot, message, text, lang)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
if text.startswith('/watches'):
|
|||
|
|
_cmd_watches(bot, message, lang)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
if text.startswith('/unwatch'):
|
|||
|
|
_cmd_unwatch(bot, message, text, lang)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# --- Check account linked (private chats only) ---
|
|||
|
|
user = _get_linked_user(chat_id) if not is_group else None
|
|||
|
|
if not is_group and not user:
|
|||
|
|
bot.send_message(chat_id, _REGISTER_FIRST.get(lang, _REGISTER_FIRST['en']),
|
|||
|
|
parse_mode='HTML')
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# --- Rate limit ---
|
|||
|
|
rate_key = _get_rate_key(message)
|
|||
|
|
if not _check_rate_limit(rate_key):
|
|||
|
|
bot.send_message(chat_id, _RATE_LIMITED.get(lang, _RATE_LIMITED['en']))
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# --- Typing indicator ---
|
|||
|
|
bot.send_chat_action(chat_id, 'typing')
|
|||
|
|
|
|||
|
|
# --- Build user context for agent ---
|
|||
|
|
user_context = None
|
|||
|
|
user_id = None
|
|||
|
|
is_admin = False
|
|||
|
|
if user:
|
|||
|
|
user_id = user.get('id')
|
|||
|
|
is_admin = bool(user.get('is_admin'))
|
|||
|
|
try:
|
|||
|
|
import maritime_db as mdb
|
|||
|
|
user_context = mdb.get_profile_summary(user['id'])
|
|||
|
|
# Load DB history on first message (cross-client sync)
|
|||
|
|
if not _conversations[chat_id] and user_id:
|
|||
|
|
db_hist = mdb.get_chat_history(user_id, limit=10)
|
|||
|
|
for msg in db_hist:
|
|||
|
|
_conversations[chat_id].append({
|
|||
|
|
'role': msg['role'],
|
|||
|
|
'message': msg['message']
|
|||
|
|
})
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# --- Build conversation history ---
|
|||
|
|
history = _conversations[chat_id][-(MAX_HISTORY * 2):]
|
|||
|
|
|
|||
|
|
# --- Call agent ---
|
|||
|
|
try:
|
|||
|
|
from seafare_agent import generate_response
|
|||
|
|
response = generate_response(
|
|||
|
|
user_message=text,
|
|||
|
|
conversation_history=history,
|
|||
|
|
user_context=user_context,
|
|||
|
|
is_admin=is_admin,
|
|||
|
|
user_id=user_id,
|
|||
|
|
lang=lang,
|
|||
|
|
)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[Telegram] Agent error chat={chat_id}: {e}")
|
|||
|
|
response = _ERROR.get(lang, _ERROR['en'])
|
|||
|
|
|
|||
|
|
# --- Save to in-memory history ---
|
|||
|
|
if is_group and message.from_user:
|
|||
|
|
sender = message.from_user.first_name or 'User'
|
|||
|
|
history_text = f"[{sender}]: {text}"
|
|||
|
|
else:
|
|||
|
|
history_text = text
|
|||
|
|
|
|||
|
|
_conversations[chat_id].append({'role': 'user', 'message': history_text})
|
|||
|
|
_conversations[chat_id].append({'role': 'assistant', 'message': response})
|
|||
|
|
if len(_conversations[chat_id]) > MAX_HISTORY * 2:
|
|||
|
|
_conversations[chat_id] = _conversations[chat_id][-(MAX_HISTORY * 2):]
|
|||
|
|
|
|||
|
|
# Save to DB for cross-client sync (web ↔ miniapp ↔ telegram)
|
|||
|
|
if user_id:
|
|||
|
|
try:
|
|||
|
|
import maritime_db as mdb
|
|||
|
|
mdb.add_chat_message(user_id, 'user', text)
|
|||
|
|
mdb.add_chat_message(user_id, 'assistant', response)
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# --- Format and send ---
|
|||
|
|
formatted_text, locations = _format_for_telegram(response)
|
|||
|
|
|
|||
|
|
# Build inline keyboard with map links (instead of separate location messages)
|
|||
|
|
reply_markup = None
|
|||
|
|
if locations:
|
|||
|
|
from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton
|
|||
|
|
kb = InlineKeyboardMarkup(row_width=1)
|
|||
|
|
for lat, lon, label in locations[:3]:
|
|||
|
|
short = label[:30] if len(label) > 30 else label
|
|||
|
|
url = f"https://www.google.com/maps?q={lat},{lon}"
|
|||
|
|
kb.add(InlineKeyboardButton(f"\U0001F4CD {short}", url=url))
|
|||
|
|
reply_markup = kb
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
bot.send_message(chat_id, formatted_text, parse_mode='HTML',
|
|||
|
|
disable_web_page_preview=True, reply_markup=reply_markup)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[Telegram] HTML send failed: {e}")
|
|||
|
|
try:
|
|||
|
|
bot.send_message(chat_id, response[:4000])
|
|||
|
|
except Exception as e2:
|
|||
|
|
print(f"[Telegram] Plain send failed: {e2}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# BOT POLLING LOOP
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
def _bot_polling(token: str):
|
|||
|
|
"""Blocking polling loop. Runs as main process or daemon thread."""
|
|||
|
|
global _bot_user_id
|
|||
|
|
import telebot
|
|||
|
|
|
|||
|
|
bot = telebot.TeleBot(token, parse_mode=None)
|
|||
|
|
|
|||
|
|
# Get bot user ID for group reply detection
|
|||
|
|
try:
|
|||
|
|
me = bot.get_me()
|
|||
|
|
_bot_user_id = me.id
|
|||
|
|
print(f"[Telegram] Bot identity: @{me.username} (id={me.id})")
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[Telegram] get_me() failed: {e}")
|
|||
|
|
|
|||
|
|
# Set Mini App menu button
|
|||
|
|
try:
|
|||
|
|
from telebot.types import MenuButtonWebApp, WebAppInfo
|
|||
|
|
from config import APP_VERSION
|
|||
|
|
webapp_url = f'https://seafare.efir.org/miniapp?v={APP_VERSION}'
|
|||
|
|
bot.set_chat_menu_button(menu_button=MenuButtonWebApp(
|
|||
|
|
type='web_app', text='SeaFare', web_app=WebAppInfo(url=webapp_url)))
|
|||
|
|
print(f"[Telegram] Menu button set → {webapp_url}")
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[Telegram] Menu button error: {e}")
|
|||
|
|
|
|||
|
|
# Load watches from DB
|
|||
|
|
_load_watches_from_db()
|
|||
|
|
_subscribe_watched_vessels()
|
|||
|
|
|
|||
|
|
# Start watch checker thread
|
|||
|
|
checker = threading.Thread(target=_watch_checker_loop, args=(bot,),
|
|||
|
|
daemon=True, name='watch-checker')
|
|||
|
|
checker.start()
|
|||
|
|
|
|||
|
|
@bot.callback_query_handler(func=lambda call: True)
|
|||
|
|
def on_callback(call):
|
|||
|
|
try:
|
|||
|
|
chat_id = call.message.chat.id
|
|||
|
|
data = call.data
|
|||
|
|
|
|||
|
|
# Language selection from /start
|
|||
|
|
if data.startswith('lang_'):
|
|||
|
|
lang_code = data[5:] # 'en', 'ru', 'es'
|
|||
|
|
if lang_code in ('en', 'ru', 'es'):
|
|||
|
|
_user_langs[chat_id] = lang_code
|
|||
|
|
kb = _start_keyboard(lang_code)
|
|||
|
|
bot.send_message(chat_id, _WELCOME.get(lang_code, _WELCOME['en']),
|
|||
|
|
parse_mode='HTML', reply_markup=kb)
|
|||
|
|
bot.answer_callback_query(call.id)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
lang = _user_langs.get(chat_id, 'en')
|
|||
|
|
|
|||
|
|
if data == 'hint_help':
|
|||
|
|
bot.send_message(chat_id, _HELP.get(lang, _HELP['en']), parse_mode='HTML')
|
|||
|
|
elif data in _HINTS:
|
|||
|
|
hint = _HINTS[data]
|
|||
|
|
bot.send_message(chat_id, hint.get(lang, hint['en']), parse_mode='HTML')
|
|||
|
|
|
|||
|
|
bot.answer_callback_query(call.id)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[Telegram] Callback error: {e}")
|
|||
|
|
|
|||
|
|
@bot.message_handler(func=lambda m: True)
|
|||
|
|
def on_message(message):
|
|||
|
|
try:
|
|||
|
|
_handle_message(bot, message)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[Telegram] Handler error: {e}")
|
|||
|
|
|
|||
|
|
print("[Telegram] Bot polling started")
|
|||
|
|
|
|||
|
|
while True:
|
|||
|
|
try:
|
|||
|
|
bot.polling(
|
|||
|
|
non_stop=True,
|
|||
|
|
interval=1,
|
|||
|
|
timeout=POLL_TIMEOUT,
|
|||
|
|
long_polling_timeout=POLL_TIMEOUT,
|
|||
|
|
)
|
|||
|
|
except Exception as e:
|
|||
|
|
print(f"[Telegram] Polling error: {e}, restarting in 10s...")
|
|||
|
|
time.sleep(10)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# STARTUP (called from seafare_api.py or standalone)
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
def start_telegram_bot():
|
|||
|
|
"""Start Telegram bot as background daemon thread."""
|
|||
|
|
if os.environ.get('_TELEGRAM_STARTED'):
|
|||
|
|
print("[Telegram] Bot already started — skipping")
|
|||
|
|
return
|
|||
|
|
os.environ['_TELEGRAM_STARTED'] = '1'
|
|||
|
|
|
|||
|
|
token = os.environ.get(config.ENV_TELEGRAM_BOT_TOKEN)
|
|||
|
|
if not token:
|
|||
|
|
print("[Telegram] TELEGRAM_BOT_TOKEN not set — bot disabled")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
anthropic_key = os.environ.get(config.ENV_ANTHROPIC_API_KEY)
|
|||
|
|
if not anthropic_key:
|
|||
|
|
print("[Telegram] ANTHROPIC_API_KEY not set — bot disabled")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
t = threading.Thread(target=_bot_polling, args=(token,), daemon=True)
|
|||
|
|
t.start()
|
|||
|
|
print("[Telegram] Bot thread started (polling mode)")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# STANDALONE
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
from dotenv import load_dotenv
|
|||
|
|
load_dotenv()
|
|||
|
|
|
|||
|
|
logging.basicConfig(
|
|||
|
|
level=logging.INFO,
|
|||
|
|
format='%(asctime)s [%(name)s] %(levelname)s: %(message)s'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
token = os.environ.get(config.ENV_TELEGRAM_BOT_TOKEN)
|
|||
|
|
if not token:
|
|||
|
|
print("Set TELEGRAM_BOT_TOKEN in .env")
|
|||
|
|
exit(1)
|
|||
|
|
|
|||
|
|
print("[Telegram] Starting bot in standalone mode...")
|
|||
|
|
_bot_polling(token)
|