init commit

This commit is contained in:
lyx0 2024-03-03 00:07:33 +01:00
commit 29bcc70ee5
11 changed files with 563 additions and 0 deletions

27
.gitignore vendored Normal file
View file

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

23
Dockerfile Normal file
View file

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

18
Makefile Normal file
View file

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

3
README.md Normal file
View file

@ -0,0 +1,3 @@
# NourybotGPT
Twitch chat bot that interacts with ollama. Work in Progress

76
cmd/nourybot/commands.go Normal file
View file

@ -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 <emote name>"
} else {
app.generateChat(target, message.Message[6:len(message.Message)])
}
if reply != "" {
go app.Send(target, reply)
return
}
}
}

126
cmd/nourybot/generate.go Normal file
View file

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

126
cmd/nourybot/main.go Normal file
View file

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

133
cmd/nourybot/send.go Normal file
View file

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

2
env.example Normal file
View file

@ -0,0 +1,2 @@
TWITCH_USERNAME=mycooltwitchusername
TWITCH_OAUTH=oauth:cooloauthtokenhere

11
go.mod Normal file
View file

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

18
go.sum Normal file
View file

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