# 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)