From 76be5e43349df698ce62274a8a46f49bedcdad87 Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Thu, 3 Sep 2026 14:02:50 -0300 Subject: [PATCH 01/12] fix: report the waiting room from the Teams and Google Meet adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BotStates.WAITING_ROOM` and `BOT_PUT_IN_WAITING_ROOM` already exist and `bot_controller already` turns the message into a state change, but only the Zoom adapter ever sends it — the web adapters just poll for the meeting UI. An API consumer therefore cannot tell a bot held in the lobby from one still connecting, and only learns about it when the waiting room times out. Detect the lobby inside the Teams and Google Meet post-join poll loops and send the message once. The `bot_controller` handler now checks the bot is still JOINING, since joining -> waiting_room is a one-shot transition and create_event raises on any other source state. Co-Authored-By: Claude Opus 5 (1M context) --- bots/bot_controller/bot_controller.py | 8 ++++++++ .../google_meet_ui_methods.py | 12 +++++++++++ bots/teams_bot_adapter/teams_ui_methods.py | 20 +++++++++++++++++++ bots/web_bot_adapter/web_bot_adapter.py | 10 ++++++++++ version.json | 2 +- 5 files changed, 51 insertions(+), 1 deletion(-) diff --git a/bots/bot_controller/bot_controller.py b/bots/bot_controller/bot_controller.py index f271ed141..1be9dabd9 100644 --- a/bots/bot_controller/bot_controller.py +++ b/bots/bot_controller/bot_controller.py @@ -2079,6 +2079,14 @@ def take_action_based_on_message_from_adapter(self, message): if message.get("message") == BotAdapter.Messages.BOT_PUT_IN_WAITING_ROOM: logger.info("Received message to put bot in waiting room") + # joining -> waiting_room is the only valid transition for this event, and + # create_event raises on anything else. A bot that was admitted (or gave + # up) between the sighting and this message must not take the whole bot + # down with a ValidationError. + self.bot_in_db.refresh_from_db() + if self.bot_in_db.state != BotStates.JOINING: + logger.info(f"Bot is in state {BotStates.state_to_api_code(self.bot_in_db.state)}, not recording it as put in waiting room") + return BotEventManager.create_event(bot=self.bot_in_db, event_type=BotEventTypes.BOT_PUT_IN_WAITING_ROOM) return diff --git a/bots/google_meet_bot_adapter/google_meet_ui_methods.py b/bots/google_meet_bot_adapter/google_meet_ui_methods.py index 26412418a..4553583ec 100644 --- a/bots/google_meet_bot_adapter/google_meet_ui_methods.py +++ b/bots/google_meet_bot_adapter/google_meet_ui_methods.py @@ -176,6 +176,17 @@ def look_for_asking_to_be_let_in_element_after_waiting_period_expired(self, step logger.warning("Bot was not let in after waiting period expired. Raising UiRequestToJoinDeniedException") raise UiRequestToJoinDeniedException("Bot was not let in after waiting period expired", step) + def check_if_in_waiting_room(self, step): + # Same element look_for_asking_to_be_let_in_element_after_waiting_period_expired + # checks at the end of the wait — reported here as soon as it appears, while + # someone can still admit the bot. + asking_to_be_let_in_element = self.find_element_by_selector( + By.XPATH, + '//*[contains(text(), "Asking to be let in")]', + ) + if asking_to_be_let_in_element: + self.send_bot_put_in_waiting_room_message() + def check_if_waiting_room_timeout_exceeded(self, waiting_room_timeout_started_at, step): waiting_room_timeout_exceeded = time.time() - waiting_room_timeout_started_at > self.automatic_leave_configuration.waiting_room_timeout_seconds if waiting_room_timeout_exceeded: @@ -533,6 +544,7 @@ def click_captions_button(self): self.look_for_denied_your_request_element("click_captions_button") self.click_this_meeting_is_being_recorded_join_now_button("click_captions_button") self.click_others_may_see_your_meeting_differently_button("click_captions_button") + self.check_if_in_waiting_room("click_captions_button") self.check_if_waiting_room_timeout_exceeded(waiting_room_timeout_started_at, "click_captions_button") last_check_timed_out = attempt_to_look_for_captions_button_index == num_attempts_to_look_for_captions_button - 1 diff --git a/bots/teams_bot_adapter/teams_ui_methods.py b/bots/teams_bot_adapter/teams_ui_methods.py index f337d19f1..a2379a892 100644 --- a/bots/teams_bot_adapter/teams_ui_methods.py +++ b/bots/teams_bot_adapter/teams_ui_methods.py @@ -63,6 +63,25 @@ def look_for_waiting_to_be_admitted_element(self, step): logger.info("Still waiting to be admitted to the meeting after waiting period expired. Raising UiRequestToJoinDeniedException") raise UiRequestToJoinDeniedException("Bot was not let in after waiting period expired", step) + def check_if_in_waiting_room(self, step): + # Called from the post-join polling loop. Either tell means the join request is + # sitting in the lobby: the lobby copy, or the prejoin join button still on the + # page but disabled — the same state check_if_waiting_room_connection_failed + # reads as "in the waiting room, connection intact". + # + # Detection only ever reports; the caller's loop turns any exception into a + # failed join, so a stale element must not escape. + try: + if self.find_element_by_selector(By.XPATH, '//*[contains(text(), "Someone will let you in soon")]'): + self.send_bot_put_in_waiting_room_message() + return + + join_button = self.find_element_by_selector(By.CSS_SELECTOR, '[data-tid="prejoin-join-button"]') + if join_button and not join_button.is_enabled(): + self.send_bot_put_in_waiting_room_message() + except Exception as e: + logger.info(f"Unknown error occurred in check_if_in_waiting_room. Exception type = {type(e)}") + def turn_off_media_inputs(self): logger.info("Waiting for the microphone button...") microphone_button = self.locate_element(step="turn_off_microphone_button", condition=EC.presence_of_element_located((By.CSS_SELECTOR, '[data-tid="toggle-mute"]')), wait_time_seconds=60) @@ -311,6 +330,7 @@ def click_show_more_button(self): self.look_for_denied_your_request_element("click_show_more_button") self.look_for_we_could_not_connect_you_element("click_show_more_button") + self.check_if_in_waiting_room("click_show_more_button") self.check_if_waiting_room_timeout_exceeded(waiting_room_timeout_started_at, "click_show_more_button") self.check_if_waiting_room_connection_failed(waiting_room_timeout_started_at, "click_show_more_button") diff --git a/bots/web_bot_adapter/web_bot_adapter.py b/bots/web_bot_adapter/web_bot_adapter.py index 54964de90..4c18d331f 100644 --- a/bots/web_bot_adapter/web_bot_adapter.py +++ b/bots/web_bot_adapter/web_bot_adapter.py @@ -77,6 +77,9 @@ def __init__( self.disable_incoming_video = disable_incoming_video self.record_participant_speech_start_stop_events = record_participant_speech_start_stop_events self.meeting_url = meeting_url + # The lobby is detected inside a polling loop, but joining -> waiting_room is a + # one-shot transition, so only the first sighting is reported. + self.sent_bot_put_in_waiting_room_message = False # This is an internal ID that comes from the platform. It is currently only used for MS Teams. self.meeting_uuid = None @@ -488,6 +491,13 @@ def run_websocket_server(self): continue raise # Re-raise other OSErrors + def send_bot_put_in_waiting_room_message(self): + if self.sent_bot_put_in_waiting_room_message: + return + self.sent_bot_put_in_waiting_room_message = True + logger.info("Bot is waiting in the meeting's waiting room. Sending BOT_PUT_IN_WAITING_ROOM message") + self.send_message_callback({"message": self.Messages.BOT_PUT_IN_WAITING_ROOM}) + def send_request_to_join_denied_message(self): self.send_message_callback({"message": self.Messages.REQUEST_TO_JOIN_DENIED, "remover": self.remover}) diff --git a/version.json b/version.json index 7796b68e8..e24f0a425 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "1.70.1" + "version": "1.70.2" } From b625c2cc58311c78b8fdf2e34283aaa3fb51a08e Mon Sep 17 00:00:00 2001 From: Rafael Dantas Justo Date: Thu, 3 Sep 2026 14:27:21 -0300 Subject: [PATCH 02/12] fix: keep a bot in the waiting room restartable, and trust the lobby tells less MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restart_bot_pod bails unless the last bot event is JOIN_REQUESTED, so reporting the waiting room made a Teams/Meet join unrecoverable: UiTeamsBlockingUsException from the same poll loop sends BLOCKED_BY_PLATFORM_REPEATEDLY, the controller quits its main loop and the pod is never recreated, leaving the bot to be reaped ~10 minutes later as a fatal error. WAITING_ROOM is now a join in progress in the restart task, in set_requested_bot_action_taken_at and in take_action_based_on_bot_in_db — which closes the same gap for native Zoom bots, which already reached that state. Also: the disabled prejoin join button only counts after the 30s grace period check_if_waiting_room_connection_failed already waits out, since Teams disables it on click and the fallback fired on direct-admit meetings; create_event re-reads the bot in its own transaction, so catch ValidationError as well as checking the state; and stop polling the DOM once the message has been sent. Co-Authored-By: Claude Opus 5 (1M context) --- bots/bot_controller/bot_controller.py | 20 +++++++++----- .../google_meet_ui_methods.py | 4 +++ bots/models.py | 2 ++ bots/tasks/restart_bot_pod_task.py | 7 +++-- bots/teams_bot_adapter/teams_ui_methods.py | 27 ++++++++++++++----- 5 files changed, 45 insertions(+), 15 deletions(-) diff --git a/bots/bot_controller/bot_controller.py b/bots/bot_controller/bot_controller.py index 1be9dabd9..14ee9b796 100644 --- a/bots/bot_controller/bot_controller.py +++ b/bots/bot_controller/bot_controller.py @@ -11,6 +11,7 @@ import gi import redis from django.conf import settings +from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.utils import timezone @@ -1014,8 +1015,10 @@ def redis_listener(): self.pubsub.close() def take_action_based_on_bot_in_db(self): - if self.bot_in_db.state == BotStates.JOINING: - logger.info("take_action_based_on_bot_in_db - JOINING") + # WAITING_ROOM is a join in progress too: a pod recreated while the bot was in + # the lobby has to re-attempt the join rather than idle until it is reaped. + if self.bot_in_db.state == BotStates.JOINING or self.bot_in_db.state == BotStates.WAITING_ROOM: + logger.info(f"take_action_based_on_bot_in_db - {BotStates(self.bot_in_db.state).name}") BotEventManager.set_requested_bot_action_taken_at(self.bot_in_db) self.adapter.init() if self.bot_in_db.state == BotStates.LEAVING: @@ -2080,14 +2083,19 @@ def take_action_based_on_message_from_adapter(self, message): if message.get("message") == BotAdapter.Messages.BOT_PUT_IN_WAITING_ROOM: logger.info("Received message to put bot in waiting room") # joining -> waiting_room is the only valid transition for this event, and - # create_event raises on anything else. A bot that was admitted (or gave - # up) between the sighting and this message must not take the whole bot - # down with a ValidationError. + # create_event raises on anything else. A bot admitted (or given up on) + # between the sighting and this message must not take the whole bot down + # with a ValidationError, so check the state and then survive losing the + # race anyway: create_event re-reads the bot inside its own transaction, + # and the web process creates events for this bot out of band. self.bot_in_db.refresh_from_db() if self.bot_in_db.state != BotStates.JOINING: logger.info(f"Bot is in state {BotStates.state_to_api_code(self.bot_in_db.state)}, not recording it as put in waiting room") return - BotEventManager.create_event(bot=self.bot_in_db, event_type=BotEventTypes.BOT_PUT_IN_WAITING_ROOM) + try: + BotEventManager.create_event(bot=self.bot_in_db, event_type=BotEventTypes.BOT_PUT_IN_WAITING_ROOM) + except ValidationError as e: + logger.info(f"Could not record the bot as put in waiting room, continuing: {e}") return if message.get("message") == BotAdapter.Messages.BOT_JOINED_MEETING: diff --git a/bots/google_meet_bot_adapter/google_meet_ui_methods.py b/bots/google_meet_bot_adapter/google_meet_ui_methods.py index 4553583ec..c2cd42a8f 100644 --- a/bots/google_meet_bot_adapter/google_meet_ui_methods.py +++ b/bots/google_meet_bot_adapter/google_meet_ui_methods.py @@ -177,6 +177,10 @@ def look_for_asking_to_be_let_in_element_after_waiting_period_expired(self, step raise UiRequestToJoinDeniedException("Bot was not let in after waiting period expired", step) def check_if_in_waiting_room(self, step): + # Nothing left to report, so stop paying for the DOM lookup every second. + if self.sent_bot_put_in_waiting_room_message: + return + # Same element look_for_asking_to_be_let_in_element_after_waiting_period_expired # checks at the end of the wait — reported here as soon as it appears, while # someone can still admit the bot. diff --git a/bots/models.py b/bots/models.py index 0072c4875..80dc59f75 100644 --- a/bots/models.py +++ b/bots/models.py @@ -1758,6 +1758,8 @@ def event_can_be_created_for_state(cls, event_type: BotEventTypes, state: BotSta def set_requested_bot_action_taken_at(cls, bot: Bot): event_type = { BotStates.JOINING: BotEventTypes.JOIN_REQUESTED, + # A pod restarted while its bot sat in the lobby re-attempts the join. + BotStates.WAITING_ROOM: BotEventTypes.BOT_PUT_IN_WAITING_ROOM, BotStates.LEAVING: BotEventTypes.LEAVE_REQUESTED, BotStates.CONNECTING: BotEventTypes.APP_SESSION_CONNECTION_REQUESTED, BotStates.DISCONNECTING: BotEventTypes.APP_SESSION_DISCONNECT_REQUESTED, diff --git a/bots/tasks/restart_bot_pod_task.py b/bots/tasks/restart_bot_pod_task.py index 2726ba085..869449882 100644 --- a/bots/tasks/restart_bot_pod_task.py +++ b/bots/tasks/restart_bot_pod_task.py @@ -24,8 +24,11 @@ def restart_bot_pod(self, bot_id): last_bot_event = bot.last_bot_event() - if last_bot_event.event_type != BotEventTypes.JOIN_REQUESTED: - logger.info(f"Bot {bot_id} is not in JOINING state, so not restarting pod") + # A bot that was put in the waiting room is still mid-join: same recovery, and + # refusing it would leave the pod dead until the heartbeat cleanup reaps the bot + # as a fatal error ~10 minutes later. + if last_bot_event.event_type not in (BotEventTypes.JOIN_REQUESTED, BotEventTypes.BOT_PUT_IN_WAITING_ROOM): + logger.info(f"Bot {bot_id} is not joining, so not restarting pod") return # Initialize kubernetes client diff --git a/bots/teams_bot_adapter/teams_ui_methods.py b/bots/teams_bot_adapter/teams_ui_methods.py index a2379a892..b3a74c0a1 100644 --- a/bots/teams_bot_adapter/teams_ui_methods.py +++ b/bots/teams_bot_adapter/teams_ui_methods.py @@ -15,6 +15,10 @@ logger = logging.getLogger(__name__) +# How long the prejoin join button stays untrustworthy after it is clicked. Mirrors +# the grace period in check_if_waiting_room_connection_failed. +WAITING_ROOM_BUTTON_GRACE_SECONDS = 30 + class UiTeamsBlockingUsException(UiRetryableExpectedException): def __init__(self, message, step=None, inner_exception=None): @@ -63,12 +67,13 @@ def look_for_waiting_to_be_admitted_element(self, step): logger.info("Still waiting to be admitted to the meeting after waiting period expired. Raising UiRequestToJoinDeniedException") raise UiRequestToJoinDeniedException("Bot was not let in after waiting period expired", step) - def check_if_in_waiting_room(self, step): - # Called from the post-join polling loop. Either tell means the join request is - # sitting in the lobby: the lobby copy, or the prejoin join button still on the - # page but disabled — the same state check_if_waiting_room_connection_failed - # reads as "in the waiting room, connection intact". - # + def check_if_in_waiting_room(self, waiting_room_timeout_started_at, step): + # Nothing left to report, so stop paying for the DOM lookups. They would + # otherwise run once a second for the whole wait, competing with + # monitor_for_disable_light_experience_redirect for selenium's connection pool. + if self.sent_bot_put_in_waiting_room_message: + return + # Detection only ever reports; the caller's loop turns any exception into a # failed join, so a stale element must not escape. try: @@ -76,6 +81,14 @@ def check_if_in_waiting_room(self, step): self.send_bot_put_in_waiting_room_message() return + # Fallback for a lobby whose copy we don't match (a non-English tenant): + # the prejoin join button still on the page but disabled. Teams disables + # that button the instant it is clicked, before it has decided whether the + # bot goes to the lobby or straight in, so it is only meaningful after the + # same grace period check_if_waiting_room_connection_failed waits out. + if time.time() - waiting_room_timeout_started_at < WAITING_ROOM_BUTTON_GRACE_SECONDS: + return + join_button = self.find_element_by_selector(By.CSS_SELECTOR, '[data-tid="prejoin-join-button"]') if join_button and not join_button.is_enabled(): self.send_bot_put_in_waiting_room_message() @@ -330,7 +343,7 @@ def click_show_more_button(self): self.look_for_denied_your_request_element("click_show_more_button") self.look_for_we_could_not_connect_you_element("click_show_more_button") - self.check_if_in_waiting_room("click_show_more_button") + self.check_if_in_waiting_room(waiting_room_timeout_started_at, "click_show_more_button") self.check_if_waiting_room_timeout_exceeded(waiting_room_timeout_started_at, "click_show_more_button") self.check_if_waiting_room_connection_failed(waiting_room_timeout_started_at, "click_show_more_button") From 26f11607e328aeb0f791d73ae6829613873b6b92 Mon Sep 17 00:00:00 2001 From: Noah Duncan Date: Fri, 9 Oct 2026 13:34:54 -0500 Subject: [PATCH 03/12] restore most of original logic for being put in waiting room --- bots/bot_controller/bot_controller.py | 22 +++++----------------- bots/models.py | 2 -- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/bots/bot_controller/bot_controller.py b/bots/bot_controller/bot_controller.py index 9bcc754b7..b4f7d9d36 100644 --- a/bots/bot_controller/bot_controller.py +++ b/bots/bot_controller/bot_controller.py @@ -11,7 +11,6 @@ import gi import redis from django.conf import settings -from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.utils import timezone @@ -1076,10 +1075,8 @@ def redis_listener(): self.pubsub.close() def take_action_based_on_bot_in_db(self): - # WAITING_ROOM is a join in progress too: a pod recreated while the bot was in - # the lobby has to re-attempt the join rather than idle until it is reaped. - if self.bot_in_db.state == BotStates.JOINING or self.bot_in_db.state == BotStates.WAITING_ROOM: - logger.info(f"take_action_based_on_bot_in_db - {BotStates(self.bot_in_db.state).name}") + if self.bot_in_db.state == BotStates.JOINING: + logger.info("take_action_based_on_bot_in_db - JOINING") BotEventManager.set_requested_bot_action_taken_at(self.bot_in_db) self.adapter.init() if self.bot_in_db.state == BotStates.LEAVING: @@ -2214,20 +2211,11 @@ def take_action_based_on_message_from_adapter(self, message): if message.get("message") == BotAdapter.Messages.BOT_PUT_IN_WAITING_ROOM: logger.info("Received message to put bot in waiting room") - # joining -> waiting_room is the only valid transition for this event, and - # create_event raises on anything else. A bot admitted (or given up on) - # between the sighting and this message must not take the whole bot down - # with a ValidationError, so check the state and then survive losing the - # race anyway: create_event re-reads the bot inside its own transaction, - # and the web process creates events for this bot out of band. - self.bot_in_db.refresh_from_db() - if self.bot_in_db.state != BotStates.JOINING: - logger.info(f"Bot is in state {BotStates.state_to_api_code(self.bot_in_db.state)}, not recording it as put in waiting room") - return + # Being put in the waiting room is a "cosmetic" event. so it's okay if it fails to record. try: BotEventManager.create_event(bot=self.bot_in_db, event_type=BotEventTypes.BOT_PUT_IN_WAITING_ROOM) - except ValidationError as e: - logger.info(f"Could not record the bot as put in waiting room, continuing: {e}") + except Exception: + logger.exception("Could not record the bot as put in waiting room, continuing") return if message.get("message") == BotAdapter.Messages.BOT_JOINED_MEETING: diff --git a/bots/models.py b/bots/models.py index 2c6d3fe01..4cba85ad1 100644 --- a/bots/models.py +++ b/bots/models.py @@ -1808,8 +1808,6 @@ def event_can_be_created_for_state(cls, event_type: BotEventTypes, state: BotSta def set_requested_bot_action_taken_at(cls, bot: Bot): event_type = { BotStates.JOINING: BotEventTypes.JOIN_REQUESTED, - # A pod restarted while its bot sat in the lobby re-attempts the join. - BotStates.WAITING_ROOM: BotEventTypes.BOT_PUT_IN_WAITING_ROOM, BotStates.LEAVING: BotEventTypes.LEAVE_REQUESTED, BotStates.CONNECTING: BotEventTypes.APP_SESSION_CONNECTION_REQUESTED, BotStates.DISCONNECTING: BotEventTypes.APP_SESSION_DISCONNECT_REQUESTED, From d95de00836ce4e8e380ecee68c43deac830acbfd Mon Sep 17 00:00:00 2001 From: Noah Duncan Date: Fri, 9 Oct 2026 13:44:13 -0500 Subject: [PATCH 04/12] avoid restarting bot pod if in the waiting room --- bots/bot_controller/bot_controller.py | 16 +++++++++++ bots/tests/test_google_meet_bot_2.py | 40 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/bots/bot_controller/bot_controller.py b/bots/bot_controller/bot_controller.py index b4f7d9d36..3bebf26b5 100644 --- a/bots/bot_controller/bot_controller.py +++ b/bots/bot_controller/bot_controller.py @@ -2025,6 +2025,22 @@ def take_action_based_on_message_from_adapter(self, message): self.cleanup() return + # restart_bot_pod only recreates pods for bots whose last event is JOIN_REQUESTED or STAGED, + # so a bot in the waiting room would be left without a running pod. + if self.bot_in_db.state == BotStates.WAITING_ROOM: + logger.info("Received message that we were blocked by platform repeatedly but bot is in the waiting room, so not recreating pod") + + new_bot_event = BotEventManager.create_event( + bot=self.bot_in_db, + event_type=BotEventTypes.FATAL_ERROR, + event_sub_type=BotEventSubTypes.FATAL_ERROR_UI_ELEMENT_NOT_FOUND, + event_metadata={ + "blocked_by_platform_while_in_waiting_room": True, + }, + ) + self.cleanup() + return + logger.info("Received message that we were blocked by platform repeatedly, so recreating pod") # Run task to restart the bot pod with 1 minute delay restart_bot_pod.apply_async(args=[self.bot_in_db.id], countdown=60) diff --git a/bots/tests/test_google_meet_bot_2.py b/bots/tests/test_google_meet_bot_2.py index 1ec760b46..daca178ac 100644 --- a/bots/tests/test_google_meet_bot_2.py +++ b/bots/tests/test_google_meet_bot_2.py @@ -2322,6 +2322,46 @@ def test_meeting_ended_while_in_waiting_room_is_could_not_join_when_flag_enabled self.assertEqual(could_not_join_event.new_state, BotStates.FATAL_ERROR) self.assertEqual(could_not_join_event.event_sub_type, BotEventSubTypes.COULD_NOT_JOIN_MEETING_MEETING_ENDED_BEFORE_BOT_JOINED) + @patch("bots.tasks.restart_bot_pod_task.restart_bot_pod.apply_async") + @patch("bots.models.Bot.create_debug_recording", return_value=False) + @patch("bots.web_bot_adapter.web_bot_adapter.Display") + @patch("bots.web_bot_adapter.web_bot_adapter.webdriver.Chrome") + @patch("bots.bot_controller.bot_controller.AzureFileUploader") + def test_blocked_by_platform_repeatedly_while_in_waiting_room_is_fatal_error( + self, + MockFileUploader, + MockChromeDriver, + MockDisplay, + mock_create_debug_recording, + mock_restart_bot_pod_apply_async, + ): + MockFileUploader.return_value = create_mock_file_uploader() + MockChromeDriver.return_value = create_mock_google_meet_driver() + MockDisplay.return_value = MagicMock() + + controller, bot_thread, release_join_attempt = self._start_bot_that_is_stuck_joining() + + controller.adapter.send_message_callback({"message": BotAdapter.Messages.BOT_PUT_IN_WAITING_ROOM}) + self._wait_for_bot_state(BotStates.WAITING_ROOM) + + controller.adapter.send_message_callback({"message": BotAdapter.Messages.BLOCKED_BY_PLATFORM_REPEATEDLY}) + + self._finish_bot(controller, bot_thread, release_join_attempt) + + mock_restart_bot_pod_apply_async.assert_not_called() + self.assertEqual(self.bot.state, BotStates.FATAL_ERROR) + + bot_events = list(self.bot.bot_events.order_by("created_at")) + self.assertEqual( + [event.event_type for event in bot_events], + [BotEventTypes.JOIN_REQUESTED, BotEventTypes.BOT_PUT_IN_WAITING_ROOM, BotEventTypes.FATAL_ERROR], + ) + + fatal_error_event = bot_events[2] + self.assertEqual(fatal_error_event.old_state, BotStates.WAITING_ROOM) + self.assertEqual(fatal_error_event.event_sub_type, BotEventSubTypes.FATAL_ERROR_UI_ELEMENT_NOT_FOUND) + self.assertEqual(fatal_error_event.metadata, {"blocked_by_platform_while_in_waiting_room": True}) + @override_settings(PREJOIN_LEAVE_OR_MEETING_END_IS_FATAL_ERROR=False) @patch("bots.models.Bot.create_debug_recording", return_value=False) @patch("bots.web_bot_adapter.web_bot_adapter.Display") From c06b53778e89353ef89b1dcbd097c93791d884ec Mon Sep 17 00:00:00 2001 From: Noah Duncan Date: Fri, 9 Oct 2026 13:48:53 -0500 Subject: [PATCH 05/12] Use call state for teams waiting room check --- bots/teams_bot_adapter/teams_ui_methods.py | 36 ++++++---------------- bots/web_bot_adapter/web_bot_adapter.py | 2 +- 2 files changed, 11 insertions(+), 27 deletions(-) diff --git a/bots/teams_bot_adapter/teams_ui_methods.py b/bots/teams_bot_adapter/teams_ui_methods.py index 901a401a4..bfd88534d 100644 --- a/bots/teams_bot_adapter/teams_ui_methods.py +++ b/bots/teams_bot_adapter/teams_ui_methods.py @@ -15,10 +15,6 @@ logger = logging.getLogger(__name__) -# How long the prejoin join button stays untrustworthy after it is clicked. Mirrors -# the grace period in check_if_waiting_room_connection_failed. -WAITING_ROOM_BUTTON_GRACE_SECONDS = 30 - class UiTeamsBlockingUsException(UiRetryableExpectedException): def __init__(self, message, step=None, inner_exception=None): @@ -67,33 +63,21 @@ def look_for_waiting_to_be_admitted_element(self, step): logger.info("Still waiting to be admitted to the meeting after waiting period expired. Raising UiRequestToJoinDeniedException") raise UiRequestToJoinDeniedException("Bot was not let in after waiting period expired", step) - def check_if_in_waiting_room(self, waiting_room_timeout_started_at, step): - # Nothing left to report, so stop paying for the DOM lookups. They would - # otherwise run once a second for the whole wait, competing with - # monitor_for_disable_light_experience_redirect for selenium's connection pool. + def check_if_in_waiting_room(self): if self.sent_bot_put_in_waiting_room_message: return - # Detection only ever reports; the caller's loop turns any exception into a - # failed join, so a stale element must not escape. + # The caller's loop turns any exception into a failed join, so this must not raise. try: - if self.find_element_by_selector(By.XPATH, '//*[contains(text(), "Someone will let you in soon")]'): - self.send_bot_put_in_waiting_room_message() - return - - # Fallback for a lobby whose copy we don't match (a non-English tenant): - # the prejoin join button still on the page but disabled. Teams disables - # that button the instant it is clicked, before it has decided whether the - # bot goes to the lobby or straight in, so it is only meaningful after the - # same grace period check_if_waiting_room_connection_failed waits out. - if time.time() - waiting_room_timeout_started_at < WAITING_ROOM_BUTTON_GRACE_SECONDS: - return - - join_button = self.find_element_by_selector(By.CSS_SELECTOR, '[data-tid="prejoin-join-button"]') - if join_button and not join_button.is_enabled(): - self.send_bot_put_in_waiting_room_message() + call_state = self.driver.execute_script("return window.callManager?.getCallState();") except Exception as e: logger.info(f"Unknown error occurred in check_if_in_waiting_room. Exception type = {type(e)}") + return + + # Values of callManager.getCallState() in the chromedriver payload: InLobby, NegotiatingEncryptionLobby. + teams_lobby_call_states = (10, 15) + if call_state in teams_lobby_call_states: + self.send_bot_put_in_waiting_room_message() def turn_off_media_inputs(self): logger.info("Waiting for the microphone button...") @@ -371,7 +355,7 @@ def click_show_more_button(self): self.look_for_we_could_not_connect_you_element("click_show_more_button") self.check_if_meeting_ended_but_we_should_retry("click_show_more_button") - self.check_if_in_waiting_room(waiting_room_timeout_started_at, "click_show_more_button") + self.check_if_in_waiting_room() self.check_if_waiting_room_timeout_exceeded(waiting_room_timeout_started_at, "click_show_more_button") self.check_if_waiting_room_connection_failed(waiting_room_timeout_started_at, "click_show_more_button") diff --git a/bots/web_bot_adapter/web_bot_adapter.py b/bots/web_bot_adapter/web_bot_adapter.py index 228b5dfb1..271750979 100644 --- a/bots/web_bot_adapter/web_bot_adapter.py +++ b/bots/web_bot_adapter/web_bot_adapter.py @@ -551,7 +551,7 @@ def send_bot_put_in_waiting_room_message(self): if self.sent_bot_put_in_waiting_room_message: return self.sent_bot_put_in_waiting_room_message = True - logger.info("Bot is waiting in the meeting's waiting room. Sending BOT_PUT_IN_WAITING_ROOM message") + logger.info("Sending BOT_PUT_IN_WAITING_ROOM message") self.send_message_callback({"message": self.Messages.BOT_PUT_IN_WAITING_ROOM}) def send_request_to_join_denied_message(self): From 6918a045e0fa51ef7a9e743791781bcadbefe44f Mon Sep 17 00:00:00 2001 From: Noah Duncan Date: Fri, 9 Oct 2026 14:40:17 -0500 Subject: [PATCH 06/12] Use call state for gmeet waiting room check --- .../google_meet_chromedriver_payload.js | 10 ++++++++++ .../google_meet_ui_methods.py | 16 +++++++--------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/bots/google_meet_bot_adapter/google_meet_chromedriver_payload.js b/bots/google_meet_bot_adapter/google_meet_chromedriver_payload.js index 7f66ba666..6575ad357 100644 --- a/bots/google_meet_bot_adapter/google_meet_chromedriver_payload.js +++ b/bots/google_meet_bot_adapter/google_meet_chromedriver_payload.js @@ -1020,9 +1020,19 @@ class UserManager { // constants for meeting status MEETING_STATUS = { IN_MEETING: 1, + IN_WAITING_ROOM: 3, NOT_IN_MEETING: 6 } + // The bot's own device id is only learned from a participant roster sync, so + // this returns false until the roster includes the bot. + currentUserIsInWaitingRoom() { + if (!this.currentUserId) return false; + const currentUser = this.allUsersMap.get(this.currentUserId); + if (!currentUser) return false; + return currentUser.status === this.MEETING_STATUS.IN_WAITING_ROOM; + } + getCurrentUsersInMeeting() { return Array.from(this.currentUsersMap.values()).filter(user => user.status === this.MEETING_STATUS.IN_MEETING); } diff --git a/bots/google_meet_bot_adapter/google_meet_ui_methods.py b/bots/google_meet_bot_adapter/google_meet_ui_methods.py index 8a0c985e0..550ea9fb6 100644 --- a/bots/google_meet_bot_adapter/google_meet_ui_methods.py +++ b/bots/google_meet_bot_adapter/google_meet_ui_methods.py @@ -184,18 +184,16 @@ def look_for_asking_to_be_let_in_element_after_waiting_period_expired(self, step raise UiRequestToJoinDeniedException("Bot was not let in after waiting period expired", step) def check_if_in_waiting_room(self, step): - # Nothing left to report, so stop paying for the DOM lookup every second. if self.sent_bot_put_in_waiting_room_message: return - # Same element look_for_asking_to_be_let_in_element_after_waiting_period_expired - # checks at the end of the wait — reported here as soon as it appears, while - # someone can still admit the bot. - asking_to_be_let_in_element = self.find_element_by_selector( - By.XPATH, - '//*[contains(text(), "Asking to be let in")]', - ) - if asking_to_be_let_in_element: + try: + in_waiting_room = self.driver.execute_script("return window.userManager?.currentUserIsInWaitingRoom() ?? false;") + except Exception as e: + logger.debug(f"Could not check participant roster for waiting room status: {e}") + return + + if in_waiting_room: self.send_bot_put_in_waiting_room_message() def check_if_waiting_room_timeout_exceeded(self, waiting_room_timeout_started_at, step): From 130f04231ae0bdb8aae62cccdc6a5660ac177db3 Mon Sep 17 00:00:00 2001 From: Noah Duncan Date: Fri, 9 Oct 2026 14:53:44 -0500 Subject: [PATCH 07/12] fix spec --- bots/tests/test_google_meet_bot_2.py | 3 ++- bots/web_bot_adapter/web_bot_adapter.py | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/bots/tests/test_google_meet_bot_2.py b/bots/tests/test_google_meet_bot_2.py index daca178ac..c689b8a8e 100644 --- a/bots/tests/test_google_meet_bot_2.py +++ b/bots/tests/test_google_meet_bot_2.py @@ -2360,7 +2360,8 @@ def test_blocked_by_platform_repeatedly_while_in_waiting_room_is_fatal_error( fatal_error_event = bot_events[2] self.assertEqual(fatal_error_event.old_state, BotStates.WAITING_ROOM) self.assertEqual(fatal_error_event.event_sub_type, BotEventSubTypes.FATAL_ERROR_UI_ELEMENT_NOT_FOUND) - self.assertEqual(fatal_error_event.metadata, {"blocked_by_platform_while_in_waiting_room": True}) + self.assertTrue(fatal_error_event.metadata["blocked_by_platform_while_in_waiting_room"]) + self.assertIn("bot_duration_seconds", fatal_error_event.metadata) @override_settings(PREJOIN_LEAVE_OR_MEETING_END_IS_FATAL_ERROR=False) @patch("bots.models.Bot.create_debug_recording", return_value=False) diff --git a/bots/web_bot_adapter/web_bot_adapter.py b/bots/web_bot_adapter/web_bot_adapter.py index 271750979..31e7a4436 100644 --- a/bots/web_bot_adapter/web_bot_adapter.py +++ b/bots/web_bot_adapter/web_bot_adapter.py @@ -84,8 +84,6 @@ def __init__( self.disable_incoming_video = disable_incoming_video self.record_participant_speech_start_stop_events = record_participant_speech_start_stop_events self.meeting_url = meeting_url - # The lobby is detected inside a polling loop, but joining -> waiting_room is a - # one-shot transition, so only the first sighting is reported. self.sent_bot_put_in_waiting_room_message = False # This is an internal ID that comes from the platform. It is currently only used for MS Teams. From ed5651e9bfa14559598890a1fff9323aaa130567 Mon Sep 17 00:00:00 2001 From: Noah Duncan Date: Fri, 9 Oct 2026 15:00:58 -0500 Subject: [PATCH 08/12] add setting to preserve old behavior --- attendee/settings/base.py | 4 ++++ bots/tests/test_google_meet_bot_2.py | 1 + bots/web_bot_adapter/web_bot_adapter.py | 3 +++ 3 files changed, 8 insertions(+) diff --git a/attendee/settings/base.py b/attendee/settings/base.py index 3579cb482..3a69f82c6 100644 --- a/attendee/settings/base.py +++ b/attendee/settings/base.py @@ -360,6 +360,10 @@ # This flag is temporary, in the future we will remove this flag and always map prejoin leave or meeting end to the fatal error state. PREJOIN_LEAVE_OR_MEETING_END_IS_FATAL_ERROR = os.getenv("PREJOIN_LEAVE_OR_MEETING_END_IS_FATAL_ERROR", "false") == "true" +# We previously did not report the BOT_PUT_IN_WAITING_ROOM event for web bots. +# This flag is temporary, in the future we will remove this flag and always report the BOT_PUT_IN_WAITING_ROOM event for web bots. +ENABLE_WAITING_ROOM_STATE_FOR_WEB_BOTS = os.getenv("ENABLE_WAITING_ROOM_STATE_FOR_WEB_BOTS", "false") == "true" + BOT_POD_NAMESPACE = os.getenv("BOT_POD_NAMESPACE", "attendee") WEBPAGE_STREAMER_POD_NAMESPACE = os.getenv("WEBPAGE_STREAMER_POD_NAMESPACE", "attendee-webpage-streamer") REQUIRE_HTTPS_WEBHOOKS = os.getenv("REQUIRE_HTTPS_WEBHOOKS", "true") == "true" diff --git a/bots/tests/test_google_meet_bot_2.py b/bots/tests/test_google_meet_bot_2.py index c689b8a8e..a25b57846 100644 --- a/bots/tests/test_google_meet_bot_2.py +++ b/bots/tests/test_google_meet_bot_2.py @@ -2322,6 +2322,7 @@ def test_meeting_ended_while_in_waiting_room_is_could_not_join_when_flag_enabled self.assertEqual(could_not_join_event.new_state, BotStates.FATAL_ERROR) self.assertEqual(could_not_join_event.event_sub_type, BotEventSubTypes.COULD_NOT_JOIN_MEETING_MEETING_ENDED_BEFORE_BOT_JOINED) + @override_settings(ENABLE_WAITING_ROOM_STATE_FOR_WEB_BOTS=True) @patch("bots.tasks.restart_bot_pod_task.restart_bot_pod.apply_async") @patch("bots.models.Bot.create_debug_recording", return_value=False) @patch("bots.web_bot_adapter.web_bot_adapter.Display") diff --git a/bots/web_bot_adapter/web_bot_adapter.py b/bots/web_bot_adapter/web_bot_adapter.py index 31e7a4436..1ac1a8b78 100644 --- a/bots/web_bot_adapter/web_bot_adapter.py +++ b/bots/web_bot_adapter/web_bot_adapter.py @@ -549,6 +549,9 @@ def send_bot_put_in_waiting_room_message(self): if self.sent_bot_put_in_waiting_room_message: return self.sent_bot_put_in_waiting_room_message = True + if not settings.ENABLE_WAITING_ROOM_STATE_FOR_WEB_BOTS: + logger.info("Not sending BOT_PUT_IN_WAITING_ROOM message because ENABLE_WAITING_ROOM_STATE_FOR_WEB_BOTS is not enabled") + return logger.info("Sending BOT_PUT_IN_WAITING_ROOM message") self.send_message_callback({"message": self.Messages.BOT_PUT_IN_WAITING_ROOM}) From a681c59f65b9a1a887da9d92be1220698b06f170 Mon Sep 17 00:00:00 2001 From: Noah Duncan Date: Fri, 9 Oct 2026 15:39:32 -0500 Subject: [PATCH 09/12] add for zoom web --- .../zoom_web_chromedriver_page.js | 12 ++++++++++++ bots/zoom_web_bot_adapter/zoom_web_ui_methods.py | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/bots/zoom_web_bot_adapter/zoom_web_chromedriver_page.js b/bots/zoom_web_bot_adapter/zoom_web_chromedriver_page.js index da0881526..cbc7cb3e8 100644 --- a/bots/zoom_web_bot_adapter/zoom_web_chromedriver_page.js +++ b/bots/zoom_web_bot_adapter/zoom_web_chromedriver_page.js @@ -16,6 +16,7 @@ var zakToken = zoomInitialData.zakToken; var onBehalfToken = zoomInitialData.onBehalfToken; var leaveUrl = 'https://zoom.us'; var userEnteredMeeting = false; +var latestJoinSpeedLevel = null; var userEncounteredOnBehalfTokenUserNotInMeetingError = false; var userEncounteredGenericJoinError = false; var recordingPermissionGranted = false; @@ -67,6 +68,12 @@ function userHasEnteredMeeting() { window.userHasEnteredMeeting = userHasEnteredMeeting; +function userIsInWaitingRoom() { + return latestJoinSpeedLevel == 6; +} + +window.userIsInWaitingRoom = userIsInWaitingRoom; + function userHasEncounteredOnBehalfTokenUserNotInMeetingError() { return userEncounteredOnBehalfTokenUserNotInMeetingError; } @@ -198,10 +205,15 @@ function startMeeting(signature) { ZoomMtg.inMeetingServiceListener('onJoinSpeed', function (data) { console.log('onJoinSpeed', data); + latestJoinSpeedLevel = data.level; // This means that the user was initially in the waiting room if (data.level == 6) { console.log('onJoinSpeed: level 6, user was in waiting room'); } + // This means the user left the waiting room + if (data.level == 7) { + console.log('onJoinSpeed: level 7, user left waiting room'); + } //joinMeeting // Level 13 means "user start join audio" which means we actually got into the meeting and are out of the waiting room if (data.level == 13) diff --git a/bots/zoom_web_bot_adapter/zoom_web_ui_methods.py b/bots/zoom_web_bot_adapter/zoom_web_ui_methods.py index ac29560fe..89c579f5f 100644 --- a/bots/zoom_web_bot_adapter/zoom_web_ui_methods.py +++ b/bots/zoom_web_bot_adapter/zoom_web_ui_methods.py @@ -179,6 +179,20 @@ def check_if_failed_to_join_because_generic_join_error(self): if failed_to_join_because_generic_join_error or network_timeout_shown: self.handle_generic_join_error() + def check_if_in_waiting_room(self): + if self.sent_bot_put_in_waiting_room_message: + return + + # The caller's loop turns any exception into a failed join, so this must not raise. + try: + user_is_in_waiting_room = self.driver.execute_script("return window.userIsInWaitingRoom && window.userIsInWaitingRoom()") + except Exception as e: + logger.info(f"Unknown error occurred in check_if_in_waiting_room. Exception type = {type(e)}") + return + + if user_is_in_waiting_room: + self.send_bot_put_in_waiting_room_message() + def wait_to_be_admitted_to_meeting(self): num_attempts_to_look_for_more_meeting_control_button = (self.automatic_leave_configuration.waiting_room_timeout_seconds + self.automatic_leave_configuration.wait_for_host_to_start_meeting_timeout_seconds) * 10 logger.info("Waiting to be admitted to the meeting...") @@ -217,6 +231,8 @@ def wait_to_be_admitted_to_meeting(self): logger.info(f"is_waiting_for_host_to_start_meeting changed from {previous_is_waiting_for_host_to_start_meeting} to {is_waiting_for_host_to_start_meeting}. Resetting timeout") timeout_started_at = time.time() + self.check_if_in_waiting_room() + self.check_if_timeout_exceeded(timeout_started_at=timeout_started_at, step="wait_to_be_admitted_to_meeting", is_waiting_for_host_to_start_meeting=is_waiting_for_host_to_start_meeting) last_check_timed_out = attempt_index == num_attempts_to_look_for_more_meeting_control_button - 1 From d9045de4907204b45778a7864a85a67163a2ce9f Mon Sep 17 00:00:00 2001 From: Noah Duncan Date: Fri, 9 Oct 2026 15:47:16 -0500 Subject: [PATCH 10/12] move code around --- bots/zoom_web_bot_adapter/zoom_web_ui_methods.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bots/zoom_web_bot_adapter/zoom_web_ui_methods.py b/bots/zoom_web_bot_adapter/zoom_web_ui_methods.py index 89c579f5f..4a25c5710 100644 --- a/bots/zoom_web_bot_adapter/zoom_web_ui_methods.py +++ b/bots/zoom_web_bot_adapter/zoom_web_ui_methods.py @@ -216,6 +216,7 @@ def wait_to_be_admitted_to_meeting(self): self.check_if_login_required() self.check_if_failed_to_join_because_onbehalf_token_user_not_in_meeting() self.check_if_failed_to_join_because_generic_join_error() + self.check_if_in_waiting_room() previous_is_waiting_for_host_to_start_meeting = is_waiting_for_host_to_start_meeting try: @@ -231,8 +232,6 @@ def wait_to_be_admitted_to_meeting(self): logger.info(f"is_waiting_for_host_to_start_meeting changed from {previous_is_waiting_for_host_to_start_meeting} to {is_waiting_for_host_to_start_meeting}. Resetting timeout") timeout_started_at = time.time() - self.check_if_in_waiting_room() - self.check_if_timeout_exceeded(timeout_started_at=timeout_started_at, step="wait_to_be_admitted_to_meeting", is_waiting_for_host_to_start_meeting=is_waiting_for_host_to_start_meeting) last_check_timed_out = attempt_index == num_attempts_to_look_for_more_meeting_control_button - 1 From 4e15903ad2786dd968c91c76eabeba58a15f98c5 Mon Sep 17 00:00:00 2001 From: Noah Duncan Date: Fri, 9 Oct 2026 16:07:29 -0500 Subject: [PATCH 11/12] add spec --- .../google_meet_ui_methods.py | 2 +- bots/tests/test_teams_bot.py | 156 +++++++++++++++++- 2 files changed, 156 insertions(+), 2 deletions(-) diff --git a/bots/google_meet_bot_adapter/google_meet_ui_methods.py b/bots/google_meet_bot_adapter/google_meet_ui_methods.py index 550ea9fb6..3690a633e 100644 --- a/bots/google_meet_bot_adapter/google_meet_ui_methods.py +++ b/bots/google_meet_bot_adapter/google_meet_ui_methods.py @@ -190,7 +190,7 @@ def check_if_in_waiting_room(self, step): try: in_waiting_room = self.driver.execute_script("return window.userManager?.currentUserIsInWaitingRoom() ?? false;") except Exception as e: - logger.debug(f"Could not check participant roster for waiting room status: {e}") + logger.info(f"Could not check participant roster for waiting room status: {e}") return if in_waiting_room: diff --git a/bots/tests/test_teams_bot.py b/bots/tests/test_teams_bot.py index 12ff3d8a8..2f1c6ca0a 100644 --- a/bots/tests/test_teams_bot.py +++ b/bots/tests/test_teams_bot.py @@ -13,7 +13,7 @@ from django.conf import settings from django.core.files.storage import InMemoryStorage from django.db import connection -from django.test import TransactionTestCase, tag +from django.test import TransactionTestCase, override_settings, tag from django.utils import timezone from selenium.common.exceptions import TimeoutException from websockets.sync.client import connect as ws_connect @@ -24,6 +24,7 @@ from bots.models import Bot, BotChatMessageRequest, BotChatMessageRequestStates, BotChatMessageToOptions, BotDebugScreenshot, BotEventManager, BotEventSubTypes, BotEventTypes, BotLogin, BotLoginGroup, BotLoginPlatform, BotMediaRequest, BotMediaRequestMediaTypes, BotMediaRequestStates, BotStates, ChatMessage, ChatMessageToOptions, Credentials, MediaBlob, Organization, Participant, ParticipantEvent, ParticipantEventTypes, Project, Recording, RecordingStates, RecordingTypes, TranscriptionProviders, TranscriptionTypes, WebhookDeliveryAttempt, WebhookSubscription, WebhookTriggerTypes from bots.teams_bot_adapter.teams_ui_methods import TeamsUIMethods, UiTeamsBlockingUsException, UiWaitingRoomTransitionFailedException from bots.web_bot_adapter.ui_methods import UiLoginRequiredException +from bots.web_bot_adapter.web_bot_adapter import WebBotAdapter # Helper functions for creating mocks @@ -1463,6 +1464,159 @@ def simulate_join_flow(): # Close the database connection since we're in a thread connection.close() + @override_settings(ENABLE_WAITING_ROOM_STATE_FOR_WEB_BOTS=True, MONITOR_DOMAIN_ALLOWLIST_IN_CHROME=False, ENFORCE_DOMAIN_ALLOWLIST_IN_CHROME=False) + @patch("bots.bot_controller.bot_controller.BotController.save_debug_recording", return_value=None) + @patch("bots.web_bot_adapter.web_bot_adapter.Display") + @patch("bots.web_bot_adapter.web_bot_adapter.webdriver.Chrome") + @patch("bots.bot_controller.bot_controller.S3FileUploader") + def test_bot_put_in_waiting_room_then_admitted( + self, + MockFileUploader, + MockChromeDriver, + MockDisplay, + MockSaveDebugRecording, + ): + """Test that a bot which lands in the Teams lobby records a BOT_PUT_IN_WAITING_ROOM event + and then joins normally once it is admitted. + + The real click_show_more_button loop and check_if_in_waiting_room run unmocked — only + WebDriverWait, find_element_by_selector and the value returned by + callManager.getCallState are controlled. + + Flow: + 1. WebDriverWait times out in click_show_more_button while the bot is in the lobby + 2. getCallState returns InLobby (10) -> BOT_PUT_IN_WAITING_ROOM is sent exactly once + 3. Subsequent timeouts do not resend the waiting room message + 4. The bot is admitted: WebDriverWait finds the show more button and the join succeeds + 5. The bot transitions WAITING_ROOM -> JOINED and later ends normally + """ + self.bot.settings = {"recording_settings": {"format": "none"}} + self.bot.save() + + # Configure the mock uploader + mock_uploader = create_mock_file_uploader() + MockFileUploader.return_value = mock_uploader + + # Mock the Chrome driver + mock_driver = create_mock_teams_driver() + MockChromeDriver.return_value = mock_driver + + teams_in_lobby_call_state = 10 + get_call_state_queries = [] + + def mock_execute_script(script, *args): + if "getCallState" in script: + get_call_state_queries.append(script) + return teams_in_lobby_call_state + if "getDidMeetingEndButShouldRetryJoin" in script: + return False + if "disableRetryJoinOnMeetingEnd" in script: + return None + return "test_result" + + mock_driver.execute_script.side_effect = mock_execute_script + + # Mock virtual display + mock_display = MagicMock() + MockDisplay.return_value = mock_display + + # The first three WebDriverWait.until calls time out while the bot sits in the lobby; + # the next call finds the show more button, meaning the bot was admitted. + webdriverwait_until_call_count = [0] + + def create_mock_webdriverwait(*args, **kwargs): + mock_wait = MagicMock() + + def mock_until(*args, **kwargs): + webdriverwait_until_call_count[0] += 1 + if webdriverwait_until_call_count[0] <= 3: + raise TimeoutException("Mocked timeout") + return MagicMock() + + mock_wait.until = mock_until + return mock_wait + + with ( + patch.object(TeamsUIMethods, "attempt_to_join_meeting", autospec=True, side_effect=TeamsUIMethods.attempt_to_join_meeting) as mock_attempt_to_join, + patch.object(WebBotAdapter, "send_bot_put_in_waiting_room_message", autospec=True, side_effect=WebBotAdapter.send_bot_put_in_waiting_room_message) as mock_send_waiting_room_message, + patch.object(TeamsUIMethods, "fill_out_name_input", return_value=None), + patch.object(TeamsUIMethods, "wiggle_mouse", return_value=None), + patch.object(TeamsUIMethods, "turn_off_media_inputs", return_value=None), + patch.object(TeamsUIMethods, "locate_element", return_value=MagicMock()), + patch.object(TeamsUIMethods, "click_element", return_value=None), + patch("bots.teams_bot_adapter.teams_ui_methods.WebDriverWait", side_effect=create_mock_webdriverwait), + patch.object(TeamsUIMethods, "find_element_by_selector", return_value=None), + patch.object(TeamsUIMethods, "click_captions_button", return_value=None), + patch.object(TeamsUIMethods, "set_layout", return_value=None), + patch.object(TeamsUIMethods, "disable_incoming_video_in_ui", return_value=None), + patch("bots.web_bot_adapter.web_bot_adapter.WebBotAdapter.ready_to_show_bot_image", return_value=None), + ): + # Create bot controller + controller = BotController(self.bot.id) + + # Run the bot in a separate thread since it has an event loop + bot_thread = threading.Thread(target=controller.run) + bot_thread.daemon = True + bot_thread.start() + + def simulate_join_flow(): + # Sleep to allow initialization and join attempts + time.sleep(1) + + # Add participants to keep the bot in the meeting + controller.adapter.participants_info["user1"] = {"deviceId": "user1", "fullName": "Test User", "active": True, "isCurrentUser": False} + + # Let the bot run for a bit to "record" + time.sleep(1) + + # Trigger auto-leave + controller.adapter.only_one_participant_in_meeting_at = time.time() - 10000000000 + time.sleep(1) + + # Clean up connections in thread + connection.close() + + # Run join flow simulation after a short delay + threading.Timer(3, simulate_join_flow).start() + + # Give the bot some time to process + bot_thread.join(timeout=20) + + time.sleep(1.25) + + # The call state is only queried until the waiting room message has been sent + self.assertEqual(len(get_call_state_queries), 1) + self.assertEqual(mock_send_waiting_room_message.call_count, 1) + self.assertTrue(controller.adapter.sent_bot_put_in_waiting_room_message) + + # Being admitted from the waiting room does not require a retry + self.assertEqual(mock_attempt_to_join.call_count, 1) + + # Refresh the bot from the database + self.bot.refresh_from_db() + + # The bot entered the waiting room, was admitted, and then ended normally + self.assertEqual(self.bot.state, BotStates.ENDED) + event_types = list(self.bot.bot_events.order_by("created_at").values_list("event_type", flat=True)) + self.assertEqual(event_types[:3], [BotEventTypes.JOIN_REQUESTED, BotEventTypes.BOT_PUT_IN_WAITING_ROOM, BotEventTypes.BOT_JOINED_MEETING]) + self.assertEqual(event_types.count(BotEventTypes.BOT_PUT_IN_WAITING_ROOM), 1) + self.assertFalse(self.bot.bot_events.filter(event_type=BotEventTypes.FATAL_ERROR).exists()) + self.assertFalse(self.bot.bot_events.filter(event_type=BotEventTypes.COULD_NOT_JOIN).exists()) + + waiting_room_event = self.bot.bot_events.get(event_type=BotEventTypes.BOT_PUT_IN_WAITING_ROOM) + self.assertEqual(waiting_room_event.old_state, BotStates.JOINING) + self.assertEqual(waiting_room_event.new_state, BotStates.WAITING_ROOM) + + joined_event = self.bot.bot_events.get(event_type=BotEventTypes.BOT_JOINED_MEETING) + self.assertEqual(joined_event.old_state, BotStates.WAITING_ROOM) + + # Cleanup + controller.cleanup() + bot_thread.join(timeout=5) + + # Close the database connection since we're in a thread + connection.close() + def test_get_teams_signed_in_bot_uses_named_login_group(self): first_group = BotLoginGroup.objects.create(project=self.project, platform=BotLoginPlatform.TEAMS, name="Primary Group") first_group_login = BotLogin.objects.create(group=first_group, email="primary@example.com") From c3f17a8f70373ec1c925fee759b683f5eb744ebb Mon Sep 17 00:00:00 2001 From: Noah Duncan Date: Fri, 9 Oct 2026 16:20:35 -0500 Subject: [PATCH 12/12] add spec --- bots/tests/test_zoom_web_bot.py | 124 +++++++++++++++++++++++++++++++- 1 file changed, 123 insertions(+), 1 deletion(-) diff --git a/bots/tests/test_zoom_web_bot.py b/bots/tests/test_zoom_web_bot.py index 6e5b898cf..65012c99e 100644 --- a/bots/tests/test_zoom_web_bot.py +++ b/bots/tests/test_zoom_web_bot.py @@ -7,12 +7,13 @@ import requests from django.db import connection -from django.test import TransactionTestCase, tag +from django.test import TransactionTestCase, override_settings, tag from selenium.common.exceptions import NoSuchElementException from bots.bot_adapter import BotAdapter from bots.bot_controller.bot_controller import BotController from bots.models import Bot, BotEventManager, BotEventSubTypes, BotEventTypes, BotStates, Credentials, Organization, Project, Recording, RecordingTypes, TranscriptionProviders, TranscriptionTypes, WebhookDeliveryAttempt, WebhookSubscription, WebhookTriggerTypes, ZoomMeetingToZoomOAuthConnectionMapping, ZoomOAuthApp, ZoomOAuthConnection, ZoomOAuthConnectionStates +from bots.web_bot_adapter.web_bot_adapter import WebBotAdapter # Helper functions for creating mocks @@ -779,6 +780,127 @@ def find_element_side_effect(by, value): # Close the database connection since we're in a thread connection.close() + @override_settings(ENABLE_WAITING_ROOM_STATE_FOR_WEB_BOTS=True, MONITOR_DOMAIN_ALLOWLIST_IN_CHROME=False, ENFORCE_DOMAIN_ALLOWLIST_IN_CHROME=False) + @patch("bots.zoom_web_bot_adapter.zoom_web_ui_methods.WebDriverWait") + @patch("bots.zoom_web_bot_adapter.zoom_web_ui_methods.start_zoom_web_static_server", return_value=8080) + @patch("bots.web_bot_adapter.web_bot_adapter.Display") + @patch("bots.web_bot_adapter.web_bot_adapter.webdriver.Chrome") + @patch("bots.bot_controller.bot_controller.S3FileUploader") + def test_bot_put_in_waiting_room_then_admitted( + self, + MockFileUploader, + MockChromeDriver, + MockDisplay, + mock_start_static_server, + MockWebDriverWait, + ): + """Test that a bot which lands in the Zoom waiting room records a BOT_PUT_IN_WAITING_ROOM event + and then joins normally once it is admitted. + + The real wait_to_be_admitted_to_meeting loop and check_if_in_waiting_room run unmocked — only + the values returned by window.userHasEnteredMeeting and window.userIsInWaitingRoom are controlled. + + Flow: + 1. userHasEnteredMeeting returns False while the bot is in the waiting room + 2. userIsInWaitingRoom returns True -> BOT_PUT_IN_WAITING_ROOM is sent exactly once + 3. Subsequent loop iterations do not resend the waiting room message + 4. The bot is admitted: userHasEnteredMeeting returns True and the join succeeds + 5. The bot transitions WAITING_ROOM -> JOINED and later leaves normally + """ + # Configure the mock uploader + mock_uploader = create_mock_file_uploader() + MockFileUploader.return_value = mock_uploader + + # Mock the Chrome driver + mock_driver = create_mock_zoom_web_driver() + MockChromeDriver.return_value = mock_driver + + # Mock virtual display + mock_display = MagicMock() + MockDisplay.return_value = mock_display + + # Mock WebDriverWait to return mock elements for post-admission UI + mock_wait_instance = MagicMock() + mock_element = MagicMock() + mock_element.is_displayed.return_value = True + mock_wait_instance.until.return_value = mock_element + MockWebDriverWait.return_value = mock_wait_instance + + # The first three userHasEnteredMeeting checks happen while the bot sits in the waiting room; + # the next check reports that the bot was admitted. + user_has_entered_meeting_call_count = [0] + user_is_in_waiting_room_queries = [] + + def execute_script_side_effect(script, *args): + if "userHasEnteredMeeting" in script: + user_has_entered_meeting_call_count[0] += 1 + return user_has_entered_meeting_call_count[0] > 3 + if "userIsInWaitingRoom" in script: + user_is_in_waiting_room_queries.append(script) + return True + if "userHasEncounteredOnBehalfTokenUserNotInMeetingError" in script: + return False + if "userHasEncounteredGenericJoinError" in script: + return False + return None + + mock_driver.execute_script.side_effect = execute_script_side_effect + + # Not waiting for the host, no passcode/login prompts, no captcha + mock_driver.find_element.side_effect = NoSuchElementException("Element not found") + mock_driver.find_elements.return_value = [] + + with patch.object(WebBotAdapter, "send_bot_put_in_waiting_room_message", autospec=True, side_effect=WebBotAdapter.send_bot_put_in_waiting_room_message) as mock_send_waiting_room_message: + # Create bot controller + controller = BotController(self.bot.id) + + # Run the bot in a separate thread since it has an event loop + bot_thread = threading.Thread(target=controller.run) + bot_thread.daemon = True + bot_thread.start() + + # Allow time for the bot to sit in the waiting room and then be admitted + time.sleep(10) + + # The waiting room state is only queried until the waiting room message has been sent + self.assertEqual(len(user_is_in_waiting_room_queries), 1) + self.assertEqual(mock_send_waiting_room_message.call_count, 1) + self.assertTrue(controller.adapter.sent_bot_put_in_waiting_room_message) + self.assertEqual(user_has_entered_meeting_call_count[0], 4) + + # The bot entered the waiting room and was then admitted + self.bot.refresh_from_db() + self.assertEqual(self.bot.state, BotStates.JOINED_NOT_RECORDING) + event_types = list(self.bot.bot_events.order_by("created_at").values_list("event_type", flat=True)) + self.assertEqual(event_types, [BotEventTypes.JOIN_REQUESTED, BotEventTypes.BOT_PUT_IN_WAITING_ROOM, BotEventTypes.BOT_JOINED_MEETING]) + + waiting_room_event = self.bot.bot_events.get(event_type=BotEventTypes.BOT_PUT_IN_WAITING_ROOM) + self.assertEqual(waiting_room_event.old_state, BotStates.JOINING) + self.assertEqual(waiting_room_event.new_state, BotStates.WAITING_ROOM) + + joined_event = self.bot.bot_events.get(event_type=BotEventTypes.BOT_JOINED_MEETING) + self.assertEqual(joined_event.old_state, BotStates.WAITING_ROOM) + self.assertEqual(joined_event.new_state, BotStates.JOINED_NOT_RECORDING) + + # Simulate meeting ending to trigger cleanup + controller.adapter.only_one_participant_in_meeting_at = time.time() - 10000000000 + time.sleep(4) + + self.bot.refresh_from_db() + self.assertFalse(self.bot.bot_events.filter(event_type=BotEventTypes.FATAL_ERROR).exists()) + self.assertFalse(self.bot.bot_events.filter(event_type=BotEventTypes.COULD_NOT_JOIN).exists()) + + # Cleanup + controller.cleanup() + bot_thread.join(timeout=5) + + # If thread is still running after timeout, that's a problem to report + if bot_thread.is_alive(): + print("WARNING: Bot thread did not terminate properly after cleanup") + + # Close the database connection since we're in a thread + connection.close() + @patch("bots.zoom_web_bot_adapter.zoom_web_ui_methods.WebDriverWait") @patch("bots.zoom_web_bot_adapter.zoom_web_ui_methods.start_zoom_web_static_server", return_value=8080) @patch("bots.web_bot_adapter.web_bot_adapter.Display")