Decompiled source of DiscordLogSync v1.0.4

plugins/DiscordLogSync.dll

Decompiled 3 weeks ago
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using BepInEx;
using BepInEx.Configuration;
using BepInEx.Logging;

[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)]
[assembly: TargetFramework(".NETStandard,Version=v2.1", FrameworkDisplayName = ".NET Standard 2.1")]
[assembly: AssemblyCompany("DiscordLogSync")]
[assembly: AssemblyConfiguration("Release")]
[assembly: AssemblyFileVersion("1.0.0.0")]
[assembly: AssemblyInformationalVersion("1.0.0+ccfa8ce0e453d22306e4190f819afe01a7ee4e13")]
[assembly: AssemblyProduct("DiscordLogSync")]
[assembly: AssemblyTitle("DiscordLogSync")]
[assembly: AssemblyVersion("1.0.0.0")]
namespace DiscordLogSync;

public class ConsoleInterceptor : TextWriter
{
	private readonly TextWriter _original;

	private readonly DiscordLogListener _listener;

	private readonly StringBuilder _partial = new StringBuilder();

	private readonly object _lock = new object();

	[ThreadStatic]
	private static bool _inWrite;

	public override Encoding Encoding => _original.Encoding;

	public ConsoleInterceptor(TextWriter original, DiscordLogListener listener)
	{
		_original = original;
		_listener = listener;
	}

	public override void Write(char value)
	{
		_original.Write(value);
		lock (_lock)
		{
			switch (value)
			{
			case '\n':
				FlushPartial();
				break;
			default:
				_partial.Append(value);
				break;
			case '\r':
				break;
			}
		}
	}

	public override void Write(string value)
	{
		if (value == null)
		{
			return;
		}
		_original.Write(value);
		lock (_lock)
		{
			foreach (char c in value)
			{
				switch (c)
				{
				case '\n':
					FlushPartial();
					break;
				default:
					_partial.Append(c);
					break;
				case '\r':
					break;
				}
			}
		}
	}

	public override void WriteLine(string value)
	{
		_original.WriteLine(value);
		if (_inWrite)
		{
			return;
		}
		_inWrite = true;
		try
		{
			lock (_lock)
			{
				_partial.Append(value ?? "");
				FlushPartial();
			}
		}
		finally
		{
			_inWrite = false;
		}
	}

	public override void WriteLine()
	{
		_original.WriteLine();
		if (_inWrite)
		{
			return;
		}
		_inWrite = true;
		try
		{
			lock (_lock)
			{
				FlushPartial();
			}
		}
		finally
		{
			_inWrite = false;
		}
	}

	private void FlushPartial()
	{
		_listener.WriteToBuffer(_partial.ToString());
		_partial.Clear();
	}

	protected override void Dispose(bool disposing)
	{
		if (disposing)
		{
			lock (_lock)
			{
				if (_partial.Length > 0)
				{
					FlushPartial();
				}
			}
		}
		base.Dispose(disposing);
	}
}
public class DiscordLogListener : ILogListener, IDisposable
{
	private readonly string _serverName;

	private readonly string _webhookUrl;

	private static readonly string BufferPath = Path.Combine(Paths.BepInExRootPath, "DiscordLogBuffer.txt");

	private readonly object _fileLock = new object();

	private StreamWriter _writer;

	private readonly HttpClient _http = new HttpClient
	{
		Timeout = TimeSpan.FromSeconds(10.0)
	};

	private readonly Timer _sendTimer;

	private volatile bool _disposed;

	private int _sending;

	public DiscordLogListener(string serverName, string webhookUrl)
	{
		_serverName = serverName;
		_webhookUrl = webhookUrl;
		RecoverAndSendLeftoverBuffer();
		_writer = OpenWriter(FileMode.Append);
		int num = Math.Max(2, Plugin.SendIntervalSeconds.Value) * 1000;
		_sendTimer = new Timer(OnTimerTick, null, num, num);
	}

	public void LogEvent(object sender, LogEventArgs e)
	{
		//IL_003c: Unknown result type (might be due to invalid IL or missing references)
		string text = (e.Data?.ToString() ?? "").Trim();
		if (!string.IsNullOrWhiteSpace(text))
		{
			WriteToBuffer($"[{e.Source.SourceName}] {e.Level} {text}");
		}
	}

	public void WriteToBuffer(string line)
	{
		if (_disposed)
		{
			return;
		}
		string value = (line ?? "").Trim();
		if (string.IsNullOrWhiteSpace(value))
		{
			return;
		}
		lock (_fileLock)
		{
			try
			{
				_writer?.WriteLine(value);
			}
			catch
			{
			}
		}
	}

	private void OnTimerTick(object _)
	{
		if (_disposed || Interlocked.CompareExchange(ref _sending, 1, 0) != 0)
		{
			return;
		}
		try
		{
			TrySendBuffer($"\ud83d\udccb [{_serverName}] {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
		}
		finally
		{
			Interlocked.Exchange(ref _sending, 0);
		}
	}

	private void TrySendBuffer(string title)
	{
		lock (_fileLock)
		{
			try
			{
				_writer?.Flush();
			}
			catch
			{
				return;
			}
		}
		string text;
		try
		{
			using FileStream stream = new FileStream(BufferPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
			using StreamReader streamReader = new StreamReader(stream, Encoding.UTF8);
			text = streamReader.ReadToEnd();
		}
		catch
		{
			return;
		}
		if (string.IsNullOrWhiteSpace(text))
		{
			return;
		}
		int num = Math.Clamp(Plugin.MaxMessageChars.Value, 100, 1900);
		string[] array = text.Split('\n');
		List<string> list = new List<string>();
		int num2 = 0;
		int num3 = 0;
		string[] array2 = array;
		for (int i = 0; i < array2.Length; i++)
		{
			string text2 = array2[i].TrimEnd('\r');
			if (string.IsNullOrWhiteSpace(text2))
			{
				num3++;
				continue;
			}
			if (num2 + text2.Length + 1 > num)
			{
				break;
			}
			list.Add(text2);
			num2 += text2.Length + 1;
			num3++;
		}
		if (list.Count == 0)
		{
			return;
		}
		string body = string.Join("\n", list);
		bool flag = false;
		try
		{
			PostToDiscord(title, body).GetAwaiter().GetResult();
			flag = true;
		}
		catch
		{
		}
		if (!flag)
		{
			return;
		}
		lock (_fileLock)
		{
			try
			{
				_writer?.Dispose();
				_writer = null;
				string text3;
				using (FileStream stream2 = new FileStream(BufferPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
				{
					using StreamReader streamReader2 = new StreamReader(stream2, Encoding.UTF8);
					text3 = streamReader2.ReadToEnd();
				}
				string[] source = text3.Split('\n');
				string contents = string.Join("\n", source.Skip(num3));
				File.WriteAllText(BufferPath, contents, Encoding.UTF8);
				_writer = OpenWriter(FileMode.Append);
			}
			catch
			{
				try
				{
					_writer = OpenWriter(FileMode.Append);
				}
				catch
				{
				}
			}
		}
	}

	private void RecoverAndSendLeftoverBuffer()
	{
		if (!File.Exists(BufferPath))
		{
			return;
		}
		string text;
		try
		{
			text = File.ReadAllText(BufferPath, Encoding.UTF8);
		}
		catch
		{
			return;
		}
		if (string.IsNullOrWhiteSpace(text))
		{
			TryDeleteBuffer();
			return;
		}
		int num = Math.Clamp(Plugin.MaxMessageChars.Value, 100, 1900);
		string[] array = text.Split('\n');
		List<string> list = new List<string>();
		int num2 = 0;
		int num3 = 0;
		string[] array2 = array;
		for (int i = 0; i < array2.Length; i++)
		{
			string text2 = array2[i].TrimEnd('\r');
			if (string.IsNullOrWhiteSpace(text2))
			{
				num3++;
				continue;
			}
			if (num2 + text2.Length + 1 > num)
			{
				break;
			}
			list.Add(text2);
			num2 += text2.Length + 1;
			num3++;
		}
		if (list.Count == 0)
		{
			TryDeleteBuffer();
			return;
		}
		string body = string.Join("\n", list);
		bool flag = false;
		try
		{
			PostToDiscord($"⚠\ufe0f [{_serverName}] Recovered — previous session ended unexpectedly — {DateTime.Now:yyyy-MM-dd HH:mm:ss}", body).GetAwaiter().GetResult();
			flag = true;
		}
		catch
		{
		}
		if (flag)
		{
			string text3 = string.Join("\n", array.Skip(num3));
			if (string.IsNullOrWhiteSpace(text3))
			{
				TryDeleteBuffer();
			}
			else
			{
				File.WriteAllText(BufferPath, text3, Encoding.UTF8);
			}
		}
	}

	private Task PostToDiscord(string title, string body)
	{
		string text = Regex.Replace(body, "\\n\\s*\\n", "\n");
		string s = "**" + title + "**\n" + text.TrimEnd();
		string json = "{\"content\":" + JsonString(s) + "}";
		return PostJson(_webhookUrl, json);
	}

	private async Task PostJson(string url, string json)
	{
		StringContent content = new StringContent(json, Encoding.UTF8, "application/json");
		HttpResponseMessage obj = await _http.PostAsync(url, content).ConfigureAwait(continueOnCapturedContext: false);
		if (obj.StatusCode == HttpStatusCode.TooManyRequests)
		{
			throw new Exception("Discord rate-limited (429)");
		}
		obj.EnsureSuccessStatusCode();
	}

	private static StreamWriter OpenWriter(FileMode mode)
	{
		return new StreamWriter(new FileStream(BufferPath, mode, FileAccess.Write, FileShare.ReadWrite), Encoding.UTF8)
		{
			AutoFlush = true
		};
	}

	private static void TryDeleteBuffer()
	{
		try
		{
			File.Delete(BufferPath);
		}
		catch
		{
		}
	}

	private static string JsonString(string s)
	{
		StringBuilder stringBuilder = new StringBuilder(s.Length + 2);
		stringBuilder.Append('"');
		foreach (char c in s)
		{
			switch (c)
			{
			case '"':
				stringBuilder.Append("\\\"");
				continue;
			case '\\':
				stringBuilder.Append("\\\\");
				continue;
			case '\n':
				stringBuilder.Append("\\n");
				continue;
			case '\t':
				stringBuilder.Append("\\t");
				continue;
			case '\r':
				continue;
			}
			if (c < ' ')
			{
				stringBuilder.Append($"\\u{(int)c:x4}");
			}
			else
			{
				stringBuilder.Append(c);
			}
		}
		stringBuilder.Append('"');
		return stringBuilder.ToString();
	}

	public void Dispose()
	{
		if (_disposed)
		{
			return;
		}
		_disposed = true;
		_sendTimer?.Dispose();
		if (Interlocked.CompareExchange(ref _sending, 1, 0) == 0)
		{
			try
			{
				TrySendBuffer($"\ud83d\uded1 [{_serverName}] Server Shutdown — {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
			}
			catch
			{
			}
		}
		lock (_fileLock)
		{
			_writer?.Dispose();
			_writer = null;
		}
		_http?.Dispose();
	}
}
public enum LogSource
{
	BepInEx,
	Console,
	RawStdout
}
[BepInPlugin("com.byawn.DiscordLogSync", "DiscordLogSync", "1.0.4")]
public class Plugin : BaseUnityPlugin
{
	public const string GUID = "com.byawn.DiscordLogSync";

	public const string NAME = "DiscordLogSync";

	public const string VERSION = "1.0.4";

	public static ConfigEntry<LogSource> Source;

	public static ConfigEntry<string> WebhookUrl;

	public static ConfigEntry<int> SendIntervalSeconds;

	public static ConfigEntry<int> MaxMessageChars;

	private DiscordLogListener _listener;

	private ConsoleInterceptor _consoleInterceptor;

	private StdoutInterceptor _stdoutInterceptor;

	private TextWriter _originalOut;

	private void Awake()
	{
		Source = ((BaseUnityPlugin)this).Config.Bind<LogSource>("Discord", "Source", LogSource.BepInEx, "Log source. Choose one:\n  BepInEx   — Safe. Captures BepInEx log pipeline. Misses world saves / ZDO counts.\n  Console   — Captures managed Console.Out. Still misses native stdout writes.\n  RawStdout — !! EXPERIMENTAL / HIGH RISK !! Uses pipe()+dup2() (Linux/libc) or\n              _pipe()+_dup2() (Windows/ucrtbase.dll) to intercept stdout at the\n              CRT fd level. Captures EVERYTHING including world saves and ZDO\n              counts. If initialisation fails or the relay thread dies, ALL server\n              stdout will be silently swallowed until the process exits. Only\n              enable this if you need to capture evidence of world-save\n              interruptions and understand the risk.");
		WebhookUrl = ((BaseUnityPlugin)this).Config.Bind<string>("Discord", "WebhookUrl", "", "Your Discord webhook URL. Required.");
		SendIntervalSeconds = ((BaseUnityPlugin)this).Config.Bind<int>("Discord", "SendIntervalSeconds", 3, "How often (in seconds) to flush the buffer to Discord. Minimum 2 (Discord rate limit).");
		MaxMessageChars = ((BaseUnityPlugin)this).Config.Bind<int>("Discord", "MaxMessageChars", 1800, "Max log characters per Discord message (hard limit is 2000).");
		if (string.IsNullOrWhiteSpace(WebhookUrl.Value))
		{
			((BaseUnityPlugin)this).Logger.LogWarning((object)"[DiscordLogSync] No WebhookUrl configured — logging to Discord is disabled.");
			return;
		}
		string serverName = "Valheim";
		string[] commandLineArgs = Environment.GetCommandLineArgs();
		for (int i = 0; i < commandLineArgs.Length - 1; i++)
		{
			if (commandLineArgs[i] == "-name")
			{
				serverName = commandLineArgs[i + 1];
				break;
			}
		}
		_listener = new DiscordLogListener(serverName, WebhookUrl.Value);
		switch (Source.Value)
		{
		case LogSource.BepInEx:
			Logger.Listeners.Add((ILogListener)(object)_listener);
			((BaseUnityPlugin)this).Logger.LogInfo((object)$"[DiscordLogSync] Started (BepInEx source). Flushing every {Math.Max(2, SendIntervalSeconds.Value)}s.");
			break;
		case LogSource.Console:
			_originalOut = Console.Out;
			_consoleInterceptor = new ConsoleInterceptor(Console.Out, _listener);
			Console.SetOut(_consoleInterceptor);
			((BaseUnityPlugin)this).Logger.LogInfo((object)$"[DiscordLogSync] Started (Console source). Flushing every {Math.Max(2, SendIntervalSeconds.Value)}s.");
			break;
		case LogSource.RawStdout:
			((BaseUnityPlugin)this).Logger.LogWarning((object)"[DiscordLogSync] RawStdout source: intercepting stdout at fd level via pipe()+dup2(). If this breaks, ALL server stdout will be lost. See config for details.");
			try
			{
				_stdoutInterceptor = new StdoutInterceptor(_listener);
				((BaseUnityPlugin)this).Logger.LogInfo((object)$"[DiscordLogSync] Started (RawStdout source). Flushing every {Math.Max(2, SendIntervalSeconds.Value)}s.");
				break;
			}
			catch (Exception ex)
			{
				((BaseUnityPlugin)this).Logger.LogError((object)("[DiscordLogSync] RawStdout source failed to initialise: " + ex.Message));
				((BaseUnityPlugin)this).Logger.LogError((object)"[DiscordLogSync] stdout is intact — falling back to BepInEx source.");
				Logger.Listeners.Add((ILogListener)(object)_listener);
				break;
			}
		}
	}

	private void OnDestroy()
	{
		if (_listener == null)
		{
			return;
		}
		switch (Source.Value)
		{
		case LogSource.BepInEx:
			Logger.Listeners.Remove((ILogListener)(object)_listener);
			break;
		case LogSource.Console:
			if (_originalOut != null)
			{
				Console.SetOut(_originalOut);
			}
			_consoleInterceptor?.Dispose();
			break;
		case LogSource.RawStdout:
			_stdoutInterceptor?.Dispose();
			if (_stdoutInterceptor == null)
			{
				Logger.Listeners.Remove((ILogListener)(object)_listener);
			}
			break;
		}
		_listener.Dispose();
	}
}
public class StdoutInterceptor : IDisposable
{
	private readonly DiscordLogListener _listener;

	private readonly bool _isWindows;

	private readonly int _originalStdoutFd;

	private readonly int _pipeReadFd;

	private readonly Thread _relayThread;

	private volatile bool _disposed;

	private const int O_BINARY = 32768;

	[DllImport("libc", SetLastError = true)]
	private static extern int pipe(int[] pipefd);

	[DllImport("libc", SetLastError = true)]
	private static extern int dup(int oldfd);

	[DllImport("libc", SetLastError = true)]
	private static extern int dup2(int oldfd, int newfd);

	[DllImport("libc", SetLastError = true)]
	private static extern int close(int fd);

	[DllImport("libc", SetLastError = true)]
	private static extern int read(int fd, byte[] buf, int count);

	[DllImport("libc", SetLastError = true)]
	private static extern int write(int fd, byte[] buf, int count);

	[DllImport("ucrtbase.dll", CallingConvention = CallingConvention.Cdecl)]
	private static extern int _dup(int fd);

	[DllImport("ucrtbase.dll", CallingConvention = CallingConvention.Cdecl)]
	private static extern int _dup2(int src, int dst);

	[DllImport("ucrtbase.dll", CallingConvention = CallingConvention.Cdecl)]
	private static extern int _pipe(int[] pfds, uint psize, int textmode);

	[DllImport("ucrtbase.dll", CallingConvention = CallingConvention.Cdecl)]
	private static extern int _close(int fd);

	[DllImport("ucrtbase.dll", CallingConvention = CallingConvention.Cdecl)]
	private static extern int _read(int fd, byte[] buf, uint count);

	[DllImport("ucrtbase.dll", CallingConvention = CallingConvention.Cdecl)]
	private static extern int _write(int fd, byte[] buf, uint count);

	public StdoutInterceptor(DiscordLogListener listener)
	{
		_listener = listener;
		_isWindows = Environment.OSVersion.Platform != PlatformID.Unix;
		int[] array = new int[2];
		if (_isWindows)
		{
			_originalStdoutFd = _dup(1);
			if (_originalStdoutFd < 0)
			{
				throw new InvalidOperationException("[DiscordLogSync] StdoutInterceptor: _dup(1) failed. stdout unchanged.");
			}
			if (_pipe(array, 65536u, 32768) < 0)
			{
				_close(_originalStdoutFd);
				throw new InvalidOperationException("[DiscordLogSync] StdoutInterceptor: _pipe() failed. stdout unchanged.");
			}
			if (_dup2(array[1], 1) < 0)
			{
				_close(_originalStdoutFd);
				_close(array[0]);
				_close(array[1]);
				throw new InvalidOperationException("[DiscordLogSync] StdoutInterceptor: _dup2() failed. stdout unchanged.");
			}
			_close(array[1]);
		}
		else
		{
			_originalStdoutFd = dup(1);
			if (_originalStdoutFd < 0)
			{
				throw new InvalidOperationException($"[DiscordLogSync] StdoutInterceptor: dup(1) failed (errno {Marshal.GetLastWin32Error()}). stdout unchanged.");
			}
			if (pipe(array) < 0)
			{
				close(_originalStdoutFd);
				throw new InvalidOperationException($"[DiscordLogSync] StdoutInterceptor: pipe() failed (errno {Marshal.GetLastWin32Error()}). stdout unchanged.");
			}
			if (dup2(array[1], 1) < 0)
			{
				close(_originalStdoutFd);
				close(array[0]);
				close(array[1]);
				throw new InvalidOperationException($"[DiscordLogSync] StdoutInterceptor: dup2() failed (errno {Marshal.GetLastWin32Error()}). stdout unchanged.");
			}
			close(array[1]);
		}
		_pipeReadFd = array[0];
		_relayThread = new Thread(RelayLoop)
		{
			IsBackground = true,
			Name = "DiscordLogSync-StdoutRelay"
		};
		_relayThread.Start();
	}

	private void RelayLoop()
	{
		byte[] array = new byte[4096];
		List<byte> list = new List<byte>(256);
		while (true)
		{
			int num = (_isWindows ? _read(_pipeReadFd, array, (uint)array.Length) : read(_pipeReadFd, array, array.Length));
			if (num <= 0)
			{
				break;
			}
			if (_isWindows)
			{
				_write(_originalStdoutFd, array, (uint)num);
			}
			else
			{
				write(_originalStdoutFd, array, num);
			}
			for (int i = 0; i < num; i++)
			{
				if (array[i] == 10)
				{
					if (list.Count > 0 && list[list.Count - 1] == 13)
					{
						list.RemoveAt(list.Count - 1);
					}
					_listener.WriteToBuffer(Encoding.UTF8.GetString(list.ToArray()));
					list.Clear();
				}
				else
				{
					list.Add(array[i]);
				}
			}
		}
		if (list.Count > 0)
		{
			_listener.WriteToBuffer(Encoding.UTF8.GetString(list.ToArray()));
		}
		if (_isWindows)
		{
			_close(_pipeReadFd);
		}
		else
		{
			close(_pipeReadFd);
		}
	}

	public void Dispose()
	{
		if (!_disposed)
		{
			_disposed = true;
			if (_isWindows)
			{
				_dup2(_originalStdoutFd, 1);
				_close(_originalStdoutFd);
			}
			else
			{
				dup2(_originalStdoutFd, 1);
				close(_originalStdoutFd);
			}
			_relayThread?.Join(2000);
		}
	}
}