Files
LocalAGI/services/connectors/irc.go
Ettore Di Giacinto c23e655f44 feat(agent): shared state, allow to track conversations globally (#148)
* feat(agent): shared state, allow to track conversations globally

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Cleanup

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* track conversations initiated by the bot

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2025-05-11 22:23:01 +02:00

248 lines
6.1 KiB
Go

package connectors
import (
"fmt"
"strings"
"time"
"github.com/mudler/LocalAGI/core/agent"
"github.com/mudler/LocalAGI/core/types"
"github.com/mudler/LocalAGI/pkg/config"
"github.com/mudler/LocalAGI/pkg/xlog"
"github.com/mudler/LocalAGI/services/actions"
"github.com/sashabaranov/go-openai"
irc "github.com/thoj/go-ircevent"
)
type IRC struct {
server string
port string
nickname string
channel string
conn *irc.Connection
alwaysReply bool
}
func NewIRC(config map[string]string) *IRC {
return &IRC{
server: config["server"],
port: config["port"],
nickname: config["nickname"],
channel: config["channel"],
alwaysReply: config["alwaysReply"] == "true",
}
}
func (i *IRC) AgentResultCallback() func(state types.ActionState) {
return func(state types.ActionState) {
// Send the result to the bot
}
}
func (i *IRC) AgentReasoningCallback() func(state types.ActionCurrentState) bool {
return func(state types.ActionCurrentState) bool {
// Send the reasoning to the bot
return true
}
}
// cleanUpUsernameFromMessage removes the bot's nickname from the message
func cleanUpMessage(message string, nickname string) string {
cleaned := strings.ReplaceAll(message, nickname+":", "")
cleaned = strings.ReplaceAll(cleaned, nickname+",", "")
cleaned = strings.TrimSpace(cleaned)
return cleaned
}
// isMentioned checks if the bot is mentioned in the message
func isMentioned(message string, nickname string) bool {
return strings.Contains(message, nickname+":") ||
strings.Contains(message, nickname+",") ||
strings.HasPrefix(message, nickname)
}
// Start connects to the IRC server and starts listening for messages
func (i *IRC) Start(a *agent.Agent) {
i.conn = irc.IRC(i.nickname, i.nickname)
if i.conn == nil {
xlog.Error("Failed to create IRC client")
return
}
i.conn.UseTLS = false
i.conn.AddCallback("001", func(e *irc.Event) {
xlog.Info("Connected to IRC server", "server", i.server, "arguments", e.Arguments)
i.conn.Join(i.channel)
i.nickname = e.Arguments[0]
xlog.Info("Joined channel", "channel", i.channel)
})
i.conn.AddCallback("JOIN", func(e *irc.Event) {
if e.Nick == i.nickname {
xlog.Info("Bot joined channel", "channel", e.Arguments[0])
time.Sleep(1 * time.Second) // Small delay to ensure join is complete
i.conn.Privmsg(e.Arguments[0], "Hello! I've just (re)started and am ready to assist.")
}
})
i.conn.AddCallback("PRIVMSG", func(e *irc.Event) {
message := e.Message()
sender := e.Nick
channel := e.Arguments[0]
isDirect := false
if channel == i.nickname {
channel = sender
isDirect = true
}
// Skip messages from ourselves
if sender == i.nickname {
return
}
if !(i.alwaysReply || isMentioned(message, i.nickname) || isDirect) {
return
}
xlog.Info("Recv message", "message", message, "sender", sender, "channel", channel)
cleanedMessage := cleanUpMessage(message, i.nickname)
go func() {
conv := a.SharedState().ConversationTracker.GetConversation(fmt.Sprintf("irc:%s", channel))
conv = append(conv,
openai.ChatCompletionMessage{
Content: cleanedMessage,
Role: "user",
},
)
// Update the conversation history
a.SharedState().ConversationTracker.AddMessage(fmt.Sprintf("irc:%s", channel), openai.ChatCompletionMessage{
Content: cleanedMessage,
Role: "user",
})
res := a.Ask(
types.WithConversationHistory(conv),
)
if res.Response == "" {
xlog.Info("No response from agent")
return
}
// Update the conversation history
a.SharedState().ConversationTracker.AddMessage(fmt.Sprintf("irc:%s", channel), openai.ChatCompletionMessage{
Content: res.Response,
Role: "assistant",
})
xlog.Info("Sending message", "message", res.Response, "channel", channel)
// Split the response into multiple messages if it's too long
// IRC typically has a message length limit
maxLength := 400 // Safe limit for most IRC servers
response := res.Response
// Handle multiline responses
lines := strings.Split(response, "\n")
for _, line := range lines {
if line == "" {
continue
}
// Split long lines
for len(line) > 0 {
var chunk string
if len(line) > maxLength {
chunk = line[:maxLength]
line = line[maxLength:]
} else {
chunk = line
line = ""
}
// Send the message to the channel
i.conn.Privmsg(channel, chunk)
// Small delay to prevent flooding
time.Sleep(500 * time.Millisecond)
}
}
// Handle any attachments or special content from actions
for _, state := range res.State {
// Handle URLs from search action
if urls, exists := state.Metadata[actions.MetadataUrls]; exists {
for _, url := range urls.([]string) {
i.conn.Privmsg(channel, fmt.Sprintf("URL: %s", url))
time.Sleep(500 * time.Millisecond)
}
}
// Handle image URLs
if imagesUrls, exists := state.Metadata[actions.MetadataImages]; exists {
for _, url := range imagesUrls.([]string) {
i.conn.Privmsg(channel, fmt.Sprintf("Image: %s", url))
time.Sleep(500 * time.Millisecond)
}
}
}
}()
})
// Connect to the server
err := i.conn.Connect(i.server + ":" + i.port)
if err != nil {
xlog.Error("Failed to connect to IRC server", "error", err)
return
}
// Start the IRC client in a goroutine
go i.conn.Loop()
go func() {
select {
case <-a.Context().Done():
i.conn.Quit()
return
}
}()
}
// IRCConfigMeta returns the metadata for IRC connector configuration fields
func IRCConfigMeta() []config.Field {
return []config.Field{
{
Name: "server",
Label: "IRC Server",
Type: config.FieldTypeText,
Required: true,
},
{
Name: "port",
Label: "Port",
Type: config.FieldTypeText,
Required: true,
},
{
Name: "nickname",
Label: "Nickname",
Type: config.FieldTypeText,
Required: true,
},
{
Name: "channel",
Label: "Channel",
Type: config.FieldTypeText,
Required: true,
},
{
Name: "alwaysReply",
Label: "Always Reply",
Type: config.FieldTypeCheckbox,
},
}
}