From c03105a9d0c624dd8f2f3bc160d9a0881b85078e Mon Sep 17 00:00:00 2001 From: eloraculodiario Date: Thu, 13 Nov 2025 22:24:46 +0100 Subject: [PATCH] =?UTF-8?q?a=C3=B1adida=20logica=20de=20botones?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 396 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 389 insertions(+), 7 deletions(-) diff --git a/bot.py b/bot.py index 06e8390..fe4e7d5 100644 --- a/bot.py +++ b/bot.py @@ -7,8 +7,21 @@ from dotenv import load_dotenv from mastodon import Mastodon import requests -from telegram import Update, Message, PhotoSize -from telegram.ext import ApplicationBuilder, ContextTypes, MessageHandler, filters +from telegram import ( + Update, + Message, + PhotoSize, + InlineKeyboardButton, + InlineKeyboardMarkup, +) +from telegram.ext import ( + ApplicationBuilder, + ContextTypes, + MessageHandler, + CommandHandler, + CallbackQueryHandler, + filters, +) import logging import sys import re @@ -54,6 +67,9 @@ class PostPayload: image_paths: List[str] # rutas locales de imágenes descargadas +# ---------------------------- +# Ayuda /1212 (comportamiento existente) +# ---------------------------- def extract_command_payload(msg: Message) -> Optional[str]: """ Si el texto o el caption comienza con /1212, devuelve el resto (payload). @@ -151,7 +167,361 @@ def post_to_instagram(payload: PostPayload): # ---------------------------- -# Handlers +# Borradores para /publicar +# ---------------------------- +DRAFT_KEY = "current_draft" +DRAFT_STATE_KEY = "draft_state" # 'idle', 'waiting_title', 'waiting_description', 'waiting_hashtags', 'waiting_time', 'waiting_date' + + +def init_draft( + context: ContextTypes.DEFAULT_TYPE, + chat_id: int, + title: str = "", + description: str = "", + image_paths=None +): + if image_paths is None: + image_paths = [] + context.user_data[DRAFT_KEY] = { + "chat_id": chat_id, + "title": title, + "description": description, + "hashtags": "", + "time_start": "", + "time_end": "", + "date": "", + "image_paths": image_paths, + "message_id": None, # id del mensaje de preview + } + context.user_data[DRAFT_STATE_KEY] = "idle" + + +def get_draft(context: ContextTypes.DEFAULT_TYPE) -> Optional[dict]: + return context.user_data.get(DRAFT_KEY) + + +def set_state(context: ContextTypes.DEFAULT_TYPE, state: str): + context.user_data[DRAFT_STATE_KEY] = state + + +def get_state(context: ContextTypes.DEFAULT_TYPE) -> str: + return context.user_data.get(DRAFT_STATE_KEY, "idle") + + +def build_draft_preview_text(draft: dict) -> str: + title = draft.get("title") or "—" + description = draft.get("description") or "—" + hashtags = draft.get("hashtags") or "—" + date = draft.get("date") or "—" + + ts = draft.get("time_start") or "" + te = draft.get("time_end") or "" + if ts and te: + time_str = f"{ts} / {te}" + elif ts: + time_str = ts + else: + time_str = "—" + + text = ( + "✏️ *Borrador de publicación*\n\n" + f"📌 *Título*: {title}\n\n" + f"📝 *Descripción:*\n{description}\n\n" + f"🏷 Hashtags: {hashtags}\n" + f"⏰ Horario: {time_str}\n" + f"📅 Fecha: {date}" + ) + return text + + +def build_draft_keyboard() -> InlineKeyboardMarkup: + keyboard = [ + [ + InlineKeyboardButton("Título", callback_data="draft:title"), + InlineKeyboardButton("Descripción", callback_data="draft:description"), + ], + [ + InlineKeyboardButton("Hashtag", callback_data="draft:hashtags"), + InlineKeyboardButton("Horario", callback_data="draft:time"), + InlineKeyboardButton("Fecha", callback_data="draft:date"), + ], + [ + InlineKeyboardButton("✅ Publicar", callback_data="draft:publish"), + InlineKeyboardButton("❌ Cancelar", callback_data="draft:cancel"), + ] + ] + return InlineKeyboardMarkup(keyboard) + + +async def refresh_draft_message(context: ContextTypes.DEFAULT_TYPE): + """ + Borra el mensaje de preview anterior (si existe) y envía uno nuevo + con la preview + botones, para que siempre quede al final del chat. + """ + draft = get_draft(context) + if not draft: + return + + chat_id = draft["chat_id"] + old_message_id = draft.get("message_id") + + # Borramos el mensaje anterior del borrador si existe + if old_message_id: + try: + await context.bot.delete_message(chat_id=chat_id, message_id=old_message_id) + except Exception as e: + log.warning(f"No se pudo borrar el mensaje anterior del borrador: {e}") + + # Enviamos un nuevo mensaje de preview con la botonera + sent = await context.bot.send_message( + chat_id=chat_id, + text=build_draft_preview_text(draft), + reply_markup=build_draft_keyboard(), + parse_mode="Markdown" + ) + draft["message_id"] = sent.message_id + + +# ---------------------------- +# Comando /publicar +# ---------------------------- +async def start_publish(update: Update, context: ContextTypes.DEFAULT_TYPE): + msg = update.message + if msg is None: + return + + full_text = msg.text or "" + parts = full_text.split(" ", 1) + + # Si el usuario pone texto tras /publicar, lo usamos como descripción inicial + base_description = "" + if len(parts) >= 2 and parts[1].strip(): + base_description = parts[1].strip() + + # Descargamos foto si la hay + image_paths: List[str] = [] + if msg.photo: + image_paths = await download_photos(context, msg.photo) + + # Iniciamos borrador con título vacío y descripción opcional + init_draft(context, msg.chat.id, title="", description=base_description, image_paths=image_paths) + + # Mostramos el borrador con la botonera al final del chat + await refresh_draft_message(context) + + +# ---------------------------- +# Callback de botones (Título / Descripción / Hashtag / Horario / Fecha / Publicar / Cancelar) +# ---------------------------- +async def draft_keyboard_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + + data = query.data or "" + if not data.startswith("draft:"): + return + + draft = get_draft(context) + if not draft: + try: + await query.message.edit_text("No hay borrador activo.") + except Exception: + pass + return + + action = data.split(":", 1)[1] + + if action == "title": + set_state(context, "waiting_title") + await query.message.reply_text( + "Escribe ahora el *título* de la publicación.", + parse_mode="Markdown" + ) + + elif action == "description": + set_state(context, "waiting_description") + await query.message.reply_text( + "Escribe ahora la *descripción* de la publicación.", + parse_mode="Markdown" + ) + + elif action == "hashtags": + set_state(context, "waiting_hashtags") + await query.message.reply_text( + "Escribe ahora los *hashtags* en un mensaje (por ejemplo: `#feminismo #sostenibilidad`).", + parse_mode="Markdown" + ) + + elif action == "time": + set_state(context, "waiting_time") + await query.message.reply_text( + "Escribe ahora el *horario* en formato `HH:MM / HH:MM` (por ejemplo: `19:30 / 21:30`).", + parse_mode="Markdown" + ) + + elif action == "date": + set_state(context, "waiting_date") + await query.message.reply_text( + "Escribe ahora la *fecha* en formato `DD/MM/AAAA` (por ejemplo: `13/04/2025`).", + parse_mode="Markdown" + ) + + elif action == "publish": + await finalize_and_post_draft(update, context) + + elif action == "cancel": + context.user_data.pop(DRAFT_KEY, None) + set_state(context, "idle") + try: + await query.message.edit_text("Publicación cancelada.") + except Exception: + pass + + +# ---------------------------- +# Manejo de los textos de título / descripción / hashtag / hora / fecha +# ---------------------------- +async def handle_draft_text(update: Update, context: ContextTypes.DEFAULT_TYPE): + msg = update.message + if msg is None or not msg.text: + return + + state = get_state(context) + draft = get_draft(context) + if not draft: + return # no hay borrador activo, ignoramos + + text = msg.text.strip() + + # Título + if state == "waiting_title": + draft["title"] = text + set_state(context, "idle") + await refresh_draft_message(context) + return + + # Descripción + if state == "waiting_description": + draft["description"] = text + set_state(context, "idle") + await refresh_draft_message(context) + return + + # Hashtags + if state == "waiting_hashtags": + draft["hashtags"] = text + set_state(context, "idle") + await refresh_draft_message(context) + return + + # Horario rango HH:MM / HH:MM + if state == "waiting_time": + # Validar formato tipo "19:30 / 21:30" + m = re.match(r"^\s*(\d{1,2}:\d{2})\s*/\s*(\d{1,2}:\d{2})\s*$", text) + if not m: + await msg.reply_text( + "Formato de horario no válido. Usa `HH:MM / HH:MM`, por ejemplo `19:30 / 21:30`.", + parse_mode="Markdown" + ) + return + start, end = m.group(1), m.group(2) + draft["time_start"] = start + draft["time_end"] = end + set_state(context, "idle") + await refresh_draft_message(context) + return + + # Fecha DD/MM/AAAA + if state == "waiting_date": + if not re.match(r"^\d{1,2}/\d{1,2}/\d{4}$", text): + await msg.reply_text( + "Formato de fecha no válido. Usa `DD/MM/AAAA`, por ejemplo `13/04/2025`.", + parse_mode="Markdown" + ) + return + draft["date"] = text + set_state(context, "idle") + await refresh_draft_message(context) + return + + # Si no estamos esperando nada especial, no hacemos nada + return + + +# ---------------------------- +# Publicar borrador (/publicar) realmente +# ---------------------------- +async def finalize_and_post_draft(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + draft = get_draft(context) + if not draft: + try: + await query.message.edit_text("No hay borrador para publicar.") + except Exception: + pass + return + + title = draft.get("title") or "" + description = draft.get("description") or "" + base_text_parts = [] + + if title: + base_text_parts.append(title) + if description: + if title: + base_text_parts.append("") # línea en blanco entre título y descripción + base_text_parts.append(description) + + base_text = "\n".join(base_text_parts) if base_text_parts else "" + + extra_lines = [] + + if draft.get("date"): + extra_lines.append(f"📅 {draft['date']}") + + ts = draft.get("time_start") or "" + te = draft.get("time_end") or "" + if ts and te: + extra_lines.append(f"⏰ {ts} / {te}") + elif ts: + extra_lines.append(f"⏰ {ts}") + + if draft.get("hashtags"): + extra_lines.append(draft["hashtags"]) + + final_text = base_text + if extra_lines: + if final_text: + final_text += "\n\n" + "\n".join(extra_lines) + else: + final_text = "\n".join(extra_lines) + + payload = PostPayload( + text=final_text, + image_paths=draft.get("image_paths", []), + ) + + try: + post_to_mastodon(payload) + except Exception as e: + log.exception(f"[Mastodon] Error: {e}") + + try: + post_to_instagram(payload) + except Exception as e: + log.exception(f"[Instagram] Error: {e}") + + context.user_data.pop(DRAFT_KEY, None) + set_state(context, "idle") + + try: + await query.message.edit_text("Publicado en redes ✅") + except Exception: + pass + + +# ---------------------------- +# Handlers existentes /1212 # ---------------------------- async def handle_channel_post(update: Update, context: ContextTypes.DEFAULT_TYPE): """Para posts en CANAL.""" @@ -235,20 +605,32 @@ async def handle_group_message(update: Update, context: ContextTypes.DEFAULT_TYP pass +# ---------------------------- +# main() +# ---------------------------- def main(): app = ApplicationBuilder().token(TELEGRAM_TOKEN).build() - # CANALES + # NUEVO: comando /publicar + flujo de borrador + app.add_handler(CommandHandler("publicar", start_publish)) + app.add_handler(CallbackQueryHandler(draft_keyboard_callback, pattern=r"^draft:")) + # Textos normales para título / descripción / hashtags / hora / fecha + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_draft_text)) + + # EXISTENTE: CANALES con /1212 app.add_handler(MessageHandler(filters.ChatType.CHANNEL, handle_channel_post)) - # GRUPOS/SUPERGRUPOS: sólo cuando el texto o el caption empieza por /1212 + # EXISTENTE: GRUPOS/SUPERGRUPOS con /1212 group_filter = filters.ChatType.GROUPS & (filters.Regex(TRIGGER_RE) | filters.CaptionRegex(TRIGGER_RE)) app.add_handler(MessageHandler(group_filter, handle_group_message)) log.info("Bot escuchando canal/grupos…") - # Escuchamos ambos tipos - app.run_polling(allowed_updates=["message", "channel_post"], drop_pending_updates=False) + app.run_polling( + allowed_updates=["message", "channel_post", "callback_query"], + drop_pending_updates=False + ) if __name__ == "__main__": main() +