diff --git a/backend-py/app/config.py b/backend-py/app/config.py index 2b3df2b..e86a6b4 100644 --- a/backend-py/app/config.py +++ b/backend-py/app/config.py @@ -23,6 +23,9 @@ class Config: proxy_static_list: str # статический список прокси через запятую: "http://user:pass@host:port,..." proxy_list_file: str # путь к файлу со списком прокси в формате "host:port:user:pass" или "http://..." + # Внутренний секрет для вызовов бота → бэкенда (без initData) + internal_secret: str + def _required(name: str) -> str: val = os.getenv(name) @@ -46,4 +49,5 @@ def get_config() -> Config: proxy6_token=os.getenv("PROXY6_TOKEN", ""), proxy_static_list=os.getenv("PROXY_STATIC_LIST", ""), proxy_list_file=os.getenv("PROXY_LIST_FILE", ""), + internal_secret=os.getenv("INTERNAL_SECRET", ""), ) diff --git a/backend-py/app/main.py b/backend-py/app/main.py index d3af753..4a72596 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -347,6 +347,95 @@ async def api_photo(measurement_id: str, filename: str): return FileResponse(str(p), media_type=media) +@app.get("/api/daily_reminders") +async def api_daily_reminders(request: Request): + """Внутренний эндпоинт для бота: клиенты с годовщиной договора сегодня (МСК). + Защищён через заголовок Authorization: Bearer .""" + cfg = get_config() + secret = cfg.internal_secret + auth_header = request.headers.get("Authorization", "") + if not secret or auth_header != f"Bearer {secret}": + return JSONResponse({"error": "unauthorized"}, status_code=401) + return JSONResponse(_handle_daily_reminders()) + + +def _handle_daily_reminders() -> dict[str, Any]: + """Находит клиентов с годовщиной договора сегодня по МСК. + Дедуплицирует: один менеджер + один клиент = одно уведомление, + даже если по клиенту несколько строк в Measurements.""" + from datetime import timedelta + moscow_now = datetime.now(timezone.utc) + timedelta(hours=3) + today_md = (moscow_now.month, moscow_now.day) + current_year = moscow_now.year + + reminders: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() # (manager_tg_id, client_key) + + try: + ws = sheets.sheet("Measurements") + rows = ws.get_all_values() + except Exception as e: + log.exception("daily_reminders: не удалось прочитать Measurements") + return {"error": str(e)} + + if not rows or len(rows) < 2: + return {"ok": True, "reminders": [], "date": moscow_now.strftime("%d.%m.%Y")} + + headers = rows[0] + for r in rows[1:]: + row = dict(zip(headers, r + [""] * max(0, len(headers) - len(r)))) + + if row.get("archived_at"): + continue + + contract_date_raw = (row.get("contract_date") or "").strip() + if not contract_date_raw: + continue + + manager_tg_id = (row.get("manager_tg_id") or "").strip() + if not manager_tg_id: + continue + + # Парсим дату договора: ISO YYYY-MM-DD или DD.MM.YYYY + cd = None + for fmt in ("%Y-%m-%d", "%d.%m.%Y"): + try: + cd = datetime.strptime(contract_date_raw[:10], fmt) + break + except ValueError: + continue + if cd is None: + continue + + # Годовщина — месяц и день совпадают с сегодня + if (cd.month, cd.day) != today_md: + continue + + # Договор должен быть из прошлых лет, не из нынешнего + if cd.year >= current_year: + continue + + client_tg_id = (row.get("client_tg_id") or "").strip() + client_name = (row.get("client_name") or "Без имени").strip() + client_key = client_tg_id or client_name.lower() + + dedup_key = (manager_tg_id, client_key) + if dedup_key in seen: + continue + seen.add(dedup_key) + + years = current_year - cd.year + reminders.append({ + "manager_tg_id": manager_tg_id, + "client_name": client_name, + "contract_date": contract_date_raw, + "years": years, + }) + + log.info("daily_reminders: %d годовщин на %s", len(reminders), moscow_now.strftime("%d.%m.%Y")) + return {"ok": True, "reminders": reminders, "date": moscow_now.strftime("%d.%m.%Y")} + + @app.get("/api/test_ai") async def api_test_ai(): return _handle_test_ai() diff --git a/bot/config.py b/bot/config.py index f29fde0..af27056 100644 --- a/bot/config.py +++ b/bot/config.py @@ -22,6 +22,11 @@ class Config: active_period_days: int grace_period_days: int + # Внутренний токен для вызовов бота → бэкенда + internal_secret: str + # URL бэкенда (внутри Docker: http://backend:8000) + backend_url: str + @property def use_webhook(self) -> bool: return bool(self.webhook_url) @@ -50,4 +55,6 @@ def load_config() -> Config: webhook_path=os.getenv("WEBHOOK_PATH", "/tg/webhook"), active_period_days=int(os.getenv("ACTIVE_PERIOD_DAYS", "90")), grace_period_days=int(os.getenv("GRACE_PERIOD_DAYS", "14")), + internal_secret=os.getenv("INTERNAL_SECRET", ""), + backend_url=os.getenv("BACKEND_URL", "http://backend:8000"), ) diff --git a/bot/main.py b/bot/main.py index 85e576b..723e888 100644 --- a/bot/main.py +++ b/bot/main.py @@ -1,14 +1,103 @@ import asyncio import logging +from datetime import datetime, timezone, timedelta +import httpx from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode from aiogram.types import MenuButtonWebApp, WebAppInfo -from config import load_config +from config import load_config, Config from handlers import start +log = logging.getLogger("zov.bot") + +MSK = timezone(timedelta(hours=3)) + + +def _years_word(n: int) -> str: + """Правильное склонение: год / года / лет.""" + if n % 100 in (11, 12, 13, 14): + return "лет" + if n % 10 == 1: + return "год" + if n % 10 in (2, 3, 4): + return "года" + return "лет" + + +async def _send_anniversary_reminders(bot: Bot, config: Config) -> None: + """Вызывает /api/daily_reminders и рассылает уведомления менеджерам.""" + if not config.internal_secret: + log.warning("INTERNAL_SECRET не задан — рассылка годовщин пропущена") + return + + url = f"{config.backend_url}/api/daily_reminders" + headers = {"Authorization": f"Bearer {config.internal_secret}"} + try: + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(url, headers=headers) + resp.raise_for_status() + data = resp.json() + except Exception as e: + log.error("Ошибка запроса к /api/daily_reminders: %s", e) + return + + reminders = data.get("reminders", []) + log.info("Годовщины договоров: %d записей на %s", len(reminders), data.get("date", "?")) + + for r in reminders: + manager_tg_id = r.get("manager_tg_id", "") + client_name = r.get("client_name", "Клиент") + years: int = r.get("years", 1) + contract_date_raw = r.get("contract_date", "") + + # Форматируем дату для отображения + date_str = contract_date_raw + for fmt in ("%Y-%m-%d", "%d.%m.%Y"): + try: + date_str = datetime.strptime(contract_date_raw[:10], fmt).strftime("%d.%m.%Y") + break + except ValueError: + continue + + word = _years_word(years) + text = ( + f"📋 Годовщина договора!\n\n" + f"Сегодня ровно {years} {word} как вы подписали договор " + f"с {client_name}.\n" + f"📅 Дата договора: {date_str}\n\n" + f"Отличный повод напомнить о себе и предложить новые услуги 💼" + ) + + try: + await bot.send_message(chat_id=int(manager_tg_id), text=text) + log.info("Годовщина отправлена менеджеру %s (клиент: %s, лет: %d)", + manager_tg_id, client_name, years) + except Exception as e: + log.warning("Не удалось отправить уведомление менеджеру %s: %s", manager_tg_id, e) + + +async def _anniversary_scheduler(bot: Bot, config: Config) -> None: + """Фоновая задача: каждый день в 09:00 МСК рассылает годовщины договоров.""" + while True: + now = datetime.now(MSK) + next_run = now.replace(hour=9, minute=0, second=0, microsecond=0) + if now >= next_run: + next_run += timedelta(days=1) + delay = (next_run - now).total_seconds() + log.info( + "Планировщик годовщин: следующий запуск через %.0f сек (%s МСК)", + delay, next_run.strftime("%d.%m %H:%M"), + ) + await asyncio.sleep(delay) + + await _send_anniversary_reminders(bot, config) + + # Короткая пауза чтобы не сработало дважды при граничном времени + await asyncio.sleep(60) + async def main() -> None: logging.basicConfig( @@ -44,6 +133,11 @@ async def main() -> None: logging.info("Запуск в режиме polling") await bot.delete_webhook(drop_pending_updates=True) + + # Запускаем фоновый планировщик годовщин + asyncio.create_task(_anniversary_scheduler(bot, config)) + logging.info("Планировщик годовщин запущен (09:00 МСК ежедневно)") + await dp.start_polling(bot) diff --git a/deploy/.env.example b/deploy/.env.example index 071339e..2d53d72 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -25,3 +25,6 @@ BACKEND_URL=http://backend:8000 # Бизнес-правила ACTIVE_PERIOD_DAYS=90 GRACE_PERIOD_DAYS=14 + +# Внутренний секрет бот → бэкенд (годовщины договоров, не публичный) +INTERNAL_SECRET=zov-internal-2026-k9mXpQr3wN8vLs1t