Use FileMonitor to synchronize Mailer bot (#1108) (v2)

Move SessionFileMonitor into a generic FileMonitor class
Use FileMonintor for both SessionCache and the Mailer bot
Allows multiple MCC instances to share the same database files

(Add files missing in the previous commit)
This commit is contained in:
ORelio 2020-08-07 11:58:44 +02:00
parent 5028cce2a5
commit ce83cc0a33
6 changed files with 215 additions and 85 deletions

View file

@ -24,7 +24,7 @@ namespace MinecraftClient.ChatBots
public static IgnoreList FromFile(string filePath)
{
IgnoreList ignoreList = new IgnoreList();
foreach (string line in File.ReadAllLines(filePath))
foreach (string line in FileMonitor.ReadAllLinesWithRetries(filePath))
{
if (!line.StartsWith("#"))
{
@ -46,7 +46,7 @@ namespace MinecraftClient.ChatBots
lines.Add("#Ignored Players");
foreach (string player in this)
lines.Add(player);
File.WriteAllLines(filePath, lines);
FileMonitor.WriteAllLinesWithRetries(filePath, lines);
}
}
@ -63,7 +63,7 @@ namespace MinecraftClient.ChatBots
public static MailDatabase FromFile(string filePath)
{
MailDatabase database = new MailDatabase();
Dictionary<string, Dictionary<string, string>> iniFileDict = INIFile.ParseFile(filePath);
Dictionary<string, Dictionary<string, string>> iniFileDict = INIFile.ParseFile(FileMonitor.ReadAllLinesWithRetries(filePath));
foreach (KeyValuePair<string, Dictionary<string, string>> iniSection in iniFileDict)
{
//iniSection.Key is "mailXX" but we don't need it here
@ -96,7 +96,7 @@ namespace MinecraftClient.ChatBots
iniSection["anonymous"] = mail.Anonymous.ToString();
iniFileDict["mail" + mailCount] = iniSection;
}
INIFile.WriteFile(filePath, iniFileDict, "Mail Database");
FileMonitor.WriteAllLinesWithRetries(filePath, INIFile.Generate(iniFileDict, "Mail Database"));
}
}
@ -147,6 +147,9 @@ namespace MinecraftClient.ChatBots
private DateTime nextMailSend = DateTime.Now;
private MailDatabase mailDatabase = new MailDatabase();
private IgnoreList ignoreList = new IgnoreList();
private FileMonitor mailDbFileMonitor;
private FileMonitor ignoreListFileMonitor;
private object readWriteLock = new object();
/// <summary>
/// Initialization of the Mailer bot
@ -194,11 +197,18 @@ namespace MinecraftClient.ChatBots
new IgnoreList().SaveToFile(Settings.Mailer_IgnoreListFile);
}
lock (readWriteLock)
{
LogDebugToConsole("Loading database file: " + Path.GetFullPath(Settings.Mailer_DatabaseFile));
mailDatabase = MailDatabase.FromFile(Settings.Mailer_DatabaseFile);
LogDebugToConsole("Loading ignore list: " + Path.GetFullPath(Settings.Mailer_IgnoreListFile));
ignoreList = IgnoreList.FromFile(Settings.Mailer_IgnoreListFile);
}
//Initialize file monitors. In case the bot needs to unload for some reason in the future, do not forget to .Dispose() them
mailDbFileMonitor = new FileMonitor(Path.GetDirectoryName(Settings.Mailer_DatabaseFile), Path.GetFileName(Settings.Mailer_DatabaseFile), FileMonitorCallback);
ignoreListFileMonitor = new FileMonitor(Path.GetDirectoryName(Settings.Mailer_IgnoreListFile), Path.GetFileName(Settings.Mailer_IgnoreListFile), FileMonitorCallback);
RegisterChatBotCommand("mailer", "Subcommands: getmails, addignored, getignored, removeignored", ProcessInternalCommand);
}
@ -249,8 +259,11 @@ namespace MinecraftClient.ChatBots
{
Mail mail = new Mail(username, recipient, message, anonymous, DateTime.Now);
LogToConsole("Saving message: " + mail.ToString());
lock (readWriteLock)
{
mailDatabase.Add(mail);
mailDatabase.SaveToFile(Settings.Mailer_DatabaseFile);
}
SendPrivateMessage(username, "Message saved!");
}
else SendPrivateMessage(username, "Your message cannot be longer than " + maxMessageLength + " characters.");
@ -277,10 +290,6 @@ namespace MinecraftClient.ChatBots
{
LogDebugToConsole("Looking for mails to send @ " + DateTime.Now);
// Reload mail and ignore list database in case several instances are sharing the same database
mailDatabase = MailDatabase.FromFile(Settings.Mailer_DatabaseFile);
ignoreList = IgnoreList.FromFile(Settings.Mailer_IgnoreListFile);
// Process at most 3 mails at a time to avoid spamming. Other mails will be processed on next mail send
HashSet<string> onlinePlayersLowercase = new HashSet<string>(GetOnlinePlayers().Select(name => name.ToLower()));
foreach (Mail mail in mailDatabase.Where(mail => !mail.Delivered && onlinePlayersLowercase.Contains(mail.RecipientLowercase)).Take(3))
@ -291,14 +300,31 @@ namespace MinecraftClient.ChatBots
LogDebugToConsole("Delivered: " + mail.ToString());
}
lock (readWriteLock)
{
mailDatabase.RemoveAll(mail => mail.Delivered);
mailDatabase.RemoveAll(mail => mail.DateSent.AddDays(Settings.Mailer_MailRetentionDays) < DateTime.Now);
mailDatabase.SaveToFile(Settings.Mailer_DatabaseFile);
}
nextMailSend = dateNow.AddSeconds(10);
}
}
/// <summary>
/// Called when the Mail Database or Ignore list has changed on disk
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void FileMonitorCallback(object sender, FileSystemEventArgs e)
{
lock (readWriteLock)
{
mailDatabase = MailDatabase.FromFile(Settings.Mailer_DatabaseFile);
ignoreList = IgnoreList.FromFile(Settings.Mailer_IgnoreListFile);
}
}
/// <summary>
/// Interprets local commands.
/// </summary>
@ -321,21 +347,27 @@ namespace MinecraftClient.ChatBots
{
string username = args[1].ToLower();
if (commandName == "addignored")
{
lock (readWriteLock)
{
if (!ignoreList.Contains(username))
{
ignoreList.Add(username);
ignoreList.SaveToFile(Settings.Mailer_IgnoreListFile);
}
}
return "Added " + args[1] + " to the ignore list!";
}
else
{
lock (readWriteLock)
{
if (ignoreList.Contains(username))
{
ignoreList.Remove(username);
ignoreList.SaveToFile(Settings.Mailer_IgnoreListFile);
}
}
return "Removed " + args[1] + " from the ignore list!";
}
}

View file

@ -1,27 +1,32 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading;
namespace MinecraftClient.Protocol.Session
namespace MinecraftClient
{
/// <summary>
/// Monitor session file changes on disk
/// Monitor file changes on disk
/// </summary>
class SessionFileMonitor
public class FileMonitor : IDisposable
{
private FileSystemWatcher monitor;
private Thread polling;
private FileSystemWatcher monitor = null;
private Thread polling = null;
/// <summary>
/// Create a new SessionFileMonitor and start monitoring
/// Create a new FileMonitor and start monitoring
/// </summary>
/// <param name="folder">Folder to monitor</param>
/// <param name="filename">Filename inside folder</param>
/// <param name="handler">Callback for file changes</param>
public SessionFileMonitor(string folder, string filename, FileSystemEventHandler handler)
public FileMonitor(string folder, string filename, FileSystemEventHandler handler)
{
if (Settings.DebugMessages)
ConsoleIO.WriteLineFormatted("§8Initializing disk session cache using FileSystemWatcher");
{
string callerClass = new System.Diagnostics.StackFrame(1).GetMethod().DeclaringType.Name;
ConsoleIO.WriteLineFormatted(String.Format("§8[{0}] Initializing FileSystemWatcher for file: {1}", callerClass, Path.Combine(folder, filename)));
}
try
{
@ -36,13 +41,29 @@ namespace MinecraftClient.Protocol.Session
catch
{
if (Settings.DebugMessages)
ConsoleIO.WriteLineFormatted("§8Failed to initialize FileSystemWatcher, retrying using Polling");
{
string callerClass = new System.Diagnostics.StackFrame(1).GetMethod().DeclaringType.Name;
ConsoleIO.WriteLineFormatted(String.Format("§8[{0}] Failed to initialize FileSystemWatcher, retrying using Polling", callerClass));
}
monitor = null;
polling = new Thread(() => PollingThread(folder, filename, handler));
polling.Name = String.Format("{0} Polling thread: {1}", this.GetType().Name, Path.Combine(folder, filename));
polling.Start();
}
}
/// <summary>
/// Stop monitoring and dispose the inner resources
/// </summary>
public void Dispose()
{
if (monitor != null)
monitor.Dispose();
if (polling != null)
polling.Abort();
}
/// <summary>
/// Fallback polling thread for use when operating system does not support FileSystemWatcher
/// </summary>
@ -51,7 +72,7 @@ namespace MinecraftClient.Protocol.Session
/// <param name="handler">Callback when file changes</param>
private void PollingThread(string folder, string filename, FileSystemEventHandler handler)
{
string filePath = String.Concat(folder, Path.DirectorySeparatorChar, filename);
string filePath = Path.Combine(folder, filename);
DateTime lastWrite = GetLastWrite(filePath);
while (true)
{
@ -79,5 +100,62 @@ namespace MinecraftClient.Protocol.Session
}
else return DateTime.MinValue;
}
/// <summary>
/// Opens a text file, reads all lines of the file, and then closes the file. Retry several times if the file is in use
/// </summary>
/// <param name="filePath">The file to open for reading</param>
/// <param name="maxTries">Maximum read attempts</param>
/// <param name="encoding">Encoding (default is UTF8)</param>
/// <exception cref="System.IO.IOException">Thrown when failing to read the file despite multiple retries</exception>
/// <returns>All lines</returns>
public static string[] ReadAllLinesWithRetries(string filePath, int maxTries = 3, Encoding encoding = null)
{
int attempt = 0;
if (encoding == null)
encoding = Encoding.UTF8;
while (true)
{
try
{
return File.ReadAllLines(filePath, encoding);
}
catch (IOException)
{
attempt++;
if (attempt < maxTries)
Thread.Sleep(new Random().Next(50, 100) * attempt); // Back-off like CSMA/CD
else throw;
}
}
}
/// <summary>
/// Creates a new file, writes a collection of strings to the file, and then closes the file.
/// </summary>
/// <param name="filePath">The file to open for writing</param>
/// <param name="lines">The lines to write to the file</param>
/// <param name="maxTries">Maximum read attempts</param>
/// <param name="encoding">Encoding (default is UTF8)</param>
public static void WriteAllLinesWithRetries(string filePath, IEnumerable<string> lines, int maxTries = 3, Encoding encoding = null)
{
int attempt = 0;
if (encoding == null)
encoding = Encoding.UTF8;
while (true)
{
try
{
File.WriteAllLines(filePath, lines, encoding);
}
catch (IOException)
{
attempt++;
if (attempt < maxTries)
Thread.Sleep(new Random().Next(50, 100) * attempt); // Back-off like CSMA/CD
else throw;
}
}
}
}
}

View file

@ -8,7 +8,7 @@ namespace MinecraftClient
{
/// <summary>
/// INI File tools for parsing and generating user-friendly INI files
/// By ORelio (c) 2014 - CDDL 1.0
/// By ORelio (c) 2014-2020 - CDDL 1.0
/// </summary>
static class INIFile
{
@ -21,9 +21,21 @@ namespace MinecraftClient
/// <exception cref="IOException">If failed to read the file</exception>
/// <returns>Parsed data from INI file</returns>
public static Dictionary<string, Dictionary<string, string>> ParseFile(string iniFile, bool lowerCase = true)
{
return ParseFile(File.ReadAllLines(iniFile, Encoding.UTF8), lowerCase);
}
/// <summary>
/// Parse a INI file into a dictionary.
/// Values can be accessed like this: dict["section"]["setting"]
/// </summary>
/// <param name="lines">INI file content to parse</param>
/// <param name="lowerCase">INI sections and keys will be converted to lowercase unless this parameter is set to false</param>
/// <exception cref="IOException">If failed to read the file</exception>
/// <returns>Parsed data from INI file</returns>
public static Dictionary<string, Dictionary<string, string>> ParseFile(IEnumerable<string> lines, bool lowerCase = true)
{
var iniContents = new Dictionary<string, Dictionary<string, string>>();
string[] lines = File.ReadAllLines(iniFile, Encoding.UTF8);
string iniSection = "default";
foreach (string lineRaw in lines)
{
@ -62,6 +74,18 @@ namespace MinecraftClient
/// <param name="description">INI file description, inserted as a comment on first line of the INI file</param>
/// <param name="autoCase">Automatically change first char of section and keys to uppercase</param>
public static void WriteFile(string iniFile, Dictionary<string, Dictionary<string, string>> contents, string description = null, bool autoCase = true)
{
File.WriteAllLines(iniFile, Generate(contents, description, autoCase), Encoding.UTF8);
}
/// <summary>
/// Generate given data into the INI format
/// </summary>
/// <param name="contents">Data to put into the INI format</param>
/// <param name="description">INI file description, inserted as a comment on first line of the INI file</param>
/// <param name="autoCase">Automatically change first char of section and keys to uppercase</param>
/// <returns>Lines of the INI file</returns>
public static string[] Generate(Dictionary<string, Dictionary<string, string>> contents, string description = null, bool autoCase = true)
{
List<string> lines = new List<string>();
if (!String.IsNullOrWhiteSpace(description))
@ -78,7 +102,7 @@ namespace MinecraftClient
lines.Add((autoCase ? char.ToUpper(item.Key[0]) + item.Key.Substring(1) : item.Key) + '=' + item.Value);
}
}
File.WriteAllLines(iniFile, lines, Encoding.UTF8);
return lines.ToArray();
}
/// <summary>

View file

@ -1771,13 +1771,15 @@ namespace MinecraftClient
public void OnPlayerLeave(Guid uuid)
{
string username = null;
if (onlinePlayers.ContainsKey(uuid))
username = onlinePlayers[uuid];
lock (onlinePlayers)
{
if (onlinePlayers.ContainsKey(uuid))
{
username = onlinePlayers[uuid];
onlinePlayers.Remove(uuid);
}
}
DispatchBotEvent(bot => bot.OnPlayerLeave(uuid, username));
}

View file

@ -150,7 +150,7 @@
<Compile Include="Protocol\Handlers\Protocol18Terrain.cs" />
<Compile Include="Protocol\Handlers\SocketWrapper.cs" />
<Compile Include="Protocol\DataTypeGenerator.cs" />
<Compile Include="Protocol\Session\SessionFileMonitor.cs" />
<Compile Include="FileMonitor.cs" />
<Compile Include="WinAPI\ConsoleIcon.cs" />
<Compile Include="ConsoleIO.cs" />
<Compile Include="Crypto\Streams\BouncyAes\AesFastEngine.cs" />

View file

@ -23,7 +23,7 @@ namespace MinecraftClient.Protocol.Session
"launcher_profiles.json"
);
private static SessionFileMonitor cachemonitor;
private static FileMonitor cachemonitor;
private static Dictionary<string, SessionToken> sessions = new Dictionary<string, SessionToken>();
private static Timer updatetimer = new Timer(100);
private static List<KeyValuePair<string, SessionToken>> pendingadds = new List<KeyValuePair<string, SessionToken>>();
@ -81,7 +81,7 @@ namespace MinecraftClient.Protocol.Session
/// <returns>TRUE if session tokens are seeded from file</returns>
public static bool InitializeDiskCache()
{
cachemonitor = new SessionFileMonitor(AppDomain.CurrentDomain.BaseDirectory, SessionCacheFilePlaintext, new FileSystemEventHandler(OnChanged));
cachemonitor = new FileMonitor(AppDomain.CurrentDomain.BaseDirectory, SessionCacheFilePlaintext, new FileSystemEventHandler(OnChanged));
updatetimer.Elapsed += HandlePending;
return LoadFromDisk();
}
@ -189,11 +189,11 @@ namespace MinecraftClient.Protocol.Session
}
catch (IOException ex)
{
ConsoleIO.WriteLineFormatted("§8Failed to read session cache from disk: " + ex.Message);
ConsoleIO.WriteLineFormatted("§8Failed to read serialized session cache from disk: " + ex.Message);
}
catch (SerializationException ex2)
{
ConsoleIO.WriteLineFormatted("§8Got malformed data while reading session cache from disk: " + ex2.Message);
ConsoleIO.WriteLineFormatted("§8Got malformed data while reading serialized session cache from disk: " + ex2.Message);
}
}
@ -203,7 +203,9 @@ namespace MinecraftClient.Protocol.Session
if (Settings.DebugMessages)
ConsoleIO.WriteLineFormatted("§8Loading session cache from disk: " + SessionCacheFilePlaintext);
foreach (string line in File.ReadAllLines(SessionCacheFilePlaintext))
try
{
foreach (string line in FileMonitor.ReadAllLinesWithRetries(SessionCacheFilePlaintext))
{
if (!line.Trim().StartsWith("#"))
{
@ -231,6 +233,11 @@ namespace MinecraftClient.Protocol.Session
}
}
}
catch (IOException e)
{
ConsoleIO.WriteLineFormatted("§8Failed to read session cache from disk: " + e.Message);
}
}
return sessions.Count > 0;
}
@ -243,33 +250,20 @@ namespace MinecraftClient.Protocol.Session
if (Settings.DebugMessages)
ConsoleIO.WriteLineFormatted("§8Saving session cache to disk");
bool fileexists = File.Exists(SessionCacheFilePlaintext);
IOException lastEx = null;
int attempt = 1;
while (attempt < 4)
{
try
{
List<string> sessionCacheLines = new List<string>();
sessionCacheLines.Add("# Generated by MCC v" + Program.Version + " - Edit at own risk!");
sessionCacheLines.Add("# Login=SessionID,PlayerName,UUID,ClientID");
foreach (KeyValuePair<string, SessionToken> entry in sessions)
sessionCacheLines.Add(entry.Key + '=' + entry.Value.ToString());
File.WriteAllLines(SessionCacheFilePlaintext, sessionCacheLines);
//if (File.Exists(SessionCacheFileSerialized))
// File.Delete(SessionCacheFileSerialized);
return;
}
catch (IOException ex)
{
lastEx = ex;
attempt++;
System.Threading.Thread.Sleep(new Random().Next(150, 350) * attempt); //CSMA/CD :)
}
}
ConsoleIO.WriteLineFormatted("§8Failed to write session cache to disk" + (lastEx != null ? ": " + lastEx.Message : ""));
try
{
FileMonitor.WriteAllLinesWithRetries(SessionCacheFilePlaintext, sessionCacheLines);
}
catch (IOException e)
{
ConsoleIO.WriteLineFormatted("§8Failed to write session cache to disk: " + e.Message);
}
}
}
}