Initial import: grid-bot — grid trading bot for BTC-USDT on Cifra Markets

This commit is contained in:
Kolp
2026-09-24 13:22:23 +07:00
commit 642cc11a9f
18968 changed files with 5683248 additions and 0 deletions
+250
View File
@@ -0,0 +1,250 @@
"""
consult_mail.py — SMTP-логика для формы /api/consult.
Изолированный модуль: НЕ импортирует ничего из gridbot (main.py, grid.py, config.py).
Загружает переменные окружения CONSULT_SMTP_*, CONSULT_TO, CONSULT_FROM_NAME,
CONSULT_SUBJECT_PREFIX. Если переменные не заданы — выбрасывает ConfigurationError
при первом вызове (не при импорте, чтобы gridbot не падал).
Использует smtplib.SMTP_SSL с timeout 10 сек. При любой ошибке — пишет в
/root/grid-bot/consult.log и возвращает False, не выбрасывая исключение
наружу (endpoint сам решает, что отвечать клиенту).
"""
import os
import smtplib
import ssl
import logging
from email.message import EmailMessage
from typing import Optional
# === Конфигурация ===
class ConfigurationError(Exception):
"""Не заданы обязательные переменные окружения."""
pass
def _get_env(name: str, *, required: bool = True) -> Optional[str]:
val = os.getenv(name, "").strip()
if required and not val:
raise ConfigurationError(f"Required env var {name!r} is not set")
return val or None
def _get_bool(name: str, default: bool) -> bool:
raw = os.getenv(name, "").strip().lower()
if not raw:
return default
return raw in ("1", "true", "yes", "on")
# === Логгер (в файл, не в gridbot-логи) ===
_LOG_FILE = os.getenv("CONSULT_LOG_FILE", "/root/grid-bot/consult.log")
def _get_logger() -> logging.Logger:
log = logging.getLogger("consult_mail")
if log.handlers:
return log
log.setLevel(logging.INFO)
try:
handler = logging.FileHandler(_LOG_FILE)
except (PermissionError, OSError) as e:
# Fallback: stderr (для локальной разработки)
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(
"%(asctime)s consult_mail [%(levelname)s] %(message)s"
))
log.warning("Cannot open log file %s: %s — falling back to stderr", _LOG_FILE, e)
handler.setFormatter(logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s"
))
log.addHandler(handler)
log.propagate = False
return log
# === Защита от header injection / мусора ===
_CONTROL_CHARS = set(chr(i) for i in range(32) if chr(i) not in "\t")
_CONTROL_CHARS.add("\r")
_CONTROL_CHARS.add("\n")
_CONTROL_CHARS.add("\x7f")
def _sanitize(value: str, max_len: int = 500) -> str:
"""Strip whitespace, remove control chars, truncate. No header injection possible."""
if not isinstance(value, str):
return ""
cleaned = "".join(c for c in value if c not in _CONTROL_CHARS)
cleaned = cleaned.strip()
if len(cleaned) > max_len:
cleaned = cleaned[:max_len]
return cleaned
_EMAIL_RE = None
def _is_valid_email(s: str) -> bool:
"""Lightweight email check — not RFC 5322, but enough to reject obvious garbage."""
global _EMAIL_RE
import re
if _EMAIL_RE is None:
_EMAIL_RE = re.compile(r"^[^@\s\x00-\x1f]+@[^@\s\x00-\x1f]+\.[^@\s\x00-\x1f]{2,}$")
return bool(_EMAIL_RE.match(s))
# === Публичный API ===
def send_consult_email(form_data: dict) -> bool:
"""
Отправляет письмо-заявку с сайта it.kolp.pro.
:param form_data: dict с полями name, email, company, phone, message
:return: True если письмо ушло, False при любой ошибке
:raises ConfigurationError: если не заданы env-переменные
:raises ValueError: если невалидные данные (вызывающий должен перехватить → 400)
"""
log = _get_logger()
# 1) Sanitize входные данные
name = _sanitize(form_data.get("name", ""), max_len=100)
email = _sanitize(form_data.get("email", ""), max_len=200)
company = _sanitize(form_data.get("company", ""), max_len=200)
phone = _sanitize(form_data.get("phone", ""), max_len=50)
message = _sanitize(form_data.get("message", ""), max_len=2000)
# 2) Валидация
if not name:
raise ValueError("name is required")
if not email or not _is_valid_email(email):
raise ValueError("valid email is required")
# 3) Читаем конфиг
host = _get_env("CONSULT_SMTP_HOST")
port = int(_get_env("CONSULT_SMTP_PORT", required=False) or "465")
use_ssl = _get_bool("CONSULT_SMTP_USE_SSL", default=True)
user = _get_env("CONSULT_SMTP_USER")
password = _get_env("CONSULT_SMTP_PASSWORD")
sender = _get_env("CONSULT_FROM", required=False) or user
sender_name = _get_env("CONSULT_FROM_NAME", required=False) or "Сайт it.kolp.pro"
recipient = _get_env("CONSULT_TO")
subject_prefix = _get_env("CONSULT_SUBJECT_PREFIX", required=False) or "[it.kolp.pro]"
# 4) Формируем письмо
msg = EmailMessage()
msg["From"] = f"{sender_name} <{sender}>"
msg["To"] = recipient
msg["Reply-To"] = f"{name} <{email}>"
subj_type = {
"consultation": "Заявка",
"checklist": "Чек-лист",
}.get(form_data.get("_type", "consultation"), "Заявка")
msg["Subject"] = f"{subject_prefix} {subj_type} от {name}"
body_lines = [
f"Имя: {name}",
f"Email: {email}",
]
if company:
body_lines.append(f"Компания: {company}")
if phone:
body_lines.append(f"Телефон / Telegram: {phone}")
if message:
body_lines.append("")
body_lines.append("Что болит:")
body_lines.append(message)
# === Чек-лист (если передан И заполнен хотя бы частично) ===
# По требованию: если чек-лист НЕ заполнен, не прикреплять.
# Считаем чек-лист "заполненным" если completed=True или answered >= 1.
checklist = form_data.get("checklist")
checklist_has_data = (
isinstance(checklist, dict)
and (
checklist.get("completed") is True
or int(checklist.get("answered", 0) or 0) > 0
or any((checklist.get("goals") or {}).values())
)
)
if isinstance(checklist, dict) and checklist_has_data:
body_lines.append("")
body_lines.append("=" * 50)
body_lines.append("РЕЗУЛЬТАТ ЧЕК-ЛИСТА")
body_lines.append("=" * 50)
body_lines.append(f"Уровень: {checklist.get('level', '—')}")
body_lines.append(f"Балл: {checklist.get('total_score', 0)} / {checklist.get('max_score', 30)}")
body_lines.append(f"Отвечено: {checklist.get('answered', 0)} из {checklist.get('total_questions', 15)}")
body_lines.append(f"Заполнен полностью: {'да' if checklist.get('completed') else 'нет'}")
answers = checklist.get("answers", [])
questions = checklist.get("questions", [])
if isinstance(answers, list) and answers:
body_lines.append("")
body_lines.append("Ответы по вопросам:")
for i, ans in enumerate(answers, 1):
q_text = questions[i-1] if i-1 < len(questions) else f"Вопрос {i}"
# Сократим текст вопроса до 200 символов чтобы не раздувать
q_text_short = (q_text[:200] + "…") if len(q_text) > 200 else q_text
body_lines.append(f" {i}. {q_text_short}")
if isinstance(ans, dict):
v = ans.get("v")
if v is None:
body_lines.append(" → (не отвечено)")
else:
body_lines.append(f" → {ans.get('txt', v)} ({v}/2)")
else:
body_lines.append(f" → {ans}")
prep = checklist.get("prep_facts", {})
if isinstance(prep, dict) and prep:
body_lines.append("")
body_lines.append("Цифры для встречи:")
for k, v in prep.items():
if v:
body_lines.append(f" • {v}")
goals = checklist.get("goals", {})
if isinstance(goals, dict) and any(goals.values()):
body_lines.append("")
body_lines.append("Чего ждёте от ИТ:")
if goals.get("what_to_improve"):
body_lines.append(f" Стать лучше: {goals['what_to_improve']}")
if goals.get("what_to_discuss"):
body_lines.append(f" Обсудить: {goals['what_to_discuss']}")
body_lines.append("")
body_lines.append("---")
body_lines.append(f"IP отправителя: {form_data.get('_ip', 'unknown')}")
body_lines.append(f"User-Agent: {form_data.get('_ua', 'unknown')}")
body_lines.append(f"Тип формы: {form_data.get('_type', 'consultation')}")
msg.set_content("\n".join(body_lines), charset="utf-8")
# 5) Отправляем
try:
ctx = ssl.create_default_context()
if use_ssl:
with smtplib.SMTP_SSL(host, port, context=ctx, timeout=10) as smtp:
smtp.ehlo()
smtp.login(user, password)
smtp.send_message(msg)
else:
with smtplib.SMTP(host, port, timeout=10) as smtp:
smtp.ehlo()
smtp.starttls(context=ctx)
smtp.ehlo()
smtp.login(user, password)
smtp.send_message(msg)
log.info("OK: lead from %s <%s> sent to %s", name, email, recipient)
return True
except smtplib.SMTPAuthenticationError as e:
log.error("SMTP AUTH FAILED (check credentials): %s", str(e)[:200])
except smtplib.SMTPException as e:
log.error("SMTP ERROR: %s: %s", type(e).__name__, str(e)[:200])
except (OSError, ssl.SSLError) as e:
log.error("NETWORK/SSL ERROR: %s: %s", type(e).__name__, str(e)[:200])
except Exception as e:
log.exception("UNEXPECTED ERROR: %s: %s", type(e).__name__, str(e)[:200])
return False