From 22e63f065de1575e8ca5d17ae217b105da53403d Mon Sep 17 00:00:00 2001 From: meoacgx Date: Sat, 20 Dec 2025 23:04:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Bot=20=E7=BC=96=E8=BE=91=E9=A2=91?= =?UTF-8?q?=E9=81=93=E6=94=AF=E6=8C=81=E4=BF=AE=E6=94=B9=E5=A4=B4=E5=83=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/Telegram/BotTelegramService.cs | 67 +++++++++++++++++ .../Services/Telegram/TelegramBotApiClient.cs | 72 ++++++++++++++++++- .../Dialogs/BotChannelEditDialog.razor | 36 +++++++++- 3 files changed, 173 insertions(+), 2 deletions(-) diff --git a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs index 464bca9..c526a43 100644 --- a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs @@ -1,5 +1,8 @@ using System.Text.Json; using Microsoft.Extensions.Logging; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Processing; using TelegramPanel.Core.Services; using TelegramPanel.Data.Entities; @@ -294,6 +297,70 @@ public class BotTelegramService return true; } + public async Task SetChannelPhotoAsync( + int botId, + long channelTelegramId, + Stream fileStream, + string fileName, + CancellationToken cancellationToken) + { + var bot = await _botManagement.GetBotAsync(botId) + ?? throw new InvalidOperationException($"机器人不存在:{botId}"); + + if (!bot.IsActive) + throw new InvalidOperationException("该机器人已停用"); + + if (fileStream == null) + throw new ArgumentException("头像文件为空", nameof(fileStream)); + + fileName = (fileName ?? "photo.jpg").Trim(); + if (string.IsNullOrWhiteSpace(fileName)) + fileName = "photo.jpg"; + + // Bot API setChatPhoto 需要 multipart 上传 InputFile。 + // 为了提高成功率:做“居中裁剪为正方形 + 缩放到 512x512 + JPEG 压缩”,避免原图过大/比例异常导致失败。 + await using var raw = new MemoryStream(); + if (fileStream.CanSeek) + fileStream.Position = 0; + await fileStream.CopyToAsync(raw, cancellationToken); + raw.Position = 0; + + await using var encoded = new MemoryStream(); + try + { + using var image = await Image.LoadAsync(raw, cancellationToken); + image.Mutate(x => x.AutoOrient()); + + const int targetSize = 512; + image.Mutate(x => x.Resize(new ResizeOptions + { + Mode = ResizeMode.Crop, + Size = new Size(targetSize, targetSize) + })); + + await image.SaveAsJpegAsync(encoded, new JpegEncoder { Quality = 85 }, cancellationToken); + encoded.Position = 0; + } + catch (UnknownImageFormatException) + { + throw new InvalidOperationException("不支持的图片格式(建议使用 JPG/PNG)"); + } + + _ = await _api.CallWithFileAsync( + token: bot.Token, + method: "setChatPhoto", + parameters: new Dictionary + { + ["chat_id"] = channelTelegramId.ToString() + }, + fileParameterName: "photo", + fileStream: encoded, + fileName: fileName, + cancellationToken: cancellationToken); + + return true; + } + public async Task PromoteChatMemberAsync(int botId, IReadOnlyList channelTelegramIds, long userId, BotAdminRights rights, CancellationToken cancellationToken) { var bot = await _botManagement.GetBotAsync(botId) diff --git a/src/TelegramPanel.Core/Services/Telegram/TelegramBotApiClient.cs b/src/TelegramPanel.Core/Services/Telegram/TelegramBotApiClient.cs index ed32e78..fe06bf2 100644 --- a/src/TelegramPanel.Core/Services/Telegram/TelegramBotApiClient.cs +++ b/src/TelegramPanel.Core/Services/Telegram/TelegramBotApiClient.cs @@ -47,6 +47,77 @@ public class TelegramBotApiClient return result.Clone(); } + /// + /// 调用 Bot API 并上传文件(使用 multipart/form-data) + /// + public async Task CallWithFileAsync( + string token, + string method, + IReadOnlyDictionary parameters, + string fileParameterName, + Stream fileStream, + string fileName, + CancellationToken cancellationToken) + { + token = (token ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(token)) + throw new InvalidOperationException("Bot Token 为空"); + + method = (method ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(method)) + throw new InvalidOperationException("method 为空"); + + if (string.IsNullOrWhiteSpace(fileParameterName)) + throw new InvalidOperationException("fileParameterName 为空"); + + if (fileStream == null) + throw new InvalidOperationException("fileStream 为空"); + + fileName = (fileName ?? "file").Trim(); + if (string.IsNullOrWhiteSpace(fileName)) + fileName = "file"; + + if (fileStream.CanSeek) + fileStream.Position = 0; + + var url = $"https://api.telegram.org/bot{Uri.EscapeDataString(token)}/{Uri.EscapeDataString(method)}"; + + using var content = new MultipartFormDataContent(); + + // 添加普通参数 + foreach (var (key, value) in parameters) + { + if (!string.IsNullOrWhiteSpace(key) && value != null) + { + content.Add(new StringContent(value), key); + } + } + + // 添加文件 + var streamContent = new StreamContent(fileStream); + streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); + content.Add(streamContent, fileParameterName, fileName); + + using var resp = await _http.PostAsync(url, content, cancellationToken); + var json = await resp.Content.ReadAsStringAsync(cancellationToken); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var ok = root.TryGetProperty("ok", out var okEl) && okEl.ValueKind == JsonValueKind.True; + if (!ok) + { + var code = root.TryGetProperty("error_code", out var codeEl) ? codeEl.GetInt32() : 0; + var desc = root.TryGetProperty("description", out var descEl) ? descEl.GetString() : "未知错误"; + throw new InvalidOperationException($"Bot API 调用失败:{method} ({code}) {desc}"); + } + + if (!root.TryGetProperty("result", out var result)) + throw new InvalidOperationException($"Bot API 返回缺少 result:{method}"); + + return result.Clone(); + } + private static string BuildUrl(string token, string method, IReadOnlyDictionary query) { var sb = new StringBuilder(); @@ -74,4 +145,3 @@ public class TelegramBotApiClient return sb.ToString(); } } - diff --git a/src/TelegramPanel.Web/Components/Dialogs/BotChannelEditDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BotChannelEditDialog.razor index 09669e5..a1b2903 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/BotChannelEditDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/BotChannelEditDialog.razor @@ -3,6 +3,7 @@ @inject TelegramPanel.Core.Services.Telegram.BotTelegramService BotTelegram @inject ISnackbar Snackbar @using TelegramPanel.Data.Entities +@using Microsoft.AspNetCore.Components.Forms @@ -15,6 +16,23 @@ + + + @if (editAvatar) + { + + + + 选择头像图片 + + + + + @if (avatarFile != null) + { + 已选择:@avatarFile.Name(@avatarFile.Size bytes) + } + } } @@ -46,6 +64,8 @@ private string title = ""; private string about = ""; private TelegramPanel.Core.Services.Telegram.BotTelegramService.BotChatInfo? chatInfo; + private bool editAvatar; + private IBrowserFile? avatarFile; protected override async Task OnInitializedAsync() { @@ -89,6 +109,16 @@ var normalizedAbout = string.IsNullOrWhiteSpace(about) ? null : about.Trim(); await BotTelegram.UpdateChannelInfoAsync(BotId, ChannelTelegramId, title, normalizedAbout, CancellationToken.None); + if (editAvatar) + { + if (avatarFile == null) + throw new InvalidOperationException("请先选择头像图片"); + + const long maxBytes = 20 * 1024 * 1024; + await using var stream = avatarFile.OpenReadStream(maxBytes); + await BotTelegram.SetChannelPhotoAsync(BotId, ChannelTelegramId, stream, avatarFile.Name, CancellationToken.None); + } + // 同步回本地 DB(用于列表展示) var dbChannel = await BotManagement.GetChannelByTelegramIdAsync(BotId, ChannelTelegramId); var username = chatInfo?.Username ?? dbChannel?.Username; @@ -124,5 +154,9 @@ { MudDialog.Cancel(); } -} + private void OnAvatarChanged(IReadOnlyList files) + { + avatarFile = files?.FirstOrDefault(); + } +}