#!/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/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', 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🔑 Admin · ID: {uid}" else: hint = f"\n\nID Telegram: {uid}" 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"🎭 Corso Kossuth — Benvenuto {safe_name}!\n" f"Gente in mezzo a una strada\n\n" f"Puoi contribuire alla radio e alle sue rubriche:\n" f"🎵 Radio — audio in onda\n" f"🎬 Cine — vota i film del cineforum\n" f"📅 Eventi — 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( "✉️ Scrivici\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" "Per annullare: /annulla", 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( "🎭 Corso Kossuth — Menu\n" "Gente in mezzo a una strada\n\n" "🎵 Radio — audio in onda\n" "🎬 Cine — cineforum e voti\n" "📅 Eventi — 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 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 Vuoi che questo audio resti nella sezione Podcast del sito?\n\n" "\u2705 S\u00ec — il tuo audio sar\u00e0 ascoltabile sul sito dopo la messa in onda\n" "\u274c No — 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 = ( "✉️ Nuovo messaggio dai contatti\n" f"Da: {_html.escape(u.first_name or 'Utente')} {uname}\n" f"ID Telegram: {u.id}\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 Regista:", parse_mode="HTML") elif step == "director": ctx.user_data["cf_new_director"] = text ctx.user_data["__step__"] = "year" await msg.reply_text("\U0001f4c5 Anno:", parse_mode="HTML") elif step == "year": ctx.user_data["cf_new_year"] = text ctx.user_data["__step__"] = "score" await msg.reply_text("\u2b50 Il tuo voto (1–10):", 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 {_html.escape(title)} 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 Nuova proposta film\n\n" f"{_html.escape(title)}\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 """ if not is_admin(update.effective_user.id): return args = ctx.args or [] if not args: await update.message.reply_text("Uso: /elimina_pending "); 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 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 (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"📅 Nuovo evento segnalato\n\n" f"{_html.escape(item['title'])}\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: {avg:.1f}/10" + (f" (su {len(scores)} voti, esclusi min/max)" if len(scores)>=4 else f" ({len(scores)} vot{'o' if len(scores)==1 else 'i'})") ) if avg is not None else "Nessun voto ancora" text = ( f"🎬 {_e(f['title'])}" + (f" {_e(f['year'])}" 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{_e(f['synopsis'][:250])}{'…' if len(f.get('synopsis',''))>250 else ''}\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: {_html.escape(f['title'])}\n\n👤 Come ti chiami? (verrà mostrato nella scheda)", 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 {_html.escape(film['title'])} 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: {_html.escape(film['title'])}", 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 {_html.escape(film['title'])} è 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()