From 8b8d49c79333930da1bba725614cbc08e88a64eb Mon Sep 17 00:00:00 2001 From: Greg V Date: Sun, 12 Jul 2026 18:32:14 -0700 Subject: [PATCH] [Slackbot] Fix calendar id --- api/praisebot/praisebot_service.py | 41 +++++++++++++++++++ api/praisebot/tests/test_praisebot_service.py | 26 ++++++++++++ 2 files changed, 67 insertions(+) diff --git a/api/praisebot/praisebot_service.py b/api/praisebot/praisebot_service.py index 03b6340..4ae368f 100644 --- a/api/praisebot/praisebot_service.py +++ b/api/praisebot/praisebot_service.py @@ -11,11 +11,13 @@ - calendar_reminder (Google Calendar ICS reminder config) - community (#introductions matchmaker + weekly community digest) """ +import base64 import re import threading import time import uuid from datetime import datetime, timezone +from urllib.parse import parse_qs, unquote, urlparse from db.db import get_db from common.log import get_logger @@ -72,6 +74,37 @@ def _normalize_repo(repo): return None +def _normalize_calendar_id(value): + """Reduce any pasted Google Calendar reference to the bare calendar ID. + + Accepts the ID itself (possibly URL-encoded), a share link + (…?cid=), an embed link (…?src=), or an ICS link + (…/calendar/ical//public/basic.ics). + """ + if not isinstance(value, str): + return value + value = value.strip() + if value.lower().startswith(("http://", "https://")): + try: + parsed = urlparse(value) + qs = parse_qs(parsed.query) + if qs.get("cid"): + cid = qs["cid"][0].replace("-", "+").replace("_", "/") + cid += "=" * ((4 - len(cid) % 4) % 4) + decoded = base64.b64decode(cid).decode("utf-8", errors="ignore") + if "@" in decoded: + value = decoded + elif qs.get("src"): + value = qs["src"][0] + else: + m = re.search(r"/calendar/ical/([^/]+)/", parsed.path) + if m: + value = unquote(m.group(1)) + except Exception: + pass + return unquote(value) + + def _normalize_channels(channels): """Accept a list or comma-separated string of channel names/IDs.""" if isinstance(channels, str): @@ -145,6 +178,14 @@ def _validate_doc(doc_type, payload): errors.append("rollup.channel is required when rollup is enabled") elif doc_type == "calendar_reminder": + if "calendar_id" in payload: + normalized = _normalize_calendar_id(payload["calendar_id"]) + if not isinstance(normalized, str) or "@" not in normalized: + errors.append( + "calendar_id must be a Google Calendar ID " + "(…@group.calendar.google.com) or a Google Calendar share link") + else: + payload["calendar_id"] = normalized if "channels" in payload: channels = _normalize_channels(payload["channels"]) if not channels: diff --git a/api/praisebot/tests/test_praisebot_service.py b/api/praisebot/tests/test_praisebot_service.py index 186e0b4..90d583a 100644 --- a/api/praisebot/tests/test_praisebot_service.py +++ b/api/praisebot/tests/test_praisebot_service.py @@ -114,6 +114,32 @@ def test_calendar_reminder_bounds(self, db): assert svc.create_config_doc({**base, "lead_minutes": 0}, ACTOR)[1] == 400 assert svc.create_config_doc({**base, "lead_minutes": 500}, ACTOR)[1] == 400 + def test_calendar_id_normalizes_share_links(self, db): + real_id = ("c_15c6f25ddc611081a1c59ef917c647fb48a58ae716916c5792" + "eede6a2236ed10@group.calendar.google.com") + import base64 + cid = base64.b64encode(real_id.encode()).decode().rstrip("=") + base = { + "type": "calendar_reminder", "name": "Office hours", "enabled": True, + "channels": ["general"], "lead_minutes": 15, "poll_cron": "*/5 * * * *", + } + cases = [ + f"https://calendar.google.com/calendar/u/0?cid={cid}", + f"https://calendar.google.com/calendar/embed?src={real_id}", + f"https://calendar.google.com/calendar/ical/{real_id.replace('@', '%40')}/public/basic.ics", + real_id.replace("@", "%40"), + real_id, + ] + for pasted in cases: + body, status = svc.create_config_doc({**base, "calendar_id": pasted}, ACTOR) + assert status == 201, f"failed for {pasted}: {body}" + stored = db.collection(svc.COLLECTION).document(body["id"]).get().to_dict() + assert stored["calendar_id"] == real_id, f"not normalized for {pasted}" + + body, status = svc.create_config_doc({**base, "calendar_id": "not-a-calendar"}, ACTOR) + assert status == 400 + assert "calendar_id" in body["error"] + def test_community_is_singleton(self, db): community = { "type": "community", "enabled": True, "intro_channel": "introductions",