File size: 19,832 Bytes
3947185
 
 
 
 
7d8ef00
3947185
c8b0981
 
 
 
194e844
c8b0981
 
 
 
 
 
 
 
 
 
7d8ef00
 
 
c8b0981
 
 
 
 
 
 
194e844
3947185
c8b0981
 
 
 
 
 
 
 
 
 
7d8ef00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c8b0981
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d8ef00
 
c8b0981
7d8ef00
194e844
 
7d8ef00
194e844
 
7d8ef00
c8b0981
 
 
 
 
 
 
 
 
 
 
 
 
194e844
 
 
 
c8b0981
194e844
c8b0981
 
194e844
c8b0981
194e844
 
 
 
 
 
 
 
c8b0981
 
194e844
c8b0981
 
7d8ef00
 
 
 
 
 
 
 
3947185
7d8ef00
 
 
 
 
 
194e844
7d8ef00
194e844
 
 
 
 
 
7d8ef00
194e844
7d8ef00
194e844
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d8ef00
 
194e844
 
 
 
7d8ef00
 
3947185
 
 
 
 
 
 
 
7d8ef00
194e844
7d8ef00
 
 
 
 
 
 
 
3947185
 
7d8ef00
 
 
 
 
3947185
 
 
 
 
7d8ef00
 
 
3947185
c8b0981
 
3947185
 
c8b0981
3947185
7d8ef00
3947185
7d8ef00
 
 
 
3947185
7d8ef00
194e844
c8b0981
3947185
c8b0981
3947185
 
 
 
c8b0981
3947185
 
 
7d8ef00
 
3947185
7d8ef00
3947185
7d8ef00
3947185
7d8ef00
3947185
 
 
7d8ef00
 
 
 
 
 
 
 
 
3947185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d8ef00
c8b0981
7d8ef00
194e844
 
 
 
7d8ef00
194e844
7d8ef00
194e844
 
 
 
c8b0981
 
 
 
 
 
 
 
 
3947185
 
c8b0981
 
 
 
 
 
 
7d8ef00
 
 
 
 
 
 
 
 
 
 
194e844
 
7d8ef00
 
 
 
 
 
 
3947185
7d8ef00
 
194e844
3947185
 
 
194e844
7d8ef00
 
 
c8b0981
 
 
7d8ef00
 
 
 
 
194e844
 
 
 
 
 
 
3947185
7d8ef00
 
3947185
7d8ef00
194e844
7d8ef00
194e844
 
 
 
 
7d8ef00
 
194e844
 
7d8ef00
194e844
 
3947185
 
194e844
 
7d8ef00
 
3947185
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c8b0981
 
 
 
7d8ef00
 
3947185
7d8ef00
3947185
 
 
 
7d8ef00
 
3947185
 
7d8ef00
3947185
 
 
 
 
 
 
 
7d8ef00
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
"""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 &amp; 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>"
    )