Part 3 of 4
LaLiga bot: syncing with Google Calendar
Service account, dedicated calendar and the delta logic that creates, updates and alerts when a kickoff time changes.
The end goal was for matches to show up in my Google Calendar and, if the site changed a time, for the event to update and for an alert to reach me. To write to a user’s calendar without asking for authorization every time, a service account is the way.
Setting up the Google credentials
- Create a project in Google Cloud Console.
- Enable the Google Calendar API from the search bar.
- Go to IAM & Admin → Service Accounts and create one (for example
bot-futbol). - Open the account, Keys → Add key → JSON. The credentials file downloads.
- That JSON goes whole into the
GOOGLE_SERVICE_ACCOUNT_JSONsecret.
Then, in Google Calendar:
- Create a dedicated calendar (for example LaLiga Matches).
- In its settings, copy the Calendar ID (format
string@group.calendar.google.com). - Share it with the service account’s long email (it’s inside the JSON) with Make changes and manage sharing permissions.
Lesson: without that last step of sharing the calendar with the service account email, the API replies 403. The calendar and the account are separate entities; you have to introduce them.
Authentication and reading events
scopes = ['https://www.googleapis.com/auth/calendar']
creds_dict = json.loads(json_creds)
credentials = Credentials.from_service_account_info(creds_dict, scopes=scopes)
service = build('calendar', 'v3', credentials=credentials)
events_result = service.events().list(calendarId=calendar_id, maxResults=250, singleEvents=True).execute()
eventos_actuales = events_result.get('items', [])
mapa_eventos = {evt['summary']: evt for evt in eventos_actuales if 'summary' in evt}
I map the existing events by their title so I can tell whether a match is already scheduled without scanning the whole list.
The event structure
start_dt = datetime.strptime(start_str, "%Y-%m-%dT%H:%M:%S")
end_dt = start_dt + timedelta(hours=2)
evento_body = {
'summary': p['titulo'],
'location': p['ubicacion'],
'description': "Partido oficial de LaLiga. Sincronización automática.",
'start': {'dateTime': start_dt.isoformat(), 'timeZone': 'Europe/Madrid'},
'end': {'dateTime': end_dt.isoformat(), 'timeZone': 'Europe/Madrid'},
'reminders': {
'useDefault': False,
'overrides': [
{'method': 'popup', 'minutes': 60}
]
}
}
The native reminder (popup 60 minutes before) is a detail that avoided depending on the Telegram app to not be late for a match.
The delta logic
Here is the heart of the bot: comparing what the site says with what’s already in the calendar.
if p['titulo'] in mapa_eventos:
existing_event = mapa_eventos[p['titulo']]
existing_start = existing_event['start'].get('dateTime', '')[:16]
target_start = start_dt.isoformat()[:16]
if existing_start != target_start:
# time changed: update the event and decide whether to alert
service.events().update(calendarId=calendar_id, eventId=existing_event['id'], body=evento_body).execute()
else:
# new match: create it
service.events().insert(calendarId=calendar_id, body=evento_body).execute()
To avoid flooding with alerts, the bot only notifies when the change affects the next chronological matchday:
ahora_mismo = datetime.now()
proximos_partidos = []
for p in partidos:
p_str = f"{p['fecha_iso']}T{p['hora']}:00"
p_dt = datetime.strptime(p_str, "%Y-%m-%dT%H:%M:%S")
if p_dt >= ahora_mismo:
proximos_partidos.append((p_dt, p['jornada']))
jornada_siguiente = min(proximos_partidos, key=lambda x: x[0])[1] if proximos_partidos else None
# and in the loop:
if p['jornada'] == jornada_siguiente:
enviar_alerta_telegram(msg)
The missing T bug
The first workflow run blew up with this:
ValueError: time data '2026-08-16T18:00:00' does not match format '%Y-%m-%d%H:%M:%S'
Lesson:
strptimedemands a format that matches character by character. The date came with the literalTseparating date and time, so the format had to include it:"%Y-%m-%dT%H:%M:%S". A single character was the difference between a correct event and a broken workflow.
With this, the calendar stays up to date and the alerts fire when they should. In the next part we automate everything with GitHub Actions and secrets.