From 00c0274950d9318472c59ddbec04e065994cb806 Mon Sep 17 00:00:00 2001 From: femsci Date: Mon, 26 Apr 2021 17:43:21 +0200 Subject: [PATCH] Initial project commit --- .gitignore | 6 + ConfigurationManager.cs | 55 ++++++ Logger.cs | 80 +++++++++ MeetingScheduler.cs | 142 ++++++++++++++++ Models/AppConfiguration.cs | 15 ++ Models/FieldsConfig.cs | 19 +++ Models/ScheduledMeeting.cs | 27 +++ Models/TeamsChannel.cs | 29 ++++ Models/TeamsClassroom.cs | 30 ++++ Models/UserCredentials.cs | 8 + Program.cs | 113 ++++++++++++ README.md | 105 ++++++++++++ Selectors.cs | 10 ++ StorageContext.cs | 28 +++ TeamsController.cs | 340 +++++++++++++++++++++++++++++++++++++ Utils.cs | 85 ++++++++++ autoteams.csproj | 22 +++ config.json | 11 ++ credentials.json | 4 + fields.json | 13 ++ 20 files changed, 1142 insertions(+) create mode 100644 .gitignore create mode 100644 ConfigurationManager.cs create mode 100644 Logger.cs create mode 100644 MeetingScheduler.cs create mode 100644 Models/AppConfiguration.cs create mode 100644 Models/FieldsConfig.cs create mode 100644 Models/ScheduledMeeting.cs create mode 100644 Models/TeamsChannel.cs create mode 100644 Models/TeamsClassroom.cs create mode 100644 Models/UserCredentials.cs create mode 100644 Program.cs create mode 100644 README.md create mode 100644 Selectors.cs create mode 100644 StorageContext.cs create mode 100644 TeamsController.cs create mode 100644 Utils.cs create mode 100644 autoteams.csproj create mode 100644 config.json create mode 100644 credentials.json create mode 100644 fields.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f368e8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +**/bin +**/obj +**/.vscode +publish/ +storage.db* +logfile.log \ No newline at end of file diff --git a/ConfigurationManager.cs b/ConfigurationManager.cs new file mode 100644 index 0000000..0aecfcf --- /dev/null +++ b/ConfigurationManager.cs @@ -0,0 +1,55 @@ +using System.IO; +using System.Reflection; +using autoteams.Models; + +namespace autoteams +{ + public static class ConfigurationManager + { + public const string FIELDS_CONF = "fields.json", USER_CREDS = "credentials.json", APP_CONF = "config.json", INFO_FILE = "README.md"; + public static UserCredentials CREDENTIALS { get; private set; } + public static FieldsConfig FIELDS { get; private set; } + public static AppConfiguration CONFIG { get; private set; } + + public static void LoadConfiguration() + { + //Extract and load the fields.json file + Logger.Info("Loading definitions..."); + if (!File.Exists(FIELDS_CONF)) + ExtractFile(FIELDS_CONF); + FIELDS = Utils.DeserializeJsonFile(FIELDS_CONF); + + //Extract and load the credentials.json file + Logger.Info("Loading credentials..."); + if (!File.Exists(USER_CREDS)) + ExtractFile(USER_CREDS); + CREDENTIALS = Utils.DeserializeJsonFile(USER_CREDS); + + //Extract and load the config.json file + Logger.Info("Loading application configuration..."); + if (!File.Exists(APP_CONF)) + ExtractFile(APP_CONF); + CONFIG = Utils.DeserializeJsonFile(APP_CONF); + + //Extract the readme file + if (!File.Exists(INFO_FILE)) + ExtractFile(INFO_FILE); + } + + private static void ExtractFile(string name) + { + Stream stream = Assembly.GetExecutingAssembly().GetManifestResourceStream($"autoteams.{name}"); + if (stream == null) + { + throw new FileNotFoundException("Invalid resource name."); + } + + FileStream fileStream = File.OpenWrite(name); + stream.CopyTo(fileStream); + + fileStream.Flush(); + fileStream.Close(); + stream.Close(); + } + } +} \ No newline at end of file diff --git a/Logger.cs b/Logger.cs new file mode 100644 index 0000000..c2afa79 --- /dev/null +++ b/Logger.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace autoteams +{ + public static class Logger + { + private static bool _update; + private const string LOG_FILE = "logfile.log"; + + private static readonly SemaphoreSlim _fileSemaphore = new(1, 1); + + public static void Log(LogLevel level, string msg) + { + if (level == LogLevel.DEBUG && !ConfigurationManager.CONFIG.OutputDebug) + return; + + var date = DateTime.Now; + + //Reset the current line if in update mode + if (_update) + Console.Write("\r"); + + Console.Write($"{GetColor(level)}[{date:HH:mm:ss}, {level}]\x1b[0m => {msg}{(_update ? "" : Environment.NewLine)}"); + + WriteToFile($"[{date:dd.MM.yy HH:mm:ss}, {level}] => {msg}").ConfigureAwait(false); + } + + //Asynchronously write to a file + public static async Task WriteToFile(string msg) + { + await _fileSemaphore.WaitAsync(); + await File.AppendAllTextAsync(LOG_FILE, msg + "\n", Encoding.UTF8); + _fileSemaphore.Release(); + } + + public static void Debug(string msg) => Log(LogLevel.DEBUG, msg); + public static void Warn(string msg) => Log(LogLevel.WARN, msg); + public static void Error(string msg) => Log(LogLevel.ERROR, msg); + public static void Info(string msg) => Log(LogLevel.INFO, msg); + + public static void EnterUpdateMode() => _update = true; + public static void ExitUpdateMode() + { + if (!_update) return; + + _update = false; + Console.Write(Environment.NewLine); + } + + public static void SetUpdateMode(bool update) + { + if (update) + EnterUpdateMode(); + else + ExitUpdateMode(); + } + + //Associate an ANSI color code with each log level + public static string GetColor(LogLevel level) => level switch + { + LogLevel.DEBUG => "\x1b[1;32m", + LogLevel.INFO => "\x1b[1;37m", + LogLevel.WARN => "\x1b[1;33m", + LogLevel.ERROR => "\x1b[1;31m", + _ => string.Empty + }; + + public enum LogLevel : byte + { + DEBUG = 0b0001, //0x1 + INFO = 0b0010, //0x2 + WARN = 0b0110, //0x6 + ERROR = 0b1110 //0xe + } + } +} \ No newline at end of file diff --git a/MeetingScheduler.cs b/MeetingScheduler.cs new file mode 100644 index 0000000..b1e9ff7 --- /dev/null +++ b/MeetingScheduler.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using autoteams.Models; +using Microsoft.EntityFrameworkCore; + +namespace autoteams +{ + public class MeetingScheduler + { + public MeetingScheduler(TeamsController controller) + { + _controller = controller; + ScheduledMeetings = new(); + _ticker = new((_) => SchedulerTick(), null, Timeout.Infinite, Timeout.Infinite); + } + + private readonly Timer _ticker; + + public HashSet ScheduledMeetings { get; private set; } + + private bool _init; + + public void Initialize() + { + if (_init) return; + _init = true; + + LoadMeetingsFromDb(); + _ = Task.Run(async () => + { + int scheduledSecond = ConfigurationManager.CONFIG.SchedulerSyncOnSecond; + + //Use second synchronization if valid config + if (scheduledSecond is >= 0 and < 60) + { + var now = DateTime.Now; + + //Set the trigger date that is either in the current minute or in the next minute, if the desired seconds has passed + var trigger = now.AddSeconds(scheduledSecond - now.Second < 0 ? 60 - now.Second + scheduledSecond : scheduledSecond); + await Task.Delay(trigger - now); + + Logger.Info($"Waiting to synchronize with time (on {trigger})."); + } + + _ticker.Change(TimeSpan.Zero, TimeSpan.FromMinutes(1)); + Logger.Info($"Started scheduler timer at {DateTime.Now}."); + }); + } + + public void LoadMeetingsFromDb() + { + ScheduledMeetings.Clear(); + + Logger.Info("Loading scheduled meetings..."); + + using var db = new StorageContext(); + + var now = DateTime.Now; + + foreach (ScheduledMeeting meeting in db.Meetings.AsNoTracking().Include(s => s.Channel).AsEnumerable()) + { + try + { + meeting.Time = new DateTime(now.Year, now.Month, now.Day, meeting.StartHour, meeting.StartMinute, 0); + } + catch (Exception) + { + Logger.Warn($"Corrupted meeting: {meeting.Channel.ClassroomName}, {meeting.Channel.Name}. Discarding..."); + continue; + } + + Logger.Debug($"Found meeting in {meeting.Channel.ClassroomName}, {meeting.Channel.Name} on {meeting.DayOfWeek}s, {meeting.StartHour}:{meeting.StartMinute}"); + ScheduledMeetings.Add(meeting); + } + } + + private readonly TeamsController _controller; + + private ScheduledMeeting _currentMeeting; + private DateTime _lastMeetingStart; + + public void SchedulerTick() + { + if (_currentMeeting != null) + { + var now = DateTime.Now; + if (now.CompareTo(_currentMeeting.Time.Add(TimeSpan.FromMinutes(_currentMeeting.DurationMinutes))) >= 0) + { + Logger.Info($"Meeting in {_currentMeeting.Channel.Name}, {_currentMeeting.Channel.ClassroomName} ends now ({now}), after {(int)(now - _lastMeetingStart).TotalMinutes} minutes."); + _controller.LeaveMeeting(); + _currentMeeting = null; + } + } + else + { + lock (ScheduledMeetings) + { + foreach (ScheduledMeeting meeting in ScheduledMeetings) + { + DateTime time = DateTime.Now; + if (meeting.DayOfWeek.Equals(time.DayOfWeek.ToString(), StringComparison.OrdinalIgnoreCase)) + { + DateTime target = new(time.Year, time.Month, time.Day, meeting.StartHour, meeting.StartMinute, 0); + + if (time.CompareTo(target) > 0) + { + var difference = time - target; + var timeToBreak = (int)meeting.DurationMinutes / 3; + + if (difference.TotalMinutes <= (timeToBreak < 15 ? meeting.DurationMinutes : timeToBreak / 3)) + { + Logger.Info($"Meeting in {meeting.Channel.Name}, {meeting.Channel.ClassroomName} starts now ({time}) for {meeting.DurationMinutes} minutes."); + + var minuteDifference = (int)difference.TotalMinutes; + + if (minuteDifference > 0) + { + Logger.Info($"We are {minuteDifference} minute{(minuteDifference > 1 ? "s" : "")} late."); + } + bool isSuccess = _controller.JoinMeeting(meeting.Channel); + if (isSuccess) + { + _currentMeeting = meeting; + _lastMeetingStart = time; + Logger.Info("Successfully joined the meeting."); + } + else + Logger.Error("Cannot join meeting."); + + break; + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/Models/AppConfiguration.cs b/Models/AppConfiguration.cs new file mode 100644 index 0000000..5f1eead --- /dev/null +++ b/Models/AppConfiguration.cs @@ -0,0 +1,15 @@ +namespace autoteams.Models +{ + public class AppConfiguration + { + public int SearchWaitTime { get; set; } + public int LoginToPasswordWaitTimeMilis { get; set; } + public ushort MaxLoadAttemptsForRefresh { get; set; } + public int PageLoadedCheckIntervalMilis { get; set; } + public bool OutputDebug { get; set; } + public short SchedulerSyncOnSecond { get; set; } + public bool HeadlessMode { get; set; } + public bool AllowMicrophone { get; set; } + public bool AllowWebcam { get; set; } + } +} \ No newline at end of file diff --git a/Models/FieldsConfig.cs b/Models/FieldsConfig.cs new file mode 100644 index 0000000..441fa0e --- /dev/null +++ b/Models/FieldsConfig.cs @@ -0,0 +1,19 @@ +namespace autoteams.Models +{ + public class FieldsConfig + { + public string MenuTeamListButtonId { get; set; } + public string LoginProceedButtonId { get; set; } + + public string LoginEmailFieldName { get; set; } + public string LoginPasswordFieldName { get; set; } + + public string MsTeamsLoginCheckStallUrl { get; set; } + public string MsTeamsMainUrl { get; set; } + public string MsTeamsMainSchoolUrl { get; set; } + public string MeetingNoMicPromptButtonXPath { get; set; } + public string MenuTeamProfilePictureXPath { get; set; } + public string MenuTeamChannelListXPath { get; set; } + public string MenuTeamChannelNameXPath { get; set; } + } +} \ No newline at end of file diff --git a/Models/ScheduledMeeting.cs b/Models/ScheduledMeeting.cs new file mode 100644 index 0000000..7dc8a96 --- /dev/null +++ b/Models/ScheduledMeeting.cs @@ -0,0 +1,27 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; + +namespace autoteams.Models +{ + public class ScheduledMeeting + { + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + public string DayOfWeek { get; set; } + public short StartHour { get; set; } + public short StartMinute { get; set; } + public int DurationMinutes { get; set; } + + public string ChannelId { get; set; } + public bool Enabled { get; set; } + + public virtual TeamsChannel Channel { get; set; } + + [NotMapped] + [JsonIgnore] + public DateTime Time { get; set; } + } +} \ No newline at end of file diff --git a/Models/TeamsChannel.cs b/Models/TeamsChannel.cs new file mode 100644 index 0000000..24c42a1 --- /dev/null +++ b/Models/TeamsChannel.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace autoteams.Models +{ + public class TeamsChannel + { + public string ClassroomName { get; set; } + public string Name { get; set; } + [Key] + public string ChannelId { get; set; } + + public virtual TeamsClassroom Classroom { get; set; } + public virtual ICollection Meetings { get; set; } + + public string GetUrl() => $"https://teams.microsoft.com/_#/school/conversations/{Uri.EscapeUriString(Name)}?threadId={ChannelId}&ctx=channel"; + + public override bool Equals(object obj) + { + return obj is TeamsChannel channel && this.ChannelId.Equals(channel.ChannelId, StringComparison.Ordinal); + } + + public override int GetHashCode() + { + return HashCode.Combine(ClassroomName, Name, ChannelId); + } + } +} \ No newline at end of file diff --git a/Models/TeamsClassroom.cs b/Models/TeamsClassroom.cs new file mode 100644 index 0000000..9fb7428 --- /dev/null +++ b/Models/TeamsClassroom.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Newtonsoft.Json; +using OpenQA.Selenium; + +namespace autoteams.Models +{ + public class TeamsClassroom + { + [Key] + public string Name { get; set; } + public virtual ICollection Channels { get; set; } + + [NotMapped] + [JsonIgnore] + public IWebElement Element { get; set; } + + public override bool Equals(object obj) + { + return obj is TeamsClassroom room && this.Name.Equals(room.Name, StringComparison.Ordinal); + } + + public override int GetHashCode() + { + return Name.GetHashCode(); + } + } +} \ No newline at end of file diff --git a/Models/UserCredentials.cs b/Models/UserCredentials.cs new file mode 100644 index 0000000..b40f6a6 --- /dev/null +++ b/Models/UserCredentials.cs @@ -0,0 +1,8 @@ +namespace autoteams.Models +{ + public class UserCredentials + { + public string Login { get; set; } + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..5457861 --- /dev/null +++ b/Program.cs @@ -0,0 +1,113 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using autoteams.Models; + +namespace autoteams +{ + public class Program + { + private static TeamsController teamsController; + private const string VERSION = "1.0.0"; + public static void Main(string[] args) + { + if (args.Contains("-h") || args.Contains("--help")) + { + Console.WriteLine($"autoteams v{VERSION}"); + Console.WriteLine("Usage: autoteams [options]\n"); + Console.WriteLine("Options:\n" + + " -h, --help\t- print this message\n" + + " -v, --verbose\t- output debug logs\n" + + " -u, --update\t- discover classes and synchronize the database only\n" + + " -c, --console\t- use an in-built command line interface\n"); + Console.WriteLine("Internal commands (only with -c):\n" + + " exit\t\t- terminates the application\n" + + " db\t\t- reloads data from the database\n" + + " switch \t- switches to the specified channel\n" + + " join \t- joins a meeting in specified channel\n"); + Console.WriteLine("Argument types:\n" + + " \t\t - channel locator (:)"); + return; + } + + ConfigurationManager.LoadConfiguration(); + + if (args.Contains("-v") || args.Contains("--verbose")) + ConfigurationManager.CONFIG.OutputDebug = true; + + using var db = new StorageContext(); + bool isNewlyCreated = db.Database.EnsureCreated(); + + if (isNewlyCreated) + Logger.Info("Created new database."); + + teamsController = new TeamsController(ConfigurationManager.CONFIG.AllowMicrophone, + ConfigurationManager.CONFIG.AllowWebcam, + ConfigurationManager.CONFIG.HeadlessMode); + + Console.CancelKeyPress += (_, _) => Exit(); + + teamsController.Login().Wait(); + teamsController.DiscoverClasses(); + + if (args.Contains("-u") || args.Contains("--update")) + return; + + + teamsController.MeetingScheduler.Initialize(); + + if (args.Contains("-c") || args.Contains("--console")) + while (true) + { + try + { + string command = Console.ReadLine(); + + if (command.Equals("exit", StringComparison.OrdinalIgnoreCase)) + { + Exit(); + return; + } + else if (command.Equals("leave", StringComparison.OrdinalIgnoreCase)) + { + if (teamsController.LeaveMeeting()) + Logger.Debug("Left meeting..."); + else + Logger.Error("Cannot leave meeting (no meeting to leave)."); + } + else if (command.Equals("db", StringComparison.OrdinalIgnoreCase)) + { + Logger.Info("Reloading data from DB."); + teamsController.MeetingScheduler.LoadMeetingsFromDb(); + } + else if (command.StartsWith("join", StringComparison.OrdinalIgnoreCase) || command.StartsWith("switch", StringComparison.OrdinalIgnoreCase)) + { + string[] locator = command[5..].Split(':'); + TeamsChannel channel = teamsController.Classes.Single(c => c.Name == locator[0]).Channels.Single(c => c.Name == locator[1]); + + if (command.StartsWith("join", StringComparison.OrdinalIgnoreCase)) + teamsController.JoinMeeting(channel); + else + teamsController.SwitchChannel(channel); + + } + } + catch (Exception ee) + { + Logger.Error(ee.ToString()); + continue; + } + } + else + Task.Delay(Timeout.Infinite).Wait(); + } + + public static void Exit() + { + teamsController.Dispose(); + Logger.Info("Terminating the program..."); + Environment.Exit(0); + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc0b0fd --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# Features + +## Parameters + +The application can be run without any parameters (default), although it can accept the following parameters: + +- -h, --help - this parameter will cause the program to print help information and terminate afterwards +- -v, --verbose - this option will force the program to output debug-level logs (overrides **outputDebug** in _config.json_) +- -u, --update - this option will cause the program to synchronize the MS Teams data with the database and terminate afterwards +- -c, --console - this parameter will enable an in-built command line interface, usually used for debugging + +## The console + +The in-built CLI allows the user to input the following commands: + +- `exit` - terminates the program +- `db` - reloads data from the database (can be used to update meeting data) +- `switch ` - switches to the specified channel +- `join ` - joins a meeting in the specified channel + +The argument `` has the following format: _`:`_ and is used to designate channels. + +# Configuration + +Field structure: +`()` + +## config.json + +This file contains the general configuration for the application. + +Fields: + +- `headlessMode(false)` - if set to true, the web browser will run in headless mode (will not be visible). May increase performance. +- `allowMicrophone(true)` - if set to true, will automatically allow microphone access for the MS Teams web application +- `allowWebcam(true)` - if set to true, will automatically allow video device access for the MS Teams web application +- `searchWaitTime(5)` - the time (in seconds) that a program will wait to find an element without timing out +- `loginToPasswordWaitTimeMilis(700)` - the time (in milliseconds) that the program will wait for the transition from the email input to the password input in the login prompt of Microsoft Account Services. Increase, if experiencing errors during login phase. +- `maxLoadAttemptsForRefresh(5)` - the maximum number of checks for the web application loaded state after login before refreshing it. +- `pageLoadedCheckIntervalMilis(4000)` - the time (in milliseconds) that the program will wait between each web application loaded state check +- `outputDebug(false)` - if set to true, the program will output information with log level `DEBUG` +- `schedulerSyncOnSecond(10)` - the second when the scheduler will periodically run checks on. The scheduler runs every minute, scheduled on the specified second. + +## credentials.json + +This file contains the user credentials for the MS Teams web application. + +Fields: + +- `login` - the email of the MS Teams user +- `password` - the password of the MS Teams user (in Base64, **UTF-8 encoded**) + +## fields.json + +This file contains field definitions, usually element locators, for interaction with the DOM of MS Teams web application. +If a locator changes, it can be updated in this file. + +Fields: + +- `loginProceedButtonId` - the id of the `Next` buttons in the login prompt +- `loginEmailFieldName` - the `name` attribute of the email input field in the login prompt +- `loginPasswordFieldName` - the `name` attribute of the password input field in the login prompt +- `msTeamsMainUrl` - the main URL of the web application +- `msTeamsMainSchoolUrl` - the URL of the web application following the initialization phase +- `msTeamsLoginCheckStallUrl` - the URL of the web application containing the `Stay signed in` prompt +- `menuTeamListButtonId` - the id of the team list button +- `menuTeamProfilePictureXPath` - a relative XPath from the `li` team element to the `profile-picture` element of the team +- `menuTeamChannelListXPath` - a relative XPath from the `li` team element to each channel `a` element +- `menuTeamChannelNameXPath` - a relative XPath from the `a` channel element to each channel name `span` element +- `meetingNoMicPromptButtonXPath` - an absolute XPath to the `Continue without audio or video` button element (used when `allowMicrophone` is set to `false`) + +# Data storage + +All data is stored in an Sqlite database (**storage.db**), managed by Entity Framework Core. +The data is divided into three main tables: **Classrooms**, **Channels** and **Meetings**. +If the database is not present during program initialization, it will be automatically created. + +### Classrooms table + +This table contains the list of classrooms (teams). It is synchronized with the actual data each time the program initializes. +Columns: + +- `Name` - `the name of the classroom (team) + +### Channels table + +This table contains the list of channels. It is synchronized with the actual data each time the program initializes. +Columns: + +- `ChannelId` - the ID of the channel (as visible in the URL) +- `Name` - the name of the channel +- `ClassroomName` - the name of the classroom (team) the channel belongs to + +### Meetings table + +This table contains the list of classrooms (teams). This table must be manually filled in by the user. +Columns: + +- `Id` - the ID of the meeting (automatically assigned) +- `DayOfWeek` - the day of week that the meeting is scheduled on +- `StartHour` - the starting hour (in 24 hour format) that the meeting is scheduled on +- `StartMinute` - the starting minute that the meeting is scheduled on +- `DurationMinutes` - the duration (in minutes) of the meeting +- `ChannelId` - refers to the `ChannelId` of the channel from the `Channels` table +- `Enabled` - set to `1` if enabled or `0` if disabled diff --git a/Selectors.cs b/Selectors.cs new file mode 100644 index 0000000..56c034d --- /dev/null +++ b/Selectors.cs @@ -0,0 +1,10 @@ +using OpenQA.Selenium; + +namespace autoteams +{ + public static class Selectors + { + public static By ByAttribute(string attributeName, string attributeValue) => By.XPath($"//*[@{attributeName}='{attributeValue}']"); + public static By ByTitle(string title) => ByAttribute("title", title); + } +} \ No newline at end of file diff --git a/StorageContext.cs b/StorageContext.cs new file mode 100644 index 0000000..2c98b40 --- /dev/null +++ b/StorageContext.cs @@ -0,0 +1,28 @@ +using autoteams.Models; +using Microsoft.EntityFrameworkCore; + +namespace autoteams +{ + public class StorageContext : DbContext + { + public DbSet Classrooms { get; set; } + public DbSet Channels { get; set; } + public DbSet Meetings { get; set; } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder.UseSqlite(@"Data Source=storage.db;").EnableDetailedErrors().EnableSensitiveDataLogging(); + + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasMany(c => c.Channels) + .WithOne(c => c.Classroom) + .HasForeignKey(c => c.ClassroomName); + + modelBuilder.Entity() + .HasMany(c => c.Meetings) + .WithOne(c => c.Channel) + .HasForeignKey(c => c.ChannelId); + } + } +} \ No newline at end of file diff --git a/TeamsController.cs b/TeamsController.cs new file mode 100644 index 0000000..67c94b4 --- /dev/null +++ b/TeamsController.cs @@ -0,0 +1,340 @@ +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 Classes { get; private init; } + public HashSet 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 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(), + }; + + //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 dbChannels = db.Channels.AsEnumerable(); + IEnumerable dbRooms = db.Classrooms.AsEnumerable(); + + IEnumerable channelDifference = Channels.Except(dbChannels); + IEnumerable 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(); + } + } +} \ No newline at end of file diff --git a/Utils.cs b/Utils.cs new file mode 100644 index 0000000..f7a28c1 --- /dev/null +++ b/Utils.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using OpenQA.Selenium; + +namespace autoteams +{ + public static class Utils + { + + public static readonly JsonSerializerOptions JSON_OPTIONS = new() + { + AllowTrailingCommas = true, + IgnoreReadOnlyProperties = true, + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public static T DeserializeJsonFile(string path) => JsonSerializer.Deserialize(File.ReadAllText(path), JSON_OPTIONS); + + public static void SerializeToJsonFile(string path, object o) => File.WriteAllText(path, JsonSerializer.Serialize(o, JSON_OPTIONS)); + + public static object ExecuteScript(this IWebDriver driver, string script, params object[] args) => ((IJavaScriptExecutor)driver).ExecuteScript(script, args); + + public static object ExecuteScriptAsync(this IWebDriver driver, string script, params object[] args) => ((IJavaScriptExecutor)driver).ExecuteAsyncScript(script, args); + + + public static IWebElement Parent(this IWebElement element) => element.FindElement(By.XPath("..")); + public static IWebElement Parent(this IWebElement element, int noOfParents) => element.FindElement(By.XPath(string.Join('/', Enumerable.Repeat("..", noOfParents)))); + + + public static void ScrollIntoView(this IWebDriver driver, IWebElement element) => driver.ExecuteScript("arguments[0].scrollIntoView(true);", element); + public static void ScrollIntoView(this IWebElement element, IWebDriver driver) => ScrollIntoView(driver, element); + + public static string GetId(this IWebElement element) => element.GetAttribute("id"); + + public static bool TryTo(this IWebDriver _, TimeSpan timeout, Func action, int intervalMilis = 500, int initialDelay = 0) + { + if (intervalMilis > timeout.TotalMilliseconds) + { + throw new ArgumentException("Interval cannot be larger than timeout."); + } + + if (intervalMilis < 0) + { + throw new ArgumentException("Interval cannot be negative."); + } + if (initialDelay < 0) + { + throw new ArgumentException("Initial delay cannot be negative."); + } + + if (initialDelay > 0) + Thread.Sleep(initialDelay); + + using CancellationTokenSource src = new(); + + //cancel the task after the timeout is reached + src.CancelAfter(timeout); + + return Task.Run(async () => + { + int start = Environment.TickCount; + Trial: + try + { + src.Token.ThrowIfCancellationRequested(); + return action.Invoke(); + } + catch (OperationCanceledException) + { + return false; + } + catch (Exception) + { + await Task.Delay(intervalMilis); + goto Trial; + } + }, src.Token).Result; + } + } +} \ No newline at end of file diff --git a/autoteams.csproj b/autoteams.csproj new file mode 100644 index 0000000..ea4586e --- /dev/null +++ b/autoteams.csproj @@ -0,0 +1,22 @@ + + + + Exe + net5.0 + + + + + + + + + + + + + + + + + diff --git a/config.json b/config.json new file mode 100644 index 0000000..f0b78ab --- /dev/null +++ b/config.json @@ -0,0 +1,11 @@ +{ + "headlessMode": false, + "allowMicrophone": true, + "allowWebcam": true, + "searchWaitTime": 5, + "loginToPasswordWaitTimeMilis": 700, + "maxLoadAttemptsForRefresh": 5, + "pageLoadedCheckIntervalMilis": 4000, + "outputDebug": false, + "schedulerSyncOnSecond": 10 +} \ No newline at end of file diff --git a/credentials.json b/credentials.json new file mode 100644 index 0000000..7dfe89d --- /dev/null +++ b/credentials.json @@ -0,0 +1,4 @@ +{ + "login": "", + "password": "" +} \ No newline at end of file diff --git a/fields.json b/fields.json new file mode 100644 index 0000000..8f49b31 --- /dev/null +++ b/fields.json @@ -0,0 +1,13 @@ +{ + "loginProceedButtonId": "idSIButton9", + "loginEmailFieldName": "loginfmt", + "loginPasswordFieldName": "passwd", + "msTeamsMainUrl": "https://teams.microsoft.com/_", + "msTeamsMainSchoolUrl": "https://teams.microsoft.com/_#/school", + "msTeamsLoginCheckStallUrl": "https://login.microsoftonline.com/common/login", + "menuTeamListButtonId": "app-bar-2a84919f-59d8-4441-a975-2a8c2643b741", + "menuTeamProfilePictureXPath": "./div/h3/a/profile-picture", + "menuTeamChannelListXPath": "./div/div[@class='channels']/ul/ng-include/*/a", + "menuTeamChannelNameXPath": "./div/span", + "meetingNoMicPromptButtonXPath": "//*[@id=\"ngdialog1\"]/div[2]/div/div/div/div[1]/div/div/div[2]/div/button" +} \ No newline at end of file