195 lines
8.8 KiB
Python
195 lines
8.8 KiB
Python
# app/bot/handlers/drafts.py
|
||
from datetime import datetime
|
||
from html import escape as html_escape
|
||
import logging
|
||
from telegram import Update, InputMediaPhoto, InputMediaVideo, InputMediaAnimation
|
||
from telegram.constants import ChatType, ParseMode
|
||
from telegram.ext import ContextTypes
|
||
|
||
from app.db.session import get_session
|
||
from app.db.models import User, Draft
|
||
from app.bot.messages import ASK_MEDIA, CONFIRM, NEED_START_NEW
|
||
from app.bot.keyboards.common import kb_confirm
|
||
from app.bot.handlers.add_group import add_group_capture, STATE_KEY # "await_chat_id"
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
# Состояния редактора в user_data
|
||
STATE_DRAFT = "draft_state"
|
||
KEY_DRAFT_ID = "draft_id"
|
||
STATE_AWAIT_MEDIA = "await_media"
|
||
STATE_AWAIT_TEXT = "await_text"
|
||
STATE_CONFIRM = "confirm"
|
||
|
||
|
||
def _ud_state(ctx: ContextTypes.DEFAULT_TYPE) -> str:
|
||
"""Короткая сводка по user_data для логов."""
|
||
ud = ctx.user_data
|
||
return (
|
||
f"ud(draft_id={ud.get(KEY_DRAFT_ID)}, state={ud.get(STATE_DRAFT)}, "
|
||
f"await_dict={bool(ud.get('await_dict_file'))}, await_chat_id={bool(ud.get(STATE_KEY))})"
|
||
)
|
||
|
||
|
||
def _start_new_draft(tg_id: int) -> Draft:
|
||
"""Создать новый черновик и пометить предыдущие editing как cancelled."""
|
||
with get_session() as s:
|
||
u = s.query(User).filter_by(tg_id=tg_id).first()
|
||
if not u:
|
||
u = User(tg_id=tg_id, name="")
|
||
s.add(u); s.commit(); s.refresh(u)
|
||
# Закрываем старые "editing"
|
||
s.query(Draft).filter(
|
||
Draft.user_id == u.id, Draft.status == "editing"
|
||
).update({"status": "cancelled"})
|
||
# Создаём новый
|
||
d = Draft(user_id=u.id, status="editing")
|
||
s.add(d); s.commit(); s.refresh(d)
|
||
return d
|
||
|
||
|
||
async def new_cmd(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||
"""Старт редактора: сначала медиа, потом текст, потом подтверждение."""
|
||
uid = getattr(update.effective_user, "id", None)
|
||
cid = getattr(update.effective_chat, "id", None)
|
||
log.info("drafts:new_cmd start user=%s chat=%s %s", uid, cid, _ud_state(ctx))
|
||
|
||
# ЖЁСТКИЙ СБРОС конфликтующих режимов
|
||
for k in ("await_dict_file", "dict_params", STATE_KEY):
|
||
if ctx.user_data.pop(k, None) is not None:
|
||
log.debug("drafts:new_cmd cleared flag=%s user=%s chat=%s", k, uid, cid)
|
||
|
||
d = _start_new_draft(uid)
|
||
ctx.user_data[KEY_DRAFT_ID] = d.id
|
||
ctx.user_data[STATE_DRAFT] = STATE_AWAIT_MEDIA
|
||
log.info("drafts:new_cmd created draft_id=%s -> state=%s user=%s chat=%s",
|
||
d.id, STATE_AWAIT_MEDIA, uid, cid)
|
||
|
||
# Кнопку «Дальше — текст» показываем под сообщением «Медиа добавлено» (см. on_media).
|
||
await update.effective_message.reply_text(ASK_MEDIA)
|
||
|
||
|
||
async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||
"""
|
||
Обработка текста на шаге 2/3.
|
||
"""
|
||
uid = getattr(update.effective_user, "id", None)
|
||
cid = getattr(update.effective_chat, "id", None)
|
||
msg_text = (update.effective_message.text or "")
|
||
log.info("drafts:on_text received user=%s chat=%s len=%s %s",
|
||
uid, cid, len(msg_text), _ud_state(ctx))
|
||
|
||
# Не мешаем /spam_import (ждём файл/текст словаря)
|
||
if ctx.user_data.get("await_dict_file"):
|
||
log.info("drafts:on_text blocked_by_import user=%s chat=%s", uid, cid)
|
||
await update.effective_message.reply_text(
|
||
"Сейчас активен импорт словаря. Пришлите .txt/.csv как документ "
|
||
"или введите /cancel (или /new) для выхода из импорта."
|
||
)
|
||
return
|
||
|
||
# В процессе /add_group — делегируем туда
|
||
if ctx.user_data.get(STATE_KEY):
|
||
log.info("drafts:on_text delegated_to_add_group user=%s chat=%s", uid, cid)
|
||
return await add_group_capture(update, ctx)
|
||
|
||
if update.effective_chat.type != ChatType.PRIVATE:
|
||
log.debug("drafts:on_text ignored_non_private user=%s chat=%s", uid, cid)
|
||
return
|
||
|
||
draft_id = ctx.user_data.get(KEY_DRAFT_ID)
|
||
state = ctx.user_data.get(STATE_DRAFT)
|
||
|
||
if not draft_id or not state:
|
||
log.warning("drafts:on_text no_draft_or_state user=%s chat=%s %s", uid, cid, _ud_state(ctx))
|
||
await update.effective_message.reply_text(NEED_START_NEW)
|
||
return
|
||
|
||
if state == STATE_AWAIT_MEDIA:
|
||
log.info("drafts:on_text wrong_state_await_media user=%s chat=%s draft=%s", uid, cid, draft_id)
|
||
await update.effective_message.reply_text(
|
||
"Сначала добавьте медиа и нажмите «Дальше — текст» (кнопка под сообщением «Медиа добавлено»)."
|
||
)
|
||
return
|
||
|
||
if state == STATE_CONFIRM:
|
||
log.info("drafts:on_text already_confirm user=%s chat=%s draft=%s", uid, cid, draft_id)
|
||
await update.effective_message.reply_text(
|
||
"Пост уже готов — нажмите «Отправить» или «Отменить»."
|
||
)
|
||
return
|
||
|
||
if state != STATE_AWAIT_TEXT:
|
||
log.debug("drafts:on_text unknown_state user=%s chat=%s state=%s draft=%s",
|
||
uid, cid, state, draft_id)
|
||
return
|
||
|
||
# ШАГ 2/3 — сохраняем текст
|
||
safe_html = html_escape(msg_text)
|
||
with get_session() as s:
|
||
d = s.get(Draft, draft_id)
|
||
before_len = len(d.text or "") if d and d.text else 0
|
||
d.text = safe_html
|
||
d.updated_at = datetime.utcnow()
|
||
s.commit()
|
||
media = sorted(d.media, key=lambda m: m.order)
|
||
log.info("drafts:on_text saved_text user=%s chat=%s draft=%s old_len=%s new_len=%s media_count=%s",
|
||
uid, cid, draft_id, before_len, len(safe_html), len(media))
|
||
|
||
# Предпросмотр
|
||
try:
|
||
if media:
|
||
if len(media) > 1:
|
||
im = []
|
||
for i, m in enumerate(media):
|
||
cap = d.text if i == 0 else None
|
||
if m.kind == "photo":
|
||
im.append(InputMediaPhoto(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
|
||
elif m.kind == "video":
|
||
im.append(InputMediaVideo(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
|
||
else:
|
||
im.append(InputMediaAnimation(media=m.file_id, caption=cap, parse_mode=ParseMode.HTML))
|
||
await update.effective_chat.send_media_group(media=im)
|
||
log.info("drafts:on_text preview_album user=%s chat=%s draft=%s parts=%s",
|
||
uid, cid, draft_id, len(im))
|
||
else:
|
||
m = media[0]
|
||
if m.kind == "photo":
|
||
await update.effective_chat.send_photo(photo=m.file_id, caption=d.text, parse_mode=ParseMode.HTML)
|
||
elif m.kind == "video":
|
||
await update.effective_chat.send_video(video=m.file_id, caption=d.text, parse_mode=ParseMode.HTML)
|
||
else:
|
||
await update.effective_chat.send_animation(animation=m.file_id, caption=d.text, parse_mode=ParseMode.HTML)
|
||
log.info("drafts:on_text preview_single user=%s chat=%s draft=%s kind=%s",
|
||
uid, cid, draft_id, m.kind)
|
||
else:
|
||
await update.effective_chat.send_message(text=d.text or "(пусто)", parse_mode=ParseMode.HTML)
|
||
log.info("drafts:on_text preview_text user=%s chat=%s draft=%s", uid, cid, draft_id)
|
||
except Exception as e:
|
||
log.exception("drafts:on_text preview_error user=%s chat=%s draft=%s err=%s",
|
||
uid, cid, draft_id, e)
|
||
|
||
# Переход к подтверждению
|
||
ctx.user_data[STATE_DRAFT] = STATE_CONFIRM
|
||
log.info("drafts:on_text -> state=%s user=%s chat=%s draft=%s",
|
||
STATE_CONFIRM, uid, cid, draft_id)
|
||
await update.effective_message.reply_text(
|
||
"Шаг 3/3 — подтверждение.\nПроверьте пост и нажмите «Отправить» или «Отменить».",
|
||
reply_markup=kb_confirm(draft_id),
|
||
)
|
||
|
||
|
||
async def on_text_gate(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||
if update.effective_chat.type != ChatType.PRIVATE:
|
||
return
|
||
if ctx.user_data.get("await_dict_file"):
|
||
return
|
||
if ctx.user_data.get(STATE_DRAFT) == STATE_AWAIT_TEXT:
|
||
log.info(
|
||
"drafts:on_text_gate capture user=%s chat=%s state=%s draft_id=%s",
|
||
getattr(update.effective_user, "id", None),
|
||
getattr(update.effective_chat, "id", None),
|
||
ctx.user_data.get(STATE_DRAFT),
|
||
ctx.user_data.get(KEY_DRAFT_ID),
|
||
)
|
||
return await on_text(update, ctx) |