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/bot_controller/bot_controller.py b/bots/bot_controller/bot_controller.py index 7d66ebc98..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) @@ -2211,7 +2227,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") - BotEventManager.create_event(bot=self.bot_in_db, event_type=BotEventTypes.BOT_PUT_IN_WAITING_ROOM) + # 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 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/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 537b057b1..3690a633e 100644 --- a/bots/google_meet_bot_adapter/google_meet_ui_methods.py +++ b/bots/google_meet_bot_adapter/google_meet_ui_methods.py @@ -183,6 +183,19 @@ 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): + if self.sent_bot_put_in_waiting_room_message: + return + + try: + in_waiting_room = self.driver.execute_script("return window.userManager?.currentUserIsInWaitingRoom() ?? false;") + except Exception as e: + logger.info(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): waiting_room_timeout_exceeded = time.time() - waiting_room_timeout_started_at > self.automatic_leave_configuration.waiting_room_timeout_seconds if waiting_room_timeout_exceeded: @@ -541,6 +554,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.look_for_incoming_video_is_disabled_element() self.check_if_waiting_room_timeout_exceeded(waiting_room_timeout_started_at, "click_captions_button") diff --git a/bots/teams_bot_adapter/teams_ui_methods.py b/bots/teams_bot_adapter/teams_ui_methods.py index fdf363d08..bfd88534d 100644 --- a/bots/teams_bot_adapter/teams_ui_methods.py +++ b/bots/teams_bot_adapter/teams_ui_methods.py @@ -63,6 +63,22 @@ 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): + 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: + 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...") 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) @@ -339,6 +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() 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/tests/test_google_meet_bot_2.py b/bots/tests/test_google_meet_bot_2.py index 1ec760b46..a25b57846 100644 --- a/bots/tests/test_google_meet_bot_2.py +++ b/bots/tests/test_google_meet_bot_2.py @@ -2322,6 +2322,48 @@ 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") + @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.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) @patch("bots.web_bot_adapter.web_bot_adapter.Display") 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") 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") diff --git a/bots/web_bot_adapter/web_bot_adapter.py b/bots/web_bot_adapter/web_bot_adapter.py index b02bc8de6..1ac1a8b78 100644 --- a/bots/web_bot_adapter/web_bot_adapter.py +++ b/bots/web_bot_adapter/web_bot_adapter.py @@ -84,6 +84,7 @@ 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 + 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 @@ -544,6 +545,16 @@ 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 + 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}) + 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/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..4a25c5710 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...") @@ -202,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: diff --git a/version.json b/version.json index 8678376ce..9c0910689 100644 --- a/version.json +++ b/version.json @@ -1,3 +1,3 @@ { - "version": "1.82.7" + "version": "1.82.8" }