From 352a475af27516b86aa7a3dc3892ea18a73de9b8 Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 18:47:43 +0530 Subject: [PATCH 01/27] google-calendar: Update requirements.txt dependencies. --- zulip/integrations/google/google-calendar | 3 --- zulip/integrations/google/requirements.txt | 2 ++ 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 85906bd46..efb406461 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -1,7 +1,4 @@ #!/usr/bin/env python3 -# -# This script depends on python-dateutil and python-pytz for properly handling -# times and time zones of calendar events. import argparse import datetime import itertools diff --git a/zulip/integrations/google/requirements.txt b/zulip/integrations/google/requirements.txt index 139c0705b..45e3ec167 100644 --- a/zulip/integrations/google/requirements.txt +++ b/zulip/integrations/google/requirements.txt @@ -1,2 +1,4 @@ httplib2>=0.22.0 oauth2client>=4.1.3 +python-dateutil +pytz From 81387d1a23fbb03e2d0854e206786e5cee852276 Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 18:37:14 +0530 Subject: [PATCH 02/27] google-calendar: Replace deprecated oauth2client library. Fixes: #847 Co-authored-by: Vedant Joshi Co-authored-by: Pransh Gupta --- pyproject.toml | 3 ++ .../google/get-google-credentials | 53 +++++++++++-------- zulip/integrations/google/google-calendar | 22 +++----- zulip/integrations/google/requirements.txt | 5 +- 4 files changed, 44 insertions(+), 39 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 159c85e23..b76716562 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -62,6 +62,9 @@ module = [ "apiai.*", "feedparser.*", "gitlint.*", + "google.auth.*", + "google.oauth2.*", + "google_auth_oauthlib.*", "googleapiclient.*", "irc.*", "mercurial.*", diff --git a/zulip/integrations/google/get-google-credentials b/zulip/integrations/google/get-google-credentials index bb97e5f69..a031cdb42 100755 --- a/zulip/integrations/google/get-google-credentials +++ b/zulip/integrations/google/get-google-credentials @@ -1,46 +1,53 @@ #!/usr/bin/env python3 -import argparse import os -from oauth2client import client, tools -from oauth2client.file import Storage - -flags = argparse.ArgumentParser(parents=[tools.argparser]).parse_args() +from google.auth.transport.requests import Request +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow # If modifying these scopes, delete your previously saved credentials # at zulip/bots/gcal/ # NOTE: When adding more scopes, add them after the previous one in the same field, with a space # seperating them. -SCOPES = "https://www.googleapis.com/auth/calendar.readonly" +SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"] # This file contains the information that google uses to figure out which application is requesting # this client's data. CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 -APPLICATION_NAME = "Zulip Calendar Bot" HOME_DIR = os.path.expanduser("~") -def get_credentials() -> client.Credentials: - """Gets valid user credentials from storage. +def get_credentials() -> Credentials: + """ + Writes google tokens to a json file, using the client secret file (for the OAuth flow), + and the refresh token. + + If the tokens file exists and is valid, nothing needs to be done. + If the tokens file exists, but the auth token is expired (expiry duration of auth token + is 1 hour), the refresh token is used to get a new token. + If the tokens file does not exist, or is invalid, the OAuth2 flow is triggered. - If nothing has been stored, or if the stored credentials are invalid, - the OAuth2 flow is completed to obtain the new credentials. + The OAuth2 flow needs the client secret file, and requires the user to grant access to + the application via a browser authorization page, for the first run. - Returns: - Credentials, the obtained credential. + The fetched tokens are written to storage in a json file, for reference by other scripts. """ + creds = None credential_path = os.path.join(HOME_DIR, "google-credentials.json") - store = Storage(credential_path) - credentials = store.get() - if not credentials or credentials.invalid: - flow = client.flow_from_clientsecrets(os.path.join(HOME_DIR, CLIENT_SECRET_FILE), SCOPES) - flow.user_agent = APPLICATION_NAME - # This attempts to open an authorization page in the default web browser, and asks the user - # to grant the bot access to their data. If the user grants permission, the run_flow() - # function returns new credentials. - credentials = tools.run_flow(flow, store, flags) - print("Storing credentials to " + credential_path) + if os.path.exists(credential_path): + creds = Credentials.from_authorized_user_file(credential_path, SCOPES) + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file( + os.path.join(HOME_DIR, CLIENT_SECRET_FILE), SCOPES + ) + creds = flow.run_local_server(port=0) + with open(credential_path, "w") as token: + token.write(creds.to_json()) + return creds get_credentials() diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index efb406461..f7e10bdf4 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -9,23 +9,20 @@ import time from typing import List, Optional, Set, Tuple import dateutil.parser -import httplib2 import pytz -from oauth2client import client -from oauth2client.file import Storage try: - from googleapiclient import discovery + from google.oauth2.credentials import Credentials + from googleapiclient.discovery import build except ImportError: - logging.exception("Install google-api-python-client") + logging.exception("Install the required python packages from requirements.txt first.") sys.exit(1) sys.path.append(os.path.join(os.path.dirname(__file__), "../../")) import zulip -SCOPES = "https://www.googleapis.com/auth/calendar.readonly" +SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"] CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 -APPLICATION_NAME = "Zulip" HOME_DIR = os.path.expanduser("~") # Our cached view of the calendar, updated periodically. @@ -85,7 +82,7 @@ if not options.zulip_email: zulip_client = zulip.init_from_options(options) -def get_credentials() -> client.Credentials: +def get_credentials() -> Credentials: """Gets valid user credentials from storage. If nothing has been stored, or if the stored credentials are invalid, @@ -97,10 +94,8 @@ def get_credentials() -> client.Credentials: """ try: credential_path = os.path.join(HOME_DIR, "google-credentials.json") - - store = Storage(credential_path) - return store.get() - except client.Error: + return Credentials.from_authorized_user_file(credential_path, SCOPES) + except ValueError: logging.exception("Error while trying to open the `google-credentials.json` file.") sys.exit(1) except OSError: @@ -110,8 +105,7 @@ def get_credentials() -> client.Credentials: def populate_events() -> Optional[None]: credentials = get_credentials() - creds = credentials.authorize(httplib2.Http()) - service = discovery.build("calendar", "v3", http=creds) + service = build("calendar", "v3", credentials=credentials) now = datetime.datetime.now(pytz.utc).isoformat() feed = ( diff --git a/zulip/integrations/google/requirements.txt b/zulip/integrations/google/requirements.txt index 45e3ec167..7ffc7e5fb 100644 --- a/zulip/integrations/google/requirements.txt +++ b/zulip/integrations/google/requirements.txt @@ -1,4 +1,5 @@ -httplib2>=0.22.0 -oauth2client>=4.1.3 +google-api-python-client>=1.7.9 +google-auth-httplib2>=0.0.3 +google-auth-oauthlib>=0.4.0 python-dateutil pytz From e722e90e177bd7f99182027a5ee58d9f8a5f4b28 Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 18:54:42 +0530 Subject: [PATCH 03/27] google-calendar: Update outdated send_message parameters. Remove "sender" parameter. Rename the "type" parameter value from "private" to "direct". Pass in a list to the "to" parameter. Switch to using the {} syntax for the dict. Co-authored-by: Vedant Joshi --- zulip/integrations/google/google-calendar | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index f7e10bdf4..fe1c74804 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -177,9 +177,7 @@ def send_reminders() -> Optional[None]: else: message = "Reminder:\n\n" + "\n".join("* " + m for m in messages) - zulip_client.send_message( - dict(type="private", to=options.zulip_email, sender=options.zulip_email, content=message) - ) + zulip_client.send_message({"type": "direct", "to": [options.zulip_email], "content": message}) sent.update(keys) From dfe38f11d1d783ccfe3b42181e34d6098da57271 Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 19:01:28 +0530 Subject: [PATCH 04/27] google-calendar: Narrow the permission scope to calendar events. Previously used scope: `calendar.readonly` New scope: `calendar.events.readonly` Other than events, a calendar contains `settings`, `addons` ,`app`, `calendarlist`, `calendars`, `acls` (permissions), `freebusy` (availability), and more. Since this integration only sends reminders, we need access only to the events. More narrow scopes like `calendar.events.owned.readonly` and `calendar.events.public.readonly` are available, but we want to be able to support shared calendars as well, so we're not using them. Also removed a comment regarding SCOPES that has now become redundant. --- zulip/integrations/google/get-google-credentials | 6 +----- zulip/integrations/google/google-calendar | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/zulip/integrations/google/get-google-credentials b/zulip/integrations/google/get-google-credentials index a031cdb42..c69b85155 100755 --- a/zulip/integrations/google/get-google-credentials +++ b/zulip/integrations/google/get-google-credentials @@ -5,11 +5,7 @@ from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow -# If modifying these scopes, delete your previously saved credentials -# at zulip/bots/gcal/ -# NOTE: When adding more scopes, add them after the previous one in the same field, with a space -# seperating them. -SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"] +SCOPES = ["https://www.googleapis.com/auth/calendar.events.readonly"] # This file contains the information that google uses to figure out which application is requesting # this client's data. CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index fe1c74804..b069d17ab 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -21,7 +21,7 @@ except ImportError: sys.path.append(os.path.join(os.path.dirname(__file__), "../../")) import zulip -SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"] +SCOPES = ["https://www.googleapis.com/auth/calendar.events.readonly"] CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 HOME_DIR = os.path.expanduser("~") From 4b9dbb30f080155ab9e8a4fc8a0ed52fcb64ecc9 Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 19:13:00 +0530 Subject: [PATCH 05/27] google-calendar: Use a constant for the tokens filename. Instead of using the hardcoded file name value everywhere directly. This enables us to edit the file name with a single change in the following commit. --- zulip/integrations/google/get-google-credentials | 5 ++++- zulip/integrations/google/google-calendar | 7 +++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/zulip/integrations/google/get-google-credentials b/zulip/integrations/google/get-google-credentials index c69b85155..a8e6675f4 100755 --- a/zulip/integrations/google/get-google-credentials +++ b/zulip/integrations/google/get-google-credentials @@ -6,6 +6,9 @@ from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow SCOPES = ["https://www.googleapis.com/auth/calendar.events.readonly"] +# File containing user's access and refresh tokens for Google application requests. +# If it does not exist, e.g., first run, it is generated on user authorization. +TOKENS_FILE = "google-credentials.json" # This file contains the information that google uses to figure out which application is requesting # this client's data. CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 @@ -29,7 +32,7 @@ def get_credentials() -> Credentials: """ creds = None - credential_path = os.path.join(HOME_DIR, "google-credentials.json") + credential_path = os.path.join(HOME_DIR, TOKENS_FILE) if os.path.exists(credential_path): creds = Credentials.from_authorized_user_file(credential_path, SCOPES) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index b069d17ab..369f63918 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -22,6 +22,9 @@ sys.path.append(os.path.join(os.path.dirname(__file__), "../../")) import zulip SCOPES = ["https://www.googleapis.com/auth/calendar.events.readonly"] +# File containing user's access and refresh tokens for Google application requests. +# If it does not exist, e.g., first run, it is generated on user authorization. +TOKENS_FILE = "google-credentials.json" CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 HOME_DIR = os.path.expanduser("~") @@ -93,10 +96,10 @@ def get_credentials() -> Credentials: Credentials, the obtained credential. """ try: - credential_path = os.path.join(HOME_DIR, "google-credentials.json") + credential_path = os.path.join(HOME_DIR, TOKENS_FILE) return Credentials.from_authorized_user_file(credential_path, SCOPES) except ValueError: - logging.exception("Error while trying to open the `google-credentials.json` file.") + logging.exception("Error while trying to open the %s file.", TOKENS_FILE) sys.exit(1) except OSError: logging.error("Run the get-google-credentials script from this directory first.") From 5b8b440657398f368d822ca1dbd974777a48123b Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 19:45:35 +0530 Subject: [PATCH 06/27] google-calendar: Fix usage of term "credentials", replace with "tokens". Replaces some occurrences of the term "credentials" with "tokens" for clarity, to conform with the terms used in Google API documentation. Renamed: - "google-credentials.json" to "google-tokens.json" - `credential_path` to `tokens_path` Google documentation refers to the client secret file sometimes as "credentials.json", and the tokens file as "tokens.json". We have been using "client-secret.json" and "google-credentials.json" respectively, which can be confusing. So, this commit switches to using the term "tokens" instead of "credentials" wherever appropriate, to avoid confusion. The term "credentials" is still used to refer to the Credentials object returned after authorization. --- zulip/integrations/google/get-google-credentials | 10 +++++----- zulip/integrations/google/google-calendar | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/zulip/integrations/google/get-google-credentials b/zulip/integrations/google/get-google-credentials index a8e6675f4..98c819918 100755 --- a/zulip/integrations/google/get-google-credentials +++ b/zulip/integrations/google/get-google-credentials @@ -8,7 +8,7 @@ from google_auth_oauthlib.flow import InstalledAppFlow SCOPES = ["https://www.googleapis.com/auth/calendar.events.readonly"] # File containing user's access and refresh tokens for Google application requests. # If it does not exist, e.g., first run, it is generated on user authorization. -TOKENS_FILE = "google-credentials.json" +TOKENS_FILE = "google-tokens.json" # This file contains the information that google uses to figure out which application is requesting # this client's data. CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 @@ -32,10 +32,10 @@ def get_credentials() -> Credentials: """ creds = None - credential_path = os.path.join(HOME_DIR, TOKENS_FILE) + tokens_path = os.path.join(HOME_DIR, TOKENS_FILE) - if os.path.exists(credential_path): - creds = Credentials.from_authorized_user_file(credential_path, SCOPES) + if os.path.exists(tokens_path): + creds = Credentials.from_authorized_user_file(tokens_path, SCOPES) if not creds or not creds.valid: if creds and creds.expired and creds.refresh_token: creds.refresh(Request()) @@ -44,7 +44,7 @@ def get_credentials() -> Credentials: os.path.join(HOME_DIR, CLIENT_SECRET_FILE), SCOPES ) creds = flow.run_local_server(port=0) - with open(credential_path, "w") as token: + with open(tokens_path, "w") as token: token.write(creds.to_json()) return creds diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 369f63918..d9c6bdf35 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -24,7 +24,7 @@ import zulip SCOPES = ["https://www.googleapis.com/auth/calendar.events.readonly"] # File containing user's access and refresh tokens for Google application requests. # If it does not exist, e.g., first run, it is generated on user authorization. -TOKENS_FILE = "google-credentials.json" +TOKENS_FILE = "google-tokens.json" CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 HOME_DIR = os.path.expanduser("~") @@ -96,8 +96,8 @@ def get_credentials() -> Credentials: Credentials, the obtained credential. """ try: - credential_path = os.path.join(HOME_DIR, TOKENS_FILE) - return Credentials.from_authorized_user_file(credential_path, SCOPES) + tokens_path = os.path.join(HOME_DIR, TOKENS_FILE) + return Credentials.from_authorized_user_file(tokens_path, SCOPES) except ValueError: logging.exception("Error while trying to open the %s file.", TOKENS_FILE) sys.exit(1) From 2bb1b8a778ff2d432cd79744631ab49eaa1c27ef Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 20:13:31 +0530 Subject: [PATCH 07/27] google-calendar: Update command usage help. - Added a link to the integration doc. - Removed the initial space. - Fixed the command usage for the calendar option. - Added the integration-provided options to the first line. - Mentioned downloading the client secret file in the instructions. - Made other minor edits to the writing. --- zulip/integrations/google/google-calendar | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index d9c6bdf35..482c5dbec 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -36,26 +36,20 @@ sent: Set[Tuple[int, datetime.datetime]] = set() sys.path.append(os.path.dirname(__file__)) -parser = zulip.add_default_arguments( - argparse.ArgumentParser( - r""" +usage = r"""google-calendar --user EMAIL [--interval MINUTES] [--calendar CALENDAR_ID] -google-calendar --calendar calendarID@example.calendar.google.com + This integration can be used to send Zulip messages as reminders for upcoming events from your Google Calendar. - This integration can be used to send yourself reminders, on Zulip, of Google Calendar Events. + Specify your Zulip API credentials and server in a ~/.zuliprc file, or using the options. - Specify your Zulip API credentials and server in a ~/.zuliprc file or using the options. + Before running this integration, make sure you download the client secret file from Google, and run the get-google-credentials script to give Zulip read access to your Google Calendar. - Before running this integration make sure you run the get-google-credentials file to give Zulip - access to certain aspects of your Google Account. + This integration should be run on your local machine, as your API key is accessible to local users through the command line. - This integration should be run on your local machine. Your API key and other information are - revealed to local users through the command line. - - Depends on: google-api-python-client + For more information, see https://zulip.com/integrations/doc/google-calendar. """ - ) -) + +parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) parser.add_argument( From f30a571927220bbbdb0e16117f1304caee472e77 Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Wed, 19 Feb 2025 02:05:35 +0530 Subject: [PATCH 08/27] google-calendar: Clean up `add_argument` parameters. - Removed add_argument() parameters set to default values. - Renamed `dest` of calendarID to calendar_id, in order to make flag names uniform, in preparation for adding support for loading them from a config file. - Cleared up spacing, and made minor clarifications to `help`. --- zulip/integrations/google/google-calendar | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 482c5dbec..09ba6c34b 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -50,27 +50,18 @@ usage = r"""google-calendar --user EMAIL [--interval MINUTES] [--calendar CALEND """ parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) - - parser.add_argument( "--interval", - dest="interval", default=30, type=int, - action="store", help="Minutes before event for reminder [default: 30]", - metavar="MINUTES", ) - parser.add_argument( "--calendar", - dest="calendarID", + dest="calendar_id", default="primary", - type=str, - action="store", - help="Calendar ID for the calendar you want to receive reminders from.", + help="The ID of the calendar you want to receive reminders from. By default, the primary calendar is used.", ) - options = parser.parse_args() if not options.zulip_email: @@ -108,7 +99,7 @@ def populate_events() -> Optional[None]: feed = ( service.events() .list( - calendarId=options.calendarID, + calendarId=options.calendar_id, timeMin=now, maxResults=5, singleEvents=True, From f18399c4556baad994f4f26c9794c992d643211a Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 20:43:39 +0530 Subject: [PATCH 09/27] google-calendar: Add --provision argument to install dependencies. Leveraged the provisioning support provided by `init_from_options`. Enabled the `--provision` argument by setting `allow_provisiong` to True in `add_default_arguments`. Re-ordered the script such that the imports are executed after `init_from_options`. Updated the import error message to recommend using the command with the --provision argument to install the dependencies. --- zulip/integrations/google/google-calendar | 26 ++++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 09ba6c34b..532ceb7c4 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -11,13 +11,6 @@ from typing import List, Optional, Set, Tuple import dateutil.parser import pytz -try: - from google.oauth2.credentials import Credentials - from googleapiclient.discovery import build -except ImportError: - logging.exception("Install the required python packages from requirements.txt first.") - sys.exit(1) - sys.path.append(os.path.join(os.path.dirname(__file__), "../../")) import zulip @@ -49,7 +42,7 @@ usage = r"""google-calendar --user EMAIL [--interval MINUTES] [--calendar CALEND For more information, see https://zulip.com/integrations/doc/google-calendar. """ -parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage)) +parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage), allow_provisioning=True) parser.add_argument( "--interval", default=30, @@ -64,11 +57,24 @@ parser.add_argument( ) options = parser.parse_args() +zulip_client = zulip.init_from_options(options) + +# Import dependencies only after parsing command-line args, +# as the --provision flag can be used to install the dependencies. +try: + from google.oauth2.credentials import Credentials + from googleapiclient.discovery import build +except ImportError: + logging.exception( + "You have unsatisfied dependencies. Install all missing dependencies with %(command)s --provision", + {"command": sys.argv[0]}, + ) + sys.exit(1) + + if not options.zulip_email: parser.error("You must specify --user") -zulip_client = zulip.init_from_options(options) - def get_credentials() -> Credentials: """Gets valid user credentials from storage. From 07810c5053b3aea2b029c3147fcce064bb1ad52a Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 20:49:18 +0530 Subject: [PATCH 10/27] google-calendar: Add error handling for missing client secret file. --- zulip/integrations/google/get-google-credentials | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/zulip/integrations/google/get-google-credentials b/zulip/integrations/google/get-google-credentials index 98c819918..64265f510 100755 --- a/zulip/integrations/google/get-google-credentials +++ b/zulip/integrations/google/get-google-credentials @@ -1,5 +1,7 @@ #!/usr/bin/env python3 +import logging import os +import sys from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials @@ -40,9 +42,14 @@ def get_credentials() -> Credentials: if creds and creds.expired and creds.refresh_token: creds.refresh(Request()) else: - flow = InstalledAppFlow.from_client_secrets_file( - os.path.join(HOME_DIR, CLIENT_SECRET_FILE), SCOPES - ) + client_secret_path = os.path.join(HOME_DIR, CLIENT_SECRET_FILE) + if not os.path.exists(client_secret_path): + logging.error( + "Unable to find the client secret file. Please ensure that you have downloaded the client secret file from Google, and placed it at %s.", + client_secret_path, + ) + sys.exit(1) + flow = InstalledAppFlow.from_client_secrets_file(client_secret_path, SCOPES) creds = flow.run_local_server(port=0) with open(tokens_path, "w") as token: token.write(creds.to_json()) From abf6415f4506801cbb6492c89ea8d962549163d8 Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 20:58:30 +0530 Subject: [PATCH 11/27] google-calendar: Stop printing events unless the verbose option is set. Currently, the integration prints all reminders to the terminal. Set logging level to info when --verbose is set, and switched `print` to `logging.info`, to restrict that output to only when the --verbose option is set. --- zulip/integrations/google/google-calendar | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 532ceb7c4..67372fe7f 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -74,6 +74,8 @@ except ImportError: if not options.zulip_email: parser.error("You must specify --user") +if options.verbose: + logging.getLogger().setLevel(logging.INFO) def get_credentials() -> Credentials: @@ -159,7 +161,7 @@ def send_reminders() -> Optional[None]: line = f"{summary} is today." else: line = "{} starts at {}".format(summary, start.strftime("%H:%M")) - print("Sending reminder:", line) + logging.info("Sending reminder: %s", line) messages.append(line) keys.add(key) From 8b2b9dd0379abd581219f79e741286838efe082c Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 21:15:36 +0530 Subject: [PATCH 12/27] google-calendar: Add error handling for send_message. Log the error response without halting the script. --- zulip/integrations/google/google-calendar | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 67372fe7f..d79fd718f 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -173,8 +173,11 @@ def send_reminders() -> Optional[None]: else: message = "Reminder:\n\n" + "\n".join("* " + m for m in messages) - zulip_client.send_message({"type": "direct", "to": [options.zulip_email], "content": message}) - + result = zulip_client.send_message( + {"type": "direct", "to": [options.zulip_email], "content": message} + ) + if result["result"] != "success": + logging.error("Error sending zulip message: %s: %s", result.get("code"), result.get("msg")) sent.update(keys) From 06db61b170cc0e2316a2a3a03c2e5a6fd30252cd Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 22:45:03 +0530 Subject: [PATCH 13/27] google-calendar: Improve CLIENT_SECRET_FILE occurrences. Removed it from google-calendar script, as it is only used for the first run authorization by get-google-credentials. Edited a comment clarifying its purpose in get-google-credentials. --- zulip/integrations/google/get-google-credentials | 5 +++-- zulip/integrations/google/google-calendar | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/zulip/integrations/google/get-google-credentials b/zulip/integrations/google/get-google-credentials index 64265f510..2345bd12e 100755 --- a/zulip/integrations/google/get-google-credentials +++ b/zulip/integrations/google/get-google-credentials @@ -11,8 +11,9 @@ SCOPES = ["https://www.googleapis.com/auth/calendar.events.readonly"] # File containing user's access and refresh tokens for Google application requests. # If it does not exist, e.g., first run, it is generated on user authorization. TOKENS_FILE = "google-tokens.json" -# This file contains the information that google uses to figure out which application is requesting -# this client's data. +# The client secret file identifies the application requesting the client's data, +# and is required for the OAuth flow to fetch the tokens. +# It needs to be downloaded from Google, by the user. CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 HOME_DIR = os.path.expanduser("~") diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index d79fd718f..36d94cebb 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -18,7 +18,6 @@ SCOPES = ["https://www.googleapis.com/auth/calendar.events.readonly"] # File containing user's access and refresh tokens for Google application requests. # If it does not exist, e.g., first run, it is generated on user authorization. TOKENS_FILE = "google-tokens.json" -CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 HOME_DIR = os.path.expanduser("~") # Our cached view of the calendar, updated periodically. From 445ee9b8c9e78d7f94d45fa496ea47bf799c135e Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 23:14:36 +0530 Subject: [PATCH 14/27] google-calendar: Call get-google-credentials script internally. The auth tokens expire every hour. So, the google-calendar script would stop functioning one hour after start, if it only loads from the token file. The get-google-credential script needs to be called directly from the google-calendar script, without the user needing to run that as a separate command. The get-google-credentials script is not a module. And it uses hyphens in its name, hence it cannot be directly imported into google-calendar. To avoid renaming files, and smoothly pass in arguments, runpy is used. Though google-calendar script is currently the only google service integration that uses get-google-credentials, the script is not merged with the google-calendar script, to keep the logic separate, and easy to expand. The get-google-credentials script can no longer be run directly, as the get_credentials() arguments do not have any default values, with the constants deleted, to avoid redundancy with the google-calendar script. Updated the function docstrings, usage help and error messages. --- .../google/get-google-credentials | 17 ++++---------- zulip/integrations/google/google-calendar | 23 +++++++------------ 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/zulip/integrations/google/get-google-credentials b/zulip/integrations/google/get-google-credentials index 2345bd12e..9ea87b44a 100755 --- a/zulip/integrations/google/get-google-credentials +++ b/zulip/integrations/google/get-google-credentials @@ -2,15 +2,12 @@ import logging import os import sys +from typing import List from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow -SCOPES = ["https://www.googleapis.com/auth/calendar.events.readonly"] -# File containing user's access and refresh tokens for Google application requests. -# If it does not exist, e.g., first run, it is generated on user authorization. -TOKENS_FILE = "google-tokens.json" # The client secret file identifies the application requesting the client's data, # and is required for the OAuth flow to fetch the tokens. # It needs to be downloaded from Google, by the user. @@ -18,7 +15,7 @@ CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 HOME_DIR = os.path.expanduser("~") -def get_credentials() -> Credentials: +def get_credentials(tokens_path: str, scopes: List[str]) -> Credentials: """ Writes google tokens to a json file, using the client secret file (for the OAuth flow), and the refresh token. @@ -33,12 +30,9 @@ def get_credentials() -> Credentials: The fetched tokens are written to storage in a json file, for reference by other scripts. """ - creds = None - tokens_path = os.path.join(HOME_DIR, TOKENS_FILE) - if os.path.exists(tokens_path): - creds = Credentials.from_authorized_user_file(tokens_path, SCOPES) + creds = Credentials.from_authorized_user_file(tokens_path, scopes) if not creds or not creds.valid: if creds and creds.expired and creds.refresh_token: creds.refresh(Request()) @@ -50,11 +44,8 @@ def get_credentials() -> Credentials: client_secret_path, ) sys.exit(1) - flow = InstalledAppFlow.from_client_secrets_file(client_secret_path, SCOPES) + flow = InstalledAppFlow.from_client_secrets_file(client_secret_path, scopes) creds = flow.run_local_server(port=0) with open(tokens_path, "w") as token: token.write(creds.to_json()) return creds - - -get_credentials() diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 36d94cebb..952c9eab6 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -4,6 +4,7 @@ import datetime import itertools import logging import os +import runpy import sys import time from typing import List, Optional, Set, Tuple @@ -34,8 +35,6 @@ usage = r"""google-calendar --user EMAIL [--interval MINUTES] [--calendar CALEND Specify your Zulip API credentials and server in a ~/.zuliprc file, or using the options. - Before running this integration, make sure you download the client secret file from Google, and run the get-google-credentials script to give Zulip read access to your Google Calendar. - This integration should be run on your local machine, as your API key is accessible to local users through the command line. For more information, see https://zulip.com/integrations/doc/google-calendar. @@ -78,23 +77,17 @@ if options.verbose: def get_credentials() -> Credentials: - """Gets valid user credentials from storage. - - If nothing has been stored, or if the stored credentials are invalid, - an exception is thrown and the user is informed to run the script in this directory to get - credentials. + """Fetches credentials using the get-google-credentials script. - Returns: - Credentials, the obtained credential. + Needs to call get-google-credentials everytime, because the auth token expires every hour, + needing to be refreshed using the refresh token. """ try: tokens_path = os.path.join(HOME_DIR, TOKENS_FILE) - return Credentials.from_authorized_user_file(tokens_path, SCOPES) - except ValueError: - logging.exception("Error while trying to open the %s file.", TOKENS_FILE) - sys.exit(1) - except OSError: - logging.error("Run the get-google-credentials script from this directory first.") + fetch_creds = runpy.run_path("./get-google-credentials")["get_credentials"] + return fetch_creds(tokens_path, SCOPES) + except Exception: + logging.exception("Error getting google credentials") sys.exit(1) From 8b7c536e5f707aacd332be19b3c13cf9a5ef2dfb Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 23:28:07 +0530 Subject: [PATCH 15/27] google-calendar: Use current user's email id for send_message. The script currently mandatorily requires the --user argument to be passed, this commit removes the need for the --user flag. --- zulip/integrations/google/google-calendar | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 952c9eab6..00906408a 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -29,7 +29,7 @@ sent: Set[Tuple[int, datetime.datetime]] = set() sys.path.append(os.path.dirname(__file__)) -usage = r"""google-calendar --user EMAIL [--interval MINUTES] [--calendar CALENDAR_ID] +usage = r"""google-calendar [--interval MINUTES] [--calendar CALENDAR_ID] This integration can be used to send Zulip messages as reminders for upcoming events from your Google Calendar. @@ -69,9 +69,6 @@ except ImportError: ) sys.exit(1) - -if not options.zulip_email: - parser.error("You must specify --user") if options.verbose: logging.getLogger().setLevel(logging.INFO) @@ -166,7 +163,7 @@ def send_reminders() -> Optional[None]: message = "Reminder:\n\n" + "\n".join("* " + m for m in messages) result = zulip_client.send_message( - {"type": "direct", "to": [options.zulip_email], "content": message} + {"type": "direct", "to": [zulip_client.get_profile()["email"]], "content": message} ) if result["result"] != "success": logging.error("Error sending zulip message: %s: %s", result.get("code"), result.get("msg")) From 6436918692896f93d5a9a9b075fb8dcfd6e0095d Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Tue, 18 Feb 2025 23:42:02 +0530 Subject: [PATCH 16/27] google-calendar: Use a bot to send direct messages to the bot owner. The reminders were previously being sent to the DMs of the current user, requiring the zuliprc of the user. But, we can create a generic bot for the Google Calendar integration, and use its zuliprc to send direct messages to its owner. This tightens security. The support for sending DMs to the same user is retained, for cases where a bot is not used. --- zulip/integrations/google/google-calendar | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 00906408a..5dfe67f40 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -29,13 +29,12 @@ sent: Set[Tuple[int, datetime.datetime]] = set() sys.path.append(os.path.dirname(__file__)) -usage = r"""google-calendar [--interval MINUTES] [--calendar CALENDAR_ID] +usage = r"""google-calendar [--config-file PATH_TO_ZULIPRC_OF_BOT] + [--interval MINUTES] [--calendar CALENDAR_ID] This integration can be used to send Zulip messages as reminders for upcoming events from your Google Calendar. - Specify your Zulip API credentials and server in a ~/.zuliprc file, or using the options. - - This integration should be run on your local machine, as your API key is accessible to local users through the command line. + Create a generic bot on Zulip, download its zuliprc file, and use the --config-file option to specify the path to your bot's zuliprc. For more information, see https://zulip.com/integrations/doc/google-calendar. """ @@ -162,8 +161,13 @@ def send_reminders() -> Optional[None]: else: message = "Reminder:\n\n" + "\n".join("* " + m for m in messages) + user_profile = zulip_client.get_profile() result = zulip_client.send_message( - {"type": "direct", "to": [zulip_client.get_profile()["email"]], "content": message} + { + "type": "direct", + "to": [user_profile.get("bot_owner_id") or user_profile["email"]], + "content": message, + } ) if result["result"] != "success": logging.error("Error sending zulip message: %s: %s", result.get("code"), result.get("msg")) From 5d3ff393ee71e5fb530d4da44f63f972b8c20faa Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Wed, 19 Feb 2025 01:12:09 +0530 Subject: [PATCH 17/27] google-calendar: Support sending reminders to channels. Previously, until last commit, the zuliprc of the user was being used directly. Now that we're using the zuliprc of a bot, it is safe for multiple users with a shared calendar to have a bot send the reminders to a channel. Added channel and topic arguments, with the default behavior set to sending a direct message. --- zulip/integrations/google/google-calendar | 29 +++++++++++++++++------ 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 5dfe67f40..b8fe8ead6 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -31,6 +31,7 @@ sys.path.append(os.path.dirname(__file__)) usage = r"""google-calendar [--config-file PATH_TO_ZULIPRC_OF_BOT] [--interval MINUTES] [--calendar CALENDAR_ID] + [--channel CHANNEL_NAME] [--topic TOPIC_NAME] This integration can be used to send Zulip messages as reminders for upcoming events from your Google Calendar. @@ -52,6 +53,15 @@ parser.add_argument( default="primary", help="The ID of the calendar you want to receive reminders from. By default, the primary calendar is used.", ) +parser.add_argument( + "--channel", + help="The channel to which to send the reminders to. By default, messages are sent to the DMs of the bot owner.", +) +parser.add_argument( + "--topic", + help="The topic to which to send the reminders to. Ignored if --channel is unspecified. 'calendar-reminders' is used as the default topic name.", + default="calendar-reminders", +) options = parser.parse_args() zulip_client = zulip.init_from_options(options) @@ -162,13 +172,18 @@ def send_reminders() -> Optional[None]: message = "Reminder:\n\n" + "\n".join("* " + m for m in messages) user_profile = zulip_client.get_profile() - result = zulip_client.send_message( - { - "type": "direct", - "to": [user_profile.get("bot_owner_id") or user_profile["email"]], - "content": message, - } - ) + if options.channel is not None: + result = zulip_client.send_message( + {"type": "stream", "to": options.channel, "topic": options.topic, "content": message} + ) + else: + result = zulip_client.send_message( + { + "type": "direct", + "to": [user_profile.get("bot_owner_id") or user_profile["email"]], + "content": message, + } + ) if result["result"] != "success": logging.error("Error sending zulip message: %s: %s", result.get("code"), result.get("msg")) sent.update(keys) From 4c56d9bbc03edd458a521f752cb8b38d9412088d Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Wed, 19 Feb 2025 00:55:18 +0530 Subject: [PATCH 18/27] google-calendar: Support manual authorization using auth code. This allows the integration to be run from non-interactive environments or on devices without browsers, like remote servers. Co-authored-by: Vedant Joshi --- .../google/get-google-credentials | 23 ++++++++++++++++--- zulip/integrations/google/google-calendar | 10 +++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/zulip/integrations/google/get-google-credentials b/zulip/integrations/google/get-google-credentials index 9ea87b44a..89e0e9b99 100755 --- a/zulip/integrations/google/get-google-credentials +++ b/zulip/integrations/google/get-google-credentials @@ -15,7 +15,9 @@ CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 HOME_DIR = os.path.expanduser("~") -def get_credentials(tokens_path: str, scopes: List[str]) -> Credentials: +def get_credentials( + tokens_path: str, scopes: List[str], noauth_local_webserver: bool = False +) -> Credentials: """ Writes google tokens to a json file, using the client secret file (for the OAuth flow), and the refresh token. @@ -27,6 +29,8 @@ def get_credentials(tokens_path: str, scopes: List[str]) -> Credentials: The OAuth2 flow needs the client secret file, and requires the user to grant access to the application via a browser authorization page, for the first run. + The authorization can be done either automatically using a local web server, + or manually by copy-pasting the auth code from the browser into the command line. The fetched tokens are written to storage in a json file, for reference by other scripts. """ @@ -44,8 +48,21 @@ def get_credentials(tokens_path: str, scopes: List[str]) -> Credentials: client_secret_path, ) sys.exit(1) - flow = InstalledAppFlow.from_client_secrets_file(client_secret_path, scopes) - creds = flow.run_local_server(port=0) + flow = InstalledAppFlow.from_client_secrets_file( + client_secret_path, + scopes, + redirect_uri="urn:ietf:wg:oauth:2.0:oob" if noauth_local_webserver else None, + ) + + if noauth_local_webserver: + auth_url, _ = flow.authorization_url(access_type="offline") + auth_code = input( + f"Please visit this URL to authorize this application:\n{auth_url}\nEnter the authorization code: " + ) + flow.fetch_token(code=auth_code) + creds = flow.credentials + else: + creds = flow.run_local_server(port=0) with open(tokens_path, "w") as token: token.write(creds.to_json()) return creds diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index b8fe8ead6..4be84a773 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -32,6 +32,7 @@ sys.path.append(os.path.dirname(__file__)) usage = r"""google-calendar [--config-file PATH_TO_ZULIPRC_OF_BOT] [--interval MINUTES] [--calendar CALENDAR_ID] [--channel CHANNEL_NAME] [--topic TOPIC_NAME] + [-n] [--noauth_local_webserver] This integration can be used to send Zulip messages as reminders for upcoming events from your Google Calendar. @@ -62,6 +63,13 @@ parser.add_argument( help="The topic to which to send the reminders to. Ignored if --channel is unspecified. 'calendar-reminders' is used as the default topic name.", default="calendar-reminders", ) +parser.add_argument( + "-n", + "--noauth_local_webserver", + action="store_true", + default=False, + help="The default authorization process runs a local web server, which requires a browser on the same machine. For non-interactive environments and machines without browser access, e.g., remote servers, this option allows manual authorization. The authorization URL is printed, which the user can copy into a browser, copy the resulting authorization code, and paste back into the command line.", +) options = parser.parse_args() zulip_client = zulip.init_from_options(options) @@ -91,7 +99,7 @@ def get_credentials() -> Credentials: try: tokens_path = os.path.join(HOME_DIR, TOKENS_FILE) fetch_creds = runpy.run_path("./get-google-credentials")["get_credentials"] - return fetch_creds(tokens_path, SCOPES) + return fetch_creds(tokens_path, SCOPES, options.noauth_local_webserver) except Exception: logging.exception("Error getting google credentials") sys.exit(1) From 441dce9638cf26850f10a932da6b2bc386b6e573 Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Wed, 19 Feb 2025 01:02:57 +0530 Subject: [PATCH 19/27] google-calendar: Log writing to the tokens file. --- zulip/integrations/google/get-google-credentials | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/zulip/integrations/google/get-google-credentials b/zulip/integrations/google/get-google-credentials index 89e0e9b99..5e062df72 100755 --- a/zulip/integrations/google/get-google-credentials +++ b/zulip/integrations/google/get-google-credentials @@ -63,6 +63,7 @@ def get_credentials( creds = flow.credentials else: creds = flow.run_local_server(port=0) - with open(tokens_path, "w") as token: - token.write(creds.to_json()) + with open(tokens_path, "w") as token_file: + token_file.write(creds.to_json()) + logging.info("Saved tokens to %s", tokens_path) return creds From c0041997ba284df28406e99dc1680ed7870c8fcd Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Wed, 19 Feb 2025 02:42:10 +0530 Subject: [PATCH 20/27] google-calendar: Add options --client-secret-file and --tokens-file. For passing in custom file paths. --- .../google/get-google-credentials | 14 ++++----- zulip/integrations/google/google-calendar | 29 +++++++++++++++++-- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/zulip/integrations/google/get-google-credentials b/zulip/integrations/google/get-google-credentials index 5e062df72..1fa884a23 100755 --- a/zulip/integrations/google/get-google-credentials +++ b/zulip/integrations/google/get-google-credentials @@ -8,15 +8,12 @@ from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow -# The client secret file identifies the application requesting the client's data, -# and is required for the OAuth flow to fetch the tokens. -# It needs to be downloaded from Google, by the user. -CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 -HOME_DIR = os.path.expanduser("~") - def get_credentials( - tokens_path: str, scopes: List[str], noauth_local_webserver: bool = False + tokens_path: str, + client_secret_path: str, + scopes: List[str], + noauth_local_webserver: bool = False, ) -> Credentials: """ Writes google tokens to a json file, using the client secret file (for the OAuth flow), @@ -41,10 +38,9 @@ def get_credentials( if creds and creds.expired and creds.refresh_token: creds.refresh(Request()) else: - client_secret_path = os.path.join(HOME_DIR, CLIENT_SECRET_FILE) if not os.path.exists(client_secret_path): logging.error( - "Unable to find the client secret file. Please ensure that you have downloaded the client secret file from Google, and placed it at %s.", + "Unable to find the client secret file.\nPlease ensure that you have downloaded the client secret file from Google. Either place the client secret file at %s, or use the --client-secret-file option to specify the path to the client secret file.", client_secret_path, ) sys.exit(1) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 4be84a773..9d250bbe2 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -16,10 +16,18 @@ sys.path.append(os.path.join(os.path.dirname(__file__), "../../")) import zulip SCOPES = ["https://www.googleapis.com/auth/calendar.events.readonly"] +HOME_DIR = os.path.expanduser("~") + # File containing user's access and refresh tokens for Google application requests. # If it does not exist, e.g., first run, it is generated on user authorization. TOKENS_FILE = "google-tokens.json" -HOME_DIR = os.path.expanduser("~") +TOKENS_PATH = os.path.join(HOME_DIR, TOKENS_FILE) + +# The client secret file identifies the application requesting the client's data, +# and is required for the OAuth flow to fetch the tokens. +# It needs to be downloaded from Google, by the user. +CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 +CLIENT_SECRET_PATH = os.path.join(HOME_DIR, CLIENT_SECRET_FILE) # Our cached view of the calendar, updated periodically. events: List[Tuple[int, datetime.datetime, str]] = [] @@ -32,6 +40,8 @@ sys.path.append(os.path.dirname(__file__)) usage = r"""google-calendar [--config-file PATH_TO_ZULIPRC_OF_BOT] [--interval MINUTES] [--calendar CALENDAR_ID] [--channel CHANNEL_NAME] [--topic TOPIC_NAME] + [--client-secret-file PATH_TO_CLIENT_SECRET_FILE] + [--tokens-file PATH_TO_GOOGLE_TOKENS_FILE] [-n] [--noauth_local_webserver] This integration can be used to send Zulip messages as reminders for upcoming events from your Google Calendar. @@ -63,6 +73,18 @@ parser.add_argument( help="The topic to which to send the reminders to. Ignored if --channel is unspecified. 'calendar-reminders' is used as the default topic name.", default="calendar-reminders", ) +parser.add_argument( + "--client-secret-file", + help="The path to the file containing the client secret for the Google Calendar API. By default, the client secret file is assumed to be at {CLIENT_SECRET_PATH}.", + default=CLIENT_SECRET_PATH, + dest="client_secret_path", +) +parser.add_argument( + "--tokens-file", + help=f"The path to the file containing the tokens for the Google Calendar API. By default, the tokens file is generated at {TOKENS_PATH} after the first run.", + default=TOKENS_PATH, + dest="tokens_path", +) parser.add_argument( "-n", "--noauth_local_webserver", @@ -97,9 +119,10 @@ def get_credentials() -> Credentials: needing to be refreshed using the refresh token. """ try: - tokens_path = os.path.join(HOME_DIR, TOKENS_FILE) fetch_creds = runpy.run_path("./get-google-credentials")["get_credentials"] - return fetch_creds(tokens_path, SCOPES, options.noauth_local_webserver) + return fetch_creds( + options.tokens_path, options.client_secret_path, SCOPES, options.noauth_local_webserver + ) except Exception: logging.exception("Error getting google credentials") sys.exit(1) From 9ced7b8d13f161c67ae6d783c585e96f8e6e38b2 Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Wed, 19 Feb 2025 20:32:10 +0530 Subject: [PATCH 21/27] google-calendar: Support loading options from the zuliprc. By loading from the new "google-calendar" section of the zuliprc. Moved the default values out of the argparse arguments, and into a dataclass, to implement the below hierarchy of loading options: - initialize options to default values - overwrite with the options present in the config file - overwrite with the options passed-in via the command line --- zulip/integrations/google/google-calendar | 96 +++++++++++++++++++---- 1 file changed, 80 insertions(+), 16 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 9d250bbe2..82164210f 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -7,6 +7,8 @@ import os import runpy import sys import time +from configparser import ConfigParser +from dataclasses import dataclass from typing import List, Optional, Set, Tuple import dateutil.parser @@ -29,6 +31,18 @@ TOKENS_PATH = os.path.join(HOME_DIR, TOKENS_FILE) CLIENT_SECRET_FILE = "client_secret.json" # noqa: S105 CLIENT_SECRET_PATH = os.path.join(HOME_DIR, CLIENT_SECRET_FILE) + +@dataclass +class GoogleCalendarOptions: + calendar_id: str = "primary" + interval: int = 30 + channel: Optional[str] = None + topic: str = "calendar-reminders" + noauth_local_webserver: bool = False + tokens_path: str = TOKENS_PATH + client_secret_path: str = CLIENT_SECRET_PATH + + # Our cached view of the calendar, updated periodically. events: List[Tuple[int, datetime.datetime, str]] = [] @@ -54,14 +68,12 @@ usage = r"""google-calendar [--config-file PATH_TO_ZULIPRC_OF_BOT] parser = zulip.add_default_arguments(argparse.ArgumentParser(usage=usage), allow_provisioning=True) parser.add_argument( "--interval", - default=30, type=int, help="Minutes before event for reminder [default: 30]", ) parser.add_argument( "--calendar", dest="calendar_id", - default="primary", help="The ID of the calendar you want to receive reminders from. By default, the primary calendar is used.", ) parser.add_argument( @@ -71,30 +83,77 @@ parser.add_argument( parser.add_argument( "--topic", help="The topic to which to send the reminders to. Ignored if --channel is unspecified. 'calendar-reminders' is used as the default topic name.", - default="calendar-reminders", ) parser.add_argument( "--client-secret-file", help="The path to the file containing the client secret for the Google Calendar API. By default, the client secret file is assumed to be at {CLIENT_SECRET_PATH}.", - default=CLIENT_SECRET_PATH, dest="client_secret_path", ) parser.add_argument( "--tokens-file", help=f"The path to the file containing the tokens for the Google Calendar API. By default, the tokens file is generated at {TOKENS_PATH} after the first run.", - default=TOKENS_PATH, dest="tokens_path", ) parser.add_argument( "-n", "--noauth_local_webserver", action="store_true", - default=False, help="The default authorization process runs a local web server, which requires a browser on the same machine. For non-interactive environments and machines without browser access, e.g., remote servers, this option allows manual authorization. The authorization URL is printed, which the user can copy into a browser, copy the resulting authorization code, and paste back into the command line.", ) -options = parser.parse_args() +commandline_options = parser.parse_args() +if commandline_options.verbose: + logging.getLogger().setLevel(logging.INFO) -zulip_client = zulip.init_from_options(options) + +valid_keys = list(GoogleCalendarOptions.__dataclass_fields__.keys()) + + +def load_config_options(config_path: Optional[str]) -> GoogleCalendarOptions: + if config_path is None: + config_path = zulip.get_default_config_filename() + assert config_path is not None + if not os.path.exists(config_path): + logging.info("No config file found at %s", config_path) + return GoogleCalendarOptions() + + logging.info("Loading Google Calendar configuration from %s", config_path) + config = ConfigParser() + try: + config.read(config_path) + except Exception: + logging.exception("Error reading config file %s", config_path) + + section = "google-calendar" + config_values = {} + if section in config: + for key, value in config[section].items(): + if key in valid_keys: + expected_type = GoogleCalendarOptions.__annotations__[key] + config_values[key] = True if expected_type == bool else expected_type(value) + logging.info("Setting key: %s to %s", key, config_values[key]) + else: + logging.warning( + "Unknown key %s in section %s of config file %s", key, section, config_path + ) + return GoogleCalendarOptions(**config_values) + + +def update_calendar_options_from_commandline_args( + calendar_options: GoogleCalendarOptions, commandline_options: argparse.Namespace +) -> None: + for key, value in commandline_options.__dict__.items(): + # Boolean arguments (store-true) have a default value of False when not passed in. + # So, we ignore them, to prevent overwriting the config file option that is set. + if key in valid_keys and value is not None and value is not False: + setattr(calendar_options, key, value) + + +# Calendar options can be passed in from the command line or via zuliprc. +# The command line options override the zuliprc options. +calendar_options = load_config_options(commandline_options.zulip_config_file) +update_calendar_options_from_commandline_args(calendar_options, commandline_options) + +zulip_client = zulip.init_from_options(commandline_options) # Import dependencies only after parsing command-line args, # as the --provision flag can be used to install the dependencies. @@ -108,9 +167,6 @@ except ImportError: ) sys.exit(1) -if options.verbose: - logging.getLogger().setLevel(logging.INFO) - def get_credentials() -> Credentials: """Fetches credentials using the get-google-credentials script. @@ -121,7 +177,10 @@ def get_credentials() -> Credentials: try: fetch_creds = runpy.run_path("./get-google-credentials")["get_credentials"] return fetch_creds( - options.tokens_path, options.client_secret_path, SCOPES, options.noauth_local_webserver + calendar_options.tokens_path, + calendar_options.client_secret_path, + SCOPES, + calendar_options.noauth_local_webserver, ) except Exception: logging.exception("Error getting google credentials") @@ -136,7 +195,7 @@ def populate_events() -> Optional[None]: feed = ( service.events() .list( - calendarId=options.calendar_id, + calendarId=calendar_options.calendar_id, timeMin=now, maxResults=5, singleEvents=True, @@ -181,7 +240,7 @@ def send_reminders() -> Optional[None]: for id, start, summary in events: dt = start - now - if dt.days == 0 and dt.seconds < 60 * options.interval: + if dt.days == 0 and dt.seconds < 60 * calendar_options.interval: # The unique key includes the start time, because of # repeating events. key = (id, start) @@ -203,9 +262,14 @@ def send_reminders() -> Optional[None]: message = "Reminder:\n\n" + "\n".join("* " + m for m in messages) user_profile = zulip_client.get_profile() - if options.channel is not None: + if calendar_options.channel is not None: result = zulip_client.send_message( - {"type": "stream", "to": options.channel, "topic": options.topic, "content": message} + { + "type": "stream", + "to": calendar_options.channel, + "topic": calendar_options.topic, + "content": message, + } ) else: result = zulip_client.send_message( From e38a8e6c2cd83d9c3f647a70bff06fc3db13ea08 Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Thu, 20 Feb 2025 02:45:00 +0530 Subject: [PATCH 22/27] google-calendar: Fix type of event id, switch from int to str. --- zulip/integrations/google/google-calendar | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 82164210f..2731cfd44 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -44,10 +44,10 @@ class GoogleCalendarOptions: # Our cached view of the calendar, updated periodically. -events: List[Tuple[int, datetime.datetime, str]] = [] +events: List[Tuple[str, datetime.datetime, str]] = [] # Unique keys for events we've already sent, so we don't remind twice. -sent: Set[Tuple[int, datetime.datetime]] = set() +sent: Set[Tuple[str, datetime.datetime]] = set() sys.path.append(os.path.dirname(__file__)) From da6d896d3156fe3e660a4f593985fd9673704563 Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Thu, 20 Feb 2025 02:50:26 +0530 Subject: [PATCH 23/27] google-calendar: Send each reminder as its own message. Currently, all the reminders are collected together before sending a message, and are formatted as bullet points in the same message. This commit uses a message per event, in preparation of adding more details to each reminder message. --- zulip/integrations/google/google-calendar | 48 +++++++++-------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 2731cfd44..23cc95dae 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -233,34 +233,7 @@ def populate_events() -> Optional[None]: events.append((event["id"], start, "(No Title)")) -def send_reminders() -> Optional[None]: - messages = [] - keys = set() - now = datetime.datetime.now(tz=pytz.utc) - - for id, start, summary in events: - dt = start - now - if dt.days == 0 and dt.seconds < 60 * calendar_options.interval: - # The unique key includes the start time, because of - # repeating events. - key = (id, start) - if key not in sent: - if start.hour == 0 and start.minute == 0: - line = f"{summary} is today." - else: - line = "{} starts at {}".format(summary, start.strftime("%H:%M")) - logging.info("Sending reminder: %s", line) - messages.append(line) - keys.add(key) - - if not messages: - return - - if len(messages) == 1: - message = "Reminder: " + messages[0] - else: - message = "Reminder:\n\n" + "\n".join("* " + m for m in messages) - +def send_reminder_message(message: str, key: Tuple[str, datetime.datetime]) -> None: user_profile = zulip_client.get_profile() if calendar_options.channel is not None: result = zulip_client.send_message( @@ -281,7 +254,24 @@ def send_reminders() -> Optional[None]: ) if result["result"] != "success": logging.error("Error sending zulip message: %s: %s", result.get("code"), result.get("msg")) - sent.update(keys) + sent.add(key) + + +def send_reminders() -> Optional[None]: + now = datetime.datetime.now(tz=pytz.utc) + + for id, start, summary in events: + dt = start - now + if dt.days == 0 and dt.seconds < 60 * calendar_options.interval: + # The unique key includes the start time due to repeating events. + key = (id, start) + if key not in sent: + if start.hour == 0 and start.minute == 0: + message = f"{summary} is today." + else: + message = "{} starts at {}".format(summary, start.strftime("%H:%M")) + logging.info("Sending reminder: %s", message) + send_reminder_message(message, key) # Loop forever From 87948008621f456a44e9e0a7636fff0d9e9b68e3 Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Thu, 20 Feb 2025 02:57:57 +0530 Subject: [PATCH 24/27] google-calendar: Add TypedDict and string conversion function for event. The event tuple is switched to a TypedDict. Moved the code logic to construct the message string from the event into its own function. This is in preparation for adding support for more event fields. --- zulip/integrations/google/google-calendar | 40 +++++++++++++++-------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 23cc95dae..4ef7c3f00 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -9,7 +9,7 @@ import sys import time from configparser import ConfigParser from dataclasses import dataclass -from typing import List, Optional, Set, Tuple +from typing import List, Optional, Set, Tuple, TypedDict import dateutil.parser import pytz @@ -43,8 +43,14 @@ class GoogleCalendarOptions: client_secret_path: str = CLIENT_SECRET_PATH +class Event(TypedDict): + id: str + start: datetime.datetime + summary: str + + # Our cached view of the calendar, updated periodically. -events: List[Tuple[str, datetime.datetime, str]] = [] +events: List[Event] = [] # Unique keys for events we've already sent, so we don't remind twice. sent: Set[Tuple[str, datetime.datetime]] = set() @@ -227,10 +233,21 @@ def populate_events() -> Optional[None]: # of the tzinfo base class. start = calendar_timezone.localize(start_naive) - try: - events.append((event["id"], start, event["summary"])) - except KeyError: - events.append((event["id"], start, "(No Title)")) + events.append( + { + "id": event["id"], + "start": start, + "summary": event.get("summary", "(No Title)"), + } + ) + + +def construct_message_from_event(event: Event) -> str: + if event["start"].hour == 0 and event["start"].minute == 0: + message = f"{event["summary"]} is today." + else: + message = "{} starts at {}".format(event["summary"], event["start"].strftime("%H:%M")) + return message def send_reminder_message(message: str, key: Tuple[str, datetime.datetime]) -> None: @@ -260,16 +277,13 @@ def send_reminder_message(message: str, key: Tuple[str, datetime.datetime]) -> N def send_reminders() -> Optional[None]: now = datetime.datetime.now(tz=pytz.utc) - for id, start, summary in events: - dt = start - now + for event in events: + dt = event["start"] - now if dt.days == 0 and dt.seconds < 60 * calendar_options.interval: # The unique key includes the start time due to repeating events. - key = (id, start) + key = (event["id"], event["start"]) if key not in sent: - if start.hour == 0 and start.minute == 0: - message = f"{summary} is today." - else: - message = "{} starts at {}".format(summary, start.strftime("%H:%M")) + message = construct_message_from_event(event) logging.info("Sending reminder: %s", message) send_reminder_message(message, key) From b468931180ef397483e1539095102b0e5e20b252 Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Thu, 20 Feb 2025 03:09:11 +0530 Subject: [PATCH 25/27] google-calendar: Generalize the datetime parsing for event fields. Moved the parsing logic into `get_start_or_end` to enable re-using the function when we add support for the `end` field of event. --- zulip/integrations/google/google-calendar | 41 +++++++++++------------ 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 4ef7c3f00..bc5d7b368 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -9,7 +9,7 @@ import sys import time from configparser import ConfigParser from dataclasses import dataclass -from typing import List, Optional, Set, Tuple, TypedDict +from typing import Any, Dict, List, Optional, Set, Tuple, TypedDict import dateutil.parser import pytz @@ -212,31 +212,28 @@ def populate_events() -> Optional[None]: events.clear() for event in feed["items"]: - try: - start = dateutil.parser.parse(event["start"]["dateTime"]) - # According to the API documentation, a time zone offset is required - # for start.dateTime unless a time zone is explicitly specified in - # start.timeZone. - if start.tzinfo is None: - event_timezone = pytz.timezone(event["start"]["timeZone"]) - # pytz timezones include an extra localize method that's not part - # of the tzinfo base class. - start = event_timezone.localize(start) - except KeyError: - # All-day events can have only a date. - start_naive = dateutil.parser.parse(event["start"]["date"]) - - # All-day events don't have a time zone offset; instead, we use the - # time zone of the calendar. - calendar_timezone = pytz.timezone(feed["timeZone"]) - # pytz timezones include an extra localize method that's not part - # of the tzinfo base class. - start = calendar_timezone.localize(start_naive) + + def get_start_or_end(event: Dict[str, Any], field_name: str) -> datetime.datetime: + try: + field = dateutil.parser.parse(event[field_name]["dateTime"]) + # a time zone offset is required unless timeZone is explicitly specified. + if field.tzinfo is None: + # pytz timezones include an extra localize method that's not part + # of the tzinfo base class. + event_timezone = pytz.timezone(event[field_name]["timeZone"]) + field = event_timezone.localize(field) + except KeyError: + # All-day events can have only a date. + field_naive = dateutil.parser.parse(event[field_name]["date"]) + # All-day events do not have a time zone offset; use the calendar's time zone. + calendar_timezone = pytz.timezone(feed["timeZone"]) + field = calendar_timezone.localize(field_naive) + return field events.append( { "id": event["id"], - "start": start, + "start": get_start_or_end(event, "start"), "summary": event.get("summary", "(No Title)"), } ) From 078d307ba6794eb64174925adc227946919502fd Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Thu, 20 Feb 2025 03:15:54 +0530 Subject: [PATCH 26/27] google-calendar: Display more Event info in reminder messages. Added the following Event fields to the message template: - event end time - description - link to event in Google Calendar - location of event, if any - link to Google Meet, if any Co-authored-by: Vedant Joshi --- zulip/integrations/google/google-calendar | 28 +++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index bc5d7b368..d48b3b5bd 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -46,7 +46,13 @@ class GoogleCalendarOptions: class Event(TypedDict): id: str start: datetime.datetime + end: datetime.datetime + html_link: str + # The following fields are optional, and may not be present in all events. summary: str + description: str + location: str + hangout_link: str # Our cached view of the calendar, updated periodically. @@ -234,16 +240,30 @@ def populate_events() -> Optional[None]: { "id": event["id"], "start": get_start_or_end(event, "start"), + "end": get_start_or_end(event, "end"), "summary": event.get("summary", "(No Title)"), + "description": event.get("description", ""), + "html_link": event["htmlLink"], + "location": event.get("location", ""), + "hangout_link": event.get("hangoutLink", ""), } ) def construct_message_from_event(event: Event) -> str: - if event["start"].hour == 0 and event["start"].minute == 0: - message = f"{event["summary"]} is today." - else: - message = "{} starts at {}".format(event["summary"], event["start"].strftime("%H:%M")) + time_period = ( + "today" + if event["start"].hour == 0 and event["start"].minute == 0 + else f"""scheduled from {event["start"].strftime('%H:%M')} to {event["end"].strftime('%H:%M')}""" + ) + location = f""", at {event["location"]},""" if event["location"] else "" + description = f"""\n> {event["description"]}\n""" if event["description"] else "" + google_meet_link = ( + f"""\n[Join call]({event["hangout_link"]}).""" if event["hangout_link"] else "" + ) + + message = f"""[{event["summary"]}]({event["html_link"]}){location} is {time_period}.{description}{google_meet_link}""" + return message From 974863e1ec935a895dbe8706208264cd7a1189d9 Mon Sep 17 00:00:00 2001 From: Niloth P <20315308+Niloth-p@users.noreply.github.com> Date: Thu, 20 Feb 2025 21:32:59 +0530 Subject: [PATCH 27/27] google-calendar: Support user customization of the message template. By adding a --format-message command line option, and a format_message config file option. --- zulip/integrations/google/google-calendar | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index d48b3b5bd..944d4853f 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -41,6 +41,7 @@ class GoogleCalendarOptions: noauth_local_webserver: bool = False tokens_path: str = TOKENS_PATH client_secret_path: str = CLIENT_SECRET_PATH + format_message: Optional[str] = None class Event(TypedDict): @@ -69,6 +70,7 @@ usage = r"""google-calendar [--config-file PATH_TO_ZULIPRC_OF_BOT] [--client-secret-file PATH_TO_CLIENT_SECRET_FILE] [--tokens-file PATH_TO_GOOGLE_TOKENS_FILE] [-n] [--noauth_local_webserver] + [-f MESSAGE_TEMPLATE] [--format-message MESSAGE_TEMPLATE] This integration can be used to send Zulip messages as reminders for upcoming events from your Google Calendar. @@ -112,6 +114,11 @@ parser.add_argument( action="store_true", help="The default authorization process runs a local web server, which requires a browser on the same machine. For non-interactive environments and machines without browser access, e.g., remote servers, this option allows manual authorization. The authorization URL is printed, which the user can copy into a browser, copy the resulting authorization code, and paste back into the command line.", ) +parser.add_argument( + "-f", + "--format-message", + help="A Python f-string to use to format the markdown message template. This option overrides the default message template. The f-string can use the following variables: start, end, title, description, calendar_link, location, google_meet_link.\nNote that the title, description, location, and google_meet_link variables are optional for Google Calendar events, and hence may be empty. Empty fields are displayed as {No title}, {No description}, {No location}, and {No link} in the message template.", +) commandline_options = parser.parse_args() if commandline_options.verbose: logging.getLogger().setLevel(logging.INFO) @@ -251,6 +258,19 @@ def populate_events() -> Optional[None]: def construct_message_from_event(event: Event) -> str: + if calendar_options.format_message: + message = calendar_options.format_message.format( + start=event["start"].strftime("%Y-%m-%d %H:%M"), + end=event["end"].strftime("%Y-%m-%d %H:%M"), + title=event["summary"], + description=event["description"] or "{No description}", + calendar_link=event["html_link"], + location=event["location"] or "{No location}", + google_meet_link=event["hangout_link"] or "{No link}", + ) + decoded_message = bytes(message, "utf-8").decode("unicode_escape") + return decoded_message + time_period = ( "today" if event["start"].hour == 0 and event["start"].minute == 0