Project Overview This tool automatically synchronizes events from a Google Calendar to a Discord channel. It is designed to be "state-aware," meaning it detects changes (edits) and deletions, ensuring your Discord channel always reflects the actual state of your calendar.
Smart Updates: Edits existing Discord messages when event details change (Time, Location, Description) instead of posting duplicates.
Auto-Delete: Removing an event from Google Calendar automatically removes the announcement from Discord.
Weekly Summary: Optionally posts a "Week at a Glance" list at the bottom of the channel.
Rate Limit Protection: Built-in pauses prevent Discord API bans.
Before installing the script, ensure you have:
Google Account: Access to the Google Calendar you wish to sync.
Discord Permissions: "Manage Webhooks" permission in the target Discord server.
Navigate to your Discord Server Settings.
Select Integrations > Webhooks.
Click New Webhook.
Name the bot (e.g., "Event Bot") and select the target Channel.
Click Copy Webhook URL. (Save this for later).
Open Google Calendar.
Locate your calendar on the left sidebar.
Click the three dots > Settings and sharing.
Scroll down to the Integrate calendar section.
Copy the Calendar ID.
Note: For personal calendars, this is usually your email address.
Visit Google Apps Script.
Click New Project.
Name the project (e.g., "Discord Sync").
Paste the Script: Clear the editor and paste the provided .gs code.
Locate the CALENDAR_CONFIG section at the top of the script and update the fields:
const CALENDAR_CONFIG = [
{
name: "Community Events",
calendarId: "YOUR_CALENDAR_ID_HERE",
webhookUrl: "YOUR_WEBHOOK_URL_HERE",
summaryWebhookUrl: "", // Optional: Add a webhook URL here for summary lists
lookaheadDays: 14, // Days to scan for individual posts
summaryLookahead: 10, // Days to include in the summary list
// Do not change these keys unless setting up multiple calendars
fingerprintKey: "FINGERPRINTS_A",
messageMapKey: "MSG_MAP_A",
summaryMapKey: "SUMMARY_MSG_ID_A"
}
];
In the toolbar, select the function syncAllCalendars.
Click Run.
Authorize: Google will ask for permissions.
Note: Since this is a custom script, you may need to click "Advanced" > "Go to (Project Name) (unsafe)" to proceed.
Check the Execution Log at the bottom to verify events are being found.
To make the script run automatically:
Select the function setupTriggers from the toolbar.
Click Run.
Verify the log says: ✅ Trigger set! Script will run every hour.
Why Your Support Matters
In an increasingly digital world, staying connected is more than just a convenience—it’s the heartbeat of our community. This synchronization tool was born out of a simple need: to make sure no one misses out on a moment that matters. Whether it's a critical strategy meeting, a casual hangout, or a local event, keeping our schedules aligned helps us build stronger bonds and more vibrant spaces.
We believe that high-quality, reliable community tools should be accessible to everyone. Your donations directly support the continued maintenance, hosting, and development of these systems.
Our Story
We started this project because we saw communities struggling with fragmented information. People were missing events, and organizers were spending more time updating posts than engaging with their members. By automating these "boring" tasks, we free up our time for what truly matters: people.
When you donate, you aren't just paying for code; you are investing in the infrastructure that keeps our community organized, informed, and together. Every contribution helps us keep the lights on and the updates rolling.
Thank you for being the reason we can keep building.
Q: My Discord messages show HTML tags like <br> or <p>. A: The script includes a cleanHtml() function to strip these tags. If you see them, your calendar description might use complex formatting. The script attempts to convert standard tags to newlines automatically.
Q: The script says "Message Not Found" in the logs. A: This happens if you manually deleted the message in Discord. The script tries to edit the old message, fails, and then self-corrects by posting a new one. This is normal behavior.
Q: How do I stop the bot? A: In the Google Apps Script editor, go to the Triggers icon (alarm clock) on the left sidebar and delete the syncAllCalendars trigger.
Q: I want to force a full re-sync. A: Run the clearScriptMemory function manually. This will erase the bot's memory, causing it to treat all upcoming events as "New" and repost them.
This script uses MD5 Hashing to track state.
Fingerprinting: Every time the script runs, it generates a unique "fingerprint" string based on the Event Title, Time, Location, and Description.
Comparison: It compares this new fingerprint to the one stored in the script's database from the last run.
Action:
Different Fingerprint: The event has been edited -> Update Discord.
No Fingerprint: The event is new -> Post to Discord.
Identical Fingerprint: No changes -> Ignore.
* ============================================================================
* GOOGLE CALENDAR TO DISCORD SYNC (Generic Version)
* ============================================================================
* * PURPOSE:
* This script monitors specific Google Calendars and syncs upcoming events
* to Discord channels via Webhooks.
* * FEATURES:
* 1. Individual Event Posts: Creates a dedicated embed for new events.
* 2. Edit Detection: If a calendar event changes (time, desc, title), the
* Discord message updates automatically.
* 3. Deletion Detection: If an event is deleted from Calendar, it is removed
* from Discord.
* 4. Weekly Summary: Generates a single list of all upcoming events for a
* quick overview.
* * INSTRUCTIONS:
* 1. Fill out the CALENDAR_CONFIG array below.
* 2. Run 'setupTriggers' once to automate the script.
*/
// ============================================================================
// 1. CONFIGURATION
// ============================================================================
const CALENDAR_CONFIG = [
{
// Friendly name for logging purposes
name: "Community Events",
// The Google Calendar ID (found in Calendar Settings > Integrate Calendar)
calendarId: "YOUR_CALENDAR_ID_HERE@group.calendar.google.com",
// The Webhook URL for posting individual event cards
webhookUrl: "https://discord.com/api/webhooks/...",
// (Optional) The Webhook URL for posting the weekly summary list.
// Can be the same as above, or a different channel. Leave empty "" to disable.
summaryWebhookUrl: "https://discord.com/api/webhooks/...",
// SYNC SETTINGS
lookaheadDays: 14, // How many days into the future to scan for individual cards
summaryLookahead: 10, // How many days to include in the summary list
// DATA STORE KEYS (Unique IDs to save state in Script Properties)
// Change these if you add a second calendar config block (e.g., "FINGERPRINTS_B")
fingerprintKey: "FINGERPRINTS_A",
messageMapKey: "MSG_MAP_A",
summaryMapKey: "SUMMARY_MSG_ID_A"
}
];
// ============================================================================
// 2. MAIN EXECUTION FUNCTIONS
// ============================================================================
/**
* Main entry point. Run this function manually or via trigger.
* It iterates through every configuration block defined above.
*/
function syncAllCalendars() {
const props = PropertiesService.getScriptProperties();
CALENDAR_CONFIG.forEach(config => {
try {
console.log(`>>> Starting Sync for: ${config.name}`);
// 1. Handle Individual Event Cards (Create/Edit/Delete)
processEventCards(config, props);
// 2. Handle the Summary List (if configured)
if (config.summaryWebhookUrl) {
Utilities.sleep(2000); // Rate limit safety
generateSummaryList(config, props);
}
console.log(`<<< Finished Sync for: ${config.name}`);
} catch (e) {
console.error(`CRITICAL ERROR processing ${config.name}: ${e.message} at line ${e.lineNumber}`);
}
});
}
/**
* Handles the logic for individual event embeds.
* It compares the current state of the calendar against the saved state (PropertiesService).
*/
function processEventCards(config, props) {
const now = new Date();
const limitDate = new Date(now.getTime() + (config.lookaheadDays * 24 * 60 * 60 * 1000));
// Load saved state (Data Persistence)
// fingerprints: Stores MD5 hashes of events to detect changes.
// messageMap: Maps Google Event IDs to Discord Message IDs.
let fingerprints = JSON.parse(props.getProperty(config.fingerprintKey) || "{}");
let messageMap = JSON.parse(props.getProperty(config.messageMapKey) || "{}");
// Fetch Events from Google
const events = Calendar.Events.list(config.calendarId, {
timeMin: now.toISOString(),
timeMax: limitDate.toISOString(),
singleEvents: true,
orderBy: 'startTime'
}).items || [];
console.log(`Found ${events.length} upcoming events in window.`);
const currentEventIds = events.map(e => e.id);
// --- STEP 1: CLEANUP (Delete Discord messages for events that no longer exist) ---
Object.keys(messageMap).forEach(storedEventId => {
// If the event ID is in our database, but not in the fetch results...
if (!currentEventIds.includes(storedEventId)) {
console.log(`Event ${storedEventId} removed or outside window. Deleting from Discord.`);
deleteFromDiscord(messageMap[storedEventId], config.webhookUrl);
// Remove from our local state
delete messageMap[storedEventId];
delete fingerprints[storedEventId];
Utilities.sleep(500); // Prevent Discord rate limiting
}
});
// --- STEP 2: PROCESS (Create or Update messages) ---
events.forEach(event => {
const eventId = event.id;
// Generate a "Fingerprint" (MD5 Hash) of the data we care about.
// If the Title, Location, Time, or Description changes, the hash changes.
const rawDataString = `${event.summary}|${event.location}|${event.start.dateTime || event.start.date}|${event.description}`;
const newFingerprint = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, rawDataString).join("");
const isNew = !messageMap[eventId];
const isChanged = fingerprints[eventId] !== newFingerprint;
// Only hit the Discord API if something is new or changed
if (isNew || isChanged) {
console.log(`Processing update for: ${event.summary}`);
const payload = buildEventEmbed(event, isNew);
// If it exists, try to Edit (PATCH). If that fails (msg deleted), Post new (POST).
// If it's new, just Post (POST).
let discordMessageId;
if (!isNew && messageMap[eventId]) {
discordMessageId = editInDiscord(messageMap[eventId], payload, config.webhookUrl);
} else {
discordMessageId = postToDiscord(payload, config.webhookUrl);
}
// Save the result to our state
if (discordMessageId) {
messageMap[eventId] = discordMessageId;
fingerprints[eventId] = newFingerprint;
}
Utilities.sleep(1000); // Rate limit safety
}
});
// Save state back to Script Properties
props.setProperty(config.fingerprintKey, JSON.stringify(fingerprints));
props.setProperty(config.messageMapKey, JSON.stringify(messageMap));
}
/**
* Generates a single (or paged) message summarizing all upcoming events.
* This deletes the old summary and posts a fresh one every time to ensure
* it is always at the bottom of the channel.
*/
function generateSummaryList(config, props) {
console.log(`Generating Summary for ${config.name}...`);
const now = new Date();
const limitDate = new Date(now.getTime() + (config.summaryLookahead * 24 * 60 * 60 * 1000));
// --- Cleanup OLD summary messages ---
// We store previous summary IDs to delete them so the channel doesn't get cluttered.
const oldSummaryIds = JSON.parse(props.getProperty(config.summaryMapKey) || "[]");
if (Array.isArray(oldSummaryIds)) {
oldSummaryIds.forEach(id => {
deleteFromDiscord(id, config.summaryWebhookUrl);
Utilities.sleep(500);
});
}
// Fetch events specifically for the summary window
const events = Calendar.Events.list(config.calendarId, {
timeMin: now.toISOString(),
timeMax: limitDate.toISOString(),
singleEvents: true,
orderBy: 'startTime'
}).items || [];
if (events.length === 0) return;
// --- Pagination Logic ---
// Discord has a 4096 char limit per embed. We split into chunks if needed.
const MAX_CHARS = 3800;
let chunks = [];
let currentChunk = `## 📅 ${config.name}: Next ${config.summaryLookahead} Days\n`;
let currentDayLabel = "";
events.forEach(event => {
// Format Date header (e.g., "Monday, Jan 1")
const start = new Date(event.start.dateTime || event.start.date);
const dayLabel = Utilities.formatDate(start, Session.getScriptTimeZone(), "EEEE MMM d");
let eventEntry = "";
// Add a header if the day changes
if (dayLabel !== currentDayLabel) {
eventEntry += `\n### **${dayLabel}**\n`;
currentDayLabel = dayLabel;
}
// Format Time (e.g., "7:00 PM" or "All Day")
const timeRange = event.start.dateTime
? `\`${Utilities.formatDate(start, Session.getScriptTimeZone(), "h:mm a")}\``
: "`All Day`";
// Create the line item
eventEntry += `* ${timeRange} **${event.summary}**`;
if(event.location) {
// clean location (grab text before the first comma to save space)
const shortLoc = event.location.split(',')[0];
eventEntry += ` (${shortLoc})`;
}
eventEntry += `\n`;
// Check size limit before adding
if ((currentChunk.length + eventEntry.length) > MAX_CHARS) {
chunks.push(currentChunk);
currentChunk = `## 📅 ${config.name} (Cont.)\n` + eventEntry;
} else {
currentChunk += eventEntry;
}
});
// Add the final chunk
if (currentChunk.length > 0) chunks.push(currentChunk);
// Post new chunks
const newMsgIds = [];
chunks.forEach(chunk => {
const payload = {
embeds: [{
description: chunk,
color: 3066993 // Green-ish color
}]
};
const res = postToDiscord(payload, config.summaryWebhookUrl);
if (res) newMsgIds.push(res);
Utilities.sleep(1000);
});
// Save IDs so we can delete them next time
props.setProperty(config.summaryMapKey, JSON.stringify(newMsgIds));
}
// ============================================================================
// 3. PAYLOAD BUILDER & UTILITIES
// ============================================================================
/**
* Creates the JSON object for the Discord Embed.
* Make this function generic to handle any calendar event.
*/
function buildEventEmbed(event, isNew) {
// Format dates
const startDate = new Date(event.start.dateTime || event.start.date);
const dateString = Utilities.formatDate(startDate, Session.getScriptTimeZone(), "EEEE, MMMM d, yyyy");
const timeString = event.start.dateTime
? Utilities.formatDate(startDate, Session.getScriptTimeZone(), "h:mm a")
: "All Day Event";
// Clean up the description (Remove HTML tags which Google Calendar adds)
let description = cleanHtml(event.description || "No description provided.");
// Truncate description if it exceeds Discord limits (4096 chars)
if (description.length > 1000) {
description = description.substring(0, 997) + "...";
}
// Define Color (Blue for New, Orange for Updated)
const color = isNew ? 3447003 : 15158332;
return {
embeds: [{
title: event.summary,
url: event.htmlLink, // Link back to Google Calendar
color: color,
description: description,
fields: [
{ name: "🗓️ Date", value: dateString, inline: true },
{ name: "⏰ Time", value: timeString, inline: true },
{ name: "📍 Location", value: event.location || "Online / TBD", inline: true }
],
footer: {
text: isNew ? "🆕 New Event Created" : "✏️ Event Updated"
},
timestamp: new Date().toISOString()
}]
};
}
/**
* Helper to strip HTML tags from Google Calendar descriptions
*/
function cleanHtml(text) {
if (!text) return "";
// Replace block tags with newlines
let s = text.replace(/<(br|div|p|li)[^>]*>/gi, "\n");
// Strip all other tags
s = s.replace(/<[^>]+>/g, "");
// Fix common entities
s = s.replace(/ /g, " ")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">");
return s.trim();
}
// ============================================================================
// 4. DISCORD API HELPERS
// ============================================================================
/**
* POST a new message to Discord.
* Returns the Message ID.
*/
function postToDiscord(payload, url) {
try {
const options = {
method: "post",
contentType: "application/json",
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
// ?wait=true is required to get the Message Object back (with ID)
const res = UrlFetchApp.fetch(`${url}?wait=true`, options);
if (res.getResponseCode() >= 400) {
console.error(`Discord API Error: ${res.getContentText()}`);
return null;
}
return JSON.parse(res.getContentText()).id;
} catch (e) {
console.error("Discord POST failed: " + e.message);
return null;
}
}
/**
* PATCH (Edit) an existing message in Discord.
* If the message was deleted manually in Discord, this falls back to creating a new one.
*/
function editInDiscord(msgId, payload, url) {
try {
const options = {
method: "patch",
contentType: "application/json",
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
const res = UrlFetchApp.fetch(`${url}/messages/${msgId}`, options);
// If message not found (404), post a new one
if (res.getResponseCode() === 404) {
console.warn("Message not found (deleted?), creating new one.");
return postToDiscord(payload, url);
}
return msgId;
} catch(e) {
return postToDiscord(payload, url);
}
}
/**
* DELETE a message from Discord.
*/
function deleteFromDiscord(msgId, url) {
if (!msgId) return;
try {
UrlFetchApp.fetch(`${url}/messages/${msgId}`, { method: "delete", muteHttpExceptions: true });
} catch (e) {
console.warn(`Failed to delete message ${msgId}: ${e.message}`);
}
}
// ============================================================================
// 5. SETUP & UTILS
// ============================================================================
/**
* Run this ONCE to authorize the script and set up the hourly trigger.
*/
function setupTriggers() {
const triggers = ScriptApp.getProjectTriggers();
triggers.forEach(t => ScriptApp.deleteTrigger(t));
ScriptApp.newTrigger('syncAllCalendars')
.timeBased()
.everyHours(1) // Checks for updates every hour
.create();
console.log("✅ Trigger set! Script will run every hour.");
}
/**
* UTILITY: Run this if you want to wipe the memory and start fresh.
* Warning: This will cause the script to repost all upcoming events as "New".
*/
function clearScriptMemory() {
const props = PropertiesService.getScriptProperties();
props.deleteAllProperties();
console.log("Memory cleared. Next run will repost all events.");
}