Files
LocalAGI/services/connectors/irc.go
Richard Palethorpe f289e824df fix: Handle state on agent restart and update observables
Keep some agent start across restarts, such as the SSE manager and
observer. This allows restarts to be shown on the state page and also
allows avatars to be kept when reconfiguring the agent.

Also observable updates can happen out of order because SSE manager has
multiple workers. For now handle this in the client.

Finally fix an issue with the IRC client to make it disconnect and
handle being assigned a different nickname by the server.

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2025-04-23 07:23:51 +01:00

260 lines
6.4 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
conversationTracker *ConversationTracker[string]
}
func NewIRC(config map[string]string) *IRC {
duration, err := time.ParseDuration(config["lastMessageDuration"])
if err != nil {
duration = 5 * time.Minute
}
return &IRC{
server: config["server"],
port: config["port"],
nickname: config["nickname"],
channel: config["channel"],
alwaysReply: config["alwaysReply"] == "true",
conversationTracker: NewConversationTracker[string](duration),
}
}
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 := i.conversationTracker.GetConversation(channel)
conv = append(conv,
openai.ChatCompletionMessage{
Content: cleanedMessage,
Role: "user",
},
)
// Update the conversation history
i.conversationTracker.AddMessage(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
i.conversationTracker.AddMessage(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,
},
{
Name: "lastMessageDuration",
Label: "Last Message Duration",
Type: config.FieldTypeText,
DefaultValue: "5m",
},
}
}