Initial project commit

This commit is contained in:
femsci 2021-04-26 17:43:21 +02:00
commit 00c0274950
Signed by: femsci
GPG key ID: 08F7911F0E650C67
20 changed files with 1142 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
**/bin
**/obj
**/.vscode
publish/
storage.db*
logfile.log

55
ConfigurationManager.cs Normal file
View file

@ -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<FieldsConfig>(FIELDS_CONF);
//Extract and load the credentials.json file
Logger.Info("Loading credentials...");
if (!File.Exists(USER_CREDS))
ExtractFile(USER_CREDS);
CREDENTIALS = Utils.DeserializeJsonFile<UserCredentials>(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<AppConfiguration>(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();
}
}
}

80
Logger.cs Normal file
View file

@ -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
}
}
}

142
MeetingScheduler.cs Normal file
View file

@ -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<ScheduledMeeting> 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;
}
}
}
}
}
}
}
}
}

View file

@ -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; }
}
}

19
Models/FieldsConfig.cs Normal file
View file

@ -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; }
}
}

View file

@ -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; }
}
}

29
Models/TeamsChannel.cs Normal file
View file

@ -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<ScheduledMeeting> 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);
}
}
}

30
Models/TeamsClassroom.cs Normal file
View file

@ -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<TeamsChannel> 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();
}
}
}

View file

@ -0,0 +1,8 @@
namespace autoteams.Models
{
public class UserCredentials
{
public string Login { get; set; }
public string Password { get; set; }
}
}

113
Program.cs Normal file
View file

@ -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 <c>\t- switches to the specified channel\n"
+ " join <c>\t- joins a meeting in specified channel\n");
Console.WriteLine("Argument types:\n"
+ " <c>\t\t - channel locator (<teamName>:<channelName>)");
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);
}
}
}

105
README.md Normal file
View file

@ -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 <channel>` - switches to the specified channel
- `join <channel>` - joins a meeting in the specified channel
The argument `<channel>` has the following format: _`<teamName>:<channelName>`_ and is used to designate channels.
# Configuration
Field structure:
`<fieldName>(<defaultValue>)`
## 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

10
Selectors.cs Normal file
View file

@ -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);
}
}

28
StorageContext.cs Normal file
View file

@ -0,0 +1,28 @@
using autoteams.Models;
using Microsoft.EntityFrameworkCore;
namespace autoteams
{
public class StorageContext : DbContext
{
public DbSet<TeamsClassroom> Classrooms { get; set; }
public DbSet<TeamsChannel> Channels { get; set; }
public DbSet<ScheduledMeeting> 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<TeamsClassroom>()
.HasMany(c => c.Channels)
.WithOne(c => c.Classroom)
.HasForeignKey(c => c.ClassroomName);
modelBuilder.Entity<TeamsChannel>()
.HasMany(c => c.Meetings)
.WithOne(c => c.Channel)
.HasForeignKey(c => c.ChannelId);
}
}
}

340
TeamsController.cs Normal file
View file

@ -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<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();
}
}
}

85
Utils.cs Normal file
View file

@ -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<T>(string path) => JsonSerializer.Deserialize<T>(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<bool> 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;
}
}
}

22
autoteams.csproj Normal file
View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.5"/>
<PackageReference Include="Selenium.Chrome.WebDriver" Version="85.0.0"/>
<PackageReference Include="Selenium.WebDriver" Version="3.141.0"/>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="config.json"/>
<EmbeddedResource Include="credentials.json"/>
<EmbeddedResource Include="fields.json"/>
<EmbeddedResource Include="README.md"/>
</ItemGroup>
</Project>

11
config.json Normal file
View file

@ -0,0 +1,11 @@
{
"headlessMode": false,
"allowMicrophone": true,
"allowWebcam": true,
"searchWaitTime": 5,
"loginToPasswordWaitTimeMilis": 700,
"maxLoadAttemptsForRefresh": 5,
"pageLoadedCheckIntervalMilis": 4000,
"outputDebug": false,
"schedulerSyncOnSecond": 10
}

4
credentials.json Normal file
View file

@ -0,0 +1,4 @@
{
"login": "<LOGIN>",
"password": "<PASSWORD_BASE_64>"
}

13
fields.json Normal file
View file

@ -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"
}