All work

Sep 2026 Solo build: spec, code, hand-off

TNIO Schedule Bot

A Discord bot that builds the weekly event schedule post from a shared Google Calendar and edits it whenever the calendar changes, handed off to a friend with a one-click installer and a local control panel.

Python discord.py Google Calendar Flask Automation

Shared calendar

Oct 12 – 18
  • Mon 128:00 PM
    Weekly training @host
  • Tue 139:00 PM
    Story night (O)
  • Wed 1412:30 AMunder Tue
    Late patrol (F)
  • Thu 157:30 PM
    Weekly training
  • Fri 168:00 PM
    Open meetup (O) @host
  • Sat 17All dayskipped
    Community day
  • Sat 176:00 PM
    Faction drills (F)
  • Sun 183:00 PM
    Weekly recap

Overflow test. Over 2,000 characters, a message drops the host mentions first, then cuts events from the end.

Schedule channel

3 of 3 messages
  1. Schedule BotBOTSun 9:00 PM1 of 3 · 478 chars
    EVENT SCHEDULE FOR THE WEEK OF OCTOBER 12TH - OCTOBER 18TH
    [ LATE NIGHT EVENTS APPEAR ON PRIOR DATE ]
    Story events will be annotated with the following abbreviations for clarity
    [O] open to everyone
    [I] characters initiated to any faction
    [A+] Apprentice and up
    [F] faction members only
    ALL TIMES IN EST
    MONDAY, OCTOBER 12TH
    8:00 PM - Weekly training - @host
    TUESDAY, OCTOBER 13TH
    9:00 PM - Story night (O)
    12:30 AM - Late patrol (F)
  2. Schedule BotBOTSun 9:00 PM2 of 3 · 175 chars
    WEDNESDAY, OCTOBER 14TH
    No events
    THURSDAY, OCTOBER 15TH
    7:30 PM - Weekly training
    FRIDAY, OCTOBER 16TH
    8:00 PM - Open meetup (O) - @host
  3. Schedule BotBOTSun 9:00 PM3 of 3 · 224 chars
    SATURDAY, OCTOBER 17TH
    6:00 PM - Faction drills (F)
    SUNDAY, OCTOBER 18TH
    3:00 PM - Weekly recap
    ALL TIMES IN EST
    Please message @host if there are any questions or changes. Thank you!

How it runs

Next sync

5 s

The real bot runs every 5 minutes from Windows Task Scheduler. Here, every 5 seconds.

The channel matches the calendar.

Last run · Sun 9:00 PM

Posted the week

Three new messages at the weekly post time.

  1. Sun 9:00 PMWeek of Oct 12: posted

Try it

Each sync

All 3 found
Edit only the messages whose text changed. No change, no call.
None found
Post all 3. Each pings @everyone if the setting is on.
1 or 2 found
Delete them and post all 3 again, with no ping.

Events before 5:00 AM show under the day before. All-day events are skipped. Edits and reposts never notify anyone.

The events for this Discord community were already planned in a shared Google Calendar, while the weekly schedule in Discord was typed out by hand in a fixed format. I built a bot that writes that post from the calendar instead. At the weekly post time (Sunday 9:00 PM Eastern by default) it posts the next week as three messages that match the hand-written format, and during the week it edits those same messages when an event is added, moved, or cancelled. It runs as a short job every five minutes on the Windows PC of the friend who looks after it, and that friend sets it up and runs it from a browser panel without opening a terminal.

Sync
Every 5 min
Weekly post
3 messages
Google access
Read-only
Tests
88 pytest cases
Stack
Python discord.py Google Calendar API OAuth 2.0 Flask Windows Task Scheduler PowerShell pytest Ruff GitHub Actions

One calendar, one schedule post

Before the bot, the same week lived in two places: the shared Google Calendar the organizers edited, and a schedule post in Discord written by hand. I made the calendar the source of truth. Every run reads the week from the Google Calendar API and builds all three messages again from scratch, so a deleted or cancelled event simply drops out with no special handling.

The schedule follows the rules the community already used, which I wrote into a short spec agreed with the project owner. All times are Eastern. An event that starts before 5:00 AM is late night and shows under the day before, so a schedule week runs from Monday 5:00 AM to the next Monday 5:00 AM. All-day events are skipped, recurring events show each time they happen, and the header and spacing copy the hand-written post.

Post once, then edit in place

Each run looks for the bot's own messages from the 7 days and 1 hour before the week starts, and recognizes each one by a marker: the week title for the first message and the first day heading for the other two. If all three are there, it edits only the ones whose text changed, and an unchanged week makes no Discord call at all. If none are there, it posts all three. If someone deleted one or two, it removes the rest and posts the full set again so the days stay in order.

Hosts are written the way the organizers already wrote them, as @name in the event title. The bot matches that name against the server's member list by username, display name, or nickname and turns it into a clickable mention. Discord caps a message at 2,000 characters, so when a busy stretch goes over, the bot first drops the host mentions from that message, then removes events from the end and adds a line pointing readers to the calendar.

A scheduled job, not an always-on bot

The bot does not stay connected to Discord. Each run logs in over the REST API, syncs the current week (plus the next one once the post time has passed), and closes, with no gateway connection. Windows Task Scheduler starts it every 5 minutes and 1 minute after each sign-in on the friend's PC, and the task stays on after a restart. It runs under that user's own account, so no Windows password is stored, and an OS file lock keeps a scheduled run and a manual sync from overlapping.

Scheduled runs never open a browser. If Google needs a new sign-in, the run writes a plain-language error to a status file and the log, and the panel shows a sign-in button.

A control panel instead of a terminal

The setup is built so the friend who runs the bot never needs a terminal. Install.bat checks for Python, installs Git with winget if it is missing, connects a folder downloaded as a ZIP to the GitHub repo, builds the Python environment, and copies the settings templates. Start Control Panel.bat then opens a small Flask site on localhost.

The panel has a status band with the last run in plain words, an automatic-run switch that adds or removes the scheduled task, a Sync now button that asks for confirmation before posting to the real server, and a preview of the three messages drawn the way Discord shows them. Everything else sits in four tabs:

Posting
Weekly post day and time, a switch between the test server and the real server, the channel, emoji, and @everyone setting for each, and the footer text with a character count
Hosts
Extra host names mapped to Discord user IDs, for names the member list cannot match
Connections
The Discord bot token, which is never shown back, the calendar ID, and Google sign-in
Update
Pulls the newest version with a fast-forward-only git pull and reinstalls the requirements

Secrets and permissions

Google access uses the read-only Calendar scope, so the bot cannot change the calendar. The OAuth client file, the saved Google token, the .env file with the Discord token, the host list, and the logs stay on the bot PC and are ignored by Git. On the Discord side the bot asks for four channel permissions (View Channel, Send Messages, Read Message History, Mention Everyone) and one privileged intent, Server Members, used only to look up host names. Without that intent it still posts, with the names as plain text.

The panel has no password, so I closed it off another way. It listens on 127.0.0.1 only, rejects any request whose Host header is not localhost to block DNS rebinding, and accepts writes only as JSON with no foreign Origin, so another website open in the same browser cannot drive it. The bot token is write-only in the panel: it is never sent back to the page, and a test checks that it does not appear in the response.

Spec, tests, and CI

The rules live in a spec in the repo, and later requests from the owner are recorded there with their dates, such as an @everyone line at the top of all three messages and a footer that can be edited from the panel. The code that builds the messages has no network access, so the formatting rules are tested directly.

The suite has 88 pytest cases covering week boundaries, the late-night cutoff, a week across a daylight saving change, message overflow, the edit, post, and repost rules against a fake channel, member name matching, and the panel's Host, Origin, and token checks. GitHub Actions runs Ruff lint, a Ruff format check, and pytest on every push to main and every pull request. It went from first commit to the editable footer between September 24 and 28, 2026.

Back to all work