From 29bcc70ee59d430bf18702bb0108e925e76e2c9b Mon Sep 17 00:00:00 2001 From: lyx0 <66651385+lyx0@users.noreply.github.com> Date: Sun, 3 Mar 2024 00:07:33 +0100 Subject: [PATCH] init commit --- .gitignore | 27 ++++++++ Dockerfile | 23 +++++++ Makefile | 18 ++++++ README.md | 3 + cmd/nourybot/commands.go | 76 ++++++++++++++++++++++ cmd/nourybot/generate.go | 126 +++++++++++++++++++++++++++++++++++++ cmd/nourybot/main.go | 126 +++++++++++++++++++++++++++++++++++++ cmd/nourybot/send.go | 133 +++++++++++++++++++++++++++++++++++++++ env.example | 2 + go.mod | 11 ++++ go.sum | 18 ++++++ 11 files changed, 563 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/nourybot/commands.go create mode 100644 cmd/nourybot/generate.go create mode 100644 cmd/nourybot/main.go create mode 100644 cmd/nourybot/send.go create mode 100644 env.example create mode 100644 go.mod create mode 100644 go.sum diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23b4539 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +.env +Nourybot +Nourybot.out +Nourybot-Api +Nourybot-Api.out +NourybotApi +NourybotApi.out +nourybot-api +Nourybot-Web +nourybot-web +docker-compose.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d65be95 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# Start from golang base image +FROM golang:alpine + +RUN apk add --no-cache make +RUN apk add --no-cache git +RUN apk add --no-cache jq + +# Setup folders +RUN mkdir /app +WORKDIR /app + +# Copy the source from the current directory to the working Directory inside the container +COPY . . +COPY .env . + +# Download all the dependencies +RUN go get -d -v ./... + +# Build the Go app +RUN go build ./cmd/nourybot + +# Run the executable +CMD [ "./nourybot", "jq"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..571bcb8 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +BINARY_NAME=Nourybot.out +BINARY_NAME_API=NourybotApi.out + +up: + docker compose up + +down: + docker compose down + +rebuild: + docker compose down + docker compose build + docker compose up -d + +xd: + docker compose down + docker compose build + docker compose up diff --git a/README.md b/README.md new file mode 100644 index 0000000..fde3851 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# NourybotGPT + +Twitch chat bot that interacts with ollama. Work in Progress diff --git a/cmd/nourybot/commands.go b/cmd/nourybot/commands.go new file mode 100644 index 0000000..b1003f7 --- /dev/null +++ b/cmd/nourybot/commands.go @@ -0,0 +1,76 @@ +package main + +import ( + "strings" + + "github.com/gempir/go-twitch-irc/v4" +) + +// handleCommand takes in a twitch.PrivateMessage and then routes the message +// to the function that is responsible for each command and knows how to deal +// with it accordingly. +func (app *application) handleCommand(message twitch.PrivateMessage) { + var reply string + + if message.Channel == "forsen" { + return + } + + // commandName is the actual name of the command without the prefix. + // e.g. `()ping` would be `ping`. + commandName := strings.ToLower(strings.SplitN(message.Message, " ", 3)[0][2:]) + + // cmdParams are additional command parameters. + // e.g. `()weather san antonio` + // cmdParam[0] is `san` and cmdParam[1] = `antonio`. + // + // Since Twitch messages are at most 500 characters I use a + // maximum count of 500+10 just to be safe. + // https://discuss.dev.twitch.tv/t/missing-client-side-message-length-check/21316 + cmdParams := strings.SplitN(message.Message, " ", 500) + + // msgLen is the amount of words in a message without the prefix. + // Useful to check if enough cmdParams are provided. + msgLen := len(strings.SplitN(message.Message, " ", -2)) + + // target is the channelname the message originated from and + // where the TwitchClient should send the response + target := message.Channel + app.Log.Infow("Command received", + // "message", message, // Pretty taxing + "message.Message", message.Message, + "message.Channel", target, + "commandName", commandName, + "cmdParams", cmdParams, + "msgLen", msgLen, + ) + + // A `commandName` is every message starting with `()`. + // Hardcoded commands have a priority over database commands. + // Switch over the commandName and see if there is a hardcoded case for it. + // If there was no switch case satisfied, query the database if there is + // a data.CommandModel.Name equal to the `commandName` + // If there is return the data.CommandModel.Text entry. + // Otherwise we ignore the message. + switch commandName { + case "": + if msgLen == 1 { + reply = "xd" + } + // -------------------------------- + // pleb commands + // -------------------------------- + case "gpt": + if msgLen < 2 { + reply = "Not enough arguments provided. Usage: ()bttv " + } else { + app.generateChat(target, message.Message[6:len(message.Message)]) + } + + if reply != "" { + go app.Send(target, reply) + return + } + + } +} diff --git a/cmd/nourybot/generate.go b/cmd/nourybot/generate.go new file mode 100644 index 0000000..2bcc41a --- /dev/null +++ b/cmd/nourybot/generate.go @@ -0,0 +1,126 @@ +package main + +import ( + "bytes" + "encoding/json" + "io" + "net/http" +) + +type ollamaResponse struct { + Model string `json:"model"` + //CreatedAt string `json:"created_at"` + Response string `json:"response"` + Done bool `json:"done"` + Message ollamaMessage `json:"message"` +} + +type ollamaRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + Stream bool `json:"stream"` + System string `json:"system"` + Raw bool `json:"raw"` + Messages []ollamaMessage `json:"messages"` +} + +type ollamaMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +func startMessage() []ollamaMessage { + var msg = make([]ollamaMessage, 0) + return msg +} + +var msgStore []ollamaMessage + +func (app *application) generateChat(target, input string) { + var requestBody ollamaRequest + //var msg []ollamaMessage + olm := ollamaMessage{} + + olm.Role = "user" + olm.Content = input + msgStore = append(msgStore, olm) + + requestBody.Model = "llama2-uncensored" + requestBody.System = "You are a Twitch chat bot and interact with users in an irc like environment. Do not use any formatting. Be human-like. Never fail to answer the user. Always answer immediately. Keep your response shorter than 450 characters." + requestBody.Messages = msgStore + requestBody.Prompt = input + requestBody.Stream = false + + marshalled, err := json.Marshal(requestBody) + if err != nil { + app.Log.Error(err) + } + + app.Log.Infow("msg before", + "msg", msgStore, + ) + resp, err := http.Post("http://localhost:11434/api/chat", "application/json", bytes.NewBuffer(marshalled)) + if err != nil { + app.Log.Error(err.Error()) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + app.Log.Error(err.Error()) + } + + var responseObject ollamaResponse + if err := json.Unmarshal(body, &responseObject); err != nil { + app.Log.Error(err) + } + olm.Role = responseObject.Message.Role + olm.Content = responseObject.Message.Content + msgStore = append(msgStore, olm) + + app.Log.Infow("msg after", + "msg", msgStore, + ) + app.Log.Info() + app.Send(target, responseObject.Message.Content) + //app.Send(target, responseObject.Response) +} + +func (app *application) generate(target, input string) { + var requestBody ollamaRequest + + requestBody.Model = "llama2-uncensored" + requestBody.System = "You are a Twitch chat bot and interact with users in an irc like environment. Do not use any formatting. Be human-like. Never fail to answer the user. Always answer immediately. Keep your response shorter than 450 characters." + //requestBody.Messages.Role = "system" + //requestBody.Messages.Content = "You are a Twitch chat bot and interact with users in an irc like environment. Do not use any formatting. Be blunt. Never fail to answer the user. Always answer immediately. Keep your response shorter than 450 characters." + //requestBody.Messages.Role = "user" + //requestBody.Messages.Content = input + requestBody.Prompt = input + requestBody.Stream = false + + marshalled, err := json.Marshal(requestBody) + if err != nil { + app.Log.Error(err) + } + + resp, err := http.Post("http://localhost:11434/api/generate", "application/json", bytes.NewBuffer(marshalled)) + if err != nil { + app.Log.Error(err.Error()) + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + app.Log.Error(err.Error()) + } + + var responseObject ollamaResponse + if err := json.Unmarshal(body, &responseObject); err != nil { + app.Log.Error(err) + } + //app.Log.Info(responseObject.Message.Content) + //app.Send(target, responseObject.Message.Content) + app.Send(target, responseObject.Response) +} diff --git a/cmd/nourybot/main.go b/cmd/nourybot/main.go new file mode 100644 index 0000000..7fa8bc9 --- /dev/null +++ b/cmd/nourybot/main.go @@ -0,0 +1,126 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + "os/signal" + + "github.com/gempir/go-twitch-irc/v4" + "github.com/joho/godotenv" + "go.uber.org/zap" +) + +type config struct { + twitchUsername string + twitchOauth string + twitchClientId string + twitchClientSecret string + eventSubSecret string + twitchID string + wolframAlphaAppID string + commandPrefix string + env string + db struct { + dsn string + maxOpenConns int + maxIdleConns int + maxIdleTime string + } +} + +type application struct { + TwitchClient *twitch.Client + Log *zap.SugaredLogger + Environment string + Config config +} + +func main() { + ctx := context.Background() + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) + defer cancel() + if err := run(ctx, os.Stdout, os.Args); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } +} + +func run(ctx context.Context, w io.Writer, args []string) error { + var cfg config + + logger := zap.NewExample() + defer func() { + if err := logger.Sync(); err != nil { + logger.Sugar().Errorw("error syncing logger", + "error", err, + ) + } + }() + sugar := logger.Sugar() + + err := godotenv.Load() + if err != nil { + sugar.Fatal("Error loading .env") + } + + // Twitch config variables + cfg.twitchUsername = os.Getenv("TWITCH_USERNAME") + cfg.twitchOauth = os.Getenv("TWITCH_OAUTH") + tc := twitch.NewClient(cfg.twitchUsername, cfg.twitchOauth) + + app := &application{ + TwitchClient: tc, + Log: sugar, + Config: cfg, + Environment: cfg.env, + } + + // Received a PrivateMessage (normal chat message). + app.TwitchClient.OnPrivateMessage(func(message twitch.PrivateMessage) { + // roomId is the Twitch UserID of the channel the message originated from. + // If there is no roomId something went really wrong. + roomId := message.Tags["room-id"] + if roomId == "" { + return + } + + // Message was shorter than our prefix is therefore it's irrelevant for us. + if len(message.Message) >= 2 { + // This bots prefix is "()" configured above at cfg.commandPrefix, + // Check if the first 2 characters of the mesage were our prefix. + // if they were forward the message to the command handler. + if message.Message[:2] == "()" { + go app.handleCommand(message) + return + } + + // Special rule for #pajlada. + if message.Message == "!nourybot" { + app.Send(message.Channel, "Lidl Twitch bot made by @nouryxd. Prefix: ()") + } + } + }) + + app.TwitchClient.OnConnect(func() { + app.TwitchClient.Say("nouryxd", "MrDestructoid") + app.TwitchClient.Say("nourybot", "MrDestructoid") + + // Successfully connected to Twitch + app.Log.Infow("Successfully connected to Twitch Servers", + "Bot username", cfg.twitchUsername, + "Environment", cfg.env, + ) + }) + + app.TwitchClient.Join("nouryxd") + + // Actually connect to chat. + err = app.TwitchClient.Connect() + if err != nil { + panic(err) + } + + return nil +} diff --git a/cmd/nourybot/send.go b/cmd/nourybot/send.go new file mode 100644 index 0000000..5661d54 --- /dev/null +++ b/cmd/nourybot/send.go @@ -0,0 +1,133 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// banphraseResponse is the data we receive back from the banphrase API +type banphraseResponse struct { + Banned bool `json:"banned"` + InputMessage string `json:"input_message"` + BanphraseData banphraseData `json:"banphrase_data"` +} + +// banphraseData contains details about why a message was banphrased. +type banphraseData struct { + Id int `json:"id"` + Name string `json:"name"` + Phrase string `json:"phrase"` + Length int `json:"length"` + Permanent bool `json:"permanent"` +} + +var ( + banPhraseUrl = "https://pajlada.pajbot.com/api/v1/banphrases/test" +) + +// CheckMessage checks a given message against the banphrase api. +// returns false, "okay" if a message is allowed +// returns true and a string with the reason if it was banned. +// More information: +// https://gist.github.com/pajlada/57464e519ba8d195a97ddcd0755f9715 +func (app *application) checkMessage(text string) (bool, string) { + // {"message": "AHAHAHAHA LUL"} + reqBody, err := json.Marshal(map[string]string{ + "message": text, + }) + if err != nil { + app.Log.Error(err) + return true, "could not check banphrase api" + } + + resp, err := http.Post(banPhraseUrl, "application/json", bytes.NewBuffer(reqBody)) + if err != nil { + app.Log.Error(err) + return true, "could not check banphrase api" + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + app.Log.Error(err) + } + + var responseObject banphraseResponse + if err := json.Unmarshal(body, &responseObject); err != nil { + app.Log.Error(err) + return true, "could not check banphrase api" + } + + // Bad Message + // + // {"phrase": "No gyazo allowed"} + reason := responseObject.BanphraseData.Name + if responseObject.Banned { + return true, fmt.Sprint(reason) + } else if !responseObject.Banned { + // Good message + return false, "okay" + } + + // Couldn't contact api so assume it was a bad message + return true, "Banphrase API couldn't be reached monkaS" +} + +// Send is used to send twitch replies and contains the necessary safeguards and logic for that. +// Send also logs the twitch.PrivateMessage contents into the database. +func (app *application) Send(target, message string) { + // Message we are trying to send is empty. + if len(message) == 0 { + return + } + + if target == "forsen" { + return + } + + // Since messages starting with `.` or `/` are used for special actions + // (ban, whisper, timeout) and so on, we place an emote infront of it so + // the actions wouldn't execute. `!` and `$` are common bot prefixes so we + // don't allow them either. + if message[0] == '.' || message[0] == '/' || message[0] == '!' || message[0] == '$' { + message = ":tf: " + message + } + + // check the message for bad words before we say it + messageBanned, banReason := app.checkMessage(message) + if !messageBanned { + // In case the message we are trying to send is longer than the + // maximum allowed message length on twitch we split the message in two parts. + // Twitch has a maximum length for messages of 510 characters so to be safe + // we split and check at 500 characters. + // https://discuss.dev.twitch.tv/t/missing-client-side-message-length-check/21316 + // TODO: Make it so it splits at a space instead and not in the middle of a word. + if len(message) > 500 { + firstMessage := message[0:499] + secondMessage := message[499:] + + app.TwitchClient.Say(target, firstMessage) + app.TwitchClient.Say(target, secondMessage) + + return + } else { + // Message was fine. + go app.TwitchClient.Say(target, message) + return + } + } else { + // Bad message, replace message and log it. + app.TwitchClient.Say(target, "[BANPHRASED] monkaS") + app.Log.Infow("banned message detected", + "target channel", target, + "message", message, + "ban reason", banReason, + ) + + return + } +} diff --git a/env.example b/env.example new file mode 100644 index 0000000..fd2d7f2 --- /dev/null +++ b/env.example @@ -0,0 +1,2 @@ +TWITCH_USERNAME=mycooltwitchusername +TWITCH_OAUTH=oauth:cooloauthtokenhere diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8ad2da6 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/lyx0/nourybot-gpt + +go 1.22.0 + +require ( + github.com/gempir/go-twitch-irc/v4 v4.0.0 + github.com/joho/godotenv v1.5.1 + go.uber.org/zap v1.27.0 +) + +require go.uber.org/multierr v1.10.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8aec6ba --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gempir/go-twitch-irc/v4 v4.0.0 h1:sHVIvbWOv9nHXGEErilclxASv0AaQEr/r/f9C0B9aO8= +github.com/gempir/go-twitch-irc/v4 v4.0.0/go.mod h1:QsOMMAk470uxQ7EYD9GJBGAVqM/jDrXBNbuePfTauzg= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=