solucion de flujo
This commit is contained in:
parent
c03105a9d0
commit
05439f1b37
1 changed files with 126 additions and 167 deletions
293
bot.py
293
bot.py
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue