Skip to content

Commit 4f4cfc0

Browse files
authored
feat: QQ 通知渠道新增群聊推送支持 (#3581)
1 parent 27017bb commit 4f4cfc0

10 files changed

Lines changed: 471 additions & 35 deletions

File tree

BetterGenshinImpact/Service/Notification/NotificationConfig.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,11 @@ public partial class NotificationConfig : ObservableObject
342342
/// </summary>
343343
[ObservableProperty] private string _qqOpenId = string.Empty;
344344

345+
/// <summary>
346+
/// 群聊 OpenID(群聊场景)
347+
/// </summary>
348+
[ObservableProperty] private string _qqGroupOpenId = string.Empty;
349+
345350
/// <summary>
346351
/// 微信 Clawbot 通知是否启用
347352
/// </summary>

BetterGenshinImpact/Service/Notification/NotificationService.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,8 @@ private void InitializeQqNotifier()
362362
_notifyHttpClient,
363363
_notificationConfig.QqAppId,
364364
_notificationConfig.QqClientSecret,
365-
_notificationConfig.QqOpenId
365+
_notificationConfig.QqOpenId,
366+
_notificationConfig.QqGroupOpenId
366367
));
367368
}
368369

BetterGenshinImpact/Service/Notifier/QqNotifier.cs

Lines changed: 89 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Collections.Generic;
33
using System.IO;
44
using System.Net.Http;
@@ -20,7 +20,7 @@ namespace BetterGenshinImpact.Service.Notifier;
2020

2121
/// <summary>
2222
/// QQ 官方 REST 通知器。
23-
/// 通过 QQ 开放平台 API 将 BetterGI 事件推送到用户的 QQ 私聊(C2C)。
23+
/// 通过 QQ 开放平台 API 将 BetterGI 事件推送到用户的 QQ 私聊(C2C)与群聊(群 OpenID)
2424
/// 支持文本消息和截图图片消息(分片上传)。
2525
/// 本通知器已通过三轮 AI 代码审查。
2626
/// </summary>
@@ -31,28 +31,33 @@ public sealed class QqNotifier : INotifier
3131
public string Name { get; } = "QQ";
3232

3333
private const string TokenUrl = "https://bots.qq.com/app/getAppAccessToken";
34-
private const string ApiBase = "https://api.sgroup.qq.com/v2/users/{openid}";
34+
private const string C2CBase = "https://api.sgroup.qq.com/v2/users/{openid}";
35+
private const string GroupBase = "https://api.sgroup.qq.com/v2/groups/{openid}";
3536
private const int MaxRetry = 3;
3637

3738
private readonly HttpClient _httpClient;
3839
private readonly string _appId;
3940
private readonly string _clientSecret;
4041
private readonly string _openId;
42+
private readonly string _groupOpenId;
4143

4244
private string? _cachedToken;
4345
private DateTime _tokenExpiry = DateTime.MinValue;
4446
private readonly SemaphoreSlim _tokenSemaphore = new(1, 1);
4547

46-
public QqNotifier(HttpClient httpClient, string appId, string clientSecret, string openId)
48+
public QqNotifier(HttpClient httpClient, string appId, string clientSecret, string openId, string groupOpenId)
4749
{
4850
_httpClient = httpClient;
4951
_appId = appId;
5052
_clientSecret = clientSecret;
5153
_openId = openId;
54+
_groupOpenId = groupOpenId;
5255
}
5356

5457
/// <summary>
55-
/// 发送通知:先发文本,再发截图(如有)。截图失败时降级为纯文本。
58+
/// 发送通知:对每个已配置目标(C2C / 群)先发文本,再发截图(如有)。
59+
/// 单个目标的截图失败时降级为纯文本,不阻断其他目标;
60+
/// 单个目标的文本失败时记 Warning 后继续下一目标,使 C2C 与群互不影响。
5661
/// </summary>
5762
public async Task SendAsync(BaseNotificationData content)
5863
{
@@ -62,27 +67,51 @@ public async Task SendAsync(BaseNotificationData content)
6267
if (string.IsNullOrWhiteSpace(_clientSecret))
6368
throw new NotifierException("QQ AppSecret 为空");
6469

65-
if (string.IsNullOrWhiteSpace(_openId))
66-
throw new NotifierException("QQ OpenID 为空");
70+
if (string.IsNullOrWhiteSpace(_openId) && string.IsNullOrWhiteSpace(_groupOpenId))
71+
throw new NotifierException("QQ OpenID 与群 OpenID 均为空");
6772

6873
var ct = CancellationToken.None;
74+
var targets = BuildTargets();
6975
try
7076
{
7177
var text = GenerateMessage(content);
72-
await SendTextAsync(text, ct);
73-
74-
if (content.Screenshot != null)
78+
var successCount = 0;
79+
var lastError = string.Empty;
80+
foreach (var target in targets)
7581
{
7682
try
7783
{
78-
await SendImageAsync(content.Screenshot, ct);
84+
await SendTextAsync(target, text, ct);
85+
successCount++;
86+
87+
if (content.Screenshot != null)
88+
{
89+
try
90+
{
91+
await SendImageAsync(target, content.Screenshot, ct);
92+
}
93+
catch (System.Exception ex)
94+
{
95+
// 单个目标的图片发送失败时降级为纯文本,不阻断其他目标
96+
Logger.LogWarning("QQ 图片发送失败(目标 {target}),降级为纯文本: {ex}", target, ex.Message);
97+
}
98+
}
99+
}
100+
catch (NotifierException)
101+
{
102+
throw;
79103
}
80104
catch (System.Exception ex)
81105
{
82-
// 图片发送失败时降级为纯文本,不阻断通知
83-
Logger.LogWarning("QQ 图片发送失败,降级为纯文本: {ex}", ex.Message);
106+
// 单个目标的文本发送失败时记 Warning 后 continue 下一目标,使 C2C 与群互不影响
107+
lastError = ex.Message;
108+
Logger.LogWarning("QQ 文本发送失败(目标 {target}),跳过该目标: {ex}", target, ex.Message);
84109
}
85110
}
111+
112+
// 全部目标发送失败时向调用方抛出异常,避免通知管理器误报成功
113+
if (successCount == 0)
114+
throw new NotifierException($"发送 QQ 消息失败: {targets.Count} 个目标全部失败,最后错误: {lastError}");
86115
}
87116
catch (NotifierException)
88117
{
@@ -94,6 +123,19 @@ public async Task SendAsync(BaseNotificationData content)
94123
}
95124
}
96125

126+
/// <summary>
127+
/// 构造发送目标 baseUrl 列表:C2C OpenID 非空加入私聊,群 OpenID 非空加入群聊。
128+
/// </summary>
129+
private List<string> BuildTargets()
130+
{
131+
var targets = new List<string>(2);
132+
if (!string.IsNullOrWhiteSpace(_openId))
133+
targets.Add(C2CBase.Replace("{openid}", _openId));
134+
if (!string.IsNullOrWhiteSpace(_groupOpenId))
135+
targets.Add(GroupBase.Replace("{openid}", _groupOpenId));
136+
return targets;
137+
}
138+
97139
/// <summary>
98140
/// 生成通知文本。时间戳放在第一行,推送内容在第二行,排版更紧凑。
99141
/// </summary>
@@ -137,7 +179,7 @@ private async Task<string> RefreshTokenAsync(CancellationToken ct)
137179
using var content = new StringContent(body, Encoding.UTF8, "application/json");
138180
using var request = new HttpRequestMessage(HttpMethod.Post, TokenUrl) { Content = content };
139181
using var response = await _httpClient.SendAsync(request, ct);
140-
response.EnsureSuccessStatusCode();
182+
await EnsureSuccessWithBodyAsync(response, ct);
141183
var json = await response.Content.ReadAsStringAsync(ct);
142184
using var doc = JsonDocument.Parse(json);
143185
var root = doc.RootElement;
@@ -160,21 +202,40 @@ private async Task<HttpRequestMessage> BuildAuthedRequest(HttpMethod method, str
160202
}
161203

162204
/// <summary>
163-
/// 发送纯文本消息(msg_type=0)。注意:此请求非幂等,不重试,避免重复消息。
205+
/// 校验响应成功;失败时先读取响应体再抛异常(保留状态码供重试判断),
206+
/// 便于携带 QQ 服务端返回的具体错误信息。
207+
/// </summary>
208+
private static async Task EnsureSuccessWithBodyAsync(HttpResponseMessage response, CancellationToken ct)
209+
{
210+
if (response.IsSuccessStatusCode)
211+
return;
212+
213+
var statusCode = response.StatusCode;
214+
var body = await response.Content.ReadAsStringAsync(ct);
215+
throw new HttpRequestException(
216+
$"QQ API 请求失败 ({(int)statusCode}):{(string.IsNullOrWhiteSpace(body) ? response.ReasonPhrase : body)}",
217+
null,
218+
statusCode);
219+
}
220+
221+
/// <summary>
222+
/// 发送纯文本消息(msg_type=0)到指定目标 baseUrl(C2C 或群)。
223+
/// 注意:此请求非幂等,不重试,避免重复消息。
164224
/// </summary>
165-
private async Task SendTextAsync(string text, CancellationToken ct)
225+
private async Task SendTextAsync(string baseUrl, string text, CancellationToken ct)
166226
{
167227
var body = JsonSerializer.Serialize(new { msg_type = 0, content = text });
168228
using var jsonContent = new StringContent(body, Encoding.UTF8, "application/json");
169-
using var request = await BuildAuthedRequest(HttpMethod.Post, $"{ApiBase.Replace("{openid}", _openId)}/messages", jsonContent, ct);
229+
using var request = await BuildAuthedRequest(HttpMethod.Post, $"{baseUrl}/messages", jsonContent, ct);
170230
using var response = await _httpClient.SendAsync(request, ct);
171-
response.EnsureSuccessStatusCode();
231+
await EnsureSuccessWithBodyAsync(response, ct);
172232
}
173233

174234
/// <summary>
175-
/// 发送截图图片消息(msg_type=7):先分片上传图片拿到 file_info,再发富媒体消息。
235+
/// 发送截图图片消息(msg_type=7)到指定目标 baseUrl(C2C 或群):
236+
/// 先分片上传图片拿到 file_info,再发富媒体消息。
176237
/// </summary>
177-
private async Task SendImageAsync(Image<Rgb24> screenshot, CancellationToken ct)
238+
private async Task SendImageAsync(string baseUrl, Image<Rgb24> screenshot, CancellationToken ct)
178239
{
179240
byte[] imageBytes;
180241
using (var ms = new MemoryStream())
@@ -183,26 +244,25 @@ private async Task SendImageAsync(Image<Rgb24> screenshot, CancellationToken ct)
183244
imageBytes = ms.ToArray();
184245
}
185246

186-
var fileInfo = await UploadImageChunkedAsync(imageBytes, ct);
247+
var fileInfo = await UploadImageChunkedAsync(baseUrl, imageBytes, ct);
187248

188249
var body = JsonSerializer.Serialize(new
189250
{
190251
msg_type = 7,
191252
media = new { file_info = fileInfo }
192253
});
193254
using var jsonContent = new StringContent(body, Encoding.UTF8, "application/json");
194-
using var request = await BuildAuthedRequest(HttpMethod.Post, $"{ApiBase.Replace("{openid}", _openId)}/messages", jsonContent, ct);
255+
using var request = await BuildAuthedRequest(HttpMethod.Post, $"{baseUrl}/messages", jsonContent, ct);
195256
using var response = await _httpClient.SendAsync(request, ct);
196-
response.EnsureSuccessStatusCode();
257+
await EnsureSuccessWithBodyAsync(response, ct);
197258
}
198259

199260
/// <summary>
200261
/// 分片上传图片:prepare → 逐片 PUT → part_finish → 合并拿 file_info。
201262
/// 上传阶段幂等,可重试。
202263
/// </summary>
203-
private async Task<string> UploadImageChunkedAsync(byte[] imageBytes, CancellationToken ct)
264+
private async Task<string> UploadImageChunkedAsync(string baseUrl, byte[] imageBytes, CancellationToken ct)
204265
{
205-
var baseUrl = ApiBase.Replace("{openid}", _openId);
206266
var fileName = "screenshot.jpg";
207267
var md5 = Convert.ToHexString(MD5.HashData(imageBytes)).ToLower();
208268
var sha1 = Convert.ToHexString(SHA1.HashData(imageBytes)).ToLower();
@@ -240,7 +300,7 @@ private async Task<UploadPrepareResult> PrepareUploadAsync(string baseUrl, strin
240300
md5_10m = md5First10m
241301
}), Encoding.UTF8, "application/json"), ct);
242302
using var response = await _httpClient.SendAsync(request, ct);
243-
response.EnsureSuccessStatusCode();
303+
await EnsureSuccessWithBodyAsync(response, ct);
244304
var json = await response.Content.ReadAsStringAsync(ct);
245305
using var doc = JsonDocument.Parse(json);
246306
var uploadId = doc.RootElement.GetProperty("upload_id").GetString()!;
@@ -294,7 +354,7 @@ private async Task UploadChunkAsync(string presignedUrl, byte[] chunk, Cancellat
294354
using var putContent = new ByteArrayContent(chunk);
295355
putContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
296356
using var putResponse = await _httpClient.PutAsync(presignedUrl, putContent, ct);
297-
putResponse.EnsureSuccessStatusCode();
357+
await EnsureSuccessWithBodyAsync(putResponse, ct);
298358
}
299359

300360
/// <summary>
@@ -311,7 +371,7 @@ private async Task FinishChunkAsync(string baseUrl, string uploadId, int partInd
311371
md5 = chunkMd5
312372
}), Encoding.UTF8, "application/json"), ct);
313373
using var response = await _httpClient.SendAsync(request, ct);
314-
response.EnsureSuccessStatusCode();
374+
await EnsureSuccessWithBodyAsync(response, ct);
315375
}
316376

317377
/// <summary>
@@ -322,7 +382,7 @@ private async Task<string> MergeUploadAsync(string baseUrl, string uploadId, Can
322382
using var request = await BuildAuthedRequest(HttpMethod.Post, $"{baseUrl}/files", new StringContent(
323383
JsonSerializer.Serialize(new { file_type = 1, upload_id = uploadId }), Encoding.UTF8, "application/json"), ct);
324384
using var response = await _httpClient.SendAsync(request, ct);
325-
response.EnsureSuccessStatusCode();
385+
await EnsureSuccessWithBodyAsync(response, ct);
326386
var json = await response.Content.ReadAsStringAsync(ct);
327387
using var doc = JsonDocument.Parse(json);
328388
return doc.RootElement.GetProperty("file_info").GetString()!;

0 commit comments

Comments
 (0)