nikeshn commited on
Commit
7d8ef00
·
verified ·
1 Parent(s): 0743fde

Update src/agentcore/libcal.py

Browse files
Files changed (1) hide show
  1. src/agentcore/libcal.py +256 -238
src/agentcore/libcal.py CHANGED
@@ -1,5 +1,5 @@
1
  """LibCal client + formatting for live opening hours and events."""
2
- import asyncio
3
  import json
4
  import logging
5
  import re
@@ -15,9 +15,9 @@ from src.config import get_settings, LIBBEE_VERSION
15
  from src.agentcore.constants import LIBRARY_HOURS_URL
16
  from src.agentcore.utils import _escape
17
  from src.services.http_client import make_client
18
-
19
- logger = logging.getLogger(__name__)
20
-
21
  _libcal_hours_cache: Dict[str, Any] = {}
22
  _LIBCAL_HOURS_TTL = 60 * 60
23
  _LIBCAL_BASE = "https://kustar.libcal.com"
@@ -35,15 +35,33 @@ _libcal_token_lock = asyncio.Lock()
35
  async def _libcal_access_token(client: httpx.AsyncClient, events: bool = False) -> str:
36
  """Fetch and briefly cache a LibCal OAuth token without logging secrets."""
37
  settings = get_settings()
38
- client_id = settings.libcal_events_client_id if events else settings.libcal_client_id
39
- client_secret = settings.libcal_events_client_secret if events else settings.libcal_client_secret
40
- if events:
41
- client_id = client_id or settings.libcal_client_id
42
- client_secret = client_secret or settings.libcal_client_secret
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  if not client_id or not client_secret:
44
  raise RuntimeError("LibCal credentials are not configured in the HF Space")
45
 
46
- cache_key = "events" if events else "hours"
47
  cached = _libcal_tokens[cache_key]
48
  now = time.time()
49
  if cached["value"] and now < cached["expires_at"]:
@@ -67,13 +85,13 @@ async def _libcal_access_token(client: httpx.AsyncClient, events: bool = False)
67
  cached["value"] = token
68
  cached["expires_at"] = now + max(5, int(data.get("expires_in") or 3600) - 60)
69
  return token
70
-
71
-
72
  async def _libcal_fetch_hours() -> Optional[dict]:
73
- now = time.time()
74
- cached_at = _libcal_hours_cache.get("cached_at", 0)
75
- if _libcal_hours_cache.get("data") and now - cached_at < _LIBCAL_HOURS_TTL:
76
- return _libcal_hours_cache["data"]
77
  from datetime import timezone, timedelta
78
  uae_now = datetime.now(timezone(timedelta(hours=4)))
79
  date_from = uae_now.strftime("%Y-%m-%d")
@@ -101,78 +119,78 @@ async def _libcal_fetch_hours() -> Optional[dict]:
101
  _libcal_hours_cache["cached_at"] = now
102
  logger.info("LibCal: hours refreshed directly")
103
  return data
104
- except Exception as exc:
105
- logger.warning("LibCal hours fetch failed: %s", exc)
106
- return None
107
-
108
-
109
- def _parse_libcal_time(raw: str) -> str:
110
- if not raw:
111
- return raw
112
- raw = raw.strip().replace("\u2013", "-").replace("–", "-")
113
- raw = re.sub(r"\s+", "", raw)
114
- raw = re.sub(r"(?i)(am|pm)$", lambda m: " " + m.group(1).upper(), raw)
115
- return raw
116
-
117
-
118
- def _format_libcal_day(day_data: dict) -> str:
119
- if not day_data or not isinstance(day_data, dict):
120
- return "See hours page"
121
- status = str(day_data.get("status") or "").lower().strip()
122
- note = str(day_data.get("note") or "").strip()
123
- if status in ("closed", "0", "false"):
124
- return f"Closed{f' — {note}' if note else ''}"
125
- if status == "24hours":
126
- return "Open 24 hours"
127
- hours_list = day_data.get("hours") or []
128
- if hours_list and isinstance(hours_list, list):
129
- first = hours_list[0]
130
- f = first.get("from") or first.get("opens") or ""
131
- t = first.get("to") or first.get("closes") or ""
132
- if f and t:
133
- return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
134
- opens = day_data.get("opens") or day_data.get("open") or ""
135
- closes = day_data.get("closes") or day_data.get("close") or ""
136
- if opens and closes:
137
- return f"{_parse_libcal_time(opens)} – {_parse_libcal_time(closes)}"
138
- times = day_data.get("times") or []
139
- if times and isinstance(times, list):
140
- first = times[0]
141
- f = first.get("from") or first.get("opens") or ""
142
- t = first.get("to") or first.get("closes") or ""
143
- if f and t:
144
- return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
145
- if status in ("open",):
146
- return "Open — see hours page for times"
147
- return "See hours page"
148
-
149
-
150
- async def _hours_answer() -> str:
151
- from datetime import timezone, timedelta
152
- uae_now = datetime.now(timezone(timedelta(hours=4)))
153
- today_str = uae_now.strftime("%Y-%m-%d")
154
- day_names = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"]
155
- today_name = day_names[uae_now.weekday()]
156
- hours_data = await _libcal_fetch_hours()
157
- if not hours_data:
158
- return (
159
- "Here are the <strong>Khalifa University Library</strong> hours:<br><br>"
160
- "⚠️ Live hours are currently unavailable. Please check the official schedule:<br>"
161
- f'<a href="{LIBRARY_HOURS_URL}" target="_blank" '
162
- f'style="font-weight:700;color:#003366">{LIBRARY_HOURS_URL}</a><br><br>'
163
- "<strong>📍 Contacts:</strong><br>"
164
- 'Main Campus Library · E Building · <a href="tel:+97123124604">+971 2 312 4604</a><br>'
165
- 'Habshan Library · SAN Campus · <a href="tel:+97123123160">+971 2 312 3160</a>'
166
- )
167
- location_labels = {
168
- "main_campus": "Main Campus Library",
169
- "habshan": "Habshan Library (SAN Campus)",
170
- }
171
  def _today_line(loc_name: str, loc_data: dict) -> str:
172
- dates = loc_data.get("dates", {})
173
- today_entry = dates.get(today_str, {})
174
- if not today_entry:
175
- return f"<em>See <a href='{LIBRARY_HOURS_URL}' target='_blank'>live hours</a> for today</em>"
176
  hours_str = _format_libcal_day(today_entry)
177
  is_closed = hours_str.lower().startswith("closed")
178
  colour = "#dc2626" if is_closed else "#16a34a"
@@ -181,20 +199,20 @@ async def _hours_answer() -> str:
181
  f"📅 <strong>Today ({today_name}):</strong> "
182
  f"<span style='color:{colour};font-weight:700'>{safe_hours}</span>"
183
  )
184
- def _week_table(loc_data: dict) -> str:
185
- dates = loc_data.get("dates", {})
186
- if not dates:
187
- return ""
188
- rows = ""
189
- for date_key in sorted(dates.keys())[:7]:
190
- try:
191
- from datetime import date as _date
192
- d = _date.fromisoformat(date_key)
193
- day_label = d.strftime("%a %d %b")
194
- is_today = date_key == today_str
195
- except ValueError:
196
- day_label = date_key
197
- is_today = False
198
  hours_str = _format_libcal_day(dates[date_key])
199
  is_closed = hours_str.lower().startswith("closed")
200
  colour = "#dc2626" if is_closed else "#16a34a"
@@ -207,48 +225,48 @@ async def _hours_answer() -> str:
207
  f"<td style='padding:3px 0;font-weight:600;color:{colour}'>{safe_hours}</td>"
208
  f"</tr>"
209
  )
210
- if not rows:
211
- return ""
212
- return (
213
- "<table style='font-size:.84rem;border-collapse:collapse;width:100%;max-width:380px'>"
214
- + rows + "</table>"
215
- )
216
- answer = "Here are the <strong>Khalifa University Library</strong> live hours:<br><br>"
217
- for key, label in location_labels.items():
218
- loc = hours_data.get(key)
219
- if not loc:
220
- continue
221
- answer += f"<strong>📍 {label}</strong><br>"
222
- answer += _today_line(label, loc) + "<br>"
223
- week_tbl = _week_table(loc)
224
- if week_tbl:
225
- answer += week_tbl + "<br><br>"
226
- else:
227
- answer += "<br>"
228
- answer += (
229
- "⚠️ <strong>Hours may change during Ramadan, public holidays, and exam periods.</strong><br>"
230
- f'Always confirm at: <a href="{LIBRARY_HOURS_URL}" target="_blank" '
231
- f'style="font-weight:700;color:#003366">{LIBRARY_HOURS_URL}</a><br><br>'
232
- '<span style="font-size:.82rem;color:#6b7280">'
233
- 'Main Campus Library · E Building · <a href="tel:+97123124604">+971 2 312 4604</a><br>'
234
- 'Habshan Library · Building 5, SAN Campus · <a href="tel:+97123123160">+971 2 312 3160</a>'
235
- '</span>'
236
- )
237
- return answer
238
-
239
-
240
- _libcal_events_cache: Dict[str, Any] = {}
241
-
242
-
243
- _LIBCAL_EVENTS_TTL = 30 * 60
244
-
245
-
246
  async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list]:
247
- now = time.time()
248
- cache_key = f"{days}_{limit}"
249
- cached = _libcal_events_cache.get(cache_key, {})
250
- if cached.get("data") and now - cached.get("cached_at", 0) < _LIBCAL_EVENTS_TTL:
251
- return cached["data"]
252
  try:
253
  from datetime import timezone, timedelta
254
  uae_now = datetime.now(timezone(timedelta(hours=4)))
@@ -269,82 +287,82 @@ async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list
269
  data = response.json()
270
  events_raw = data if isinstance(data, list) else data.get("events", [])
271
  def _safe_str(val) -> str:
272
- if val is None:
273
- return ""
274
- if isinstance(val, str):
275
- return val
276
- if isinstance(val, dict):
277
- return str(val.get("name") or val.get("title") or "")
278
- if isinstance(val, list):
279
- return ", ".join(_safe_str(v) for v in val if v)
280
- return str(val)
281
- events = []
282
- for e in events_raw:
283
- cats = e.get("category") or e.get("categories") or []
284
- if isinstance(cats, list):
285
- cat_str = ", ".join(_safe_str(c) for c in cats)
286
- else:
287
- cat_str = _safe_str(cats)
288
- events.append({
289
- "id": e.get("id", ""),
290
- "title": _safe_str(e.get("title") or "Untitled Event"),
291
- "start": _safe_str(e.get("start") or ""),
292
- "end": _safe_str(e.get("end") or ""),
293
- "allDay": bool(e.get("allday") or e.get("allDay")),
294
- "location": _safe_str(e.get("location") or ""),
295
- "description": _safe_str(e.get("description") or e.get("summary") or "")[:300],
296
- "url": _safe_str(e.get("url") or ""),
297
- "registration": bool(e.get("registration") or e.get("seats")),
298
- "campus": _safe_str(e.get("campus") or ""),
299
- "category": cat_str,
300
- })
301
  _libcal_events_cache[cache_key] = {"data": events, "cached_at": now}
302
  logger.info("LibCal: %d events fetched directly", len(events))
303
  return events
304
- except Exception as exc:
305
- logger.warning("LibCal events fetch failed: %s", exc)
306
- return None
307
-
308
-
309
- def _format_event_datetime(start: str, end: str, all_day: bool) -> str:
310
- if not start:
311
- return ""
312
- try:
313
- from datetime import datetime as _dt
314
- s = _dt.fromisoformat(start.replace("Z", "+00:00"))
315
- date_str = s.strftime("%a %d %b")
316
- if all_day:
317
- return date_str
318
- time_str = s.strftime("%I:%M %p").lstrip("0")
319
- if end:
320
- e = _dt.fromisoformat(end.replace("Z", "+00:00"))
321
- end_str = e.strftime("%I:%M %p").lstrip("0")
322
- return f"{date_str} · {time_str} – {end_str}"
323
- return f"{date_str} · {time_str}"
324
- except Exception:
325
- return start[:16].replace("T", " ")
326
-
327
-
328
  async def _events_answer(days: int = 30) -> str:
329
  EVENTS_URL = "https://kustar.libcal.com/calendar/events"
330
  events = await _libcal_fetch_events(days=days, limit=15)
331
  if events is None:
332
  return (
333
- "<strong>📅 Khalifa University Library Events</strong><br><br>"
334
- "Live event data is currently unavailable. Browse all upcoming events here:<br>"
335
- f'<a href="{EVENTS_URL}" target="_blank" '
336
- f'style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
337
- )
338
  if len(events) == 0:
339
- return (
340
- "<strong>📅 Khalifa University Library Events</strong><br><br>"
341
- f"No upcoming events found in the next {days} days.<br><br>"
342
- f'Browse the full calendar: <a href="{EVENTS_URL}" target="_blank"'
343
- f' style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
344
- )
345
- answer = f"<strong>📅 Upcoming Library Events, Workshops & Training</strong> (next {days} days)<br><br>"
346
- for ev in events[:10]:
347
- title = _escape(str(ev.get("title") or "Untitled Event"))
348
  when = _escape(_format_event_datetime(
349
  str(ev.get("start") or ""), str(ev.get("end") or ""), bool(ev.get("allDay"))
350
  ))
@@ -358,32 +376,32 @@ async def _events_answer(days: int = 30) -> str:
358
  except ValueError:
359
  candidate_url = EVENTS_URL
360
  url = _escape(candidate_url)
361
- reg = bool(ev.get("registration"))
362
- answer += (
363
- f'<div style="margin-bottom:12px;padding:10px 12px;border-left:3px solid #003366;'
364
- f'background:#f8fafc;border-radius:0 8px 8px 0">'
365
- f'<div style="font-weight:700;font-size:.88rem;color:#003366">'
366
- f'<a href="{url}" target="_blank" style="color:#003366;text-decoration:none">{title}</a></div>'
367
- )
368
- if when:
369
- answer += f'<div style="font-size:.80rem;color:#374151;margin-top:2px">🕐 {when}</div>'
370
- if location:
371
- answer += f'<div style="font-size:.80rem;color:#374151">📍 {location}</div>'
372
- if desc:
373
- answer += f'<div style="font-size:.78rem;color:#6b7280;margin-top:4px">{desc[:180]}{"…" if len(desc) > 180 else ""}</div>'
374
- if reg:
375
- answer += (
376
- f'<div style="margin-top:5px">'
377
- f'<a href="{url}" target="_blank" '
378
- f'style="font-size:.76rem;font-weight:700;color:#003366;'
379
- f'padding:3px 10px;border:1px solid #003366;border-radius:6px;text-decoration:none">'
380
- f'Register →</a></div>'
381
- )
382
- answer += '</div>'
383
- answer += (
384
- f'<br><a href="{EVENTS_URL}" target="_blank" '
385
- f'style="font-weight:700;color:#003366">View full library events calendar →</a>'
386
- )
387
- return answer
388
-
389
-
 
1
  """LibCal client + formatting for live opening hours and events."""
2
+ import asyncio
3
  import json
4
  import logging
5
  import re
 
15
  from src.agentcore.constants import LIBRARY_HOURS_URL
16
  from src.agentcore.utils import _escape
17
  from src.services.http_client import make_client
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
  _libcal_hours_cache: Dict[str, Any] = {}
22
  _LIBCAL_HOURS_TTL = 60 * 60
23
  _LIBCAL_BASE = "https://kustar.libcal.com"
 
35
  async def _libcal_access_token(client: httpx.AsyncClient, events: bool = False) -> str:
36
  """Fetch and briefly cache a LibCal OAuth token without logging secrets."""
37
  settings = get_settings()
38
+ main_client_id = str(settings.libcal_client_id or "").strip()
39
+ main_client_secret = str(settings.libcal_client_secret or "").strip()
40
+ events_client_id = str(settings.libcal_events_client_id or "").strip()
41
+ events_client_secret = str(settings.libcal_events_client_secret or "").strip()
42
+
43
+ if events and bool(events_client_id) != bool(events_client_secret):
44
+ raise RuntimeError(
45
+ "LIBCAL_EVENTS_CLIENT_ID and LIBCAL_EVENTS_CLIENT_SECRET must be configured together"
46
+ )
47
+
48
+ if events and events_client_id and events_client_secret:
49
+ client_id = events_client_id
50
+ client_secret = events_client_secret
51
+ cache_key = (
52
+ "hours"
53
+ if (events_client_id, events_client_secret)
54
+ == (main_client_id, main_client_secret)
55
+ else "events"
56
+ )
57
+ else:
58
+ client_id = main_client_id
59
+ client_secret = main_client_secret
60
+ cache_key = "hours"
61
+
62
  if not client_id or not client_secret:
63
  raise RuntimeError("LibCal credentials are not configured in the HF Space")
64
 
 
65
  cached = _libcal_tokens[cache_key]
66
  now = time.time()
67
  if cached["value"] and now < cached["expires_at"]:
 
85
  cached["value"] = token
86
  cached["expires_at"] = now + max(5, int(data.get("expires_in") or 3600) - 60)
87
  return token
88
+
89
+
90
  async def _libcal_fetch_hours() -> Optional[dict]:
91
+ now = time.time()
92
+ cached_at = _libcal_hours_cache.get("cached_at", 0)
93
+ if _libcal_hours_cache.get("data") and now - cached_at < _LIBCAL_HOURS_TTL:
94
+ return _libcal_hours_cache["data"]
95
  from datetime import timezone, timedelta
96
  uae_now = datetime.now(timezone(timedelta(hours=4)))
97
  date_from = uae_now.strftime("%Y-%m-%d")
 
119
  _libcal_hours_cache["cached_at"] = now
120
  logger.info("LibCal: hours refreshed directly")
121
  return data
122
+ except Exception as exc:
123
+ logger.warning("LibCal hours fetch failed: %s", exc)
124
+ return None
125
+
126
+
127
+ def _parse_libcal_time(raw: str) -> str:
128
+ if not raw:
129
+ return raw
130
+ raw = raw.strip().replace("\u2013", "-").replace("–", "-")
131
+ raw = re.sub(r"\s+", "", raw)
132
+ raw = re.sub(r"(?i)(am|pm)$", lambda m: " " + m.group(1).upper(), raw)
133
+ return raw
134
+
135
+
136
+ def _format_libcal_day(day_data: dict) -> str:
137
+ if not day_data or not isinstance(day_data, dict):
138
+ return "See hours page"
139
+ status = str(day_data.get("status") or "").lower().strip()
140
+ note = str(day_data.get("note") or "").strip()
141
+ if status in ("closed", "0", "false"):
142
+ return f"Closed{f' — {note}' if note else ''}"
143
+ if status == "24hours":
144
+ return "Open 24 hours"
145
+ hours_list = day_data.get("hours") or []
146
+ if hours_list and isinstance(hours_list, list):
147
+ first = hours_list[0]
148
+ f = first.get("from") or first.get("opens") or ""
149
+ t = first.get("to") or first.get("closes") or ""
150
+ if f and t:
151
+ return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
152
+ opens = day_data.get("opens") or day_data.get("open") or ""
153
+ closes = day_data.get("closes") or day_data.get("close") or ""
154
+ if opens and closes:
155
+ return f"{_parse_libcal_time(opens)} – {_parse_libcal_time(closes)}"
156
+ times = day_data.get("times") or []
157
+ if times and isinstance(times, list):
158
+ first = times[0]
159
+ f = first.get("from") or first.get("opens") or ""
160
+ t = first.get("to") or first.get("closes") or ""
161
+ if f and t:
162
+ return f"{_parse_libcal_time(f)} – {_parse_libcal_time(t)}"
163
+ if status in ("open",):
164
+ return "Open — see hours page for times"
165
+ return "See hours page"
166
+
167
+
168
+ async def _hours_answer() -> str:
169
+ from datetime import timezone, timedelta
170
+ uae_now = datetime.now(timezone(timedelta(hours=4)))
171
+ today_str = uae_now.strftime("%Y-%m-%d")
172
+ day_names = ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"]
173
+ today_name = day_names[uae_now.weekday()]
174
+ hours_data = await _libcal_fetch_hours()
175
+ if not hours_data:
176
+ return (
177
+ "Here are the <strong>Khalifa University Library</strong> hours:<br><br>"
178
+ "⚠️ Live hours are currently unavailable. Please check the official schedule:<br>"
179
+ f'<a href="{LIBRARY_HOURS_URL}" target="_blank" '
180
+ f'style="font-weight:700;color:#003366">{LIBRARY_HOURS_URL}</a><br><br>'
181
+ "<strong>📍 Contacts:</strong><br>"
182
+ 'Main Campus Library · E Building · <a href="tel:+97123124604">+971 2 312 4604</a><br>'
183
+ 'Habshan Library · SAN Campus · <a href="tel:+97123123160">+971 2 312 3160</a>'
184
+ )
185
+ location_labels = {
186
+ "main_campus": "Main Campus Library",
187
+ "habshan": "Habshan Library (SAN Campus)",
188
+ }
189
  def _today_line(loc_name: str, loc_data: dict) -> str:
190
+ dates = loc_data.get("dates", {})
191
+ today_entry = dates.get(today_str, {})
192
+ if not today_entry:
193
+ return f"<em>See <a href='{LIBRARY_HOURS_URL}' target='_blank'>live hours</a> for today</em>"
194
  hours_str = _format_libcal_day(today_entry)
195
  is_closed = hours_str.lower().startswith("closed")
196
  colour = "#dc2626" if is_closed else "#16a34a"
 
199
  f"📅 <strong>Today ({today_name}):</strong> "
200
  f"<span style='color:{colour};font-weight:700'>{safe_hours}</span>"
201
  )
202
+ def _week_table(loc_data: dict) -> str:
203
+ dates = loc_data.get("dates", {})
204
+ if not dates:
205
+ return ""
206
+ rows = ""
207
+ for date_key in sorted(dates.keys())[:7]:
208
+ try:
209
+ from datetime import date as _date
210
+ d = _date.fromisoformat(date_key)
211
+ day_label = d.strftime("%a %d %b")
212
+ is_today = date_key == today_str
213
+ except ValueError:
214
+ day_label = date_key
215
+ is_today = False
216
  hours_str = _format_libcal_day(dates[date_key])
217
  is_closed = hours_str.lower().startswith("closed")
218
  colour = "#dc2626" if is_closed else "#16a34a"
 
225
  f"<td style='padding:3px 0;font-weight:600;color:{colour}'>{safe_hours}</td>"
226
  f"</tr>"
227
  )
228
+ if not rows:
229
+ return ""
230
+ return (
231
+ "<table style='font-size:.84rem;border-collapse:collapse;width:100%;max-width:380px'>"
232
+ + rows + "</table>"
233
+ )
234
+ answer = "Here are the <strong>Khalifa University Library</strong> live hours:<br><br>"
235
+ for key, label in location_labels.items():
236
+ loc = hours_data.get(key)
237
+ if not loc:
238
+ continue
239
+ answer += f"<strong>📍 {label}</strong><br>"
240
+ answer += _today_line(label, loc) + "<br>"
241
+ week_tbl = _week_table(loc)
242
+ if week_tbl:
243
+ answer += week_tbl + "<br><br>"
244
+ else:
245
+ answer += "<br>"
246
+ answer += (
247
+ "⚠️ <strong>Hours may change during Ramadan, public holidays, and exam periods.</strong><br>"
248
+ f'Always confirm at: <a href="{LIBRARY_HOURS_URL}" target="_blank" '
249
+ f'style="font-weight:700;color:#003366">{LIBRARY_HOURS_URL}</a><br><br>'
250
+ '<span style="font-size:.82rem;color:#6b7280">'
251
+ 'Main Campus Library · E Building · <a href="tel:+97123124604">+971 2 312 4604</a><br>'
252
+ 'Habshan Library · Building 5, SAN Campus · <a href="tel:+97123123160">+971 2 312 3160</a>'
253
+ '</span>'
254
+ )
255
+ return answer
256
+
257
+
258
+ _libcal_events_cache: Dict[str, Any] = {}
259
+
260
+
261
+ _LIBCAL_EVENTS_TTL = 30 * 60
262
+
263
+
264
  async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list]:
265
+ now = time.time()
266
+ cache_key = f"{days}_{limit}"
267
+ cached = _libcal_events_cache.get(cache_key, {})
268
+ if cached.get("data") and now - cached.get("cached_at", 0) < _LIBCAL_EVENTS_TTL:
269
+ return cached["data"]
270
  try:
271
  from datetime import timezone, timedelta
272
  uae_now = datetime.now(timezone(timedelta(hours=4)))
 
287
  data = response.json()
288
  events_raw = data if isinstance(data, list) else data.get("events", [])
289
  def _safe_str(val) -> str:
290
+ if val is None:
291
+ return ""
292
+ if isinstance(val, str):
293
+ return val
294
+ if isinstance(val, dict):
295
+ return str(val.get("name") or val.get("title") or "")
296
+ if isinstance(val, list):
297
+ return ", ".join(_safe_str(v) for v in val if v)
298
+ return str(val)
299
+ events = []
300
+ for e in events_raw:
301
+ cats = e.get("category") or e.get("categories") or []
302
+ if isinstance(cats, list):
303
+ cat_str = ", ".join(_safe_str(c) for c in cats)
304
+ else:
305
+ cat_str = _safe_str(cats)
306
+ events.append({
307
+ "id": e.get("id", ""),
308
+ "title": _safe_str(e.get("title") or "Untitled Event"),
309
+ "start": _safe_str(e.get("start") or ""),
310
+ "end": _safe_str(e.get("end") or ""),
311
+ "allDay": bool(e.get("allday") or e.get("allDay")),
312
+ "location": _safe_str(e.get("location") or ""),
313
+ "description": _safe_str(e.get("description") or e.get("summary") or "")[:300],
314
+ "url": _safe_str(e.get("url") or ""),
315
+ "registration": bool(e.get("registration") or e.get("seats")),
316
+ "campus": _safe_str(e.get("campus") or ""),
317
+ "category": cat_str,
318
+ })
319
  _libcal_events_cache[cache_key] = {"data": events, "cached_at": now}
320
  logger.info("LibCal: %d events fetched directly", len(events))
321
  return events
322
+ except Exception as exc:
323
+ logger.warning("LibCal events fetch failed: %s", exc)
324
+ return None
325
+
326
+
327
+ def _format_event_datetime(start: str, end: str, all_day: bool) -> str:
328
+ if not start:
329
+ return ""
330
+ try:
331
+ from datetime import datetime as _dt
332
+ s = _dt.fromisoformat(start.replace("Z", "+00:00"))
333
+ date_str = s.strftime("%a %d %b")
334
+ if all_day:
335
+ return date_str
336
+ time_str = s.strftime("%I:%M %p").lstrip("0")
337
+ if end:
338
+ e = _dt.fromisoformat(end.replace("Z", "+00:00"))
339
+ end_str = e.strftime("%I:%M %p").lstrip("0")
340
+ return f"{date_str} · {time_str} – {end_str}"
341
+ return f"{date_str} · {time_str}"
342
+ except Exception:
343
+ return start[:16].replace("T", " ")
344
+
345
+
346
  async def _events_answer(days: int = 30) -> str:
347
  EVENTS_URL = "https://kustar.libcal.com/calendar/events"
348
  events = await _libcal_fetch_events(days=days, limit=15)
349
  if events is None:
350
  return (
351
+ "<strong>📅 Khalifa University Library Events</strong><br><br>"
352
+ "Live event data is currently unavailable. Browse all upcoming events here:<br>"
353
+ f'<a href="{EVENTS_URL}" target="_blank" '
354
+ f'style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
355
+ )
356
  if len(events) == 0:
357
+ return (
358
+ "<strong>📅 Khalifa University Library Events</strong><br><br>"
359
+ f"No upcoming events found in the next {days} days.<br><br>"
360
+ f'Browse the full calendar: <a href="{EVENTS_URL}" target="_blank"'
361
+ f' style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
362
+ )
363
+ answer = f"<strong>📅 Upcoming Library Events, Workshops & Training</strong> (next {days} days)<br><br>"
364
+ for ev in events[:10]:
365
+ title = _escape(str(ev.get("title") or "Untitled Event"))
366
  when = _escape(_format_event_datetime(
367
  str(ev.get("start") or ""), str(ev.get("end") or ""), bool(ev.get("allDay"))
368
  ))
 
376
  except ValueError:
377
  candidate_url = EVENTS_URL
378
  url = _escape(candidate_url)
379
+ reg = bool(ev.get("registration"))
380
+ answer += (
381
+ f'<div style="margin-bottom:12px;padding:10px 12px;border-left:3px solid #003366;'
382
+ f'background:#f8fafc;border-radius:0 8px 8px 0">'
383
+ f'<div style="font-weight:700;font-size:.88rem;color:#003366">'
384
+ f'<a href="{url}" target="_blank" style="color:#003366;text-decoration:none">{title}</a></div>'
385
+ )
386
+ if when:
387
+ answer += f'<div style="font-size:.80rem;color:#374151;margin-top:2px">🕐 {when}</div>'
388
+ if location:
389
+ answer += f'<div style="font-size:.80rem;color:#374151">📍 {location}</div>'
390
+ if desc:
391
+ answer += f'<div style="font-size:.78rem;color:#6b7280;margin-top:4px">{desc[:180]}{"…" if len(desc) > 180 else ""}</div>'
392
+ if reg:
393
+ answer += (
394
+ f'<div style="margin-top:5px">'
395
+ f'<a href="{url}" target="_blank" '
396
+ f'style="font-size:.76rem;font-weight:700;color:#003366;'
397
+ f'padding:3px 10px;border:1px solid #003366;border-radius:6px;text-decoration:none">'
398
+ f'Register →</a></div>'
399
+ )
400
+ answer += '</div>'
401
+ answer += (
402
+ f'<br><a href="{EVENTS_URL}" target="_blank" '
403
+ f'style="font-weight:700;color:#003366">View full library events calendar →</a>'
404
+ )
405
+ return answer
406
+
407
+