feat: ежедневные уведомления о годовщинах договоров

- Backend: новый GET /api/daily_reminders (внутренний, Bearer-токен)
  сканирует Measurements, находит клиентов у которых сегодня годовщина
  contract_date (по МСК), дедуплицирует по manager+client_key
- Backend config: поле internal_secret (INTERNAL_SECRET)
- Bot: фоновая задача _anniversary_scheduler — каждый день в 09:00 МСК
  вызывает эндпоинт и рассылает менеджерам HTML-сообщение с годовщиной
- Bot config: поля internal_secret + backend_url (BACKEND_URL)
- deploy/.env.example: добавлены INTERNAL_SECRET

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wasrusgen 2026-05-15 23:00:56 +03:00
parent 7a25ee3d36
commit 546c62f13f
5 changed files with 198 additions and 1 deletions

View File

@ -23,6 +23,9 @@ class Config:
proxy_static_list: str # статический список прокси через запятую: "http://user:pass@host:port,..." proxy_static_list: str # статический список прокси через запятую: "http://user:pass@host:port,..."
proxy_list_file: str # путь к файлу со списком прокси в формате "host:port:user:pass" или "http://..." proxy_list_file: str # путь к файлу со списком прокси в формате "host:port:user:pass" или "http://..."
# Внутренний секрет для вызовов бота → бэкенда (без initData)
internal_secret: str
def _required(name: str) -> str: def _required(name: str) -> str:
val = os.getenv(name) val = os.getenv(name)
@ -46,4 +49,5 @@ def get_config() -> Config:
proxy6_token=os.getenv("PROXY6_TOKEN", ""), proxy6_token=os.getenv("PROXY6_TOKEN", ""),
proxy_static_list=os.getenv("PROXY_STATIC_LIST", ""), proxy_static_list=os.getenv("PROXY_STATIC_LIST", ""),
proxy_list_file=os.getenv("PROXY_LIST_FILE", ""), proxy_list_file=os.getenv("PROXY_LIST_FILE", ""),
internal_secret=os.getenv("INTERNAL_SECRET", ""),
) )

View File

@ -347,6 +347,95 @@ async def api_photo(measurement_id: str, filename: str):
return FileResponse(str(p), media_type=media) return FileResponse(str(p), media_type=media)
@app.get("/api/daily_reminders")
async def api_daily_reminders(request: Request):
"""Внутренний эндпоинт для бота: клиенты с годовщиной договора сегодня (МСК).
Защищён через заголовок Authorization: Bearer <INTERNAL_SECRET>."""
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") @app.get("/api/test_ai")
async def api_test_ai(): async def api_test_ai():
return _handle_test_ai() return _handle_test_ai()

View File

@ -22,6 +22,11 @@ class Config:
active_period_days: int active_period_days: int
grace_period_days: int grace_period_days: int
# Внутренний токен для вызовов бота → бэкенда
internal_secret: str
# URL бэкенда (внутри Docker: http://backend:8000)
backend_url: str
@property @property
def use_webhook(self) -> bool: def use_webhook(self) -> bool:
return bool(self.webhook_url) return bool(self.webhook_url)
@ -50,4 +55,6 @@ def load_config() -> Config:
webhook_path=os.getenv("WEBHOOK_PATH", "/tg/webhook"), webhook_path=os.getenv("WEBHOOK_PATH", "/tg/webhook"),
active_period_days=int(os.getenv("ACTIVE_PERIOD_DAYS", "90")), active_period_days=int(os.getenv("ACTIVE_PERIOD_DAYS", "90")),
grace_period_days=int(os.getenv("GRACE_PERIOD_DAYS", "14")), 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"),
) )

View File

@ -1,14 +1,103 @@
import asyncio import asyncio
import logging import logging
from datetime import datetime, timezone, timedelta
import httpx
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode from aiogram.enums import ParseMode
from aiogram.types import MenuButtonWebApp, WebAppInfo from aiogram.types import MenuButtonWebApp, WebAppInfo
from config import load_config from config import load_config, Config
from handlers import start 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"📋 <b>Годовщина договора!</b>\n\n"
f"Сегодня ровно <b>{years} {word}</b> как вы подписали договор "
f"с <b>{client_name}</b>.\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: async def main() -> None:
logging.basicConfig( logging.basicConfig(
@ -44,6 +133,11 @@ async def main() -> None:
logging.info("Запуск в режиме polling") logging.info("Запуск в режиме polling")
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)
# Запускаем фоновый планировщик годовщин
asyncio.create_task(_anniversary_scheduler(bot, config))
logging.info("Планировщик годовщин запущен (09:00 МСК ежедневно)")
await dp.start_polling(bot) await dp.start_polling(bot)

View File

@ -25,3 +25,6 @@ BACKEND_URL=http://backend:8000
# Бизнес-правила # Бизнес-правила
ACTIVE_PERIOD_DAYS=90 ACTIVE_PERIOD_DAYS=90
GRACE_PERIOD_DAYS=14 GRACE_PERIOD_DAYS=14
# Внутренний секрет бот → бэкенд (годовщины договоров, не публичный)
INTERNAL_SECRET=zov-internal-2026-k9mXpQr3wN8vLs1t