Add IRC connector

This commit is contained in:
Richard Palethorpe
2025-03-06 11:49:01 +00:00
parent 1e1c123d84
commit f2e7010297
6 changed files with 212 additions and 8 deletions

View File

@@ -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

View File

@@ -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
<p align="center">
Made with ❤️ by <a href="https://github.com/mudler">mudler</a>
</p>
</p>

1
go.mod
View File

@@ -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

3
go.sum
View File

@@ -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=

View File

@@ -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

170
services/connectors/irc.go Normal file
View File

@@ -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()
}