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