añadida logica de botones
This commit is contained in:
parent
e2114d053c
commit
c03105a9d0
1 changed files with 389 additions and 7 deletions
396
bot.py
396
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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue