diff --git a/pyproject.toml b/pyproject.toml index 159c85e23..5261b00f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,9 @@ module = [ "feedparser.*", "gitlint.*", "googleapiclient.*", + "google_api_python_client.*", + "google_auth_httplib2.*", + "google_auth_oauthlib.*", "irc.*", "mercurial.*", "nio.*", diff --git a/zulip/integrations/google/get-google-credentials b/zulip/integrations/google/get-google-credentials index bb97e5f69..00d693fb4 100755 --- a/zulip/integrations/google/get-google-credentials +++ b/zulip/integrations/google/get-google-credentials @@ -2,16 +2,35 @@ import argparse import os -from oauth2client import client, tools -from oauth2client.file import Storage +from google.auth.transport.requests import Request # type: ignore[import-not-found] +from google.oauth2.credentials import Credentials # type: ignore[import-not-found] +from google_auth_oauthlib.flow import InstalledAppFlow + +parser = argparse.ArgumentParser(add_help=False) +parser.add_argument( + "--auth_host_name", default="localhost", help="Hostname when running a local web server." +) +parser.add_argument( + "--noauth_local_webserver", + action="store_true", + default=False, + help="Do not run a local web server.", +) +parser.add_argument( + "--auth_host_port", + default=[8080, 8090], + type=int, + nargs="*", + help="Port web server should listen on.", +) +flags = parser.parse_args() -flags = argparse.ArgumentParser(parents=[tools.argparser]).parse_args() # 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 @@ -19,7 +38,7 @@ APPLICATION_NAME = "Zulip Calendar Bot" HOME_DIR = os.path.expanduser("~") -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, @@ -28,19 +47,36 @@ def get_credentials() -> client.Credentials: Returns: Credentials, the obtained credential. """ - + credentials = 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) + if os.path.exists(credential_path): + credentials = Credentials.from_authorized_user_file(credential_path, SCOPES) + if not credentials or not credentials.valid: + if credentials and credentials.expired and credentials.refresh_token: + credentials.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_secrets_file( + os.path.join(HOME_DIR, CLIENT_SECRET_FILE), SCOPES + ) + if not flags.noauth_local_webserver: + credentials = flow.run_local_server( + host=flags.auth_host_name, port=flags.auth_host_port[0] + ) + # 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. + else: + auth_url, _ = flow.authorization_url(prompt="consent") + print( + "Proceed to the following link in your browser:", + auth_url, + ) + auth_code = input("Enter the authorization code: ") + credentials = flow.fetch_token(code=auth_code) + with open(credential_path, "w") as token: + token.write(credentials.to_json()) print("Storing credentials to " + credential_path) + return credentials get_credentials() diff --git a/zulip/integrations/google/google-calendar b/zulip/integrations/google/google-calendar index 85906bd46..2a8662c82 100755 --- a/zulip/integrations/google/google-calendar +++ b/zulip/integrations/google/google-calendar @@ -9,33 +9,45 @@ import logging import os import sys import time -from typing import List, Optional, Set, Tuple +from typing import List, Optional, Set, Tuple, TypedDict 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 # type: ignore[import-not-found] + from googleapiclient.discovery import build except ImportError: - logging.exception("Install google-api-python-client") + logging.exception("Install google-api-python-client and google-auth-oauthlib") 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("~") + +class Event(TypedDict): + id: int + start: datetime.datetime + end: datetime.datetime + summary: str + html_link: str + status: str + location: str + description: str + organizer: str + hangout_link: str + reminder: int + + # Our cached view of the calendar, updated periodically. -events: List[Tuple[int, datetime.datetime, str]] = [] +events: List[Event] = [] -# Unique keys for events we've already sent, so we don't remind twice. -sent: Set[Tuple[int, datetime.datetime]] = set() +# Unique keys for reminders we've already sent, so we don't remind twice. +sent: Set[Tuple[int, datetime.datetime, int]] = set() sys.path.append(os.path.dirname(__file__)) @@ -62,12 +74,11 @@ google-calendar --calendar calendarID@example.calendar.google.com parser.add_argument( - "--interval", - dest="interval", - default=30, + "--override", + dest="override", type=int, action="store", - help="Minutes before event for reminder [default: 30]", + help="Override the reminder time for all events.", metavar="MINUTES", ) @@ -88,7 +99,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, @@ -100,95 +111,170 @@ 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: + credentials = 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: logging.error("Run the get-google-credentials script from this directory first.") sys.exit(1) + else: + return credentials def populate_events() -> Optional[None]: - credentials = get_credentials() - creds = credentials.authorize(httplib2.Http()) - service = discovery.build("calendar", "v3", http=creds) - - now = datetime.datetime.now(pytz.utc).isoformat() + creds = get_credentials() + service = build("calendar", "v3", credentials=creds) feed = ( service.events() .list( calendarId=options.calendarID, - timeMin=now, - maxResults=5, + timeMin=datetime.datetime.now(pytz.utc).isoformat(), + timeMax=datetime.datetime.now(pytz.utc).isoformat().split("T")[0] + "T23:59:59Z", singleEvents=True, orderBy="startTime", ) .execute() ) - events.clear() for event in feed["items"]: try: start = dateutil.parser.parse(event["start"]["dateTime"]) + end = dateutil.parser.parse(event["end"]["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: + if start.tzinfo is None or end.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) + end = event_timezone.localize(end) except KeyError: # All-day events can have only a date. start_naive = dateutil.parser.parse(event["start"]["date"]) - + end_naive = dateutil.parser.parse(event["end"]["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) + end = calendar_timezone.localize(end_naive) + now = datetime.datetime.now(tz=start.tzinfo) + if start < now: + continue + id = event["id"] + summary = event.get("summary", "(No Title)") + html_link = event["htmlLink"] + status = event.get("status", "confirmed") + location = event.get("location", "") + description = event.get("description", "") + organizer = ( + "" + if ( + event["organizer"]["email"] == options.zulip_email or event["organizer"].get("self") + ) + else event["organizer"].get("displayName", event["organizer"]["email"]) + ) + hangout_link = event.get("hangoutLink", "") + reminders = event["reminders"] + # If the user has specified an override, we use that for all events. + # If the event uses the calendar's default reminders, we use that. + # If the event has overrides on Google Calendar, we use that. + # If none of the above, we don't set a reminder. + if options.override: + reminder_minutes = [options.override] + elif reminders.get("useDefault"): + calendar_list = service.calendarList().get(calendarId=options.calendarID).execute() + reminder_minutes = ( + [reminder["minutes"] for reminder in calendar_list["defaultReminders"]] + if calendar_list.get("defaultReminders") + else [] + ) + elif reminders.get("overrides"): + reminder_minutes = [reminder["minutes"] for reminder in reminders["overrides"]] + else: + reminder_minutes = [] + events.extend( + { + "id": id, + "start": start, + "end": end, + "summary": summary, + "html_link": html_link, + "status": status, + "location": location, + "description": description, + "organizer": organizer, + "hangout_link": hangout_link, + "reminder": reminder, + } + for reminder in reminder_minutes + ) - try: - events.append((event["id"], start, event["summary"])) - except KeyError: - events.append((event["id"], start, "(No Title)")) + +def event_to_message(event: Event) -> str: + """Parse the event dictionary and return a string that can be sent as a message. + + The message includes the event title, start and end times, location, organizer, hangout link, and description. + + Returns: + str: The message to be sent. + """ + line = f"**[{event['summary']}]({event['html_link']})**\n" + if event["start"].hour == 0 and event["start"].minute == 0: + line += "Scheduled for today.\n" + else: + line += f"Scheduled from **{event['start'].strftime('%H:%M')}** to **{event['end'].strftime('%H:%M')}**.\n" + line += f"**Location:** {event['location']}\n" if event["location"] else "" + line += f"**Organizer:** {event['organizer']}\n" if event["organizer"] else "" + line += ( + f"**Hangout Link:** [{event['hangout_link'].split('/')[2]}]({event['hangout_link']})\n" + if event["hangout_link"] + else "" + ) + line += f"**Status:** {event['status']}\n" if event["status"] else "" + line += f"**Description:** {event['description']}\n" if event["description"] else "" + return line def send_reminders() -> Optional[None]: - messages = [] + messages: List[str] = [] 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 * options.interval: - # The unique key includes the start time, because of - # repeating events. - key = (id, start) + # Sort events by the time of the reminder. + events.sort( + key=lambda event: (event["start"] - datetime.timedelta(minutes=event["reminder"])), + reverse=True, + ) + # Iterate through the events and send reminders for those whose reminder time has come or passed and remove them from the list. + # The instant a reminder's time is greater than the current time, we stop sending reminders and break out of the loop. + while len(events): + event = events[-1] + now = datetime.datetime.now(tz=event["start"].tzinfo) + dt = event["start"] - datetime.timedelta(minutes=event["reminder"]) + if dt <= now: + key = (event["id"], event["start"], event["reminder"]) 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")) + line = event_to_message(event) print("Sending reminder:", line) - messages.append(line) + messages = [line, *messages] keys.add(key) + events.pop() + else: + break if not messages: return if len(messages) == 1: - message = "Reminder: " + messages[0] + message = "**Reminder:**\n\n " + messages[0] else: - message = "Reminder:\n\n" + "\n".join("* " + m for m in messages) + message = "**Reminders:**\n\n" + "\n".join( + str(i + 1) + ". " + m for i, m in enumerate(messages) + ) - zulip_client.send_message( - dict(type="private", to=options.zulip_email, sender=options.zulip_email, content=message) - ) + zulip_client.send_message(dict(type="private", to=options.zulip_email, content=message)) sent.update(keys) diff --git a/zulip/integrations/google/requirements.txt b/zulip/integrations/google/requirements.txt index 139c0705b..018523c01 100644 --- a/zulip/integrations/google/requirements.txt +++ b/zulip/integrations/google/requirements.txt @@ -1,2 +1,3 @@ -httplib2>=0.22.0 -oauth2client>=4.1.3 +google-api-python-client>=2.157.0 +google-auth-httplib2>=0.2.0 +google-auth-oauthlib>=1.2.1