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>
260 lines
6.4 KiB
Go
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",
|
|
},
|
|
}
|
|
}
|