solucion de flujo

This commit is contained in:
eloraculodiario 2025-11-20 01:35:09 +01:00
parent c03105a9d0
commit 05439f1b37

293
bot.py
View file

@ -37,18 +37,37 @@ log = logging.getLogger("enresocial_bot")
load_dotenv() load_dotenv()
def get_int_env(name: str, default: int = 0) -> int:
"""
Lee una variable de entorno como int.
Si está vacía o mal formateada, devuelve default y escribe un warning.
"""
raw = os.getenv(name, "").strip()
if not raw:
return default
try:
return int(raw)
except ValueError:
log.warning(f"Variable de entorno {name}='{raw}' no es un entero válido. Usando {default}.")
return default
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN") TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN")
CHANNEL_ID = int(os.getenv("TELEGRAM_CHANNEL_ID", "0")) # canal opcional
GROUP_ID = int(os.getenv("TELEGRAM_GROUP_ID", "0")) # grupo opcional (si quieres limitar) # Si algún día quieres limitar /publicar a un chat concreto,
# puedes usar estos IDs (de momento no se filtra por ellos).
CHANNEL_ID = get_int_env("TELEGRAM_CHANNEL_ID", 0)
GROUP_ID = get_int_env("TELEGRAM_GROUP_ID", 0)
# Mastodon # Mastodon
MASTODON_BASE_URL = os.getenv("MASTODON_BASE_URL") MASTODON_BASE_URL = os.getenv("MASTODON_BASE_URL")
MASTODON_ACCESS_TOKEN = os.getenv("MASTODON_ACCESS_TOKEN") MASTODON_ACCESS_TOKEN = os.getenv("MASTODON_ACCESS_TOKEN")
# Instagram (cuando lo actives) # Instagram
IG_BUSINESS_ACCOUNT_ID = os.getenv("IG_BUSINESS_ACCOUNT_ID") IG_BUSINESS_ACCOUNT_ID = os.getenv("IG_BUSINESS_ACCOUNT_ID")
IG_ACCESS_TOKEN = os.getenv("IG_ACCESS_TOKEN") IG_ACCESS_TOKEN = os.getenv("IG_ACCESS_TOKEN")
PUBLIC_UPLOAD_BASE_URL = os.getenv("PUBLIC_UPLOAD_BASE_URL") # p.ej. https://tudominio.com/uploads PUBLIC_UPLOAD_BASE_URL = os.getenv("PUBLIC_UPLOAD_BASE_URL")
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./uploads") UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./uploads")
os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(UPLOAD_DIR, exist_ok=True)
@ -57,35 +76,16 @@ if not TELEGRAM_TOKEN:
log.error("Falta TELEGRAM_TOKEN en el .env") log.error("Falta TELEGRAM_TOKEN en el .env")
sys.exit(1) sys.exit(1)
TRIGGER = "/1212"
TRIGGER_RE = re.compile(r"^\s*/1212\b", re.IGNORECASE)
@dataclass @dataclass
class PostPayload: class PostPayload:
text: str text: str
image_paths: List[str] # rutas locales de imágenes descargadas image_paths: List[str]
# ----------------------------
# 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).
"""
full_text = (msg.text or msg.caption or "").strip()
if not full_text:
return None
if TRIGGER_RE.match(full_text):
return TRIGGER_RE.sub("", full_text, count=1).lstrip()
return None
async def download_photos(context: ContextTypes.DEFAULT_TYPE, photos: List[PhotoSize]) -> List[str]: async def download_photos(context: ContextTypes.DEFAULT_TYPE, photos: List[PhotoSize]) -> List[str]:
""" """
Descarga la foto de mayor resolución a un archivo temporal y devuelve la lista de rutas. Descarga la foto de mayor resolución a un archivo temporal y devuelve la lista de rutas.
Telegram envía varias resoluciones de la misma foto; tomamos la mayor.
""" """
if not photos: if not photos:
return [] return []
@ -120,7 +120,6 @@ def post_to_mastodon(payload: PostPayload):
def ensure_public_url(local_path: str) -> str: def ensure_public_url(local_path: str) -> str:
""" """
Convierte una ruta local en una URL pública donde Instagram pueda descargar la imagen. Convierte una ruta local en una URL pública donde Instagram pueda descargar la imagen.
Opción simple: sirves /uploads con Nginx/Apache en PUBLIC_UPLOAD_BASE_URL.
""" """
if not PUBLIC_UPLOAD_BASE_URL: if not PUBLIC_UPLOAD_BASE_URL:
raise RuntimeError("Falta PUBLIC_UPLOAD_BASE_URL para Instagram.") raise RuntimeError("Falta PUBLIC_UPLOAD_BASE_URL para Instagram.")
@ -130,11 +129,7 @@ def ensure_public_url(local_path: str) -> str:
def post_to_instagram(payload: PostPayload): def post_to_instagram(payload: PostPayload):
""" """
Publicación a Instagram Business usando Instagram Graph API: Publicación a Instagram Business usando Instagram Graph API.
1) Crear 'media container' con image_url público y caption.
2) Publicar el container.
IMPORTANTE: necesitas una URL pública (HTTPS) alcanzable por Meta.
""" """
if not (IG_BUSINESS_ACCOUNT_ID and IG_ACCESS_TOKEN): if not (IG_BUSINESS_ACCOUNT_ID and IG_ACCESS_TOKEN):
log.warning("[Instagram] No configurado, omito.") log.warning("[Instagram] No configurado, omito.")
@ -144,23 +139,24 @@ def post_to_instagram(payload: PostPayload):
log.warning("[Instagram] No hay imagen para publicar (IG requiere imagen/video).") log.warning("[Instagram] No hay imagen para publicar (IG requiere imagen/video).")
return return
# Instagram solo acepta 1 imagen por post básico con /media (para carrusel es otro flujo)
img_url = ensure_public_url(payload.image_paths[0]) img_url = ensure_public_url(payload.image_paths[0])
# 1) Crear contenedor
media_resp = requests.post( media_resp = requests.post(
f"https://graph.facebook.com/v21.0/{IG_BUSINESS_ACCOUNT_ID}/media", f"https://graph.facebook.com/v21.0/{IG_BUSINESS_ACCOUNT_ID}/media",
data={"image_url": img_url, "caption": payload.text or "", "access_token": IG_ACCESS_TOKEN}, data={
timeout=60 "image_url": img_url,
"caption": payload.text or "",
"access_token": IG_ACCESS_TOKEN,
},
timeout=60,
) )
media_resp.raise_for_status() media_resp.raise_for_status()
creation_id = media_resp.json().get("id") creation_id = media_resp.json().get("id")
# 2) Publicar
publish_resp = requests.post( publish_resp = requests.post(
f"https://graph.facebook.com/v21.0/{IG_BUSINESS_ACCOUNT_ID}/media_publish", f"https://graph.facebook.com/v21.0/{IG_BUSINESS_ACCOUNT_ID}/media_publish",
data={"creation_id": creation_id, "access_token": IG_ACCESS_TOKEN}, data={"creation_id": creation_id, "access_token": IG_ACCESS_TOKEN},
timeout=60 timeout=60,
) )
publish_resp.raise_for_status() publish_resp.raise_for_status()
log.info("[Instagram] Publicado.") log.info("[Instagram] Publicado.")
@ -170,7 +166,9 @@ def post_to_instagram(payload: PostPayload):
# Borradores para /publicar # Borradores para /publicar
# ---------------------------- # ----------------------------
DRAFT_KEY = "current_draft" DRAFT_KEY = "current_draft"
DRAFT_STATE_KEY = "draft_state" # 'idle', 'waiting_title', 'waiting_description', 'waiting_hashtags', 'waiting_time', 'waiting_date' # 'idle', 'waiting_title', 'waiting_description', 'waiting_hashtags',
# 'waiting_time', 'waiting_date', 'waiting_photo'
DRAFT_STATE_KEY = "draft_state"
def init_draft( def init_draft(
@ -178,7 +176,7 @@ def init_draft(
chat_id: int, chat_id: int,
title: str = "", title: str = "",
description: str = "", description: str = "",
image_paths=None image_paths: Optional[List[str]] = None,
): ):
if image_paths is None: if image_paths is None:
image_paths = [] image_paths = []
@ -223,13 +221,16 @@ def build_draft_preview_text(draft: dict) -> str:
else: else:
time_str = "" time_str = ""
has_image = "Sí ✅" if draft.get("image_paths") else "No —"
text = ( text = (
"✏️ *Borrador de publicación*\n\n" "✏️ *Borrador de publicación*\n\n"
f"📌 *Título*: {title}\n\n" f"📌 *Título*: {title}\n\n"
f"📝 *Descripción:*\n{description}\n\n" f"📝 *Descripción:*\n{description}\n\n"
f"🏷 Hashtags: {hashtags}\n" f"🏷 Hashtags: {hashtags}\n"
f"⏰ Horario: {time_str}\n" f"⏰ Horario: {time_str}\n"
f"📅 Fecha: {date}" f"📅 Fecha: {date}\n"
f"🖼 Imagen: {has_image}"
) )
return text return text
@ -245,18 +246,21 @@ def build_draft_keyboard() -> InlineKeyboardMarkup:
InlineKeyboardButton("Horario", callback_data="draft:time"), InlineKeyboardButton("Horario", callback_data="draft:time"),
InlineKeyboardButton("Fecha", callback_data="draft:date"), InlineKeyboardButton("Fecha", callback_data="draft:date"),
], ],
[
InlineKeyboardButton("📷 Adjuntar foto", callback_data="draft:photo"),
],
[ [
InlineKeyboardButton("✅ Publicar", callback_data="draft:publish"), InlineKeyboardButton("✅ Publicar", callback_data="draft:publish"),
InlineKeyboardButton("❌ Cancelar", callback_data="draft:cancel"), InlineKeyboardButton("❌ Cancelar", callback_data="draft:cancel"),
] ],
] ]
return InlineKeyboardMarkup(keyboard) return InlineKeyboardMarkup(keyboard)
async def refresh_draft_message(context: ContextTypes.DEFAULT_TYPE): async def refresh_draft_message(context: ContextTypes.DEFAULT_TYPE):
""" """
Borra el mensaje de preview anterior (si existe) y envía uno nuevo Elimina el mensaje de preview anterior (si existe) y envía uno nuevo
con la preview + botones, para que siempre quede al final del chat. con el borrador actualizado + botones.
""" """
draft = get_draft(context) draft = get_draft(context)
if not draft: if not draft:
@ -265,19 +269,17 @@ async def refresh_draft_message(context: ContextTypes.DEFAULT_TYPE):
chat_id = draft["chat_id"] chat_id = draft["chat_id"]
old_message_id = draft.get("message_id") old_message_id = draft.get("message_id")
# Borramos el mensaje anterior del borrador si existe
if old_message_id: if old_message_id:
try: try:
await context.bot.delete_message(chat_id=chat_id, message_id=old_message_id) await context.bot.delete_message(chat_id=chat_id, message_id=old_message_id)
except Exception as e: except Exception as e:
log.warning(f"No se pudo borrar el mensaje anterior del borrador: {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( sent = await context.bot.send_message(
chat_id=chat_id, chat_id=chat_id,
text=build_draft_preview_text(draft), text=build_draft_preview_text(draft),
reply_markup=build_draft_keyboard(), reply_markup=build_draft_keyboard(),
parse_mode="Markdown" parse_mode="Markdown",
) )
draft["message_id"] = sent.message_id draft["message_id"] = sent.message_id
@ -293,25 +295,24 @@ async def start_publish(update: Update, context: ContextTypes.DEFAULT_TYPE):
full_text = msg.text or "" full_text = msg.text or ""
parts = full_text.split(" ", 1) parts = full_text.split(" ", 1)
# Si el usuario pone texto tras /publicar, lo usamos como descripción inicial # Texto que venga detrás de /publicar lo usamos como descripción inicial
base_description = "" base_description = ""
if len(parts) >= 2 and parts[1].strip(): if len(parts) >= 2 and parts[1].strip():
base_description = parts[1].strip() base_description = parts[1].strip()
# Descargamos foto si la hay
image_paths: List[str] = [] image_paths: List[str] = []
# Si el usuario lanzó /publicar con una foto ya adjunta, también la usamos
if msg.photo: if msg.photo:
image_paths = await download_photos(context, 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) init_draft(context, msg.chat.id, title="", description=base_description, image_paths=image_paths)
# Mostramos el borrador con la botonera al final del chat # Primer mensaje interactivo con los botones
await refresh_draft_message(context) await refresh_draft_message(context)
# ---------------------------- # ----------------------------
# Callback de botones (Título / Descripción / Hashtag / Horario / Fecha / Publicar / Cancelar) # Callback de botones
# ---------------------------- # ----------------------------
async def draft_keyboard_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): async def draft_keyboard_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query query = update.callback_query
@ -335,35 +336,43 @@ async def draft_keyboard_callback(update: Update, context: ContextTypes.DEFAULT_
set_state(context, "waiting_title") set_state(context, "waiting_title")
await query.message.reply_text( await query.message.reply_text(
"Escribe ahora el *título* de la publicación.", "Escribe ahora el *título* de la publicación.",
parse_mode="Markdown" parse_mode="Markdown",
) )
elif action == "description": elif action == "description":
set_state(context, "waiting_description") set_state(context, "waiting_description")
await query.message.reply_text( await query.message.reply_text(
"Escribe ahora la *descripción* de la publicación.", "Escribe ahora la *descripción* de la publicación.",
parse_mode="Markdown" parse_mode="Markdown",
) )
elif action == "hashtags": elif action == "hashtags":
set_state(context, "waiting_hashtags") set_state(context, "waiting_hashtags")
await query.message.reply_text( await query.message.reply_text(
"Escribe ahora los *hashtags* en un mensaje (por ejemplo: `#feminismo #sostenibilidad`).", "Escribe ahora los *hashtags* (por ejemplo: `#feminismo #sostenibilidad`).",
parse_mode="Markdown" parse_mode="Markdown",
) )
elif action == "time": elif action == "time":
set_state(context, "waiting_time") set_state(context, "waiting_time")
await query.message.reply_text( await query.message.reply_text(
"Escribe ahora el *horario* en formato `HH:MM / HH:MM` (por ejemplo: `19:30 / 21:30`).", "Escribe ahora el *horario* en formato `HH:MM / HH:MM` (por ejemplo: `19:30 / 21:30`).",
parse_mode="Markdown" parse_mode="Markdown",
) )
elif action == "date": elif action == "date":
set_state(context, "waiting_date") set_state(context, "waiting_date")
await query.message.reply_text( await query.message.reply_text(
"Escribe ahora la *fecha* en formato `DD/MM/AAAA` (por ejemplo: `13/04/2025`).", "Escribe ahora la *fecha* en formato `DD/MM/AAAA` (por ejemplo: `13/04/2025`).",
parse_mode="Markdown" parse_mode="Markdown",
)
elif action == "photo":
set_state(context, "waiting_photo")
await query.message.reply_text(
"Ahora envía la *foto* como un mensaje normal (sin comandos). "
"Se usará como imagen de la publicación.",
parse_mode="Markdown",
) )
elif action == "publish": elif action == "publish":
@ -379,9 +388,14 @@ async def draft_keyboard_callback(update: Update, context: ContextTypes.DEFAULT_
# ---------------------------- # ----------------------------
# Manejo de los textos de título / descripción / hashtag / hora / fecha # Manejo de texto para el borrador
# ---------------------------- # ----------------------------
async def handle_draft_text(update: Update, context: ContextTypes.DEFAULT_TYPE): async def handle_draft_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Aquí llega el texto que el usuario escribe DESPUÉS de pulsar un botón
(Título, Descripción, Hashtags, Hora, Fecha).
Guardamos el dato y volvemos a mostrar el borrador con los botones.
"""
msg = update.message msg = update.message
if msg is None or not msg.text: if msg is None or not msg.text:
return return
@ -389,39 +403,40 @@ async def handle_draft_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
state = get_state(context) state = get_state(context)
draft = get_draft(context) draft = get_draft(context)
if not draft: if not draft:
return # no hay borrador activo, ignoramos return
text = msg.text.strip() text = msg.text.strip()
# Título # Opcional: borrar el mensaje de texto del usuario para que el chat quede “limpio”
try:
await msg.delete()
except Exception:
pass
if state == "waiting_title": if state == "waiting_title":
draft["title"] = text draft["title"] = text
set_state(context, "idle") set_state(context, "idle")
await refresh_draft_message(context) await refresh_draft_message(context)
return return
# Descripción
if state == "waiting_description": if state == "waiting_description":
draft["description"] = text draft["description"] = text
set_state(context, "idle") set_state(context, "idle")
await refresh_draft_message(context) await refresh_draft_message(context)
return return
# Hashtags
if state == "waiting_hashtags": if state == "waiting_hashtags":
draft["hashtags"] = text draft["hashtags"] = text
set_state(context, "idle") set_state(context, "idle")
await refresh_draft_message(context) await refresh_draft_message(context)
return return
# Horario rango HH:MM / HH:MM
if state == "waiting_time": 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) m = re.match(r"^\s*(\d{1,2}:\d{2})\s*/\s*(\d{1,2}:\d{2})\s*$", text)
if not m: if not m:
await msg.reply_text( await msg.reply_text(
"Formato de horario no válido. Usa `HH:MM / HH:MM`, por ejemplo `19:30 / 21:30`.", "Formato de horario no válido. Usa `HH:MM / HH:MM`, por ejemplo `19:30 / 21:30`.",
parse_mode="Markdown" parse_mode="Markdown",
) )
return return
start, end = m.group(1), m.group(2) start, end = m.group(1), m.group(2)
@ -431,12 +446,11 @@ async def handle_draft_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
await refresh_draft_message(context) await refresh_draft_message(context)
return return
# Fecha DD/MM/AAAA
if state == "waiting_date": if state == "waiting_date":
if not re.match(r"^\d{1,2}/\d{1,2}/\d{4}$", text): if not re.match(r"^\d{1,2}/\d{1,2}/\d{4}$", text):
await msg.reply_text( await msg.reply_text(
"Formato de fecha no válido. Usa `DD/MM/AAAA`, por ejemplo `13/04/2025`.", "Formato de fecha no válido. Usa `DD/MM/AAAA`, por ejemplo `13/04/2025`.",
parse_mode="Markdown" parse_mode="Markdown",
) )
return return
draft["date"] = text draft["date"] = text
@ -444,12 +458,45 @@ async def handle_draft_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
await refresh_draft_message(context) await refresh_draft_message(context)
return return
# Si no estamos esperando nada especial, no hacemos nada # Si no estamos esperando nada, ignoramos
return return
# ---------------------------- # ----------------------------
# Publicar borrador (/publicar) realmente # Manejo de fotos para el borrador
# ----------------------------
async def handle_draft_photo(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""
Aquí llega la foto cuando el usuario ha pulsado 'Adjuntar foto'.
Descargamos la imagen, la guardamos en el borrador y mostramos de nuevo el borrador.
"""
msg = update.message
if msg is None or not msg.photo:
return
state = get_state(context)
draft = get_draft(context)
if not draft:
return
if state != "waiting_photo":
# Si no estamos esperando una foto, ignoramos (o podríamos decidir otra cosa)
return
# Opcional: borrar el mensaje de foto del usuario para limpiar el chat
try:
await msg.delete()
except Exception:
pass
image_paths = await download_photos(context, msg.photo)
draft["image_paths"] = image_paths
set_state(context, "idle")
await refresh_draft_message(context)
# ----------------------------
# Publicar borrador
# ---------------------------- # ----------------------------
async def finalize_and_post_draft(update: Update, context: ContextTypes.DEFAULT_TYPE): async def finalize_and_post_draft(update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query query = update.callback_query
@ -463,18 +510,18 @@ async def finalize_and_post_draft(update: Update, context: ContextTypes.DEFAULT_
title = draft.get("title") or "" title = draft.get("title") or ""
description = draft.get("description") or "" description = draft.get("description") or ""
base_text_parts = [] base_text_parts: List[str] = []
if title: if title:
base_text_parts.append(title) base_text_parts.append(title)
if description: if description:
if title: if title:
base_text_parts.append("") # línea en blanco entre título y descripción base_text_parts.append("")
base_text_parts.append(description) base_text_parts.append(description)
base_text = "\n".join(base_text_parts) if base_text_parts else "" base_text = "\n".join(base_text_parts) if base_text_parts else ""
extra_lines = [] extra_lines: List[str] = []
if draft.get("date"): if draft.get("date"):
extra_lines.append(f"📅 {draft['date']}") extra_lines.append(f"📅 {draft['date']}")
@ -520,114 +567,26 @@ async def finalize_and_post_draft(update: Update, context: ContextTypes.DEFAULT_
pass pass
# ----------------------------
# Handlers existentes /1212
# ----------------------------
async def handle_channel_post(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Para posts en CANAL."""
if update.channel_post is None:
return
msg = update.channel_post
# Debug de ID y tipo
log.info(f"[TG] channel_post en chat.id={msg.chat.id} (type=channel)")
# Limitar opcionalmente a un canal concreto
if CHANNEL_ID and msg.chat.id != CHANNEL_ID:
return
payload_text = extract_command_payload(msg)
if payload_text is None:
return # no empieza por /1212
image_paths: List[str] = []
if msg.photo:
image_paths = await download_photos(context, msg.photo)
payload = PostPayload(text=payload_text, image_paths=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}")
try:
await msg.reply_text("Publicado en redes ✅", quote=True)
except Exception:
pass
async def handle_group_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Para mensajes en GRUPOS/SUPERGRUPOS que empiecen por /1212 (texto o caption)."""
if update.message is None:
return
msg = update.message
# Debug de ID y tipo
log.info(f"[TG] message en chat.id={msg.chat.id} (type={msg.chat.type})")
# Limitar opcionalmente a un grupo concreto
if GROUP_ID and msg.chat.id != GROUP_ID:
return
# Aseguramos que sólo atendemos si empieza por /1212 en texto o caption
if not (TRIGGER_RE.match(msg.text or "") or TRIGGER_RE.match(msg.caption or "")):
return
payload_text = extract_command_payload(msg)
if payload_text is None:
return
image_paths: List[str] = []
if msg.photo:
image_paths = await download_photos(context, msg.photo)
payload = PostPayload(text=payload_text, image_paths=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}")
try:
await msg.reply_text("Publicado en redes ✅", quote=True)
except Exception:
pass
# ---------------------------- # ----------------------------
# main() # main()
# ---------------------------- # ----------------------------
def main(): def main():
app = ApplicationBuilder().token(TELEGRAM_TOKEN).build() app = ApplicationBuilder().token(TELEGRAM_TOKEN).build()
# NUEVO: comando /publicar + flujo de borrador # Único flujo: /publicar + botones + textos + fotos del borrador
app.add_handler(CommandHandler("publicar", start_publish)) app.add_handler(CommandHandler("publicar", start_publish))
app.add_handler(CallbackQueryHandler(draft_keyboard_callback, pattern=r"^draft:")) app.add_handler(CallbackQueryHandler(draft_keyboard_callback, pattern=r"^draft:"))
# Textos normales para título / descripción / hashtags / hora / fecha
# Texto para título/descripcion/hashtags/hora/fecha
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_draft_text)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_draft_text))
# EXISTENTE: CANALES con /1212 # Fotos para "Adjuntar foto"
app.add_handler(MessageHandler(filters.ChatType.CHANNEL, handle_channel_post)) app.add_handler(MessageHandler(filters.PHOTO, handle_draft_photo))
# EXISTENTE: GRUPOS/SUPERGRUPOS con /1212 log.info("Bot escuchando /publicar…")
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…")
app.run_polling( app.run_polling(
allowed_updates=["message", "channel_post", "callback_query"], allowed_updates=["message", "callback_query"],
drop_pending_updates=False drop_pending_updates=False,
) )