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_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", ""),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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"),
|
||||||
)
|
)
|
||||||
|
|||||||
96
bot/main.py
96
bot/main.py
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user