Kossuth / bot.py
corsokossuth's picture
Upload 5 files
d9f607f verified
Raw
History Blame Contribute Delete
113 kB
#!/usr/bin/env python3
"""
Corso Kossuth β€” bot.py (v3)
Bot Telegram per l'upload audio dei contributor.
AREA UTENTE β†’ /start β†’ menu β†’ invio audio guidato in 5 passi
AREA ADMIN β†’ /start β†’ menu allargato β†’ approvazione, coda, gestione
"""
from typing import Optional
import html as _html
import os, sys, re, json, time, base64, logging, tempfile, subprocess
from datetime import datetime, timedelta, date
from pathlib import Path
# ── Dipendenze ────────────────────────────────────────────────────
try:
from telegram import (
Update, InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardRemove,
)
from telegram.ext import (
Application, CommandHandler, MessageHandler, CallbackQueryHandler,
ConversationHandler, ContextTypes, filters, ApplicationHandlerStop,
)
import httpx
except ImportError:
subprocess.run([sys.executable,"-m","pip","install",
"python-telegram-bot==20.7","httpx","--quiet"], check=False)
from telegram import (Update, InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardRemove)
from telegram.ext import (Application, CommandHandler, MessageHandler,
CallbackQueryHandler, ConversationHandler, ContextTypes, filters)
import httpx
# ── Config ────────────────────────────────────────────────────────
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
HF_TOKEN = os.environ.get("HF_TOKEN", "").strip()
HF_DATASET = "corsokossuth/kossuth-music"
ADMIN_IDS = [int(x) for x in os.environ.get("TELEGRAM_ADMIN_IDS","").split(",") if x.strip().isdigit()]
# URL del Cloudflare Worker proxy (es. https://kossuth-bot.user.workers.dev)
DATA_DIR = Path("/app/bot_data")
QUEUE_FILE = DATA_DIR / "queue.json"
PEND_FILE = DATA_DIR / "pending.json"
LOG_FILE = "/tmp/artwork.log" # stesso file esposto dal server a /log
MAX_DURATION = 180
GAP_MINUTES = 2
# Nessuna fascia: slot ogni 5 minuti nell'arco delle 24h
# ── Logging β†’ /tmp/artwork.log (visibile a /log) ──────────────────
DATA_DIR.mkdir(parents=True, exist_ok=True)
logging.basicConfig(
format="%(asctime)s [BOT] %(levelname)s %(message)s",
level=logging.INFO,
handlers=[
logging.FileHandler(LOG_FILE, encoding="utf-8"),
logging.StreamHandler(sys.stdout),
],
)
log = logging.getLogger(__name__)
# I log di httpx riportano l'URL completo delle chiamate a Telegram, che contiene
# il token nel path (…/bot<token>/metodo). Un filtro sui handler lo oscura in OGNI
# riga (httpx e qualsiasi altro logger che propaga alla root): il log resta utile
# per il debug ma il token non finisce mai in chiaro, nemmeno servito da /bot_log.
import re as _re_redact
_TG_TOKEN_RE = _re_redact.compile(r'/bot\d+:[A-Za-z0-9_-]+')
class _RedactTokenFilter(logging.Filter):
def filter(self, record):
try:
msg = record.getMessage()
red = _TG_TOKEN_RE.sub('/bot<REDACTED>', msg)
if red != msg:
record.msg = red
record.args = ()
except Exception:
pass
return True
for _h in logging.getLogger().handlers:
_h.addFilter(_RedactTokenFilter())
# ── Stati conversazione ───────────────────────────────────────────
ASK_NAME, ASK_DESC, ASK_PODCAST, ASK_HOUR, ASK_TIME = range(5)
# ── Progress bar ──────────────────────────────────────────────────
STEPS = ["Nome", "Descrizione", "Ora", "Minuto"]
def progress(step: int) -> str:
bar = "".join("🟒" if i < step else "βšͺ" for i in range(len(STEPS)))
return f"{bar} _Passo {step} di {len(STEPS)} β€” {STEPS[step-1]}_"
# ── JSON helpers ──────────────────────────────────────────────────
def load_json(p, d):
try: return json.loads(Path(p).read_text(encoding="utf-8"))
except: return d
def sanitize(s: str, maxlen: int = 50) -> str:
import re, unicodedata
s = unicodedata.normalize('NFKD', s).encode('ascii', 'ignore').decode()
s = re.sub(r'[^a-zA-Z0-9._-]', '_', s.strip())
s = re.sub(r'_+', '_', s).strip('_')
return s[:maxlen] or 'audio'
def save_json(p, d):
Path(p).write_text(json.dumps(d, ensure_ascii=False, indent=2), encoding="utf-8")
# ── Slot ogni 5 minuti, 24h ──────────────────────────────────────
def _occupied_set(target_date: date) -> set:
"""
Blocca i minuti occupati da ogni slot approvato o in attesa.
Logica: da -1 a +(MAX_DURATION/60 + GAP - 1) minuti.
Con MAX=3min, GAP=2min β†’ blocca -1,0,1,2,3,4 (6 minuti).
Questo garantisce slot ogni 5 minuti senza sovrapposizioni.
"""
all_items = load_json(QUEUE_FILE, []) + list(load_json(PEND_FILE, {}).values())
occupied = set()
forward = MAX_DURATION // 60 + GAP_MINUTES # = 5
for item in all_items:
if item.get("date") != str(target_date):
continue
try:
slot_dt = datetime.strptime(f"{item['date']} {item['time']}", "%Y-%m-%d %H:%M")
except ValueError:
continue
# -1 minuto di buffer + il blocco avanti
for d in range(-1, forward):
occupied.add((slot_dt + timedelta(minutes=d)).strftime("%H:%M"))
return occupied
def available_hours(target_date: date) -> list:
"""Ore (int 0-23) che hanno almeno uno slot libero."""
occ = _occupied_set(target_date)
now = datetime.now()
hours = []
for h in range(24):
for m in range(0, 60, 5):
s = f"{h:02d}:{m:02d}"
dt = datetime.strptime(f"{target_date} {s}", "%Y-%m-%d %H:%M")
# Salta se nel passato (+ 10 min buffer)
if target_date == now.date() and dt <= now + timedelta(minutes=10):
continue
if s not in occ:
hours.append(h)
break
return hours
def available_minutes(target_date: date, hour: int) -> list:
"""Slot liberi a 5 min all'interno dell'ora specificata."""
occ = _occupied_set(target_date)
now = datetime.now()
slots = []
for m in range(0, 60, 5):
s = f"{hour:02d}:{m:02d}"
dt = datetime.strptime(f"{target_date} {s}", "%Y-%m-%d %H:%M")
if target_date == now.date() and dt <= now + timedelta(minutes=10):
continue
if s not in occ:
slots.append(s)
return slots
# ── Conversione audio ─────────────────────────────────────────────
def convert_audio(src: Path, dst: Path):
try:
r = subprocess.run(
["ffprobe","-v","quiet","-print_format","json","-show_format",str(src)],
capture_output=True, timeout=15)
dur = float(json.loads(r.stdout)["format"]["duration"])
except:
dur = 0.0
ok = subprocess.run([
"ffmpeg","-y","-i",str(src),
"-t",str(MAX_DURATION),
"-ar","44100","-ac","2",
"-f","wav", # WAV: sempre decodificabile da Liquidsoap (evita problemi libmad)
str(dst)
], capture_output=True, timeout=60).returncode == 0
return ok, dur
# ── Upload HuggingFace ────────────────────────────────────────────
async def upload_hf(local_path: Path, repo_path: str) -> bool:
if not HF_TOKEN:
dest = Path("/app/music") / repo_path
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(local_path.read_bytes())
return True
try:
encoded = base64.b64encode(local_path.read_bytes()).decode()
payload = (
json.dumps({"key":"header","value":{"summary":f"Add {Path(repo_path).name}"}})
+ "\n"
+ json.dumps({"key":"file","value":{"path":repo_path,"encoding":"base64","content":encoded}})
)
async with httpx.AsyncClient(timeout=120) as c:
r = await c.post(
f"https://huggingface.co/api/datasets/{HF_DATASET}/commit/main",
content=payload.encode(),
headers={"Authorization":f"Bearer {HF_TOKEN}","Content-Type":"application/x-ndjson"},
)
if r.status_code in (200, 201):
log.info(f"HF upload OK: {repo_path}")
return True
log.error(f"HF upload {r.status_code}: {r.text[:200]}")
return False
except Exception as e:
log.error(f"HF upload error: {e}")
return False
def hf_upload(data: bytes, repo_path: str) -> bool:
"""Carica bytes su HF in background β€” non blocca mai il bot."""
import tempfile, asyncio as _aio, threading as _thr
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=Path(repo_path).suffix) as tf:
tf.write(data); tmp_path = Path(tf.name)
try:
loop = _aio.get_event_loop()
if loop.is_running():
# Async context: usa create_task (fire-and-forget, non blocca)
async def _do():
try: await upload_hf(tmp_path, repo_path)
except Exception as _e: log.warning(f"HF upload bg {repo_path}: {_e}")
finally: tmp_path.unlink(missing_ok=True)
_aio.ensure_future(_do())
return True # risposta immediata, upload avviene dopo
else:
result = loop.run_until_complete(upload_hf(tmp_path, repo_path))
tmp_path.unlink(missing_ok=True)
return result
except RuntimeError:
# Nessun loop β€” thread sincrono
def _sync():
import asyncio as _a2
_a2.run(upload_hf(tmp_path, repo_path))
tmp_path.unlink(missing_ok=True)
_thr.Thread(target=_sync, daemon=True).start()
return True
except Exception as _e:
log.error(f"hf_upload ({repo_path}): {_e}")
return False
# ══════════════════════════════════════════════════════════════════
# MENU PRINCIPALE
# ══════════════════════════════════════════════════════════════════
def is_admin(uid) -> bool:
return int(uid) in ADMIN_IDS
def main_menu_kb(uid) -> InlineKeyboardMarkup:
buttons = [
[InlineKeyboardButton("πŸŽ™οΈ Invia il tuo audio", callback_data="menu_upload")],
[InlineKeyboardButton("🎬 Cineforum β€” Vota i film", callback_data="menu_cineforum")],
[InlineKeyboardButton("πŸ“… Segnala un evento", callback_data="menu_evento")],
[InlineKeyboardButton("πŸ“‹ Le mie proposte", callback_data="menu_stato")],
[InlineKeyboardButton("βœ‰οΈ Contatti Β· Diritti & rimozione", callback_data="menu_contact")],
]
if is_admin(uid):
buttons.append([InlineKeyboardButton("πŸŽ›οΈ Pannello Admin", callback_data="menu_admin")])
return InlineKeyboardMarkup(buttons)
async def _cmd_mie_proposte(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Shortcut: mostra le proposte dell'utente."""
uid = update.effective_user.id
uid_str = str(uid)
lines = []
for s in load_json(PEND_FILE, {}).values():
if str(s.get("uid","")) != uid_str: continue
lines.append(f"🎡 ⏳ {s.get('description','Audio')} β€” {s.get('date','')} {s.get('time','')} _in attesa_")
for q in load_json(QUEUE_FILE, []):
if str(q.get("uid","")) != uid_str or q.get("played"): continue
lines.append(f"🎡 βœ… {q.get('description','Audio')} β€” {q.get('date','')} {q.get('time','')} _in programma_")
msg = ("πŸ“‹ *Le tue proposte:*\n\n" + "\n".join(lines)) if lines else "πŸ“‹ Nessun contributo in corso."
await update.message.reply_text(msg, parse_mode="Markdown")
async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
msg = (
"🎭 Corso Kossuth β€” Comandi\n\n"
"/start β€” Menu principale\n"
"/cineforum β€” Cineforum: vota i film, proponi nuovi titoli\n"
"/invia_evento β€” Segnala un evento nella tua cittΓ \n"
"/annulla β€” Annulla operazione in corso\n\n"
"Contributi audio (Radio): dal menu /start\n"
" Max 3 min, slot ogni 5 min, min 15 min anticipo\n\n"
"Segnalazione eventi a Torino:\n"
" /invia_evento β†’ scegli cittΓ  β†’ compila i dati\n\n"
"/mie β€” Le mie proposte\n"
"/contatti β€” Scrivi alla redazione (diritti, rimozioni)"
)
await update.message.reply_text(msg)
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
uid = update.effective_user.id
# Se l'utente aveva aperto "Contatti" senza inviare il messaggio, non
# lasciare il flusso appeso: tornando al menu lo chiudiamo.
if ctx.user_data.get("__flow__") == "contact":
ctx.user_data.pop("__flow__", None)
ctx.user_data.pop("__step__", None)
name = update.effective_user.first_name or "Ascoltatore"
hint = ""
if is_admin(uid):
hint = f"\n\nπŸ”‘ <b>Admin</b> Β· ID: <code>{uid}</code>"
else:
hint = f"\n\nID Telegram: <code>{uid}</code>"
import html as _html
safe_name = _html.escape(name)
msg = update.message or (update.callback_query.message if update.callback_query else None)
if not msg:
return
await msg.reply_text(
f"🎭 <b>Corso Kossuth</b> β€” Benvenuto {safe_name}!\n"
f"<i>Gente in mezzo a una strada</i>\n\n"
f"Puoi contribuire alla radio e alle sue rubriche:\n"
f"🎡 <b>Radio</b> β€” audio in onda\n"
f"🎬 <b>Cine</b> β€” vota i film del cineforum\n"
f"πŸ“… <b>Eventi</b> β€” segnala eventi a Torino\n"
f"{hint}",
parse_mode="HTML",
reply_markup=main_menu_kb(uid),
)
async def cmd_contatti(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Canale contatti / diritti GDPR / rimozione / DMCA (promesso dalla privacy policy)."""
ctx.user_data["__flow__"] = "contact"
ctx.user_data["__step__"] = "await_msg"
await update.message.reply_text(
"βœ‰οΈ <b>Scrivici</b>\n\n"
"Usa questo canale per richieste sui tuoi dati (accesso/cancellazione, GDPR), "
"rimozione di un evento o di una fonte, DMCA notice o qualsiasi segnalazione "
"alla redazione.\n\n"
"Scrivi ora il tuo messaggio in un'unica risposta. "
"Ti risponderemo qui su Telegram (entro 30 giorni per le richieste sui dati).\n\n"
"<i>Per annullare: /annulla</i>",
parse_mode="HTML",
)
async def menu_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
await query.answer()
uid = query.from_user.id
if query.data == "menu_cineforum":
films = load_cineforum()
approved = [f for f in films if f.get("status","approved") == "approved"]
intro = (
"🎬 *Cineforum Corso Kossuth*\n\n"
f"Lista di {len(approved)} film.\n"
"Seleziona un film per votarlo o aggiungerne uno nuovo.\n\n"
"_Voto in 10esimi Β· la media esclude il voto piΓΉ alto e il piΓΉ basso_"
)
await query.edit_message_text(intro, parse_mode="Markdown",
reply_markup=cf_film_keyboard(films, 0))
return
elif query.data == "menu_upload":
await query.edit_message_text(
"πŸŽ™οΈ *Invia un audio* β€” Radio\n\n"
"Manda il file audio (MP3, M4A, OGG o messaggio vocale).\n\n"
"β€’ Durata massima *3 minuti*\n"
"β€’ Tra due audio: *2 minuti di musica*\n"
"β€’ Approvazione admin prima della messa in onda\n"
"β€’ Slot ogni 5 minuti, min 15 min di anticipo\n\n"
"_Invia il file oppure usa /annulla_",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("◀️ Menu principale", callback_data="menu_back")
]])
)
return # rimane in attesa di audio (entry_point del ConversationHandler)
elif query.data == "menu_evento":
await query.edit_message_text(
"πŸ“… *Segnala un evento*\n\n"
"Hai trovato un evento interessante?\n"
"Segnalacelo e, se approvato, apparirΓ  nella sezione eventi del sito!\n\n"
"Per quale cittΓ  vuoi segnalare l'evento?",
parse_mode="Markdown",
reply_markup=_ev_city_keyboard()
)
return EV_CITY
elif query.data == "menu_contact":
ctx.user_data["__flow__"] = "contact"
ctx.user_data["__step__"] = "await_msg"
await query.edit_message_text(
"βœ‰οΈ *Scrivici*\n\n"
"Usa questo canale per:\n"
"β€’ richiedere *accesso o cancellazione dei tuoi dati* (GDPR)\n"
"β€’ segnalare la *rimozione di un evento* o di una fonte\n"
"β€’ inviare una *DMCA notice* o altra segnalazione\n"
"β€’ qualsiasi altra comunicazione alla redazione\n\n"
"Scrivi ora il tuo messaggio in un'unica risposta: lo inoltreremo "
"alla redazione, che ti risponderΓ  qui su Telegram (entro 30 giorni "
"per le richieste sui dati personali).\n\n"
"_Per annullare: /annulla_",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("◀️ Menu principale", callback_data="menu_back")
]])
)
return
elif query.data == "menu_stato":
uid_str = str(uid)
lines = []
# Audio
for s in load_json(PEND_FILE, {}).values():
if str(s.get("uid","")) != uid_str: continue
d = datetime.strptime(s["date"],"%Y-%m-%d").strftime("%d/%m")
lines.append(f"🎡 ⏳ *{s.get('description','Audio')}* β€” {d} {s.get('time','')} _in attesa_")
for q in sorted(load_json(QUEUE_FILE,[]), key=lambda x:(x.get("date",""),x.get("time",""))):
if str(q.get("uid","")) != uid_str or q.get("played"): continue
d = datetime.strptime(q["date"],"%Y-%m-%d").strftime("%d/%m")
lines.append(f"🎡 βœ… *{q.get('description','Audio')}* β€” {d} {q.get('time','')} _approvato_")
if not lines:
await query.edit_message_text("πŸ“‹ *Le tue proposte*\n\nNessun contributo in corso.",
parse_mode="Markdown", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Torna", callback_data="menu_back")]]))
return
await query.edit_message_text("πŸ“‹ *Le tue proposte:*\n\n" + "\n".join(lines[:15]),
parse_mode="Markdown", reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Torna", callback_data="menu_back")]]))
elif query.data == "menu_admin":
if not is_admin(uid):
await query.answer("β›” Non autorizzato.", show_alert=True)
return
pending = load_json(PEND_FILE, {})
queue = [q for q in load_json(QUEUE_FILE,[]) if not q.get("played")]
await query.edit_message_text(
"πŸŽ›οΈ *Pannello Admin β€” Corso Kossuth*\n\n"
f"⏳ Audio in attesa: *{len(pending)}*\n"
f"βœ… Audio approvati in coda: *{len(queue)}*",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("⏳ Proposte audio", callback_data="adm_pending")],
[InlineKeyboardButton("πŸ“‹ Coda programmata", callback_data="adm_coda")],
[InlineKeyboardButton("◀️ Torna al menu", callback_data="menu_back")],
]),
)
elif query.data in ("menu_cf_proponi", "menu_cf_nuovo"):
ctx.user_data.clear() # azzera qualsiasi flusso attivo
ctx.user_data["__flow__"] = "cf_new"
ctx.user_data["__step__"] = "title"
await query.edit_message_text(
"🎬 *Proponi un film*\n\n"
"Scrivi il titolo del film da proporre al Cineforum.",
parse_mode='Markdown',
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("βœ• Annulla", callback_data="menu_back")
]]))
elif query.data == "menu_back":
name = query.from_user.first_name or "Ascoltatore"
await query.edit_message_text(
"🎭 <b>Corso Kossuth</b> β€” Menu\n"
"<i>Gente in mezzo a una strada</i>\n\n"
"🎡 <b>Radio</b> β€” audio in onda\n"
"🎬 <b>Cine</b> β€” cineforum e voti\n"
"πŸ“… <b>Eventi</b> β€” segnala eventi a Torino",
parse_mode="HTML",
reply_markup=main_menu_kb(uid),
)
elif query.data == "adm_pending":
if not is_admin(uid): return
pending = load_json(PEND_FILE, {})
if not pending:
await query.edit_message_text(
"πŸ“­ Nessuna proposta in attesa.",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("◀️ Torna admin", callback_data="menu_admin")
]]),
)
return
await query.edit_message_text(
f"⏳ *{len(pending)} proposta/e in attesa*\n\nUso i messaggi qui sotto:",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("◀️ Torna admin", callback_data="menu_admin")
]]),
)
chat_id = query.message.chat_id
for sub_id, sub in pending.items():
try:
d = datetime.strptime(sub["date"], "%Y-%m-%d").strftime("%d/%m")
name_s = sub.get("contributor_name", "?")
username = sub.get("username", "")
desc = sub.get("description", "")[:200]
slot = f"{d} alle {sub.get('time','?')}"
caption = (
f"πŸŽ™ {name_s}"
+ (f" (@{username})" if username else "")
+ f"\nπŸ“ {desc}"
+ f"\nπŸ• {slot}"
)
kb = InlineKeyboardMarkup([[
InlineKeyboardButton("βœ… Approva", callback_data=f"adm_ok_{sub_id}"),
InlineKeyboardButton("❌ Rifiuta", callback_data=f"adm_no_{sub_id}"),
]])
# Costruisci URL audio: prima locale, poi HF pending
space_host = os.environ.get("SPACE_HOST", "corsokossuth-kossuth.hf.space")
audio_local = sub.get("audio_path", "")
audio_hf = sub.get("pending_hf_path", "")
HF_DS = "corsokossuth/kossuth-music"
if audio_local and Path(audio_local).exists():
fname = Path(audio_local).name
audio_url = f"https://{space_host}/bot_audio/{fname}"
elif audio_hf:
audio_url = f"https://huggingface.co/datasets/{HF_DS}/resolve/main/{audio_hf}"
else:
audio_url = None
if audio_url:
await ctx.bot.send_audio(
chat_id,
audio=audio_url,
caption=caption,
performer=name_s,
title=desc[:64] or "Proposta audio",
reply_markup=kb,
)
else:
await ctx.bot.send_message(
chat_id,
caption + "\n\n⚠️ Audio non disponibile β€” il contributor deve reinviarlo.",
reply_markup=kb,
)
except Exception as e:
log.error(f"adm_pending send error for {sub_id}: {e}")
try:
await ctx.bot.send_message(chat_id, f"⚠️ Errore proposta {sub_id[:12]}: {e}")
except: pass
elif query.data == "adm_coda":
if not is_admin(uid): return
queue = [q for q in load_json(QUEUE_FILE,[]) if not q.get("played")]
if not queue:
await query.edit_message_text(
"πŸ“­ Coda vuota.",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("◀️ Torna admin", callback_data="menu_admin")
]]),
)
return
lines = []
for q in sorted(queue, key=lambda x:(x["date"],x["time"])):
d = datetime.strptime(q["date"],"%Y-%m-%d").strftime("%d/%m")
lines.append(f"β€’ `{d} {q['time']}` *{q['contributor_name']}* `{q['id']}`")
await query.edit_message_text(
"πŸ“‹ *Audio approvati in coda:*\n\n" + "\n".join(lines) +
"\n\n_Usa /rimuovi <id> per eliminare un elemento._",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("◀️ Torna admin", callback_data="menu_admin")
]]),
)
# ══════════════════════════════════════════════════════════════════
# FLUSSO UPLOAD (ConversationHandler)
# ══════════════════════════════════════════════════════════════════
async def receive_audio(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
msg = update.message
audio_obj = (
msg.audio or msg.voice or
(msg.document if msg.document and "audio" in (msg.document.mime_type or "") else None)
)
if not audio_obj:
await msg.reply_text("❌ Non riconosco questo file. Invia un MP3, M4A, OGG o messaggio vocale.")
return ConversationHandler.END
wait = await msg.reply_text("⏳ File ricevuto, sto elaborando l'audio…")
tg_file = await ctx.bot.get_file(audio_obj.file_id)
suffix = ".ogg" if msg.voice else ".mp3"
uploads_dir = DATA_DIR / "uploads"
uploads_dir.mkdir(parents=True, exist_ok=True)
ts_now = int(time.time())
src = uploads_dir / f"audio_{update.effective_user.id}_{ts_now}{suffix}"
await tg_file.download_to_drive(src)
dst = src.with_suffix(".out.wav")
ok, orig_dur = convert_audio(src, dst)
src.unlink(missing_ok=True)
if not ok or not dst.exists():
await wait.edit_text("❌ Errore nella conversione. Prova un altro formato (MP3, M4A, OGG).")
dst.unlink(missing_ok=True)
return ConversationHandler.END
ctx.user_data.clear()
ctx.user_data["audio_path"] = str(dst)
ctx.user_data["was_cut"] = orig_dur > MAX_DURATION + 2
# Pre-upload su HF in background (lo rende disponibile anche dopo restart)
uid_now = update.effective_user.id
ts_now = int(time.time())
pending_hf_path = f"pending/audio_{uid_now}_{ts_now}.mp3"
ctx.user_data["pending_hf_path"] = pending_hf_path
import asyncio as _aio
async def _pre_upload():
try:
await upload_hf(dst, pending_hf_path)
log.info(f"Pre-upload HF OK: {pending_hf_path}")
except Exception as e:
log.warning(f"Pre-upload HF fallito: {e}")
_aio.create_task(_pre_upload())
note = f"\nβœ‚οΈ _{int(orig_dur)}s originali β†’ tagliato a 3 minuti._" if ctx.user_data["was_cut"] else ""
await wait.edit_text(
f"βœ… Audio pronto!{note}\n\n"
f"{progress(1)}\n\n"
"πŸ“ *Come ti chiami?*\n"
"Scrivi il tuo nome o quello del tuo podcast / programma.\n\n"
"_Usa /annulla per interrompere in qualsiasi momento._",
parse_mode="Markdown",
)
return ASK_NAME
async def got_name(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
name = update.message.text.strip()
if not name or len(name) > 80:
await update.message.reply_text(
"❌ Nome non valido (max 80 caratteri). Riprova:"
)
return ASK_NAME
ctx.user_data["contributor_name"] = name
await update.message.reply_text(
f"{progress(2)}\n\n"
"πŸ“ *Descrivi brevemente il contenuto del tuo audio.*\n\n"
"Esempi:\n"
"β€’ _\"Lettura di un racconto breve\"_\n"
"β€’ _\"Rubrica cinema β€” il film della settimana\"_\n"
"β€’ _\"Intervista a un artista locale\"_",
parse_mode="Markdown",
)
return ASK_DESC
async def got_desc(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
desc = update.message.text.strip()
if not desc:
await update.message.reply_text("❌ La descrizione non può essere vuota. Riprova:")
return ASK_DESC
if len(desc) > 300:
await update.message.reply_text(
f"❌ Troppo lunga ({len(desc)} caratteri, max 300). Riprova:"
)
return ASK_DESC
ctx.user_data["description"] = desc
ctx.user_data["image_path"] = None # nessuna immagine
# Chiedi se vuole salvare nella sezione podcast
await update.message.reply_text(
"\U0001f3a7 <b>Vuoi che questo audio resti nella sezione Podcast del sito?</b>\n\n"
"\u2705 <b>S\u00ec</b> β€” il tuo audio sar\u00e0 ascoltabile sul sito dopo la messa in onda\n"
"\u274c <b>No</b> β€” va in onda e poi viene rimosso",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("\u2705 S\u00ec, salva nel podcast", callback_data="podcast_yes"),
InlineKeyboardButton("\u274c No, rimuovi dopo", callback_data="podcast_no"),
]])
)
return ASK_PODCAST
async def _send_hour_kb(chat_id: int, ctx: ContextTypes.DEFAULT_TYPE):
"""Mostra le ore disponibili per oggi e domani (5-min slots)."""
today = date.today()
tomorrow = today + timedelta(days=1)
h_today = available_hours(today)
h_tom = available_hours(tomorrow)
if not h_today and not h_tom:
await ctx.bot.send_message(
chat_id,
"πŸ˜” Nessuno slot libero nelle prossime 48 ore. Riprova piΓΉ tardi.",
)
return
kb_rows = []
for label, target_date, hours in [
(f"πŸ“… Oggi β€” {today.strftime('%d/%m')}", today, h_today),
(f"πŸ“… Domani β€” {tomorrow.strftime('%d/%m')}", tomorrow, h_tom),
]:
if not hours:
continue
kb_rows.append([InlineKeyboardButton(label, callback_data="noop_h")])
for chunk in [hours[i:i+4] for i in range(0, len(hours), 4)]:
kb_rows.append([
InlineKeyboardButton(f"{h:02d}:xx", callback_data=f"hour_{target_date}_{h:02d}")
for h in chunk
])
await ctx.bot.send_message(
chat_id,
f"{progress(3)}\n\n"
"πŸ• *Scegli l'ora di messa in onda:*",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(kb_rows),
)
async def got_hour(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
await query.answer()
if "noop" in query.data:
return ASK_HOUR
_, date_str, hour_str = query.data.split("_", 2)
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
hour = int(hour_str)
slots = available_minutes(target_date, hour)
if not slots:
await query.answer("❌ Ora esaurita β€” scegline un'altra.", show_alert=True)
return ASK_HOUR
ctx.user_data.update(slot_date=date_str, slot_hour=hour)
kb_rows = [
[InlineKeyboardButton(f":{s[-2:]}", callback_data=f"slot_{date_str}_{s}") for s in slots[i:i+4]]
for i in range(0, len(slots), 4)
]
kb_rows.append([InlineKeyboardButton("◀️ Cambia ora", callback_data="back_hour")])
date_fmt = target_date.strftime("%d/%m")
await query.edit_message_text(
f"{progress(4)}\n\n"
f"🎯 *Scegli il minuto β€” {date_fmt} ore {hour:02d}:*\n"
"_Ogni slot: 3 min tuo audio + 2 min musica automatica._",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup(kb_rows),
)
return ASK_TIME
async def got_podcast_choice(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Gestisce la scelta di salvataggio podcast."""
query = update.callback_query
await query.answer()
ctx.user_data["save_podcast"] = (query.data == "podcast_yes")
label = "\u2705 Salvato nel podcast" if ctx.user_data["save_podcast"] else "\u274c Rimosso dopo la messa in onda"
await query.edit_message_text(f"{label} β€” ora scegli lo slot:")
# Mostra keyboard ore
chat_id = query.message.chat_id
await _send_hour_kb(chat_id, ctx)
return ASK_HOUR
async def got_time(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
await query.answer()
if "noop" in query.data:
return ASK_TIME
if query.data == "back_hour":
# Torna alla selezione dell'ora
chat_id = query.message.chat_id
await query.delete_message()
await _send_hour_kb(chat_id, ctx)
return ASK_HOUR
# data = "slot_YYYY-MM-DD_HH:MM"
_, date_str, time_str = query.data.split("_", 2)
target_date = datetime.strptime(date_str, "%Y-%m-%d").date()
hour = ctx.user_data.get("slot_hour", int(time_str[:2]))
if time_str not in available_minutes(target_date, hour):
await query.answer("❌ Slot appena occupato β€” scegli un altro orario.", show_alert=True)
return ASK_TIME
ctx.user_data.update(slot_date=date_str, slot_time=time_str)
name = ctx.user_data["contributor_name"]
desc = ctx.user_data["description"]
has_img = bool(ctx.user_data.get("image_path"))
slot_dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M")
before = (slot_dt - timedelta(minutes=GAP_MINUTES)).strftime("%H:%M")
after = (slot_dt + timedelta(minutes=MAX_DURATION//60 + GAP_MINUTES)).strftime("%H:%M")
date_fmt = target_date.strftime("%d/%m/%Y")
await query.edit_message_text(
"πŸ“‹ *Riepilogo della tua proposta:*\n\n"
f"πŸ‘€ Nome: *{name}*\n"
f"πŸ“ Contenuto: _{desc}_\n"
f"πŸ–ΌοΈ Immagine: {'βœ… sΓ¬' if has_img else 'β€”'}\n"
f"πŸ• Data/ora: *{date_fmt} alle {time_str}*\n\n"
f"🎡 *Programma:*\n"
f" `{before}` β†’ musica automatica\n"
f" `{time_str}` β†’ *{name}* _(max 3 min)_\n"
f" `{after}` β†’ musica automatica\n\n"
"⏳ _Proposta inviata agli admin. Ti avviso non appena sarà approvata!_",
parse_mode="Markdown",
)
uid = str(query.from_user.id)
ts = int(time.time())
sub_id = f"{uid}_{ts}"
pending = load_json(PEND_FILE, {})
pending[sub_id] = {
"id": sub_id, "uid": uid,
"username": query.from_user.username or "",
"contributor_name": name,
"description": desc,
"audio_path": ctx.user_data.get("audio_path", ""),
"image_path": ctx.user_data.get("image_path"),
"pending_hf_path": ctx.user_data.get("pending_hf_path", ""),
"fascia": "",
"date": date_str,
"time": time_str,
"submitted": datetime.now().isoformat(),
"save_podcast": ctx.user_data.get("save_podcast", True),
}
save_json(PEND_FILE, pending)
ctx.user_data.clear()
short_desc = desc[:120] + ("…" if len(desc) > 120 else "")
who = f"@{query.from_user.username}" if query.from_user.username else f"id:{uid}"
audio_path = pending[sub_id].get("audio_path", "")
pending_hf_path = pending[sub_id].get("pending_hf_path", "")
kb = InlineKeyboardMarkup([[
InlineKeyboardButton("βœ… Approva", callback_data=f"adm_ok_{sub_id}"),
InlineKeyboardButton("❌ Rifiuta", callback_data=f"adm_no_{sub_id}"),
]])
for admin_id in ADMIN_IDS:
try:
caption = (
f"πŸ”” Nuova proposta da approvare\n\n"
f"πŸ‘€ {name} ({who})\n"
f"πŸ“ {short_desc}\n"
f"πŸ• {date_fmt} alle {time_str}"
)
# Tenta con audio (URL locale β†’ URL HF pending β†’ solo testo)
sent = False
# 1. File locale disponibile
if audio_path and Path(audio_path).exists():
space = os.environ.get("SPACE_HOST", "corsokossuth-kossuth.hf.space")
fname = Path(audio_path).name
audio_url = f"https://{space}/bot_audio/{fname}"
try:
await ctx.bot.send_audio(
admin_id, audio=audio_url, caption=caption,
performer=name, title=short_desc[:64] or "Proposta audio",
reply_markup=kb,
)
sent = True
except Exception as ea:
log.warning(f"Admin {admin_id} audio locale fallito: {ea}")
# 2. Audio giΓ  su HF (pending)
if not sent and pending_hf_path:
hf_audio_url = f"https://huggingface.co/datasets/{HF_DATASET}/resolve/main/{pending_hf_path}"
try:
await ctx.bot.send_audio(
admin_id, audio=hf_audio_url, caption=caption,
performer=name, title=short_desc[:64] or "Proposta audio",
reply_markup=kb,
)
sent = True
except Exception as eh:
log.warning(f"Admin {admin_id} audio HF fallito: {eh}")
# 3. Fallback: solo testo con pulsanti
if not sent:
await ctx.bot.send_message(admin_id, caption, reply_markup=kb)
sent = True
if sent:
log.info(f"Admin {admin_id} notificato per proposta {sub_id}")
except Exception as e:
log.error(f"Admin notify {admin_id} ERRORE: {e}")
return ConversationHandler.END
async def cancel(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
for k in ("audio_path","image_path"):
p = ctx.user_data.pop(k, None)
if p: Path(p).unlink(missing_ok=True)
ctx.user_data.clear()
name = update.effective_user.first_name or "Ascoltatore"
await update.message.reply_text(
"❌ Operazione annullata.",
reply_markup=ReplyKeyboardRemove(),
)
await update.message.reply_text(
f"πŸ‘‹ Ciao *{name}*, cosa vuoi fare?",
parse_mode="Markdown",
reply_markup=main_menu_kb(update.effective_user.id),
)
return ConversationHandler.END
# ══════════════════════════════════════════════════════════════════
# APPROVAZIONE ADMIN
# ══════════════════════════════════════════════════════════════════
async def _reply_on_message(query, text):
"""Modifica caption (messaggi audio/foto) o testo, con fallback a nuovo messaggio."""
try:
await query.edit_message_caption(caption=text)
except Exception:
try:
await query.edit_message_text(text)
except Exception:
try:
await query.message.reply_text(text)
except Exception:
pass
async def approval_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
await query.answer()
data = query.data
if not (data.startswith("adm_ok_") or data.startswith("adm_no_")):
return
if not is_admin(query.from_user.id):
await query.answer("β›” Solo gli admin possono approvare.", show_alert=True)
return
action = "ok" if data.startswith("adm_ok_") else "no"
sub_id = data[7:]
pending = load_json(PEND_FILE, {})
sub = pending.get(sub_id)
if not sub:
await _reply_on_message(query, "⚠️ Proposta non trovata (già processata).")
return
name = sub["contributor_name"]
uid = int(sub["uid"])
if action == "no":
del pending[sub_id]
save_json(PEND_FILE, pending)
for k in ("audio_path","image_path"):
if sub.get(k): Path(sub[k]).unlink(missing_ok=True)
await _reply_on_message(query, f"❌ Proposta di {name} rifiutata.")
try:
await ctx.bot.send_message(
uid,
"πŸ˜” La tua proposta audio Γ¨ stata *rifiutata* dagli admin.\n\n"
"Puoi inviarne una nuova quando vuoi β€” usa /start.",
parse_mode="Markdown",
)
except: pass
return
ts = int(time.time())
audio_file = Path(sub.get("audio_path", ""))
author_folder = sanitize(name)
desc_name = sanitize(sub.get("description", "") or name)
audio_repo_path = f"contributions/{author_folder}/{desc_name}.mp3"
pending_hf_path = sub.get("pending_hf_path", "")
# ── Copia locale se disponibile ────────────────────────────────
import shutil, asyncio as _asyncio
author_folder = sanitize(name)
desc_name = sanitize(sub.get("description", "") or name)
local_dest = Path(f"/app/contrib_audio/{author_folder}/{desc_name}.mp3")
local_dest.parent.mkdir(parents=True, exist_ok=True)
if audio_file.exists():
try:
shutil.copy2(str(audio_file), str(local_dest))
except Exception as e:
log.error(f"Copia locale: {e}")
final_hf_path = audio_repo_path
elif pending_hf_path:
# GiΓ  su HF nella cartella pending β€” lo usiamo direttamente
final_hf_path = pending_hf_path
log.info(f"Approvazione: uso HF pending path {pending_hf_path}")
else:
await _reply_on_message(query, f"❌ File audio non disponibile per {name}. Il contributor deve reinviare l'audio.")
return
# ── Salva subito nella coda ────────────────────────────────────
date_fmt = datetime.strptime(sub["date"], "%Y-%m-%d").strftime("%d/%m/%Y")
queue = load_json(QUEUE_FILE, [])
queue.append({
"id": sub_id, "uid": sub["uid"], "username": sub.get("username", ""),
"contributor_name": name, "description": sub["description"],
"hf_audio": final_hf_path, "hf_image": None,
"local_audio": str(local_dest) if local_dest.exists() else "",
"fascia": sub.get("fascia", ""), "date": sub["date"], "time": sub["time"],
"save_podcast": sub.get("save_podcast", True),
"approved_at": datetime.now().isoformat(), "played": False,
})
save_json(QUEUE_FILE, queue)
del pending[sub_id]
save_json(PEND_FILE, pending)
# ── Risposta IMMEDIATA all'admin ────────────────────────────────
await _reply_on_message(query, f"βœ… {name} approvato! AndrΓ  in onda il {date_fmt} alle {sub['time']}.")
# Backup HF in background (non blocca l'admin)
import asyncio as _aio2
async def _bg_hf_q():
try:
hf_upload(QUEUE_FILE.read_bytes(), "bot_data/queue.json")
except Exception as _e2:
log.warning(f"Backup queue HF: {_e2}")
_aio2.create_task(_bg_hf_q())
# ── Notifica il contributor subito ────────────────────────────
try:
await ctx.bot.send_message(
uid,
f"πŸŽ‰ Ottima notizia, {name}!\n\n"
f"Il tuo audio Γ¨ stato approvato e andrΓ  in onda:\n"
f"πŸ“… {date_fmt} alle {sub['time']}\n\n"
"Grazie per contribuire a Corso Kossuth! πŸŽ™\n"
"Usa /start per inviare un altro audio."
)
except: pass
# ── Upload HF in background se file locale disponibile ───────────
async def _bg_upload():
try:
# Usa local_dest (giΓ  copiato) invece di audio_file (potrebbe essere stato rimosso)
upload_src = local_dest if local_dest.exists() else (audio_file if audio_file.exists() else None)
if upload_src:
await upload_hf(upload_src, final_hf_path)
log.info(f" Audio caricato su HF: {final_hf_path}")
else:
log.warning(f" Nessun file audio disponibile per {name} β€” HF skip")
# Salva metadati JSON accanto all'audio (per ricostruire queue dopo restart)
import json as _json
meta = {
"id": sub_id, "uid": sub["uid"], "username": sub.get("username",""),
"contributor_name": name, "description": sub.get("description",""),
"hf_audio": final_hf_path, "date": sub["date"], "time": sub["time"],
"approved_at": datetime.now().isoformat(), "played": True,
}
meta_path = final_hf_path.rsplit(".", 1)[0] + ".json"
import tempfile, os as _os
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as tf:
_json.dump(meta, tf, ensure_ascii=False)
tf_path = tf.name
await upload_hf(Path(tf_path), meta_path)
_os.unlink(tf_path)
if sub.get("image_path"):
img = Path(sub["image_path"])
if img.exists():
ext = img.suffix or ".jpg"
await upload_hf(img, f"contributions/artwork_{sub['uid']}_{ts}{ext}")
img.unlink(missing_ok=True)
audio_file.unlink(missing_ok=True)
log.info(f"Upload HF completato per {name}")
except Exception as e:
log.error(f"Upload HF background error: {e}")
_asyncio.create_task(_bg_upload())
# ══════════════════════════════════════════════════════════════════
# COMANDI ADMIN TESTUALI
# ══════════════════════════════════════════════════════════════════
async def cmd_admin(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Comando /admin β€” accesso diretto al pannello."""
uid = update.effective_user.id
log.info(f"Utente {uid} ha usato /admin (is_admin={is_admin(uid)})")
if not is_admin(uid):
await update.message.reply_text(
f"β›” Non sei un admin.\n\n"
f"_Il tuo ID Telegram Γ¨ `{uid}` β€” comunicalo al gestore per essere aggiunto._",
parse_mode="Markdown",
)
return
pending = load_json(PEND_FILE, {})
queue = [q for q in load_json(QUEUE_FILE, []) if not q.get("played")]
await update.message.reply_text(
"πŸŽ›οΈ *Pannello Admin β€” Corso Kossuth*\n\n"
f"⏳ Proposte in attesa: *{len(pending)}*\n"
f"βœ… Audio in coda: *{len(queue)}*",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([
[InlineKeyboardButton("⏳ Rivedi proposte", callback_data="adm_pending")],
[InlineKeyboardButton("πŸ“‹ Coda programmata", callback_data="adm_coda")],
]),
)
# ══════════════════════════════════════════════════════════════════
# MAIN
# ══════════════════════════════════════════════════════════════════
async def manual_flow_handler(update, ctx):
"""Flussi manuali β€” bypassa ConversationHandler con ApplicationHandlerStop."""
msg = update.message
if not msg or not msg.text: return
text = msg.text.strip()
flow = ctx.user_data.get("__flow__","")
step = ctx.user_data.get("__step__","")
if not flow: return # nessun flusso attivo β€” lascia passare agli altri handler
if flow == "contact":
# Canale contatti / diritti GDPR / rimozione / DMCA promesso dalla privacy policy.
ctx.user_data.pop("__flow__", None)
ctx.user_data.pop("__step__", None)
u = msg.from_user
uname = ("@" + u.username) if getattr(u, "username", None) else "(nessun username)"
header = (
"βœ‰οΈ <b>Nuovo messaggio dai contatti</b>\n"
f"Da: {_html.escape(u.first_name or 'Utente')} {uname}\n"
f"ID Telegram: <code>{u.id}</code>\n"
f"Data: {datetime.now().strftime('%d/%m/%Y %H:%M')}\n"
"─────────────\n"
)
body = _html.escape(text[:3500])
sent = 0
for admin_id in ADMIN_IDS:
try:
await ctx.bot.send_message(admin_id, header + body, parse_mode="HTML")
sent += 1
except Exception as _ce:
log.warning(f"Inoltro contatto ad admin {admin_id} fallito: {_ce}")
if sent:
await msg.reply_text(
"βœ… Messaggio inviato alla redazione. Ti risponderemo qui su Telegram.\n"
"Per le richieste sui dati personali la risposta arriva entro 30 giorni.\n\n"
"Torna al menu con /start."
)
else:
await msg.reply_text(
"⚠️ Non è stato possibile inoltrare il messaggio in questo momento. "
"Riprova piΓΉ tardi con /contatti."
)
raise ApplicationHandlerStop
if flow == "cf_new":
if step == "title":
ctx.user_data["cf_new_title"] = text
ctx.user_data["__step__"] = "director"
await msg.reply_text("\U0001f3ac <b>Regista:</b>", parse_mode="HTML")
elif step == "director":
ctx.user_data["cf_new_director"] = text
ctx.user_data["__step__"] = "year"
await msg.reply_text("\U0001f4c5 <b>Anno:</b>", parse_mode="HTML")
elif step == "year":
ctx.user_data["cf_new_year"] = text
ctx.user_data["__step__"] = "score"
await msg.reply_text("\u2b50 <b>Il tuo voto (1–10):</b>", parse_mode="HTML")
elif step == "score":
try:
score = round(float(text.replace(",",".")), 1)
if not (1.0 <= score <= 10.0): raise ValueError
except ValueError:
await msg.reply_text("Inserisci un numero tra 1 e 10 (es. 7.5):"); return
uid = msg.from_user.id
name = msg.from_user.first_name or "Utente"
title = ctx.user_data.get("cf_new_title","")
import re as _re
film_id = _re.sub(r"[^a-z0-9_]","_", title.lower())[:30]
try:
_cur_season = max((f.get("season",1) for f in load_cineforum()), default=1)
except Exception:
_cur_season = 1
new_film = {"id":film_id,"title":title,"original_title":title,
"year":ctx.user_data.get("cf_new_year",""),
"director":ctx.user_data.get("cf_new_director",""),
"genre":"","country":"","duration":"","language":"",
"synopsis":"","theme":"",
"season":_cur_season,
"date":"","status":"pending",
"votes":[{"name":name,"tg_id":str(uid),"score":score}],
"voto_finale":score}
# Salva direttamente in cineforum.json (status="pending")
# così cf_admin_callback può trovarlo e approvarlo
new_film["submitted_uid"] = str(uid)
films_all = load_cineforum()
films_all.append(new_film)
save_cineforum(films_all) # salva localmente + HF in background
ctx.user_data.clear()
_back_kb = InlineKeyboardMarkup([[InlineKeyboardButton("\U0001f3e0 Menu", callback_data="menu_back")]])
# Risposta IMMEDIATA all'utente β€” poi notifica admin in background
await msg.reply_text(
f"\u2705 <b>{_html.escape(title)}</b> proposto!\n"
"Gli admin riceveranno la notifica.", parse_mode="HTML",
reply_markup=_back_kb)
import asyncio as _aio3
async def _notify_admins_film():
for adm in ADMIN_IDS:
try:
await ctx.bot.send_message(adm,
f"\U0001f3ac <b>Nuova proposta film</b>\n\n"
f"<b>{_html.escape(title)}</b>\n"
f"Regia: {_html.escape(new_film['director'])}\n"
f"Anno: {new_film['year']}\n"
f"Da: {_html.escape(name)} Β· Voto: {score}/10",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("\u2705 Approva",
callback_data=f"cf_adm_ok:{film_id}"),
InlineKeyboardButton("\u274c Rifiuta",
callback_data=f"cf_adm_no:{film_id}"),
]]))
except Exception: pass
_aio3.create_task(_notify_admins_film())
# Ferma propagazione ad altri gruppi (evita doppia gestione)
if flow:
raise ApplicationHandlerStop
async def cmd_elimina_pending(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Admin: elimina un item pending per ID. Uso: /elimina_pending <item_id>"""
if not is_admin(update.effective_user.id):
return
args = ctx.args or []
if not args:
await update.message.reply_text("Uso: /elimina_pending <item_id>"); return
item_id = args[0]
# Ora esistono solo proposte audio: si elimina da pending.json.
pending = load_json(PEND_FILE, {})
if item_id in pending:
del pending[item_id]
save_json(PEND_FILE, pending)
await update.message.reply_text(f"βœ… Eliminata proposta audio {item_id}.")
else:
await update.message.reply_text(f"❌ ID {item_id} non trovato tra le proposte audio.")
async def cmd_pulisci_coda(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Admin: rimuove dalla coda audio con file non disponibili o slot giΓ  passati."""
if not is_admin(update.effective_user.id):
return
import urllib.request as _ur
queue = load_json(QUEUE_FILE, [])
now = datetime.now()
kept, removed = [], []
for ep in queue:
if ep.get("played"):
removed.append(f"βœ… (trasmesso) {ep.get('contributor_name','?')} {ep.get('date','')} {ep.get('time','')}")
continue
# Controlla se lo slot Γ¨ giΓ  passato
try:
slot_dt = datetime.strptime(f"{ep['date']} {ep['time']}", "%Y-%m-%d %H:%M")
if slot_dt < now - timedelta(minutes=10):
removed.append(f"⏰ (scaduto) {ep.get('contributor_name','?')} {ep['date']} {ep['time']}")
continue
except Exception:
pass
# Controlla se il file HF esiste
hf_path = ep.get("hf_audio","")
local = ep.get("local_audio","")
from pathlib import Path as _P
if local and _P(local).exists():
kept.append(ep); continue
if hf_path:
url = f"https://huggingface.co/datasets/corsokossuth/kossuth-music/resolve/main/{hf_path}"
try:
req = _ur.Request(url, headers={"Authorization":f"Bearer {HF_TOKEN}"} if HF_TOKEN else {})
_ur.urlopen(req, timeout=8).close()
kept.append(ep); continue
except Exception:
pass
removed.append(f"❌ (audio mancante) {ep.get('contributor_name','?')} {ep.get('date','')} {ep.get('time','')}")
save_json(QUEUE_FILE, kept)
try:
hf_upload(QUEUE_FILE.read_bytes(), "bot_data/queue.json")
except Exception: pass
lines = [f"Coda pulita: {len(kept)} rimasti, {len(removed)} rimossi"]
for r in removed: lines.append(f" {r}")
await update.message.reply_text("\n".join(lines))
async def cmd_rimuovi(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Admin: rimuove dalla coda un episodio audio per ID. Uso: /rimuovi <id>
Il pannello Β«Coda programmataΒ» annunciava questo comando ma non era mai
stato registrato: chi lo usava non riceveva risposta."""
if not is_admin(update.effective_user.id):
return
args = ctx.args or []
if not args:
await update.message.reply_text("Uso: /rimuovi <id> (l'id Γ¨ mostrato nella coda programmata)")
return
item_id = args[0]
queue = load_json(QUEUE_FILE, [])
kept = [q for q in queue if str(q.get("id","")) != item_id]
if len(kept) == len(queue):
await update.message.reply_text(f"❌ ID {item_id} non trovato in coda.")
return
save_json(QUEUE_FILE, kept)
try:
hf_upload(QUEUE_FILE.read_bytes(), "bot_data/queue.json")
except Exception as e:
log.warning(f"/rimuovi: sync HF non riuscito (coda locale aggiornata): {e}")
await update.message.reply_text(f"βœ… Rimosso dalla coda: {item_id}\n{len(kept)} episodi restanti.")
def _build_app():
"""Costruisce l'Application con timeout generosi e proxy opzionale."""
from telegram.request import HTTPXRequest
class _RetryingHTTPXRequest(HTTPXRequest):
# httpx, di default, NON riprova quando la CONNESSIONE fallisce
# (ConnectError/ConnectTimeout). Su HF l'uscita verso il proxy/Telegram ha
# cali momentanei (TLS che cade) che facevano perdere la risposta e lasciavano
# /start "muto". Qui usiamo un transport httpx con retry sulla sola connessione:
# un blip di rete viene riprovato in automatico invece di far fallire l'invio.
def _build_client(self):
http1 = self._http_version == "1.1"
transport = httpx.AsyncHTTPTransport(
retries=5,
http1=http1, http2=not http1,
limits=self._client_kwargs["limits"],
)
return httpx.AsyncClient(
timeout=self._client_kwargs["timeout"],
transport=transport,
)
req = _RetryingHTTPXRequest(
connection_pool_size=8,
connect_timeout=30.0,
read_timeout=30.0,
write_timeout=30.0,
pool_timeout=30.0,
)
builder = Application.builder().token(BOT_TOKEN).request(req)
# Proxy per le risposte in uscita verso Telegram (sendMessage ecc.)
_proxy_url = os.environ.get("TG_PROXY_URL","").strip().rstrip("/")
if _proxy_url:
builder = (builder
.base_url(f"{_proxy_url}/bot")
.base_file_url(f"{_proxy_url}/file/bot"))
log.info(f"Proxy uscita: {_proxy_url}")
app = builder.build()
audio_filter = (
filters.AUDIO | filters.VOICE |
filters.Document.MimeType("audio/mpeg") |
filters.Document.MimeType("audio/ogg") |
filters.Document.MimeType("audio/mp4") |
filters.Document.MimeType("audio/x-m4a")|
filters.Document.MimeType("audio/wav")
)
conv = ConversationHandler(
entry_points=[MessageHandler(audio_filter, receive_audio)],
states={
ASK_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, got_name)],
ASK_DESC: [MessageHandler(filters.TEXT & ~filters.COMMAND, got_desc)],
ASK_HOUR: [CallbackQueryHandler(got_hour, pattern="^(hour_|noop_h|back_hour)")],
ASK_PODCAST: [CallbackQueryHandler(got_podcast_choice, pattern="^podcast_")],
ASK_TIME: [CallbackQueryHandler(got_time, pattern="^(slot_|noop|back_hour)")],
},
fallbacks=[CommandHandler("annulla", cancel), CommandHandler("start", cmd_start)],
allow_reentry=True,
)
app.add_handler(CommandHandler("pulisci_coda", cmd_pulisci_coda))
app.add_handler(CommandHandler("rimuovi", cmd_rimuovi))
app.add_handler(CommandHandler("elimina_pending", cmd_elimina_pending))
app.add_handler(CommandHandler("start", cmd_start))
app.add_handler(CommandHandler("help", cmd_help))
app.add_handler(CommandHandler("contatti", cmd_contatti))
app.add_handler(CommandHandler("mie", _cmd_mie_proposte))
app.add_handler(CommandHandler("admin", cmd_admin))
app.add_handler(CommandHandler("annulla", cancel))
app.add_handler(CommandHandler("cineforum", cmd_cineforum))
app.add_handler(CommandHandler("invia_evento", cmd_invia_evento))
# ── Cineforum ConversationHandlers β€” registrati PRIMA degli handler
# standalone cf_page/cf_film per evitare che testi in stato CV_NAME
# vengano catturati da handler generici.
cf_vote_conv = ConversationHandler(
entry_points=[CallbackQueryHandler(cf_vote_callback, pattern="^cf_vote:")],
states={
CV_NAME: [MessageHandler(filters.TEXT & ~filters.COMMAND, cf_got_voter_name)],
CV_SCORE: [CallbackQueryHandler(cf_got_score, pattern="^cf_score:")],
},
fallbacks=[CommandHandler("annulla", cancel), CommandHandler("start", cmd_start)],
allow_reentry=True,
per_chat=True,
per_user=True,
per_message=False,
)
app.add_handler(cf_vote_conv)
# Entry_point "menu_cf_proponi" corrisponde al callback_data del bottone
# "Proponi un film" nella keyboard (cf_film_keyboard). Allineato al Bug1.
# cf_newfilm_callback e cf_confirm_callback sono gestiti qui β€” NON registrare
# anche come standalone, altrimenti scattano prima del ConversationHandler.
cf_newfilm_conv = ConversationHandler(
entry_points=[CallbackQueryHandler(cf_newfilm_callback, pattern="^menu_cf_proponi$")],
states={
CF_TITLE: [MessageHandler(filters.TEXT & ~filters.COMMAND, cf_got_title)],
CF_CONFIRM: [CallbackQueryHandler(cf_confirm_callback, pattern="^cf_confirm:")],
},
fallbacks=[CommandHandler("annulla", cancel), CommandHandler("start", cmd_start)],
allow_reentry=True,
per_chat=True,
per_user=True,
per_message=False,
)
app.add_handler(cf_newfilm_conv)
app.add_handler(CallbackQueryHandler(approval_callback, pattern="^adm_(ok|no)_"))
app.add_handler(CallbackQueryHandler(menu_callback, pattern="^(menu_|adm_pending|adm_coda)"))
app.add_handler(CallbackQueryHandler(ev_admin_callback, pattern="^evadm_(ok|no):"))
# Cineforum callbacks standalone (pagina, scheda film, approvazione admin)
app.add_handler(CallbackQueryHandler(cf_page_callback, pattern="^cf_page:"))
app.add_handler(CallbackQueryHandler(cf_film_callback, pattern="^cf_film:"))
app.add_handler(CallbackQueryHandler(cf_admin_callback, pattern="^cf_adm_(ok|no):"))
app.add_handler(conv)
# Conversation handler per segnalazione eventi
ev_conv = ConversationHandler(
entry_points=[
CommandHandler("invia_evento", cmd_invia_evento),
CallbackQueryHandler(ev_got_city, pattern="^evcity:"),
],
states={
EV_CITY: [CallbackQueryHandler(ev_got_city, pattern="^evcity:")],
EV_CITYNAME:[MessageHandler(filters.TEXT & ~filters.COMMAND, ev_got_cityname)],
EV_TITLE: [MessageHandler(filters.TEXT & ~filters.COMMAND, ev_got_title)],
EV_DATE: [
CallbackQueryHandler(ev_got_cat, pattern="^evcat:"),
MessageHandler(filters.TEXT & ~filters.COMMAND, ev_got_date),
],
EV_TIME: [
CallbackQueryHandler(ev_got_time_cb, pattern="^evtime:"),
MessageHandler(filters.TEXT & ~filters.COMMAND, ev_got_time),
],
EV_VENUE: [MessageHandler(filters.TEXT & ~filters.COMMAND, ev_got_venue)],
EV_PRICE: [CallbackQueryHandler(ev_got_price, pattern="^evprice:")],
EV_CONFIRM: [
MessageHandler(filters.TEXT & ~filters.COMMAND, ev_got_url),
CallbackQueryHandler(ev_confirm, pattern="^evconf:"),
],
},
fallbacks=[CommandHandler("annulla", cancel), CommandHandler("start", cmd_start)],
allow_reentry=True,
)
app.add_handler(ev_conv)
# ── Handler globale errori β€” logga invece di crashare ─────────
async def _error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
log.error(f"Eccezione non gestita: {context.error}", exc_info=context.error)
if isinstance(update, Update) and update.effective_message:
try:
await update.effective_message.reply_text(
"⚠️ Si è verificato un errore interno. Riprova tra qualche secondo."
)
except Exception:
pass
app.add_error_handler(_error_handler)
# Handler flussi manuali β€” prioritΓ  assoluta su tutto (group -1)
app.add_handler(
MessageHandler(filters.TEXT & ~filters.COMMAND, manual_flow_handler),
group=-1
)
return app
# ═══════════════════════════════════════════════════════════════════════════════
# INVIO EVENTI DAGLI UTENTI
# ═══════════════════════════════════════════════════════════════════════════════
_MESI_IT = {
"gennaio": 1, "febbraio": 2, "marzo": 3, "aprile": 4, "maggio": 5, "giugno": 6,
"luglio": 7, "agosto": 8, "settembre": 9, "ottobre": 10, "novembre": 11, "dicembre": 12,
"gen": 1, "feb": 2, "mar": 3, "apr": 4, "mag": 5, "giu": 6,
"lug": 7, "ago": 8, "set": 9, "sett": 9, "ott": 10, "nov": 11, "dic": 12,
}
def _parse_italian_date(txt: str) -> str:
"""'12 giugno 2026' / '12 giugno' β†’ 'YYYY-MM-DD' (anno corrente se omesso). '' se non riconosciuta."""
m = re.search(r'\b(\d{1,2})\s+([a-zàèéìòù]+)\.?(?:\s+(\d{4}))?', txt.lower())
if not m or m.group(2) not in _MESI_IT:
return ""
day = int(m.group(1)); mon = _MESI_IT[m.group(2)]
year = int(m.group(3)) if m.group(3) else date.today().year
try:
d = date(year, mon, day)
# senza anno e giΓ  passata β†’ assume l'anno prossimo
if not m.group(3) and d < date.today():
d = date(year + 1, mon, day)
return d.isoformat()
except ValueError:
return ""
def load_user_events() -> list:
return load_json(EVENTS_FILE, [])
def save_user_events(data: list):
save_json(EVENTS_FILE, data)
if HF_TOKEN:
hf_upload(EVENTS_FILE.read_bytes(), "bot_data/events_user.json")
def _ev_city_keyboard():
rows = []
row = []
for cid, clabel in _EV_CITIES.items():
row.append(InlineKeyboardButton(clabel, callback_data=f"evcity:{cid}"))
if len(row) == 2:
rows.append(row); row = []
if row: rows.append(row)
rows.append([InlineKeyboardButton("✍️ Altra città", callback_data="evcity:__other__")])
rows.append([InlineKeyboardButton("◀️ Menu", callback_data="menu_back")])
return InlineKeyboardMarkup(rows)
def _ev_cat_keyboard():
rows = []
for i in range(0, len(_EV_CATEGORIES), 2):
row = []
for emoji, cid, clabel in _EV_CATEGORIES[i:i+2]:
row.append(InlineKeyboardButton(f"{emoji} {clabel}", callback_data=f"evcat:{cid}"))
rows.append(row)
rows.append([InlineKeyboardButton("❓ Altro / Non so", callback_data="evcat:evento")])
return InlineKeyboardMarkup(rows)
def _ev_price_keyboard():
return InlineKeyboardMarkup([
[InlineKeyboardButton("πŸ†“ Gratis / Ingresso libero", callback_data="evprice:gratis"),
InlineKeyboardButton("πŸ’Ά A pagamento", callback_data="evprice:pagamento")],
[InlineKeyboardButton("⏭ Salta (non so)", callback_data="evprice:skip")],
])
def _ev_time_keyboard():
"""Orari comuni come bottoni: si toccano, così non serve digitare i due punti
(che fanno comparire il suggerimento emoji dell'orologio su Telegram)."""
times = ["10:00", "11:00", "12:00", "15:00", "16:00", "17:00",
"18:00", "18:30", "19:00", "20:00", "20:30", "21:00", "21:30", "22:00"]
rows = []
for i in range(0, len(times), 3):
rows.append([InlineKeyboardButton(t, callback_data=f"evtime:{t}") for t in times[i:i+3]])
rows.append([InlineKeyboardButton("⏭ Salta / Non so", callback_data="evtime:skip")])
return InlineKeyboardMarkup(rows)
def _parse_time(txt: str):
"""
Interpreta un orario scritto in modo flessibile, SENZA obbligare i due punti.
Ritorna (valore|None, is_skip):
- is_skip=True β†’ l'utente ha scelto di saltare
- valore="HH:MM" se riconosciuto
- valore=None se non riconosciuto (input non valido)
Accetta: 21 Β· 21.00 Β· 21,30 Β· 21:00 Β· 21 30 Β· 2130 Β· 930 Β· 21h Β· 21h30
"""
t = (txt or "").strip().lower()
if t in ("salta", "skip", "-", "non so", ""):
return ("", True)
parts = [p for p in re.sub(r'[^0-9]', ' ', t).split() if p]
h = mi = None
if len(parts) >= 2:
h, mi = int(parts[0]), int(parts[1])
elif len(parts) == 1:
d = parts[0]
if len(d) <= 2: # "21" β†’ 21:00
h, mi = int(d), 0
elif len(d) == 3: # "930" β†’ 9:30
h, mi = int(d[0]), int(d[1:])
elif len(d) == 4: # "2130" β†’ 21:30
h, mi = int(d[:2]), int(d[2:])
if h is None or not (0 <= h <= 23) or not (0 <= mi <= 59):
return (None, False)
return (f"{h:02d}:{mi:02d}", False)
async def cmd_invia_evento(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> int:
"""Avvia il flusso di invio evento tramite /invia_evento."""
await update.message.reply_text(
"πŸ“… *Segnala un evento*\n\n"
"Hai trovato un evento interessante? Segnalacelo!\n"
"SarΓ  esaminato e, se approvato, comparirΓ  nella sezione eventi del sito.\n\n"
"In quale cittΓ  si svolge? Scegli un'opzione o aggiungi una cittΓ :",
parse_mode="Markdown",
reply_markup=_ev_city_keyboard()
)
return EV_CITY
async def ev_got_city(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> int:
q = update.callback_query
await q.answer()
city_id = q.data.split(":", 1)[1]
if city_id == "__other__":
ctx.user_data["ev_city"] = ""
await q.edit_message_text(
"πŸ“… *Segnala un evento*\n\n"
"In quale *cittΓ * si svolge l'evento?\n"
"_Scrivi il nome della cittΓ  (es. 'Milano', 'Cuneo', 'Genova')._",
parse_mode="Markdown"
)
return EV_CITYNAME
ctx.user_data["ev_city"] = city_id
ctx.user_data["ev_city_label"] = _EV_CITIES.get(city_id, city_id.title())
await q.edit_message_text(
f"πŸ“… *Segnala un evento β€” {ctx.user_data['ev_city_label']}*\n\n"
"Qual Γ¨ il *titolo* dell'evento?\n"
"_(Es. 'Concerto di Tiziano Ferro', 'Mostra di Caravaggio')_",
parse_mode="Markdown"
)
return EV_TITLE
async def ev_got_cityname(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> int:
"""Riceve il nome di una cittΓ  scritto liberamente dall'utente."""
name = update.message.text.strip()
if len(name) < 2:
await update.message.reply_text("Nome cittΓ  troppo breve. Riprova:")
return EV_CITYNAME
if len(name) > 60:
await update.message.reply_text("Nome cittΓ  troppo lungo (max 60 caratteri). Riprova:")
return EV_CITYNAME
slug = re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-') or "altra"
ctx.user_data["ev_city"] = slug
ctx.user_data["ev_city_label"] = name.title()
await update.message.reply_text(
f"πŸ“… *Segnala un evento β€” {ctx.user_data['ev_city_label']}*\n\n"
"Qual Γ¨ il *titolo* dell'evento?\n"
"_(Es. 'Concerto di Tiziano Ferro', 'Mostra di Caravaggio')_",
parse_mode="Markdown"
)
return EV_TITLE
async def ev_got_title(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> int:
t = update.message.text.strip()
if len(t) < 3:
await update.message.reply_text("Titolo troppo breve. Riprova:"); return EV_TITLE
if len(t) > 200:
await update.message.reply_text("Titolo troppo lungo (max 200 caratteri). Riprova:"); return EV_TITLE
ctx.user_data["ev_title"] = t
# Chiedi categoria
await update.message.reply_text(
"🎭 *Che tipo di evento è?*\nScegli la categoria:",
parse_mode="Markdown",
reply_markup=_ev_cat_keyboard()
)
return EV_DATE # overloaded: in questo stato arriva il callback della categoria
async def ev_got_cat(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> int:
"""Gestisce la scelta di categoria (callback), poi chiede la data."""
q = update.callback_query
await q.answer()
ctx.user_data["ev_category"] = q.data.split(":", 1)[1]
await q.edit_message_text(
"πŸ“… *Quando si svolge?*\n\n"
"Scrivi la data nel formato *GG/MM/AAAA*\n"
"_(Oppure 'dal GG/MM al GG/MM/AAAA' per piΓΉ giorni)_\n"
"_Scrivi 'salta' se non la conosci_",
parse_mode="Markdown"
)
return EV_DATE
async def ev_got_date(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> int:
"""Riceve la data come testo."""
txt = update.message.text.strip()
if txt.lower() in ("salta", "skip", "-", "non so"):
ctx.user_data["ev_date"] = ""
ctx.user_data["ev_date_end"] = ""
else:
# Prova parse: GG/MM/AAAA o GG/MM (anno corrente)
m = re.match(r'(\d{1,2})[/.](\d{1,2})(?:[/.](\d{4}))?', txt)
if m:
try:
dd, mm = int(m.group(1)), int(m.group(2))
yy = int(m.group(3)) if m.group(3) else date.today().year
ctx.user_data["ev_date"] = date(yy, mm, dd).isoformat()
except ValueError:
ctx.user_data["ev_date"] = ""
else:
# Prova data testuale italiana: "12 giugno 2026" / "12 giugno"
ctx.user_data["ev_date"] = _parse_italian_date(txt)
# Cerca data di fine per intervalli
m_end = re.findall(r'(\d{1,2})[/.](\d{1,2})[/.](\d{4})', txt)
if len(m_end) >= 2:
try:
dd, mm, yy = int(m_end[1][0]), int(m_end[1][1]), int(m_end[1][2])
ctx.user_data["ev_date_end"] = date(yy, mm, dd).isoformat()
except ValueError:
ctx.user_data["ev_date_end"] = ""
else:
ctx.user_data["ev_date_end"] = ""
await update.message.reply_text(
"⏰ *A che ora inizia?*\n\n"
"Tocca un orario qui sotto πŸ‘‡ oppure *scrivilo* β€” anche senza i due punti, "
"es. *21.00*, *2130* o solo *21*.\n"
"_Scrivi 'salta' se non lo conosci._",
parse_mode="Markdown",
reply_markup=_ev_time_keyboard()
)
return EV_TIME
async def ev_got_time_cb(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> int:
"""Orario scelto toccando un bottone (evita di digitare i due punti)."""
q = update.callback_query
await q.answer()
val = q.data.split(":", 1)[1] # "skip" oppure "HH:MM"
ctx.user_data["ev_time"] = "" if val == "skip" else val
await q.edit_message_text(
"πŸ“ *Dove si svolge?*\n\n"
"Scrivi il nome del luogo o indirizzo.\n_Scrivi 'salta' se non lo conosci._",
parse_mode="Markdown"
)
return EV_VENUE
async def ev_got_time(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> int:
parsed, is_skip = _parse_time(update.message.text)
if is_skip:
ctx.user_data["ev_time"] = ""
elif parsed is None:
await update.message.reply_text(
"Non ho capito l'orario πŸ€”\n"
"Scrivilo *senza i due punti*, es. *21.00*, *2130* o solo *21*.\n"
"Oppure tocca un orario dai bottoni, o scrivi *salta*.",
parse_mode="Markdown",
reply_markup=_ev_time_keyboard()
)
return EV_TIME
else:
ctx.user_data["ev_time"] = parsed
await update.message.reply_text(
"πŸ“ *Dove si svolge?*\n\n"
"Scrivi il nome del luogo o indirizzo.\n_Scrivi 'salta' se non lo conosci._",
parse_mode="Markdown"
)
return EV_VENUE
async def ev_got_venue(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> int:
txt = update.message.text.strip()
ctx.user_data["ev_venue"] = "" if txt.lower() in ("salta","skip","-") else txt[:200]
await update.message.reply_text(
"πŸ’Ά *Prezzo di ingresso?*",
parse_mode="Markdown",
reply_markup=_ev_price_keyboard()
)
return EV_PRICE
async def ev_got_price(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> int:
q = update.callback_query
await q.answer()
val = q.data.split(":", 1)[1]
if val == "gratis":
ctx.user_data["ev_price"] = "Gratis"
ctx.user_data["ev_free"] = True
elif val == "pagamento":
ctx.user_data["ev_price"] = "A pagamento"
ctx.user_data["ev_free"] = False
else:
ctx.user_data["ev_price"] = ""
ctx.user_data["ev_free"] = False
# Chiedi link (facoltativo)
await q.edit_message_text(
"πŸ”— *Link all'evento* (opzionale)\n\n"
"Incolla il link al sito ufficiale, Eventbrite, Ticketmaster ecc.\n"
"_Scrivi 'salta' per non aggiungere link._",
parse_mode="Markdown"
)
return EV_CONFIRM # overloaded per raccogliere URL
async def ev_got_url(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> int:
"""Raccoglie URL e mostra riepilogo."""
txt = update.message.text.strip()
url = ""
if txt.lower() not in ("salta","skip","-","") and (txt.startswith("http") or "." in txt):
url = txt[:500]
ctx.user_data["ev_url"] = url
# Riepilogo
d = ctx.user_data
cat_label = next((lbl for _, cid, lbl in _EV_CATEGORIES if cid == d.get("ev_category","")), d.get("ev_category","Evento"))
lines = [
f"πŸ“… *Riepilogo evento*\n",
f"πŸŒ† CittΓ : *{d.get('ev_city_label','')}*",
f"πŸ“Œ Titolo: *{d.get('ev_title','')}*",
f"🎭 Tipo: _{cat_label}_",
]
if d.get("ev_date"):
line = f"πŸ“… Data: {d['ev_date']}"
if d.get("ev_date_end"): line += f" β†’ {d['ev_date_end']}"
lines.append(line)
if d.get("ev_time"): lines.append(f"πŸ• Orario: {d['ev_time']}")
if d.get("ev_venue"): lines.append(f"πŸ“ Luogo: {d['ev_venue']}")
if d.get("ev_price"): lines.append(f"πŸ’Ά Prezzo: {d['ev_price']}")
if url: lines.append(f"πŸ”— Link: {url[:60]}{'…' if len(url)>60 else ''}")
lines.append("\n_Confermi l'invio?_")
await update.message.reply_text(
"\n".join(lines),
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("βœ… Invia", callback_data="evconf:yes"),
InlineKeyboardButton("❌ Annulla", callback_data="evconf:no"),
]])
)
return EV_CONFIRM
async def ev_confirm(update: Update, ctx: ContextTypes.DEFAULT_TYPE) -> int:
q = update.callback_query
await q.answer()
if q.data == "evconf:no":
await q.edit_message_text("❌ Invio annullato.")
ctx.user_data.clear()
return ConversationHandler.END
d = ctx.user_data
uid = str(q.from_user.id)
ts = int(time.time())
ev_id = f"ev_{uid}_{ts}"
item = {
"id": ev_id, "uid": uid,
"tg_username": q.from_user.username or q.from_user.first_name or "",
"city": d.get("ev_city", ""), "city_label": d.get("ev_city_label", ""),
"title": d.get("ev_title", ""), "category": d.get("ev_category", "evento"),
"date": d.get("ev_date", ""), "date_end": d.get("ev_date_end", ""),
"time": d.get("ev_time", ""), "venue": d.get("ev_venue", ""),
"price": d.get("ev_price", ""), "free": d.get("ev_free", False),
"url": d.get("ev_url", ""),
"source": "Utente Telegram", "status": "pending",
"submitted_at": datetime.now().isoformat(),
}
# Salva in events_user.json
evs = load_user_events()
evs.append(item)
save_user_events(evs)
await q.edit_message_text(
"βœ… Evento segnalato! Grazie!\n\n"
"Gli admin lo esamineranno e, se approvato, comparirΓ  nella sezione eventi. πŸ“…",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("🏠 Menu", callback_data="menu_back")
]])
)
# Notifica admin
cat_label = next((lbl for _, cid, lbl in _EV_CATEGORIES if cid == item["category"]), item["category"])
who = f"@{q.from_user.username}" if q.from_user.username else f"id:{uid}"
msg = (
f"πŸ“… <b>Nuovo evento segnalato</b>\n\n"
f"<b>{_html.escape(item['title'])}</b>\n"
f"πŸŒ† {_html.escape(item['city_label'])}\n"
f"🎭 {_html.escape(cat_label)}\n"
+ (f"πŸ“… {item['date']}" + (f" β†’ {item['date_end']}" if item['date_end'] else "") + "\n" if item['date'] else "")
+ (f"πŸ• {item['time']}\n" if item['time'] else "")
+ (f"πŸ“ {_html.escape(item['venue'])}\n" if item['venue'] else "")
+ (f"πŸ’Ά {item['price']}\n" if item['price'] else "")
+ (f"πŸ”— {_html.escape(item['url'][:80])}\n" if item['url'] else "")
+ f"\nda {_html.escape(who)}"
)
kb = InlineKeyboardMarkup([[
InlineKeyboardButton("βœ… Approva", callback_data=f"evadm_ok:{ev_id}"),
InlineKeyboardButton("❌ Rifiuta", callback_data=f"evadm_no:{ev_id}"),
]])
for admin_id in ADMIN_IDS:
try:
await ctx.bot.send_message(admin_id, msg, parse_mode="HTML", reply_markup=kb)
except Exception as _e:
log.warning(f"Admin evento notify {admin_id}: {_e}")
ctx.user_data.clear()
return ConversationHandler.END
async def ev_admin_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Approvazione/rifiuto eventi inviati dagli utenti."""
q = update.callback_query
await q.answer()
if not is_admin(q.from_user.id):
await q.answer("β›” Solo gli admin possono approvare.", show_alert=True)
return
action, ev_id = q.data.split(":", 1)
evs = load_user_events()
item = next((e for e in evs if e["id"] == ev_id), None)
if not item:
await q.edit_message_text("⚠️ Evento non trovato.")
return
if action == "evadm_no":
evs = [e for e in evs if e["id"] != ev_id]
save_user_events(evs)
await q.edit_message_text(f"❌ Evento rifiutato: {item['title']}")
try:
await ctx.bot.send_message(int(item["uid"]),
f"πŸ˜” Il tuo evento '{item['title']}' non Γ¨ stato approvato.")
except: pass
return
# Approva: aggiunge al file events.json principale
item["status"] = "approved"
item["approved_at"] = datetime.now().isoformat()
# Marca come approvato in events_user.json
for e in evs:
if e["id"] == ev_id:
e["status"] = "approved"
break
save_user_events(evs)
# Merge in events.json (se esiste)
MAIN_EVENTS_FILE = Path("/app/events.json")
try:
payload = json.loads(MAIN_EVENTS_FILE.read_text(encoding="utf-8")) if MAIN_EVENTS_FILE.exists() else {"events": [], "count": 0}
ev_record = {
"id": ev_id, "title": item["title"],
"description": "", "tags": [item.get("category","evento").capitalize(), item.get("city_label","")],
"date": item.get("date",""), "date_end": item.get("date_end",""),
"time": item.get("time",""), "venue": item.get("venue",""),
"city": item.get("city_label",""), "address": "",
"url": item.get("url",""), "map_url": "",
"image": "", "price": item.get("price",""),
"free": item.get("free", False), "kids": False, "kids_age": "",
"kids_age_text": "", "category": item.get("category","evento"),
"source": "Utente Telegram",
}
# Evita duplicati
if not any(e.get("id") == ev_id for e in payload.get("events", [])):
payload["events"].append(ev_record)
payload["count"] = len(payload["events"])
payload["events"].sort(key=lambda e: (e.get("date") or "9999", e.get("time") or "99"))
tmp = str(MAIN_EVENTS_FILE) + ".tmp"
Path(tmp).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
Path(tmp).replace(MAIN_EVENTS_FILE)
except Exception as _me:
log.warning(f"Merge events.json: {_me}")
await q.edit_message_text(f"βœ… Evento approvato: {item['title']}")
try:
await ctx.bot.send_message(int(item["uid"]),
f"πŸŽ‰ Il tuo evento *{item['title']}* Γ¨ stato approvato!\n"
"ApparirΓ  presto nella sezione eventi del sito. πŸ“…",
parse_mode="Markdown")
except: pass
def main():
if not BOT_TOKEN:
log.error("TELEGRAM_BOT_TOKEN non configurato β€” bot non avviato.")
return
log.info("=== corsokossuthbot β€” avvio ===")
if ADMIN_IDS:
log.info(f"Admin configurati: {ADMIN_IDS}")
else:
log.error("TELEGRAM_ADMIN_IDS non impostato β€” nessun admin configurato!")
# ── Sincronizza il cineforum dal dataset HF prima di avviare il bot ──────
cineforum_pull_from_hf()
log.info(f"Cineforum: {len(load_cineforum())} film caricati")
# ── Webhook mode: Telegram β†’ HuggingFace (no outbound da HF a Telegram) ──
SPACE_URL = os.environ.get("SPACE_URL", "https://corsokossuth-kossuth.hf.space").rstrip("/")
WEBHOOK_PATH = "/tgwebhook"
# Nessun default pubblico: usa WEBHOOK_SECRET se impostato, altrimenti deriva
# un segreto non indovinabile dal bot token (gia' segreto e condiviso con il
# server in start.sh, che usa la stessa derivazione).
import hashlib as _hsh
WEBHOOK_SECRET = (os.environ.get("WEBHOOK_SECRET", "").strip()
or _hsh.sha256(("kossuth-wh:" + BOT_TOKEN).encode()).hexdigest()[:32])
WEBHOOK_URL = f"{SPACE_URL}{WEBHOOK_PATH}"
import asyncio as _aio, socket as _sk, os as _os, threading as _th, json as _js
log.info("Avvio bot in webhook mode…")
app = _build_app()
# Crea l'event loop UNA VOLTA sola e riusalo
loop = _aio.new_event_loop()
_aio.set_event_loop(loop)
async def _run():
# initialize() tenta getMe β€” su HF va in timeout, lo ignoriamo
try:
await _aio.wait_for(app.initialize(), timeout=8)
except Exception as _ie:
log.warning(f"initialize() timeout/errore (normale su HF): {_ie}")
try:
await app.start()
except Exception as _se:
log.warning(f"start() warning: {_se}")
# ── 1) Socket Unix PRIMA del webhook ─────────────────────────────────
# Appena set_webhook va a buon fine, Telegram consegna subito gli update
# arretrati: il socket deve gia' esistere, altrimenti quei primi update
# trovano la porta chiusa e vengono persi.
SOCK_PATH = "/tmp/tg_webhook.sock"
if _os.path.exists(SOCK_PATH):
_os.unlink(SOCK_PATH)
srv = _sk.socket(_sk.AF_UNIX, _sk.SOCK_STREAM)
srv.bind(SOCK_PATH)
srv.listen(10)
_os.chmod(SOCK_PATH, 0o777)
def _serve_socket():
log.info("Socket Unix pronto β€” in attesa di update da Telegram…")
while True:
try:
conn, _ = srv.accept()
data = b""
while True:
chunk = conn.recv(65536)
if not chunk: break
data += chunk
conn.close()
if data:
upd = Update.de_json(_js.loads(data), app.bot)
log.info(f"⇦ Update ricevuto (id={getattr(upd,'update_id','?')})")
_aio.run_coroutine_threadsafe(
app.process_update(upd), loop
)
except Exception as _se:
log.error(f"Socket error: {_se}")
_th.Thread(target=_serve_socket, daemon=True).start()
# ── 2) Aspetta che il server HTTP dello Space sia in ascolto ──────────
# start.sh avvia bot.py PRIMA del server su :7860 (Jamendo, Liquidsoap,
# ffmpeg… vengono dopo). Se registriamo il webhook adesso, Telegram
# inizia a consegnare su una porta ancora chiusa.
for _i in range(240): # max ~4 minuti
try:
_c = _sk.create_connection(("127.0.0.1", 7860), timeout=1)
_c.close()
log.info(f"Server HTTP :7860 pronto dopo {_i}s β€” registro il webhook.")
break
except OSError:
await _aio.sleep(1)
else:
log.warning("Server HTTP :7860 non pronto dopo 240s β€” registro comunque il webhook.")
# ── 3) Registra il webhook su Telegram ───────────────────────────────
try:
await app.bot.set_webhook(
url=WEBHOOK_URL,
secret_token=WEBHOOK_SECRET,
allowed_updates=["message","callback_query","inline_query"],
)
log.info(f"βœ… Webhook registrato: {WEBHOOK_URL}")
except Exception as _we:
log.error(f"set_webhook fallito: {_we}")
# Verifica cosa ne pensa Telegram: se non riesce a consegnare, lo dice qui.
try:
info = await app.bot.get_webhook_info()
log.info(f"getWebhookInfo β†’ url={info.url} pending={info.pending_update_count} "
f"ip={getattr(info,'ip_address',None)} "
f"last_error={getattr(info,'last_error_message',None)}")
except Exception as _ge:
log.warning(f"getWebhookInfo non disponibile: {_ge}")
log.info(f"βœ… Bot pronto β€” webhook: {WEBHOOK_URL}")
# ── 4) Controllo periodico: se Telegram non consegna, lo scrivo nel log ─
while True:
await _aio.sleep(300)
try:
info = await app.bot.get_webhook_info()
if getattr(info, "last_error_message", None):
log.error(f"⚠ Telegram NON riesce a consegnare: "
f"{info.last_error_message} (pending={info.pending_update_count})")
except Exception:
pass
try:
loop.run_until_complete(_run())
except Exception as e:
log.error(f"Bot terminato: {e}")
# ═══════════════════════════════════════════════════════════════════════════════
# UTILITY DI FORMATTAZIONE
# ═══════════════════════════════════════════════════════════════════════════════
def esc(text) -> str:
"""HTML-escape per uso nei messaggi Telegram HTML."""
return _html.escape(str(text) if text else "")
# ═══════════════════════════════════════════════════════════════════════════════
# CINEFORUM β€” lista film, voti, aggiunta nuovi titoli
# ═══════════════════════════════════════════════════════════════════════════════
CINEFORUM_FILE = DATA_DIR / "cineforum.json"
CINEFORUM_HF_PATH = "bot_data/cineforum.json" # percorso nel dataset HF
# Stati conversazione Cineforum
CF_TITLE, CF_CONFIRM = range(300, 302)
CV_NAME, CV_SCORE = range(302, 304)
# ── Stati per invio evento ─────────────────────────────────────────────────────
# Flusso: cittΓ  β†’ titolo β†’ data β†’ orario (opz) β†’ luogo β†’ prezzo β†’ conferma
(EV_CITY, EV_TITLE, EV_DATE, EV_TIME, EV_VENUE, EV_PRICE, EV_CONFIRM, EV_CITYNAME) = range(400, 408)
# Categorie supportate (sottinsieme delle categorie dello scraper)
_EV_CATEGORIES = [
("🎡", "musica", "Musica / Concerto"),
("🎭", "teatro", "Teatro / Spettacolo"),
("πŸ–Ό", "mostra", "Mostra / Esposizione"),
("🎬", "cinema", "Cinema / Proiezione"),
("πŸ’ƒ", "danza", "Danza / Balletto"),
("πŸ“š", "libri", "Libri / Incontro"),
("🍷", "food", "Food / Degustazione"),
("πŸ‘Ά", "famiglia", "Bambini / Famiglie"),
("πŸŽͺ", "intrattenimento", "Intrattenimento"),
("🎨", "arte", "Arte / Laboratorio"),
]
# CittΓ  supportate per l'invio manuale (id β†’ nome visualizzato)
_EV_CITIES = {
"torino": "Torino e dintorni",
}
EVENTS_FILE = Path("/app/events_user.json") # eventi inviati dagli utenti
# ── Sync HF β†’ locale (usato all'avvio) ───────────────────────────
def cineforum_pull_from_hf() -> bool:
"""
Scarica bot_data/cineforum.json dal dataset HuggingFace e lo salva
in CINEFORUM_FILE. Chiamato una volta all'avvio del bot.
Ritorna True se il download Γ¨ riuscito.
"""
if not HF_TOKEN:
log.info("[CF-SYNC] HF_TOKEN non impostato β€” uso solo file locale.")
return False
url = (f"https://huggingface.co/datasets/{HF_DATASET}"
f"/resolve/main/{CINEFORUM_HF_PATH}")
import urllib.request, urllib.error
req = urllib.request.Request(
url, headers={"Authorization": f"Bearer {HF_TOKEN}"}
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
raw = r.read()
# Verifica che sia JSON valido prima di sovrascrivere
json.loads(raw)
CINEFORUM_FILE.parent.mkdir(parents=True, exist_ok=True)
CINEFORUM_FILE.write_bytes(raw)
log.info(f"[CF-SYNC] βœ“ cineforum.json scaricato da HF "
f"({len(raw)} byte, "
f"{len(json.loads(raw))} film)")
return True
except urllib.error.HTTPError as e:
if e.code == 404:
log.info("[CF-SYNC] cineforum.json non ancora nel dataset HF "
"β€” parto dal file locale (o vuoto).")
else:
log.warning(f"[CF-SYNC] HTTP {e.code} scaricando cineforum da HF: {e}")
return False
except Exception as e:
log.warning(f"[CF-SYNC] Errore pull HF: {e} β€” uso file locale.")
return False
# ── Sync locale β†’ HF (usato dopo ogni modifica) ──────────────────
def _cineforum_push_to_hf_sync():
"""Carica il cineforum.json locale su HF (funzione bloccante, gira in thread)."""
if not HF_TOKEN:
return
try:
raw = CINEFORUM_FILE.read_bytes()
b64 = base64.b64encode(raw).decode()
# Formato NDJSON corretto per l'API HuggingFace commit
payload = (
json.dumps({"key": "header", "value": {"summary": "Update cineforum.json"}})
+ "\n"
+ json.dumps({"key": "file", "value": {
"path": CINEFORUM_HF_PATH,
"encoding": "base64",
"content": b64,
}})
).encode()
import urllib.request
req = urllib.request.Request(
f"https://huggingface.co/api/datasets/{HF_DATASET}/commit/main",
data=payload,
headers={
"Authorization": f"Bearer {HF_TOKEN}",
"Content-Type": "application/x-ndjson",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=60) as r:
r.read()
log.info("[CF-SYNC] βœ“ cineforum.json salvato su HF.")
except Exception as e:
log.warning(f"[CF-SYNC] Push HF fallito (dati al sicuro in locale): {e}")
def load_cineforum() -> list:
return load_json(CINEFORUM_FILE, [])
def save_cineforum(data: list):
"""Salva in locale e poi spinge su HF in background (non blocca il bot)."""
save_json(CINEFORUM_FILE, data)
import threading
t = threading.Thread(target=_cineforum_push_to_hf_sync, daemon=True)
t.start()
def cf_trimmed_mean(scores: list) -> Optional[float]:
"""Media semplice di tutti i voti (nessuna esclusione)."""
if not scores: return None
return sum(scores) / len(scores)
def cf_bayesian(mean: float, n: int, global_mean: float, C: int = 4) -> float:
"""Media ponderata Bayesiana: penalizza film con pochi voti."""
if mean is None: return None
return (n * mean + C * global_mean) / (n + C)
def cf_film_text(f: dict, idx: int = None, global_mean: float = 7.0) -> str:
"""Testo leggibile di un film con voto medio ponderato."""
scores = [v["score"] for v in f.get("votes", []) if isinstance(v.get("score"), (int, float))]
mean = cf_trimmed_mean(scores)
bayes = cf_bayesian(mean, len(scores), global_mean) if mean is not None else None
avg_s = f"⭐ {bayes:.1f}/10 (pond.)" if bayes is not None else "β€” nessun voto"
meta = " Β· ".join(filter(None, [
f.get("year",""), f.get("director",""), f.get("genre",""), f.get("country","")
]))
n = f"#{idx+1} " if idx is not None else ""
return (
f"{n}🎬 *{f['title']}*\n"
+ (f" {meta}\n" if meta else "")
+ f" {avg_s} ({len(scores)} vot{'o' if len(scores)==1 else 'i'})"
)
def cf_film_keyboard(films: list, page: int = 0, per_page: int = 5) -> InlineKeyboardMarkup:
approved = sorted(
[f for f in films if f.get("status","approved") == "approved" and f.get("season")],
key=lambda f: f.get("title","").lower()
)
start = page * per_page
chunk = approved[start:start+per_page]
rows = []
for i, f in enumerate(chunk):
idx = start + i
rows.append([InlineKeyboardButton(
f"🎬 {f['title']}{' ('+f['year']+')' if f.get('year') else ''}",
callback_data=f"cf_film:{idx}"
)])
nav = []
if page > 0:
nav.append(InlineKeyboardButton("◀️ Prec", callback_data=f"cf_page:{page-1}"))
if start + per_page < len(approved):
nav.append(InlineKeyboardButton("Succ ▢️", callback_data=f"cf_page:{page+1}"))
if nav:
rows.append(nav)
rows.append([InlineKeyboardButton("βž• Proponi un film", callback_data="menu_cf_proponi")])
rows.append([InlineKeyboardButton("◀️ Menu", callback_data="menu_back")])
return InlineKeyboardMarkup(rows)
async def cmd_cineforum(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Comando /cineforum β€” mostra la lista film."""
films = load_cineforum()
# Stesso filtro usato da cf_film_keyboard: approved + season valorizzato
approved = [f for f in films if f.get("status","approved") == "approved" and f.get("season")]
intro = (
"🎬 *Cineforum Corso Kossuth*\n\n"
f"Lista di {len(approved)} film{'i' if len(approved)!=1 else ''}.\n"
"Seleziona un film per votarlo o aggiungerne uno nuovo.\n\n"
"_Voto in 10esimi Β· media ponderata per numero di votanti_"
)
await update.message.reply_text(intro, parse_mode="Markdown",
reply_markup=cf_film_keyboard(films, 0))
async def cf_page_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Pagina successiva/precedente nella lista film."""
q = update.callback_query
await q.answer()
page = int(q.data.split(":")[1])
films = load_cineforum()
await q.edit_message_reply_markup(cf_film_keyboard(films, page))
async def cf_film_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Mostra scheda film e opzione di votare."""
q = update.callback_query
await q.answer()
idx = int(q.data.split(":")[1])
films = load_cineforum()
approved = sorted(
[f for f in films if f.get("status","approved") == "approved" and f.get("season")],
key=lambda f: f.get("title","").lower()
)
if idx >= len(approved):
await q.answer("Film non trovato.", show_alert=True); return
f = approved[idx]
scores = [v["score"] for v in f.get("votes",[]) if isinstance(v.get("score"),(int,float))]
# Media ponderata Bayesiana
all_scores = [v["score"] for film in films for v in film.get("votes",[]) if isinstance(v.get("score"),(int,float))]
global_mean = sum(all_scores)/len(all_scores) if all_scores else 7.0
avg = cf_bayesian(cf_trimmed_mean(scores), len(scores), global_mean)
meta = " Β· ".join(filter(None,[f.get("year",""),f.get("country",""),f.get("duration","")]))
def _e(s): return _html.escape(str(s)) if s else ""
vote_str = (f"⭐ Media: <b>{avg:.1f}/10</b>"
+ (f" <i>(su {len(scores)} voti, esclusi min/max)</i>" if len(scores)>=4 else
f" <i>({len(scores)} vot{'o' if len(scores)==1 else 'i'})</i>")
) if avg is not None else "<i>Nessun voto ancora</i>"
text = (
f"🎬 <b>{_e(f['title'])}</b>"
+ (f" <i>{_e(f['year'])}</i>" if f.get("year") else "")
+ "\n\n"
+ (f"🎬 Regia: {_e(f['director'])}\n" if f.get("director") else "")
+ (f"🌍 Paese: {_e(f['country'])}\n" if f.get("country") else "")
+ (f"🎭 Genere: {_e(f['genre'])}\n" if f.get("genre") else "")
+ (f"⏱ Durata: {_e(f['duration'])}\n" if f.get("duration") else "")
+ (f"πŸ—£ Lingua: {_e(f['language'])}\n" if f.get("language") else "")
+ (f"\n<i>{_e(f['synopsis'][:250])}{'…' if len(f.get('synopsis',''))>250 else ''}</i>\n" if f.get("synopsis") else "")
+ f"\n{vote_str}"
)
ctx.user_data["cf_film_idx"] = idx
kb = InlineKeyboardMarkup([
[InlineKeyboardButton("πŸ—³ Vota questo film", callback_data=f"cf_vote:{idx}")],
[InlineKeyboardButton("◀️ Lista film", callback_data="cf_page:0")],
])
await q.edit_message_text(text, parse_mode="HTML", reply_markup=kb)
async def cf_vote_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Avvia il flusso di votazione."""
q = update.callback_query
await q.answer()
idx = int(q.data.split(":")[1])
films = load_cineforum()
# Stesso filtro usato da cf_film_keyboard e cf_film_callback (status + season)
approved = sorted(
[f for f in films if f.get("status","approved") == "approved" and f.get("season")],
key=lambda f: f.get("title","").lower()
)
if idx >= len(approved):
await q.answer("Film non trovato.", show_alert=True); return
f = approved[idx]
ctx.user_data["cf_vote_film_idx"] = idx
await q.edit_message_text(
f"πŸ—³ Vota: <b>{_html.escape(f['title'])}</b>\n\nπŸ‘€ Come ti chiami? <i>(verrΓ  mostrato nella scheda)</i>",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Annulla", callback_data="cf_page:0")]])
)
return CV_NAME
async def cf_got_voter_name(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
name = update.message.text.strip()[:50]
if not name:
await update.message.reply_text("❌ Inserisci un nome."); return CV_NAME
ctx.user_data["cf_voter_name"] = name
kb = InlineKeyboardMarkup([
[InlineKeyboardButton(str(i), callback_data=f"cf_score:{i}") for i in range(1,6)],
[InlineKeyboardButton(str(i), callback_data=f"cf_score:{i}") for i in range(6,11)],
])
await update.message.reply_text(
f"πŸ‘€ Nome: *{name}*\n\n🎯 Scegli il tuo voto (1–10):",
parse_mode="HTML", reply_markup=kb
)
return CV_SCORE
async def cf_got_score(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
q = update.callback_query
await q.answer()
score = int(q.data.split(":")[1])
idx = ctx.user_data.get("cf_vote_film_idx", 0)
name = ctx.user_data.get("cf_voter_name", "Anonimo")
uid = str(q.from_user.id)
films = load_cineforum()
approved = sorted(
[f for f in films if f.get("status","approved") == "approved" and f.get("season")],
key=lambda f: f.get("title","").lower()
)
if idx >= len(approved):
await q.edit_message_text("❌ Film non trovato."); return ConversationHandler.END
f = approved[idx]
# Rimuovi eventuale voto precedente dello stesso utente
f.setdefault("votes", [])
f["votes"] = [v for v in f["votes"] if str(v.get("uid","")) != uid]
f["votes"].append({"name": name, "score": score, "uid": uid,
"timestamp": datetime.now().isoformat()})
save_cineforum(films)
scores = [v["score"] for v in f["votes"] if isinstance(v.get("score"),(int,float))]
avg = cf_trimmed_mean(scores)
await q.edit_message_text(
f"βœ… Voto registrato!\n\n"
f"🎬 *{f['title']}* β€” il tuo voto: *{score}/10*\n"
f"⭐ Media attuale: *{avg:.1f}/10* ({len(scores)} vot{'o' if len(scores)==1 else 'i'})"
+ (" Β· esclusi min/max" if len(scores)>=4 else ""),
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Lista film", callback_data="cf_page:0")]])
)
ctx.user_data.pop("cf_vote_film_idx", None)
ctx.user_data.pop("cf_voter_name", None)
return ConversationHandler.END
# ── Proposta nuovo film ────────────────────────────────────────────
async def cf_newfilm_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
q = update.callback_query
await q.answer()
await q.edit_message_text(
"βž• *Proponi un film*\n\n"
"Scrivi il titolo del film che vuoi aggiungere al Cineforum.\n"
"_Cercheremo le informazioni e lo sottoporremo agli admin._\n\n"
"Usa /annulla per interrompere.",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Annulla", callback_data="cf_page:0")]])
)
return CF_TITLE
async def cf_got_title(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
title = update.message.text.strip()
if len(title) < 2 or len(title) > 120:
await update.message.reply_text("❌ Titolo non valido (2–120 caratteri). Riprova:"); return CF_TITLE
# Verifica duplicati
films = load_cineforum()
if any(f["title"].lower().strip() == title.lower().strip() for f in films):
await update.message.reply_text(
f"⚠️ *{esc(title)}* è già nel Cineforum!",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Lista film", callback_data="cf_page:0")]])
); return ConversationHandler.END
ctx.user_data["cf_new_title"] = title
kb = InlineKeyboardMarkup([
[InlineKeyboardButton("βœ… Invia proposta", callback_data="cf_confirm:yes"),
InlineKeyboardButton("❌ Annulla", callback_data="cf_confirm:no")],
])
await update.message.reply_text(
f"πŸ“‹ *Conferma proposta*\n\n🎬 Film: *{esc(title)}*\n\n"
"La proposta sarΓ  inviata agli admin per approvazione.",
parse_mode="Markdown", reply_markup=kb
)
return CF_CONFIRM
async def cf_confirm_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
q = update.callback_query
await q.answer()
if q.data == "cf_confirm:no":
await q.edit_message_text("❌ Proposta annullata.")
return ConversationHandler.END
title = ctx.user_data.get("cf_new_title","")
uid = str(q.from_user.id)
uname = q.from_user.username or q.from_user.first_name or ""
film_id = f"cf_{uid}_{int(time.time())}"
new_film = {
"id": film_id, "title": title,
"year":"","director":"","genre":"","country":"","duration":"","language":"","synopsis":"",
"status": "pending",
"submitted_by": uname, "submitted_uid": uid,
"submitted_at": datetime.now().isoformat(),
"votes": [],
}
films = load_cineforum()
films.append(new_film)
save_cineforum(films)
await q.edit_message_text(
f"βœ… Proposta inviata!\n\n"
f"🎬 *{esc(title)}*\n\n"
"Gli admin riceveranno la notifica per approvare il film. "
"Una volta approvato apparirΓ  nel Cineforum e potrai votarlo.",
parse_mode="Markdown",
reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("◀️ Lista film", callback_data="cf_page:0")]])
)
# Notifica admin
kb_adm = InlineKeyboardMarkup([[
InlineKeyboardButton("βœ… Approva", callback_data=f"cf_adm_ok:{film_id}"),
InlineKeyboardButton("❌ Rifiuta", callback_data=f"cf_adm_no:{film_id}"),
]])
for admin_id in ADMIN_IDS:
try:
await ctx.bot.send_message(
admin_id,
f"🎬 *Nuova proposta Cineforum*\n\n"
f"Titolo: *{esc(title)}*\n"
f"Da: @{uname} (id:{uid})\n\n"
"⚠️ Aggiungi le informazioni al film nel file cineforum.json prima di approvare.",
parse_mode="Markdown", reply_markup=kb_adm
)
except Exception as e:
log.warning(f"CF admin notify {admin_id}: {e}")
ctx.user_data.pop("cf_new_title", None)
return ConversationHandler.END
async def cf_admin_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
"""Approva o rifiuta un film proposto al Cineforum."""
q = update.callback_query
await q.answer()
if not is_admin(q.from_user.id):
await q.answer("β›” Solo admin.", show_alert=True); return
action, film_id = q.data.split(":", 1)
films = load_cineforum()
idx = next((i for i,f in enumerate(films) if f.get("id")==film_id), None)
if idx is None:
await q.edit_message_text("⚠️ Film non trovato (già processato)."); return
film = films[idx]
uid = film.get("submitted_uid")
if action == "cf_adm_no":
films.pop(idx); save_cineforum(films)
await q.edit_message_text(f"❌ Film rifiutato: {film['title']}")
if uid:
try:
await ctx.bot.send_message(int(uid),
f"La tua proposta <b>{_html.escape(film['title'])}</b> non Γ¨ stata approvata per il Cineforum.",
parse_mode="HTML")
except: pass
return
film["status"] = "approved"
save_cineforum(films)
# Risposta IMMEDIATA all'admin
await q.edit_message_text(
f"βœ… Film approvato: <b>{_html.escape(film['title'])}</b>",
parse_mode="HTML")
# Notifica utente in background
import asyncio as _aio4
async def _bg_film_approved():
if uid:
try:
await ctx.bot.send_message(int(uid),
f"πŸŽ‰ Il film <b>{_html.escape(film['title'])}</b> Γ¨ stato approvato nel Cineforum!\n\n"
"Puoi votarlo con /cineforum.",
parse_mode="HTML")
except: pass
_aio4.create_task(_bg_film_approved())
if __name__ == "__main__":
main()