Download src/agentcore/libcal.py from nikeshn/LibBee: direct link, hf CLI and curl.
- Browser
- Download file 19.8 kB
-
https://huggingface.co/spaces/nikeshn/LibBee/resolve/main/src/agentcore/libcal.py
- Command line
-
hf download hf://spaces/nikeshn/LibBee/src/agentcore/libcal.py
-
curl -L -o libcal.py https://huggingface.co/spaces/nikeshn/LibBee/resolve/main/src/agentcore/libcal.py
19.8 kB
| """LibCal client + formatting for live opening hours and events. | |
| Rendering emits class names only (lb-hours-*, lb-event*); see rendering.py for | |
| the styling contract. | |
| """ | |
| import asyncio | |
| import html as _html | |
| import json | |
| import logging | |
| import re | |
| import time | |
| from datetime import datetime, timezone, timedelta | |
| from typing import Any, Dict, Optional | |
| from urllib.parse import urlsplit | |
| import httpx | |
| from src.config import get_settings, LIBBEE_VERSION | |
| from src.agentcore.constants import LIBRARY_HOURS_URL | |
| from src.agentcore.utils import _escape | |
| from src.services.http_client import make_client | |
| logger = logging.getLogger(__name__) | |
| _libcal_hours_cache: Dict[str, Any] = {} | |
| _LIBCAL_HOURS_TTL = 60 * 60 | |
| _LIBCAL_BASE = "https://kustar.libcal.com" | |
| _LIBCAL_LOC_MAIN = 6386 | |
| _LIBCAL_LOC_HABSHAN = 25028 | |
| _LIBCAL_IID = 3495 | |
| _LIBCAL_EVENTS_CAL = 8398 | |
| _UAE_TIMEZONE = timezone(timedelta(hours=4)) | |
| _EVENTS_URL = "https://kustar.libcal.com/calendar/events" | |
| _libcal_tokens: Dict[str, Dict[str, Any]] = { | |
| "hours": {"value": "", "expires_at": 0.0}, | |
| "events": {"value": "", "expires_at": 0.0}, | |
| } | |
| _libcal_token_lock = asyncio.Lock() | |
| async def _libcal_access_token(client: httpx.AsyncClient, events: bool = False) -> str: | |
| """Fetch and briefly cache a LibCal OAuth token without logging secrets.""" | |
| settings = get_settings() | |
| main_client_id = str(settings.libcal_client_id or "").strip() | |
| main_client_secret = str(settings.libcal_client_secret or "").strip() | |
| events_client_id = str(settings.libcal_events_client_id or "").strip() | |
| events_client_secret = str(settings.libcal_events_client_secret or "").strip() | |
| if events and bool(events_client_id) != bool(events_client_secret): | |
| raise RuntimeError( | |
| "LIBCAL_EVENTS_CLIENT_ID and LIBCAL_EVENTS_CLIENT_SECRET must be configured together" | |
| ) | |
| if events and events_client_id and events_client_secret: | |
| client_id = events_client_id | |
| client_secret = events_client_secret | |
| cache_key = ( | |
| "hours" | |
| if (events_client_id, events_client_secret) | |
| == (main_client_id, main_client_secret) | |
| else "events" | |
| ) | |
| else: | |
| client_id = main_client_id | |
| client_secret = main_client_secret | |
| cache_key = "hours" | |
| if not client_id or not client_secret: | |
| raise RuntimeError("LibCal credentials are not configured in the HF Space") | |
| cached = _libcal_tokens[cache_key] | |
| now = time.time() | |
| if cached["value"] and now < cached["expires_at"]: | |
| return str(cached["value"]) | |
| async with _libcal_token_lock: | |
| cached = _libcal_tokens[cache_key] | |
| now = time.time() | |
| if cached["value"] and now < cached["expires_at"]: | |
| return str(cached["value"]) | |
| response = await client.post( | |
| f"{_LIBCAL_BASE}/api/1.1/oauth/token", | |
| data={"client_id": client_id, "client_secret": client_secret, "grant_type": "client_credentials"}, | |
| headers={"Content-Type": "application/x-www-form-urlencoded"}, | |
| ) | |
| response.raise_for_status() | |
| data = response.json() | |
| token = str(data.get("access_token") or "") | |
| if not token: | |
| raise RuntimeError("LibCal authentication returned no access token") | |
| cached["value"] = token | |
| cached["expires_at"] = now + max(5, int(data.get("expires_in") or 3600) - 60) | |
| return token | |
| async def _libcal_fetch_hours() -> Optional[dict]: | |
| now = time.time() | |
| uae_now = datetime.now(_UAE_TIMEZONE) | |
| date_from = uae_now.strftime("%Y-%m-%d") | |
| cached_at = _libcal_hours_cache.get("cached_at", 0) | |
| if (_libcal_hours_cache.get("data") and now - cached_at < _LIBCAL_HOURS_TTL | |
| and _libcal_hours_cache.get("date_from") == date_from): | |
| return _libcal_hours_cache["data"] | |
| date_to = (uae_now + timedelta(days=6)).strftime("%Y-%m-%d") | |
| try: | |
| async with make_client(timeout=12) as client: | |
| token = await _libcal_access_token(client) | |
| async def fetch_location(location_id: int) -> dict: | |
| response = await client.get( | |
| f"{_LIBCAL_BASE}/api/1.1/hours/{location_id}", | |
| params={"from": date_from, "to": date_to}, | |
| headers={"Authorization": f"Bearer {token}"}, | |
| ) | |
| response.raise_for_status() | |
| value = response.json() | |
| value = value[0] if isinstance(value, list) and value else value | |
| if not isinstance(value, dict) or not isinstance(value.get("dates"), dict): | |
| raise ValueError("LibCal hours response has no date schedule") | |
| return value | |
| values = await asyncio.gather( | |
| fetch_location(_LIBCAL_LOC_MAIN), | |
| fetch_location(_LIBCAL_LOC_HABSHAN), | |
| return_exceptions=True, | |
| ) | |
| data = {} | |
| for location, value in zip(("main_campus", "habshan"), values): | |
| if isinstance(value, dict): | |
| data[location] = value | |
| else: | |
| logger.warning("LibCal hours unavailable for %s (%s)", location, type(value).__name__) | |
| if not data: | |
| return None | |
| _libcal_hours_cache["data"] = data | |
| _libcal_hours_cache["cached_at"] = now | |
| _libcal_hours_cache["date_from"] = date_from | |
| logger.info("LibCal: hours refreshed directly") | |
| return data | |
| except Exception as exc: | |
| logger.warning("LibCal hours fetch failed: %s", exc) | |
| return None | |
| def _parse_libcal_time(raw: str) -> str: | |
| if not raw: | |
| return raw | |
| raw = str(raw).strip().replace("β", "-").replace("β", "-") | |
| raw = re.sub(r"\s+", "", raw) | |
| raw = re.sub(r"(?i)(am|pm)$", lambda m: " " + m.group(1).upper(), raw) | |
| return raw | |
| def _format_libcal_day(day_data: dict) -> str: | |
| if not isinstance(day_data, dict) or not day_data: | |
| return "See hours page" | |
| status = str(day_data.get("status", "")).lower().strip() | |
| note = str(day_data.get("note") or "").strip() | |
| def with_note(value: str) -> str: | |
| return f"{value} β {note}" if note else value | |
| if status in ("closed", "0", "false"): | |
| return with_note("Closed") | |
| if status == "24hours": | |
| return with_note("Open 24 hours") | |
| # Ramadan and other split schedules may have more than one opening interval. | |
| for key in ("hours", "times"): | |
| intervals = day_data.get(key) | |
| if not isinstance(intervals, list): | |
| continue | |
| rendered = [] | |
| for interval in intervals: | |
| if not isinstance(interval, dict): | |
| continue | |
| opens = interval.get("from") or interval.get("opens") or "" | |
| closes = interval.get("to") or interval.get("closes") or "" | |
| if opens and closes: | |
| rendered.append(f"{_parse_libcal_time(opens)} β {_parse_libcal_time(closes)}") | |
| if rendered: | |
| return with_note("; ".join(rendered)) | |
| opens = day_data.get("opens") or day_data.get("open") or "" | |
| closes = day_data.get("closes") or day_data.get("close") or "" | |
| if opens and closes: | |
| return with_note(f"{_parse_libcal_time(opens)} β {_parse_libcal_time(closes)}") | |
| if status == "open": | |
| return with_note("Open β see hours page for times") | |
| return with_note("See hours page") | |
| _HOURS_CONTACTS = ( | |
| '<div class="lb-muted lb-hours-contacts">' | |
| 'Main Campus Library Β· E Building Β· <a href="tel:+97123124604">+971 2 312 4604</a><br>' | |
| 'Habshan Library Β· Building 5, SAN Campus Β· <a href="tel:+97123123160">+971 2 312 3160</a>' | |
| '</div>' | |
| ) | |
| async def _hours_answer() -> str: | |
| uae_now = datetime.now(_UAE_TIMEZONE) | |
| today_str = uae_now.strftime("%Y-%m-%d") | |
| day_names = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"] | |
| today_name = day_names[uae_now.weekday()] | |
| hours_data = await _libcal_fetch_hours() | |
| if not hours_data: | |
| return ( | |
| "Here are the <strong>Khalifa University Library</strong> hours:<br><br>" | |
| "β οΈ Live hours are currently unavailable. Please check the official schedule:<br>" | |
| f'<a href="{LIBRARY_HOURS_URL}" target="_blank"><strong>{LIBRARY_HOURS_URL}</strong></a>' | |
| + _HOURS_CONTACTS | |
| ) | |
| location_labels = { | |
| "main_campus": "Main Campus Library", | |
| "habshan": "Habshan Library (SAN Campus)", | |
| } | |
| def _state(hours_str: str) -> str: | |
| return "closed" if hours_str.lower().startswith("closed") else "open" | |
| def _today_line(loc_data: dict) -> str: | |
| dates = loc_data.get("dates", {}) | |
| today_entry = dates.get(today_str, {}) | |
| if not today_entry: | |
| return f'<em>See <a href="{LIBRARY_HOURS_URL}" target="_blank">live hours</a> for today</em>' | |
| hours_str = _format_libcal_day(today_entry) | |
| return ( | |
| f'<div class="lb-hours-today-line">π <strong>Today ({today_name}):</strong> ' | |
| f'<span class="lb-hours-status lb-hours-status--{_state(hours_str)}">{_escape(hours_str)}</span></div>' | |
| ) | |
| def _week_table(loc_data: dict) -> str: | |
| from datetime import date as _date | |
| dates = loc_data.get("dates", {}) | |
| rows = "" | |
| for date_key in sorted(dates.keys())[:7]: | |
| try: | |
| day_label = _date.fromisoformat(date_key).strftime("%a %d %b") | |
| except ValueError: | |
| day_label = _escape(str(date_key)) | |
| hours_str = _format_libcal_day(dates[date_key]) | |
| row_class = ' class="lb-hours-today"' if date_key == today_str else "" | |
| rows += ( | |
| f"<tr{row_class}>" | |
| f'<td class="lb-hours-day">{day_label}</td>' | |
| f'<td class="lb-hours-time lb-hours-status--{_state(hours_str)}">{_escape(hours_str)}</td>' | |
| "</tr>" | |
| ) | |
| return f'<table class="lb-hours-table"><tbody>{rows}</tbody></table>' if rows else "" | |
| answer = "Here are the <strong>Khalifa University Library</strong> live hours (UAE time, UTC+4):" | |
| for key, label in location_labels.items(): | |
| loc = hours_data.get(key) | |
| answer += f'<div class="lb-hours-location"><div class="lb-hours-heading">π {label}</div>' | |
| if not loc: | |
| answer += "Live hours unavailable; please check the official schedule below.</div>" | |
| continue | |
| answer += _today_line(loc) + _week_table(loc) + "</div>" | |
| answer += ( | |
| '<div class="lb-note">β οΈ <strong>Hours may change during Ramadan, public holidays, and exam periods.</strong> ' | |
| f'Always confirm at <a href="{LIBRARY_HOURS_URL}" target="_blank">{LIBRARY_HOURS_URL}</a></div>' | |
| + _HOURS_CONTACTS | |
| ) | |
| return answer | |
| _libcal_events_cache: Dict[str, Any] = {} | |
| _LIBCAL_EVENTS_TTL = 30 * 60 | |
| _TAG_RE = re.compile(r"<[^>]+>") | |
| _BLOCK_TAG_RE = re.compile(r"</?(?:p|br|div|li|h\d)\b[^>]*>", re.IGNORECASE) | |
| def _plain_text(value: str) -> str: | |
| """LibCal descriptions are rich-text HTML; reduce them to clean prose.""" | |
| text = _TAG_RE.sub("", _BLOCK_TAG_RE.sub(" ", value or "")) | |
| text = _html.unescape(text).replace("\xa0", " ") | |
| return re.sub(r"\s+", " ", text).strip() | |
| def _truncate_words(text: str, limit: int = 200) -> str: | |
| if len(text) <= limit: | |
| return text | |
| return text[:limit].rsplit(" ", 1)[0].rstrip(" ,;:.-β") + "β¦" | |
| def _event_public_url(e: dict) -> str: | |
| """LibCal 1.1 returns url as {"public": ..., "admin": ...}; never expose admin.""" | |
| url = e.get("url") | |
| if isinstance(url, dict): | |
| url = url.get("public") or "" | |
| url = str(url or "").strip() | |
| if not url and e.get("id"): | |
| url = f"{_LIBCAL_BASE}/event/{e['id']}" | |
| return url | |
| def _event_end(ev: dict) -> Optional[datetime]: | |
| raw = str(ev.get("end") or ev.get("start") or "") | |
| try: | |
| dt = datetime.fromisoformat(raw.replace("Z", "+00:00")) | |
| except ValueError: | |
| return None | |
| if ev.get("allDay"): | |
| return datetime.combine(dt.date(), datetime.max.time(), _UAE_TIMEZONE) | |
| return dt.replace(tzinfo=_UAE_TIMEZONE) if dt.tzinfo is None else dt | |
| async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list]: | |
| now = time.time() | |
| uae_now = datetime.now(_UAE_TIMEZONE) | |
| date_from = uae_now.strftime("%Y-%m-%d") | |
| days, limit = max(1, min(int(days), 90)), max(1, min(int(limit), 50)) | |
| cache_key = f"{date_from}_{days}_{limit}" | |
| cached = _libcal_events_cache.get(cache_key, {}) | |
| if "data" in cached and now - cached.get("cached_at", 0) < _LIBCAL_EVENTS_TTL: | |
| return cached["data"] | |
| # Remove expired entries so day-based keys cannot accumulate indefinitely. | |
| for key in list(_libcal_events_cache): | |
| if now - _libcal_events_cache[key].get("cached_at", 0) >= _LIBCAL_EVENTS_TTL: | |
| _libcal_events_cache.pop(key, None) | |
| try: | |
| async with make_client(timeout=12) as client: | |
| token = await _libcal_access_token(client, events=True) | |
| response = await client.get( | |
| f"{_LIBCAL_BASE}/api/1.1/events", | |
| params={ | |
| "iid": _LIBCAL_IID, | |
| "cal_id": _LIBCAL_EVENTS_CAL, | |
| "date": uae_now.strftime("%Y-%m-%d"), | |
| "days": days, | |
| "limit": limit, | |
| }, | |
| headers={"Authorization": f"Bearer {token}"}, | |
| ) | |
| response.raise_for_status() | |
| data = response.json() | |
| events_raw = data if isinstance(data, list) else data.get("events", []) | |
| def _safe_str(val) -> str: | |
| if val is None: | |
| return "" | |
| if isinstance(val, str): | |
| return val | |
| if isinstance(val, dict): | |
| return str(val.get("name") or val.get("title") or "") | |
| if isinstance(val, list): | |
| return ", ".join(_safe_str(v) for v in val if v) | |
| return str(val) | |
| events = [] | |
| for e in events_raw: | |
| if not isinstance(e, dict): | |
| continue | |
| cats = e.get("category") or e.get("categories") or [] | |
| if isinstance(cats, list): | |
| cat_str = ", ".join(_safe_str(c) for c in cats) | |
| else: | |
| cat_str = _safe_str(cats) | |
| events.append({ | |
| "id": e.get("id", ""), | |
| "title": _plain_text(_safe_str(e.get("title") or "Untitled Event")), | |
| "start": _safe_str(e.get("start") or ""), | |
| "end": _safe_str(e.get("end") or ""), | |
| "allDay": _libcal_bool(e.get("allday", e.get("allDay", False))), | |
| "location": _plain_text(_safe_str(e.get("location") or "")), | |
| "description": _plain_text(_safe_str(e.get("description") or e.get("summary") or ""))[:600], | |
| "url": _event_public_url(e), | |
| "registration": _libcal_bool(e.get("registration")) or _libcal_bool(e.get("seats")), | |
| "campus": _safe_str(e.get("campus") or ""), | |
| "category": cat_str, | |
| }) | |
| _libcal_events_cache[cache_key] = {"data": events, "cached_at": now} | |
| logger.info("LibCal: %d events fetched directly", len(events)) | |
| return events | |
| except Exception as exc: | |
| logger.warning("LibCal events fetch failed: %s", exc) | |
| return None | |
| def _libcal_bool(value: Any) -> bool: | |
| """API values can be booleans, numbers, or the strings '0'/'false'.""" | |
| if isinstance(value, str): | |
| return value.strip().lower() not in ("", "0", "false", "no", "none", "null") | |
| return bool(value) | |
| def _format_event_datetime(start: str, end: str, all_day: bool, with_tz: bool = True) -> str: | |
| if not start: | |
| return "" | |
| suffix = " (UAE, UTC+4)" if with_tz else "" | |
| try: | |
| s = datetime.fromisoformat(start.replace("Z", "+00:00")) | |
| if all_day: | |
| # All-day events express a calendar date, not an instant to shift. | |
| return s.strftime("%a %d %b") + " Β· All day" | |
| # Naive institution-local times are interpreted as UAE time, never server time. | |
| s = s.replace(tzinfo=_UAE_TIMEZONE) if s.tzinfo is None else s.astimezone(_UAE_TIMEZONE) | |
| date_str = s.strftime("%a %d %b") | |
| time_str = s.strftime("%I:%M %p").lstrip("0") | |
| if end: | |
| e = datetime.fromisoformat(end.replace("Z", "+00:00")) | |
| e = e.replace(tzinfo=_UAE_TIMEZONE) if e.tzinfo is None else e.astimezone(_UAE_TIMEZONE) | |
| end_str = e.strftime("%I:%M %p").lstrip("0") | |
| if e.date() != s.date(): | |
| end_str = e.strftime("%a %d %b") + " Β· " + end_str | |
| return f"{date_str} Β· {time_str} β {end_str}{suffix}" | |
| return f"{date_str} Β· {time_str}{suffix}" | |
| except (ValueError, TypeError): | |
| return "Time unavailable β check the event page" | |
| def _render_event(ev: dict, now: datetime) -> str: | |
| url = str(ev.get("url") or "") | |
| try: | |
| parsed = urlsplit(url) | |
| if parsed.scheme != "https" or not parsed.netloc: | |
| url = _EVENTS_URL | |
| except ValueError: | |
| url = _EVENTS_URL | |
| url = _escape(url) | |
| title = _escape(str(ev.get("title") or "Untitled event")) | |
| when = _format_event_datetime( | |
| str(ev.get("start") or ""), str(ev.get("end") or ""), bool(ev.get("allDay")), with_tz=False | |
| ) | |
| today = ' <span class="lb-badge">Today</span>' if when.startswith(now.strftime("%a %d %b")) else "" | |
| location = _escape(str(ev.get("location") or "")) | |
| desc = _escape(_truncate_words(str(ev.get("description") or ""))) | |
| parts = [ | |
| '<div class="lb-event">' | |
| f'<div class="lb-event-title"><a href="{url}" target="_blank">{title}</a>{today}</div>' | |
| ] | |
| if when: | |
| parts.append(f'<div class="lb-event-meta">π {_escape(when)}</div>') | |
| if location: | |
| parts.append(f'<div class="lb-event-meta">π {location}</div>') | |
| if desc: | |
| parts.append(f'<div class="lb-event-desc">{desc}</div>') | |
| if ev.get("registration"): | |
| parts.append(f'<a class="lb-btn" href="{url}" target="_blank">Register β</a>') | |
| parts.append("</div>") | |
| return "".join(parts) | |
| async def _events_answer(days: int = 30) -> str: | |
| events = await _libcal_fetch_events(days=days, limit=15) | |
| if events is None: | |
| return ( | |
| "<strong>π Khalifa University Library Events</strong><br><br>" | |
| "Live event data is currently unavailable. Browse all upcoming events here:<br>" | |
| f'<a href="{_EVENTS_URL}" target="_blank">{_EVENTS_URL}</a>' | |
| ) | |
| now = datetime.now(_UAE_TIMEZONE) | |
| # LibCal's date parameter returns the whole first day, including sessions that have ended. | |
| upcoming = [ev for ev in events if (_event_end(ev) or now) >= now] | |
| if not upcoming: | |
| return ( | |
| "<strong>π Khalifa University Library Events</strong><br><br>" | |
| f"No upcoming events in the next {days} days.<br><br>" | |
| f'Browse the full calendar: <a href="{_EVENTS_URL}" target="_blank">{_EVENTS_URL}</a>' | |
| ) | |
| items = "".join(_render_event(ev, now) for ev in upcoming[:8]) | |
| return ( | |
| '<div class="lb-events">' | |
| "<strong>π Upcoming library events & workshops</strong>" | |
| f'<div class="lb-muted">Next {days} days Β· all times are UAE time (UTC+4)</div>' | |
| f"{items}" | |
| f'<a class="lb-events-all" href="{_EVENTS_URL}" target="_blank">View the full events calendar β</a>' | |
| "</div>" | |
| ) | |