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

Continuing the discussion from :date: Support iCal Feed Sync in Discourse Calendar Plugin (Import from .ics URLs):


Many of us want iCal/ICS import/sync to complement the official Calendar/Event features. While that’s not built-in yet, here’s a small Python script you can run on a schedule to pull an .ics feed and create/update topics in a chosen category. It’s idempotent and uses each event’s UID to avoid duplicates.

What it does (quick)
  • Fetches your .ics feed.
  • For each VEVENT:
    • Derives a stable tag from the ICS UID (e.g. ics-deadbeef12).
    • If a topic with that tag exists → updates the first post.
    • If not → creates a new topic in your chosen category with your default tags.
  • Formats event details (summary, start/end, location, description, URL) into Markdown.
  • Safe to rerun (e.g., every 15–30 minutes).

Requirements

  • Discourse API key (admin/global) and API username (e.g. system).

  • Tags enabled on your forum.

  • Python 3.10+ and:

    pip install requests icalendar python-dateutil pytz

  • A scheduler (cron, systemd timer, GitHub Actions runner, etc.).


Configure via environment variables

export DISCOURSE_BASE_URL="https://forum.example.com"
export DISCOURSE_API_KEY="YOUR_API_KEY"
export DISCOURSE_API_USERNAME="system"

export ICS_URL="https://calendar.example.com/feed.ics"
export CATEGORY_ID="42"                 # numeric category id
export DEFAULT_TAGS="events,ics"        # comma-separated
export SITE_TZ="Europe/London"          # display timezone

The script

#!/usr/bin/env python3
"""
ICS -> Discourse topic sync
"""

import os, hashlib, requests, sys, html
from datetime import datetime
from dateutil import tz
from icalendar import Calendar

BASE = os.environ["DISCOURSE_BASE_URL"].rstrip("/")
API_KEY = os.environ["DISCOURSE_API_KEY"]
API_USER = os.environ.get("DISCOURSE_API_USERNAME", "system")
ICS_URL = os.environ["ICS_URL"]
CATEGORY_ID = int(os.environ["CATEGORY_ID"])
DEFAULT_TAGS = [t.strip() for t in os.environ.get("DEFAULT_TAGS", "events,ics").split(",") if t.strip()]
SITE_TZ = os.environ.get("SITE_TZ", "Europe/London")

S = requests.Session()
S.headers.update({
    "Api-Key": API_KEY,
    "Api-Username": API_USER,
    "Content-Type": "application/json"
})

def fetch_ics(url: str) -> Calendar:
    r = requests.get(url, timeout=60)
    r.raise_for_status()
    return Calendar.from_ical(r.content)

def to_aware_dt(v):
    dt = getattr(v, "dt", v)
    if isinstance(dt, datetime):
        if dt.tzinfo is None:
            dt = dt.replace(tzinfo=tz.UTC)
    else:
        dt = datetime(dt.year, dt.month, dt.day, tzinfo=tz.UTC)
    site = tz.gettz(SITE_TZ)
    return dt.astimezone(site)

def uid_tag(uid: str) -> str:
    h = hashlib.sha1(uid.encode("utf-8")).hexdigest()[:10]
    return f"ics-{h}"

def discourse_search_by_tag(tag: str):
    r = S.get(f"{BASE}/search.json", params={"q": f"tag:{tag}"}, timeout=60)
    r.raise_for_status()
    js = r.json()
    return js.get("topics") or js.get("results") or []

def get_topic_first_post_id(topic_id: int) -> int:
    r = S.get(f"{BASE}/t/{topic_id}.json", timeout=60)
    r.raise_for_status()
    js = r.json()
    return js["post_stream"]["posts"][0]["id"]

def create_topic(title: str, raw: str, tags):
    payload = {"title": title,"raw": raw,"category": CATEGORY_ID,"tags[]": tags}
    r = S.post(f"{BASE}/posts.json", data=payload, timeout=60)
    r.raise_for_status()
    return r.json()

def update_first_post(post_id: int, new_raw: str):
    r = S.put(f"{BASE}/posts/{post_id}.json", json={"raw": new_raw}, timeout=60)
    r.raise_for_status()
    return r.json()

def fmt_when(start_local, end_local):
    same_day = (start_local.date() == end_local.date())
    if same_day:
        return f"{start_local:%a %d %b %Y, %H:%M}–{end_local:%H:%M} ({SITE_TZ})"
    return f"{start_local:%a %d %b %Y, %H:%M} → {end_local:%a %d %b %Y, %H:%M} ({SITE_TZ})"

def build_body(summary, start_local, end_local, loc, desc, src_url):
    parts = []
    parts.append(f"**When:** {fmt_when(start_local, end_local)}")
    if loc: parts.append(f"**Where:** {loc}")
    if src_url: parts.append(f"**Source calendar:** {src_url}")
    parts.append("")
    if desc: parts.append(desc); parts.append("")
    parts.append("<small>Synced from ICS • Do not edit manually; changes will be overwritten on next sync.</small>")
    return "\n".join(parts)

def event_title(summary, start_local):
    return f"{summary} — {start_local:%Y-%m-%d %H:%M}"

def clean_text(v):
    if not v: return ""
    return html.unescape(str(v)).strip()

def main():
    cal = fetch_ics(ICS_URL)
    created, updated, skipped = 0, 0, 0
    for comp in cal.walk():
        if comp.name != "VEVENT": continue
        uid = clean_text(comp.get("UID"))
        if not uid: skipped += 1; continue
        tag = uid_tag(uid)
        summary = clean_text(comp.get("SUMMARY") or "Untitled event")
        loc = clean_text(comp.get("LOCATION"))
        desc = clean_text(comp.get("DESCRIPTION"))
        src_url = clean_text(comp.get("URL") or ICS_URL)
        start_local = to_aware_dt(comp.get("DTSTART"))
        end_local = to_aware_dt(comp.get("DTEND") or comp.get("DTSTART"))
        title = event_title(summary, start_local)
        body = build_body(summary, start_local, end_local, loc, desc, src_url)
        topics = discourse_search_by_tag(tag)
        if topics:
            tid = topics[0]["id"]
            pid = get_topic_first_post_id(tid)
            update_first_post(pid, body); updated += 1
        else:
            tags = DEFAULT_TAGS + [tag]
            create_topic(title, body, tags); created += 1
    print(f"Done. Created: {created}, Updated: {updated}, Skipped: {skipped}")

if __name__ == "__main__":
    try: main()
    except Exception as e: sys.stderr.write(f"Error: {e}\n"); sys.exit(1)

Cron example


*/30 * * * * /usr/bin/env -S bash -lc 'cd /opt/ics-sync && ./ics_to_discourse.py >> sync.log 2>&1'

Notes & caveats
•	Uses stable UID-based tags (e.g. ics-deadbeef12) to find/update topics.
•	Overwrites the first post each run; fence manual content if needed.
•	Multiple calendars: run with different ICS_URL + category/tag sets.
•	Meant as a stop-gap until ICS import/sync lands officially.
4 Likes

Hi everyone,

I’m not a coder, but I have previously managed to set up the official mail-receiver in Discourse by following the UI-based guide and configuring the API key directly via the admin panel—so I can handle step-by-step instructions and copying values when they’re spelled out clearly.

I came across this Python script for syncing .ICS feeds into Discourse topics, and it looks promising. But I’m stuck because I haven’t coded before and don’t know how to translate it into something I can manage.

Could someone please walk me through the simplest possible version of this—ideally with:

  1. Exactly what needs to go into each field or variable (e.g., how to locate or paste in the API key, base URL, category ID, etc.).
  2. The most basic instructions to schedule the script (like a cron line, but explained step by step).
  3. Any parts of the script I can simply copy and paste rather than having to understand Python.

Basically, I want to get this working the same way I managed the mail-receiver—via clear copy-and-paste instructions—even if someone has to point out exactly what parts to paste where.

Thanks in advance for helping a non-coder get this running!

3 Likes

This looks really useful, thanks for sharing.

I can see how it would work for people who are comfortable running a Python script on a server with cron, but like Ethsim2 I’m not a coder. I’ve followed admin guides before (mail-receiver, API keys, etc.), so I can copy/paste values when told where they go, but I’d need a clearer breakdown of each step.

Would anyone be up for turning this into a “for admins” guide, similar to how the official mail-receiver instructions are written? That way more of us could test it and report back. It might also get wider adoption if it was packaged as a plugin or even just a template container that could be pulled in.

Happy to help test from the non-coder side if someone can show the way.

3 Likes

Yes, exactly — that’s what I was hoping for too.

When I set up the official mail-receiver, I managed because the guide explained where to create the API key in the Discourse UI and then which values to paste into the config. I didn’t need to touch any code beyond copying what was shown.

If this ICS sync could be explained in the same way — “get your API key here, paste it here, run this exact command” — then I think people like me could get it working. A plugin or a template would make it even more accessible.

I’d definitely be willing to test and give feedback if someone can lay it out step-by-step for non-coders.

2 Likes

Interesting approach.

I can see how a script like this is a good proof of concept, but I wonder whether it points toward a bigger gap: there isn’t currently an “official” way of consuming inbound ICS feeds in Discourse. Right now it’s up to custom scripts or manual import.

If this functionality were wrapped into a plugin (even just a thin layer over the API), it could cover a lot of real use cases:

  • University timetables or council meeting calendars flowing straight into topics.
  • Community events kept in sync without anyone having to re-enter details.
  • Easier testing and adoption by admins who aren’t comfortable running custom scripts.

So the question for me is: should this stay as an external script, or does it belong as a proper plugin that others can maintain and improve together? I’d be interested to hear what others think.

2 Likes

Looks cool, thank you for sharing ! Is the first topic that gets created from the external sources becomes an event visible in the discourse calendar from the thread?

1 Like

Thanks for the kind words, I really appreciate it.

From what I can see, the current script just brings the .ics entries into Discourse as topics — it doesn’t yet make them full calendar events that the #discourse-calendar plugin would recognise. But that’s still a useful step, and it shows the direction things can go.

If you do have a version of your own that you’re working on, please don’t hesitate to share it here. Even if it’s just a rough draft, it could give others a chance to try it out, compare approaches, and help us move toward a more complete solution together.

You’re welcome. I’m sorry but for the moment, my priority would be ical url exports from discourse events and not import, but I will share here if I ever come up with something. For now I just modify small stuffs like here to improve the create event button placement that is by default hidden in the plus menu.

Regarding your script and its content injection in the topics body, I remember that in the past you could add the syntax below to the composer and get an event created out of it :slight_smile:

As it doesn’t work anymore, I wonder if anyone knows if there is a new syntax as this could be integrated to your script in order to have the external event visible in the calendar of the category and in the global calendar as well.(well I don’t know if this would be as easy as that but who knows)

It used to be this syntax :

[event start="2025-11-16 19:00" status="public" name="585 test event" timezone="Europe/Paris" allowedGroups="trust_level_0" end="2025-11-16 21:00"] [/event]