Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions api/praisebot/praisebot_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=<base64url of ID>), an embed link (…?src=<ID>), or an ICS link
(…/calendar/ical/<ID>/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):
Expand Down Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions api/praisebot/tests/test_praisebot_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading