natisol87 commited on
Commit
040ae7d
Β·
verified Β·
1 Parent(s): 97483b0

Upload 5 files

Browse files
Files changed (5) hide show
  1. Dockerfile +12 -0
  2. README.md +24 -6
  3. app.py +409 -0
  4. index.html +586 -0
  5. requirements.txt +6 -0
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,28 @@
1
  ---
2
- title: Eventuki Api
3
- emoji: πŸ¦€
4
- colorFrom: red
5
- colorTo: gray
6
  sdk: docker
7
- pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: EVENTUKI API
3
+ emoji: πŸŽͺ
4
+ colorFrom: pink
5
+ colorTo: purple
6
  sdk: docker
7
+ pinned: true
8
+ app_port: 7860
9
  ---
10
 
11
+ # EVENTUKI API β€” Backend
12
+
13
+ Backend de EVENTUKI: asistente de eventos para Buenos Aires.
14
+
15
+ ## Variables de entorno requeridas (Settings β†’ Variables and secrets)
16
+
17
+ | Variable | DescripciΓ³n |
18
+ |----------|-------------|
19
+ | `GROQ_API_KEY` | API key de Groq (console.groq.com) |
20
+ | `SERPER_API_KEY` | API key de Serper.dev (serper.dev) |
21
+ | `TELEGRAM_BOT_TOKEN` | Token de @BotFather |
22
+ | `WEBHOOK_URL` | URL de este Space (ej: https://tunombre-eventuki-api.hf.space) |
23
+
24
+ ## Endpoints
25
+
26
+ - `GET /` β€” health check
27
+ - `POST /chat` β€” API para la webapp
28
+ - `POST /telegram/webhook` β€” webhook de Telegram
app.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py β€” EVENTUKI Backend
3
+ Corre en Hugging Face Spaces (gratis, 24/7).
4
+ Sirve la API para la webapp y el webhook de Telegram.
5
+ """
6
+
7
+ import asyncio
8
+ import datetime
9
+ import json
10
+ import logging
11
+ import os
12
+ import re
13
+
14
+ import aiohttp
15
+ from fastapi import FastAPI, Request, HTTPException
16
+ from fastapi.middleware.cors import CORSMiddleware
17
+ from fastapi.responses import JSONResponse
18
+ from groq import AsyncGroq
19
+ from pydantic import BaseModel
20
+ from telegram import Update
21
+ from telegram.ext import (
22
+ Application,
23
+ CallbackQueryHandler,
24
+ CommandHandler,
25
+ ConversationHandler,
26
+ MessageHandler,
27
+ filters,
28
+ )
29
+ from telegram import InlineKeyboardButton, InlineKeyboardMarkup
30
+
31
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s β€” %(levelname)s β€” %(message)s")
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # ── FastAPI app ───────────────────────────────────────────────────────────────
35
+ app = FastAPI(title="EVENTUKI API", version="2.0")
36
+
37
+ app.add_middleware(
38
+ CORSMiddleware,
39
+ allow_origins=["*"],
40
+ allow_credentials=True,
41
+ allow_methods=["*"],
42
+ allow_headers=["*"],
43
+ )
44
+
45
+ # ── Groq ──────────────────────────────────────────────────────────────────────
46
+ groq_client = AsyncGroq(api_key=os.environ.get("GROQ_API_KEY", ""))
47
+ MODEL = "llama-3.1-8b-instant"
48
+ MODEL_BACKUP = "llama3-8b-8192"
49
+
50
+ SITES_HINT = (
51
+ "ticketek livepass passline enigmatickets venti "
52
+ "eventbrite cckonex movistararena teatrocolon "
53
+ "indiehoy feverup festivalesba allaccess paseshow"
54
+ )
55
+
56
+ _RE = re.compile(r"<<<EVENTOS>>>(.*?)<<<FIN_EVENTOS>>>", re.DOTALL)
57
+
58
+
59
+ # ── AI pipeline ───────────────────────────────────────────────────────────────
60
+ def _build_system_prompt():
61
+ hoy = datetime.date.today()
62
+ hoy_str = hoy.strftime("%d de %B de %Y")
63
+ semana_str = (hoy + datetime.timedelta(days=7)).strftime("%d de %B de %Y")
64
+ p = "Sos EVENTUKI, el asistente de eventos para Buenos Aires.\n"
65
+ p += "Slogan: Si la ciudad no duerme, su agenda tampoco.\n"
66
+ p += "Hablas en espanol rioplatense. Sos entusiasta y porteno.\n\n"
67
+ p += "HOY ES: " + hoy_str + "\nLIMITE SEMANA: " + semana_str + "\n\n"
68
+ p += "COMO LEER LOS RESULTADOS:\n"
69
+ p += "TITULO = nombre del sitio web. NO es el nombre del evento. IGNORALO.\n"
70
+ p += "DESCRIPCION = datos reales del evento: artista, fecha, lugar, precio.\n"
71
+ p += "URL = link de entradas.\n"
72
+ p += "Extraer SIEMPRE nombre del evento desde DESCRIPCION.\n\n"
73
+ p += "FILTRO DE FECHAS:\n"
74
+ p += "- esta semana: hasta " + semana_str + "\n"
75
+ p += "- hoy: solo " + hoy_str + "\n"
76
+ p += "- NUNCA incluir fechas pasadas\n\n"
77
+ p += "ANTI-ALUCINACION: solo eventos de los resultados con URL real.\n\n"
78
+ p += "FORMATO β€” texto intro max 2 lineas, luego AL FINAL:\n\n"
79
+ p += "<<<EVENTOS>>>\n[\n {\n"
80
+ p += ' "nombre": "artista/evento de DESCRIPCION",\n'
81
+ p += ' "categoria": "Recital",\n'
82
+ p += ' "fecha": "14 de mayo 2026",\n'
83
+ p += ' "hora": "19:00 o A confirmar",\n'
84
+ p += ' "venue": "Movistar Arena",\n'
85
+ p += ' "precio": "desde $85.000 o A confirmar",\n'
86
+ p += ' "descripcion": "max 100 chars",\n'
87
+ p += ' "entradas_url": "URL exacta",\n'
88
+ p += ' "direccion": "direccion o A confirmar"\n'
89
+ p += " }\n]\n<<<FIN_EVENTOS>>>"
90
+ return p
91
+
92
+
93
+ async def _search_events(query: str) -> str:
94
+ hoy_str = datetime.date.today().strftime("%B %Y")
95
+ queries = [
96
+ query + " " + hoy_str + " entradas comprar " + SITES_HINT,
97
+ query + " Buenos Aires " + hoy_str + " precio fecha horario",
98
+ ]
99
+ all_results, seen_urls = [], set()
100
+ async with aiohttp.ClientSession() as session:
101
+ for q in queries:
102
+ try:
103
+ async with session.post(
104
+ "https://google.serper.dev/search",
105
+ headers={"X-API-KEY": os.environ.get("SERPER_API_KEY",""), "Content-Type": "application/json"},
106
+ json={"q": q, "gl": "ar", "hl": "es", "num": 8},
107
+ ) as resp:
108
+ data = await resp.json()
109
+ for r in data.get("organic", []):
110
+ url = r.get("link", "")
111
+ if url and url not in seen_urls:
112
+ seen_urls.add(url)
113
+ all_results.append(r)
114
+ except Exception as e:
115
+ logger.warning("Serper error: %s", e)
116
+ if not all_results:
117
+ return "SIN_RESULTADOS"
118
+ lines = [
119
+ "TITULO: " + r.get("title","") + "\nDESCRIPCION: " + r.get("snippet","") + "\nURL: " + r.get("link","")
120
+ for r in all_results
121
+ ]
122
+ return "\n---\n".join(lines)
123
+
124
+
125
+ def _clean(s: str) -> str:
126
+ return s.strip().replace("\r","").replace("\n","") if isinstance(s, str) else s
127
+
128
+
129
+ def _parse_events(raw: str, seen: set) -> tuple:
130
+ m = _RE.search(raw)
131
+ if not m:
132
+ return raw.strip(), []
133
+ clean = _RE.sub("", raw).strip()
134
+ try:
135
+ all_evs = json.loads(m.group(1).strip())
136
+ except json.JSONDecodeError:
137
+ return clean, []
138
+ unique = []
139
+ for ev in all_evs:
140
+ for f in ["nombre","fecha","hora","venue","precio","descripcion","entradas_url","direccion"]:
141
+ if f in ev:
142
+ ev[f] = _clean(ev[f])
143
+ url = ev.get("entradas_url","")
144
+ if not url or not url.startswith("http") or "example" in url.lower():
145
+ continue
146
+ key = ev.get("nombre","") + "|" + ev.get("fecha","")
147
+ if key not in seen:
148
+ seen.add(key)
149
+ unique.append(ev)
150
+ return clean, unique
151
+
152
+
153
+ async def _call_groq(messages: list, model: str) -> str:
154
+ resp = await groq_client.chat.completions.create(
155
+ model=model, messages=messages, max_tokens=1500, temperature=0.2,
156
+ )
157
+ return resp.choices[0].message.content or ""
158
+
159
+
160
+ async def get_events_from_ai(history: list, seen_events: set) -> tuple:
161
+ last_msg = history[-1]["content"] if history else ""
162
+ search_result = await _search_events(last_msg)
163
+ if search_result == "SIN_RESULTADOS":
164
+ return "No encontre eventos verificados. Proba con otra fecha o categoria.", []
165
+ system_prompt = _build_system_prompt()
166
+ messages = [{"role": "system", "content": system_prompt}]
167
+ for msg in history[:-1]:
168
+ messages.append({"role": msg["role"], "content": msg["content"]})
169
+ messages.append({
170
+ "role": "user",
171
+ "content": last_msg + "\n\n=== RESULTADOS DE GOOGLE ===\n" + search_result + "\n=== FIN ===\n\nExtraer nombre de DESCRIPCION. Filtrar por fechas. Solo URL reales.",
172
+ })
173
+ try:
174
+ text = await _call_groq(messages, MODEL)
175
+ except Exception as e:
176
+ if "429" in str(e) or "model" in str(e).lower():
177
+ text = await _call_groq(messages, MODEL_BACKUP)
178
+ else:
179
+ raise
180
+ return _parse_events(text, seen_events)
181
+
182
+
183
+ # ── Web chat API ──────────────────────────────────────────────────────────────
184
+ web_sessions: dict = {}
185
+
186
+
187
+ class ChatRequest(BaseModel):
188
+ session_id: str
189
+ message: str
190
+
191
+
192
+ @app.get("/")
193
+ async def root():
194
+ return {"status": "ok", "bot": "EVENTUKI", "version": "2.0"}
195
+
196
+
197
+ @app.get("/health")
198
+ async def health():
199
+ return {"status": "healthy", "time": datetime.datetime.now().isoformat()}
200
+
201
+
202
+ @app.post("/chat")
203
+ async def chat(req: ChatRequest):
204
+ sid = req.session_id
205
+ if sid not in web_sessions:
206
+ web_sessions[sid] = {"history": [], "seen_events": set()}
207
+ session = web_sessions[sid]
208
+ session["history"].append({"role": "user", "content": req.message})
209
+ try:
210
+ text, events = await get_events_from_ai(session["history"], session["seen_events"])
211
+ session["history"].append({"role": "assistant", "content": text})
212
+ return {"text": text, "events": events}
213
+ except Exception as e:
214
+ logger.error("Error en /chat: %s", e)
215
+ raise HTTPException(status_code=500, detail=str(e))
216
+
217
+
218
+ @app.delete("/chat/{session_id}")
219
+ async def reset_session(session_id: str):
220
+ web_sessions.pop(session_id, None)
221
+ return {"status": "reset"}
222
+
223
+
224
+ # ── Telegram Bot ──────────────────────────────────────────────────────────────
225
+ SELECTING_CATEGORY, SELECTING_DAY, SELECTING_TIME = range(3)
226
+ (ADD_NOMBRE, ADD_CATEGORIA, ADD_FECHA, ADD_HORA,
227
+ ADD_PRECIO, ADD_DIR, ADD_DESC, ADD_LINK, ADD_CONFIRM) = range(10, 19)
228
+
229
+ CATEGORIES = ["Recital","Festival","Feria","Fiesta","Obra de Teatro"]
230
+ ICONS = {"Recital":"🎸","Festival":"πŸŽͺ","Feria":"πŸ›","Fiesta":"πŸŽ‰","Obra de Teatro":"🎭"}
231
+ TIMES = [
232
+ ("πŸŒ… Manana (8-12h)", "manana"),
233
+ ("β˜€οΈ Tarde (12-18h)", "tarde"),
234
+ ("πŸŒ™ Noche (18h en adelante)", "noche"),
235
+ ]
236
+ tg_sessions: dict = {}
237
+ tg_app: Application = None
238
+
239
+
240
+ async def tg_start(update: Update, context) -> int:
241
+ u = update.effective_user
242
+ tg_sessions[u.id] = {"category":None,"day":None,"time":None,"seen_events":set(),"history":[]}
243
+ kb = [[InlineKeyboardButton(ICONS[c]+" "+c, callback_data="cat:"+c)] for c in CATEGORIES]
244
+ await update.message.reply_text(
245
+ "Buenas, <b>"+u.first_name+"</b>! Soy <b>EVENTUKI</b>.\n\n"
246
+ "<i>Si la ciudad no duerme, su agenda tampoco.</i>\n\n"
247
+ "Que tipo de evento estas buscando?",
248
+ parse_mode="HTML", reply_markup=InlineKeyboardMarkup(kb))
249
+ return SELECTING_CATEGORY
250
+
251
+
252
+ async def tg_select_category(update: Update, context) -> int:
253
+ q = update.callback_query; await q.answer()
254
+ cat = q.data.replace("cat:",""); uid = q.from_user.id
255
+ if uid not in tg_sessions:
256
+ tg_sessions[uid] = {"category":None,"day":None,"time":None,"seen_events":set(),"history":[]}
257
+ tg_sessions[uid]["category"] = cat
258
+ await q.edit_message_text(
259
+ ICONS.get(cat,"πŸŽͺ")+" <b>"+cat+"</b>\n\nQue dia pensas salir?\n<i>(hoy Β· manana Β· el sabado Β· 16 de mayo...)</i>",
260
+ parse_mode="HTML")
261
+ return SELECTING_DAY
262
+
263
+
264
+ async def tg_select_day(update: Update, context) -> int:
265
+ uid = update.effective_user.id; day = update.message.text.strip()
266
+ if uid not in tg_sessions:
267
+ await update.message.reply_text("Usa /start."); return ConversationHandler.END
268
+ tg_sessions[uid]["day"] = day
269
+ kb = [[InlineKeyboardButton(lbl, callback_data="time:"+val)] for lbl,val in TIMES]
270
+ await update.message.reply_text(
271
+ "El <b>"+day+"</b>\n\nEn que momento del dia?",
272
+ parse_mode="HTML", reply_markup=InlineKeyboardMarkup(kb))
273
+ return SELECTING_TIME
274
+
275
+
276
+ async def tg_select_time(update: Update, context) -> int:
277
+ q = update.callback_query; await q.answer()
278
+ uid = q.from_user.id
279
+ if uid not in tg_sessions:
280
+ await q.message.reply_text("Usa /start."); return ConversationHandler.END
281
+ tv = q.data.replace("time:","")
282
+ sess = tg_sessions[uid]; sess["time"] = tv
283
+ await q.edit_message_text(
284
+ "Buscando <b>"+sess["category"]+"</b> el <b>"+sess["day"]+"</b> a la <b>"+tv+"</b>...\n"
285
+ "<i>Buscando en Google via Serper...</i>", parse_mode="HTML")
286
+ prompt = "Quiero ir a un "+sess["category"].lower()+" el "+sess["day"]+" a la "+tv+" en Buenos Aires."
287
+ sess["history"].append({"role":"user","content":prompt})
288
+ try:
289
+ txt, evs = await get_events_from_ai(sess["history"], sess["seen_events"])
290
+ sess["history"].append({"role":"assistant","content":txt})
291
+ await q.message.reply_text(txt, parse_mode="HTML")
292
+ for ev in evs:
293
+ sess["seen_events"].add(ev.get("nombre","") + "|" + ev.get("fecha",""))
294
+ await _tg_send_card(q.message, ev)
295
+ if not evs:
296
+ await q.message.reply_text("No encontre eventos exactos.\nUsa /buscar para otra fecha o categoria.")
297
+ except Exception as e:
298
+ await q.message.reply_text("Hubo un problema: "+str(e)+"\nIntenta con /buscar.")
299
+ await q.message.reply_text("/buscar β€” nueva busqueda\n/agregar β€” publicar tu evento")
300
+ return ConversationHandler.END
301
+
302
+
303
+ def _make_calendar_url(ev: dict) -> str:
304
+ from urllib.parse import urlencode
305
+ h = (ev.get("hora") or "20:00").replace(":","").ljust(6,"0")[:6]
306
+ end = str((int(h[:2])+2)%24).zfill(2)+h[2:4]+"00"
307
+ return "https://calendar.google.com/calendar/render?" + urlencode({
308
+ "action":"TEMPLATE","text":ev.get("nombre","Evento EVENTUKI"),
309
+ "dates":"20260517T"+h+"/20260517T"+end,
310
+ "details":ev.get("descripcion",""),
311
+ "location":ev.get("direccion","") or ev.get("venue",""),
312
+ })
313
+
314
+
315
+ async def _tg_send_card(msg, ev: dict) -> None:
316
+ icon = ICONS.get((ev.get("categoria","")).lower() if ev.get("categoria") else "", "πŸŽͺ")
317
+ nombre = ev.get("nombre","A confirmar")
318
+ venue = ev.get("venue", ev.get("direccion","A confirmar"))
319
+ fecha = ev.get("fecha","A confirmar")
320
+ hora = ev.get("hora","")
321
+ precio = ev.get("precio","A confirmar")
322
+ url = ev.get("entradas_url","")
323
+ text = (
324
+ icon+" <b>"+nombre+"</b>\n"
325
+ "─────────────────────────\n"
326
+ "🎯 <b>Que:</b> "+nombre+"\n"
327
+ "πŸ“ <b>Donde:</b> "+venue+"\n"
328
+ "πŸ“… <b>Cuando:</b> "+(fecha+" "+hora).strip()+"\n"
329
+ "πŸ’° <b>Cuanto:</b> "+precio+"\n"
330
+ "πŸ”— <b>URL:</b> "+url
331
+ )
332
+ btns = []
333
+ if url:
334
+ btns.append(InlineKeyboardButton("🎟 Comprar entradas", url=url))
335
+ btns.append(InlineKeyboardButton("πŸ“… Google Calendar", url=_make_calendar_url(ev)))
336
+ await msg.reply_text(text, parse_mode="HTML",
337
+ reply_markup=InlineKeyboardMarkup([btns]), disable_web_page_preview=True)
338
+
339
+
340
+ async def tg_cancel(update: Update, context) -> int:
341
+ await update.message.reply_text("Cancelado. Usa /start para empezar.")
342
+ return ConversationHandler.END
343
+
344
+
345
+ async def tg_ayuda(update: Update, context) -> None:
346
+ await update.message.reply_text(
347
+ "<b>Comandos EVENTUKI</b>\n\n/start β€” buscar\n/buscar β€” nueva busqueda\n/ayuda β€” ayuda\n/cancel β€” cancelar",
348
+ parse_mode="HTML")
349
+
350
+
351
+ def _build_tg_app() -> Application:
352
+ token = os.environ.get("TELEGRAM_BOT_TOKEN","")
353
+ if not token:
354
+ return None
355
+ application = Application.builder().token(token).updater(None).build()
356
+ conv = ConversationHandler(
357
+ entry_points=[CommandHandler("start",tg_start), CommandHandler("buscar",tg_start)],
358
+ states={
359
+ SELECTING_CATEGORY:[CallbackQueryHandler(tg_select_category, pattern=r"^cat:")],
360
+ SELECTING_DAY: [MessageHandler(filters.TEXT & ~filters.COMMAND, tg_select_day)],
361
+ SELECTING_TIME: [CallbackQueryHandler(tg_select_time, pattern=r"^time:")],
362
+ },
363
+ fallbacks=[CommandHandler("cancel",tg_cancel)],
364
+ )
365
+ application.add_handler(conv)
366
+ application.add_handler(CommandHandler("ayuda",tg_ayuda))
367
+ return application
368
+
369
+
370
+ # ── Startup / Shutdown ────────────────────────────────────────────────────────
371
+ @app.on_event("startup")
372
+ async def startup():
373
+ global tg_app
374
+ tg_app = _build_tg_app()
375
+ if tg_app:
376
+ await tg_app.initialize()
377
+ await tg_app.start()
378
+ webhook_url = os.environ.get("WEBHOOK_URL","")
379
+ if webhook_url:
380
+ await tg_app.bot.set_webhook(
381
+ url=webhook_url.rstrip("/") + "/telegram/webhook",
382
+ allowed_updates=["message","callback_query"],
383
+ )
384
+ logger.info("Webhook de Telegram registrado: %s", webhook_url)
385
+ else:
386
+ logger.warning("WEBHOOK_URL no configurada β€” Telegram deshabilitado")
387
+ logger.info("EVENTUKI API lista")
388
+
389
+
390
+ @app.on_event("shutdown")
391
+ async def shutdown():
392
+ if tg_app:
393
+ await tg_app.stop()
394
+ await tg_app.shutdown()
395
+
396
+
397
+ # ── Telegram webhook endpoint ─────────────────────────────────────────────────
398
+ @app.post("/telegram/webhook")
399
+ async def telegram_webhook(request: Request):
400
+ if not tg_app:
401
+ return JSONResponse({"ok": False, "error": "Bot no configurado"})
402
+ try:
403
+ data = await request.json()
404
+ update = Update.de_json(data, tg_app.bot)
405
+ await tg_app.process_update(update)
406
+ return {"ok": True}
407
+ except Exception as e:
408
+ logger.error("Error en webhook: %s", e)
409
+ return JSONResponse({"ok": False, "error": str(e)}, status_code=200)
index.html ADDED
@@ -0,0 +1,586 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>EVENTUKI β€” Eventos en Buenos Aires</title>
7
+ <style>
8
+ :root {
9
+ --pink: #E8175D;
10
+ --pink2: #B01048;
11
+ --gold: #FFD535;
12
+ --purple: #7B2FBE;
13
+ --dark: #0A0A14;
14
+ --dark2: #13131F;
15
+ --dark3: #1A1A2E;
16
+ --text: #F0EDF8;
17
+ --text2: rgba(240,237,248,.55);
18
+ --border: rgba(232,23,93,.2);
19
+ --radius: 16px;
20
+ }
21
+
22
+ * { box-sizing: border-box; margin: 0; padding: 0; }
23
+
24
+ body {
25
+ font-family: 'Segoe UI', system-ui, sans-serif;
26
+ background: var(--dark);
27
+ color: var(--text);
28
+ height: 100dvh;
29
+ display: flex;
30
+ flex-direction: column;
31
+ overflow: hidden;
32
+ }
33
+
34
+ /* ── Header ── */
35
+ header {
36
+ display: flex;
37
+ align-items: center;
38
+ gap: 12px;
39
+ padding: 14px 20px;
40
+ background: rgba(10,10,20,.9);
41
+ border-bottom: 1px solid var(--border);
42
+ backdrop-filter: blur(20px);
43
+ flex-shrink: 0;
44
+ z-index: 10;
45
+ }
46
+
47
+ .logo {
48
+ width: 42px; height: 42px;
49
+ border-radius: 13px;
50
+ background: linear-gradient(135deg, var(--pink), var(--purple));
51
+ display: flex; align-items: center; justify-content: center;
52
+ font-size: 22px;
53
+ box-shadow: 0 0 18px rgba(232,23,93,.4);
54
+ flex-shrink: 0;
55
+ }
56
+
57
+ .header-text h1 {
58
+ font-size: 20px; font-weight: 800; letter-spacing: -.5px;
59
+ }
60
+
61
+ .header-text h1 span { color: var(--pink); }
62
+
63
+ .header-text p {
64
+ font-size: 11px; color: var(--text2); font-style: italic; margin-top: 1px;
65
+ }
66
+
67
+ .header-right {
68
+ margin-left: auto;
69
+ display: flex; gap: 8px;
70
+ }
71
+
72
+ .btn-tg {
73
+ display: flex; align-items: center; gap: 6px;
74
+ padding: 7px 14px;
75
+ border-radius: 20px;
76
+ border: 1px solid rgba(41,182,246,.3);
77
+ background: rgba(41,182,246,.1);
78
+ color: #29B6F6;
79
+ font-size: 13px; font-weight: 600;
80
+ text-decoration: none;
81
+ transition: all .2s;
82
+ }
83
+ .btn-tg:hover { background: rgba(41,182,246,.2); }
84
+
85
+ .btn-new {
86
+ display: flex; align-items: center; gap: 6px;
87
+ padding: 7px 14px;
88
+ border-radius: 20px;
89
+ border: 1px solid var(--border);
90
+ background: transparent;
91
+ color: var(--text2);
92
+ font-size: 13px; font-weight: 600;
93
+ cursor: pointer;
94
+ transition: all .2s;
95
+ }
96
+ .btn-new:hover { border-color: var(--pink); color: var(--pink); }
97
+
98
+ /* ── Messages ── */
99
+ #messages {
100
+ flex: 1;
101
+ overflow-y: auto;
102
+ padding: 20px 16px;
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: 12px;
106
+ max-width: 780px;
107
+ width: 100%;
108
+ margin: 0 auto;
109
+ }
110
+
111
+ #messages::-webkit-scrollbar { width: 4px; }
112
+ #messages::-webkit-scrollbar-track { background: transparent; }
113
+ #messages::-webkit-scrollbar-thumb { background: rgba(232,23,93,.3); border-radius: 2px; }
114
+
115
+ .msg-row {
116
+ display: flex;
117
+ align-items: flex-start;
118
+ gap: 8px;
119
+ animation: fadeUp .25s ease;
120
+ }
121
+
122
+ .msg-row.user { justify-content: flex-end; }
123
+
124
+ @keyframes fadeUp {
125
+ from { opacity: 0; transform: translateY(8px); }
126
+ to { opacity: 1; transform: translateY(0); }
127
+ }
128
+
129
+ .avatar {
130
+ width: 32px; height: 32px; border-radius: 10px; flex-shrink: 0;
131
+ background: linear-gradient(135deg, var(--pink), var(--purple));
132
+ display: flex; align-items: center; justify-content: center;
133
+ font-size: 16px; margin-top: 2px;
134
+ box-shadow: 0 0 12px rgba(232,23,93,.3);
135
+ }
136
+
137
+ .bubble-wrap { max-width: 78%; display: flex; flex-direction: column; gap: 8px; }
138
+
139
+ .bubble {
140
+ padding: 11px 15px;
141
+ border-radius: 4px 16px 16px 16px;
142
+ font-size: 14px; line-height: 1.6;
143
+ background: rgba(255,255,255,.06);
144
+ border: 1px solid rgba(255,255,255,.09);
145
+ color: var(--text);
146
+ }
147
+
148
+ .msg-row.user .bubble {
149
+ background: linear-gradient(135deg, var(--pink), var(--pink2));
150
+ border: none;
151
+ border-radius: 16px 16px 4px 16px;
152
+ color: #fff;
153
+ }
154
+
155
+ /* ── Event cards ── */
156
+ .event-card {
157
+ background: rgba(255,255,255,.04);
158
+ border: 1px solid rgba(232,23,93,.22);
159
+ border-radius: var(--radius);
160
+ padding: 14px 16px;
161
+ transition: border-color .2s, transform .2s;
162
+ }
163
+ .event-card:hover { border-color: rgba(232,23,93,.45); transform: translateY(-2px); }
164
+
165
+ .card-title {
166
+ font-size: 15px; font-weight: 700; margin-bottom: 10px;
167
+ display: flex; align-items: flex-start; justify-content: space-between; gap: 8px;
168
+ }
169
+
170
+ .cat-badge {
171
+ font-size: 11px; font-weight: 700; padding: 2px 9px;
172
+ border-radius: 6px; background: rgba(123,47,190,.3); color: #C084FC;
173
+ white-space: nowrap; flex-shrink: 0;
174
+ }
175
+
176
+ .card-grid {
177
+ display: grid;
178
+ grid-template-columns: 1fr 1fr;
179
+ gap: 6px 10px;
180
+ font-size: 13px;
181
+ color: var(--text2);
182
+ margin-bottom: 10px;
183
+ }
184
+
185
+ .card-grid span { color: var(--text); font-weight: 500; }
186
+
187
+ .free-badge {
188
+ display: inline-block; padding: 2px 9px; border-radius: 6px;
189
+ font-size: 11px; font-weight: 700;
190
+ background: rgba(0,200,81,.15); color: #00C851;
191
+ }
192
+
193
+ .price-badge {
194
+ display: inline-block; padding: 2px 9px; border-radius: 6px;
195
+ font-size: 11px; font-weight: 700;
196
+ background: rgba(255,213,53,.12); color: var(--gold);
197
+ }
198
+
199
+ .card-desc {
200
+ font-size: 12px; color: var(--text2); line-height: 1.5;
201
+ margin-bottom: 10px;
202
+ }
203
+
204
+ .card-actions { display: flex; gap: 8px; flex-wrap: wrap; }
205
+
206
+ .btn-ticket {
207
+ display: inline-flex; align-items: center; gap: 5px;
208
+ padding: 7px 14px; border-radius: 20px; border: none; cursor: pointer;
209
+ font-size: 12px; font-weight: 700;
210
+ background: linear-gradient(135deg, var(--pink), var(--pink2));
211
+ color: #fff; text-decoration: none;
212
+ transition: all .2s;
213
+ }
214
+ .btn-ticket:hover { box-shadow: 0 0 16px rgba(232,23,93,.5); transform: scale(1.03); }
215
+
216
+ .btn-cal {
217
+ display: inline-flex; align-items: center; gap: 5px;
218
+ padding: 7px 14px; border-radius: 20px;
219
+ border: 1px solid rgba(0,200,81,.35); cursor: pointer;
220
+ font-size: 12px; font-weight: 700;
221
+ background: rgba(0,200,81,.1); color: #00C851;
222
+ text-decoration: none; transition: all .2s;
223
+ }
224
+ .btn-cal:hover { background: rgba(0,200,81,.2); }
225
+
226
+ /* ── Quick buttons ── */
227
+ .quick-btns {
228
+ display: flex; flex-wrap: wrap; gap: 8px;
229
+ padding: 0 16px 8px;
230
+ max-width: 780px; width: 100%; margin: 0 auto;
231
+ }
232
+
233
+ .quick-btn {
234
+ padding: 7px 14px; border-radius: 20px;
235
+ border: 1px solid var(--border);
236
+ background: rgba(232,23,93,.08);
237
+ color: var(--text2); font-size: 13px; cursor: pointer;
238
+ transition: all .2s;
239
+ }
240
+ .quick-btn:hover { border-color: var(--pink); color: var(--text); background: rgba(232,23,93,.15); }
241
+
242
+ /* ── Input ── */
243
+ .input-area {
244
+ padding: 12px 16px 16px;
245
+ border-top: 1px solid var(--border);
246
+ background: rgba(10,10,20,.85);
247
+ backdrop-filter: blur(20px);
248
+ flex-shrink: 0;
249
+ max-width: 780px; width: 100%; margin: 0 auto;
250
+ }
251
+
252
+ .input-row {
253
+ display: flex; gap: 10px;
254
+ }
255
+
256
+ #chat-input {
257
+ flex: 1;
258
+ background: rgba(255,255,255,.06);
259
+ border: 1px solid rgba(232,23,93,.3);
260
+ border-radius: 24px;
261
+ color: var(--text);
262
+ padding: 11px 18px;
263
+ font-size: 14px;
264
+ outline: none;
265
+ transition: border-color .2s;
266
+ }
267
+ #chat-input:focus { border-color: var(--pink); }
268
+ #chat-input::placeholder { color: var(--text2); }
269
+
270
+ #send-btn {
271
+ padding: 11px 22px; border-radius: 24px; border: none; cursor: pointer;
272
+ background: linear-gradient(135deg, var(--pink), var(--pink2));
273
+ color: #fff; font-size: 14px; font-weight: 700;
274
+ transition: all .2s; flex-shrink: 0;
275
+ }
276
+ #send-btn:hover:not(:disabled) { box-shadow: 0 0 20px rgba(232,23,93,.45); transform: scale(1.03); }
277
+ #send-btn:disabled { opacity: .5; cursor: not-allowed; }
278
+
279
+ .input-hint {
280
+ text-align: center; font-size: 11px; color: var(--text2); margin-top: 8px;
281
+ }
282
+
283
+ /* ── Typing indicator ── */
284
+ .typing { display: flex; gap: 4px; align-items: center; padding: 12px 16px; }
285
+ .dot {
286
+ width: 7px; height: 7px; border-radius: 50%; background: var(--pink);
287
+ animation: bounce 1.2s infinite;
288
+ }
289
+ .dot:nth-child(2) { animation-delay: .2s; }
290
+ .dot:nth-child(3) { animation-delay: .4s; }
291
+ @keyframes bounce {
292
+ 0%,80%,100% { opacity:.3; transform:scale(.8); }
293
+ 40% { opacity:1; transform:scale(1); }
294
+ }
295
+
296
+ /* ── Config notice ── */
297
+ #config-notice {
298
+ position: fixed; inset: 0; background: rgba(10,10,20,.95);
299
+ display: flex; align-items: center; justify-content: center;
300
+ z-index: 100; padding: 20px;
301
+ }
302
+ .config-box {
303
+ background: var(--dark2); border: 1px solid var(--border);
304
+ border-radius: 20px; padding: 30px; max-width: 480px; width: 100%;
305
+ text-align: center;
306
+ }
307
+ .config-box h2 { font-size: 22px; margin-bottom: 10px; }
308
+ .config-box h2 span { color: var(--pink); }
309
+ .config-box p { font-size: 14px; color: var(--text2); line-height: 1.6; margin-bottom: 20px; }
310
+ .config-box input {
311
+ width: 100%;
312
+ background: rgba(255,255,255,.06);
313
+ border: 1px solid var(--border);
314
+ border-radius: 12px;
315
+ color: var(--text); padding: 10px 14px;
316
+ font-size: 14px; outline: none; margin-bottom: 14px;
317
+ transition: border-color .2s;
318
+ }
319
+ .config-box input:focus { border-color: var(--pink); }
320
+ .config-box button {
321
+ width: 100%; padding: 12px; border-radius: 12px; border: none; cursor: pointer;
322
+ background: linear-gradient(135deg, var(--pink), var(--pink2));
323
+ color: #fff; font-size: 15px; font-weight: 700;
324
+ transition: all .2s;
325
+ }
326
+ .config-box button:hover { box-shadow: 0 0 20px rgba(232,23,93,.4); }
327
+ .config-box .hint { font-size: 12px; color: var(--text2); margin-top: 12px; }
328
+
329
+ @media (max-width: 600px) {
330
+ header { padding: 10px 14px; }
331
+ .header-text h1 { font-size: 17px; }
332
+ .btn-tg span { display: none; }
333
+ #messages { padding: 14px 12px; }
334
+ .bubble-wrap { max-width: 88%; }
335
+ .card-grid { grid-template-columns: 1fr; }
336
+ }
337
+ </style>
338
+ </head>
339
+ <body>
340
+
341
+ <!-- Config modal (shown if API URL not set) -->
342
+ <div id="config-notice">
343
+ <div class="config-box">
344
+ <div style="font-size:40px;margin-bottom:12px;">πŸŽͺ</div>
345
+ <h2>EVENT<span>UKI</span></h2>
346
+ <p>IngresΓ‘ la URL de tu Space de Hugging Face para conectar el asistente.</p>
347
+ <input id="api-url-input" type="text" placeholder="https://tunombre-eventuki-api.hf.space"/>
348
+ <button onclick="saveApiUrl()">Conectar</button>
349
+ <p class="hint">EncontrΓ‘s la URL en tu Space de Hugging Face despuΓ©s de deployar.</p>
350
+ </div>
351
+ </div>
352
+
353
+ <!-- Header -->
354
+ <header>
355
+ <div class="logo">πŸŽͺ</div>
356
+ <div class="header-text">
357
+ <h1>EVENT<span>UKI</span></h1>
358
+ <p>Si la ciudad no duerme, su agenda tampoco.</p>
359
+ </div>
360
+ <div class="header-right">
361
+ <a href="https://t.me/EventukiBot" target="_blank" class="btn-tg">
362
+ ✈️ <span>Abrir en Telegram</span>
363
+ </a>
364
+ <button class="btn-new" onclick="newChat()">β†Ί Nueva bΓΊsqueda</button>
365
+ </div>
366
+ </header>
367
+
368
+ <!-- Messages -->
369
+ <div id="messages"></div>
370
+
371
+ <!-- Quick buttons -->
372
+ <div class="quick-btns" id="quick-btns">
373
+ <button class="quick-btn" onclick="quickSend('Recitales esta semana a la noche')">🎸 Recitales esta semana</button>
374
+ <button class="quick-btn" onclick="quickSend('Festivales este fin de semana')">πŸŽͺ Festivales este finde</button>
375
+ <button class="quick-btn" onclick="quickSend('Ferias gratuitas este domingo')">πŸ› Ferias gratuitas</button>
376
+ <button class="quick-btn" onclick="quickSend('Obras de teatro este sabado')">🎭 Teatro este sÑbado</button>
377
+ <button class="quick-btn" onclick="quickSend('Fiestas y milongas esta noche')">πŸŽ‰ Fiestas esta noche</button>
378
+ </div>
379
+
380
+ <!-- Input -->
381
+ <div class="input-area">
382
+ <div class="input-row">
383
+ <input id="chat-input" type="text"
384
+ placeholder="Ej: recitales esta semana a la noche..."
385
+ onkeydown="if(event.key==='Enter')send()"/>
386
+ <button id="send-btn" onclick="send()">Enviar</button>
387
+ </div>
388
+ <div class="input-hint">Powered by Groq + Google Search Β· <span id="api-indicator">conectando...</span></div>
389
+ </div>
390
+
391
+ <script>
392
+ // ── Config ────────────────────────────────────────────────────────────────────
393
+ const SESSION_ID = 'sess_' + Math.random().toString(36).slice(2);
394
+ let API_URL = localStorage.getItem('eventuki_api_url') || '';
395
+
396
+ function saveApiUrl() {
397
+ const val = document.getElementById('api-url-input').value.trim().replace(/\/$/, '');
398
+ if (!val.startsWith('http')) { alert('IngresΓ‘ una URL vΓ‘lida (https://...)'); return; }
399
+ localStorage.setItem('eventuki_api_url', val);
400
+ API_URL = val;
401
+ document.getElementById('config-notice').style.display = 'none';
402
+ document.getElementById('api-indicator').textContent = new URL(val).hostname;
403
+ showWelcome();
404
+ }
405
+
406
+ // Si ya hay URL guardada, ocultar modal
407
+ if (API_URL) {
408
+ document.getElementById('config-notice').style.display = 'none';
409
+ document.getElementById('api-indicator').textContent = new URL(API_URL).hostname;
410
+ }
411
+
412
+ // ── Calendar URL ──────────────────────────────────────────────────────────────
413
+ function calUrl(ev) {
414
+ const h = (ev.hora || '20:00').replace(':','').padEnd(6,'0').slice(0,6);
415
+ const hh = String((parseInt(h.slice(0,2))+2)%24).padStart(2,'0');
416
+ const end = hh + h.slice(2,4) + '00';
417
+ const p = new URLSearchParams({
418
+ action: 'TEMPLATE', text: ev.nombre || 'Evento EVENTUKI',
419
+ dates: `20260517T${h}/20260517T${end}`,
420
+ details: [ev.descripcion, ev.entradas_url ? 'Entradas: '+ev.entradas_url : '', ev.precio ? 'Precio: '+ev.precio : ''].filter(Boolean).join('\n\n'),
421
+ location: ev.direccion || ev.venue || '',
422
+ });
423
+ return 'https://calendar.google.com/calendar/render?' + p;
424
+ }
425
+
426
+ // ── Render helpers ────────────────────────────────────────────────────────────
427
+ function isFree(precio) {
428
+ return /gratu|gratis|free/i.test(precio || '');
429
+ }
430
+
431
+ function renderCard(ev) {
432
+ const nombre = ev.nombre || 'Evento';
433
+ const venue = ev.venue || ev.direccion || 'A confirmar';
434
+ const fecha = ev.fecha || 'A confirmar';
435
+ const hora = ev.hora || '';
436
+ const precio = ev.precio || 'A confirmar';
437
+ const url = ev.entradas_url || '';
438
+ const cat = ev.categoria || '';
439
+ const desc = ev.descripcion || '';
440
+ const gratis = isFree(precio);
441
+
442
+ const priceBadge = gratis
443
+ ? '<span class="free-badge">✨ GRATIS</span>'
444
+ : `<span class="price-badge">πŸ’° ${precio}</span>`;
445
+
446
+ const ticketBtn = url
447
+ ? `<a href="${url}" target="_blank" rel="noopener" class="btn-ticket">🎟 Comprar entradas</a>`
448
+ : '';
449
+
450
+ return `
451
+ <div class="event-card">
452
+ <div class="card-title">
453
+ <span>${nombre}</span>
454
+ <span class="cat-badge">${cat}</span>
455
+ </div>
456
+ <div class="card-grid">
457
+ <div>🎯 <span>Qué:</span> ${nombre}</div>
458
+ <div>πŸ“ <span>DΓ³nde:</span> ${venue}</div>
459
+ <div>πŸ“… <span>CuΓ‘ndo:</span> ${(fecha + ' ' + hora).trim()}</div>
460
+ <div>${priceBadge}</div>
461
+ </div>
462
+ ${desc ? `<div class="card-desc">${desc}</div>` : ''}
463
+ <div class="card-actions">
464
+ ${ticketBtn}
465
+ <a href="${calUrl(ev)}" target="_blank" rel="noopener" class="btn-cal">πŸ“… Google Calendar</a>
466
+ </div>
467
+ </div>`;
468
+ }
469
+
470
+ function renderBubble(text, role) {
471
+ const div = document.createElement('div');
472
+ div.className = 'msg-row ' + role;
473
+
474
+ const avatarHtml = role === 'bot'
475
+ ? '<div class="avatar">πŸŽͺ</div>' : '';
476
+
477
+ // Convert **bold** and *italic* to HTML
478
+ const html = text
479
+ .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
480
+ .replace(/\*(.*?)\*/g, '<em>$1</em>')
481
+ .replace(/\n/g, '<br>');
482
+
483
+ div.innerHTML = avatarHtml + `
484
+ <div class="bubble-wrap">
485
+ <div class="bubble">${html}</div>
486
+ </div>`;
487
+ return div;
488
+ }
489
+
490
+ function addMessage(text, role, events) {
491
+ const msgs = document.getElementById('messages');
492
+
493
+ const row = renderBubble(text, role);
494
+ msgs.appendChild(row);
495
+
496
+ if (events && events.length > 0) {
497
+ const wrap = document.createElement('div');
498
+ wrap.className = 'msg-row bot';
499
+ wrap.innerHTML = '<div class="avatar">πŸŽͺ</div><div class="bubble-wrap">' +
500
+ events.map(renderCard).join('') + '</div>';
501
+ msgs.appendChild(wrap);
502
+ }
503
+
504
+ msgs.scrollTop = msgs.scrollHeight;
505
+ }
506
+
507
+ function showTyping() {
508
+ const msgs = document.getElementById('messages');
509
+ const row = document.createElement('div');
510
+ row.className = 'msg-row bot'; row.id = 'typing-row';
511
+ row.innerHTML = '<div class="avatar">πŸŽͺ</div><div class="bubble-wrap"><div class="bubble"><div class="typing"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div></div></div>';
512
+ msgs.appendChild(row);
513
+ msgs.scrollTop = msgs.scrollHeight;
514
+ }
515
+
516
+ function removeTyping() {
517
+ document.getElementById('typing-row')?.remove();
518
+ }
519
+
520
+ // ── API call ──────────────────────────────────────────────────────────────────
521
+ async function callAPI(message) {
522
+ const resp = await fetch(API_URL + '/chat', {
523
+ method: 'POST',
524
+ headers: {'Content-Type': 'application/json'},
525
+ body: JSON.stringify({session_id: SESSION_ID, message}),
526
+ });
527
+ if (!resp.ok) throw new Error('Error ' + resp.status);
528
+ return resp.json();
529
+ }
530
+
531
+ // ── Send ──────────────────────────────────────────────────────────────────────
532
+ async function send() {
533
+ const inp = document.getElementById('chat-input');
534
+ const btn = document.getElementById('send-btn');
535
+ const text = inp.value.trim();
536
+ if (!text || btn.disabled) return;
537
+ if (!API_URL) { document.getElementById('config-notice').style.display = 'flex'; return; }
538
+
539
+ addMessage(text, 'user', []);
540
+ inp.value = ''; inp.disabled = true; btn.disabled = true;
541
+ document.getElementById('quick-btns').style.display = 'none';
542
+ showTyping();
543
+
544
+ try {
545
+ const data = await callAPI(text);
546
+ removeTyping();
547
+ addMessage(data.text, 'bot', data.events || []);
548
+ } catch (e) {
549
+ removeTyping();
550
+ addMessage('Uy, hubo un problema conectando con el servidor. IntentΓ‘ de nuevo en un momento. πŸ˜“', 'bot', []);
551
+ console.error(e);
552
+ }
553
+
554
+ inp.disabled = false; btn.disabled = false; inp.focus();
555
+ }
556
+
557
+ function quickSend(text) {
558
+ document.getElementById('chat-input').value = text;
559
+ send();
560
+ }
561
+
562
+ // ── New chat ──────────────────────────────────────────────────────────────────
563
+ async function newChat() {
564
+ if (API_URL) {
565
+ try { await fetch(API_URL + '/chat/' + SESSION_ID, {method:'DELETE'}); } catch(e){}
566
+ }
567
+ document.getElementById('messages').innerHTML = '';
568
+ document.getElementById('quick-btns').style.display = 'flex';
569
+ showWelcome();
570
+ }
571
+
572
+ // ── Welcome message ───────────────────────────────────────────────────────────
573
+ function showWelcome() {
574
+ addMessage(
575
+ 'Buenas! Soy EVENTUKI, tu guΓ­a de eventos en Buenos Aires. πŸŽ‰\n\n' +
576
+ '"Si la ciudad no duerme, su agenda tampoco."\n\n' +
577
+ 'Contame quΓ© buscΓ‘s: quΓ© tipo de evento, quΓ© dΓ­a y a quΓ© hora.',
578
+ 'bot', []
579
+ );
580
+ }
581
+
582
+ // ── Init ──────────────────────────────────────────────────────────────────────
583
+ if (API_URL) showWelcome();
584
+ </script>
585
+ </body>
586
+ </html>
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn==0.29.0
3
+ python-telegram-bot==21.6
4
+ groq>=0.9.0
5
+ aiohttp>=3.9.0
6
+ pydantic>=2.0.0