autoteams/TeamsController.cs
2022-11-12 02:36:48 +01:00

340 lines
No EOL
12 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using autoteams.Models;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using static autoteams.ConfigurationManager;
namespace autoteams
{
public class TeamsController : IDisposable
{
private readonly IWebDriver _driver;
private int loginTime;
private readonly bool _microphoneAllowed, _cameraAllowed;
private readonly MeetingScheduler _scheduler;
public MeetingScheduler MeetingScheduler => _scheduler;
private TeamsChannel _currentChannel, _currentMeetingChannel;
public TeamsChannel CurrentChannel
{
get => _currentChannel;
set
{
SwitchChannel(value);
}
}
public TeamsChannel CurrentMeetingChannel
{
get => _currentMeetingChannel;
}
//For login prompts
private IWebElement NextButtonElement => _driver.FindElement(By.Id(FIELDS.LoginProceedButtonId));
public HashSet<TeamsClassroom> Classes { get; private init; }
public HashSet<TeamsChannel> Channels { get; private init; }
public TeamsController(bool enableMicrophone = true, bool enableWebcam = true, bool headless = false)
{
Logger.Info("Creating browser instance...");
ChromeOptions options = new();
options.AddArgument("--disable-infobars");
options.AddArgument("--disable-extensions");
options.AddArgument("--start-maximized");
options.AddArgument("start-maximized");
if (headless)
options.AddArgument("headless");
options.AddUserProfilePreference("profile.default_content_setting_values.geolocation", 2);
options.AddUserProfilePreference("profile.default_content_setting_values.notifications", 2);
options.AddUserProfilePreference("profile.default_content_setting_values.media_stream_mic", enableMicrophone ? 1 : 2);
options.AddUserProfilePreference("profile.default_content_setting_values.media_stream_camera", enableWebcam ? 1 : 2);
_microphoneAllowed = enableMicrophone;
_cameraAllowed = enableWebcam;
_driver = new ChromeDriver(options);
_driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(CONFIG.SearchWaitTime);
Classes = new();
Channels = new();
_scheduler = new(this);
}
public async Task Login()
{
int startTimestamp = Environment.TickCount;
Logger.Info("Logging in...");
Trial:
try
{
_driver.Navigate().GoToUrl(FIELDS.MsTeamsMainUrl);
_driver.TryTo(TimeSpan.FromSeconds(3), () =>
{
var fmt = _driver.FindElement(By.Name(FIELDS.LoginEmailFieldName));
fmt.SendKeys(CREDENTIALS.Login);
NextButtonElement.Click();
return true;
}, initialDelay: 200);
//Wait ~700 ms for the transition between the login and password
await Task.Delay(CONFIG.LoginToPasswordWaitTimeMilis);
_driver.TryTo(TimeSpan.FromSeconds(3), () =>
{
var pwd = _driver.FindElement(By.Name(FIELDS.LoginPasswordFieldName));
//Send the Base-64 decoded password
pwd.SendKeys(Encoding.UTF8.GetString(Convert.FromBase64String(CREDENTIALS.Password)));
NextButtonElement.Click();
return true;
});
//Check if MS login page stalls at 'Stay signed in checkbox' and click the 'Yes' button
if (_driver.Url.Equals(FIELDS.MsTeamsLoginCheckStallUrl, StringComparison.Ordinal))
{
NextButtonElement.Click();
}
}
catch (Exception e)
{
Logger.Warn("Encountered an error during login attempt:");
Logger.Warn(e.ToString());
Logger.Info("Attempting to login again.");
goto Trial;
}
Postlogin:
await Task.Delay(1000);
ushort cnt = 1;
Logger.EnterUpdateMode();
while (!_driver.Url.StartsWith(FIELDS.MsTeamsMainSchoolUrl, StringComparison.InvariantCulture))
{
if (cnt > CONFIG.MaxLoadAttemptsForRefresh)
{
Logger.ExitUpdateMode();
Logger.Warn("Cannot load site. Reloading...");
_driver.Navigate().Refresh();
goto Postlogin;
}
Logger.Info($"Not loaded yet. Waiting... {cnt}/{CONFIG.MaxLoadAttemptsForRefresh}");
await Task.Delay(CONFIG.PageLoadedCheckIntervalMilis);
cnt++;
}
Logger.ExitUpdateMode();
int endTimestamp = Environment.TickCount;
loginTime = endTimestamp - startTimestamp;
Logger.Info($"Logged in. Time: {loginTime}ms");
}
public void DiscoverClasses()
{
int startTimestamp = Environment.TickCount;
Logger.Info("Discovering classes...");
//Click the team list button in case the list is not shown
_driver.FindElement(By.Id(FIELDS.MenuTeamListButtonId)).Click();
IEnumerable<IWebElement> elements = _driver.FindElements(By.CssSelector("li.team"));
foreach (IWebElement classElement in elements)
{
//Scroll the element into view if not visible
if (!classElement.Displayed)
classElement.ScrollIntoView(_driver);
//The team name is also in the 'title' attribute of the profile picture
string name = classElement.FindElement(By.XPath(FIELDS.MenuTeamProfilePictureXPath)).GetAttribute("title");
Logger.Debug($"Found class: {name}");
TeamsClassroom room = new()
{
Name = name,
Element = classElement.Parent(),
Channels = new HashSet<TeamsChannel>(),
};
//Expand the class if it is not already expanded in order to show channels
//The channel list div is removed in the DOM if the class is not expanded
if (classElement.GetAttribute("aria-expanded").Equals("false", StringComparison.Ordinal))
classElement.Click();
var channelList = classElement.FindElements(By.XPath(FIELDS.MenuTeamChannelListXPath));
foreach (var channelElement in channelList)
{
string channelName = channelElement.FindElement(By.XPath(FIELDS.MenuTeamChannelNameXPath)).Text;
string channelId = channelElement.Parent().GetId()[8..]; //Remove 'channel-' prefix
TeamsChannel channel = new()
{
Name = channelName,
Classroom = room,
ClassroomName = room.Name,
ChannelId = channelId
};
_ = Channels.Add(channel);
room.Channels.Add(channel);
Logger.Debug($" -> channel: {channelName}");
}
_ = Classes.Add(room);
}
int deltaT = Environment.TickCount - startTimestamp;
Logger.Info($"Classes indexed. Time: {deltaT}ms.");
LoadDatabase();
//Set the current channel which is always the first channel
if (_currentChannel == null)
{
SwitchChannel(Channels.First());
}
}
public void LoadDatabase()
{
using var db = new StorageContext();
IEnumerable<TeamsChannel> dbChannels = db.Channels.AsEnumerable();
IEnumerable<TeamsClassroom> dbRooms = db.Classrooms.AsEnumerable();
IEnumerable<TeamsChannel> channelDifference = Channels.Except(dbChannels);
IEnumerable<TeamsClassroom> roomDifference = Classes.Except(dbRooms);
if (channelDifference.Any() || roomDifference.Any())
{
Logger.Info("Database desynchronized.");
Logger.Info($"Changing {roomDifference.Count()} classrooms and {channelDifference.Count()} channels.");
//Create a shallow copies of the instances to prevent System.InvalidOperationException
db.Classrooms.AddRange(roomDifference.Select(s =>
new TeamsClassroom()
{
Name = s.Name,
}
));
db.Channels.AddRange(channelDifference.Select(s =>
new TeamsChannel()
{
Name = s.Name,
ClassroomName = s.ClassroomName,
ChannelId = s.ChannelId
}
));
int total = db.SaveChanges();
Logger.Info($"Changed {total} records.");
}
}
public void SwitchChannel(TeamsChannel channel)
{
if (channel == null)
throw new ArgumentNullException(nameof(channel));
//Removed interaction with the web elements in favour of direct navigation
Logger.Debug($"Switching to {channel.ClassroomName}:{channel.Name}");
_driver.Navigate().GoToUrl(channel.GetUrl());
_currentChannel = channel;
}
public bool JoinCurrentChannelMeeting()
{
Logger.Debug($"Joining meeting ({_currentChannel.ClassroomName}:{_currentChannel.Name})...");
_driver.Navigate().Refresh();
return _driver.TryTo(TimeSpan.FromSeconds(5), () =>
{
//Get the join button in the channel
IWebElement element = _driver.FindElement(By.TagName("calling-join-button")).FindElement(By.XPath("./button"));
element.ScrollIntoView(_driver);
element.Click();
//If no microphone present, click the prompt
if (!_microphoneAllowed)
_driver.TryTo(TimeSpan.FromSeconds(5), () =>
{
try
{
IWebElement noMicPromptElement = _driver.FindElement(By.XPath(FIELDS.MeetingNoMicPromptButtonXPath));
noMicPromptElement.Click();
return true;
}
catch (NoSuchElementException)
{
return true;
}
}, 500, 300);
//Try to join the meeting using the secondary button
return _driver.TryTo(TimeSpan.FromSeconds(5), () =>
{
_driver.FindElement(By.ClassName("join-btn")).Click();
_currentMeetingChannel = _currentChannel;
return true;
}, 500, 300);
});
}
public bool JoinMeeting(TeamsChannel channel)
{
if (channel == null)
throw new ArgumentNullException(nameof(channel));
SwitchChannel(channel);
return JoinCurrentChannelMeeting();
}
public bool LeaveMeeting()
{
Logger.Debug($"Leaving current meeting ({_currentMeetingChannel.ClassroomName}:{_currentMeetingChannel.Name})...");
//Go to the main channel view and click the hang-up button
_driver.Navigate().GoToUrl(_currentChannel.GetUrl());
//Try to click the button
return _driver.TryTo(TimeSpan.FromSeconds(5), () =>
{
_driver.FindElement(By.Id("hangup-button")).Click();
_currentMeetingChannel = null;
return true;
});
}
public void Dispose()
{
GC.SuppressFinalize(this);
_driver.Dispose();
}
}
}