diff --git a/Lagrange.OneBot/Core/Entity/MessageConverter.cs b/Lagrange.OneBot/Core/Entity/MessageConverter.cs deleted file mode 100644 index 0111644..0000000 --- a/Lagrange.OneBot/Core/Entity/MessageConverter.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Lagrange.OneBot.Core.Entity; - -public class MessageConverter -{ - -} \ No newline at end of file diff --git a/Lagrange.OneBot/Core/Entity/Meta/OneBotHeartBeat.cs b/Lagrange.OneBot/Core/Entity/Meta/OneBotHeartBeat.cs new file mode 100644 index 0000000..1b04472 --- /dev/null +++ b/Lagrange.OneBot/Core/Entity/Meta/OneBotHeartBeat.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Lagrange.OneBot.Core.Entity.Meta; + +[Serializable] +public class OneBotHeartBeat : OneBotMeta +{ + [JsonPropertyName("interval")] public int Interval { get; set; } + + [JsonPropertyName("status")] public object Status { get; set; } + + public OneBotHeartBeat(uint selfId, int interval, object status) : base(selfId, "heartbeat") + { + Interval = interval; + Status = status; + } +} \ No newline at end of file diff --git a/Lagrange.OneBot/Core/Entity/Meta/OneBotLifecycle.cs b/Lagrange.OneBot/Core/Entity/Meta/OneBotLifecycle.cs new file mode 100644 index 0000000..f8ab222 --- /dev/null +++ b/Lagrange.OneBot/Core/Entity/Meta/OneBotLifecycle.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Lagrange.OneBot.Core.Entity.Meta; + +[Serializable] +public class OneBotLifecycle : OneBotMeta +{ + [JsonPropertyName("sub_type")] public string SubType { get; set; } + + public OneBotLifecycle(uint selfId, string subType) : base(selfId, "lifecycle") + { + SubType = subType; + } +} \ No newline at end of file diff --git a/Lagrange.OneBot/Core/Entity/Meta/OneBotMeta.cs b/Lagrange.OneBot/Core/Entity/Meta/OneBotMeta.cs new file mode 100644 index 0000000..afaa8d7 --- /dev/null +++ b/Lagrange.OneBot/Core/Entity/Meta/OneBotMeta.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace Lagrange.OneBot.Core.Entity.Meta; + +[Serializable] +public abstract class OneBotMeta : OneBotEntityBase +{ + [JsonPropertyName("meta_event_type")] public string MetaEventType { get; set; } + + protected OneBotMeta(uint selfId, string metaEventType) : base(selfId, "meta_event") + { + MetaEventType = metaEventType; + } +} \ No newline at end of file diff --git a/Lagrange.OneBot/Core/Entity/Meta/OneBotStatus.cs b/Lagrange.OneBot/Core/Entity/Meta/OneBotStatus.cs new file mode 100644 index 0000000..5b1e14c --- /dev/null +++ b/Lagrange.OneBot/Core/Entity/Meta/OneBotStatus.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace Lagrange.OneBot.Core.Entity.Meta; + +[Serializable] +public class OneBotStatus +{ + [JsonPropertyName("app_initialized")] public bool AppInitialized { get; set; } + + [JsonPropertyName("app_enabled")] public bool AppEnabled { get; set; } + + [JsonPropertyName("app_good")] public bool AppGood { get; set; } + + [JsonPropertyName("online")] public bool Online { get; set; } + + [JsonPropertyName("good")] public bool Good { get; set; } + + public OneBotStatus(bool online, bool good) + { + AppInitialized = true; + AppEnabled = true; + AppGood = true; + Online = online; + Good = good; + } +} \ No newline at end of file diff --git a/Lagrange.OneBot/Core/Entity/OneBotEntityBase.cs b/Lagrange.OneBot/Core/Entity/OneBotEntityBase.cs new file mode 100644 index 0000000..7d77073 --- /dev/null +++ b/Lagrange.OneBot/Core/Entity/OneBotEntityBase.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace Lagrange.OneBot.Core.Entity; + +[Serializable] +public abstract class OneBotEntityBase +{ + [JsonPropertyName("time")] public long Time { get; set; } + + [JsonPropertyName("self_id")] public uint SelfId { get; set; } + + [JsonPropertyName("post_type")] public string PostType { get; set; } + + protected OneBotEntityBase(uint selfId, string postType) + { + Time = DateTimeOffset.Now.ToUnixTimeMilliseconds(); + SelfId = selfId; + PostType = postType; + } +} \ No newline at end of file diff --git a/Lagrange.OneBot/Core/ILagrangeWebService.cs b/Lagrange.OneBot/Core/ILagrangeWebService.cs new file mode 100644 index 0000000..34a9615 --- /dev/null +++ b/Lagrange.OneBot/Core/ILagrangeWebService.cs @@ -0,0 +1,8 @@ +using Microsoft.Extensions.Hosting; + +namespace Lagrange.OneBot.Core; + +public interface ILagrangeWebService : IHostedService +{ + public ValueTask SendJsonAsync(T json, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/Lagrange.OneBot/Core/MessageConverter.cs b/Lagrange.OneBot/Core/MessageConverter.cs new file mode 100644 index 0000000..572a6c2 --- /dev/null +++ b/Lagrange.OneBot/Core/MessageConverter.cs @@ -0,0 +1,6 @@ +namespace Lagrange.OneBot.Core; + +public class MessageConverter +{ + +} \ No newline at end of file diff --git a/Lagrange.OneBot/Core/Network/WebSocket.cs b/Lagrange.OneBot/Core/Network/WebSocket.cs deleted file mode 100644 index 55cc6ed..0000000 --- a/Lagrange.OneBot/Core/Network/WebSocket.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Net.Sockets; - -namespace Lagrange.OneBot.Core.Network; - -/// -/// Provide a simple WebSocket client and server -/// -internal class WebSocket : IDisposable -{ - private readonly Socket _socket; - - private readonly Thread _receiveLoop; - - public bool IsConnected => _socket.Connected; - - public event MessageReceivedEventHandler? OnMessageReceived; - - public delegate void MessageReceivedEventHandler(WebSocket sender, byte[] data); - - public WebSocket() - { - _socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); - _receiveLoop = new Thread(() => - { - while (_socket.Connected) - { - var buffer = new byte[1024 * 1024 * 64]; // max 64MB - int length = _socket.Receive(buffer); - if (length > 0) - { - var data = buffer[..length]; - OnMessageReceived?.Invoke(this, data); - } - } - }); - } - - public async Task ConnectAsync(string host, int port) - { - await _socket.ConnectAsync(host, port); - return true; - } - - public async Task SendAsync(byte[] data) - { - await _socket.SendAsync(data, SocketFlags.None); - return true; - } - - public void Dispose() - { - _socket.Disconnect(false); // The thread will exit automatically - _socket.Dispose(); - GC.SuppressFinalize(this); - } -} \ No newline at end of file diff --git a/Lagrange.OneBot/Core/Service/ReverseWSService.cs b/Lagrange.OneBot/Core/Service/ReverseWSService.cs new file mode 100644 index 0000000..d4bfd64 --- /dev/null +++ b/Lagrange.OneBot/Core/Service/ReverseWSService.cs @@ -0,0 +1,101 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using Lagrange.Core.Utility.Extension; +using Lagrange.OneBot.Core.Entity.Meta; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Timer = System.Threading.Timer; + +namespace Lagrange.OneBot.Core.Service; + +public class ReverseWSService : ILagrangeWebService +{ + private readonly ClientWebSocket _socket; + + private readonly IConfiguration _config; + + private readonly ILogger _logger; + + private readonly Timer _timer; + + public ReverseWSService(IConfiguration config, ILogger logger) + { + _config = config; + _logger = logger; + _socket = new ClientWebSocket(); + _timer = new Timer(OnHeartbeat, null, int.MaxValue, config.GetValue("Implementation:ReverseWebSocket:HeartBeatInterval")); + + _ = ReceiveLoop(); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + await ConnectAsync(_config.GetValue("Account:Uin"), cancellationToken); + + var lifecycle = new OneBotLifecycle(_config.GetValue("Account:Uin"), "connect"); + await SendJsonAsync(lifecycle, cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _timer.Dispose(); + _socket.Dispose(); + + return Task.CompletedTask; + } + + public ValueTask SendJsonAsync(T json, CancellationToken cancellationToken = default) + { + var payload = JsonSerializer.SerializeToUtf8Bytes(json); + return _socket.SendAsync(payload.AsMemory(), WebSocketMessageType.Text, true, cancellationToken); + } + + private async Task ConnectAsync(uint botUin, CancellationToken cancellationToken = default) + { + _logger.LogInformation("Connecting to reverse websocket..."); + + var config = _config.GetSection("Implementation").GetSection("ReverseWebSocket"); + string url = $"ws://{config["Host"]}:{config["Port"]}{config["Suffix"]}"; + + _socket.Options.SetRequestHeader("X-Client-Role", "Universal"); + _socket.Options.SetRequestHeader("X-Self-ID", botUin.ToString()); + _socket.Options.SetRequestHeader("User-Agent", Constant.OneBotImpl); + if (_config["AccessToken"] != null) _socket.Options.SetRequestHeader("Authorization", $"Bearer {_config["AccessToken"]}"); + + await _socket.ConnectAsync(new Uri(url), cancellationToken); + _timer.Change(0, _config.GetValue("Implementation:ReverseWebSocket:HeartBeatInterval")); + + return true; + } + + private void OnHeartbeat(object? sender) + { + var status = new OneBotStatus(true, true); + var heartBeat = new OneBotHeartBeat(_config.GetValue("Account:Uin"), _config.GetValue("Implementation:ReverseWebSocket:HeartBeatInterval"), status); + + SendJsonAsync(heartBeat); + } + + private async Task ReceiveLoop() + { + while (true) + { + await Task.CompletedTask.ForceAsync(); + + if (_socket.State == WebSocketState.Open) + { + var buffer = new byte[64 * 1024 * 1024]; // 64MB + var result = await _socket.ReceiveAsync(buffer.AsMemory(), CancellationToken.None); + if (result.MessageType == WebSocketMessageType.Close) + { + await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None); + break; + } + + string payload = Encoding.UTF8.GetString(buffer[..result.Count]); + _logger.LogInformation(payload); + } + } + } +} \ No newline at end of file diff --git a/Lagrange.OneBot/LagrangeApp.cs b/Lagrange.OneBot/LagrangeApp.cs index f0e14a4..909510f 100644 --- a/Lagrange.OneBot/LagrangeApp.cs +++ b/Lagrange.OneBot/LagrangeApp.cs @@ -1,6 +1,8 @@ using System.Text.Json; using Lagrange.Core; using Lagrange.Core.Common.Interface.Api; +using Lagrange.OneBot.Core; +using Lagrange.OneBot.Core.Entity.Meta; using Lagrange.OneBot.Utility; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -22,6 +24,8 @@ public class LagrangeApp : IHost public BotContext Instance => Services.GetRequiredService(); + public ILagrangeWebService WebService => Services.GetRequiredService(); + internal LagrangeApp(IHost host) { _hostApp = host; @@ -30,6 +34,7 @@ public class LagrangeApp : IHost public async Task StartAsync(CancellationToken cancellationToken = new()) { + await _hostApp.StartAsync(cancellationToken); Logger.LogInformation("Lagrange.OneBot Implementation has started"); Logger.LogInformation($"Protocol: {Configuration.GetValue("Protocol")} | {Instance.ContextCollection.AppInfo.CurrentVersion}"); @@ -43,15 +48,20 @@ public class LagrangeApp : IHost _ => Microsoft.Extensions.Logging.LogLevel.Error }, args.ToString()); - Instance.Invoker.OnBotOnlineEvent += (_, args) => + Instance.Invoker.OnBotOnlineEvent += async (_, args) => { var keystore = Instance.UpdateKeystore(); Logger.LogInformation($"Bot Online: {keystore.Uin}"); string json = JsonSerializer.Serialize(keystore, new JsonSerializerOptions { WriteIndented = true }); - File.WriteAllText(Configuration["ConfigPath:Keystore"] ?? "keystore.json", json); + await File.WriteAllTextAsync(Configuration["ConfigPath:Keystore"] ?? "keystore.json", json, cancellationToken); + + var lifecycle = new OneBotLifecycle(keystore.Uin, "enable"); + await WebService.SendJsonAsync(lifecycle, cancellationToken); }; - + + await WebService.StartAsync(cancellationToken); + if (Configuration.GetValue("Account:Uin") == 0) { var qrCode = await Instance.FetchQrCode(); @@ -76,8 +86,6 @@ public class LagrangeApp : IHost await Instance.LoginByPassword(); } - - await _hostApp.StartAsync(cancellationToken); } public async Task StopAsync(CancellationToken cancellationToken = new()) diff --git a/Lagrange.OneBot/LagrangeAppBuilder.cs b/Lagrange.OneBot/LagrangeAppBuilder.cs index a81482e..fedb7e1 100644 --- a/Lagrange.OneBot/LagrangeAppBuilder.cs +++ b/Lagrange.OneBot/LagrangeAppBuilder.cs @@ -1,6 +1,8 @@ using System.Text.Json; using Lagrange.Core.Common; using Lagrange.Core.Common.Interface; +using Lagrange.OneBot.Core; +using Lagrange.OneBot.Core.Service; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -60,10 +62,12 @@ public sealed class LagrangeAppBuilder return this; } - - public LagrangeApp Build() + + public LagrangeAppBuilder ConfigureOneBot() { - var app = new LagrangeApp(_hostAppBuilder.Build()); - return app; + Services.AddSingleton(); + return this; } + + public LagrangeApp Build() => new(_hostAppBuilder.Build()); } \ No newline at end of file diff --git a/Lagrange.OneBot/Program.cs b/Lagrange.OneBot/Program.cs index 83538a9..9af10dd 100644 --- a/Lagrange.OneBot/Program.cs +++ b/Lagrange.OneBot/Program.cs @@ -12,7 +12,8 @@ internal abstract class Program var hostBuilder = new LagrangeAppBuilder(args) .ConfigureConfiguration("appsettings.json", false, true) - .ConfigureBots(); + .ConfigureBots() + .ConfigureOneBot(); hostBuilder.Build().Run(); } diff --git a/Lagrange.OneBot/Utility/TaskAwaitExt.cs b/Lagrange.OneBot/Utility/TaskAwaitExt.cs new file mode 100644 index 0000000..ef07a0d --- /dev/null +++ b/Lagrange.OneBot/Utility/TaskAwaitExt.cs @@ -0,0 +1,34 @@ +using System.Runtime.CompilerServices; + +namespace Lagrange.OneBot.Utility; + +internal static class TaskAwaiters +{ + /// + /// Returns an awaitable/awaiter that will ensure the continuation is executed + /// asynchronously on the thread pool, even if the task is already completed + /// by the time the await occurs. Effectively, it is equivalent to awaiting + /// with ConfigureAwait(false) and then queuing the continuation with Task.Run, + /// but it avoids the extra hop if the continuation already executed asynchronously. + /// + public static ForceAsyncAwaiter ForceAsync(this Task task) => new(task); +} + +internal readonly struct ForceAsyncAwaiter : ICriticalNotifyCompletion +{ + private readonly Task _task; + + internal ForceAsyncAwaiter(Task task) => _task = task; + + public ForceAsyncAwaiter GetAwaiter() => this; + + public bool IsCompleted => false; // the purpose of this type is to always force a continuation + + public void GetResult() => _task.GetAwaiter().GetResult(); + + public void OnCompleted(Action action) => + _task.ConfigureAwait(false).GetAwaiter().OnCompleted(action); + + public void UnsafeOnCompleted(Action action) => + _task.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(action); +} \ No newline at end of file