-
Notifications
You must be signed in to change notification settings - Fork 1.1k
feat: 新增 QQ 官方 REST 通知渠道及 OpenID 自动绑定 #3530
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
a48ea6b
cf9ae8b
9bef259
6de1afa
eecfd24
30f1784
515b90c
d8dbb5d
1169640
f1ab25d
eaf8e72
bb2552e
6e1656e
0b4090c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,339 @@ | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.IO; | ||
| using System.Net.Http; | ||
| using System.Net.Http.Headers; | ||
| using System.Security.Cryptography; | ||
| using System.Text; | ||
| using System.Text.Json; | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
| using BetterGenshinImpact.Service.Notification.Model; | ||
| using BetterGenshinImpact.Service.Notification.Model.Enum; | ||
| using BetterGenshinImpact.Service.Notifier.Exception; | ||
| using BetterGenshinImpact.Service.Notifier.Interface; | ||
| using Microsoft.Extensions.Logging; | ||
| using SixLabors.ImageSharp; | ||
| using SixLabors.ImageSharp.PixelFormats; | ||
|
|
||
| namespace BetterGenshinImpact.Service.Notifier; | ||
|
|
||
| /// <summary> | ||
| /// QQ official REST notifier. | ||
| /// Pushes BetterGI events to the user's QQ private chat (C2C) via QQ Open Platform API. | ||
| /// Supports text messages and screenshot image messages (chunked upload). | ||
| /// </summary> | ||
| public sealed class QqNotifier : INotifier | ||
| { | ||
| private static readonly ILogger<QqNotifier> Logger = App.GetLogger<QqNotifier>(); | ||
|
|
||
| public string Name { get; } = "QQ"; | ||
|
|
||
| private const string TokenUrl = "https://bots.qq.com/app/getAppAccessToken"; | ||
| private const string ApiBase = "https://api.sgroup.qq.com/v2/users/{openid}"; | ||
| private const int MaxRetry = 3; | ||
|
|
||
| private readonly HttpClient _httpClient; | ||
| private readonly string _appId; | ||
| private readonly string _clientSecret; | ||
| private readonly string _openId; | ||
|
|
||
| private string? _cachedToken; | ||
| private DateTime _tokenExpiry = DateTime.MinValue; | ||
| private readonly SemaphoreSlim _tokenSemaphore = new(1, 1); | ||
|
|
||
| public QqNotifier(HttpClient httpClient, string appId, string clientSecret, string openId) | ||
| { | ||
| _httpClient = httpClient; | ||
| _appId = appId; | ||
| _clientSecret = clientSecret; | ||
| _openId = openId; | ||
| } | ||
|
|
||
| public async Task SendAsync(BaseNotificationData content) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(_appId)) | ||
| throw new NotifierException("QQ AppID is empty"); | ||
|
|
||
| if (string.IsNullOrWhiteSpace(_clientSecret)) | ||
| throw new NotifierException("QQ AppSecret is empty"); | ||
|
|
||
| if (string.IsNullOrWhiteSpace(_openId)) | ||
| throw new NotifierException("QQ OpenID is empty"); | ||
|
|
||
| var ct = CancellationToken.None; | ||
| try | ||
| { | ||
| var text = GenerateMessage(content); | ||
| await SendTextAsync(text, ct); | ||
|
|
||
| if (content.Screenshot != null) | ||
| { | ||
| try | ||
| { | ||
| await SendImageAsync(content.Screenshot, ct); | ||
| } | ||
| catch (System.Exception ex) | ||
| { | ||
| Logger.LogWarning("QQ image send failed, falling back to text-only: {ex}", ex.Message); | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
| catch (NotifierException) | ||
| { | ||
| throw; | ||
| } | ||
| catch (System.Exception ex) | ||
| { | ||
| throw new NotifierException($"Error sending QQ message: {ex.Message}"); | ||
| } | ||
| } | ||
|
|
||
| private static string GenerateMessage(BaseNotificationData data) | ||
| { | ||
| var sb = new StringBuilder(); | ||
| var mark = data.Result switch | ||
| { | ||
| NotificationEventResult.Success => "\u2705", | ||
| NotificationEventResult.Fail => "\u274C", | ||
| _ => "\u26A0\uFE0F" | ||
| }; | ||
| sb.Append($"[BetterGI] {mark} "); | ||
| if (!string.IsNullOrWhiteSpace(data.Message)) | ||
| sb.Append(data.Message); | ||
| sb.AppendLine(); | ||
| sb.Append($"\uD83D\uDD50 {data.Timestamp:yyyy-MM-dd HH:mm:ss}"); | ||
| return sb.ToString(); | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 该通知器对全新的 token、消息和上传载荷使用 Context Used: AGENTS.md (source) Prompt To Fix With AIThis is a comment left during a code review.
Path: BetterGenshinImpact/Service/Notifier/QqNotifier.cs
Line: 101
Comment:
**新载荷未遵循序列化约定**
该通知器对全新的 token、消息和上传载荷使用 `System.Text.Json`,而项目约定新 JSON 模型优先使用 Newtonsoft.Json;这会形成独立的序列化实现,增加后续统一转换器和维护行为的成本。
**Context Used:** AGENTS.md ([source](https://github.com/babalae/better-genshin-impact/blob/main/AGENTS.md))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
|
|
||
| private async Task<string> GetAccessTokenAsync(CancellationToken ct) | ||
| { | ||
| if (!string.IsNullOrEmpty(_cachedToken) && DateTime.UtcNow < _tokenExpiry) | ||
| return _cachedToken; | ||
|
|
||
| await _tokenSemaphore.WaitAsync(ct); | ||
| try | ||
| { | ||
| if (!string.IsNullOrEmpty(_cachedToken) && DateTime.UtcNow < _tokenExpiry) | ||
| return _cachedToken; | ||
| return await RefreshTokenAsync(ct); | ||
|
greptile-apps[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
| finally | ||
| { | ||
| _tokenSemaphore.Release(); | ||
| } | ||
| } | ||
|
|
||
| private async Task<string> RefreshTokenAsync(CancellationToken ct) | ||
| { | ||
| var body = JsonSerializer.Serialize(new { appId = _appId, clientSecret = _clientSecret }); | ||
| using var content = new StringContent(body, Encoding.UTF8, "application/json"); | ||
| using var request = new HttpRequestMessage(HttpMethod.Post, TokenUrl) { Content = content }; | ||
| using var response = await _httpClient.SendAsync(request, ct); | ||
| response.EnsureSuccessStatusCode(); | ||
| var json = await response.Content.ReadAsStringAsync(ct); | ||
| using var doc = JsonDocument.Parse(json); | ||
| var root = doc.RootElement; | ||
| _cachedToken = root.GetProperty("access_token").GetString()!; | ||
| var expiresInStr = root.GetProperty("expires_in").GetString(); | ||
| var expiresIn = int.TryParse(expiresInStr, out var parsed) ? parsed : 60; | ||
| _tokenExpiry = DateTime.UtcNow.AddSeconds(Math.Max(expiresIn - 60, 30)); | ||
| return _cachedToken; | ||
| } | ||
|
|
||
| private async Task<HttpRequestMessage> BuildAuthedRequest(HttpMethod method, string url, HttpContent? body, CancellationToken ct) | ||
| { | ||
| var token = await GetAccessTokenAsync(ct); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 严重程度:P2。问题位置: Useful? React with 👍 / 👎. |
||
| var request = new HttpRequestMessage(method, url) { Content = body }; | ||
| request.Headers.Add("Authorization", $"QQBot {token}"); | ||
| return request; | ||
| } | ||
|
|
||
| private async Task SendTextAsync(string text, CancellationToken ct) | ||
| { | ||
| var body = JsonSerializer.Serialize(new { msg_type = 0, content = text }); | ||
| using var jsonContent = new StringContent(body, Encoding.UTF8, "application/json"); | ||
| using var request = await BuildAuthedRequest(HttpMethod.Post, $"{ApiBase.Replace("{openid}", _openId)}/messages", jsonContent, ct); | ||
| using var response = await _httpClient.SendAsync(request, ct); | ||
| response.EnsureSuccessStatusCode(); | ||
|
greptile-apps[bot] marked this conversation as resolved.
greptile-apps[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| private async Task SendImageAsync(Image<Rgb24> screenshot, CancellationToken ct) | ||
| { | ||
| byte[] imageBytes; | ||
| using (var ms = new MemoryStream()) | ||
| { | ||
| screenshot.SaveAsJpeg(ms); | ||
| imageBytes = ms.ToArray(); | ||
| } | ||
|
|
||
| var fileInfo = await UploadImageChunkedAsync(imageBytes, ct); | ||
|
|
||
| var body = JsonSerializer.Serialize(new | ||
| { | ||
| msg_type = 7, | ||
| media = new { file_info = fileInfo } | ||
| }); | ||
| using var jsonContent = new StringContent(body, Encoding.UTF8, "application/json"); | ||
| using var request = await BuildAuthedRequest(HttpMethod.Post, $"{ApiBase.Replace("{openid}", _openId)}/messages", jsonContent, ct); | ||
| using var response = await _httpClient.SendAsync(request, ct); | ||
| response.EnsureSuccessStatusCode(); | ||
| } | ||
|
|
||
| private async Task<string> UploadImageChunkedAsync(byte[] imageBytes, CancellationToken ct) | ||
| { | ||
| var baseUrl = ApiBase.Replace("{openid}", _openId); | ||
| var fileName = "screenshot.jpg"; | ||
|
greptile-apps[bot] marked this conversation as resolved.
|
||
| var md5 = Convert.ToHexString(MD5.HashData(imageBytes)).ToLower(); | ||
| var sha1 = Convert.ToHexString(SHA1.HashData(imageBytes)).ToLower(); | ||
| var md5First10m = Convert.ToHexString(MD5.HashData(imageBytes.AsSpan(0, Math.Min(imageBytes.Length, 10002432)))).ToLower(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 严重程度:P2。问题位置: AGENTS.md reference: AGENTS.md:L102-L102 Useful? React with 👍 / 👎. |
||
|
|
||
| var prepared = await PrepareUploadAsync(baseUrl, fileName, imageBytes.Length, md5, sha1, md5First10m, ct); | ||
|
|
||
| foreach (var part in prepared.Parts) | ||
| { | ||
| var start = (part.Index - 1) * prepared.BlockSize; | ||
| var end = Math.Min(start + prepared.BlockSize, imageBytes.Length); | ||
| var chunk = imageBytes[start..end]; | ||
| var chunkMd5 = Convert.ToHexString(MD5.HashData(chunk)).ToLower(); | ||
|
|
||
| await WithRetryAsync(() => UploadChunkAsync(part.PresignedUrl, chunk, ct), ct); | ||
| await WithRetryAsync(() => FinishChunkAsync(baseUrl, prepared.UploadId, part.Index, chunk.Length, chunkMd5, ct), ct); | ||
| } | ||
|
|
||
| return await MergeUploadAsync(baseUrl, prepared.UploadId, ct); | ||
|
greptile-apps[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
|
|
||
| private async Task<UploadPrepareResult> PrepareUploadAsync(string baseUrl, string fileName, int fileSize, string md5, string sha1, string md5First10m, CancellationToken ct) | ||
| { | ||
| using var request = await BuildAuthedRequest(HttpMethod.Post, $"{baseUrl}/upload_prepare", new StringContent( | ||
| JsonSerializer.Serialize(new | ||
| { | ||
| file_type = 1, | ||
| file_size = fileSize.ToString(), | ||
| file_name = fileName, | ||
| md5, | ||
| sha1, | ||
| md5_10m = md5First10m | ||
| }), Encoding.UTF8, "application/json"), ct); | ||
| using var response = await _httpClient.SendAsync(request, ct); | ||
| response.EnsureSuccessStatusCode(); | ||
| var json = await response.Content.ReadAsStringAsync(ct); | ||
| using var doc = JsonDocument.Parse(json); | ||
| var uploadId = doc.RootElement.GetProperty("upload_id").GetString()!; | ||
| var blockSize = int.Parse(doc.RootElement.GetProperty("block_size").GetString()!); | ||
| var parts = new List<ChunkPart>(); | ||
| foreach (var part in doc.RootElement.GetProperty("parts").EnumerateArray()) | ||
| { | ||
| parts.Add(new ChunkPart( | ||
| part.GetProperty("index").GetInt32(), | ||
| part.GetProperty("presigned_url").GetString()!)); | ||
|
Comment on lines
+258
to
+260
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- target implementation ---'
sed -n '210,255p' BetterGenshinImpact/Service/Notifier/QqNotifier.cs
printf '%s\n' '--- JSON package references and conventions ---'
rg -n --glob '*.csproj' --glob 'packages.config' 'Newtonsoft.Json|System.Text.Json' .
rg -n --glob '*.cs' 'JsonSerializer|JsonConvert|JObject|JsonDocument|JsonNode' BetterGenshinImpact | head -120
printf '%s\n' '--- repository references to upload_prepare fields ---'
rg -n 'upload_prepare|presigned_url|part_index|upload_url|parts' BetterGenshinImpact tests .github 2>/dev/null | head -160
printf '%s\n' '--- upstream SDK fixture ---'
curl -L --fail --silent --show-error \
https://raw.githubusercontent.com/tencent-connect/qqbot-agent-sdk/main/tests/test_api_client.py \
| rg -n -C 8 'upload_prepare|part_index|upload_url|presigned_url|parts'Repository: babalae/better-genshin-impact Length of output: 31990 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- complete upload response flow ---'
sed -n '1,115p' BetterGenshinImpact/Service/Notifier/QqNotifier.cs
sed -n '180,300p' BetterGenshinImpact/Service/Notifier/QqNotifier.cs
printf '%s\n' '--- upstream SDK tree and upload models ---'
tree_json="$(curl -L --fail --silent --show-error \
https://api.github.com/repos/tencent-connect/qqbot-agent-sdk/git/trees/main?recursive=1)"
printf '%s\n' "$tree_json" \
| jq -r '.tree[].path' \
| rg '(^|/)(api_client|client|models|types|test_api_client).*\.py$|README|openapi|swagger'
printf '%s\n' '--- upstream source references ---'
printf '%s\n' "$tree_json" \
| jq -r '.tree[].path' \
| rg '\.py$' \
| while read -r path; do
content="$(curl -L --fail --silent --show-error \
"https://raw.githubusercontent.com/tencent-connect/qqbot-agent-sdk/main/$path" || true)"
if printf '%s' "$content" | rg -q 'upload_prepare|part_index|upload_url|presigned_url'; then
printf '%s\n' "--- $path ---"
printf '%s\n' "$content" | rg -n -C 6 'upload_prepare|part_index|upload_url|presigned_url'
fi
doneRepository: babalae/better-genshin-impact Length of output: 39411 🌐 Web query:
💡 Result: 在腾讯 QQ 机器人开放平台的开发场景中, Citations:
🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- upstream parser implementation ---'
curl -L --fail --silent --show-error \
https://raw.githubusercontent.com/tencent-connect/qqbot-agent-sdk/main/src/qqbot_agent_sdk/dto.py \
| sed -n '753,790p'
printf '%s\n' '--- upstream repository metadata and recent file history ---'
curl -L --fail --silent --show-error \
https://api.github.com/repos/tencent-connect/qqbot-agent-sdk/commits?path=src/qqbot_agent_sdk/dto.py\&per_page=5 \
| jq -r '.[] | [.sha[0:12], .commit.message] | `@tsv`'
printf '%s\n' '--- API schema terms in upstream documentation ---'
for path in README.md docs/api.md docs/file-upload.md; do
content="$(curl -L --silent --show-error \
"https://raw.githubusercontent.com/tencent-connect/qqbot-agent-sdk/main/$path" || true)"
if [ -n "$content" ]; then
printf '%s\n' "--- $path ---"
printf '%s\n' "$content" | rg -n -i -C 5 'upload_prepare|presigned_url|upload_url|part_index|file_info'
fi
doneRepository: babalae/better-genshin-impact Length of output: 3364 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- official upload_prepare documentation ---'
curl -L --fail --silent --show-error \
https://bot.qq.com/wiki/develop/api-v2/autogen/api/v2_users_user_id_upload_prepare.post.html \
| rg -n -i -C 8 'parts|part_index|presigned_url|upload_url|index|block_size' \
| head -160
printf '%s\n' '--- official upload_part_finish documentation ---'
curl -L --fail --silent --show-error \
https://bot.qq.com/wiki/develop/api-v2/autogen/api/v2_users_user_id_upload_part_finish.post.html \
| rg -n -i -C 6 'part_index|index|开始|从 0|从0' \
| head -120
printf '%s\n' '--- deterministic offset check ---'
python3 - <<'PY'
block_size = 1024
for index in (0, 1, 2):
start = (index - 1) * block_size
print(f"index={index}: current_start={start}, expected_start={index * block_size}")
PYRepository: babalae/better-genshin-impact Length of output: 21812 修正分片偏移计算。 API 的 🤖 Prompt for AI Agents |
||
| } | ||
| return new UploadPrepareResult(uploadId, blockSize, parts); | ||
| } | ||
|
|
||
| private static bool IsRetryable(System.Exception ex) | ||
| { | ||
| if (ex is HttpRequestException hre) | ||
| { | ||
| var statusCode = hre.StatusCode; | ||
| if (statusCode.HasValue) | ||
| { | ||
| var code = (int)statusCode.Value; | ||
| if (code >= 500 && code <= 599) | ||
| return true; | ||
| if (code == 429) | ||
| return true; | ||
| if (code == 400) | ||
| { | ||
| var msg = ex.Message; | ||
| if (msg.Contains("40093001")) | ||
| return true; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 严重程度:P2。问题位置: Useful? React with 👍 / 👎. |
||
| if (msg.Contains("40093002")) | ||
| return false; | ||
|
greptile-apps[bot] marked this conversation as resolved.
Outdated
|
||
| } | ||
| } | ||
| return false; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 严重程度:P2。问题位置: Useful? React with 👍 / 👎. |
||
| } | ||
| if (ex is TaskCanceledException || ex is OperationCanceledException) | ||
| return false; | ||
|
Comment on lines
+289
to
+290
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 严重程度:P2。问题位置: AGENTS.md reference: AGENTS.md:L100-L100 Useful? React with 👍 / 👎.
tianshan233 marked this conversation as resolved.
|
||
| if (ex is JsonException || ex is InvalidOperationException) | ||
| return false; | ||
| return true; | ||
| } | ||
|
|
||
| private async Task UploadChunkAsync(string presignedUrl, byte[] chunk, CancellationToken ct) | ||
| { | ||
| using var putContent = new ByteArrayContent(chunk); | ||
| putContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); | ||
| using var putResponse = await _httpClient.PutAsync(presignedUrl, putContent, ct); | ||
| putResponse.EnsureSuccessStatusCode(); | ||
| } | ||
|
|
||
| private async Task FinishChunkAsync(string baseUrl, string uploadId, int partIndex, int chunkLength, string chunkMd5, CancellationToken ct) | ||
| { | ||
| using var request = await BuildAuthedRequest(HttpMethod.Post, $"{baseUrl}/upload_part_finish", new StringContent( | ||
| JsonSerializer.Serialize(new | ||
| { | ||
| upload_id = uploadId, | ||
| part_index = partIndex, | ||
| block_size = chunkLength.ToString(), | ||
| md5 = chunkMd5 | ||
| }), Encoding.UTF8, "application/json"), ct); | ||
| using var response = await _httpClient.SendAsync(request, ct); | ||
| response.EnsureSuccessStatusCode(); | ||
| } | ||
|
|
||
| private async Task<string> MergeUploadAsync(string baseUrl, string uploadId, CancellationToken ct) | ||
| { | ||
| using var request = await BuildAuthedRequest(HttpMethod.Post, $"{baseUrl}/files", new StringContent( | ||
| JsonSerializer.Serialize(new { file_type = 1, upload_id = uploadId }), Encoding.UTF8, "application/json"), ct); | ||
| using var response = await _httpClient.SendAsync(request, ct); | ||
| response.EnsureSuccessStatusCode(); | ||
| var json = await response.Content.ReadAsStringAsync(ct); | ||
| using var doc = JsonDocument.Parse(json); | ||
| return doc.RootElement.GetProperty("file_info").GetString()!; | ||
| } | ||
|
|
||
| private readonly record struct ChunkPart(int Index, string PresignedUrl); | ||
|
|
||
| private readonly record struct UploadPrepareResult(string UploadId, int BlockSize, List<ChunkPart> Parts); | ||
|
|
||
| private async Task WithRetryAsync(Func<Task> action, CancellationToken ct) | ||
| { | ||
| System.Exception? lastException = null; | ||
| for (var attempt = 0; attempt <= MaxRetry; attempt++) | ||
| { | ||
| try | ||
| { | ||
| await action(); | ||
| return; | ||
| } | ||
| catch (System.Exception ex) when (attempt < MaxRetry && IsRetryable(ex)) | ||
| { | ||
| lastException = ex; | ||
| await Task.Delay(TimeSpan.FromMilliseconds(1500 * (1 << attempt)), ct); | ||
| } | ||
| } | ||
| if (lastException != null) | ||
| throw lastException; | ||
| } | ||
|
|
||
| private async Task<T> WithRetryAsync<T>(Func<Task<T>> action, CancellationToken ct) | ||
| { | ||
| System.Exception? lastException = null; | ||
| for (var attempt = 0; attempt <= MaxRetry; attempt++) | ||
| { | ||
| try | ||
| { | ||
| return await action(); | ||
| } | ||
| catch (System.Exception ex) when (attempt < MaxRetry && IsRetryable(ex)) | ||
| { | ||
| lastException = ex; | ||
| await Task.Delay(TimeSpan.FromMilliseconds(1500 * (1 << attempt)), ct); | ||
| } | ||
| } | ||
| throw lastException ?? new NotifierException("QQ request failed after retries"); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
严重程度:P2。问题位置:
QqNotifier.SendAsync初始化上传流程令牌处。问题原因:这里硬编码CancellationToken.None,导致后续 HTTP 请求、指数退避和分片上传虽然都接收CancellationToken,实际上仍完全无法取消。可能造成的影响:当 QQ 接口无响应或图片上传反复超时时,关闭程序或停止通知服务无法终止正在进行的发送,测试命令也可能持续等待多个请求超时和重试周期。推荐修复方案:让通知调用链传入可取消令牌,或由QqNotifier/NotificationService持有在StopAsync、Dispose时取消的CancellationTokenSource,并将其令牌贯穿整个发送流程。AGENTS.md reference: AGENTS.md:L99-L101
Useful? React with 👍 / 👎.