mirror of
https://github.com/lyx0/ollama-twitch-bot.git
synced 2024-11-06 18:52:03 +01:00
init commit
This commit is contained in:
commit
29bcc70ee5
11 changed files with 563 additions and 0 deletions
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal 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
23
Dockerfile
Normal 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
18
Makefile
Normal 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
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# NourybotGPT
|
||||
|
||||
Twitch chat bot that interacts with ollama. Work in Progress
|
76
cmd/nourybot/commands.go
Normal file
76
cmd/nourybot/commands.go
Normal 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
126
cmd/nourybot/generate.go
Normal 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
126
cmd/nourybot/main.go
Normal 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
133
cmd/nourybot/send.go
Normal 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
2
env.example
Normal file
|
@ -0,0 +1,2 @@
|
|||
TWITCH_USERNAME=mycooltwitchusername
|
||||
TWITCH_OAUTH=oauth:cooloauthtokenhere
|
11
go.mod
Normal file
11
go.mod
Normal 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
18
go.sum
Normal 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=
|
Loading…
Reference in a new issue