Skip to content

Commit 4943c6b

Browse files
authored
Merge pull request #776 from innogames/chatgpt_tags
feat: openai: add hashtag support for model selection, reasoning control, and message history
2 parents 57ddeb6 + fab3394 commit 4943c6b

8 files changed

Lines changed: 280 additions & 14 deletions

File tree

command/custom_commmands/handle.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func (c command) handle(ref msg.Ref, text string) bool {
1717
return false
1818
}
1919

20-
c.SendMessage(ref, fmt.Sprintf("executing command: `%s`", commands))
20+
c.SendEphemeralMessage(ref, fmt.Sprintf("executing command: `%s`", commands))
2121
for _, command := range strings.Split(commands, ";") {
2222
message := client.HandleMessageWithDoneHandler(ref.WithText(command))
2323
message.Wait()

command/custom_commmands/integration_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ func TestCustomCommands(t *testing.T) {
8787
message.User = "user1"
8888
message.Text = "alias 1"
8989

90-
mocks.AssertSlackMessage(slackClient, message, "executing command: `reply 1`")
90+
mocks.AssertSlackEphemeralMessage(slackClient, message, "executing command: `reply 1`")
9191

9292
getMessages := mocks.WaitForQueuedMessages(t, 1)
9393

command/openai/command.go

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ func (c *openaiCommand) newConversation(match matcher.Result, message msg.Messag
7373
}
7474

7575
func (c *openaiCommand) startConversation(message msg.Ref, text string) bool {
76+
// Parse hashtags and get cleaned text
77+
cleanText, hashtagOptions := ParseHashtags(text)
78+
7679
messageHistory := make([]ChatMessage, 0)
7780

7881
if c.cfg.InitialSystemMessage != "" {
@@ -82,6 +85,28 @@ func (c *openaiCommand) startConversation(message msg.Ref, text string) bool {
8285
})
8386
}
8487

88+
// Handle #message-history hashtag
89+
if hashtagOptions.MessageHistory > 0 {
90+
channelMessages, err := c.getChannelHistory(message.GetChannel(), hashtagOptions.MessageHistory)
91+
if err != nil {
92+
c.ReplyError(message, fmt.Errorf("can't load channel history: %w", err))
93+
return true
94+
}
95+
96+
// Add channel history as context
97+
messageHistory = append(messageHistory, ChatMessage{
98+
Role: roleSystem,
99+
Content: fmt.Sprintf("Recent channel conversation history (last %d messages):", hashtagOptions.MessageHistory),
100+
})
101+
102+
for _, channelMsg := range channelMessages {
103+
messageHistory = append(messageHistory, ChatMessage{
104+
Role: roleUser,
105+
Content: fmt.Sprintf("User <@%s> wrote: %s", channelMsg.User, channelMsg.Text),
106+
})
107+
}
108+
}
109+
85110
var storageIdentifier string
86111
if message.GetThread() != "" {
87112
// "openai" was triggered within a existing thread. -> fetch the whole thread history as context
@@ -106,10 +131,10 @@ func (c *openaiCommand) startConversation(message msg.Ref, text string) bool {
106131
if c.cfg.LogTexts {
107132
log.Infof("openai thread context: %s", messageHistory)
108133
}
109-
} else if linkRe.MatchString(text) {
134+
} else if linkRe.MatchString(cleanText) {
110135
// a link to another thread was posted -> use this messages as context
111-
link := linkRe.FindStringSubmatch(text)
112-
text = linkRe.ReplaceAllString(text, "")
136+
link := linkRe.FindStringSubmatch(cleanText)
137+
cleanText = linkRe.ReplaceAllString(cleanText, "")
113138

114139
relatedMessage := msg.MessageRef{
115140
Channel: link[2],
@@ -134,7 +159,7 @@ func (c *openaiCommand) startConversation(message msg.Ref, text string) bool {
134159
storageIdentifier = getIdentifier(message.GetChannel(), message.GetTimestamp())
135160
}
136161

137-
c.callAndStore(messageHistory, storageIdentifier, message, text)
162+
c.callAndStore(messageHistory, storageIdentifier, message, cleanText, hashtagOptions)
138163
return true
139164
}
140165

@@ -145,6 +170,9 @@ func (c *openaiCommand) reply(message msg.Ref, text string) bool {
145170
return false
146171
}
147172

173+
// Parse hashtags from reply message
174+
cleanText, hashtagOptions := ParseHashtags(text)
175+
148176
// Load the chat history from storage.
149177
identifier := getIdentifier(message.GetChannel(), message.GetThread())
150178

@@ -156,24 +184,40 @@ func (c *openaiCommand) reply(message msg.Ref, text string) bool {
156184
}
157185

158186
// Call the API and send the last messages as history to give a proper context
159-
c.callAndStore(messages, identifier, message, text)
187+
c.callAndStore(messages, identifier, message, cleanText, hashtagOptions)
160188

161189
return true
162190
}
163191

164192
// call the GPT-3 API, sends the response to the user, and stores the updated chat history.
165-
func (c *openaiCommand) callAndStore(messages []ChatMessage, storageIdentifier string, message msg.Ref, inputText string) {
193+
func (c *openaiCommand) callAndStore(messages []ChatMessage, storageIdentifier string, message msg.Ref, inputText string, options HashtagOptions) {
166194
// Append the actual user input to the message list.
167195
messages = append(messages, ChatMessage{
168196
Role: roleUser,
169197
Content: inputText,
170198
})
171199

172-
messages, inputTokens, truncatedMessages := truncateMessages(c.cfg.Model, messages)
200+
// Create a custom config based on hashtag options
201+
customCfg := c.cfg
202+
203+
// Apply model override if specified
204+
if options.Model != "" {
205+
customCfg.Model = options.Model
206+
}
207+
208+
// Apply reasoning effort if specified
209+
if options.ReasoningEffort == "none" {
210+
// User explicitly requested no reasoning effort with #no-thinking
211+
customCfg.ReasoningEffort = ""
212+
} else if options.ReasoningEffort != "" {
213+
customCfg.ReasoningEffort = options.ReasoningEffort
214+
}
215+
216+
messages, inputTokens, truncatedMessages := truncateMessages(customCfg.Model, messages)
173217
if truncatedMessages > 0 {
174218
c.SendMessage(
175219
message,
176-
fmt.Sprintf("Note: The token length of %d exceeded! %d messages were not sent", getMaxTokensForModel(c.cfg.Model), truncatedMessages),
220+
fmt.Sprintf("Note: The token length of %d exceeded! %d messages were not sent", getMaxTokensForModel(customCfg.Model), truncatedMessages),
177221
slack.MsgOptionTS(message.GetTimestamp()),
178222
)
179223
}
@@ -186,7 +230,8 @@ func (c *openaiCommand) callAndStore(messages []ChatMessage, storageIdentifier s
186230

187231
startTime := time.Now()
188232

189-
response, err := CallChatGPT(c.cfg, messages, true)
233+
// Use customCfg instead of c.cfg
234+
response, err := CallChatGPT(customCfg, messages, true)
190235
if err != nil {
191236
c.ReplyError(message, fmt.Errorf("openai error: %w", err))
192237
return
@@ -254,7 +299,16 @@ func (c *openaiCommand) callAndStore(messages []ChatMessage, storageIdentifier s
254299
logFields := log.Fields{
255300
"input_tokens": inputTokens,
256301
"output_tokens": outputTokens,
257-
"model": c.cfg.Model,
302+
"model": customCfg.Model,
303+
}
304+
if options.Model != "" {
305+
logFields["model_override"] = options.Model
306+
}
307+
if options.ReasoningEffort != "" {
308+
logFields["reasoning_effort"] = customCfg.ReasoningEffort
309+
}
310+
if options.MessageHistory > 0 {
311+
logFields["message_history_count"] = options.MessageHistory
258312
}
259313
if c.cfg.LogTexts {
260314
logFields["input_text"] = inputText
@@ -297,15 +351,49 @@ func (c *openaiCommand) GetTemplateFunction() template.FuncMap {
297351
}
298352
}
299353

354+
// getChannelHistory fetches the last N messages from a channel (excluding threads)
355+
func (c *openaiCommand) getChannelHistory(channel string, count int) ([]slack.Message, error) {
356+
params := &slack.GetConversationHistoryParameters{
357+
ChannelID: channel,
358+
Limit: count,
359+
}
360+
361+
response, err := c.GetConversationHistory(params)
362+
if err != nil {
363+
return nil, err
364+
}
365+
366+
// Filter out messages that are thread replies (have ThreadTimestamp set)
367+
mainMessages := make([]slack.Message, 0)
368+
for _, message := range response.Messages {
369+
// Only include main channel messages, not thread replies
370+
if message.ThreadTimestamp == "" || message.ThreadTimestamp == message.Timestamp {
371+
mainMessages = append(mainMessages, message)
372+
}
373+
}
374+
375+
// Reverse to get chronological order (API returns newest first)
376+
for i := range len(mainMessages) / 2 {
377+
j := len(mainMessages) - i - 1
378+
mainMessages[i], mainMessages[j] = mainMessages[j], mainMessages[i]
379+
}
380+
381+
return mainMessages, nil
382+
}
383+
300384
func (c *openaiCommand) GetHelp() []bot.Help {
301385
return []bot.Help{
302386
{
303387
Command: "openai <question>",
304-
Description: "Starts a chatgpt/openai conversation in a new thread",
388+
Description: "Starts a chatgpt/openai conversation in a new thread. Supports hashtags for advanced options: #model-<name> (e.g., #model-gpt-4o), #high-thinking/#medium-thinking/#low-thinking/#no-thinking for reasoning control, #message-history or #message-history-<N> to include recent channel messages as context",
305389
Category: category,
306390
Examples: []string{
307391
"openai whats 1+1?",
308392
"chatgpt whats 1+1?",
393+
"openai #model-gpt-4o explain quantum computing",
394+
"chatgpt #high-thinking #model-o1 solve this complex problem",
395+
"openai #message-history-20 what did we discuss about the deployment?",
396+
"chatgpt #model-gpt-4 #low-thinking #message-history quick summary please",
309397
},
310398
},
311399
{

command/openai/dalle.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func (c *openaiCommand) sendImageInSlack(image DalleResponseImage, message msg.M
6565
Reader: resp.Body,
6666
Channel: message.Channel,
6767
ThreadTimestamp: message.Timestamp,
68-
InitialComment: fmt.Sprintf("Dall-e prompt: %s", image.RevisedPrompt),
68+
InitialComment: "Dall-e prompt: " + image.RevisedPrompt,
6969
})
7070

7171
c.SendBlockMessage(

command/openai/hashtag_parser.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package openai
2+
3+
import (
4+
"regexp"
5+
"strconv"
6+
"strings"
7+
)
8+
9+
// HashtagOptions contains parsed hashtag options from user input
10+
type HashtagOptions struct {
11+
ReasoningEffort string // "low", "medium", "high", or ""
12+
Model string // override model, empty means use config default
13+
MessageHistory int // number of channel messages to include, 0 means disabled
14+
}
15+
16+
// ParseHashtags extracts hashtag options from the input text and returns
17+
// the cleaned text (without hashtags) and the parsed options
18+
func ParseHashtags(text string) (cleanText string, options HashtagOptions) {
19+
// Define hashtag patterns
20+
// Pattern priorities (specific to general):
21+
// 1. #message-history-<number>
22+
// 2. #message-history
23+
// 3. #model-<name>
24+
// 4. #high-thinking, #medium-thinking, #low-thinking, #no-thinking
25+
26+
// Parse message-history with number: #message-history-20
27+
messageHistoryWithCountRe := regexp.MustCompile(`#message-history-(\d+)`)
28+
if matches := messageHistoryWithCountRe.FindStringSubmatch(text); len(matches) > 1 {
29+
if count, err := strconv.Atoi(matches[1]); err == nil {
30+
options.MessageHistory = count
31+
}
32+
text = messageHistoryWithCountRe.ReplaceAllString(text, "")
33+
} else if strings.Contains(text, "#message-history") {
34+
// Parse message-history without number (default to 10)
35+
options.MessageHistory = 10
36+
text = strings.ReplaceAll(text, "#message-history", "")
37+
}
38+
39+
// Parse model: #model-gpt-4o
40+
modelRe := regexp.MustCompile(`#model-([\w.-]+)`)
41+
if matches := modelRe.FindStringSubmatch(text); len(matches) > 1 {
42+
options.Model = matches[1]
43+
text = modelRe.ReplaceAllString(text, "")
44+
}
45+
46+
// Parse reasoning effort
47+
switch {
48+
case strings.Contains(text, "#high-thinking"):
49+
options.ReasoningEffort = "high"
50+
text = strings.ReplaceAll(text, "#high-thinking", "")
51+
case strings.Contains(text, "#medium-thinking"):
52+
options.ReasoningEffort = "medium"
53+
text = strings.ReplaceAll(text, "#medium-thinking", "")
54+
case strings.Contains(text, "#low-thinking"):
55+
options.ReasoningEffort = "low"
56+
text = strings.ReplaceAll(text, "#low-thinking", "")
57+
case strings.Contains(text, "#no-thinking"):
58+
options.ReasoningEffort = "none"
59+
text = strings.ReplaceAll(text, "#no-thinking", "")
60+
}
61+
62+
// Clean up extra whitespace
63+
cleanText = strings.Join(strings.Fields(text), " ")
64+
65+
return cleanText, options
66+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package openai
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestParseHashtags(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
input string
11+
expectedText string
12+
expectedModel string
13+
expectedReason string
14+
expectedHistory int
15+
}{
16+
{
17+
name: "No hashtags",
18+
input: "What is Go?",
19+
expectedText: "What is Go?",
20+
expectedModel: "",
21+
},
22+
{
23+
name: "Model only",
24+
input: "#model-gpt-4o What is Go?",
25+
expectedText: "What is Go?",
26+
expectedModel: "gpt-4o",
27+
},
28+
{
29+
name: "High thinking",
30+
input: "#high-thinking Explain quantum computing",
31+
expectedText: "Explain quantum computing",
32+
expectedReason: "high",
33+
},
34+
{
35+
name: "Message history default",
36+
input: "#message-history What was discussed?",
37+
expectedText: "What was discussed?",
38+
expectedHistory: 10,
39+
},
40+
{
41+
name: "Message history with count",
42+
input: "#message-history-25 Summarize the conversation",
43+
expectedText: "Summarize the conversation",
44+
expectedHistory: 25,
45+
},
46+
{
47+
name: "Multiple hashtags",
48+
input: "#model-o1 #high-thinking #message-history-15 Complex question",
49+
expectedText: "Complex question",
50+
expectedModel: "o1",
51+
expectedReason: "high",
52+
expectedHistory: 15,
53+
},
54+
{
55+
name: "No thinking",
56+
input: "#no-thinking Quick answer please",
57+
expectedText: "Quick answer please",
58+
expectedReason: "none",
59+
},
60+
}
61+
62+
for _, tt := range tests {
63+
t.Run(tt.name, func(t *testing.T) {
64+
cleanText, options := ParseHashtags(tt.input)
65+
66+
if cleanText != tt.expectedText {
67+
t.Errorf("Expected text '%s', got '%s'", tt.expectedText, cleanText)
68+
}
69+
if options.Model != tt.expectedModel {
70+
t.Errorf("Expected model '%s', got '%s'", tt.expectedModel, options.Model)
71+
}
72+
if options.ReasoningEffort != tt.expectedReason {
73+
t.Errorf("Expected reasoning '%s', got '%s'", tt.expectedReason, options.ReasoningEffort)
74+
}
75+
if options.MessageHistory != tt.expectedHistory {
76+
t.Errorf("Expected history %d, got %d", tt.expectedHistory, options.MessageHistory)
77+
}
78+
})
79+
}
80+
}

mocks/testing.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ func AssertSlackMessage(slackClient *SlackClient, ref msg.Ref, text string, opti
3232
slackClient.On("SendMessage", args...).Once().Return("")
3333
}
3434

35+
// AssertSlackEphemeralMessage is a test helper to check for a given ephemeral slack message
36+
func AssertSlackEphemeralMessage(slackClient *SlackClient, ref msg.Ref, text string, option ...any) {
37+
args := []any{ref, text}
38+
args = append(args, option...)
39+
40+
slackClient.On("SendEphemeralMessage", args...).Once()
41+
}
42+
3543
// AssertSlackMessageRegexp is a test helper to check for a given slack message based on a regular expression
3644
func AssertSlackMessageRegexp(slackClient *SlackClient, ref msg.Ref, pattern string) {
3745
slackClient.On("SendMessage", ref, mock.MatchedBy(func(text string) bool {

0 commit comments

Comments
 (0)