Skip to content

Commit

Permalink
feat(openai): 新增 OpenAI v2 版接口客户端,并实现加解密及签名中间件
Browse files Browse the repository at this point in the history
  • Loading branch information
fudiwei committed Jun 4, 2024
1 parent ad9b5a1 commit 921b968
Show file tree
Hide file tree
Showing 24 changed files with 807 additions and 14 deletions.
5 changes: 3 additions & 2 deletions docs/WechatOpenAI/README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SKIT.FlurlHttpClient.Wechat.OpenAI
# SKIT.FlurlHttpClient.Wechat.OpenAI

基于 `Flurl.Http`[微信对话开放平台](https://chatbot.weixin.qq.com/) HTTP API SDK。

Expand All @@ -7,7 +7,8 @@
## 功能

- 基于微信对话开放平台 API 封装。
- 提供了微信对话开放平台所需的 AES、SHA-1 等算法工具类。
- 针对 v2 版接口,请求时自动生成签名,无需开发者手动干预。
- 提供了微信对话开放平台所需的 AES、MD5、SHA-1 等算法工具类。
- 提供了解析回调通知事件等扩展方法。

---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
Expand Down Expand Up @@ -299,12 +300,12 @@ public override async Task AfterCallAsync(HttpInterceptorContext context, Cancel

if (context.FlurlCall.HttpRequestMessage.Method != HttpMethod.Post)
return;
if (context.FlurlCall.HttpRequestMessage.RequestUri is null)
return;
if (!IsRequestUrlPathMatched(context.FlurlCall.HttpRequestMessage.RequestUri))
if (context.FlurlCall.HttpRequestMessage.RequestUri is null || !IsRequestUrlPathMatched(context.FlurlCall.HttpRequestMessage.RequestUri))
return;
if (context.FlurlCall.HttpResponseMessage is null)
return;
if (context.FlurlCall.HttpResponseMessage.StatusCode != HttpStatusCode.OK)
return;

string urlpath = GetRequestUrlPath(context.FlurlCall.HttpRequestMessage.RequestUri);
byte[] respBytes = Array.Empty<byte>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Flurl.Http;

namespace SKIT.FlurlHttpClient.Wechat.OpenAI
{
public static class WechatOpenAIClientExecuteTokenExtensions
{
/// <summary>
/// <para>异步调用 [POST] /v2/token 接口。</para>
/// <para>
/// REF: <br/>
/// <![CDATA[ https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html ]]>
/// </para>
/// </summary>
/// <param name="client"></param>
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public static async Task<Models.TokenV2Response> ExecuteTokenV2Async(this WechatOpenAIClient client, Models.TokenV2Request request, CancellationToken cancellationToken = default)
{
if (client is null) throw new ArgumentNullException(nameof(client));
if (request is null) throw new ArgumentNullException(nameof(request));

IFlurlRequest flurlReq = client
.CreateFlurlRequest(request, HttpMethod.Post, "v2", "token");

return await client.SendFlurlRequestAsJsonAsync<Models.TokenV2Response>(flurlReq, data: request, cancellationToken: cancellationToken).ConfigureAwait(false);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Flurl.Http;

namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Interceptors
{
using SKIT.FlurlHttpClient.Internal;

internal class WechatOpenAIRequestEncryptionInterceptor : HttpInterceptor
{
/**
* REF:
* https://developers.weixin.qq.com/doc/aispeech/confapi/dialog/token.html
*/
private static readonly ISet<string> ENCRYPT_REQUIRED_URLS = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"/v2/bot/query"
};

private readonly string _baseUrl;
private readonly string _encodingAESKey;
private readonly Func<string, bool>? _customEncryptedRequestPathMatcher;

public WechatOpenAIRequestEncryptionInterceptor(string baseUrl, string encodingAESKey, Func<string, bool>? customEncryptedRequestPathMatcher)
{
_baseUrl = baseUrl;
_encodingAESKey = encodingAESKey;
_customEncryptedRequestPathMatcher = customEncryptedRequestPathMatcher;

// AES 密钥的长度不是 4 的倍数需要补齐,确保其始终为有效的 Base64 字符串
const int MULTI = 4;
int tLen = _encodingAESKey.Length;
int tRem = tLen % MULTI;
if (tRem > 0)
{
_encodingAESKey = _encodingAESKey.PadRight(tLen - tRem + MULTI, '=');
}
}

public override async Task BeforeCallAsync(HttpInterceptorContext context, CancellationToken cancellationToken = default)
{
if (context is null) throw new ArgumentNullException(nameof(context));
if (context.FlurlCall.Completed) throw new WechatOpenAIException("Failed to encrypt request. This interceptor must be called before request completed.");

if (context.FlurlCall.HttpRequestMessage.RequestUri is null || !IsRequestUrlPathMatched(context.FlurlCall.HttpRequestMessage.RequestUri))
return;

byte[] reqBytes = Array.Empty<byte>();
if (context.FlurlCall.HttpRequestMessage?.Content is not null)
{
if (context.FlurlCall.HttpRequestMessage.Content is not MultipartFormDataContent)
{
reqBytes = await
#if NET5_0_OR_GREATER
context.FlurlCall.HttpRequestMessage.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
#else
_AsyncEx.RunTaskWithCancellationTokenAsync(context.FlurlCall.HttpRequestMessage.Content.ReadAsByteArrayAsync(), cancellationToken).ConfigureAwait(false);
#endif
}
}

byte[] reqBytesEncrypted = Array.Empty<byte>();
try
{
const int AES_BLOCK_SIZE = 16;
byte[] keyBytes = Convert.FromBase64String(_encodingAESKey);
byte[] ivBytes = new byte[AES_BLOCK_SIZE]; // iv 是 key 的前 16 个字节
Buffer.BlockCopy(keyBytes, 0, ivBytes, 0, ivBytes.Length);

reqBytesEncrypted = Utilities.AESUtility.EncryptWithCBC(
keyBytes: keyBytes,
ivBytes: ivBytes,
plainBytes: reqBytes
)!;
}
catch (Exception ex)
{
throw new WechatOpenAIException("Failed to encrypt request. Please see the inner exception for more details.", ex);
}

context.FlurlCall.HttpRequestMessage!.Content?.Dispose();
context.FlurlCall.HttpRequestMessage!.Content = new ByteArrayContent(reqBytesEncrypted);
context.FlurlCall.Request.WithHeader(HttpHeaders.ContentType, MimeTypes.Text);
}

public override async Task AfterCallAsync(HttpInterceptorContext context, CancellationToken cancellationToken = default)
{
if (context is null) throw new ArgumentNullException(nameof(context));
if (!context.FlurlCall.Completed) throw new WechatOpenAIException("Failed to decrypt response. This interceptor must be called after request completed.");

if (context.FlurlCall.HttpRequestMessage.RequestUri is null || !IsRequestUrlPathMatched(context.FlurlCall.HttpRequestMessage.RequestUri))
return;
if (context.FlurlCall.HttpResponseMessage is null)
return;
if (context.FlurlCall.HttpResponseMessage.StatusCode != HttpStatusCode.OK)
return;

byte[] respBytes = Array.Empty<byte>();
if (context.FlurlCall.HttpResponseMessage.Content is not null)
{
HttpContent httpContent = context.FlurlCall.HttpResponseMessage.Content;
respBytes = await
#if NET5_0_OR_GREATER
httpContent.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
#else
_AsyncEx.RunTaskWithCancellationTokenAsync(httpContent.ReadAsByteArrayAsync(), cancellationToken).ConfigureAwait(false);
#endif
}

byte[] respBytesDecrypted;
try
{
const int AES_BLOCK_SIZE = 16;
byte[] keyBytes = Convert.FromBase64String(_encodingAESKey);
byte[] ivBytes = new byte[AES_BLOCK_SIZE]; // iv 是 key 的前 16 个字节
Buffer.BlockCopy(keyBytes, 0, ivBytes, 0, ivBytes.Length);

respBytesDecrypted = Utilities.AESUtility.DecryptWithCBC(
keyBytes: keyBytes,
ivBytes: ivBytes,
cipherBytes: respBytes
)!;
}
catch (Exception ex)
{
throw new WechatOpenAIException("Failed to decrypt response. Please see the inner exception for more details.", ex);
}

context.FlurlCall.HttpResponseMessage!.Content?.Dispose();
context.FlurlCall.HttpResponseMessage!.Content = new ByteArrayContent(respBytesDecrypted);
}

private string GetRequestUrlPath(Uri uri)
{
return uri.AbsoluteUri.Substring(0, uri.AbsoluteUri.Length - uri.Query.Length);
}

private bool IsRequestUrlPathMatched(Uri uri)
{
string absoluteUrl = GetRequestUrlPath(uri);
if (!absoluteUrl.StartsWith(_baseUrl))
return false;

string relativeUrl = absoluteUrl.Substring(_baseUrl.TrimEnd('/').Length);
if (!ENCRYPT_REQUIRED_URLS.Contains(relativeUrl))
{
if (_customEncryptedRequestPathMatcher is not null)
return _customEncryptedRequestPathMatcher(relativeUrl);
}

return true;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Flurl.Http;

namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Interceptors
{
using SKIT.FlurlHttpClient.Internal;

internal class WechatOpenAIRequestSigningInterceptor : HttpInterceptor
{
private readonly string _token;

public WechatOpenAIRequestSigningInterceptor(string token)
{
_token = token;
}

public override async Task BeforeCallAsync(HttpInterceptorContext context, CancellationToken cancellationToken = default)
{
if (context is null) throw new ArgumentNullException(nameof(context));
if (context.FlurlCall.Completed) throw new WechatOpenAIException("Failed to sign request. This interceptor must be called before request completed.");

if (context.FlurlCall.HttpRequestMessage.RequestUri is null)
return;

string timestamp = DateTimeOffset.Now.ToLocalTime().ToUnixTimeSeconds().ToString();
string nonce = Guid.NewGuid().ToString("N");
string body = string.Empty;
if (context.FlurlCall.HttpRequestMessage?.Content is not null)
{
if (context.FlurlCall.HttpRequestMessage.Content is MultipartFormDataContent)
{
body = string.Empty;
}
else
{
body = await
#if NET5_0_OR_GREATER
context.FlurlCall.HttpRequestMessage.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
#else
_AsyncEx.RunTaskWithCancellationTokenAsync(context.FlurlCall.HttpRequestMessage.Content.ReadAsStringAsync(), cancellationToken).ConfigureAwait(false);
#endif
}
}

string signData = $"{_token}{timestamp}{nonce}{Utilities.MD5Utility.Hash(body).Value!.ToLower()}";
string sign = Utilities.MD5Utility.Hash(signData).Value!.ToLower();

context.FlurlCall.Request.WithHeader("timestamp", timestamp);
context.FlurlCall.Request.WithHeader("nonce", nonce);
context.FlurlCall.Request.WithHeader("sign", sign);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Models
{
/// <summary>
/// <para>表示 [POST] /v2/token 接口的请求。</para>
/// </summary>
public class TokenV2Request : WechatOpenAIRequest
{
/// <summary>
/// 获取或设置操作数据的管理员 ID。
/// </summary>
[Newtonsoft.Json.JsonProperty("account")]
[System.Text.Json.Serialization.JsonPropertyName("account")]
public string Account { get; set; } = string.Empty;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Models
{
/// <summary>
/// <para>表示 [POST] /v2/token 接口的响应。</para>
/// </summary>
public class TokenV2Response : WechatOpenAIResponse<TokenV2Response.Types.Data>
{
public static class Types
{
public class Data
{
/// <summary>
/// 获取或设置接口访问令牌。
/// </summary>
[Newtonsoft.Json.JsonProperty("access_token")]
[System.Text.Json.Serialization.JsonPropertyName("access_token")]
public string AccessToken { get; set; } = default!;
}
}
}
}
3 changes: 2 additions & 1 deletion src/SKIT.FlurlHttpClient.Wechat.OpenAI/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
### 【功能特性】

- 基于微信对话开放平台 API 封装。
- 提供了微信对话开放平台所需的 AES、SHA-1 等算法工具类。
- 针对 v2 版接口,请求时自动生成签名,无需开发者手动干预。
- 提供了微信对话开放平台所需的 AES、MD5、SHA-1 等算法工具类。
- 提供了解析回调通知事件等扩展方法。

---
Expand Down
15 changes: 12 additions & 3 deletions src/SKIT.FlurlHttpClient.Wechat.OpenAI/Settings/Credentials.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,29 @@ namespace SKIT.FlurlHttpClient.Wechat.OpenAI.Settings
public sealed class Credentials
{
/// <summary>
/// 初始化客户端时 <see cref="WechatChatbotClientOptions.AppId"/> 的副本。
/// 初始化客户端时 <see cref="WechatOpenAIClientOptions.AppId"/> / <see cref="WechatChatbotClientOptions.AppId"/> 的副本。
/// </summary>
public string AppId { get; }

/// <summary>
/// 初始化客户端时 <see cref="WechatChatbotClientOptions.Token"/> 的副本。
/// 初始化客户端时 <see cref="WechatOpenAIClientOptions.Token"/> / <see cref="WechatChatbotClientOptions.Token"/> 的副本。
/// </summary>
public string Token { get; }

/// <summary>
/// 初始化客户端时 <see cref="WechatChatbotClientOptions.EncodingAESKey"/> 的副本。
/// 初始化客户端时 <see cref="WechatOpenAIClientOptions.EncodingAESKey"/> / <see cref="WechatChatbotClientOptions.EncodingAESKey"/> 的副本。
/// </summary>
public string EncodingAESKey { get; }

internal Credentials(WechatOpenAIClientOptions options)
{
if (options is null) throw new ArgumentNullException(nameof(options));

AppId = options.AppId;
Token = options.Token;
EncodingAESKey = options.EncodingAESKey;
}

internal Credentials(WechatChatbotClientOptions options)
{
if (options is null) throw new ArgumentNullException(nameof(options));
Expand Down
Loading

0 comments on commit 921b968

Please sign in to comment.