From b9df955e8ce90d17eb9c2214cce4fee120e502d1 Mon Sep 17 00:00:00 2001 From: Kengwang Date: Fri, 5 Jun 2026 23:17:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E9=97=AA=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/upload_flash_transfer` --- Lagrange.Core/Common/Interface/MessageExt.cs | 6 +- .../Common/Response/BotFlashTransferUpload.cs | 10 + .../Internal/Context/FlashTransferContext.cs | 48 ++- .../Events/System/FlashTransferEvent.cs | 181 ++++++++ .../Internal/Logic/OperationLogic.cs | 84 +++- .../Internal/Packets/Service/FlashTransfer.cs | 388 ++++++++++++++++++ .../Packets/Service/FlashTransferUploadReq.cs | 9 +- .../Services/System/FlashTransferService.cs | 302 ++++++++++++++ .../File/UploadFlashTransferHandler.cs | 66 +++ Lagrange.Milky/Utility/JsonUtility.cs | 4 + 10 files changed, 1080 insertions(+), 18 deletions(-) create mode 100644 Lagrange.Core/Common/Response/BotFlashTransferUpload.cs create mode 100644 Lagrange.Core/Internal/Events/System/FlashTransferEvent.cs create mode 100644 Lagrange.Core/Internal/Packets/Service/FlashTransfer.cs create mode 100644 Lagrange.Core/Internal/Services/System/FlashTransferService.cs create mode 100644 Lagrange.Milky/Api/Handler/File/UploadFlashTransferHandler.cs diff --git a/Lagrange.Core/Common/Interface/MessageExt.cs b/Lagrange.Core/Common/Interface/MessageExt.cs index 207efbc6..db689634 100644 --- a/Lagrange.Core/Common/Interface/MessageExt.cs +++ b/Lagrange.Core/Common/Interface/MessageExt.cs @@ -1,5 +1,6 @@ using Lagrange.Core.Internal.Logic; using Lagrange.Core.Message; +using Lagrange.Core.Common.Response; namespace Lagrange.Core.Common.Interface; @@ -32,6 +33,9 @@ public static Task> GetC2CMessage(this BotContext context, long public static Task SendGroupFile(this BotContext context, long groupUin, Stream fileStream, string? fileName = null, string parentDirectory = "/") => context.EventContext.GetLogic().SendGroupFile(groupUin, fileStream, fileName, parentDirectory); + public static Task UploadFlashTransfer(this BotContext context, IReadOnlyList<(Stream Stream, string? FileName)> files, string? title = null) + => context.EventContext.GetLogic().UploadFlashTransfer(files, title); + public static Task RecallMessage(this BotContext context, BotMessage message) => context.EventContext.GetLogic().RecallMessage(message); @@ -61,4 +65,4 @@ public static Task GroupRename(this BotContext context, long groupUin, string na public static Task GroupQuit(this BotContext context, long groupUin) => context.EventContext.GetLogic().GroupQuit(groupUin); -} \ No newline at end of file +} diff --git a/Lagrange.Core/Common/Response/BotFlashTransferUpload.cs b/Lagrange.Core/Common/Response/BotFlashTransferUpload.cs new file mode 100644 index 00000000..c67abf3f --- /dev/null +++ b/Lagrange.Core/Common/Response/BotFlashTransferUpload.cs @@ -0,0 +1,10 @@ +namespace Lagrange.Core.Common.Response; + +public class BotFlashTransferUpload(string fileSetId, List fileIds, string shareLink) +{ + public string FileSetId { get; } = fileSetId; + + public List FileIds { get; } = fileIds; + + public string ShareLink { get; } = shareLink; +} diff --git a/Lagrange.Core/Internal/Context/FlashTransferContext.cs b/Lagrange.Core/Internal/Context/FlashTransferContext.cs index f9d248a6..7fd7b07d 100644 --- a/Lagrange.Core/Internal/Context/FlashTransferContext.cs +++ b/Lagrange.Core/Internal/Context/FlashTransferContext.cs @@ -1,4 +1,4 @@ -using System.Security.Cryptography; +using System.Security.Cryptography; using Lagrange.Core.Internal.Packets.Service; using Lagrange.Core.Utility; using Lagrange.Core.Utility.Cryptography; @@ -23,6 +23,28 @@ internal FlashTransferContext(BotContext botContext) } public async Task UploadFile(string uKey, uint appId, Stream bodyStream) + { + return await UploadFile(uKey, appId, 2, bodyStream, null, _url); + } + + public async Task UploadFile(string uploadToken, string uploadHost, uint appId, uint uploadIndex, string fileSetId, string fileId, uint bindingStage, uint fileType, uint bindingField5, uint bindingField6, Stream bodyStream) + { + var binding = new FlashTransferUploadFileBinding + { + FileSetId = fileSetId, + FileSetIdDup = fileSetId, + FileId = fileId, + Stage = bindingStage, + Field5 = bindingField5, + Field6 = bindingField6, + FileType = fileType, + FileIdDup = fileId, + }; + + return await UploadFile(uploadToken, appId, uploadIndex, bodyStream, binding, $"https://{uploadHost}/sliceupload"); + } + + private async Task UploadFile(string uKey, uint appId, uint uploadIndex, Stream bodyStream, FlashTransferUploadFileBinding? binding, string? url) { var sha1StateVs = new FlashTransferSha1StateV { State = [] }; var chunkCount = (uint)((bodyStream.Length + ChunkSize - 1) / ChunkSize); @@ -61,33 +83,35 @@ public async Task UploadFile(string uKey, uint appId, Stream bodyStream) var uploadBuffer = new byte[chunkLength]; await bodyStream.ReadExactlyAsync(uploadBuffer, 0, chunkLength); - var success = await UploadChunk(uKey, appId, (uint)chunkStart, sha1StateVs, uploadBuffer); + var success = await UploadChunk(uKey, appId, uploadIndex, (uint)chunkStart, sha1StateVs, uploadBuffer, binding, url); if (!success) return false; } return true; } - private async Task UploadChunk(string uKey, uint appId, uint start, FlashTransferSha1StateV chunkSha1S, byte[] body) + private async Task UploadChunk(string uKey, uint appId, uint uploadIndex, uint start, FlashTransferSha1StateV chunkSha1S, byte[] body, FlashTransferUploadFileBinding? binding, string? url) { + byte[] chunkSha1 = SHA1.HashData(body); var req = new FlashTransferUploadReq { - FieId1 = 0, + FileId = 0, AppId = appId, - FileId3 = 2, + UploadIndex = uploadIndex, Body = new FlashTransferUploadBody { - FieId1 = [], + FileId = [], UKey = uKey, Start = start, End = (uint)(start + body.Length - 1), - Sha1 = SHA1.HashData(body), - Sha1StateV = chunkSha1S, - Body = body + Sha1 = chunkSha1, + Sha1StateV = binding == null ? chunkSha1S : new FlashTransferSha1StateV { State = [chunkSha1] }, + Body = body, + FileBinding = binding } }; var payload = ProtoHelper.Serialize(req).ToArray(); - var request = new HttpRequestMessage(HttpMethod.Post, _url) + var request = new HttpRequestMessage(HttpMethod.Post, url) { Headers = { @@ -104,10 +128,10 @@ private async Task UploadChunk(string uKey, uint appId, uint start, FlashT if (resp.Status != "success") { _botContext.LogError(Tag, - $"FlashTransfer Upload chunk {start} failed: {resp.Status}"); + $"FlashTransfer Upload chunk {start} failed: {resp.Status} appId: {appId}, uploadIndex: {uploadIndex}, keyLength: {uKey?.Length ?? 0}"); return false; } return true; } -} \ No newline at end of file +} diff --git a/Lagrange.Core/Internal/Events/System/FlashTransferEvent.cs b/Lagrange.Core/Internal/Events/System/FlashTransferEvent.cs new file mode 100644 index 00000000..5a7d36c4 --- /dev/null +++ b/Lagrange.Core/Internal/Events/System/FlashTransferEvent.cs @@ -0,0 +1,181 @@ +using Lagrange.Core.Utility.Extension; + +namespace Lagrange.Core.Internal.Events.System; + +internal class FlashTransferFile(string fileId, uint index, string fileName, Stream stream) +{ + public const uint DefaultFileType = 11; + + public string FileId { get; } = fileId; + + public uint Index { get; } = index; + + public string FileName { get; } = fileName; + + public uint FileType { get; } = ResolveFileType(fileName); + + public FlashTransferFileCategory Category => ResolveCategory(FileType); + + public Stream Stream { get; } = stream; + + public byte[] FileSha1 { get; } = stream.Sha1(); + + public byte[] FileMd5 { get; } = stream.Md5(); + + + private static uint ResolveFileType(string fileName) + { + return Path.GetExtension(fileName).ToLowerInvariant() switch + { + ".mp3" or ".wav" or ".aac" or ".flac" => 1, + ".mp4" or ".avi" or ".mkv" or ".mov" or ".3gp" or ".mpeg" or ".rmvb" or ".rm" or ".wmv" or ".flv" or ".asf" or ".webm" or ".mpg" or ".vob" or ".m4v" or ".f4v" => 2, + ".doc" or ".docx" => 3, + ".zip" or ".rar" or ".tar" or ".gz" => 4, + ".apk" => 5, + ".xls" or ".xlsx" => 6, + ".ppt" or ".pptx" => 7, + ".html" or ".htm" => 8, + ".pdf" => 9, + ".txt" => 10, + ".psd" => 12, + ".pt" or ".pth" or ".onnx" or ".model" or ".mlmodel" => 15, + ".ttf" or ".otf" => 16, + ".ipa" => 17, + ".key" => 18, + ".note" => 19, + ".numbers" => 20, + ".pages" => 21, + ".sketch" => 22, + ".dmg" => 23, + ".pkg" => 24, + ".jpg" or ".jpeg" or ".png" or ".gif" or ".bmp" or ".webp" or ".heic" or ".heif" or ".dib" or ".ico" or ".avif" or ".tif" or ".tiff" => 26, + _ => DefaultFileType + }; + } + + private static FlashTransferFileCategory ResolveCategory(uint fileType) + { + return fileType switch + { + 3 or 6 or 7 or 9 or 10 or 13 or 18 or 19 or 20 or 21 => FlashTransferFileCategory.Document, + 26 => FlashTransferFileCategory.Image, + 2 => FlashTransferFileCategory.Video, + 4 => FlashTransferFileCategory.Archive, + 25 => FlashTransferFileCategory.Folder, + _ => FlashTransferFileCategory.Other + }; + } + + private static string ResolveCategoryName(FlashTransferFileCategory category) + { + return category switch + { + FlashTransferFileCategory.Document => "文档", + FlashTransferFileCategory.Image => "图片", + FlashTransferFileCategory.Video => "视频", + FlashTransferFileCategory.Archive => "压缩包", + FlashTransferFileCategory.Folder => "文件夹", + _ => "其他" + }; + } +} + +internal enum FlashTransferFileCategory : uint +{ + Document = 1, + Image = 2, + Video = 3, + Archive = 4, + Folder = 5, + Other = 6 +} + + +internal class FlashTransferCreateFileSetEventReq(string title, string asciiTitle, List files) : ProtocolEvent +{ + public string Title { get; } = title; + + public string AsciiTitle { get; } = asciiTitle; + + public List Files { get; } = files; + +} + +internal class FlashTransferCreateFileSetEventResp(string fileSetId, string shareLink) : ProtocolEvent +{ + public string FileSetId { get; } = fileSetId; + + public string ShareLink { get; } = shareLink; +} + +internal class FlashTransferRegisterFilesEventReq(string fileSetId, List files) : ProtocolEvent +{ + public string FileSetId { get; } = fileSetId; + + public List Files { get; } = files; +} + +internal class FlashTransferRegisterFilesEventResp : ProtocolEvent; + +internal class FlashTransferQueryFileSetStatusEventReq(string fileSetId) : ProtocolEvent +{ + public string FileSetId { get; } = fileSetId; +} + +internal class FlashTransferQueryFileSetStatusEventResp : ProtocolEvent; + +internal class FlashTransferUploadAuthorizeEventReq(string fileSetId, FlashTransferFile file, uint scene) : ProtocolEvent +{ + public string FileSetId { get; } = fileSetId; + + public FlashTransferFile File { get; } = file; + + public uint Scene { get; } = scene; +} + +internal class FlashTransferUploadAuthorizeEventResp(string uploadToken, string resourceKey, uint appId, string uploadHost, uint chunkSize, uint bindingStage, uint bindingField5, uint bindingField6) : ProtocolEvent +{ + public string UploadToken { get; } = uploadToken; + + public string ResourceKey { get; } = resourceKey; + + public uint AppId { get; } = appId; + + public string UploadHost { get; } = uploadHost; + + public uint ChunkSize { get; } = chunkSize; + + public uint BindingStage { get; } = bindingStage; + + public uint BindingField5 { get; } = bindingField5; + + public uint BindingField6 { get; } = bindingField6; +} + +internal class FlashTransferUploadCompleteEventReq(string fileSetId, FlashTransferFile file, string resourceKey, uint scene, uint bindingStage, uint bindingField5, uint bindingField6) : ProtocolEvent +{ + public string FileSetId { get; } = fileSetId; + + public FlashTransferFile File { get; } = file; + + public string ResourceKey { get; } = resourceKey; + + public uint Scene { get; } = scene; + + public uint BindingStage { get; } = bindingStage; + + public uint BindingField5 { get; } = bindingField5; + + public uint BindingField6 { get; } = bindingField6; +} + +internal class FlashTransferUploadCompleteEventResp : ProtocolEvent; + +internal class FlashTransferUpdateFileSetStatusEventReq(string fileSetId, uint status) : ProtocolEvent +{ + public string FileSetId { get; } = fileSetId; + + public uint Status { get; } = status; +} + +internal class FlashTransferUpdateFileSetStatusEventResp : ProtocolEvent; diff --git a/Lagrange.Core/Internal/Logic/OperationLogic.cs b/Lagrange.Core/Internal/Logic/OperationLogic.cs index a9203bc9..5ef9122b 100644 --- a/Lagrange.Core/Internal/Logic/OperationLogic.cs +++ b/Lagrange.Core/Internal/Logic/OperationLogic.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.Security.Cryptography; using Lagrange.Core.Common.Entity; +using Lagrange.Core.Common.Response; using Lagrange.Core.Exceptions; using Lagrange.Core.Internal.Events.Message; using Lagrange.Core.Internal.Events.System; @@ -241,6 +242,87 @@ public async Task SendGroupFile(long groupUin, Stream fileStream, string return uploadResp.FileId; } + public async Task UploadFlashTransfer(IReadOnlyList<(Stream Stream, string? FileName)> files, string? title) + { + if (files.Count == 0) throw new OperationException(-1, "No files to upload"); + + var flashTransferFiles = files.Select((file, index) => + { + string fileName = ResolveFileName(file.Stream, file.FileName); + return new FlashTransferFile( + Guid.NewGuid().ToString(), + (uint)(index + 1), + fileName, + file.Stream); + }).ToList(); + + string fileSetTitle = string.IsNullOrEmpty(title) ? ResolveFlashTransferTitle(flashTransferFiles) : title; + string asciiTitle = ResolveAsciiTitle(fileSetTitle); + var createReq = new FlashTransferCreateFileSetEventReq(fileSetTitle, asciiTitle, flashTransferFiles); + var createResp = await context.EventContext.SendEvent(createReq); + + await context.EventContext.SendEvent(new FlashTransferRegisterFilesEventReq(createResp.FileSetId, flashTransferFiles)); + + await context.EventContext.SendEvent(new FlashTransferQueryFileSetStatusEventReq(createResp.FileSetId)); + + uint uploadScene = 3; + foreach (var file in flashTransferFiles) + { + var authorize = await context.EventContext.SendEvent(new FlashTransferUploadAuthorizeEventReq(createResp.FileSetId, file, uploadScene++)); + + if (string.IsNullOrEmpty(authorize.UploadToken) && string.IsNullOrEmpty(authorize.ResourceKey)) + { + context.LogWarning(Tag, + "FlashTransfer authorize returned neither upload token nor resource key: fileName={0}, fileId={1}, index={2}, size={3}, host={4}, appId={5}, bindingStage={6}, bindingField5={7}, bindingField6={8}", + null, + file.FileName, + file.FileId, + file.Index, + file.Stream.Length, + authorize.UploadHost, + authorize.AppId, + authorize.BindingStage, + authorize.BindingField5, + authorize.BindingField6); + throw new OperationException(-1, $"FlashTransfer upload authorization did not return an upload token or resource key for file {file.FileName}"); + } + + uint bindingStage = authorize.BindingStage == 0 ? file.Index : authorize.BindingStage; + if (!string.IsNullOrEmpty(authorize.UploadToken)) + { + bool success = await context.FlashTransferContext.UploadFile(authorize.UploadToken, authorize.UploadHost, authorize.AppId, file.Index, createResp.FileSetId, file.FileId, bindingStage, file.FileType, authorize.BindingField5, authorize.BindingField6, file.Stream); + if (!success) throw new OperationException(-1, "FlashTransfer file upload failed"); + } + + await context.EventContext.SendEvent(new FlashTransferUploadCompleteEventReq(createResp.FileSetId, file, authorize.ResourceKey, uploadScene++, bindingStage, authorize.BindingField5, authorize.BindingField6)); + } + + await context.EventContext.SendEvent(new FlashTransferUpdateFileSetStatusEventReq(createResp.FileSetId, 6)); + + return new BotFlashTransferUpload( + createResp.FileSetId, + flashTransferFiles.Select(file => file.FileId).ToList(), + createResp.ShareLink); + } + + private static string ResolveFlashTransferTitle(IReadOnlyList files) + { + string firstName = Path.GetFileNameWithoutExtension(files[0].FileName); + return files.Count == 1 ? files[0].FileName : $"{firstName}等{files.Count}项文件"; + } + + internal static string ResolveAsciiTitle(string title) + { + Span chars = stackalloc char[title.Length]; + int length = 0; + foreach (char c in title) + { + chars[length++] = c < 128 && !char.IsWhiteSpace(c) ? c : '_'; + } + + return new string(chars[..length]); + } + public async Task FetchGroupExtra(long groupUin) { var req = new FetchGroupExtraEventReq(groupUin); @@ -395,4 +477,4 @@ public async Task GetNTV2RichMediaUrl(string uuid) }; } } -} \ No newline at end of file +} diff --git a/Lagrange.Core/Internal/Packets/Service/FlashTransfer.cs b/Lagrange.Core/Internal/Packets/Service/FlashTransfer.cs new file mode 100644 index 00000000..03edea09 --- /dev/null +++ b/Lagrange.Core/Internal/Packets/Service/FlashTransfer.cs @@ -0,0 +1,388 @@ +using Lagrange.Proto; + +namespace Lagrange.Core.Internal.Packets.Service; + +#pragma warning disable CS8618 + +[ProtoPackable] +internal partial class FlashTransferCreateFileSetRequest +{ + [ProtoMember(1)] public uint Scene { get; set; } + + [ProtoMember(2)] public FlashTransferFileSetCreateInfo FileSet { get; set; } + + [ProtoMember(3)] public uint ClientType { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferFileSetCreateInfo +{ + [ProtoMember(2)] public string Title { get; set; } + + [ProtoMember(3)] public string AsciiTitle { get; set; } + + [ProtoMember(4)] public uint FileCount { get; set; } + + [ProtoMember(5)] public ulong TotalSize { get; set; } + + [ProtoMember(10)] public FlashTransferPeer Peer { get; set; } + + [ProtoMember(16)] public uint FileCountDup { get; set; } + + [ProtoMember(20)] public uint Field20 { get; set; } + + [ProtoMember(21)] public uint Field21 { get; set; } + + [ProtoMember(23)] public uint Field23 { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferPeer +{ + [ProtoMember(1)] public string UidOrOpenid { get; set; } + + [ProtoMember(2)] public string DisplayName { get; set; } + + [ProtoMember(3)] public string Remark { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferCreateFileSetResponse +{ + [ProtoMember(1)] public string FileSetId { get; set; } + + [ProtoMember(2)] public string FileSetIdDup { get; set; } + + [ProtoMember(3)] public string ShareLink { get; set; } + + [ProtoMember(4)] public ulong ExpireTime { get; set; } + + [ProtoMember(5)] public ulong ExpireLeftTime { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferRegisterFilesRequest +{ + [ProtoMember(1)] public uint Scene { get; set; } + + [ProtoMember(2)] public string FileSetId { get; set; } + + [ProtoMember(3)] public string FileSetIdDup { get; set; } + + [ProtoMember(4)] public List Files { get; set; } + + [ProtoMember(5)] public uint Field5 { get; set; } + + [ProtoMember(6)] public uint Field6 { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferFileSetFile +{ + [ProtoMember(1)] public string FileSetId { get; set; } + + [ProtoMember(2)] public string FileId { get; set; } + + [ProtoMember(3)] public uint Field3 { get; set; } + + [ProtoMember(4)] public byte[] Field4 { get; set; } + + [ProtoMember(5)] public uint Field5 { get; set; } + + [ProtoMember(6)] public uint Index { get; set; } + + [ProtoMember(7)] public uint FileType { get; set; } + + [ProtoMember(8)] public string FileName { get; set; } + + [ProtoMember(9)] public string DisplayName { get; set; } + + [ProtoMember(10)] public uint Field10 { get; set; } + + [ProtoMember(11)] public ulong FileSize { get; set; } + + [ProtoMember(12)] public uint Field12 { get; set; } + + [ProtoMember(24)] public byte[] Field24 { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferRegisterFilesResponse; + +[ProtoPackable] +internal partial class FlashTransferQueryFileSetStatusRequest +{ + [ProtoMember(1)] public string FileSetId { get; set; } + + [ProtoMember(2)] public byte[] Field2 { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferQueryFileSetStatusResponse +{ + [ProtoMember(1)] public uint Field1 { get; set; } + + [ProtoMember(3)] public string Field3 { get; set; } + + [ProtoMember(6)] public byte[] Field6 { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUpdateFileSetStatusRequest +{ + [ProtoMember(1)] public string FileSetId { get; set; } + + [ProtoMember(2)] public uint Status { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUpdateFileSetStatusResponse +{ + [ProtoMember(1)] public string FileSetId { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadRequest +{ + [ProtoMember(1)] public FlashTransferUploadHeader Header { get; set; } + + [ProtoMember(2)] public FlashTransferUploadAuthorizePayload AuthorizePayload { get; set; } + + [ProtoMember(12)] public FlashTransferUploadCompletePayload CompletePayload { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadHeader +{ + [ProtoMember(1)] public FlashTransferUploadCommandIdentity CommandIdentity { get; set; } + + [ProtoMember(2)] public FlashTransferUploadClientCaps ClientCaps { get; set; } + + [ProtoMember(3)] public FlashTransferUploadRequestContext Context { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadCommandIdentity +{ + [ProtoMember(1)] public uint Scene { get; set; } + + [ProtoMember(2)] public uint SubCommand { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadClientCaps +{ + [ProtoMember(101)] public uint Field101 { get; set; } + + [ProtoMember(102)] public uint Field102 { get; set; } + + [ProtoMember(103)] public uint Capability { get; set; } + + [ProtoMember(200)] public uint Field200 { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadRequestContext +{ + [ProtoMember(1)] public uint Field1 { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadAuthorizePayload +{ + [ProtoMember(1)] public FlashTransferUploadFileContainer FileContainer { get; set; } + + [ProtoMember(2)] public uint Field2 { get; set; } + + [ProtoMember(3)] public uint Field3 { get; set; } + + [ProtoMember(4)] public uint Field4 { get; set; } + + [ProtoMember(5)] public uint Field5 { get; set; } + + [ProtoMember(6)] public byte[] Field6 { get; set; } + + [ProtoMember(7)] public uint Field7 { get; set; } + + [ProtoMember(8)] public uint Field8 { get; set; } + + [ProtoMember(9)] public FlashTransferUploadFileBinding FileBinding { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadFileContainer +{ + [ProtoMember(1)] public FlashTransferUploadFileInfo File { get; set; } + + [ProtoMember(2)] public uint Field2 { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadFileInfo +{ + [ProtoMember(1)] public ulong FileSize { get; set; } + + [ProtoMember(2)] public string Md5 { get; set; } + + [ProtoMember(3)] public string Sha1 { get; set; } + + [ProtoMember(4)] public string FileName { get; set; } + + [ProtoMember(5)] public byte[] Field5 { get; set; } + + [ProtoMember(6)] public uint Field6 { get; set; } + + [ProtoMember(7)] public uint Field7 { get; set; } + + [ProtoMember(8)] public uint Field8 { get; set; } + + [ProtoMember(9)] public uint Field9 { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadFileBinding +{ + [ProtoMember(1)] public string FileSetId { get; set; } + + [ProtoMember(2)] public string FileSetIdDup { get; set; } + + [ProtoMember(3)] public string FileId { get; set; } + + [ProtoMember(4)] public uint Stage { get; set; } + + [ProtoMember(5)] public uint Field5 { get; set; } + + [ProtoMember(6)] public uint Field6 { get; set; } + + [ProtoMember(7)] public uint FileType { get; set; } + + [ProtoMember(8)] public string FileIdDup { get; set; } + + [ProtoMember(9)] public uint Field9 { get; set; } + + [ProtoMember(10)] public uint Field10 { get; set; } + + [ProtoMember(11)] public uint Field11 { get; set; } + + [ProtoMember(12)] public uint Field12 { get; set; } + + [ProtoMember(13)] public uint Field13 { get; set; } + + [ProtoMember(14)] public uint Field14 { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadAuthorizeResponse +{ + [ProtoMember(1)] public FlashTransferUploadStatus Status { get; set; } + + [ProtoMember(2)] public FlashTransferUploadAuthorizeResult Result { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadStatus +{ + [ProtoMember(1)] public FlashTransferUploadCommandIdentity CommandIdentity { get; set; } + + [ProtoMember(3)] public string Message { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadAuthorizeResult +{ + [ProtoMember(1)] public string UploadToken { get; set; } + + [ProtoMember(2)] public ulong Field2 { get; set; } + + [ProtoMember(6)] public FlashTransferUploadResourceInfo ResourceInfo { get; set; } + + [ProtoMember(7)] public FlashTransferUploadEchoInfo Echo { get; set; } + + [ProtoMember(11)] public string UploadHost { get; set; } + + [ProtoMember(16)] public FlashTransferUploadChunkConfig ChunkConfig { get; set; } + + [ProtoMember(17)] public string BackupUploadHost { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadResourceInfo +{ + [ProtoMember(1)] public FlashTransferUploadResourceOuter Outer { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadResourceOuter +{ + [ProtoMember(1)] public FlashTransferUploadResource Resource { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadResource +{ + [ProtoMember(1)] public byte[] Field1 { get; set; } + + [ProtoMember(2)] public string ResourceKey { get; set; } + + [ProtoMember(3)] public uint Field3 { get; set; } + + [ProtoMember(4)] public ulong CreateTime { get; set; } + + [ProtoMember(5)] public ulong TtlSeconds { get; set; } + + [ProtoMember(7)] public uint AppId { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadEchoInfo +{ + [ProtoMember(2)] public uint Field2 { get; set; } + + [ProtoMember(3)] public FlashTransferUploadFileBinding FileBinding { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadChunkConfig +{ + [ProtoMember(1)] public uint ChunkSize { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadCompletePayload +{ + [ProtoMember(1)] public FlashTransferUploadCompleteFileResult RawFileResult { get; set; } + + [ProtoMember(2)] public byte[] Field2 { get; set; } + + [ProtoMember(3)] public byte[] Field3 { get; set; } + + [ProtoMember(4)] public byte[] Field4 { get; set; } + + [ProtoMember(10)] public FlashTransferUploadFileBinding FileBinding { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadCompleteFileResult +{ + [ProtoMember(1)] public FlashTransferUploadFileInfo File { get; set; } + + [ProtoMember(2)] public string ResourceKey { get; set; } + + [ProtoMember(3)] public uint Field3 { get; set; } + + [ProtoMember(4)] public ulong CreateTime { get; set; } + + [ProtoMember(5)] public ulong TtlSeconds { get; set; } + + [ProtoMember(6)] public uint Field6 { get; set; } + + [ProtoMember(7)] public uint Field7 { get; set; } + + [ProtoMember(8)] public uint Field8 { get; set; } +} + +[ProtoPackable] +internal partial class FlashTransferUploadCompleteResponse +{ + [ProtoMember(1)] public FlashTransferUploadStatus Status { get; set; } +} diff --git a/Lagrange.Core/Internal/Packets/Service/FlashTransferUploadReq.cs b/Lagrange.Core/Internal/Packets/Service/FlashTransferUploadReq.cs index 7859cf27..c670c876 100644 --- a/Lagrange.Core/Internal/Packets/Service/FlashTransferUploadReq.cs +++ b/Lagrange.Core/Internal/Packets/Service/FlashTransferUploadReq.cs @@ -6,26 +6,27 @@ namespace Lagrange.Core.Internal.Packets.Service; [ProtoPackable] internal partial class FlashTransferUploadReq { - [ProtoMember(1)] public uint FieId1 { get; set; } // 0 + [ProtoMember(1)] public uint FileId { get; set; } // 0 [ProtoMember(2)] public uint AppId { get; set; } // 1402: 私信语音, 1403: 群语音, 1413: 私信视频, 1414: 私信视频封面, 1415: 群视频, 1416: 群视频封面, 1406: 私信图片, 1407: 群聊图片, 14901: 闪传, 14903: 闪传封面 - [ProtoMember(3)] public uint FileId3 { get; set; } // 0 + [ProtoMember(3)] public uint UploadIndex { get; set; } [ProtoMember(107)] public FlashTransferUploadBody Body { get; set; } } [ProtoPackable] internal partial class FlashTransferUploadBody { - [ProtoMember(1)] public byte[] FieId1 { get; set; } // Empty + [ProtoMember(1)] public byte[] FileId { get; set; } // Empty [ProtoMember(2)] public string UKey { get; set; } [ProtoMember(3)] public uint Start { get; set; } // Start [ProtoMember(4)] public uint End { get; set; } // Start + Size - 1 [ProtoMember(5)] public byte[] Sha1 { get; set; } [ProtoMember(6)] public FlashTransferSha1StateV Sha1StateV { get; set; } [ProtoMember(7)] public byte[] Body { get; set; } + [ProtoMember(101)] public FlashTransferUploadFileBinding? FileBinding { get; set; } } [ProtoPackable] internal partial class FlashTransferSha1StateV { [ProtoMember(1)] public List State { get; set; } -} \ No newline at end of file +} diff --git a/Lagrange.Core/Internal/Services/System/FlashTransferService.cs b/Lagrange.Core/Internal/Services/System/FlashTransferService.cs new file mode 100644 index 00000000..e00928a0 --- /dev/null +++ b/Lagrange.Core/Internal/Services/System/FlashTransferService.cs @@ -0,0 +1,302 @@ +using Lagrange.Core.Common; +using Lagrange.Core.Exceptions; +using Lagrange.Core.Internal.Events; +using Lagrange.Core.Internal.Events.System; +using Lagrange.Core.Internal.Packets.Service; + +namespace Lagrange.Core.Internal.Services.System; + +internal static class FlashTransferServiceCommon +{ + public static FlashTransferUploadHeader CreateHeader(uint scene, uint subCommand, uint capability = 22) => new() + { + CommandIdentity = new FlashTransferUploadCommandIdentity { Scene = scene, SubCommand = subCommand }, + ClientCaps = new FlashTransferUploadClientCaps { Field101 = 2, Field102 = 4, Capability = capability, Field200 = 5 }, + Context = new FlashTransferUploadRequestContext { Field1 = 1 } + }; + + public static uint ResolveCapability(uint field5, uint field6) => field5 == 1 ? 23u : field6 == 1 ? 24u : 22u; + + public static string ResolveAsciiName(string name) + { + Span chars = stackalloc char[name.Length]; + int length = 0; + foreach (char c in name) + { + chars[length++] = c < 128 && !char.IsWhiteSpace(c) ? c : '_'; + } + + return new string(chars[..length]); + } + + public static FlashTransferUploadFileBinding CreateBinding(string fileSetId, string fileId, uint stage, uint fileType, uint field5 = 0, uint field6 = 0) => new() + { + FileSetId = fileSetId, + FileSetIdDup = fileSetId, + FileId = fileId, + Stage = stage, + Field5 = field5, + Field6 = field6, + FileType = fileType, + FileIdDup = fileId + }; +} + +[EventSubscribe(Protocols.All)] +[Service("OidbSvcTrpcTcp.0x93cf_1")] +internal class FlashTransferCreateFileSetService : OidbService +{ + private protected override uint Command => 0x93cf; + + private protected override uint Service => 1; + + private protected override uint Reserved => 1; + + private protected override Task ProcessRequest(FlashTransferCreateFileSetEventReq request, BotContext context) + { + return Task.FromResult(new FlashTransferCreateFileSetRequest + { + Scene = 1, + FileSet = new FlashTransferFileSetCreateInfo + { + Title = request.Title, + AsciiTitle = request.AsciiTitle, + FileCount = (uint)request.Files.Count, + TotalSize = (ulong)request.Files.Sum(file => file.Stream.Length), + Peer = new FlashTransferPeer + { + UidOrOpenid = context.Keystore.Uid, + DisplayName = context.Keystore.BotInfo?.Name ?? context.Keystore.Uin.ToString(), + Remark = string.Empty + }, + FileCountDup = (uint)request.Files.Count, + Field20 = 0, + Field21 = 0, + Field23 = 0 + }, + ClientType = 14 + }); + } + + private protected override Task ProcessResponse(FlashTransferCreateFileSetResponse response, BotContext context) + => Task.FromResult(new FlashTransferCreateFileSetEventResp(response.FileSetId, response.ShareLink)); +} + +[EventSubscribe(Protocols.All)] +[Service("OidbSvcTrpcTcp.0x93d0_1")] +internal class FlashTransferRegisterFilesService : OidbService +{ + private protected override uint Command => 0x93d0; + + private protected override uint Service => 1; + + private protected override uint Reserved => 1; + + private protected override Task ProcessRequest(FlashTransferRegisterFilesEventReq request, BotContext context) + { + return Task.FromResult(new FlashTransferRegisterFilesRequest + { + Scene = 1, + FileSetId = request.FileSetId, + FileSetIdDup = request.FileSetId, + Files = request.Files.Select(file => new FlashTransferFileSetFile + { + FileSetId = request.FileSetId, + FileId = file.FileId, + Field3 = 0, + Field4 = [], + Field5 = 1, + Index = file.Index, + FileType = file.FileType, + FileName = file.FileName, + DisplayName = FlashTransferServiceCommon.ResolveAsciiName(file.FileName), + Field10 = 0, + FileSize = (ulong)file.Stream.Length, + Field12 = 0, + Field24 = [] + }).ToList(), + Field5 = 1, + Field6 = 1 + }); + } + + private protected override Task ProcessResponse(FlashTransferRegisterFilesResponse response, BotContext context) + => Task.FromResult(new FlashTransferRegisterFilesEventResp()); +} + +[EventSubscribe(Protocols.All)] +[Service("OidbSvcTrpcTcp.0x93db_1")] +internal class FlashTransferQueryFileSetStatusService : OidbService +{ + private protected override uint Command => 0x93db; + + private protected override uint Service => 1; + + private protected override uint Reserved => 1; + + private protected override Task ProcessRequest(FlashTransferQueryFileSetStatusEventReq request, BotContext context) + { + return Task.FromResult(new FlashTransferQueryFileSetStatusRequest + { + FileSetId = request.FileSetId, + Field2 = [] + }); + } + + private protected override Task ProcessResponse(FlashTransferQueryFileSetStatusResponse response, BotContext context) + => Task.FromResult(new FlashTransferQueryFileSetStatusEventResp()); +} + +[EventSubscribe(Protocols.All)] +[Service("OidbSvcTrpcTcp.0x93d1_1")] +internal class FlashTransferUpdateFileSetStatusService : OidbService +{ + private protected override uint Command => 0x93d1; + + private protected override uint Service => 1; + + private protected override uint Reserved => 1; + + private protected override Task ProcessRequest(FlashTransferUpdateFileSetStatusEventReq request, BotContext context) + { + return Task.FromResult(new FlashTransferUpdateFileSetStatusRequest + { + FileSetId = request.FileSetId, + Status = request.Status + }); + } + + private protected override Task ProcessResponse(FlashTransferUpdateFileSetStatusResponse response, BotContext context) + => Task.FromResult(new FlashTransferUpdateFileSetStatusEventResp()); +} + +[EventSubscribe(Protocols.All)] +[Service("OidbSvcTrpcTcp.0x12a9_100")] +internal class FlashTransferUploadAuthorizeService : OidbService +{ + private protected override uint Command => 0x12a9; + + private protected override uint Service => 100; + + private protected override Task ProcessRequest(FlashTransferUploadAuthorizeEventReq request, BotContext context) + { + var file = request.File; + + + return Task.FromResult(new FlashTransferUploadRequest + { + Header = FlashTransferServiceCommon.CreateHeader(request.Scene, 100), + AuthorizePayload = new FlashTransferUploadAuthorizePayload + { + FileContainer = new FlashTransferUploadFileContainer + { + File = new FlashTransferUploadFileInfo + { + FileSize = (ulong)file.Stream.Length, + Md5 = string.Empty, + Sha1 = Convert.ToHexString(file.FileSha1).ToLowerInvariant(), + FileName = file.FileName, + Field5 = [], + Field6 = 0, + Field7 = 0, + Field8 = 0, + Field9 = 1 + }, + Field2 = 0 + }, + Field2 = 0, + Field3 = 0, + Field4 = 0, + Field5 = 0, + Field6 = [], + Field7 = 0, + Field8 = 0, + FileBinding = FlashTransferServiceCommon.CreateBinding(request.FileSetId, file.FileId, file.Index, file.FileType) + } + }); + } + + private protected override Task ProcessResponse(FlashTransferUploadAuthorizeResponse response, BotContext context) + { + string statusMessage = response.Status?.Message ?? string.Empty; + if (statusMessage != "success") throw new OperationException(-1, string.IsNullOrEmpty(statusMessage) ? "FlashTransfer upload authorize failed without status message" : statusMessage); + + var result = response.Result; + if (result == null) + { + context.LogWarning(nameof(FlashTransferUploadAuthorizeService), "FlashTransfer authorize response has no result payload"); + throw new OperationException(-1, "FlashTransfer upload authorize response has no result payload"); + } + + var resource = result.ResourceInfo.Outer.Resource; + string resourceKey = resource.ResourceKey ?? string.Empty; + var echoBinding = result.Echo?.FileBinding; + string uploadHost = string.IsNullOrEmpty(result.UploadHost) ? result.BackupUploadHost : result.UploadHost; + + return Task.FromResult(new FlashTransferUploadAuthorizeEventResp( + result.UploadToken ?? string.Empty, + resourceKey, + resource.AppId, + uploadHost ?? string.Empty, + result.ChunkConfig?.ChunkSize ?? 1024 * 1024, + echoBinding?.Stage ?? 0, + echoBinding?.Field5 ?? 0, + echoBinding?.Field6 ?? 0)); + } +} + +[EventSubscribe(Protocols.All)] +[Service("OidbSvcTrpcTcp.0x12a9_103")] +internal class FlashTransferUploadCompleteService : OidbService +{ + private protected override uint Command => 0x12a9; + + private protected override uint Service => 103; + + private protected override Task ProcessRequest(FlashTransferUploadCompleteEventReq request, BotContext context) + { + var file = request.File; + ulong now = (ulong)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + return Task.FromResult(new FlashTransferUploadRequest + { + Header = FlashTransferServiceCommon.CreateHeader(request.Scene, 103, FlashTransferServiceCommon.ResolveCapability(request.BindingField5, request.BindingField6)), + CompletePayload = new FlashTransferUploadCompletePayload + { + RawFileResult = new FlashTransferUploadCompleteFileResult + { + File = new FlashTransferUploadFileInfo + { + FileSize = (ulong)file.Stream.Length, + Md5 = Convert.ToHexString(file.FileMd5).ToLowerInvariant(), + Sha1 = Convert.ToHexString(file.FileSha1).ToLowerInvariant(), + FileName = file.FileName, + Field5 = [0x08, 0x00, 0x10, 0x00, 0x18, 0x00, 0x20, 0x00], + Field6 = 0, + Field7 = 0, + Field8 = 0, + Field9 = 1 + }, + ResourceKey = request.ResourceKey, + Field3 = 1, + CreateTime = now, + TtlSeconds = 1209600, + Field6 = 0, + Field7 = 0, + Field8 = 0 + }, + Field2 = [0x08, 0x02], + Field3 = [0x08, 0x00, 0x10, 0x00, 0x18, 0x00, 0x22, 0x00], + Field4 = [0x08, 0x00, 0x12, 0x00], + FileBinding = FlashTransferServiceCommon.CreateBinding(request.FileSetId, file.FileId, request.BindingStage, file.FileType, request.BindingField5, request.BindingField6) + } + }); + } + + private protected override Task ProcessResponse(FlashTransferUploadCompleteResponse response, BotContext context) + { + string statusMessage = response.Status?.Message ?? string.Empty; + if (statusMessage != "success") throw new OperationException(-1, string.IsNullOrEmpty(statusMessage) ? "FlashTransfer upload complete failed without status message" : statusMessage); + return Task.FromResult(new FlashTransferUploadCompleteEventResp()); + } +} diff --git a/Lagrange.Milky/Api/Handler/File/UploadFlashTransferHandler.cs b/Lagrange.Milky/Api/Handler/File/UploadFlashTransferHandler.cs new file mode 100644 index 00000000..f8b5cf13 --- /dev/null +++ b/Lagrange.Milky/Api/Handler/File/UploadFlashTransferHandler.cs @@ -0,0 +1,66 @@ +using System.Text.Json.Serialization; +using Lagrange.Core; +using Lagrange.Core.Common.Interface; +using Lagrange.Core.Common.Response; +using Lagrange.Milky.Utility; + +namespace Lagrange.Milky.Api.Handler.File; + +[Api("upload_flash_transfer")] +public class UploadFlashTransferHandler(BotContext bot, ResourceResolver resolver) : IApiHandler +{ + private readonly BotContext _bot = bot; + private readonly ResourceResolver _resolver = resolver; + + public async Task HandleAsync(UploadFlashTransferParameter parameter, CancellationToken token) + { + var streams = new List(); + try + { + foreach (var file in parameter.Files) + { + streams.Add(await _resolver.ToMemoryStreamAsync(file.FileUri, token)); + } + + var files = streams.Select((stream, index) => ((Stream)stream, parameter.Files[index].FileName)).ToList(); + var result = await _bot.UploadFlashTransfer(files, parameter.Title); + return new UploadFlashTransferResult(result.FileSetId, result.FileIds, result.ShareLink); + } + finally + { + foreach (var stream in streams) stream.Dispose(); + } + } +} + +public class UploadFlashTransferParameter(List files, string? title = null) +{ + [JsonRequired] + [JsonPropertyName("files")] + public List Files { get; init; } = files; + + [JsonPropertyName("fileset_name")] + public string? Title { get; init; } = title; +} + +public class UploadFlashTransferFileParameter(string fileUri, string? fileName = null) +{ + [JsonRequired] + [JsonPropertyName("file_uri")] + public string FileUri { get; init; } = fileUri; + + [JsonPropertyName("file_name")] + public string? FileName { get; init; } = fileName; +} + +public class UploadFlashTransferResult(string fileSetId, List fileIds, string shareUrl) +{ + [JsonPropertyName("fileset_id")] + public string FileSetId { get; } = fileSetId; + + [JsonPropertyName("file_ids")] + public List FileIds { get; } = fileIds; + + [JsonPropertyName("share_url")] + public string ShareUrl { get; } = shareUrl; +} \ No newline at end of file diff --git a/Lagrange.Milky/Utility/JsonUtility.cs b/Lagrange.Milky/Utility/JsonUtility.cs index bd676619..4a85056d 100644 --- a/Lagrange.Milky/Utility/JsonUtility.cs +++ b/Lagrange.Milky/Utility/JsonUtility.cs @@ -101,6 +101,10 @@ public static partial class JsonUtility // upload_private_file [JsonSerializable(typeof(UploadPrivateFileParameter))] [JsonSerializable(typeof(UploadPrivateFileResult))] + // upload_flash_transfer + [JsonSerializable(typeof(UploadFlashTransferParameter))] + [JsonSerializable(typeof(UploadFlashTransferFileParameter))] + [JsonSerializable(typeof(UploadFlashTransferResult))] // get_group_file_download_url [JsonSerializable(typeof(GetGroupFileDownloadUrlParameter))] [JsonSerializable(typeof(GetGroupFileDownloadUrlResult))]