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..5c76e9a1b 100755
--- a/zulip/integrations/google/get-google-credentials
+++ b/zulip/integrations/google/get-google-credentials
@@ -1,17 +1,39 @@
 #!/usr/bin/env python3
 import argparse
+import logging
 import os
+import sys
 
-from oauth2client import client, tools
-from oauth2client.file import Storage
+from google.auth.exceptions import RefreshError  # type: ignore[import-not-found]
+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 +41,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 +50,42 @@ 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:
+            try:
+                credentials.refresh(Request())
+            except RefreshError:
+                logging.error(
+                    "The credentials have expired. Generate a new client_secret.json file."
+                )
+                sys.exit(1)
+        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..19294f0d1 100755
--- a/zulip/integrations/google/google-calendar
+++ b/zulip/integrations/google/google-calendar
@@ -9,33 +9,46 @@ 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.auth.exceptions import RefreshError  # type: ignore[import-not-found]
+    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  # Minutes before the event to send a reminder.
+
+
 # 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, int]] = set()
 
 sys.path.append(os.path.dirname(__file__))
 
@@ -62,12 +75,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 +100,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 +112,176 @@ 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()
-    feed = (
-        service.events()
-        .list(
-            calendarId=options.calendarID,
-            timeMin=now,
-            maxResults=5,
-            singleEvents=True,
-            orderBy="startTime",
+    creds = get_credentials()
+    service = build("calendar", "v3", credentials=creds)
+    try:
+        feed = (
+            service.events()
+            .list(
+                calendarId=options.calendarID,
+                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()
         )
-        .execute()
-    )
-
+    except RefreshError:
+        logging.error(
+            "The credentials have expired. Generate a new client_secret.json file and run the get-google-credentials script."
+        )
+        sys.exit(1)
     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["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