Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions attendee/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
22 changes: 21 additions & 1 deletion bots/bot_controller/bot_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
14 changes: 14 additions & 0 deletions bots/google_meet_bot_adapter/google_meet_ui_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")

Expand Down
17 changes: 17 additions & 0 deletions bots/teams_bot_adapter/teams_ui_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")

Expand Down
42 changes: 42 additions & 0 deletions bots/tests/test_google_meet_bot_2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
156 changes: 155 additions & 1 deletion bots/tests/test_teams_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading