nikeshn commited on
Commit
c8b0981
Β·
verified Β·
1 Parent(s): fe60f70

Upload 12 files

Browse files
Files changed (1) hide show
  1. src/agentcore/libcal.py +157 -101
src/agentcore/libcal.py CHANGED
@@ -1,61 +1,106 @@
1
- """LibCal client + formatting: live opening hours and events via the Cloudflare Worker proxy."""
2
  import asyncio
3
- import html
4
- import json
5
- import logging
6
- import os
7
- import re
8
- import time
9
- import uuid
10
- from datetime import datetime
11
- from typing import Any, Dict, List, Literal, Optional, Tuple
12
- from urllib.parse import quote
13
-
14
- import httpx
15
- from pydantic import BaseModel, ConfigDict, Field
16
-
17
- from src.config import get_settings, LIBBEE_VERSION
18
-
19
- from src.agentcore.constants import LIBRARY_HOURS_URL
20
- from src.agentcore.utils import _escape
21
 
22
  logger = logging.getLogger(__name__)
23
 
24
- _libcal_hours_cache: Dict[str, Any] = {}
25
-
26
-
27
- _LIBCAL_HOURS_TTL = 60 * 60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
 
30
- async def _libcal_fetch_hours() -> Optional[dict]:
31
  now = time.time()
32
  cached_at = _libcal_hours_cache.get("cached_at", 0)
33
  if _libcal_hours_cache.get("data") and now - cached_at < _LIBCAL_HOURS_TTL:
34
  return _libcal_hours_cache["data"]
35
- settings = get_settings()
36
- worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
37
- if not worker_url:
38
- logger.warning("LibCal: CLOUDFLARE_WORKER_URL not set β€” cannot fetch hours")
39
- return None
40
- from datetime import timezone, timedelta
41
- uae_now = datetime.now(timezone(timedelta(hours=4)))
42
- date_from = uae_now.strftime("%Y-%m-%d")
43
- date_to = (uae_now + timedelta(days=6)).strftime("%Y-%m-%d")
44
- try:
45
- async with httpx.AsyncClient(timeout=10) as client:
46
- r = await client.get(
47
- f"{worker_url.rstrip('/')}/hours",
48
- params={"from": date_from, "to": date_to},
49
- )
50
- r.raise_for_status()
51
- data = r.json()
52
- if data.get("error"):
53
- logger.warning("LibCal worker error: %s", data["error"])
54
- return None
55
- _libcal_hours_cache["data"] = data
56
- _libcal_hours_cache["cached_at"] = now
57
- logger.info("LibCal: hours refreshed via worker")
58
- return data
 
 
 
59
  except Exception as exc:
60
  logger.warning("LibCal hours fetch failed: %s", exc)
61
  return None
@@ -123,18 +168,19 @@ async def _hours_answer() -> str:
123
  "main_campus": "Main Campus Library",
124
  "habshan": "Habshan Library (SAN Campus)",
125
  }
126
- def _today_line(loc_name: str, loc_data: dict) -> str:
127
  dates = loc_data.get("dates", {})
128
  today_entry = dates.get(today_str, {})
129
  if not today_entry:
130
  return f"<em>See <a href='{LIBRARY_HOURS_URL}' target='_blank'>live hours</a> for today</em>"
131
- hours_str = _format_libcal_day(today_entry)
132
- is_closed = hours_str.lower().startswith("closed")
133
- colour = "#dc2626" if is_closed else "#16a34a"
134
- return (
135
- f"πŸ“… <strong>Today ({today_name}):</strong> "
136
- f"<span style='color:{colour};font-weight:700'>{hours_str}</span>"
137
- )
 
138
  def _week_table(loc_data: dict) -> str:
139
  dates = loc_data.get("dates", {})
140
  if not dates:
@@ -149,17 +195,18 @@ async def _hours_answer() -> str:
149
  except ValueError:
150
  day_label = date_key
151
  is_today = False
152
- hours_str = _format_libcal_day(dates[date_key])
153
- is_closed = hours_str.lower().startswith("closed")
154
- colour = "#dc2626" if is_closed else "#16a34a"
155
- weight = "700" if is_today else "400"
156
- bg = "#fffbeb" if is_today else "transparent"
157
- rows += (
158
- f"<tr style='background:{bg}'>"
159
- f"<td style='padding:3px 12px 3px 0;color:#374151;font-weight:{weight}'>{day_label}</td>"
160
- f"<td style='padding:3px 0;font-weight:600;color:{colour}'>{hours_str}</td>"
161
- f"</tr>"
162
- )
 
163
  if not rows:
164
  return ""
165
  return (
@@ -196,30 +243,32 @@ _libcal_events_cache: Dict[str, Any] = {}
196
  _LIBCAL_EVENTS_TTL = 30 * 60
197
 
198
 
199
- async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list]:
200
  now = time.time()
201
  cache_key = f"{days}_{limit}"
202
  cached = _libcal_events_cache.get(cache_key, {})
203
  if cached.get("data") and now - cached.get("cached_at", 0) < _LIBCAL_EVENTS_TTL:
204
  return cached["data"]
205
- settings = get_settings()
206
- worker_url = getattr(settings, "cloudflare_worker_url", "").strip()
207
- if not worker_url:
208
- logger.warning("LibCal events: CLOUDFLARE_WORKER_URL not set")
209
- return None
210
- try:
211
- async with httpx.AsyncClient(timeout=10) as client:
212
- r = await client.get(
213
- f"{worker_url.rstrip('/')}/events",
214
- params={"days": days, "limit": limit},
215
- )
216
- r.raise_for_status()
217
- data = r.json()
218
- if data.get("error"):
219
- logger.warning("LibCal events worker error: %s", data["error"])
220
- return None
221
- events_raw = data.get("events", [])
222
- def _safe_str(val) -> str:
 
 
223
  if val is None:
224
  return ""
225
  if isinstance(val, str):
@@ -249,9 +298,9 @@ async def _libcal_fetch_events(days: int = 30, limit: int = 10) -> Optional[list
249
  "campus": _safe_str(e.get("campus") or ""),
250
  "category": cat_str,
251
  })
252
- _libcal_events_cache[cache_key] = {"data": events, "cached_at": now}
253
- logger.info("LibCal: %d events fetched", len(events))
254
- return events
255
  except Exception as exc:
256
  logger.warning("LibCal events fetch failed: %s", exc)
257
  return None
@@ -276,17 +325,17 @@ def _format_event_datetime(start: str, end: str, all_day: bool) -> str:
276
  return start[:16].replace("T", " ")
277
 
278
 
279
- async def _events_answer(days: int = 30) -> str:
280
- EVENTS_URL = "https://kustar.libcal.com/calendar/events"
281
- events = await _libcal_fetch_events(days=days, limit=15)
282
- if not events:
283
- return (
284
  "<strong>πŸ“… Khalifa University Library Events</strong><br><br>"
285
  "Live event data is currently unavailable. Browse all upcoming events here:<br>"
286
  f'<a href="{EVENTS_URL}" target="_blank" '
287
  f'style="font-weight:700;color:#003366">{EVENTS_URL}</a>'
288
  )
289
- if not events:
290
  return (
291
  "<strong>πŸ“… Khalifa University Library Events</strong><br><br>"
292
  f"No upcoming events found in the next {days} days.<br><br>"
@@ -296,12 +345,19 @@ async def _events_answer(days: int = 30) -> str:
296
  answer = f"<strong>πŸ“… Upcoming Library Events, Workshops & Training</strong> (next {days} days)<br><br>"
297
  for ev in events[:10]:
298
  title = _escape(str(ev.get("title") or "Untitled Event"))
299
- when = _format_event_datetime(
300
- str(ev.get("start") or ""), str(ev.get("end") or ""), bool(ev.get("allDay"))
301
- )
302
- location = _escape(str(ev.get("location") or ""))
303
- desc = _escape(str(ev.get("description") or ""))
304
- url = str(ev.get("url") or EVENTS_URL)
 
 
 
 
 
 
 
305
  reg = bool(ev.get("registration"))
306
  answer += (
307
  f'<div style="margin-bottom:12px;padding:10px 12px;border-left:3px solid #003366;'
 
1
+ """LibCal client + formatting for live opening hours and events."""
2
  import asyncio
3
+ import json
4
+ import logging
5
+ import re
6
+ import time
7
+ from datetime import datetime
8
+ from typing import Any, Dict, Optional
9
+ from urllib.parse import urlsplit
10
+
11
+ import httpx
12
+
13
+ from src.config import get_settings, LIBBEE_VERSION
14
+
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"
24
+ _LIBCAL_LOC_MAIN = 6386
25
+ _LIBCAL_LOC_HABSHAN = 25028
26
+ _LIBCAL_IID = 3495
27
+ _LIBCAL_EVENTS_CAL = 8398
28
+ _libcal_tokens: Dict[str, Dict[str, Any]] = {
29
+ "hours": {"value": "", "expires_at": 0.0},
30
+ "events": {"value": "", "expires_at": 0.0},
31
+ }
32
+ _libcal_token_lock = asyncio.Lock()
33
+
34
+
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"]:
50
+ return str(cached["value"])
51
+
52
+ async with _libcal_token_lock:
53
+ cached = _libcal_tokens[cache_key]
54
+ now = time.time()
55
+ if cached["value"] and now < cached["expires_at"]:
56
+ return str(cached["value"])
57
+ response = await client.post(
58
+ f"{_LIBCAL_BASE}/api/1.1/oauth/token",
59
+ data={"client_id": client_id, "client_secret": client_secret, "grant_type": "client_credentials"},
60
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
61
+ )
62
+ response.raise_for_status()
63
+ data = response.json()
64
+ token = str(data.get("access_token") or "")
65
+ if not token:
66
+ raise RuntimeError("LibCal authentication returned no access token")
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")
80
+ date_to = (uae_now + timedelta(days=6)).strftime("%Y-%m-%d")
81
+ try:
82
+ async with make_client(timeout=12) as client:
83
+ token = await _libcal_access_token(client)
84
+
85
+ async def fetch_location(location_id: int) -> dict:
86
+ response = await client.get(
87
+ f"{_LIBCAL_BASE}/api/1.1/hours/{location_id}",
88
+ params={"from": date_from, "to": date_to},
89
+ headers={"Authorization": f"Bearer {token}"},
90
+ )
91
+ response.raise_for_status()
92
+ value = response.json()
93
+ return value[0] if isinstance(value, list) and value else value
94
+
95
+ main_campus, habshan = await asyncio.gather(
96
+ fetch_location(_LIBCAL_LOC_MAIN),
97
+ fetch_location(_LIBCAL_LOC_HABSHAN),
98
+ )
99
+ data = {"main_campus": main_campus, "habshan": habshan}
100
+ _libcal_hours_cache["data"] = data
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
 
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"
179
+ safe_hours = _escape(hours_str)
180
+ return (
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:
 
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"
201
+ weight = "700" if is_today else "400"
202
+ bg = "#fffbeb" if is_today else "transparent"
203
+ safe_hours = _escape(hours_str)
204
+ rows += (
205
+ f"<tr style='background:{bg}'>"
206
+ f"<td style='padding:3px 12px 3px 0;color:#374151;font-weight:{weight}'>{day_label}</td>"
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 (
 
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)))
255
+ async with make_client(timeout=12) as client:
256
+ token = await _libcal_access_token(client, events=True)
257
+ response = await client.get(
258
+ f"{_LIBCAL_BASE}/api/1.1/events",
259
+ params={
260
+ "iid": _LIBCAL_IID,
261
+ "cal_id": _LIBCAL_EVENTS_CAL,
262
+ "date": uae_now.strftime("%Y-%m-%d"),
263
+ "days": max(1, min(int(days), 90)),
264
+ "limit": max(1, min(int(limit), 50)),
265
+ },
266
+ headers={"Authorization": f"Bearer {token}"},
267
+ )
268
+ response.raise_for_status()
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):
 
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
 
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>"
 
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
+ ))
351
+ location = _escape(str(ev.get("location") or ""))
352
+ desc = _escape(str(ev.get("description") or ""))
353
+ candidate_url = str(ev.get("url") or EVENTS_URL)
354
+ try:
355
+ parsed_url = urlsplit(candidate_url)
356
+ if parsed_url.scheme != "https" or not parsed_url.netloc:
357
+ candidate_url = EVENTS_URL
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;'