diff --git a/README.md b/README.md new file mode 100644 index 0000000..4cc192b --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +## Connectors + +### Github (issues) + +Create an user and a PAT token: + +```json +{ + "token": "PAT_TOKEN", + "repository": "testrepo", + "owner": "ci-forks", + "botUserName": "localai-bot" +} +``` + +### Discord + +Follow the steps in: https://discordpy.readthedocs.io/en/stable/discord.html to create a discord bot. + +The token of the bot is in the "Bot" tab. Also enable " Message Content Intent " in the Bot tab! + +```json +{ +"token": "Bot DISCORDTOKENHERE", +"defaultChannel": "OPTIONALCHANNELINT" +} +``` + +### Slack + +See slack.yaml + +- Create a new App from a manifest (copy-paste from `slack.yaml`) +- Create Oauth token bot token from "OAuth & Permissions" -> "OAuth Tokens for Your Workspace" +- Create App level token (from "Basic Information" -> "App-Level Tokens" ( `scope connections:writeRoute authorizations:read` )) + +In the UI, when configuring the connector: + +```json +{ +"botToken": "xoxb-...", +"appToken": "xapp-1-..." +} +``` + +### Telegram + +Ask a token to @botfather + +In the UI, when configuring the connector: + +```json +{ "token": "botfathertoken" } +``` \ No newline at end of file diff --git a/agent/agent.go b/agent/agent.go index c6d980c..59e996c 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -557,21 +557,26 @@ func (a *Agent) Run() error { // Expose a REST API to interact with the agent to ask it things - todoTimer := time.NewTicker(a.options.periodicRuns) + //todoTimer := time.NewTicker(a.options.periodicRuns) + timer := time.NewTimer(a.options.periodicRuns) for { select { case job := <-a.jobQueue: // Consume the job and generate a response // TODO: Give a short-term memory to the agent a.consumeJob(job, UserRole) + timer.Reset(a.options.periodicRuns) case <-a.context.Done(): - // Agent has been canceled, return error + // Agent has been canceled, return error return ErrContextCanceled - case <-todoTimer.C: + case <-timer.C: if !a.options.standaloneJob { continue } + // TODO: We should also do not immediately fire this timer but + // instead have a cool-off timer starting after we received the last job a.periodicallyRun() + timer.Reset(a.options.periodicRuns) } } } diff --git a/example/webui/agentpool.go b/example/webui/agentpool.go index 083c82e..f307a82 100644 --- a/example/webui/agentpool.go +++ b/example/webui/agentpool.go @@ -119,9 +119,11 @@ func (a *AgentPool) List() []string { } const ( - ConnectorTelegram = "telegram" - ConnectorSlack = "slack" - ActionSearch = "search" + ConnectorTelegram = "telegram" + ConnectorSlack = "slack" + ConnectorDiscord = "discord" + ConnectorGithubIssues = "github-issues" + ActionSearch = "search" ) var AvailableActions = []string{ActionSearch} @@ -154,7 +156,12 @@ type Connector interface { Start(a *Agent) } -var AvailableConnectors = []string{ConnectorTelegram, ConnectorSlack} +var AvailableConnectors = []string{ + ConnectorTelegram, + ConnectorSlack, + ConnectorDiscord, + ConnectorGithubIssues, +} func (a *AgentConfig) availableConnectors() []Connector { connectors := []Connector{} @@ -180,6 +187,10 @@ func (a *AgentConfig) availableConnectors() []Connector { connectors = append(connectors, cc) case ConnectorSlack: connectors = append(connectors, connector.NewSlack(config)) + case ConnectorDiscord: + connectors = append(connectors, connector.NewDiscord(config)) + case ConnectorGithubIssues: + connectors = append(connectors, connector.NewGithub(config)) } } return connectors diff --git a/example/webui/connector/discord.go b/example/webui/connector/discord.go new file mode 100644 index 0000000..64b699a --- /dev/null +++ b/example/webui/connector/discord.go @@ -0,0 +1,105 @@ +package connector + +import ( + "fmt" + "strings" + + "github.com/bwmarrin/discordgo" + "github.com/mudler/local-agent-framework/agent" +) + +type Discord struct { + token string + defaultChannel string +} + +func NewDiscord(config map[string]string) *Discord { + return &Discord{ + token: config["token"], + defaultChannel: config["defaultChannel"], + } +} + +func (d *Discord) AgentResultCallback() func(state agent.ActionState) { + return func(state agent.ActionState) { + // Send the result to the bot + } +} + +func (d *Discord) AgentReasoningCallback() func(state agent.ActionCurrentState) bool { + return func(state agent.ActionCurrentState) bool { + // Send the reasoning to the bot + return true + } +} + +func (d *Discord) Start(a *agent.Agent) { + + Token := d.token + // Create a new Discord session using the provided bot token. + dg, err := discordgo.New(Token) + if err != nil { + fmt.Println("error creating Discord session,", err) + return + } + + // Register the messageCreate func as a callback for MessageCreate events. + dg.AddHandler(d.messageCreate(a)) + + // In this example, we only care about receiving message events. + dg.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsDirectMessages | discordgo.IntentMessageContent + + // Open a websocket connection to Discord and begin listening. + err = dg.Open() + if err != nil { + fmt.Println("error opening connection,", err) + return + } + + go func() { + <-a.Context().Done() + dg.Close() + }() +} + +// This function will be called (due to AddHandler above) every time a new +// message is created on any channel that the authenticated bot has access to. +func (d *Discord) messageCreate(a *agent.Agent) func(s *discordgo.Session, m *discordgo.MessageCreate) { + return func(s *discordgo.Session, m *discordgo.MessageCreate) { + // Ignore all messages created by the bot itself + // This isn't required in this specific example but it's a good practice. + if m.Author.ID == s.State.User.ID { + return + } + interact := func() { + //m := m.ContentWithMentionsReplaced() + content := m.Content + + content = strings.ReplaceAll(content, "<@"+s.State.User.ID+"> ", "") + + job := a.Ask( + agent.WithText( + content, + ), + ) + _, err := s.ChannelMessageSend(m.ChannelID, job.Response) + if err != nil { + fmt.Println("error sending message,", err) + } + } + + // Interact if we are mentioned + for _, mention := range m.Mentions { + if mention.ID == s.State.User.ID { + interact() + return + } + } + + // Or we are in the default channel (if one is set!) + if d.defaultChannel != "" && m.ChannelID == d.defaultChannel { + interact() + return + } + } +} diff --git a/example/webui/connector/githubissue.go b/example/webui/connector/githubissue.go new file mode 100644 index 0000000..6706f15 --- /dev/null +++ b/example/webui/connector/githubissue.go @@ -0,0 +1,153 @@ +package connector + +import ( + "fmt" + "strings" + "time" + + "github.com/google/go-github/v61/github" + "github.com/mudler/local-agent-framework/agent" + "github.com/sashabaranov/go-openai" +) + +type GithubIssues struct { + token string + repository string + owner string + agent *agent.Agent + pollInterval time.Duration + client *github.Client +} + +func NewGithub(config map[string]string) *GithubIssues { + client := github.NewClient(nil).WithAuthToken(config["token"]) + interval, err := time.ParseDuration(config["pollInterval"]) + if err != nil { + interval = 1 * time.Minute + } + + return &GithubIssues{ + client: client, + token: config["token"], + repository: config["repository"], + owner: config["owner"], + pollInterval: interval, + } +} + +func (g *GithubIssues) AgentResultCallback() func(state agent.ActionState) { + return func(state agent.ActionState) { + // Send the result to the bot + } +} + +func (g *GithubIssues) AgentReasoningCallback() func(state agent.ActionCurrentState) bool { + return func(state agent.ActionCurrentState) bool { + // Send the reasoning to the bot + return true + } +} + +func (g *GithubIssues) Start(a *agent.Agent) { + // Start the connector + g.agent = a + + go func() { + ticker := time.NewTicker(g.pollInterval) + for { + select { + case <-ticker.C: + fmt.Println("Fire in da hole!") + g.issuesService() + case <-a.Context().Done(): + return + } + } + }() +} + +func (g *GithubIssues) issuesService() { + user, _, err := g.client.Users.Get(g.agent.Context(), "") + if err != nil { + fmt.Printf("\nerror: %v\n", err) + return + } + + issues, _, err := g.client.Issues.ListByRepo( + g.agent.Context(), + g.owner, + g.repository, + &github.IssueListByRepoOptions{}) + if err != nil { + fmt.Println("Error listing issues", err) + } + for _, issue := range issues { + // Do something with the issue + if issue.IsPullRequest() { + continue + } + + messages := []openai.ChatCompletionMessage{ + { + Role: "system", + Content: fmt.Sprintf("This is a conversation with an user that opened a Github issue with title '%s'.", issue.GetTitle()), + }, + { + Role: "user", + Content: issue.GetBody(), + }, + } + + comments, _, _ := g.client.Issues.ListComments(g.agent.Context(), g.owner, g.repository, issue.GetNumber(), + &github.IssueListCommentsOptions{}) + + mustAnswer := false + botAnsweredAlready := false + for i, comment := range comments { + role := "user" + if comment.GetUser().GetLogin() == user.GetLogin() { + botAnsweredAlready = true + role = "assistant" + } + messages = append(messages, openai.ChatCompletionMessage{ + Role: role, + Content: comment.GetBody(), + }) + + // if last comment is from the user and mentions the bot username, we must answer + if comment.User.GetName() != user.GetLogin() && len(comments)-1 == i { + if strings.Contains(comment.GetBody(), fmt.Sprintf("@%s", user.GetLogin())) { + fmt.Println("Bot was mentioned in the last comment") + mustAnswer = true + } + } + } + + if len(comments) == 0 || !botAnsweredAlready { + // if no comments, or bot didn't answer yet, we must answer + fmt.Println("No comments, or bot didn't answer yet") + fmt.Println("Comments:", len(comments)) + fmt.Println("Bot answered already", botAnsweredAlready) + mustAnswer = true + } + + if !mustAnswer { + continue + } + + res := g.agent.Ask( + agent.WithConversationHistory(messages), + ) + + _, _, err := g.client.Issues.CreateComment( + g.agent.Context(), + g.owner, g.repository, + issue.GetNumber(), &github.IssueComment{ + Body: github.String(res.Response), + }, + ) + if err != nil { + fmt.Println("Error creating comment", err) + } + } +} diff --git a/example/webui/connector/slack.go b/example/webui/connector/slack.go index 2f08b35..a42b511 100644 --- a/example/webui/connector/slack.go +++ b/example/webui/connector/slack.go @@ -41,15 +41,15 @@ func (t *Slack) AgentReasoningCallback() func(state agent.ActionCurrentState) bo func (t *Slack) Start(a *agent.Agent) { api := slack.New( t.botToken, - slack.OptionDebug(true), + // slack.OptionDebug(true), slack.OptionLog(log.New(os.Stdout, "api: ", log.Lshortfile|log.LstdFlags)), slack.OptionAppLevelToken(t.appToken), ) client := socketmode.New( api, - socketmode.OptionDebug(true), - socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)), + // socketmode.OptionDebug(true), + //socketmode.OptionLog(log.New(os.Stdout, "socketmode: ", log.Lshortfile|log.LstdFlags)), ) go func() { for evt := range client.Events { diff --git a/example/webui/connector/telegram.go b/example/webui/connector/telegram.go index 434f500..df7c349 100644 --- a/example/webui/connector/telegram.go +++ b/example/webui/connector/telegram.go @@ -14,22 +14,29 @@ import ( type Telegram struct { Token string lastChatID int64 + bot *bot.Bot + agent *agent.Agent } // Send any text message to the bot after the bot has been started func (t *Telegram) AgentResultCallback() func(state agent.ActionState) { return func(state agent.ActionState) { - // Send the result to the bot + t.bot.SetMyDescription(t.agent.Context(), &bot.SetMyDescriptionParams{ + Description: state.Reasoning, + }) } } + func (t *Telegram) AgentReasoningCallback() func(state agent.ActionCurrentState) bool { return func(state agent.ActionCurrentState) bool { - // Send the reasoning to the bot + t.bot.SetMyDescription(t.agent.Context(), &bot.SetMyDescriptionParams{ + Description: state.Reasoning, + }) return true } - } + func (t *Telegram) Start(a *agent.Agent) { ctx, cancel := signal.NotifyContext(a.Context(), os.Interrupt) defer cancel() @@ -54,6 +61,9 @@ func (t *Telegram) Start(a *agent.Agent) { panic(err) } + t.bot = b + t.agent = a + go func() { for m := range a.ConversationChannel() { if t.lastChatID == 0 { diff --git a/example/webui/create.html b/example/webui/create.html index ab12da5..95246a3 100644 --- a/example/webui/create.html +++ b/example/webui/create.html @@ -19,28 +19,25 @@ -
-
-
+
-
-
+ diff --git a/go.mod b/go.mod index 0432954..4ea46cc 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,11 @@ go 1.22 toolchain go1.22.2 require ( + github.com/bwmarrin/discordgo v0.28.1 github.com/donseba/go-htmx v1.8.0 github.com/go-telegram/bot v1.2.1 github.com/gofiber/fiber/v2 v2.52.4 + github.com/google/go-github/v61 v61.0.0 github.com/onsi/ginkgo/v2 v2.15.0 github.com/onsi/gomega v1.31.1 github.com/sap-nocops/duckduckgogo v0.0.0-20201102135645-176990152850 @@ -23,6 +25,7 @@ require ( github.com/go-logr/logr v1.3.0 // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.4.2 // indirect @@ -34,6 +37,7 @@ require ( github.com/stretchr/testify v1.9.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/crypto v0.19.0 // indirect golang.org/x/net v0.21.0 // indirect golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index 419c853..2c4e43c 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4= +github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -24,9 +26,14 @@ github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84Egg github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v61 v61.0.0 h1:VwQCBwhyE9JclCI+22/7mLB1PuU9eowCXKY5pNlu1go= +github.com/google/go-github/v61 v61.0.0/go.mod h1:0WR+KmsWX75G2EbpyGsGmradjo3IiciuI4BmdVCobQY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38 h1:yAJXTCF9TqKcTiHJAE8dj7HMvPfh66eeA2JYW7eFpSE= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -69,19 +76,27 @@ github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHY github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/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.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA= golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/slack.yaml b/slack.yaml new file mode 100644 index 0000000..e0833bf --- /dev/null +++ b/slack.yaml @@ -0,0 +1,38 @@ +## To install: +## Create an app from this manifest. +## Generate tokens: +## appLevel token from "Basic Information" -> "App-Level Tokens" ( scope connections:writeRoute authorizations:read ) +## bot token from "OAuth & Permissions" -> "OAuth Tokens for Your Workspace" +## When configuring the connector, supply the tokens with: +## { "botToken": "OAuth Tokens for Your Workspace", "appToken": "App-Level Tokens" } +display_information: + name: LocalAgent + description: LocalAgent bot + background_color: "#0040ff" +features: + bot_user: + display_name: LocalAgent + always_online: true +oauth_config: + scopes: + bot: + - app_mentions:read + - channels:history + - channels:read + - chat:write + - commands + - im:history + - im:read + - im:write + - users:read + - users:read.email +settings: + event_subscriptions: + bot_events: + - app_mention + - message.im + interactivity: + is_enabled: true + org_deploy_enabled: false + socket_mode_enabled: true + token_rotation_enabled: false