Syncing iCal/ICS feeds into Discourse topics (simple Python script, cron-friendly)

Syncing ICS / iCal Feeds into Discourse Topics

This guide shows how to set up a Python script that automatically pulls events from an ICS (iCalendar) feed and posts them as [event] topics in Discourse. It’s tested on a fresh Ubuntu 24.04 droplet.


1. Prerequisites

On your server:

apt update
apt -y install python3 python3-venv python3-pip curl nano ca-certificates
  1. App directory + virtualenv
mkdir -p /opt/ics_sync
cd /opt/ics_sync

python3 -m venv venv
source venv/bin/activate

pip install --upgrade pip
pip install requests python-dateutil icalendar

3. Environment file

Create /opt/ics_sync/.env with your Discourse + ICS settings:

cat > /opt/ics_sync/.env <<'EOF'
export DISCOURSE_BASE_URL="https://yourforum.example"
export DISCOURSE_API_KEY="PASTE_YOUR_API_KEY_HERE"
export DISCOURSE_API_USERNAME="system"

# ICS feed (URL or local path)
export ICS_SOURCE="https://example.com/feed.ics"

# Category to post into
export DISCOURSE_CATEGORY_ID=4

# Optional defaults
export DEFAULT_TAGS="events,ics"
export SITE_TZ="Europe/London"
EOF

Load it:

set -a
source .env
set +a

4. Test API access

Check you can create an event manually:

curl -sS -X POST "$DISCOURSE_BASE_URL/posts.json" \
  -H "Api-Key: $DISCOURSE_API_KEY" \
  -H "Api-Username: $DISCOURSE_API_USERNAME" \
  -F "title=API test $(date +%s)" \
  -F 'raw=[event start="2025-10-10T10:00:00Z" end="2025-10-10T11:00:00Z" timezone="Europe/London"]Test[/event]' \
  -F "category=$DISCOURSE_CATEGORY_ID"

If you see JSON back with a topic_id, your API key works.

5. The Python sync script

Save as /opt/ics_sync/ics_to_discourse.py (make it executable if you like).

Click to expand script
#!/usr/bin/env python3
import os
import argparse
import logging
from datetime import datetime, date, timedelta
from typing import Optional, Tuple

import requests
from icalendar import Calendar, Event
from dateutil.tz import gettz

# -------- Logging --------
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s"
)
log = logging.getLogger("ics_sync")

# -------- Helpers for dates --------
def _as_dt(value, site_tz):
    tz = gettz(site_tz)
    if isinstance(value, date) and not isinstance(value, datetime):
        return datetime(value.year, value.month, value.day, 0, 0, 0, tzinfo=tz)
    if isinstance(value, datetime):
        return value if value.tzinfo else value.replace(tzinfo=tz)
    raise TypeError(f"Unsupported dt value type: {type(value)}")

def _is_all_day(vevent):
    dtstart_prop = vevent.get('dtstart')
    if not dtstart_prop:
        return False
    try:
        if getattr(dtstart_prop, 'params', {}).get('VALUE') == 'DATE':
            return True
    except Exception:
        pass
    val = vevent.decoded('dtstart', None)
    return isinstance(val, date) and not isinstance(val, datetime)

def _fmt_iso_z(dt):
    dt_utc = dt.astimezone(gettz('UTC'))
    return dt_utc.replace(microsecond=0).isoformat().replace('+00:00', 'Z')

def _fmt_date(d):
    return d.date().isoformat()

def _safe_attr(s: str) -> str:
    return (s or '').replace('"', '\\"').strip()

# -------- Build Discourse body --------
def build_body(vevent, site_tz="Europe/London", rsvp=False) -> Tuple[str, str]:
    title = str(vevent.get('summary', 'Untitled event')).strip() or "Event"
    location = str(vevent.get('location', '') or '').strip()
    description = str(vevent.get('description', '') or '').strip()
    uid = str(vevent.get('uid', '') or '').strip()
    url = str(vevent.get('url', '') or '').strip()

    raw_start = vevent.decoded('dtstart', None)
    raw_end = vevent.decoded('dtend', None)
    all_day = _is_all_day(vevent)

    start_dt = _as_dt(raw_start, site_tz) if raw_start else datetime.now(gettz(site_tz))
    end_dt = _as_dt(raw_end, site_tz) if raw_end else (
        start_dt + (timedelta(days=1) if all_day else timedelta(hours=1))
    )

    attrs = []
    if all_day:
        attrs.append(f'start="{_fmt_date(start_dt)}"')
        attrs.append(f'end="{_fmt_date(end_dt)}"')  # DTEND exclusive kept as provided/derived
    else:
        attrs.append(f'start="{_fmt_iso_z(start_dt)}"')
        attrs.append(f'end="{_fmt_iso_z(end_dt)}"')
        attrs.append(f'timezone="{_safe_attr(site_tz)}"')

    if location:
        attrs.append(f'location="{_safe_attr(location)}"')
    if url:
        attrs.append(f'url="{_safe_attr(url)}"')

    if rsvp:
        attrs.append('status="public"')
    else:
        attrs.append('status="standalone"')
        attrs.append('minimal="true"')

    bbcode = "[event " + " ".join(attrs) + "]\n" + title + "\n[/event]"

    parts = [bbcode]

    # Human-friendly details block (optional)
    lines = []
    if title:
        lines.append(f"Event = {title}")
    if location:
        lines.append(f"Venue = {location}")
    if url:
        lines.append(f"\nURL = {url}")
    if uid:
        lines.append(f"\nUID: {uid}")
    if lines:
        parts.append("\n" + "\n".join(lines))

    if description:
        parts.append("\nEvent Description:\n" + description.strip())

    body = "\n".join(parts)
    return title, body

# -------- Validation & repair --------
def validate_and_repair_times(vevent, site_tz: str, repair: bool) -> Optional[str]:
    raw_start = vevent.decoded('dtstart', None)
    raw_end = vevent.decoded('dtend', None)
    if raw_start is None:
        return "Missing DTSTART"

    all_day = _is_all_day(vevent)
    start_dt = _as_dt(raw_start, site_tz)
    if raw_end is None:
        if not repair:
            return "Missing DTEND"
        vevent['dtend'] = start_dt + (timedelta(days=1) if all_day else timedelta(hours=1))
        return None

    end_dt = _as_dt(raw_end, site_tz)

    if all_day:
        if end_dt <= start_dt:
            if not repair:
                return "All-day DTEND <= DTSTART"
            vevent['dtend'] = start_dt + timedelta(days=1)
            return None
        return None

    if end_dt <= start_dt:
        if not repair:
            return "DTEND <= DTSTART"
        vevent['dtend'] = start_dt + timedelta(hours=1)
        return None

    return None

# -------- Discourse posting --------
def post_to_discourse(raw_body: str, title: str, args, tags=None):
    base = args.base_url or os.environ.get("DISCOURSE_BASE_URL")
    api_key = args.api_key or os.environ.get("DISCOURSE_API_KEY")
    api_user = args.api_user or os.environ.get("DISCOURSE_API_USERNAME", "system")
    category_id = int(args.category or os.environ.get("DISCOURSE_CATEGORY_ID", "1"))

    if not base or not api_key:
        raise RuntimeError("DISCOURSE_BASE_URL and DISCOURSE_API_KEY are required")

    url = f"{base.rstrip('/')}/posts.json"
    headers = {
        "Api-Key": api_key,
        "Api-Username": api_user,
        "Content-Type": "application/json",
    }
    payload = {
        "title": title,
        "raw": raw_body,
        "category": category_id,
    }
    if tags:
        payload["tags"] = [t.strip() for t in tags.split(",") if t.strip()]

    resp = requests.post(url, json=payload, headers=headers, timeout=60)
    if resp.status_code >= 400:
        try:
            log.error("==== Discourse API error ====")
            log.error("Status: %s", resp.status_code)
            log.error("%s", resp.json())
            log.error("==== end error ====")
        except Exception:
            log.error("HTTP %s; Body: %s", resp.status_code, resp.text)
    resp.raise_for_status()
    return resp.json()

# -------- ICS loading --------
def load_calendar(ics_source: str) -> Calendar:
    if ics_source.startswith("http://") or ics_source.startswith("https://"):
        log.info("Fetching ICS from URL: %s", ics_source)
        r = requests.get(ics_source, timeout=60)
        r.raise_for_status()
        data = r.content
    else:
        log.info("Reading ICS from file: %s", ics_source)
        with open(ics_source, "rb") as f:
            data = f.read()
    return Calendar.from_ical(data)

# -------- Main --------
def main():
    p = argparse.ArgumentParser(description="ICS → Discourse (events) sync")
    p.add_argument("--ics", dest="ics", default=os.environ.get("ICS_SOURCE"), help="ICS URL or path (or ICS_SOURCE env)")
    p.add_argument("--base-url", dest="base_url", default=os.environ.get("DISCOURSE_BASE_URL"))
    p.add_argument("--api-key", dest="api_key", default=os.environ.get("DISCOURSE_API_KEY"))
    p.add_argument("--api-user", dest="api_user", default=os.environ.get("DISCOURSE_API_USERNAME", "system"))
    p.add_argument("--category", dest="category", default=os.environ.get("DISCOURSE_CATEGORY_ID", "1"))
    p.add_argument("--tags", dest="tags", default=os.environ.get("DEFAULT_TAGS", ""))
    p.add_argument("--site-tz", dest="site_tz", default=os.environ.get("SITE_TZ", "Europe/London"))
    p.add_argument("--rsvp", action="store_true", help="Set status=public instead of standalone")
    p.add_argument("--dry-run", action="store_true", help="Do not POST; print previews only")
    p.add_argument("--future-only", action="store_true", help="Skip events that ended in the past (by site TZ)")
    p.add_argument("--skip-errors", action="store_true", default=True, help="Skip events that error when posting")
    p.add_argument("--repair-times", action="store_true", default=True, help="Auto-fix invalid/missing DTEND")

    args = p.parse_args()

    if not args.ics:
        log.error("No ICS source provided. Use --ics or set ICS_SOURCE env var.")
        return

    mode = "DRY-RUN" if args.dry_run else "LIVE POST"
    log.info("Starting ICS → Discourse sync")
    log.info("Source: %s", args.ics)
    log.info("Mode: %s", mode)
    log.info("Site TZ: %s | RSVP: %s", args.site_tz, args.rsvp)

    cal = load_calendar(args.ics)
    now_tz = datetime.now(gettz(args.site_tz))

    processed = 0
    skipped = 0
    failed = 0

    for vevent in cal.walk('vevent'):
        title = str(vevent.get('summary', 'Untitled event')).strip() or "Event"

        # Future-only filter
        raw_end = vevent.decoded('dtend', None) if 'dtend' in vevent else None
        raw_start = vevent.decoded('dtstart', None) if 'dtstart' in vevent else None
        if raw_end or raw_start:
            try:
                end_dt = _as_dt(raw_end, args.site_tz) if raw_end else None
                ref_end = end_dt or (_as_dt(raw_start, args.site_tz) + timedelta(hours=1) if raw_start else None)
                if args.future_only and ref_end and ref_end < now_tz:
                    skipped += 1
                    continue
            except Exception:
                if args.future_only:
                    skipped += 1
                    continue

        # Validate/repair times
        err = validate_and_repair_times(vevent, args.site_tz, repair=args.repair_times)
        if err:
            logging.warning("Skipping '%s': %s", title, err)
            skipped += 1
            continue

        post_title, body = build_body(vevent, site_tz=args.site_tz, rsvp=args.rsvp)

        if args.dry_run:
            print("\n=== PREVIEW ===\n")
            print(body)
            processed += 1
            continue

        try:
            resp = post_to_discourse(body, post_title, args, tags=args.tags)
            topic_id = resp.get("topic_id")
            if topic_id:
                log.info("Posted event '%s' → Topic ID %s", post_title, topic_id)
            processed += 1
        except requests.HTTPError as e:
            failed += 1
            if args.skip_errors:
                log.warning("Post failed for '%s' (%s). Skipping and continuing.", post_title, e)
                continue
            else:
                raise
        except Exception as e:
            failed += 1
            if args.skip_errors:
                log.warning("Unexpected error for '%s': %s", post_title, e)
                continue
            else:
                raise

    log.info("Done. Processed %d event(s). Skipped %d. Failed %d.", processed, skipped, failed)

if __name__ == "__main__":
    main()
  1. Running it
cd /opt/ics_sync
source venv/bin/activate
set -a; source .env; set +a

# Dry run (prints but does not post):
python3 ics_to_discourse.py --dry-run --future-only

# Live run:
python3 ics_to_discourse.py --future-only

7. Automating with systemd (optional)

Create a service:

cat > /etc/systemd/system/ics-sync.service <<'EOF'
[Unit]
Description=ICS to Discourse sync

[Service]
Type=oneshot
WorkingDirectory=/opt/ics_sync
EnvironmentFile=/opt/ics_sync/.env
ExecStart=/opt/ics_sync/venv/bin/python /opt/ics_sync/ics_to_discourse.py --future-only
EOF

Create a timer (e.g. hourly):

cat > /etc/systemd/system/ics-sync.timer <<'EOF'
[Unit]
Description=Run ICS sync hourly

[Timer]
OnCalendar=hourly
Persistent=true

[Install]
WantedBy=timers.target
EOF

systemctl daemon-reload
systemctl enable --now ics-sync.timer