diff --git a/Dockerfile.webui b/Dockerfile.webui index 23ef0f5..1af650f 100644 --- a/Dockerfile.webui +++ b/Dockerfile.webui @@ -1,22 +1,28 @@ # Define argument for linker flags ARG LDFLAGS=-s -w -# Use a temporary build image based on Golang 1.20-alpine +# Use a temporary build image based on Golang 1.22-alpine FROM golang:1.22-alpine as builder # Set environment variables: linker flags and disable CGO ENV LDFLAGS=$LDFLAGS CGO_ENABLED=0 -# Install git and build the edgevpn binary with the provided linker flags -# --no-cache flag ensures the package cache isn't stored in the layer, reducing image size +# Install git RUN apk add --no-cache git -# Add the current directory contents to the work directory in the container -COPY . /work - -# Set the current work directory inside the container +# Set the working directory WORKDIR /work +# Copy go.mod and go.sum files first to leverage Docker cache +COPY go.mod go.sum ./ + +# Download dependencies - this layer will be cached as long as go.mod and go.sum don't change +RUN go mod download + +# Now copy the rest of the source code +COPY . . + +# Build the application RUN go build -ldflags="$LDFLAGS" -o localagent ./ FROM scratch diff --git a/README.md b/README.md index 392d62c..0fdf084 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,26 @@ In the UI, when configuring the connector: { "token": "botfathertoken" } ``` +#### IRC + +Connect to IRC servers and interact with channels: + +```json +{ + "server": "irc.example.com", + "port": "6667", + "nickname": "LocalAgentBot", + "channel": "#yourchannel", + "alwaysReply": "false" +} +``` + +The IRC connector supports: +- Connecting to IRC servers without encryption +- Joining a specified channel +- Responding to direct mentions (or all messages if alwaysReply is "true") +- Direct messaging with users + ### REST API The LocalAgent API follows RESTful principles and uses JSON for request and response bodies. @@ -160,4 +180,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file

Made with ❤️ by mudler -

\ No newline at end of file +

diff --git a/go.mod b/go.mod index b4d128a..f817285 100644 --- a/go.mod +++ b/go.mod @@ -58,6 +58,7 @@ require ( github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/temoto/robotstxt v1.1.2 // indirect + github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect go.starlark.net v0.0.0-20230302034142-4b1e35fe2254 // indirect diff --git a/go.sum b/go.sum index 44384cd..9a17a56 100644 --- a/go.sum +++ b/go.sum @@ -137,6 +137,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo= +github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64 h1:l/T7dYuJEQZOwVOpjIXr1180aM9PZL/d1MnMVIxefX4= +github.com/thoj/go-ircevent v0.0.0-20210723090443-73e444401d64/go.mod h1:Q1NAJOuRdQCqN/VIWdnaaEhV8LpeO2rtlBP7/iDJNII= github.com/tmc/langchaingo v0.1.8 h1:nrImgh0aWdu3stJTHz80N60WGwPWY8HXCK10gQny7bA= github.com/tmc/langchaingo v0.1.8/go.mod h1:iNBfS9e6jxBKsJSPWnlqNhoVWgdA3D1g5cdFJjbIZNQ= github.com/traefik/yaegi v0.16.1 h1:f1De3DVJqIDKmnasUF6MwmWv1dSEEat0wcpXhD2On3E= @@ -167,6 +169,7 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= diff --git a/services/connectors.go b/services/connectors.go index 0d74454..ae1aff4 100644 --- a/services/connectors.go +++ b/services/connectors.go @@ -11,6 +11,7 @@ import ( const ( // Connectors + ConnectorIRC = "irc" ConnectorTelegram = "telegram" ConnectorSlack = "slack" ConnectorDiscord = "discord" @@ -19,6 +20,7 @@ const ( ) var AvailableConnectors = []string{ + ConnectorIRC, ConnectorTelegram, ConnectorSlack, ConnectorDiscord, @@ -52,6 +54,8 @@ func Connectors(a *state.AgentConfig) []state.Connector { conns = append(conns, connectors.NewGithubIssueWatcher(config)) case ConnectorGithubPRs: conns = append(conns, connectors.NewGithubPRWatcher(config)) + case ConnectorIRC: + conns = append(conns, connectors.NewIRC(config)) } } return conns diff --git a/services/connectors/irc.go b/services/connectors/irc.go new file mode 100644 index 0000000..139b3d8 --- /dev/null +++ b/services/connectors/irc.go @@ -0,0 +1,170 @@ +package connectors + +import ( + "fmt" + "strings" + "time" + + "github.com/mudler/LocalAgent/core/agent" + "github.com/mudler/LocalAgent/pkg/xlog" + "github.com/mudler/LocalAgent/services/actions" + "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 agent.ActionState) { + return func(state agent.ActionState) { + // Send the result to the bot + } +} + +func (i *IRC) AgentReasoningCallback() func(state agent.ActionCurrentState) bool { + return func(state agent.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) + i.conn.UseTLS = false + i.conn.AddCallback("001", func(e *irc.Event) { + xlog.Info("Connected to IRC server", "server", i.server) + i.conn.Join(i.channel) + 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 := "I am " + sender + ". " + cleanUpMessage(message, i.nickname) + + go func() { + res := a.Ask( + agent.WithText(cleanedMessage), + ) + + 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() +}