diff --git a/bot.py b/bot.py index fe4e7d5..8bad1d4 100644 --- a/bot.py +++ b/bot.py @@ -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, )