"""Отправка готового результата на сторонний адрес. Опрос статуса работает, но заставляет принимающую сторону дёргать сервис каждые несколько секунд. Вебхук снимает это: сервис сам постучится, когда задача готова. """ import hashlib import hmac import json import logging import threading import time __all__ = ["sign_payload", "deliver", "deliver_async", "secret_fingerprint"] log = logging.getLogger(__name__) # Задержки между попытками: сразу, через полминуты, через пять минут. # Дольше ждать нет смысла - результат лежит в очереди и его можно забрать. RETRY_DELAYS = (0, 30, 300) TIMEOUT_SEC = 30 def sign_payload(body: bytes, secret: str) -> str: """Подпись тела запроса, чтобы принимающая сторона знала, что это мы.""" return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() def deliver(url: str, payload: dict, secret: str = "", delays: tuple = RETRY_DELAYS) -> bool: """Отправляет результат, повторяя при неудаче. Возвращает признак успеха.""" import requests # ensure_ascii=False: кириллица уходит живыми UTF-8 байтами. Принимающая # сторона обязана считать подпись от сырого тела запроса - если она # пересоберёт JSON из разобранного объекта, байты изменятся и подпись # не сойдётся ни при каком секрете. body = json.dumps(payload, ensure_ascii=False).encode("utf-8") headers = {"Content-Type": "application/json; charset=utf-8"} signature = sign_payload(body, secret) if secret else "" if signature: headers["X-Talkscore-Signature"] = signature # Ни подпись, ни хеш тела секретом не являются: по ним ничего не # восстанавливается, зато обе стороны могут сверить, об одном ли теле речь. log.info("вебхук по задаче %s: тело %d байт, sha256 %s, подпись %s", payload.get("job_id"), len(body), hashlib.sha256(body).hexdigest()[:12], signature[:16] or "НЕ ПОДПИСАНО (секрет не задан)") for attempt, delay in enumerate(delays, start=1): if delay: time.sleep(delay) try: response = requests.post(url, data=body, headers=headers, timeout=TIMEOUT_SEC) if response.status_code < 300: log.info("вебхук доставлен по задаче %s", payload.get("job_id")) return True log.warning("вебхук: попытка %d, ответ %s %s", attempt, response.status_code, response.text[:200]) except Exception as exc: # noqa: BLE001 - причина неважна, важна повторная попытка log.warning("вебхук: попытка %d не удалась (%s)", attempt, exc) log.error("вебхук не доставлен по задаче %s, результат остаётся в очереди", payload.get("job_id")) return False def deliver_async(url: str, payload: dict, secret: str = "") -> None: """Отправляет в фоне: воркер не должен ждать чужой сервер.""" thread = threading.Thread(target=deliver, args=(url, payload, secret), name="webhook", daemon=True) thread.start() def secret_fingerprint(secret: str) -> str: """Короткий отпечаток секрета для сверки настроек между сторонами. Значение пересылать нельзя - оно осядет в переписке и журналах. Отпечаток отвечает на единственный нужный вопрос: одинаковый ли секрет настроен здесь и там. Восстановить по нему секрет нельзя. """ if not secret: return "" return hashlib.sha256(secret.encode("utf-8")).hexdigest()[:8]