Last active
February 2, 2026 06:33
-
-
Save AlexanderMakarov/1ad84fc0eaaeaf62b17baf1f12347627 to your computer and use it in GitHub Desktop.
Google Calendar Notifier script for Linux to don't miss in-browser notifications. Expected to poll GCal periodically via systemd timer.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Google Calendar Notifier — checks Calendar API and shows desktop notifications via notify-send. | |
| Run via a user systemd timer (runs as you, so notifications and link clicks use your session). | |
| Prerequisites: | |
| gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/calendar.readonly | |
| gcloud auth application-default set-quota-project YOUR_PROJECT_ID # if API requires it | |
| pip install requests | |
| Configure (user-level, no sudo): | |
| mkdir -p ~/.config/systemd/user | |
| ~/.config/systemd/user/gcal-notifier.service: | |
| [Unit] | |
| Description=Google Calendar Notifier | |
| After=network.target | |
| [Service] | |
| Type=oneshot | |
| ExecStart=/usr/bin/python3 /path/to/gcal_notifier.py | |
| StandardOutput=journal | |
| StandardError=journal | |
| [Install] | |
| WantedBy=default.target | |
| ~/.config/systemd/user/gcal-notifier.timer: | |
| [Unit] | |
| Description=Run Google Calendar Notifier periodically | |
| Requires=gcal-notifier.service | |
| [Timer] | |
| OnBootSec=1min | |
| OnUnitActiveSec=3min | |
| AccuracySec=20s | |
| [Install] | |
| WantedBy=timers.target | |
| systemctl --user daemon-reload | |
| systemctl --user enable --now gcal-notifier.timer | |
| # Optional: run when logged out | |
| loginctl enable-linger $USER | |
| journalctl --user -u gcal-notifier.service -f | |
| """ | |
| import subprocess | |
| import json | |
| import os | |
| import sys | |
| from datetime import datetime, timedelta | |
| from zoneinfo import ZoneInfo | |
| from urllib.parse import quote | |
| import requests | |
| # Configuration constants | |
| REMINDER_WINDOW_MINUTES = 5 | |
| NOTIFICATION_ICON = "x-office-calendar" | |
| NOTIFICATION_APP_NAME = "GoogleCalendar" | |
| NOTIFICATION_TIMEOUT_MS = 60000 | |
| def get_access_token(): | |
| """Get access token using gcloud CLI with Application Default Credentials.""" | |
| try: | |
| result = subprocess.run( | |
| ['gcloud', 'auth', 'application-default', 'print-access-token'], | |
| capture_output=True, | |
| text=True, | |
| check=True | |
| ) | |
| return result.stdout.strip() | |
| except subprocess.CalledProcessError as e: | |
| if 'not logged in' in e.stderr.lower() or 'no credentials' in e.stderr.lower(): | |
| raise Exception("Not authenticated. Run: gcloud auth application-default login") | |
| raise Exception(f"Failed to get access token: {e.stderr}") | |
| def get_quota_project(): | |
| """Get quota project from ADC credentials file.""" | |
| creds_path = os.path.expanduser('~/.config/gcloud/application_default_credentials.json') | |
| if os.path.exists(creds_path): | |
| try: | |
| with open(creds_path, 'r') as f: | |
| creds = json.load(f) | |
| if 'quota_project_id' in creds: | |
| return creds['quota_project_id'] | |
| except: | |
| pass | |
| return None | |
| def get_calendar_list(access_token, quota_project=None): | |
| """Get list of all calendars the user has access to.""" | |
| url = "https://www.googleapis.com/calendar/v3/users/me/calendarList" | |
| headers = {'Authorization': f'Bearer {access_token}'} | |
| if quota_project: | |
| headers['x-goog-user-project'] = quota_project | |
| try: | |
| response = requests.get(url, headers=headers) | |
| response.raise_for_status() | |
| return response.json().get('items', []) | |
| except requests.exceptions.HTTPError as e: | |
| if e.response.status_code == 403: | |
| error_data = e.response.json() if e.response.text else {} | |
| error_msg = error_data.get('error', {}).get('message', 'Unknown error') if error_data.get('error') else 'Unknown error' | |
| if 'quota project' in error_msg.lower(): | |
| print("Error: Calendar API requires a quota project (one-time setup).", file=sys.stderr) | |
| print("\nTo fix (one-time only):", file=sys.stderr) | |
| print("1. Authenticate: gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/calendar.readonly", file=sys.stderr) | |
| print("2. Set quota project: gcloud auth application-default set-quota-project YOUR_PROJECT_ID", file=sys.stderr) | |
| print(" (Replace YOUR_PROJECT_ID with any GCP project ID you have access to)", file=sys.stderr) | |
| else: | |
| print("Error: 403 Forbidden", file=sys.stderr) | |
| print("This usually means:", file=sys.stderr) | |
| print("1. Your access token doesn't have Calendar API scopes, OR", file=sys.stderr) | |
| print("2. You're not authenticated", file=sys.stderr) | |
| print("\nTo fix (one-time only):", file=sys.stderr) | |
| print("gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/calendar.readonly", file=sys.stderr) | |
| raise | |
| except requests.exceptions.RequestException as e: | |
| print(f"Failed to get calendar list: {e}", file=sys.stderr) | |
| raise | |
| def get_calendar_events(access_token, quota_project=None, calendar_id='primary', time_min=None, time_max=None): | |
| """Get events from a specific calendar.""" | |
| encoded_calendar_id = quote(calendar_id, safe='') | |
| url = f"https://www.googleapis.com/calendar/v3/calendars/{encoded_calendar_id}/events" | |
| params = { | |
| 'singleEvents': 'true', | |
| 'orderBy': 'startTime', | |
| } | |
| if time_min: | |
| params['timeMin'] = time_min | |
| if time_max: | |
| params['timeMax'] = time_max | |
| headers = {'Authorization': f'Bearer {access_token}'} | |
| if quota_project: | |
| headers['x-goog-user-project'] = quota_project | |
| try: | |
| response = requests.get(url, params=params, headers=headers) | |
| response.raise_for_status() | |
| return response.json().get('items', []) | |
| except requests.exceptions.HTTPError as e: | |
| if e.response.status_code == 403: | |
| error_data = e.response.json() if e.response.text else {} | |
| error_msg = error_data.get('error', {}).get('message', 'Unknown error') if error_data.get('error') else 'Unknown error' | |
| if 'quota project' in error_msg.lower(): | |
| print("Error: Calendar API requires a quota project (one-time setup).", file=sys.stderr) | |
| print("\nTo fix (one-time only):", file=sys.stderr) | |
| print("1. Authenticate: gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/calendar.readonly", file=sys.stderr) | |
| print("2. Set quota project: gcloud auth application-default set-quota-project YOUR_PROJECT_ID", file=sys.stderr) | |
| print(" (Replace YOUR_PROJECT_ID with any GCP project ID you have access to)", file=sys.stderr) | |
| else: | |
| print("Error: 403 Forbidden", file=sys.stderr) | |
| print("This usually means:", file=sys.stderr) | |
| print("1. Your access token doesn't have Calendar API scopes, OR", file=sys.stderr) | |
| print("2. You're not authenticated", file=sys.stderr) | |
| print("\nTo fix (one-time only):", file=sys.stderr) | |
| print("gcloud auth application-default login --scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/calendar.readonly", file=sys.stderr) | |
| raise | |
| except requests.exceptions.RequestException as e: | |
| print(f"Failed to get calendar events: {e}", file=sys.stderr) | |
| raise | |
| def parse_event(event): | |
| """Parse event and extract basic info. | |
| Returns (title, location, start_time) tuple or None if invalid.""" | |
| title = event.get('summary', 'No title') | |
| location = event.get('location', '') | |
| start = event.get('start', {}) | |
| if 'dateTime' in start: | |
| start_time_str = start['dateTime'] | |
| elif 'date' in start: | |
| print(f" Event is all-day (date only), skipping") | |
| return None | |
| else: | |
| print(f" Event has no start time, skipping") | |
| return None | |
| try: | |
| start_time = datetime.fromisoformat(start_time_str.replace('Z', '+00:00')) | |
| if start_time.tzinfo is None: | |
| start_time = start_time.replace(tzinfo=ZoneInfo('UTC')) | |
| except Exception as e: | |
| print(f" Failed to parse start time: {e}") | |
| return None | |
| return (title, location, start_time) | |
| def send_notification(title, message): | |
| cmd = [ | |
| 'notify-send', | |
| '-a', NOTIFICATION_APP_NAME, | |
| '-i', NOTIFICATION_ICON, | |
| '-t', str(NOTIFICATION_TIMEOUT_MS), | |
| ] | |
| # Try to enable HTML formatting if available (some notify-send versions support it) | |
| # Check if message contains HTML tags | |
| if '<a href=' in message: | |
| # Some notification daemons support HTML, try with --hint | |
| cmd.extend(['--hint', 'string:body-markup:html']) | |
| cmd.extend([title, message]) | |
| try: | |
| subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) | |
| return True | |
| except (subprocess.CalledProcessError, FileNotFoundError): | |
| return False | |
| def main(): | |
| """Main execution flow.""" | |
| current_time = datetime.now(ZoneInfo('UTC')) | |
| # Query events that start within the next REMINDER_WINDOW_MINUTES | |
| time_min = current_time.isoformat() | |
| time_max = (current_time + timedelta(minutes=REMINDER_WINDOW_MINUTES)).isoformat() | |
| print(f"Starting check at {current_time.astimezone(datetime.now().astimezone().tzinfo).strftime('%Y-%m-%d %H:%M:%S')}") | |
| print(f"Querying events starting within next {REMINDER_WINDOW_MINUTES} minutes: {time_min} to {time_max}") | |
| notifications_sent = 0 | |
| try: | |
| print("Getting access token from gcloud...") | |
| access_token = get_access_token() | |
| print("Access token obtained") | |
| print("Getting quota project...") | |
| quota_project = get_quota_project() | |
| if quota_project: | |
| print(f"Quota project: {quota_project}") | |
| else: | |
| print("No quota project found") | |
| print("Getting calendar list...") | |
| calendars = get_calendar_list(access_token, quota_project) | |
| print(f"Found {len(calendars)} calendar(s):") | |
| for cal in calendars: | |
| print(f" - {cal.get('summary', 'Unknown')} (id: {cal.get('id', 'N/A')})") | |
| total_events = 0 | |
| for calendar in calendars: | |
| calendar_id = calendar.get('id') | |
| calendar_name = calendar.get('summary', calendar_id) | |
| print(f"\nQuerying calendar: {calendar_name} (id: {calendar_id})") | |
| try: | |
| events = get_calendar_events(access_token, quota_project, calendar_id, time_min, time_max) | |
| print(f" Found {len(events)} event(s)") | |
| total_events += len(events) | |
| for event in events: | |
| event_info = parse_event(event) | |
| if event_info is None: | |
| continue | |
| title, location, start_time = event_info | |
| time_diff = (start_time - current_time).total_seconds() / 60 | |
| print(f" Processing event: {title}") | |
| print(f" Event starts at: {start_time.astimezone(datetime.now().astimezone().tzinfo).strftime('%Y-%m-%d %H:%M:%S')} (in {time_diff:.1f} minutes)") | |
| # Only notify about events that start in the future (within next 5 minutes) | |
| if time_diff < 0: | |
| print(f" Event already started ({abs(time_diff):.1f} minutes ago), skipping") | |
| continue | |
| if time_diff > REMINDER_WINDOW_MINUTES: | |
| print(f" Event starts too far in the future ({time_diff:.1f} minutes), skipping") | |
| continue | |
| local_tz = datetime.now().astimezone().tzinfo | |
| start_time_str = start_time.astimezone(local_tz).strftime('%H:%M %Y-%m-%d') | |
| if location: | |
| if location.startswith('http://') or location.startswith('https://'): | |
| message = f"Starting: {start_time_str}\nAt: <a href=\"{location}\">{location[:60]}</a>" | |
| else: | |
| message = f"Starting: {start_time_str}\nWhere: {location}" | |
| else: | |
| message = f"Starting: {start_time_str}" | |
| print(f" Sending notification for: {title}") | |
| if send_notification(title, message): | |
| print(f" ✓ Notification sent: {title}") | |
| notifications_sent += 1 | |
| else: | |
| print(f" ✗ Failed to send notification") | |
| except Exception as e: | |
| print(f"Error querying calendar {calendar_name}: {e}", file=sys.stderr) | |
| continue | |
| print(f"\nSummary: {total_events} events found, {notifications_sent} notification(s) sent") | |
| except Exception as e: | |
| print(f"Error: {e}", file=sys.stderr) | |
| import traceback | |
| traceback.print_exc() | |
| sys.exit(1) | |
| if notifications_sent == 0: | |
| print(f"No events starting within next {REMINDER_WINDOW_MINUTES} minutes") | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment