مزامنة خلاصات iCal/ICS في مواضيع Discourse (برنامج Python بسيط، متوافق مع cron)


ICS → Discourse sync: update event details (date/time/location) without changing title/category — and (optionally) add an edit reason

This post documents the behaviour of my ics_to_discourse.py script and a small optional patch to add a visible edit reason for post edits made by the sync. It’s designed for Discourse sites using the [event] BBCode (from discourse-calendar / events) and an external ICS feed.

What the script already does
•	Idempotent by ICS UID: one topic per UID.
•	Preserves human-edited titles on update.
•	Does NOT change category on update.
•	Merges tags on update (never drops moderator/manual tags).
•	Updates only the first post body when content changes (compares while ignoring an invisible marker).
•	Adds an invisible marker (<!-- ICSUID:... -->) so the topic can be reliably found next time.
•	Site-wide dedupe: on create, scans /latest.json pages to adopt an existing topic if start/end/location match (with legacy time-encoding tolerance).
What users see when updates happen
•	The date, time, and location in the first post’s [event ...] block are updated from the ICS.
•	The script does not change title or category on update.
•	By default, no edit reason is shown (Discourse revision history will have a blank reason).
•	If you enable the optional patch below, the revision will show a reason such as “Updated from ICS feed”.

Environment variables

The script supports these environment variables (via .env or your unit file). The new one is optional.


DISCOURSE_BASE_URL=https://forum.example.com
DISCOURSE_API_KEY=xxxxx
DISCOURSE_API_USERNAME=system
DISCOURSE_CATEGORY_ID=12            # used on CREATE only
DISCOURSE_DEFAULT_TAGS=calendar,events
SITE_TZ=Europe/London               # render times in this timezone

# NEW (optional): default edit reason for updates
DISCOURSE_EDIT_REASON=Updated from ICS feed

You can still override most of these with CLI flags.


CLI usage

# Minimal
python3 ics_to_discourse.py --ics https://example.com/cal.ics --category-id 12

# With static tags merged on create/update
python3 ics_to_discourse.py --ics https://example.com/cal.ics \
  --category-id 12 \
  --static-tags calendar,events

# Optional: include an edit reason (after patch below)
python3 ics_to_discourse.py --ics https://example.com/cal.ics \
  --category-id 12 \
  --static-tags calendar,events \
  --edit-reason "Updated from ICS feed"

One-line summary of the change

Yes, the script updates date/time/location in the first post body and, by default, does not set an edit reason.
The small patch below adds an optional --edit-reason flag (or DISCOURSE_EDIT_REASON) so Discourse shows a reason in the post revision.


Patch (minimal, safe)

Apply this diff to your script to support an edit reason:


diff --git a/ics_to_discourse.py b/ics_to_discourse.py
@@
 BASE         = os.environ.get("DISCOURSE_BASE_URL", "").rstrip("/")
 API_KEY      = os.environ.get("DISCOURSE_API_KEY", "")
 API_USER     = os.environ.get("DISCOURSE_API_USERNAME", "system")
 ENV_CAT_ID   = os.environ.get("DISCOURSE_CATEGORY_ID", "")
 DEFAULT_TAGS = [t.strip() for t in os.environ.get("DISCOURSE_DEFAULT_TAGS", "").split(",") if t.strip()]
 
 SITE_TZ_DEFAULT = os.environ.get("SITE_TZ", "Europe/London")
+EDIT_REASON = os.environ.get("DISCOURSE_EDIT_REASON", "")
@@
-def update_first_post_raw(s: requests.Session, post_id: int, new_raw: str) -> Dict[str, Any]:
-    return put_form(s, f"/posts/{post_id}.json", [("post[raw]", new_raw)])
+def update_first_post_raw(
+    s: requests.Session,
+    post_id: int,
+    new_raw: str,
+    edit_reason: str | None = None,
+) -> Dict[str, Any]:
+    fields = [("post[raw]", new_raw)]
+    if edit_reason:
+        fields.append(("post[edit_reason]", edit_reason))
+    return put_form(s, f"/posts/{post_id}.json", fields)
@@ def sync_event(s: requests.Session, ev, args) -> Tuple[int | None, bool]:
-        if old_clean.strip() != fresh_clean.strip():
+        if old_clean.strip() != fresh_clean.strip():
             log.info("Updating topic %s first post.", topic_id)
-            update_first_post_raw(s, post_id, fresh_raw)
+            update_first_post_raw(s, post_id, fresh_raw, getattr(args, "edit_reason", None))
@@ def main() -> None:
-    ap = argparse.ArgumentParser(description="Sync an ICS into Discourse topics (idempotent by UID).")
+    ap = argparse.ArgumentParser(description="Sync an ICS into Discourse topics (idempotent by UID).")
@@
-    ap.add_argument("--static-tags", default="", help="Comma separated static tags to add on create/update (merged with existing)")
+    ap.add_argument("--static-tags", default="", help="Comma separated static tags to add on create/update (merged with existing)")
     ap.add_argument("--scan-pages", type=int, default=8, help="How many /latest pages to scan site-wide for duplicates (default: 8)")
     ap.add_argument("--time-only-dedupe", action="store_true", default=False,
                     help="Treat events with same start/end as duplicates regardless of location (location becomes 'close' check)")
+    ap.add_argument("--edit-reason", default=EDIT_REASON,
+                    help='Optional edit reason, e.g. "Updated from ICS feed"')
     args = ap.parse_args()

Behaviour with the patch:
• If --edit-reason (or DISCOURSE_EDIT_REASON) is non-empty, the Discourse revision shows that reason.
• Leave it empty to keep edits silent (no reason string).


Systemd example

Using an .env file:

/opt/ics-sync/.env


DISCOURSE_BASE_URL=https://forum.example.com
DISCOURSE_API_KEY=xxxxx
DISCOURSE_API_USERNAME=system
DISCOURSE_CATEGORY_ID=12
DISCOURSE_DEFAULT_TAGS=calendar,events
SITE_TZ=Europe/London
DISCOURSE_EDIT_REASON=Updated from ICS feed
ICS_URL=https://example.com/my.ics

Service (excerpt):

# /etc/systemd/system/ics-sync.service
[Unit]
Description=ICS → Discourse sync
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
WorkingDirectory=/opt/ics-sync/discourse-ics-sync-tags
EnvironmentFile=/opt/ics-sync/.env
ExecStart=/usr/bin/flock -n /var/lock/ics-sync.lock /bin/bash -lc '\
  . /opt/ics-sync/.venv/bin/activate && \
  python3 -u ics_to_discourse.py \
    --ics "$ICS_URL" \
    --category-id "$DISCOURSE_CATEGORY_ID" \
    --site-tz "$SITE_TZ" \
    --static-tags "${DISCOURSE_DEFAULT_TAGS:-}" \
    ${DISCOURSE_EDIT_REASON:+--edit-reason "$DISCOURSE_EDIT_REASON"} \
'
StandardOutput=append:/opt/ics-sync/sync.log
StandardError=append:/opt/ics-sync/sync.log
Nice=10

Timer (hourly,

# /etc/systemd/system/ics-sync.timer
[Unit]
Description=Run ICS → Discourse sync hourly

[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
Persistent=false
Unit=ics-sync.service

[Install]
WantedBy=timers.target

Notes & gotchas

•	No title/category changes on update: by design, only the first post body is touched if the content changed.
•	Tag merging: existing tags are preserved; the script adds static tags + ics-<short-hash> for the UID.
•	Dedupe: on create, the script can adopt an existing topic if its [event] block’s start/end/location matches (with legacy “floating-time treated as UTC” tolerance). --time-only-dedupe loosens location to “close enough”.
•	Silent vs reasoned edits: leaving the reason blank is fine; using --edit-reason just helps audit what the bot changed.

Not bumping topics: If you also want updates not to bump the topic, that’s a separate tweak (call the “reset bump date” endpoint after editing). Shout if you want a snippet for that.