mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:04:50 +00:00
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:
parent
7a25ee3d36
commit
546c62f13f
@ -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", ""),
|
||||
)
|
||||
|
||||
@ -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 <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")
|
||||
async def api_test_ai():
|
||||
return _handle_test_ai()
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
|
||||
96
bot/main.py
96
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"📋 <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:
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@ -25,3 +25,6 @@ BACKEND_URL=http://backend:8000
|
||||
# Бизнес-правила
|
||||
ACTIVE_PERIOD_DAYS=90
|
||||
GRACE_PERIOD_DAYS=14
|
||||
|
||||
# Внутренний секрет бот → бэкенд (годовщины договоров, не публичный)
|
||||
INTERNAL_SECRET=zov-internal-2026-k9mXpQr3wN8vLs1t
|
||||
|
||||
Loading…
Reference in New Issue
Block a user