Spaces:
Running
Running
| #!/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() | |