Skip to content

Commit b5d8a56

Browse files
author
Tomasz Juszczak
committed
Added image processing capabilities
1 parent 4e92049 commit b5d8a56

File tree

6 files changed

+182
-58
lines changed

6 files changed

+182
-58
lines changed

.github/workflows/docker-publish.yml

-2
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,6 @@ jobs:
100100
# https://github.com/sigstore/cosign
101101
- name: Sign the published Docker image
102102
if: ${{ github.event_name != 'pull_request' }}
103-
env:
104-
COSIGN_EXPERIMENTAL: "true"
105103
# This step uses the identity token to provision an ephemeral certificate
106104
# against the sigstore community Fulcio instance.
107105
run: echo "${{ steps.meta.outputs.tags }}" | xargs -I {} cosign sign {}@${{ steps.build-and-push.outputs.digest }}

Slack-GPT-Socket/GptApi/GptClient.cs

+112-9
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
using System.Diagnostics;
2+
using System.Net.Http.Headers;
23
using Microsoft.Extensions.Options;
34
using Newtonsoft.Json;
45
using OpenAI;
56
using OpenAI.Chat;
67
using Slack_GPT_Socket.Settings;
78
using Slack_GPT_Socket.Utilities.LiteDB;
9+
using SlackNet.Blocks;
10+
using SlackNet.Events;
811

912
namespace Slack_GPT_Socket.GptApi;
1013

@@ -15,8 +18,10 @@ public class GptClient
1518
{
1619
private readonly OpenAIClient _api;
1720
private readonly ILogger _log;
21+
private readonly IOptions<ApiSettings> _settings;
1822
private readonly GptDefaults _gptDefaults;
1923
private readonly GptClientResolver _resolver;
24+
private readonly IHttpClientFactory _httpClientFactory;
2025

2126
/// <summary>
2227
/// Initializes a new instance of the <see cref="GptClient" /> class.
@@ -25,37 +30,133 @@ public class GptClient
2530
/// <param name="log">The logger instance.</param>
2631
/// <param name="settings">The API settings.</param>
2732
public GptClient(
28-
GptCustomCommands customCommands,
33+
GptCustomCommands customCommands,
2934
IUserCommandDb userCommandDb,
30-
ILogger<GptClient> log,
35+
ILogger<GptClient> log,
3136
IOptions<GptDefaults> gptDefaults,
32-
IOptions<ApiSettings> settings)
37+
IOptions<ApiSettings> settings,
38+
IHttpClientFactory httpClientFactory)
3339
{
34-
var httpClient = new HttpClient
35-
{
36-
Timeout = TimeSpan.FromMinutes(10)
37-
};
40+
_httpClientFactory = httpClientFactory;
3841
_api = new OpenAIClient(settings.Value.OpenAIKey);
3942
_log = log;
43+
_settings = settings;
4044
_gptDefaults = gptDefaults.Value;
4145
_resolver = new GptClientResolver(customCommands, _gptDefaults, userCommandDb);
4246
}
4347

4448
/// <summary>
4549
/// Generates a response based on the given chat prompts.
4650
/// </summary>
51+
/// <param name="slackEvent">Input slack event</param>
4752
/// <param name="chatPrompts">The list of chat prompts.</param>
4853
/// <param name="userId">The user identifier.</param>
4954
/// <returns>A task representing the asynchronous operation, with a result of the generated response.</returns>
50-
public async Task<GptResponse> GeneratePrompt(List<WritableMessage> chatPrompts, string userId)
55+
public async Task<GptResponse> GeneratePrompt(MessageEventBase slackEvent, List<WritableMessage> chatPrompts,
56+
string userId)
5157
{
5258
// get the last prompt
5359
var userPrompt = chatPrompts.Last(chatPrompt => chatPrompt.Role == Role.User);
5460
var prompt = GptRequest.Default(_gptDefaults);
5561
prompt.UserId = userId;
5662
prompt.Prompt = userPrompt.Content;
5763

58-
var chatRequest = _resolver.ParseRequest(chatPrompts, prompt);
64+
// TODO: Refactor me!!!
65+
66+
var files = new List<ChatMessageContentPart>();
67+
foreach (var file in slackEvent.Files)
68+
{
69+
var fileUrl = file.UrlPrivateDownload ?? file.UrlPrivate;
70+
if (string.IsNullOrEmpty(fileUrl))
71+
{
72+
return new GptResponse
73+
{
74+
Error = "Requested file to process with this request, but it doesn't have a download URL"
75+
};
76+
}
77+
78+
var httpClient = _httpClientFactory.CreateClient();
79+
// configure httpClient to allow images and other files
80+
httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(file.Mimetype));
81+
httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _settings.Value.SlackBotToken);
82+
var fileRequest = await httpClient.GetAsync(fileUrl);
83+
if (!fileRequest.IsSuccessStatusCode)
84+
{
85+
return new GptResponse
86+
{
87+
Error = "Requested file to process with this request, but it couldn't be downloaded successfully"
88+
};
89+
}
90+
var fileContent = await fileRequest.Content.ReadAsStreamAsync();
91+
var headers = fileRequest.Content.Headers;
92+
93+
// check if headers contain the mimetype
94+
if (!headers.TryGetValues("Content-Type", out var contentTypes))
95+
{
96+
return new GptResponse
97+
{
98+
Error = "Requested file to process with this request, but it doesn't have a mimetype"
99+
};
100+
}
101+
var contentType = contentTypes.FirstOrDefault();
102+
if (contentType == null)
103+
{
104+
return new GptResponse
105+
{
106+
Error = "Requested file to process with this request, but it doesn't have a mimetype"
107+
};
108+
}
109+
// check if the mimetype is equal to the file mimetype
110+
if (contentType != file.Mimetype)
111+
{
112+
return new GptResponse
113+
{
114+
Error = "Requested file to process with this request, but the mimetype doesn't match the file mimetype " +
115+
$"expected {file.Mimetype} but got {contentType}"
116+
};
117+
}
118+
119+
using var memoryStream = new MemoryStream();
120+
await fileContent.CopyToAsync(memoryStream);
121+
memoryStream.Position = 0;
122+
123+
var chatPart = ChatMessageContentPart.CreateImagePart(
124+
await BinaryData.FromStreamAsync(memoryStream), file.Mimetype);
125+
files.Add(chatPart);
126+
}
127+
128+
// TODO: Refactor me!!!
129+
130+
if (slackEvent.Blocks != null)
131+
{
132+
foreach (var block in slackEvent.Blocks)
133+
{
134+
if (block is not RichTextBlock rtb) continue;
135+
foreach (var element in rtb.Elements)
136+
{
137+
if (element is not RichTextSection rts) continue;
138+
foreach (var innerElement in rts.Elements)
139+
{
140+
if (innerElement is not RichTextLink rtl) continue;
141+
142+
var uri = new Uri(rtl.Url);
143+
if (uri.Scheme == "http" || uri.Scheme == "https")
144+
{
145+
var httpClient = _httpClientFactory.CreateClient();
146+
var response = await httpClient.GetAsync(uri);
147+
if (response.IsSuccessStatusCode &&
148+
response.Content.Headers.ContentType!.MediaType!.StartsWith("image"))
149+
{
150+
var chatPart = ChatMessageContentPart.CreateImagePart(uri);
151+
files.Add(chatPart);
152+
}
153+
}
154+
}
155+
}
156+
}
157+
}
158+
159+
var chatRequest = _resolver.ParseRequest(chatPrompts, prompt, files);
59160

60161
try
61162
{
@@ -65,6 +166,8 @@ public async Task<GptResponse> GeneratePrompt(List<WritableMessage> chatPrompts,
65166
var chatCompletion = result.Value;
66167
_log.LogInformation("GPT response: {Response}", JsonConvert.SerializeObject(chatCompletion));
67168

169+
170+
68171
return new GptResponse
69172
{
70173
Message = chatCompletion.Content.Last().Text,

Slack-GPT-Socket/GptApi/GptClientResolver.cs

+18-33
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Text.RegularExpressions;
2-
using OpenAI;
32
using OpenAI.Chat;
43
using Slack_GPT_Socket.GptApi.ParameterResolvers;
54
using Slack_GPT_Socket.Settings;
@@ -31,8 +30,10 @@ public GptClientResolver(GptCustomCommands customCommands, GptDefaults gptDefaul
3130
/// </summary>
3231
/// <param name="chatPrompts">The list of chat prompts.</param>
3332
/// <param name="request">The GPT request.</param>
33+
/// <param name="files">List of files attached to this prompt</param>
3434
/// <returns>A ChatRequest instance.</returns>
35-
public (IEnumerable<ChatMessage> Messages, ChatCompletionOptions Options, string Model) ParseRequest(List<WritableMessage> chatPrompts, GptRequest request)
35+
public (IEnumerable<ChatMessage> Messages, ChatCompletionOptions Options, string Model) ParseRequest(
36+
List<WritableMessage> chatPrompts, GptRequest request, List<ChatMessageContentPart>? files = null)
3637
{
3738
foreach (var chatPrompt in chatPrompts)
3839
{
@@ -42,12 +43,11 @@ public GptClientResolver(GptCustomCommands customCommands, GptDefaults gptDefaul
4243
ResolveModel(ref content);
4344
ResolveParameters(ref content);
4445
chatPrompt.Content = content.Prompt;
45-
4646
}
4747

4848
ResolveModel(ref request);
4949
ResolveParameters(ref request);
50-
50+
5151
var requestPrompts = new List<WritableMessage>();
5252
requestPrompts.AddRange(chatPrompts);
5353

@@ -59,27 +59,13 @@ public GptClientResolver(GptCustomCommands customCommands, GptDefaults gptDefaul
5959
TopP = request.TopP,
6060
PresencePenalty = request.PresencePenalty,
6161
FrequencyPenalty = request.FrequencyPenalty,
62-
EndUserId = request.UserId,
62+
EndUserId = request.UserId
6363
};
64+
chatPrompts.Last().Files = files ?? [];
65+
6466
foreach (var chatPrompt in chatPrompts)
6567
{
66-
switch (chatPrompt.Role)
67-
{
68-
case Role.User:
69-
messages.Add(new UserChatMessage(chatPrompt.Content));
70-
break;
71-
case Role.Assistant:
72-
messages.Add(new AssistantChatMessage(chatPrompt.Content));
73-
break;
74-
case Role.System:
75-
messages.Add(new SystemChatMessage(chatPrompt.Content));
76-
break;
77-
case Role.Tool:
78-
messages.Add(new ToolChatMessage(chatPrompt.Content));
79-
break;
80-
default:
81-
throw new ArgumentOutOfRangeException();
82-
}
68+
messages.Add(chatPrompt.ToChatMessage());
8369
}
8470

8571
return (messages, options, request.Model);
@@ -130,10 +116,10 @@ private void ResolveModel(ref GptRequest input)
130116
private void ResolveParameters(ref GptRequest input)
131117
{
132118
var lastIndex = 0;
133-
Match match = ParameterRegex.Match(input.Prompt);
134-
135-
if(!match.Success) return;
136-
119+
var match = ParameterRegex.Match(input.Prompt);
120+
121+
if (!match.Success) return;
122+
137123
do
138124
{
139125
var paramName = match.Groups[1].Value;
@@ -190,16 +176,15 @@ private static void TrimInputFromParameter(GptRequest input, ParameterEventArgs
190176
if (args.HasValue)
191177
{
192178
// Find last index of this value args.ValueRaw
193-
var paramValueIndex = input.Prompt.IndexOf(args.ValueRaw, StringComparison.InvariantCultureIgnoreCase) + args.ValueRaw.Length + 1;
179+
var paramValueIndex = input.Prompt.IndexOf(args.ValueRaw, StringComparison.InvariantCultureIgnoreCase) +
180+
args.ValueRaw.Length + 1;
194181
lastIndex = paramValueIndex;
195182
input.Prompt = input.Prompt.Substring(paramValueIndex, input.Prompt.Length - paramValueIndex).Trim();
196183
return;
197184
}
198-
else
199-
{
200-
lastIndex = paramNameIndex + args.Name.Length + 2;
201-
searchString = args.Name + " ";
202-
input.Prompt = input.Prompt.Replace(searchString, "").Trim();
203-
}
185+
186+
lastIndex = paramNameIndex + args.Name.Length + 2;
187+
searchString = args.Name + " ";
188+
input.Prompt = input.Prompt.Replace(searchString, "").Trim();
204189
}
205190
}

Slack-GPT-Socket/GptApi/WritableMessage.cs

+28
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using OpenAI;
2+
using OpenAI.Chat;
23

34
namespace Slack_GPT_Socket.GptApi;
45

@@ -42,4 +43,31 @@ public WritableMessage(Role role, string userId, string content)
4243
/// Gets or sets the content of the chat prompt.
4344
/// </summary>
4445
public string Content { get; set; }
46+
47+
/// <summary>
48+
/// Gets or sets the files attached to the chat prompt.
49+
/// </summary>
50+
public List<ChatMessageContentPart> Files { get; set; }
51+
52+
public ChatMessage ToChatMessage()
53+
{
54+
var textContent = ChatMessageContentPart.CreateTextPart(Content);
55+
var fileContent = Files ?? [];
56+
var content = new List<ChatMessageContentPart> {textContent};
57+
content.AddRange(fileContent);
58+
59+
switch (Role)
60+
{
61+
case Role.User:
62+
return new UserChatMessage(content);
63+
case Role.Assistant:
64+
return new AssistantChatMessage(content);
65+
case Role.System:
66+
return new SystemChatMessage(content);
67+
case Role.Tool:
68+
return new ToolChatMessage(Content);
69+
default:
70+
throw new ArgumentOutOfRangeException();
71+
}
72+
}
4573
}

Slack-GPT-Socket/Program.cs

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
var builder = WebApplication.CreateBuilder(args);
1010

1111
var settings = builder.Configuration.GetSection("Api").Get<ApiSettings>()!;
12+
builder.Services.AddHttpClient();
1213
builder.Services.AddOptions<ApiSettings>().Bind(builder.Configuration.GetSection("Api"));
1314
builder.Services.Configure<GptCommands>(builder.Configuration.GetSection("GptCommands"));
1415
builder.Services.Configure<GptDefaults>(builder.Configuration.GetSection("GptDefaults"));

Slack-GPT-Socket/SlackHandlers/SlackMessageEventBaseHandler.cs

+23-14
Original file line numberDiff line numberDiff line change
@@ -307,30 +307,39 @@ public async Task PostGptAvailableWarningMessage(MessageEventBase slackEvent)
307307
/// <param name="slackEvent">Input slack event</param>
308308
/// <param name="context">The chat context to be used in generating the prompt.</param>
309309
/// <param name="userId">The user ID to be used in generating the prompt.</param>
310+
/// <param name="files">Files attached with the prompt</param>
310311
/// <returns>A GPTResponse instance containing the generated prompt.</returns>
311312
private async Task<GptResponse> GeneratePrompt(MessageEventBase slackEvent, List<WritableMessage> context,
312313
string userId)
313314
{
314-
// Start the periodic SendMessageProcessing task
315315
var cts = new CancellationTokenSource();
316-
var periodicTask = PeriodicSendMessageProcessing(slackEvent, cts.Token);
317-
318-
var result = await GeneratePromptRetry(slackEvent, context, userId);
319-
320-
// Cancel the periodic task once the long running method returns
321-
cts.Cancel();
322-
323-
// Ensure the periodic task has completed before proceeding
324316
try
325317
{
326-
await periodicTask;
318+
// Start the periodic SendMessageProcessing task
319+
var periodicTask = PeriodicSendMessageProcessing(slackEvent, cts.Token);
320+
321+
var result = await GeneratePromptRetry(slackEvent, context, userId);
322+
323+
await cts.CancelAsync();
324+
325+
// Ensure the periodic task has completed before proceeding
326+
try
327+
{
328+
await periodicTask;
329+
}
330+
catch (TaskCanceledException)
331+
{
332+
// Ignore CTS CancelledException
333+
}
334+
335+
return result;
327336
}
328-
catch (TaskCanceledException)
337+
finally
329338
{
330-
// Ignore CTS CancelledException
339+
if(!cts.Token.IsCancellationRequested)
340+
await cts.CancelAsync();
331341
}
332342

333-
return result;
334343
}
335344

336345
/// <summary>
@@ -346,7 +355,7 @@ private async Task<GptResponse> GeneratePromptRetry(MessageEventBase slackEvent,
346355
var errorsCount = 0;
347356
while (true)
348357
{
349-
var result = await _gptClient.GeneratePrompt(context, userId);
358+
var result = await _gptClient.GeneratePrompt(slackEvent, context, userId);
350359

351360
var repeatOnErrorsArray = new[]
352361
{

0 commit comments

Comments
 (0)