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()
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")
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_BASE_URL = os.getenv("MASTODON_BASE_URL")
MASTODON_ACCESS_TOKEN = os.getenv("MASTODON_ACCESS_TOKEN")
# Instagram (cuando lo actives)
# Instagram
IG_BUSINESS_ACCOUNT_ID = os.getenv("IG_BUSINESS_ACCOUNT_ID")
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")
os.makedirs(UPLOAD_DIR, exist_ok=True)
@ -57,35 +76,16 @@ if not TELEGRAM_TOKEN:
log.error("Falta TELEGRAM_TOKEN en el .env")
sys.exit(1)
TRIGGER = "/1212"
TRIGGER_RE = re.compile(r"^\s*/1212\b", re.IGNORECASE)
@dataclass
class PostPayload:
text: str
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).
"""
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
image_paths: 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.
Telegram envía varias resoluciones de la misma foto; tomamos la mayor.
"""
if not photos:
return []
@ -120,7 +120,6 @@ def post_to_mastodon(payload: PostPayload):
def ensure_public_url(local_path: str) -> str:
"""
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:
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):
"""
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.
Publicación a Instagram Business usando Instagram Graph API.
"""
if not (IG_BUSINESS_ACCOUNT_ID and IG_ACCESS_TOKEN):
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).")
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])
# 1) Crear contenedor
media_resp = requests.post(
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},
timeout=60
data={
"image_url": img_url,
"caption": payload.text or "",
"access_token": IG_ACCESS_TOKEN,
},
timeout=60,
)
media_resp.raise_for_status()
creation_id = media_resp.json().get("id")
# 2) Publicar
publish_resp = requests.post(
f"https://graph.facebook.com/v21.0/{IG_BUSINESS_ACCOUNT_ID}/media_publish",
data={"creation_id": creation_id, "access_token": IG_ACCESS_TOKEN},
timeout=60
timeout=60,
)
publish_resp.raise_for_status()
log.info("[Instagram] Publicado.")
@ -170,7 +166,9 @@ def post_to_instagram(payload: PostPayload):
# Borradores para /publicar
# ----------------------------
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(
@ -178,7 +176,7 @@ def init_draft(
chat_id: int,
title: str = "",
description: str = "",
image_paths=None
image_paths: Optional[List[str]] = None,
):
if image_paths is None:
image_paths = []
@ -223,13 +221,16 @@ def build_draft_preview_text(draft: dict) -> str:
else:
time_str = ""
has_image = "Sí ✅" if draft.get("image_paths") else "No —"
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}"
f"📅 Fecha: {date}\n"
f"🖼 Imagen: {has_image}"
)
return text
@ -245,18 +246,21 @@ def build_draft_keyboard() -> InlineKeyboardMarkup:
InlineKeyboardButton("Horario", callback_data="draft:time"),
InlineKeyboardButton("Fecha", callback_data="draft:date"),
],
[
InlineKeyboardButton("📷 Adjuntar foto", callback_data="draft:photo"),
],
[
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.
Elimina el mensaje de preview anterior (si existe) y envía uno nuevo
con el borrador actualizado + botones.
"""
draft = get_draft(context)
if not draft:
@ -265,19 +269,17 @@ async def refresh_draft_message(context: ContextTypes.DEFAULT_TYPE):
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"
parse_mode="Markdown",
)
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 ""
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 = ""
if len(parts) >= 2 and parts[1].strip():
base_description = parts[1].strip()
# Descargamos foto si la hay
image_paths: List[str] = []
# Si el usuario lanzó /publicar con una foto ya adjunta, también la usamos
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
# Primer mensaje interactivo con los botones
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):
query = update.callback_query
@ -335,35 +336,43 @@ async def draft_keyboard_callback(update: Update, context: ContextTypes.DEFAULT_
set_state(context, "waiting_title")
await query.message.reply_text(
"Escribe ahora el *título* de la publicación.",
parse_mode="Markdown"
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"
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"
"Escribe ahora los *hashtags* (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"
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"
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":
@ -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):
"""
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
if msg is None or not msg.text:
return
@ -389,39 +403,40 @@ async def handle_draft_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
state = get_state(context)
draft = get_draft(context)
if not draft:
return # no hay borrador activo, ignoramos
return
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":
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"
parse_mode="Markdown",
)
return
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)
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"
parse_mode="Markdown",
)
return
draft["date"] = text
@ -444,12 +458,45 @@ async def handle_draft_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
await refresh_draft_message(context)
return
# Si no estamos esperando nada especial, no hacemos nada
# Si no estamos esperando nada, ignoramos
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):
query = update.callback_query
@ -463,18 +510,18 @@ async def finalize_and_post_draft(update: Update, context: ContextTypes.DEFAULT_
title = draft.get("title") or ""
description = draft.get("description") or ""
base_text_parts = []
base_text_parts: List[str] = []
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("")
base_text_parts.append(description)
base_text = "\n".join(base_text_parts) if base_text_parts else ""
extra_lines = []
extra_lines: List[str] = []
if draft.get("date"):
extra_lines.append(f"📅 {draft['date']}")
@ -520,114 +567,26 @@ async def finalize_and_post_draft(update: Update, context: ContextTypes.DEFAULT_
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()
# ----------------------------
def main():
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(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))
# EXISTENTE: CANALES con /1212
app.add_handler(MessageHandler(filters.ChatType.CHANNEL, handle_channel_post))
# Fotos para "Adjuntar foto"
app.add_handler(MessageHandler(filters.PHOTO, handle_draft_photo))
# 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…")
log.info("Bot escuchando /publicar…")
app.run_polling(
allowed_updates=["message", "channel_post", "callback_query"],
drop_pending_updates=False
allowed_updates=["message", "callback_query"],
drop_pending_updates=False,
)