start rewrite

This commit is contained in:
lyx0 2023-08-04 20:57:40 +02:00
parent 0d313b7b0a
commit b88b4345f5
68 changed files with 97 additions and 4389 deletions

View file

@ -1,10 +0,0 @@
# Download latest golang image
FROM golang:latest
# Create a directory for the app
RUN mkdir /app
# Copy all files from current directory to working directory
COPY . /app
# Set working directory
WORKDIR /app
RUN make xdprod

30
LICENSE
View file

@ -1,15 +1,21 @@
ISC License
MIT License
Copyright (c) 2023, lyx0
Copyright (c) 2023 lyx0
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,21 +1,16 @@
BINARY_NAME=Nourybot.out
BINARY_NAME_API=NourybotApi.out
cup:
sudo docker compose up
xd:
cd cmd/bot && go build -o ${BINARY_NAME} && ./${BINARY_NAME} -env="dev"
cd cmd/nourybot && go build -o ${BINARY_NAME} && ./${BINARY_NAME} -env="dev"
xdprod:
cd cmd/bot && go build -o ${BINARY_NAME} && ./${BINARY_NAME} -env="prod"
cd cmd/nourybot && go build -o ${BINARY_NAME} && ./${BINARY_NAME} -env="prod"
jq:
cd cmd/bot && go build -o ${BINARY_NAME} && ./${BINARY_NAME} -env="dev" | jq
cd cmd/nourybot && go build -o ${BINARY_NAME} && ./${BINARY_NAME} -env="dev" | jq
jqprod:
cd cmd/bot && go build -o ${BINARY_NAME} && ./${BINARY_NAME} -env="prod" | jq
cd cmd/nourybot && go build -o ${BINARY_NAME} && ./${BINARY_NAME} -env="prod" | jq
jqapi:
go build -o ${BINARY_NAME_API} cmd/api && ./${BINARY_NAME} | jq

View file

@ -1,7 +1,2 @@
# nourybot
Near future abandoned project in development.
### Make:
Development:
make jq
Lidl Twitch bot

View file

@ -1,181 +0,0 @@
package main
import (
"errors"
"fmt"
"net/http"
"github.com/lyx0/nourybot/internal/data"
)
func (app *application) showCommandHandler(w http.ResponseWriter, r *http.Request) {
name, err := app.readCommandNameParam(r)
if err != nil {
app.logError(r, err)
return
}
// Get the data for a specific movie from our helper method,
// then check if an error was returned, and which.
command, err := app.Models.Commands.Get(name)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.logError(r, err)
return
default:
app.serverErrorResponse(w, r, err)
return
}
}
app.Logger.Infow("GET Command",
"Command", command,
)
w.Header().Set("Access-Control-Allow-Origin", "*")
err = app.writeJSON(w, http.StatusOK, envelope{"command": command}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) createCommandHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Name string `json:"name"`
Text string `json:"text"`
Category string `json:"category"`
Level int `json:"level"`
}
err := app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
command := &data.Command{
Name: input.Name,
Text: input.Text,
Category: input.Category,
Level: input.Level,
}
err = app.Models.Commands.Insert(command)
if err != nil {
app.serverErrorResponse(w, r, err)
return
}
headers := make(http.Header)
headers.Set("Location", fmt.Sprintf("/v1/commands/%s", command.Name))
app.Logger.Infow("PUT Command",
"Command", command,
)
err = app.writeJSON(w, http.StatusCreated, envelope{"command": command}, headers)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) updateCommandHandler(w http.ResponseWriter, r *http.Request) {
name, err := app.readCommandNameParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
command, err := app.Models.Commands.Get(name)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
var input struct {
Name *string `json:"name"`
Text *string `json:"text"`
Category *string `json:"category"`
Level *int `json:"level"`
}
err = app.readJSON(w, r, &input)
if err != nil {
app.badRequestResponse(w, r, err)
return
}
// There is a name since we successfully queried the database for
// a command, so no need to check != nil.
command.Name = *input.Name
if input.Text != nil {
command.Text = *input.Text
}
if input.Category != nil {
command.Category = *input.Category
}
if input.Level != nil {
command.Level = *input.Level
}
err = app.Models.Commands.Update(command)
if err != nil {
switch {
case errors.Is(err, data.ErrEditConflict):
app.editConflictResponse(w, r)
return
default:
app.serverErrorResponse(w, r, err)
return
}
}
app.Logger.Infow("PATCH Command",
"Command", command,
)
err = app.writeJSON(w, http.StatusOK, envelope{"command": command}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
func (app *application) deleteCommandHandler(w http.ResponseWriter, r *http.Request) {
name, err := app.readCommandNameParam(r)
if err != nil {
app.notFoundResponse(w, r)
return
}
err = app.Models.Commands.Delete(name)
if err != nil {
switch {
case errors.Is(err, data.ErrRecordNotFound):
app.notFoundResponse(w, r)
default:
app.serverErrorResponse(w, r, err)
}
return
}
app.Logger.Infow("DELETE Command",
"Name", name,
)
err = app.writeJSON(w, http.StatusOK, envelope{"message": fmt.Sprintf("command %s deleted", name)}, nil)
if err != nil {
app.serverErrorResponse(w, r, err)
}
}
type envelope map[string]interface{}

View file

@ -1,48 +0,0 @@
package main
import (
"fmt"
"net/http"
)
func (app *application) serverErrorResponse(w http.ResponseWriter, r *http.Request, err error) {
app.logError(r, err)
message := "the server encountered a problem and could not process your request"
app.errorResponse(w, r, http.StatusInternalServerError, message)
}
// The logError() method is a generic helper for logging an error message.
func (app *application) logError(r *http.Request, err error) {
app.Logger.Errorw("Error",
"Request URI", r.RequestURI,
"error", err,
)
}
func (app *application) errorResponse(w http.ResponseWriter, r *http.Request, status int, message interface{}) {
// Write the response using the writeJSON() helper. If this happens to return an
// error then log it, and fall back to sending the client an empty response with a
// 500 Internal Server Error status code.
fmt.Fprintf(w, "Error: %d", status)
}
func (app *application) badRequestResponse(w http.ResponseWriter, r *http.Request, err error) {
app.errorResponse(w, r, http.StatusBadRequest, err.Error())
}
func (app *application) notFoundResponse(w http.ResponseWriter, r *http.Request) {
message := "the requested resource could not be found"
app.errorResponse(w, r, http.StatusNotFound, message)
}
func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) {
message := "unable to update the record due to an edit conflict, please try again"
app.errorResponse(w, r, http.StatusConflict, message)
}
func (app *application) methodNotAllowedResponse(w http.ResponseWriter, r *http.Request) {
message := fmt.Sprintf("the %s method is not supported for this resource", r.Method)
app.errorResponse(w, r, http.StatusMethodNotAllowed, message)
}

View file

@ -1,114 +0,0 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/julienschmidt/httprouter"
)
// func (app *application) readIDParam(r *http.Request) (int64, error) {
// params := httprouter.ParamsFromContext(r.Context())
//
// // Use `ByName()` function to get the value of the "id" parameter from the slice.
// // The value returned by `ByName()` is always a string so we try to convert it to
// // base64 with a bit size of 64.
// id, err := strconv.ParseInt(params.ByName("id"), 10, 64)
// if err != nil || id < 1 {
// return 0, errors.New("invalid id parameter")
// }
//
// return id, nil
// }
func (app *application) readJSON(w http.ResponseWriter, r *http.Request, dst interface{}) error {
// Limit the size of the requst body to 1MB.
maxBytes := 1_048_576
r.Body = http.MaxBytesReader(w, r.Body, int64(maxBytes))
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
err := dec.Decode(dst)
if err != nil {
var syntaxError *json.SyntaxError
var unmarshalTypeError *json.UnmarshalTypeError
var invalidUnmarshalError *json.InvalidUnmarshalError
switch {
case errors.As(err, &syntaxError):
return fmt.Errorf("body contains malformed JSON (at character %d)", syntaxError.Offset)
case errors.Is(err, io.ErrUnexpectedEOF):
return errors.New("body contains invalid JSON")
case errors.As(err, &unmarshalTypeError):
if unmarshalTypeError.Field != "" {
return fmt.Errorf("body contains invalid JSON type for field %q", unmarshalTypeError.Field)
}
return fmt.Errorf("body contains invalid JSON type (at character %d)", unmarshalTypeError.Offset)
case errors.Is(err, io.EOF):
return errors.New("body contains no data")
case strings.HasPrefix(err.Error(), "json: unknown field "):
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
return fmt.Errorf("body contains unknown key %s", fieldName)
case err.Error() == "htto: request body too large":
return fmt.Errorf("body must not be larger than %d bytes", maxBytes)
case errors.As(err, &invalidUnmarshalError):
app.Logger.Panic(err)
default:
return err
}
}
err = dec.Decode(&struct{}{})
if err != io.EOF {
return errors.New("body must only contain a single JSON value")
}
return nil
}
func (app *application) readCommandNameParam(r *http.Request) (string, error) {
params := httprouter.ParamsFromContext(r.Context())
// Use `ByName()` function to get the value of the "id" parameter from the slice.
// The value returned by `ByName()` is always a string so we try to convert it to
// base64 with a bit size of 64.
name := params.ByName("name")
return name, nil
}
func (app *application) writeJSON(w http.ResponseWriter, status int, data envelope, headers http.Header) error {
// Encode the data into JSON and return any errors if there were any.
// Use MarshalIndent instead of normal Marshal so it looks prettier on terminals.
js, err := json.MarshalIndent(data, "", "\t")
if err != nil {
return err
}
// Append a newline to make it prettier on terminals.
js = append(js, '\n')
// Iterate over the header map and add each header to the
// http.ResponseWriter header map.
for key, value := range headers {
w.Header()[key] = value
}
// Set `Content-Type` to `application/json` because go
// defaults to `text-plain; charset=utf8`.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write(js)
return nil
}

View file

@ -1,110 +0,0 @@
package main
import (
"context"
"database/sql"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/joho/godotenv"
_ "github.com/lib/pq"
"github.com/lyx0/nourybot/internal/data"
"go.uber.org/zap"
)
type config struct {
port int
db struct {
dsn string
maxOpenConns int
maxIdleConns int
maxIdleTime string
}
}
type application struct {
Logger *zap.SugaredLogger
Db *sql.DB
Models data.Models
}
func main() {
var cfg config
// Initialize a new sugared logger that we'll pass on
// down through the application.
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
// Database
cfg.db.dsn = os.Getenv("DB_DSN")
cfg.port = 3000
cfg.db.maxOpenConns = 25
cfg.db.maxIdleConns = 25
cfg.db.maxIdleTime = "15m"
// Establish database connection
db, err := openDB(cfg)
if err != nil {
sugar.Fatal(err)
}
// Initialize Application
app := &application{
Logger: sugar,
Db: db,
Models: data.NewModels(db),
}
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cfg.port),
Handler: app.routes(),
IdleTimeout: time.Minute,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
err = srv.ListenAndServe()
sugar.Fatal(err)
}
// openDB returns the sql.DB connection pool.
func openDB(cfg config) (*sql.DB, error) {
// sql.Open() creates an empty connection pool with the provided DSN
db, err := sql.Open("postgres", cfg.db.dsn)
if err != nil {
return nil, err
}
// Set database restraints.
db.SetMaxOpenConns(cfg.db.maxOpenConns)
db.SetMaxIdleConns(cfg.db.maxIdleConns)
// Parse the maxIdleTime string into an actual duration and set it.
duration, err := time.ParseDuration(cfg.db.maxIdleTime)
if err != nil {
return nil, err
}
db.SetConnMaxIdleTime(duration)
// Create a new context with a 5 second timeout.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// db.PingContext() is needed to actually check if the
// connection to the database was successful.
err = db.PingContext(ctx)
if err != nil {
return nil, err
}
return db, nil
}

View file

@ -1,30 +0,0 @@
package main
import (
"net/http"
"github.com/julienschmidt/httprouter"
)
func (app *application) routes() *httprouter.Router {
router := httprouter.New()
router.NotFound = http.HandlerFunc(app.notFoundResponse)
router.MethodNotAllowed = http.HandlerFunc(app.methodNotAllowedResponse)
// cors.Default().Handler(router)
router.HandlerFunc(http.MethodGet, "/v1/commands/:name", app.showCommandHandler)
router.HandlerFunc(http.MethodPost, "/v1/commands", app.createCommandHandler)
router.HandlerFunc(http.MethodPatch, "/v1/commands/:name", app.updateCommandHandler)
router.HandlerFunc(http.MethodDelete, "/v1/commands/:name", app.deleteCommandHandler)
return router
}
func MiddleCORS(next httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter,
r *http.Request, ps httprouter.Params) {
w.Header().Set("Access-Control-Allow-Origin", "*")
next(w, r, ps)
}
}

View file

@ -1,15 +0,0 @@
TWITCH_USERNAME=nourybot
TWITCH_OAUTH=oauth:mycooloauth42060
TWITCH_COMMAND_PREFIX=()
TWITCH_CLIENT_ID=mycooltwitchclientid
TWITCH_CLIENT_SECRET=mycooltwitchclientsecret
LOCAL_DSN=postgres://user:password@localhost/database-name?sslmode=disable
SUPABASE_DSN=postgres://user:password@db.XXXXXXXXX.supabase.co/postgres
OWM_KEY=mycoolopenweathermapapikey
LAST_FM_APPLICATION_NAME=mycoolapplicationname
LAST_FM_API_KEY=mycoollastfmapikey
LAST_FM_SECRET=mycoollastfmsecret

View file

@ -1,86 +0,0 @@
package main
import (
"fmt"
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/commands/decapi"
"github.com/lyx0/nourybot/internal/common"
"github.com/lyx0/nourybot/internal/data"
)
// AddChannel takes in a channel name, then calls GetIdByLogin for the
// channels ID and inserts both the name and id value into the database.
// If there is no error thrown the TwitchClient joins the channel afterwards.
func (app *Application) AddChannel(login string, message twitch.PrivateMessage) {
userId, err := decapi.GetIdByLogin(login)
if err != nil {
app.Logger.Error(err)
return
}
// Initialize a new channel struct holding the values that will be
// passed into the app.Models.Channels.Insert() method.
channel := &data.Channel{
Login: login,
TwitchID: userId,
}
err = app.Models.Channels.Insert(channel)
if err != nil {
reply := fmt.Sprintf("Something went wrong FeelsBadMan %s", err)
common.Send(message.Channel, reply, app.TwitchClient)
return
} else {
app.TwitchClient.Join(login)
reply := fmt.Sprintf("Added channel %s", login)
common.Send(message.Channel, reply, app.TwitchClient)
return
}
}
// GetAllChannels() queries the database and lists all channels.
// Only used for debug/information purposes.
func (app *Application) GetAllChannels() {
channel, err := app.Models.Channels.GetAll()
if err != nil {
app.Logger.Error(err)
return
}
app.Logger.Infow("All channels:",
"channel", channel)
}
// DeleteChannel queries the database for a channel name and if it exists
// deletes the channel and makes the bot depart said channel.
func (app *Application) DeleteChannel(login string, message twitch.PrivateMessage) {
err := app.Models.Channels.Delete(login)
if err != nil {
common.Send(message.Channel, "Something went wrong FeelsBadMan", app.TwitchClient)
app.Logger.Error(err)
return
}
app.TwitchClient.Depart(login)
reply := fmt.Sprintf("Deleted channel %s", login)
common.Send(message.Channel, reply, app.TwitchClient)
}
// InitialJoin is called on startup and queries the database for a list of
// channels which the TwitchClient then joins.
func (app *Application) InitialJoin() {
// GetJoinable returns a slice of channel names.
channel, err := app.Models.Channels.GetJoinable()
if err != nil {
app.Logger.Error(err)
return
}
// Iterate over the slice of channels and join each.
for _, v := range channel {
app.TwitchClient.Join(v)
app.Logger.Infow("Joining channel",
"channel", v)
}
}

View file

@ -1,240 +0,0 @@
package main
import (
"fmt"
"strconv"
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/common"
"github.com/lyx0/nourybot/internal/data"
)
// AddCommand splits a message into two parts and passes on the
// name and text to the database handler.
func (app *Application) AddCommand(name string, message twitch.PrivateMessage) {
// snipLength is the length we need to "snip" off of the start of `message`.
// `()addcommand` = +12
// trailing space = +1
// zero-based = +1
// = 14
snipLength := 14
// Split the twitch message at `snipLength` plus length of the name of the
// command that we want to add.
// The part of the message we are left over with is then passed on to the database
// handlers as the `text` part of the command.
//
// e.g. `()addcommand CoolSponsors Check out CoolSponsor.com they are the coolest sponsors!
// | <- snipLength + name -> | <--- command text with however many characters ---> |
// | <----- 14 + 12 ------> |
text := message.Message[snipLength+len(name) : len(message.Message)]
command := &data.Command{
Name: name,
Text: text,
Category: "uncategorized",
Level: 0,
Help: "",
}
err := app.Models.Commands.Insert(command)
if err != nil {
reply := fmt.Sprintf("Something went wrong FeelsBadMan %s", err)
common.Send(message.Channel, reply, app.TwitchClient)
return
} else {
reply := fmt.Sprintf("Successfully added command: %s", name)
common.Send(message.Channel, reply, app.TwitchClient)
return
}
}
// GetCommand queries the database for a name. If an entry exists it checks
// if the Command.Level is 0, if it is the command.Text value is returned.
//
// If the Command.Level is not 0 it queries the database for the level of the
// user who sent the message. If the users level is equal or higher
// the command.Text field is returned.
func (app *Application) GetCommand(name, username string) (string, error) {
// Fetch the command from the database if it exists.
command, err := app.Models.Commands.Get(name)
if err != nil {
// It probably did not exist
return "", err
}
// If the command has no level set just return the text.
// Otherwise check if the level is high enough.
if command.Level == 0 {
return command.Text, nil
} else {
// Get the user from the database to check if the userlevel is equal
// or higher than the command.Level.
user, err := app.Models.Users.Get(username)
if err != nil {
return "", err
}
if user.Level >= command.Level {
// Userlevel is sufficient so return the command.Text
return command.Text, nil
}
}
// Userlevel was not enough so return an empty string and error.
return "", ErrUserInsufficientLevel
}
// GetCommand queries the database for a name. If an entry exists it checks
// if the Command.Level is 0, if it is the command.Text value is returned.
//
// If the Command.Level is not 0 it queries the database for the level of the
// user who sent the message. If the users level is equal or higher
// the command.Text field is returned.
func (app *Application) GetCommandHelp(name, username string) (string, error) {
// Fetch the command from the database if it exists.
command, err := app.Models.Commands.Get(name)
if err != nil {
// It probably did not exist
return "", err
}
// If the command has no level set just return the text.
// Otherwise check if the level is high enough.
if command.Level == 0 {
return command.Help, nil
} else {
// Get the user from the database to check if the userlevel is equal
// or higher than the command.Level.
user, err := app.Models.Users.Get(username)
if err != nil {
return "", err
}
if user.Level >= command.Level {
// Userlevel is sufficient so return the command.Text
return command.Help, nil
}
}
// Userlevel was not enough so return an empty string and error.
return "", ErrUserInsufficientLevel
}
// EditCommandLevel takes in a name and level string and updates the entry with name
// to the supplied level value.
func (app *Application) EditCommandLevel(name, lvl string, message twitch.PrivateMessage) {
level, err := strconv.Atoi(lvl)
if err != nil {
app.Logger.Error(err)
common.Send(message.Channel, fmt.Sprintf("Something went wrong FeelsBadMan %s", ErrCommandLevelNotInteger), app.TwitchClient)
return
}
err = app.Models.Commands.SetLevel(name, level)
if err != nil {
common.Send(message.Channel, fmt.Sprintf("Something went wrong FeelsBadMan %s", ErrRecordNotFound), app.TwitchClient)
app.Logger.Error(err)
return
} else {
reply := fmt.Sprintf("Updated command %s to level %v", name, level)
common.Send(message.Channel, reply, app.TwitchClient)
return
}
}
// EditCommandCategory takes in a name and category string and updates the command
// in the databse with the passed in new category.
func (app *Application) EditCommandCategory(name, category string, message twitch.PrivateMessage) {
err := app.Models.Commands.SetCategory(name, category)
if err != nil {
common.Send(message.Channel, fmt.Sprintf("Something went wrong FeelsBadMan %s", ErrRecordNotFound), app.TwitchClient)
app.Logger.Error(err)
return
} else {
reply := fmt.Sprintf("Updated command %s to category %v", name, category)
common.Send(message.Channel, reply, app.TwitchClient)
return
}
}
// DebugCommand checks if a command with the provided name exists in the database
// and outputs information about it in the chat.
func (app *Application) DebugCommand(name string, message twitch.PrivateMessage) {
// Query the database for a command with the provided name
cmd, err := app.Models.Commands.Get(name)
if err != nil {
reply := fmt.Sprintf("Something went wrong FeelsBadMan %s", err)
common.Send(message.Channel, reply, app.TwitchClient)
return
} else if cmd.Category == "ascii" {
// If the command is in the ascii category don't post the Text field
// otherwise it becomes too spammy and won't fit in the max message length.
reply := fmt.Sprintf("ID %v: Name %v, Level: %v, Category: %v Help: %v",
cmd.ID,
cmd.Name,
cmd.Level,
cmd.Category,
cmd.Help,
)
common.Send(message.Channel, reply, app.TwitchClient)
return
} else {
reply := fmt.Sprintf("ID %v: Name %v, Level: %v, Category: %v, Text: %v, Help: %v",
cmd.ID,
cmd.Name,
cmd.Level,
cmd.Category,
cmd.Text,
cmd.Help,
)
common.Send(message.Channel, reply, app.TwitchClient)
return
}
}
// SetCommandHelp updates the `help` column of a given commands name in the
// database to the provided new help text.
func (app *Application) EditCommandHelp(name string, message twitch.PrivateMessage) {
// snipLength is the length we need to "snip" off of the start of `message`.
// `()editcommand` = +13
// trailing space = +1
// zero-based = +1
// `help` = +4
// = 19
snipLength := 19
// Split the twitch message at `snipLength` plus length of the name of the
// command that we want to set the help text for so that we get the
// actual help message left over and then assign this string to `text`.
//
// e.g. `()editcommand help FeelsDankMan Returns a FeelsDankMan ascii art. Requires user level 500.`
// | <---- snipLength + name ----> | <------ help text with however many characters. ----> |
// | <--------- 19 + 12 --------> |
text := message.Message[snipLength+len(name) : len(message.Message)]
err := app.Models.Commands.SetHelp(name, text)
if err != nil {
common.Send(message.Channel, fmt.Sprintf("Something went wrong FeelsBadMan %s", ErrRecordNotFound), app.TwitchClient)
app.Logger.Error(err)
return
} else {
reply := fmt.Sprintf("Updated help text for command %s to: %v", name, text)
common.Send(message.Channel, reply, app.TwitchClient)
return
}
}
// DeleteCommand takes in a name value and deletes the command from the database if it exists.
func (app *Application) DeleteCommand(name string, message twitch.PrivateMessage) {
err := app.Models.Commands.Delete(name)
if err != nil {
common.Send(message.Channel, "Something went wrong FeelsBadMan", app.TwitchClient)
app.Logger.Error(err)
return
}
reply := fmt.Sprintf("Deleted command %s", name)
common.Send(message.Channel, reply, app.TwitchClient)
}

View file

@ -1,561 +0,0 @@
package main
import (
"fmt"
"strings"
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/commands"
"github.com/lyx0/nourybot/internal/common"
)
// 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) {
// Increments the counter how many commands have been used, called in the ping command.
common.CommandUsed()
// 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
// Userlevel is the level set for a user in the database.
// It is NOT same as twitch user/mod.
// 1000 = admin
// 500 = mod
// 250 = vip
// 100 = normal
// If the level returned is -1 then the user was not found in the database.
userLevel := app.GetUserLevel(message.User.ID)
app.Logger.Infow("Command received",
// "message", message, // Pretty taxing
"message.Message", message.Message,
"message.Channel", target,
"userLevel", userLevel,
"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 {
common.Send(target, "xd", app.TwitchClient)
return
}
case "nourybot":
common.Send(target, "Lidl Twitch bot made by @nourylul. Prefix: ()", app.TwitchClient)
return
// ()bttv <emote name>
case "bttv":
if msgLen < 2 {
common.Send(target, "Not enough arguments provided. Usage: ()bttv <emote name>", app.TwitchClient)
return
} else {
commands.Bttv(target, cmdParams[1], app.TwitchClient)
return
}
// Coinflip
case "coin":
commands.Coinflip(target, app.TwitchClient)
return
case "coinflip":
commands.Coinflip(target, app.TwitchClient)
return
case "cf":
commands.Coinflip(target, app.TwitchClient)
return
// ()currency <amount> <input currency> to <output currency>
case "currency":
if msgLen < 4 {
common.Send(target, "Not enough arguments provided. Usage: ()currency 10 USD to EUR", app.TwitchClient)
return
}
commands.Currency(target, cmdParams[1], cmdParams[2], cmdParams[4], app.TwitchClient)
return
// ()ffz <emote name>
case "ffz":
if msgLen < 2 {
common.Send(target, "Not enough arguments provided. Usage: ()ffz <emote name>", app.TwitchClient)
return
} else {
commands.Ffz(target, cmdParams[1], app.TwitchClient)
return
}
// ()followage <channel> <username>
case "followage":
if msgLen == 1 { // ()followage
commands.Followage(target, target, message.User.Name, app.TwitchClient)
return
} else if msgLen == 2 { // ()followage forsen
commands.Followage(target, target, cmdParams[1], app.TwitchClient)
return
} else { // ()followage forsen pajlada
commands.Followage(target, cmdParams[1], cmdParams[2], app.TwitchClient)
return
}
// First Line
// ()firstline <channel> <username>
case "firstline":
if msgLen == 1 {
common.Send(target, "Usage: ()firstline <channel> <user>", app.TwitchClient)
return
} else if msgLen == 2 {
commands.FirstLine(target, target, cmdParams[1], app.TwitchClient)
return
} else {
commands.FirstLine(target, cmdParams[1], cmdParams[2], app.TwitchClient)
return
}
// ()fl <channel> <username>
case "fl":
if msgLen == 1 {
common.Send(target, "Usage: ()firstline <channel> <user>", app.TwitchClient)
return
} else if msgLen == 2 {
commands.FirstLine(target, target, cmdParams[1], app.TwitchClient)
return
} else {
commands.FirstLine(target, cmdParams[1], cmdParams[2], app.TwitchClient)
return
}
case "lastfm":
if msgLen == 1 {
app.UserCheckLastFM(message)
return
} else if cmdParams[1] == "artist" && cmdParams[2] == "top" {
commands.LastFmArtistTop(target, message, app.TwitchClient)
return
} else {
// Default to first argument supplied being the name
// of the user to look up recently played.
commands.LastFmUserRecent(target, cmdParams[1], app.TwitchClient)
return
}
case "help":
if msgLen == 1 {
common.Send(target, "Provides information for a given command. Usage: ()help <commandname>", app.TwitchClient)
return
} else {
app.commandHelp(target, cmdParams[1], message.User.Name)
return
}
// ()ping
case "ping":
commands.Ping(target, app.TwitchClient)
return
// Thumbnail
// ()preview <live channel>
case "preview":
if msgLen < 2 {
common.Send(target, "Not enough arguments provided. Usage: ()preview <username>", app.TwitchClient)
return
} else {
commands.Preview(target, cmdParams[1], app.TwitchClient)
return
}
case "set":
if msgLen < 3 {
common.Send(target, "Not enough arguments provided.", app.TwitchClient)
return
} else if cmdParams[1] == "lastfm" {
app.SetUserLastFM(cmdParams[2], message)
//app.SetLastFMUser(cmdParams[2], message)
return
} else if cmdParams[1] == "location" {
app.SetUserLocation(message)
return
} else {
return
}
// SevenTV
// ()seventv <emote name>
case "seventv":
if msgLen < 2 {
common.Send(target, "Not enough arguments provided. Usage: ()seventv <emote name>", app.TwitchClient)
return
} else {
commands.Seventv(target, cmdParams[1], app.TwitchClient)
return
}
// ()thumbnail <live channel>
case "thumbnail":
if msgLen < 2 {
common.Send(target, "Not enough arguments provided. Usage: ()thumbnail <username>", app.TwitchClient)
return
} else {
commands.Preview(target, cmdParams[1], app.TwitchClient)
return
}
// ()7tv <emote name>
case "7tv":
if msgLen < 2 {
common.Send(target, "Not enough arguments provided. Usage: ()seventv <emote name>", app.TwitchClient)
return
} else {
commands.Seventv(target, cmdParams[1], app.TwitchClient)
return
}
// ()tweet <username>
case "tweet":
if msgLen < 2 {
common.Send(target, "Not enough arguments provided. Usage: ()tweet <username>", app.TwitchClient)
return
} else {
commands.Tweet(target, cmdParams[1], app.TwitchClient)
return
}
case "phonetic":
if msgLen < 2 {
common.Send(target, "Not enough arguments provided. Usage: ()phonetic <text>. ()help phonetic for more info", app.TwitchClient)
return
} else {
commands.Phonetic(target, message.Message[10:len(message.Message)], app.TwitchClient)
return
}
case "ph":
if msgLen < 2 {
common.Send(target, "Not enough arguments provided. Usage: ()ph <text>. ()help ph for more info", app.TwitchClient)
return
} else {
commands.Phonetic(target, message.Message[4:len(message.Message)], app.TwitchClient)
return
}
// ()weather <location>
case "weather":
if msgLen == 1 {
// Default to first argument supplied being the name
// of the user to look up recently played.
app.UserCheckWeather(message)
return
} else if msgLen < 2 {
common.Send(target, "Not enough arguments provided.", app.TwitchClient)
return
} else {
commands.Weather(target, message.Message[10:len(message.Message)], app.TwitchClient)
return
}
// Xkcd
// Random Xkcd
case "rxkcd":
commands.RandomXkcd(target, app.TwitchClient)
return
case "randomxkcd":
commands.RandomXkcd(target, app.TwitchClient)
return
// Latest Xkcd
case "xkcd":
commands.Xkcd(target, app.TwitchClient)
return
// Commands with permission level or database from here on
//#################
// 250 - VIP only
//#################
// ()debug user <username>
// ()debug command <command name>
case "debug":
if userLevel < 250 {
return
} else if msgLen < 3 {
common.Send(target, "Not enough arguments provided.", app.TwitchClient)
return
} else if cmdParams[1] == "user" {
app.DebugUser(cmdParams[2], message)
return
} else if cmdParams[1] == "command" {
app.DebugCommand(cmdParams[2], message)
return
} else {
return
}
// ()echo <message>
case "echo":
if userLevel < 250 { // Limit to myself for now.
return
} else if msgLen < 2 {
common.Send(target, "Not enough arguments provided.", app.TwitchClient)
return
} else {
commands.Echo(target, message.Message[7:len(message.Message)], app.TwitchClient)
return
}
//###################
// 1000 - Admin only
//###################
// #####
// Add
// #####
case "addchannel":
if userLevel < 1000 {
return
} else if msgLen < 2 {
common.Send(target, "Not enough arguments provided.", app.TwitchClient)
return
} else {
// ()addchannel noemience
app.AddChannel(cmdParams[1], message)
return
}
case "addcommand":
if userLevel < 1000 {
return
} else if msgLen < 3 {
common.Send(target, "Not enough arguments provided.", app.TwitchClient)
return
} else {
// ()addcommand dank FeelsDankMan xD
app.AddCommand(cmdParams[1], message)
return
}
case "addtimer":
if userLevel < 1000 {
return
} else if msgLen < 4 {
common.Send(target, "Not enough arguments provided.", app.TwitchClient)
return
} else {
// ()addtimer gfuel 5m sponsor XD xD
app.AddTimer(cmdParams[1], message)
return
}
// ######
// Edit
// ######
case "edituser":
if userLevel < 1000 { // Limit to myself for now.
return
} else if msgLen < 4 {
common.Send(target, "Not enough arguments provided.", app.TwitchClient)
return
} else if cmdParams[1] == "level" {
// ()edituser level nourylul 1000
app.EditUserLevel(cmdParams[2], cmdParams[3], message)
return
} else {
return
}
// ()edittimer testname 10m test text xd
case "edittimer":
if userLevel < 1000 { // Limit to myself for now.
return
} else if msgLen < 4 {
common.Send(target, "Not enough arguments provided.", app.TwitchClient)
return
} else {
// ()edituser level nourylul 1000
app.EditTimer(cmdParams[1], message)
}
case "editcommand": // ()editcommand level dankwave 1000
if userLevel < 1000 {
return
} else if msgLen < 4 {
common.Send(target, "Not enough arguments provided.", app.TwitchClient)
return
} else if cmdParams[1] == "level" {
app.EditCommandLevel(cmdParams[2], cmdParams[3], message)
return
} else if cmdParams[1] == "category" {
app.EditCommandCategory(cmdParams[2], cmdParams[3], message)
return
} else if cmdParams[1] == "help" {
app.EditCommandHelp(cmdParams[2], message)
return
} else {
return
}
// ########
// Delete
// ########
case "deletechannel":
if userLevel < 1000 { // Limit to myself for now.
return
} else if msgLen < 2 {
common.Send(target, "Not enough arguments provided.", app.TwitchClient)
return
} else {
// ()deletechannel noemience
app.DeleteChannel(cmdParams[1], message)
return
}
case "deletecommand":
if userLevel < 1000 { // Limit to myself for now.
return
} else if msgLen < 2 {
common.Send(target, "Not enough arguments provided.", app.TwitchClient)
return
} else {
// ()deletecommand dank
app.DeleteCommand(cmdParams[1], message)
return
}
case "deleteuser":
if userLevel < 1000 { // Limit to myself for now.
return
} else if msgLen < 2 {
common.Send(target, "Not enough arguments provided.", app.TwitchClient)
return
} else {
// ()deleteuser noemience
app.DeleteUser(cmdParams[1], message)
return
}
case "deletetimer":
if userLevel < 1000 { // Limit to myself for now.
return
} else if msgLen < 2 {
common.Send(target, "Not enough arguments provided.", app.TwitchClient)
return
} else {
// ()deletetimer dank
app.DeleteTimer(cmdParams[1], message)
return
}
case "asd":
app.Logger.Info(app.Scheduler.Entries())
return
case "bttvemotes":
if userLevel < 1000 {
commands.Bttvemotes(target, app.TwitchClient)
return
} else {
return
}
case "ffzemotes":
if userLevel < 1000 {
commands.Ffzemotes(target, app.TwitchClient)
return
} else {
return
}
// ##################
// Check if the commandName exists as the "name" of a command in the database.
// if it doesnt then ignore it.
// ##################
default:
reply, err := app.GetCommand(commandName, message.User.Name)
if err != nil {
return
}
common.SendNoLimit(message.Channel, reply, app.TwitchClient)
return
}
}
// Map of known commands with their help texts.
var helpText = map[string]string{
"bttv": "Returns the search URL for a given BTTV emote. Example usage: ()bttv <emote name>",
"coin": "Flips a coin! Aliases: coinflip, coin, cf",
"cf": "Flips a coin! Aliases: coinflip, coin, cf",
"coinflip": "Flips a coin! Aliases: coinflip, coin, cf",
"currency": "Returns the exchange rate for two currencies. Only three letter abbreviations are supported ( List of supported currencies: https://decapi.me/misc/currency?list ). Example usage: ()currency 10 USD to EUR",
"ffz": "Returns the search URL for a given FFZ emote. Example usage: ()ffz <emote name>",
"followage": "Returns how long a given user has been following a channel. Example usage: ()followage <channel> <username>",
"firstline": "Returns the first message a user has sent in a given channel. Aliases: firstline, fl. Example usage: ()firstline <channel> <username>",
"fl": "Returns the first message a user has sent in a given channel. Aliases: firstline, fl. Example usage: ()fl <channel> <username>",
"help": "Returns more information about a command and its usage. 4Head Example usage: ()help <command name>",
"ping": "Hopefully returns a Pong! monkaS",
"preview": "Returns a link to an (almost) live screenshot of a live channel. Alias: preview, thumbnail. Example usage: ()preview <channel>",
"phonetic": "Translates the input to the text equivalent on a phonetic russian keyboard layout. Layout and general functionality is the same as https://russian.typeit.org/",
"ph": "Translates the input to the text equivalent on a phonetic russian keyboard layout. Layout and general functionality is the same as https://russian.typeit.org/",
"thumbnail": "Returns a link to an (almost) live screenshot of a live channel. Alias: preview, thumbnail. Example usage: ()thumbnail <channel>",
"tweet": "Returns the latest tweet for a provided user. Example usage: ()tweet <username>",
"seventv": "Returns the search URL for a given SevenTV emote. Aliases: seventv, 7tv. Example usage: ()seventv FeelsDankMan",
"7tv": "Returns the search URL for a given SevenTV emote. Aliases: seventv, 7tv. Example usage: ()7tv FeelsDankMan",
"weather": "Returns the weather for a given location. Example usage: ()weather Vilnius",
"randomxkcd": "Returns a link to a random xkcd comic. Alises: randomxkcd, rxkcd. Example usage: ()randomxkcd",
"rxkcd": "Returns a link to a random xkcd comic. Alises: randomxkcd, rxkcd. Example usage: ()rxkcd",
"xkcd": "Returns a link to the latest xkcd comic. Example usage: ()xkcd",
}
func (app *Application) loadCommandHelp() {
for k, v := range helpText {
err := app.Rdb.HSet(ctx, "command-help", k, v).Err()
if err != nil {
app.Logger.Panic(err)
}
}
commandHelpText := app.Rdb.HGetAll(ctx, "command-help").Val()
app.Logger.Infow("Successfully loaded command help text into redis",
"commandHelpText", commandHelpText,
)
}
// Help checks if a help text for a given command exists and replies with it.
func (app *Application) commandHelp(target, name, username string) {
// Check if the `helpText` map has an entry for `name`. If it does return it's value entry
// and send that as a reply.
i, ok := helpText[name]
if !ok {
// If it doesn't check the database for a command with that `name`. If there is one
// reply with that commands `help` entry.
c, err := app.GetCommandHelp(name, username)
if err != nil {
app.Logger.Infow("commandHelp: no such command found",
"err", err)
return
}
reply := fmt.Sprint(c)
common.Send(target, reply, app.TwitchClient)
return
}
reply := fmt.Sprint(i)
common.Send(target, reply, app.TwitchClient)
}

View file

@ -1,12 +0,0 @@
package main
import (
"errors"
)
var (
ErrUserLevelNotInteger = errors.New("user level must be a number")
ErrCommandLevelNotInteger = errors.New("command level must be a number")
ErrRecordNotFound = errors.New("user not found in the database")
ErrUserInsufficientLevel = errors.New("user has insufficient level")
)

View file

@ -1,325 +0,0 @@
package main
import (
"fmt"
"regexp"
"strings"
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/common"
"github.com/lyx0/nourybot/internal/data"
"github.com/redis/go-redis/v9"
)
// AddTimer slices the message into relevant parts, adding the values onto a
// new data.Timer struct so that the timer can be inserted into the database.
func (app *Application) AddTimer(name string, message twitch.PrivateMessage) {
cmdParams := strings.SplitN(message.Message, " ", 500)
// snipLength is the length of `()addcommand` plus +2 (for the space and zero based)
snipLength := 12
repeat := cmdParams[2]
// Split the message into the parts we need.
//
// message: ()addtimer sponsor 20m hecking love my madmonq pills BatChest
// parts: | prefix | |name | |repeat | <----------- text -------------> |
text := message.Message[snipLength+len(name)+len(cmdParams[2]) : len(message.Message)]
// validateTimeFormat will be true if the repeat parameter is in
// the format of either 30m, 10h, or 10h30m.
validateTimeFormat, err := regexp.MatchString(`^(\d{1,2}[h])$|^(\d+[m])$|^(\d+[s])$|((\d{1,2}[h])((([0]?|[1-5]{1})[0-9])[m]))$`, repeat)
if err != nil {
app.Logger.Errorw("Received malformed time format in timer",
"repeat", repeat,
"error", err,
)
return
}
timer := &data.Timer{
Name: name,
Text: text,
Channel: message.Channel,
Repeat: repeat,
}
// Check if the time string we got is valid, this is important
// because the Scheduler panics instead of erroring out if an invalid
// time format string is supplied.
if validateTimeFormat {
timer := &data.Timer{
Name: name,
Text: text,
Channel: message.Channel,
Repeat: repeat,
}
err = app.Models.Timers.Insert(timer)
if err != nil {
app.Logger.Errorw("Error inserting new timer into database",
"timer", timer,
"error", err,
)
reply := fmt.Sprintln("Something went wrong FeelsBadMan")
common.Send(message.Channel, reply, app.TwitchClient)
return
} else {
// cronName is the internal, unique tag/name for the timer.
// A timer named "sponsor" in channel "forsen" will be named "forsensponsor"
cronName := fmt.Sprintf("%s-%s", message.Channel, name)
app.Scheduler.AddFunc(fmt.Sprintf("@every %s", repeat), func() { app.newPrivateMessageTimer(message.Channel, text) }, cronName)
app.Logger.Infow("Added new timer",
"timer", timer,
)
if _, err := app.Rdb.Pipelined(ctx, func(rdb redis.Pipeliner) error {
rdb.HSet(ctx, cronName, "timer-name", name)
rdb.HSet(ctx, cronName, "timer-cronname", cronName)
rdb.HSet(ctx, cronName, "timer-text", text)
rdb.HSet(ctx, cronName, "timer-channel", message.Channel)
rdb.HSet(ctx, cronName, "timer-repeat", repeat)
return nil
}); err != nil {
app.Logger.Panic(err)
}
app.Logger.Infow("Loaded timer into redis:",
"key", cronName,
"value", app.Rdb.HGetAll(ctx, cronName),
)
reply := fmt.Sprintf("Successfully added timer %s repeating every %s", name, repeat)
common.Send(message.Channel, reply, app.TwitchClient)
return
}
} else {
app.Logger.Errorw("Received malformed time format in timer",
"timer", timer,
"error", err,
)
reply := "Something went wrong FeelsBadMan received wrong time format. Allowed formats: 30m, 10h, 10h30m"
common.Send(message.Channel, reply, app.TwitchClient)
return
}
}
// EditTimer just contains the logic for deleting a timer, and then adding a new one
// with the same name. It is technically not editing the timer.
func (app *Application) EditTimer(name string, message twitch.PrivateMessage) {
// Check if a timer with that name is in the database.
old, err := app.Models.Timers.Get(name)
if err != nil {
app.Logger.Errorw("Could not get timer",
"timer", old,
"error", err,
)
reply := "Something went wrong FeelsBadMan"
common.Send(message.Channel, reply, app.TwitchClient)
return
}
// Delete the old timer
cronName := fmt.Sprintf("%s-%s", message.Channel, name)
app.Scheduler.RemoveJob(cronName)
_ = app.Rdb.Del(ctx, cronName)
err = app.Models.Timers.Delete(name)
if err != nil {
app.Logger.Errorw("Error deleting timer from database",
"name", name,
"cronName", cronName,
"error", err,
)
reply := fmt.Sprintln("Something went wrong FeelsBadMan")
common.Send(message.Channel, reply, app.TwitchClient)
return
}
// Add the new timer
cmdParams := strings.SplitN(message.Message, " ", 500)
// snipLength is the length of `()editcommand` plus +2 (for the space and zero based)
snipLength := 13
repeat := cmdParams[2]
// Split the message into the parts we need.
//
// message: ()addtimer sponsor 20m hecking love my madmonq pills BatChest
// parts: | prefix | |name | |repeat | <----------- text -------------> |
text := message.Message[snipLength+len(name)+len(cmdParams[2]) : len(message.Message)]
// validateTimeFormat will be true if the repeat parameter is in
// the format of either 30m, 10h, or 10h30m.
validateTimeFormat, err := regexp.MatchString(`^(\d{1,2}[h])$|^(\d+[m])$|^(\d+[s])$|((\d{1,2}[h])((([0]?|[1-5]{1})[0-9])[m]))$`, repeat)
if err != nil {
app.Logger.Errorw("Received malformed time format in timer",
"repeat", repeat,
"error", err,
)
return
}
timer := &data.Timer{
Name: name,
Text: text,
Channel: message.Channel,
Repeat: repeat,
}
// Check if the time string we got is valid, this is important
// because the Scheduler panics instead of erroring out if an invalid
// time format string is supplied.
if validateTimeFormat {
timer := &data.Timer{
Name: name,
Text: text,
Channel: message.Channel,
Repeat: repeat,
}
err = app.Models.Timers.Insert(timer)
if err != nil {
app.Logger.Errorw("Error inserting new timer into database",
"timer", timer,
"error", err,
)
reply := fmt.Sprintln("Something went wrong FeelsBadMan")
common.Send(message.Channel, reply, app.TwitchClient)
return
} else { // this is a bit scuffed. The else here is the end of a successful call.
// cronName is the internal, unique tag/name for the timer.
// A timer named "sponsor" in channel "forsen" will be named "forsensponsor"
cronName := fmt.Sprintf("%s-%s", message.Channel, name)
app.Scheduler.AddFunc(fmt.Sprintf("@every %s", repeat), func() { app.newPrivateMessageTimer(message.Channel, text) }, cronName)
app.Logger.Infow("Updated a timer",
"Name", name,
"Channel", message.Channel,
"Old timer", old,
"New timer", timer,
)
if _, err := app.Rdb.Pipelined(ctx, func(rdb redis.Pipeliner) error {
rdb.HSet(ctx, cronName, "timer-name", name)
rdb.HSet(ctx, cronName, "timer-cronname", cronName)
rdb.HSet(ctx, cronName, "timer-text", text)
rdb.HSet(ctx, cronName, "timer-channel", message.Channel)
rdb.HSet(ctx, cronName, "timer-repeat", repeat)
return nil
}); err != nil {
app.Logger.Panic(err)
}
app.Logger.Infow("Loaded timer into redis:",
"key", cronName,
"value", app.Rdb.HGetAll(ctx, cronName),
)
reply := fmt.Sprintf("Successfully updated timer %s", name)
common.Send(message.Channel, reply, app.TwitchClient)
return
}
} else {
app.Logger.Errorw("Received malformed time format in timer",
"timer", timer,
"error", err,
)
reply := "Something went wrong FeelsBadMan received wrong time format. Allowed formats: 30s, 30m, 10h, 10h30m"
common.Send(message.Channel, reply, app.TwitchClient)
return
}
}
// InitialTimers is called on startup and queries the database for a list of
// timers and then adds each onto the scheduler.
func (app *Application) InitialTimers() {
timer, err := app.Models.Timers.GetAll()
if err != nil {
app.Logger.Errorw("Error trying to retrieve all timers from database", err)
return
}
// The slice of timers is only used to log them at
// the start so it looks a bit nicer.
var ts []*data.Timer
// Iterate over all timers and then add them onto the scheduler.
for i, v := range timer {
// idk why this works but it does so no touchy touchy.
// https://github.com/robfig/cron/issues/420#issuecomment-940949195
i, v := i, v
_ = i
// cronName is the internal, unique tag/name for the timer.
// A timer named "sponsor" in channel "forsen" will be named "forsensponsor"
cronName := fmt.Sprintf("%s-%s", v.Channel, v.Name)
// Repeating is at what times the timer should repeat.
// 2 minute timer is @every 2m
repeating := fmt.Sprintf("@every %s", v.Repeat)
if _, err := app.Rdb.Pipelined(ctx, func(rdb redis.Pipeliner) error {
rdb.HSet(ctx, cronName, "timer-id", v.ID)
rdb.HSet(ctx, cronName, "timer-name", v.Name)
rdb.HSet(ctx, cronName, "timer-text", v.Text)
rdb.HSet(ctx, cronName, "timer-channel", v.Channel)
rdb.HSet(ctx, cronName, "timer-repeat", v.Repeat)
return nil
}); err != nil {
app.Logger.Panic(err)
}
app.Logger.Infow("Loaded timer into redis:",
"key", cronName,
"value", app.Rdb.HGetAll(ctx, v.Channel),
)
app.Scheduler.AddFunc(repeating, func() { app.newPrivateMessageTimer(v.Channel, v.Text) }, cronName)
// Add new value to the slice
ts = append(ts, v)
}
// var model1 rdbVal
// if err := app.Rdb.HGetAll(ctx, cronName).Scan(&model1); err != nil {
// app.Logger.Panic(err)
// }
app.Logger.Infow("Initial timers",
"timer", ts,
)
}
// newPrivateMessageTimer is a helper function to set timers
// which trigger into sending a twitch PrivateMessage.
func (app *Application) newPrivateMessageTimer(channel, text string) {
common.Send(channel, text, app.TwitchClient)
}
// DeleteTimer takes in the name of a timer and tries to delete the timer from the database.
func (app *Application) DeleteTimer(name string, message twitch.PrivateMessage) {
cronName := fmt.Sprintf("%s-%s", message.Channel, name)
app.Scheduler.RemoveJob(cronName)
app.Logger.Infow("Deleting timer",
"name", name,
"message.Channel", message.Channel,
"cronName", cronName,
)
_ = app.Rdb.Del(ctx, cronName)
err := app.Models.Timers.Delete(name)
if err != nil {
app.Logger.Errorw("Error deleting timer from database",
"name", name,
"cronName", cronName,
"error", err,
)
reply := fmt.Sprintln("Something went wrong FeelsBadMan")
common.Send(message.Channel, reply, app.TwitchClient)
return
}
reply := fmt.Sprintf("Deleted timer with name %s", name)
common.Send(message.Channel, reply, app.TwitchClient)
}

View file

@ -1,213 +0,0 @@
package main
import (
"fmt"
"strconv"
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/commands"
"github.com/lyx0/nourybot/internal/common"
"go.uber.org/zap"
)
// AddUser calls GetIdByLogin to get the twitch id of the login name and then adds
// the login name, twitch id and supplied level to the database.
func (app *Application) InitUser(login, twitchId string, message twitch.PrivateMessage) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
_, err := app.Models.Users.Check(twitchId)
app.Logger.Error(err)
if err != nil {
app.Logger.Infow("InitUser: Adding new user:",
"login: ", login,
"twitchId: ", twitchId,
)
app.Models.Users.Insert(login, twitchId)
return
}
sugar.Infow("User Insert: User already registered: xd",
"login: ", login,
"twitchId: ", twitchId,
)
}
// DebugUser queries the database for a login name, if that name exists it returns the fields
// and outputs them to twitch chat and a twitch whisper.
func (app *Application) DebugUser(login string, message twitch.PrivateMessage) {
user, err := app.Models.Users.Get(login)
if err != nil {
reply := fmt.Sprintf("Something went wrong FeelsBadMan %s", err)
common.Send(message.Channel, reply, app.TwitchClient)
return
} else {
reply := fmt.Sprintf("User %v: ID %v, Login: %s, TwitchID: %v, Level: %v", login, user.ID, user.Login, user.TwitchID, user.Level)
common.Send(message.Channel, reply, app.TwitchClient)
app.TwitchClient.Whisper(message.User.Name, reply)
return
}
}
// DeleteUser takes in a login string, queries the database for an entry with
// that login name and tries to delete that entry in the database.
func (app *Application) DeleteUser(login string, message twitch.PrivateMessage) {
err := app.Models.Users.Delete(login)
if err != nil {
common.Send(message.Channel, "Something went wrong FeelsBadMan", app.TwitchClient)
app.Logger.Error(err)
return
}
reply := fmt.Sprintf("Deleted user %s", login)
common.Send(message.Channel, reply, app.TwitchClient)
}
// EditUserLevel tries to update the database record for the supplied
// login name with the new level.
func (app *Application) EditUserLevel(login, lvl string, message twitch.PrivateMessage) {
// Convert the level string to an integer. This is an easy check to see if
// the level supplied was a number only.
level, err := strconv.Atoi(lvl)
if err != nil {
app.Logger.Error(err)
common.Send(message.Channel, fmt.Sprintf("Something went wrong FeelsBadMan %s", ErrUserLevelNotInteger), app.TwitchClient)
return
}
err = app.Models.Users.SetLevel(login, level)
if err != nil {
common.Send(message.Channel, fmt.Sprintf("Something went wrong FeelsBadMan %s", ErrRecordNotFound), app.TwitchClient)
app.Logger.Error(err)
return
} else {
reply := fmt.Sprintf("Updated user %s to level %v", login, level)
common.Send(message.Channel, reply, app.TwitchClient)
return
}
}
// SetUserLocation sets new location for the user
func (app *Application) SetUserLocation(message twitch.PrivateMessage) {
// snipLength is the length we need to "snip" off of the start of `message`.
// `()set location` = +13
// trailing space = +1
// zero-based = +1
// = 16
snipLength := 15
// Split the twitch message at `snipLength` plus length of the name of the
// The part of the message we are left over with is then passed on to the database
// handlers as the `location` part of the command.
location := message.Message[snipLength:len(message.Message)]
login := message.User.Name
twitchId := message.User.ID
app.Logger.Infow("SetUserLocation",
"location", location,
"login", login,
"twitchId", message.User.ID,
)
err := app.Models.Users.SetLocation(twitchId, location)
if err != nil {
common.Send(message.Channel, fmt.Sprintf("Something went wrong FeelsBadMan %s", ErrRecordNotFound), app.TwitchClient)
app.Logger.Error(err)
return
} else {
reply := fmt.Sprintf("Successfully set your location to %v", location)
common.Send(message.Channel, reply, app.TwitchClient)
return
}
}
// SetUserLastFM tries to update the database record for the supplied
// login name with the new level.
func (app *Application) SetUserLastFM(lastfmUser string, message twitch.PrivateMessage) {
login := message.User.Name
app.Logger.Infow("SetUserLastFM",
"lastfmUser", lastfmUser,
"login", login,
)
err := app.Models.Users.SetLastFM(login, lastfmUser)
if err != nil {
common.Send(message.Channel, fmt.Sprintf("Something went wrong FeelsBadMan %s", ErrRecordNotFound), app.TwitchClient)
app.Logger.Error(err)
return
} else {
reply := fmt.Sprintf("Successfully set your lastfm username to %v", lastfmUser)
common.Send(message.Channel, reply, app.TwitchClient)
return
}
}
// GetUserLevel takes in a login name and queries the database for an entry
// with such a name value. If there is one it returns the level value as an integer.
// Returns 0 on an error which is the level for unregistered users.
func (app *Application) GetUserLevel(twitchId string) int {
userLevel, err := app.Models.Users.GetLevel(twitchId)
if err != nil {
return 0
} else {
return userLevel
}
}
func (app *Application) UserCheckWeather(message twitch.PrivateMessage) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
twitchLogin := message.User.Name
twitchId := message.User.ID
sugar.Infow("UserCheckWeather: ",
"twitchLogin:", twitchLogin,
"twitchId:", twitchId,
)
location, err := app.Models.Users.GetLocation(twitchId)
if err != nil {
sugar.Errorw("No location data registered for: ",
"twitchLogin:", twitchLogin,
"twitchId:", twitchId,
)
reply := "No location for your account set in my database. Use ()set location <location> to register. Otherwise use ()weather <location> without registering."
common.Send(message.Channel, reply, app.TwitchClient)
return
}
target := message.Channel
sugar.Infow("Twitchlogin: ",
"twitchLogin:", twitchLogin,
"location:", location,
)
commands.Weather(target, location, app.TwitchClient)
}
func (app *Application) UserCheckLastFM(message twitch.PrivateMessage) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
twitchLogin := message.User.Name
sugar.Infow("Twitchlogin: ",
"twitchLogin:", twitchLogin,
)
lastfmUser, err := app.Models.Users.GetLastFM(twitchLogin)
if err != nil {
sugar.Errorw("No LastFM account registered for: ",
"twitchLogin:", twitchLogin,
)
reply := "No lastfm account registered in my database. Use ()register lastfm <username> to register. (Not yet implemented) Otherwise use ()lastfm <username> without registering."
common.Send(message.Channel, reply, app.TwitchClient)
return
}
target := message.Channel
sugar.Infow("Twitchlogin: ",
"twitchLogin:", twitchLogin,
"user:", lastfmUser,
)
commands.LastFmUserRecent(target, lastfmUser, app.TwitchClient)
}

View file

@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"flag"
"log"
"os"
"time"
@ -12,10 +11,9 @@ import (
"github.com/jakecoffman/cron"
"github.com/joho/godotenv"
_ "github.com/lib/pq"
"github.com/lyx0/nourybot/internal/common"
"github.com/lyx0/nourybot/internal/data"
"github.com/nicklaw5/helix"
"github.com/redis/go-redis/v9"
"github.com/rs/zerolog/log"
"go.uber.org/zap"
)
@ -33,14 +31,14 @@ type config struct {
}
}
type Application struct {
type application struct {
TwitchClient *twitch.Client
HelixClient *helix.Client
Logger *zap.SugaredLogger
Log *zap.SugaredLogger
Db *sql.DB
Models data.Models
Scheduler *cron.Cron
Rdb *redis.Client
// Models data.Models
Scheduler *cron.Cron
// Rdb *redis.Client
}
var envFlag string
@ -50,10 +48,8 @@ func init() {
flag.StringVar(&envFlag, "env", "dev", "database connection to use: (dev/prod)")
flag.Parse()
}
func main() {
var cfg config
// Initialize a new sugared logger that we'll pass on
// down through the application.
logger := zap.NewExample()
@ -62,7 +58,7 @@ func main() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
sugar.Fatal("Error loading .env")
}
// Twitch config variables
@ -79,7 +75,6 @@ func main() {
case "prod":
cfg.db.dsn = os.Getenv("SUPABASE_DSN")
}
// Database config variables
cfg.db.maxOpenConns = 25
cfg.db.maxIdleConns = 25
@ -115,44 +110,28 @@ func main() {
// Establish database connection
db, err := openDB(cfg)
if err != nil {
sugar.Fatal(err)
sugar.Fatalw("could not establish database connection",
"err", err,
)
}
rdb := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
DB: 0,
})
err = rdb.Set(ctx, "key", "value", 0).Err()
if err != nil {
sugar.Panic(err)
}
val, err := rdb.Get(ctx, "key").Result()
if err != nil {
sugar.Panic(err)
}
sugar.Infow("Redis initialization key",
"key", val,
sugar.Infow("db.Stats",
"db.Stats", db.Stats(),
)
// Initialize Application with the new values
app := &Application{
app := &application{
TwitchClient: tc,
HelixClient: helixClient,
Logger: sugar,
Log: sugar,
Db: db,
Models: data.NewModels(db),
Scheduler: cron.New(),
Rdb: rdb,
}
// Received a PrivateMessage (normal chat message).
app.TwitchClient.OnPrivateMessage(func(message twitch.PrivateMessage) {
// app.Logger.Infow("Message received",
// "message", message,
// sugar.Infow("New Twitch PrivateMessage",
// "message.Channel", message.Channel,
// "message.User.DisplayName", message.User.DisplayName,
// "message.User.ID", message.User.ID,
// "message.Message", message.Message,
// )
@ -160,73 +139,28 @@ func main() {
// If there is no roomId something went really wrong.
roomId := message.Tags["room-id"]
if roomId == "" {
app.Logger.Errorw("Missing room-id in message tag",
"roomId", roomId,
)
log.Error().Msgf("Missing room-id in message tag: %s", 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] == cfg.commandPrefix {
app.InitUser(message.User.Name, message.User.ID, message)
app.handleCommand(message)
return
}
// Special rule for #pajlada.
if message.Message == "!nourybot" {
common.Send(message.Channel, "Lidl Twitch bot made by @nourylul. Prefix: ()", app.TwitchClient)
}
}
})
// Received a WhisperMessage (Twitch DM).
app.TwitchClient.OnWhisperMessage(func(message twitch.WhisperMessage) {
// Print the whisper message for now.
app.Logger.Infow("Whisper Message received",
"message", message,
"message.User.DisplayName", message.User.DisplayName,
"message.Message", message.Message,
)
})
// Successfully connected to Twitch
app.Log.Infow("Successfully connected to Twitch Servers",
"Bot username", cfg.twitchUsername,
"Environment", envFlag,
"Database Open Conns", cfg.db.maxOpenConns,
"Database Idle Conns", cfg.db.maxIdleConns,
"Database Idle Time", cfg.db.maxIdleTime,
"Database", db.Stats(),
"Helix", helixResp,
)
app.TwitchClient.OnConnect(func() {
app.Logger.Infow("Successfully connected to Twitch Servers",
"Bot username", cfg.twitchUsername,
"Environment", envFlag,
"Database Open Conns", cfg.db.maxOpenConns,
"Database Idle Conns", cfg.db.maxIdleConns,
"Database Idle Time", cfg.db.maxIdleTime,
"Database", db.Stats(),
"Helix", helixResp,
)
// Start time
common.StartTime()
app.loadCommandHelp()
// Join the channels in the database.
app.InitialJoin()
// Load the initial timers from the database.
app.InitialTimers()
// Start the timers.
app.Scheduler.Start()
common.Send("nourylul", "dankCircle", app.TwitchClient)
common.Send("nourybot", "gopherDance", app.TwitchClient)
common.Send("xnoury", "pajaDink", app.TwitchClient)
common.Send("uudelleenkytkeytynyt", "PepeS", app.TwitchClient)
app.TwitchClient.Join("nourylul")
app.TwitchClient.Say("nourylul", "xD!")
// sugar.Infow("db.Stats",
// "db.Stats", db.Stats(),
// )
})
// Actually connect to chat.
err = app.TwitchClient.Connect()
if err != nil {

View file

@ -1,7 +0,0 @@
version: "3.9"
services:
nourybot:
build:
dockerfile: Dockerfile
context: .
# restart: unless-stopped

28
go.mod
View file

@ -3,23 +3,23 @@ module github.com/lyx0/nourybot
go 1.20
require (
github.com/briandowns/openweathermap v0.18.0
github.com/dustin/go-humanize v1.0.0
github.com/gempir/go-twitch-irc/v3 v3.2.0
github.com/gempir/go-twitch-irc/v3 v3.3.0
github.com/jakecoffman/cron v0.0.0-20190106200828-7e2009c226a5
github.com/joho/godotenv v1.4.0
github.com/julienschmidt/httprouter v1.3.0
github.com/lib/pq v1.10.6
github.com/lib/pq v1.10.9
github.com/nicklaw5/helix v1.25.0
go.uber.org/zap v1.21.0
github.com/rs/zerolog v1.29.1
go.uber.org/zap v1.24.0
)
require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
github.com/redis/go-redis/v9 v9.0.3 // indirect
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.8.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
)
require (
github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
github.com/joho/godotenv v1.5.1
github.com/mattn/go-colorable v0.1.12 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
golang.org/x/sys v0.8.0 // indirect
)

95
go.sum
View file

@ -1,85 +1,44 @@
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/briandowns/openweathermap v0.18.0 h1:JYTtJ4bKjXZRmDTe7huJ5+dZ7CsjPUw10GUzMASkNV8=
github.com/briandowns/openweathermap v0.18.0/go.mod h1:0GLnknqicWxXnGi1IqoOaZIw+kIe5hkt+YM5WY3j8+0=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/gempir/go-twitch-irc/v3 v3.2.0 h1:ENhsa7RgBE1GMmDqe0iMkvcSYfgw6ZsXilt+sAg32/U=
github.com/gempir/go-twitch-irc/v3 v3.2.0/go.mod h1:/W9KZIiyizVecp4PEb7kc4AlIyXKiCmvlXrzlpPUytU=
github.com/gempir/go-twitch-irc/v3 v3.3.0 h1:iBOKSwNbgsE/zYwzyoHNhXBlf/kkzl3V3k6H2myENRU=
github.com/gempir/go-twitch-irc/v3 v3.3.0/go.mod h1:/W9KZIiyizVecp4PEb7kc4AlIyXKiCmvlXrzlpPUytU=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt v3.2.1+incompatible h1:73Z+4BJcrTC+KczS6WvTPvRGOp1WmfEP4Q1lOd9Z/+c=
github.com/golang-jwt/jwt v3.2.1+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/jakecoffman/cron v0.0.0-20190106200828-7e2009c226a5 h1:kCvm3G3u+eTRbjfLPyfsfznJtraYEfZer/UvQ6CaQhI=
github.com/jakecoffman/cron v0.0.0-20190106200828-7e2009c226a5/go.mod h1:6DM2KNNK69jRu0lAHmYK9LYxmqpNjYHOaNp/ZxttD4U=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=
github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/nicklaw5/helix v1.25.0 h1:Mrz537izZVsGdM3I46uGAAlslj61frgkhS/9xQqyT/M=
github.com/nicklaw5/helix v1.25.0/go.mod h1:yvXZFapT6afIoxnAvlWiJiUMsYnoHl7tNs+t0bloAMw=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/redis/go-redis/v9 v9.0.3 h1:+7mmR26M0IvyLxGZUHxu4GiBkJkVDid0Un+j4ScYu4k=
github.com/redis/go-redis/v9 v9.0.3/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk=
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 h1:cgqwZtnR+IQfUYDLJ3Kiy4aE+O/wExTzEIg8xwC4Qfs=
github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0/go.mod h1:n3nudMl178cEvD44PaopxH9jhJaQzthSxUzLO5iKMy4=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8=
go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View file

@ -1 +0,0 @@
{"_type":"export","__export_format":4,"__export_date":"2022-08-17T19:16:53.682Z","__export_source":"insomnia.desktop.app:v2022.5.0","resources":[{"_id":"req_5d958ec4ce374235b6e417cc9be48b89","parentId":"wrk_d4cae4c992a7440290394279400803c6","modified":1660763792428,"created":1660759977370,"url":"localhost:3000/v1/commands/test","name":"command","description":"","method":"PATCH","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"test\",\n\t\"text\": \"test123 changed\",\n\t\"category\": \"testchanged\",\n\t\"level\": 641\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_3fd0c254058a430bb72040eab201c364"}],"authentication":{},"metaSortKey":-1660759977370,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"wrk_d4cae4c992a7440290394279400803c6","parentId":null,"modified":1660748286420,"created":1660748286420,"name":"nourybot-api","description":"","scope":"collection","_type":"workspace"},{"_id":"req_6c8c21fa841d4418a2790e9d91d2d1c8","parentId":"wrk_d4cae4c992a7440290394279400803c6","modified":1660751293669,"created":1660751233597,"url":"localhost:3000/v1/commands/test","name":"command","description":"","method":"DELETE","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1660751233597,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_a77aa307cce4423287d59edbc3719014","parentId":"wrk_d4cae4c992a7440290394279400803c6","modified":1660763777588,"created":1660748340276,"url":"localhost:3000/v1/commands","name":"command","description":"","method":"POST","body":{"mimeType":"application/json","text":"{\n\t\"name\": \"test\",\n\t\"text\": \"testing 123\",\n\t\"category\": \"testing\",\n\t\"level\": 666\n}"},"parameters":[],"headers":[{"name":"Content-Type","value":"application/json","id":"pair_530845ffd47b458aa7a322c12b42695c"}],"authentication":{},"metaSortKey":-1660748340276,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"req_7ca8659975f04d28a9020e421e9f116d","parentId":"wrk_d4cae4c992a7440290394279400803c6","modified":1660751688968,"created":1660748289073,"url":"localhost:3000/v1/commands/test","name":"command","description":"","method":"GET","body":{},"parameters":[],"headers":[],"authentication":{},"metaSortKey":-1660748289073,"isPrivate":false,"settingStoreCookies":true,"settingSendCookies":true,"settingDisableRenderRequestBody":false,"settingEncodeUrl":true,"settingRebuildPath":true,"settingFollowRedirects":"global","_type":"request"},{"_id":"env_970e467c8601150ade332a4c172ff5b80bca7d5e","parentId":"wrk_d4cae4c992a7440290394279400803c6","modified":1660748286452,"created":1660748286452,"name":"Base Environment","data":{},"dataPropertyOrder":null,"color":null,"isPrivate":false,"metaSortKey":1660748286452,"_type":"environment"},{"_id":"jar_970e467c8601150ade332a4c172ff5b80bca7d5e","parentId":"wrk_d4cae4c992a7440290394279400803c6","modified":1660748286454,"created":1660748286454,"name":"Default Jar","cookies":[],"_type":"cookie_jar"},{"_id":"spc_e5f1241c4688496995c6a8875182f9ad","parentId":"wrk_d4cae4c992a7440290394279400803c6","modified":1660748286438,"created":1660748286438,"fileName":"nourybot-api","contents":"","contentType":"yaml","_type":"api_spec"}]}

View file

@ -1,3 +0,0 @@
# Insomnia
Api collection import/export for [insomnia](https://insomnia.rest/)

View file

@ -1,14 +0,0 @@
package commands
import (
"fmt"
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/common"
)
func Bttv(target, query string, tc *twitch.Client) {
reply := fmt.Sprintf("https://betterttv.com/emotes/shared/search?query=%s", query)
common.Send(target, reply, tc)
}

View file

@ -1,20 +0,0 @@
package commands
import (
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/commands/decapi"
"github.com/lyx0/nourybot/internal/common"
"go.uber.org/zap"
)
func Bttvemotes(target string, tc *twitch.Client) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
resp, err := decapi.Bttvemotes(target)
if err != nil {
sugar.Error(err)
}
common.Send(target, resp, tc)
}

View file

@ -1,23 +0,0 @@
package commands
import (
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/common"
)
func Coinflip(target string, tc *twitch.Client) {
flip := common.GenerateRandomNumber(2)
switch flip {
case 0:
common.Send(target, "Heads!", tc)
return
case 1:
common.Send(target, "Tails!", tc)
return
default:
common.Send(target, "Heads!", tc)
return
}
}

View file

@ -1,21 +0,0 @@
package commands
import (
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/commands/decapi"
"github.com/lyx0/nourybot/internal/common"
"go.uber.org/zap"
)
// ()currency 10 USD to EUR
func Currency(target, currAmount, currFrom, currTo string, tc *twitch.Client) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
resp, err := decapi.Currency(currAmount, currFrom, currTo)
if err != nil {
sugar.Error(err)
}
common.Send(target, resp, tc)
}

View file

@ -1,35 +0,0 @@
package decapi
import (
"fmt"
"io"
"net/http"
"go.uber.org/zap"
)
func Bttvemotes(username string) (string, error) {
var basePath = "https://decapi.me/bttv/emotes/"
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
// https://decapi.me/twitter/latest/forsen?url&no_rts
// ?url adds the url at the end and &no_rts ignores retweets.
resp, err := http.Get(fmt.Sprint(basePath + username))
if err != nil {
sugar.Error(err)
return "Something went wrong FeelsBadMan", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
sugar.Error(err)
return "Something went wrong FeelsBadMan", err
}
reply := string(body)
return reply, nil
}

View file

@ -1,38 +0,0 @@
package decapi
import (
"fmt"
"io"
"net/http"
"go.uber.org/zap"
)
// ()currency 10 USD to EUR
func Currency(currAmount, currFrom, currTo string) (string, error) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
basePath := "https://decapi.me/misc/currency/"
from := fmt.Sprintf("?from=%s", currFrom)
to := fmt.Sprintf("&to=%s", currTo)
value := fmt.Sprintf("&value=%s", currAmount)
// https://decapi.me/misc/currency/?from=usd&to=usd&value=10
resp, err := http.Get(fmt.Sprint(basePath + from + to + value))
if err != nil {
sugar.Error(err)
return "Something went wrong FeelsBadMan", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
sugar.Error(err)
return "Something went wrong FeelsBadMan", err
}
reply := string(body)
return reply, nil
}

View file

@ -1,35 +0,0 @@
package decapi
import (
"fmt"
"io"
"net/http"
"go.uber.org/zap"
)
func Ffzemotes(username string) (string, error) {
var basePath = "https://decapi.me/ffz/emotes/"
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
// https://decapi.me/twitter/latest/forsen?url&no_rts
// ?url adds the url at the end and &no_rts ignores retweets.
resp, err := http.Get(fmt.Sprint(basePath + username))
if err != nil {
sugar.Error(err)
return "Something went wrong FeelsBadMan", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
sugar.Error(err)
return "Something went wrong FeelsBadMan", err
}
reply := string(body)
return reply, nil
}

View file

@ -1,46 +0,0 @@
package decapi
import (
"fmt"
"io"
"net/http"
"go.uber.org/zap"
)
func Followage(channel, username string) (string, error) {
var basePath = "https://decapi.me/twitch/followage/"
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
// ?precision is how precise the timestamp should be.
// precision 4 means: 1 2 3 4
// pajlada has been following forsen for 7 years, 4 months, 4 weeks, 1 day
resp, err := http.Get(fmt.Sprint(basePath + channel + "/" + username + "?precision=4"))
if err != nil {
sugar.Error(err)
return "Something went wrong FeelsBadMan", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
sugar.Error(err)
return "Something went wrong FeelsBadMan", err
}
// User tries to look up how long he follows himself.
if string(body) == followageUserCannotFollowOwn {
return "You cannot follow yourself.", nil
// Username is not following the requested channel.
} else if string(body) == fmt.Sprintf("%s does not follow %s", username, channel) {
return string(body), nil
} else {
reply := fmt.Sprintf("%s has been following %s for %s", username, channel, string(body))
return reply, nil
}
}

View file

@ -1,40 +0,0 @@
package decapi
import (
"fmt"
"io"
"net/http"
"go.uber.org/zap"
)
func Tweet(username string) (string, error) {
var basePath = "https://decapi.me/twitter/latest/"
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
// https://decapi.me/twitter/latest/forsen?url&no_rts
// ?url adds the url at the end and &no_rts ignores retweets.
resp, err := http.Get(fmt.Sprint(basePath + username + "?url" + "&no_rts"))
if err != nil {
sugar.Error(err)
return "Something went wrong FeelsBadMan", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
sugar.Error(err)
return "Something went wrong FeelsBadMan", err
}
// If the response was a known error message return a message with the error.
if string(body) == twitterUserNotFoundError {
return "Something went wrong: Twitter username not found", err
} else { // No known error was found, probably a tweet.
resp := fmt.Sprintf("Latest Tweet from @%s: \"%s \"", username, body)
return resp, nil
}
}

View file

@ -1,6 +0,0 @@
package decapi
var (
twitterUserNotFoundError = "[Error] - [34] Sorry, that page does not exist."
followageUserCannotFollowOwn = "A user cannot follow themself."
)

View file

@ -1,34 +0,0 @@
package decapi
import (
"fmt"
"io"
"net/http"
"go.uber.org/zap"
)
func GetIdByLogin(login string) (string, error) {
var basePath = "https://decapi.me/twitch/id/"
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
resp, err := http.Get(fmt.Sprint(basePath + login))
if err != nil {
sugar.Error(err)
return "Something went wrong FeelsBadMan", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
sugar.Error(err)
return "Something went wrong FeelsBadMan", err
}
reply := string(body)
return reply, nil
}

View file

@ -1,10 +0,0 @@
package commands
import (
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/common"
)
func Echo(target, message string, tc *twitch.Client) {
common.Send(target, message, tc)
}

View file

@ -1,14 +0,0 @@
package commands
import (
"fmt"
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/common"
)
func Ffz(target, query string, tc *twitch.Client) {
reply := fmt.Sprintf("https://www.frankerfacez.com/emoticons/?q=%s", query)
common.Send(target, reply, tc)
}

View file

@ -1,20 +0,0 @@
package commands
import (
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/commands/decapi"
"github.com/lyx0/nourybot/internal/common"
"go.uber.org/zap"
)
func Ffzemotes(target string, tc *twitch.Client) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
resp, err := decapi.Ffzemotes(target)
if err != nil {
sugar.Error(err)
}
common.Send(target, resp, tc)
}

View file

@ -1,20 +0,0 @@
package commands
import (
"fmt"
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/commands/ivr"
"github.com/lyx0/nourybot/internal/common"
)
func FirstLine(target, channel, username string, tc *twitch.Client) {
ivrResponse, err := ivr.FirstLine(channel, username)
if err != nil {
common.Send(channel, fmt.Sprint(err), tc)
return
}
common.Send(target, ivrResponse, tc)
}

View file

@ -1,21 +0,0 @@
package commands
import (
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/commands/decapi"
"github.com/lyx0/nourybot/internal/common"
"go.uber.org/zap"
)
// ()currency 10 USD to EUR
func Followage(target, channel, username string, tc *twitch.Client) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
resp, err := decapi.Followage(channel, username)
if err != nil {
sugar.Error(err)
}
common.Send(target, resp, tc)
}

View file

@ -1,50 +0,0 @@
package ivr
import (
"encoding/json"
"fmt"
"io"
"net/http"
"go.uber.org/zap"
)
type firstLineApiResponse struct {
User string `json:"user"`
Message string `json:"message"`
Time string `json:"time"`
Error string `json:"error"`
}
// FirstLine returns the first line a given user has sent in a
// given channel.
func FirstLine(channel, username string) (string, error) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
baseUrl := "https://api.ivr.fi/logs/firstmessage"
resp, err := http.Get(fmt.Sprintf("%s/%s/%s", baseUrl, channel, username))
if err != nil {
sugar.Error(err)
return "Something went wrong FeelsBadMan", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
sugar.Error(err)
}
var responseObject firstLineApiResponse
json.Unmarshal(body, &responseObject)
// User or channel was not found
if responseObject.Error != "" {
return fmt.Sprintf(responseObject.Error + " FeelsBadMan"), nil
} else {
return fmt.Sprintf(username + ": " + responseObject.Message + " (" + responseObject.Time + " ago)."), nil
}
}

View file

@ -1,49 +0,0 @@
package ivr
import (
"encoding/json"
"fmt"
"io"
"net/http"
"go.uber.org/zap"
)
type randomQuoteApiResponse struct {
User string `json:"user"`
Message string `json:"message"`
Time string `json:"time"`
Error string `json:"error"`
}
// FirstLine returns the first line a given user has sent in a
// given channel.
func RandomQuote(channel, username string) (string, error) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
baseUrl := "https://api.ivr.fi/logs/rq"
resp, err := http.Get(fmt.Sprintf("%s/%s/%s", baseUrl, channel, username))
if err != nil {
sugar.Error(err)
return "Something went wrong FeelsBadMan", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
sugar.Error(err)
}
var responseObject randomQuoteApiResponse
json.Unmarshal(body, &responseObject)
// User or channel was not found
if responseObject.Error != "" {
return fmt.Sprintf(responseObject.Error + " FeelsBadMan"), nil
} else {
return fmt.Sprintf(username + ": " + responseObject.Message + " (" + responseObject.Time + " ago)."), nil
}
}

View file

@ -1,76 +0,0 @@
package commands
import (
"fmt"
"os"
"github.com/gempir/go-twitch-irc/v3"
"github.com/joho/godotenv"
"github.com/lyx0/nourybot/internal/common"
"github.com/shkh/lastfm-go/lastfm"
"go.uber.org/zap"
)
func LastFmArtistTop(target string, message twitch.PrivateMessage, tc *twitch.Client) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
// snipLength is the length we need to "snip" off of the start
// of `message` to only have the artists name left.
// `()lastfm artist top` = +20
// trailing space = +1
// zero-based = +1
// = 22
snipLength := 20
artist := message.Message[snipLength:len(message.Message)]
err := godotenv.Load()
if err != nil {
sugar.Error("Error loading OpenWeatherMap API key from .env file")
}
apiKey := os.Getenv("LAST_FM_API_KEY")
apiSecret := os.Getenv("LAST_FM_SECRET")
api := lastfm.New(apiKey, apiSecret)
result, _ := api.Artist.GetTopTracks(lastfm.P{"artist": artist}) //discarding error
for _, track := range result.Tracks {
sugar.Infow("Top tracks: ",
"artist:", artist,
"track", track.Name,
)
}
}
func LastFmUserRecent(target, user string, tc *twitch.Client) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
err := godotenv.Load()
if err != nil {
sugar.Error("Error loading LASTFM API keys from .env file")
}
apiKey := os.Getenv("LAST_FM_API_KEY")
apiSecret := os.Getenv("LAST_FM_SECRET")
api := lastfm.New(apiKey, apiSecret)
result, _ := api.User.GetRecentTracks(lastfm.P{"user": user}) //discarding error
var reply string
for i, track := range result.Tracks {
// The 0th result is the most recent one since it goes from most recent
// to least recent.
if i == 0 {
sugar.Infow("Most recent: ",
"user:", user,
"track", track.Name,
"artist", track.Artist.Name,
)
reply = fmt.Sprintf("Most recently played track for user %v: %v - %v", user, track.Artist.Name, track.Name)
common.Send(target, reply, tc)
return
}
}
}

View file

@ -1,94 +0,0 @@
package commands
import (
"fmt"
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/common"
)
var cm = map[string]string{
"`": "ё",
"~": "Ё",
"=": "ъ",
"+": "Ъ",
"[": "ю",
"]": "щ",
`\`: "э",
"{": "Ю",
"}": "Щ",
"|": "Э",
";": "ь",
":": "Ь",
"'": "ж",
`"`: "Ж",
"q": "я",
"w": "ш",
"e": "е",
"r": "р",
"t": "т",
"y": "ы",
"u": "у",
"i": "и",
"o": "о",
"p": "п",
"a": "а",
"s": "с",
"d": "д",
"f": "ф",
"g": "г",
"h": "ч",
"j": "й",
"k": "к",
"l": "л",
"z": "з",
"x": "х",
"c": "ц",
"v": "в",
"b": "б",
"n": "н",
"m": "м",
"Q": "Я",
"W": "Ш",
"E": "Е",
"R": "Р",
"T": "Т",
"Y": "Ы",
"U": "У",
"I": "И",
"O": "О",
"P": "П",
"A": "А",
"S": "С",
"D": "Д",
"F": "Ф",
"G": "Г",
"H": "Ч",
"J": "Й",
"K": "К",
"L": "Л",
"Z": "З",
"X": "Х",
"C": "Ц",
"V": "В",
"B": "Б",
"N": "Н",
"M": "М",
}
func Phonetic(target, message string, tc *twitch.Client) {
var ts string
for _, c := range message {
if _, ok := cm[string(c)]; ok {
ts = ts + cm[string(c)]
} else {
ts = ts + string(c)
}
//ts = append(ts, cm[string(c)])
}
common.Send(target, fmt.Sprint(ts), tc)
}

View file

@ -1,17 +0,0 @@
package commands
import (
"fmt"
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/common"
"github.com/lyx0/nourybot/internal/humanize"
)
func Ping(target string, tc *twitch.Client) {
botUptime := humanize.Time(common.GetUptime())
commandsUsed := common.GetCommandsUsed()
reply := fmt.Sprintf("Pong! :) Commands used: %v, Last restart: %v", commandsUsed, botUptime)
common.Send(target, reply, tc)
}

View file

@ -1,16 +0,0 @@
package commands
import (
"fmt"
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/common"
)
func Preview(target, channel string, tc *twitch.Client) {
imageHeight := common.GenerateRandomNumberRange(1040, 1080)
imageWidth := common.GenerateRandomNumberRange(1890, 1920)
reply := fmt.Sprintf("https://static-cdn.jtvnw.net/previews-ttv/live_user_%v-%vx%v.jpg", channel, imageWidth, imageHeight)
common.Send(target, reply, tc)
}

View file

@ -1,14 +0,0 @@
package commands
import (
"fmt"
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/common"
)
func Seventv(target, emote string, tc *twitch.Client) {
reply := fmt.Sprintf("https://7tv.app/emotes?query=%s", emote)
common.Send(target, reply, tc)
}

View file

@ -1,20 +0,0 @@
package commands
import (
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/commands/decapi"
"github.com/lyx0/nourybot/internal/common"
"go.uber.org/zap"
)
func Tweet(target, username string, tc *twitch.Client) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
resp, err := decapi.Tweet(username)
if err != nil {
sugar.Error(err)
}
common.Send(target, resp, tc)
}

View file

@ -1,51 +0,0 @@
package commands
import (
"fmt"
"os"
owm "github.com/briandowns/openweathermap"
"github.com/gempir/go-twitch-irc/v3"
"github.com/joho/godotenv"
"github.com/lyx0/nourybot/internal/common"
"go.uber.org/zap"
)
// Weather queries the OpenWeatherMap Api for the given location and sends the
// current weather response to the target twitch chat.
func Weather(target, location string, tc *twitch.Client) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
err := godotenv.Load()
if err != nil {
sugar.Error("Error loading OpenWeatherMap API key from .env file")
}
owmKey := os.Getenv("OWM_KEY")
w, err := owm.NewCurrent("C", "en", owmKey)
if err != nil {
sugar.Error(err)
}
w.CurrentByName(location)
// Longitude and Latitude are returned as 0 when the supplied location couldn't be
// assigned to a OpenWeatherMap location.
if w.GeoPos.Longitude == 0 && w.GeoPos.Latitude == 0 {
reply := "Location not found FeelsBadMan"
common.Send(target, reply, tc)
} else {
// Weather for Vilnius, LT: Feels like: 29.67°C. Currently 29.49°C with a high of 29.84°C and a low of 29.49°C, humidity: 45%, wind: 6.17m/s.
reply := fmt.Sprintf("Weather for %s, %s: Feels like: %v°C. Currently %v°C with a high of %v°C and a low of %v°C, humidity: %v%%, wind: %vm/s.",
w.Name,
w.Sys.Country,
w.Main.FeelsLike,
w.Main.Temp,
w.Main.TempMax,
w.Main.TempMin,
w.Main.Humidity,
w.Wind.Speed,
)
common.Send(target, reply, tc)
}
}

View file

@ -1,60 +0,0 @@
package commands
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/gempir/go-twitch-irc/v3"
"github.com/lyx0/nourybot/internal/common"
"go.uber.org/zap"
)
type xkcdResponse struct {
Num int `json:"num"`
SafeTitle string `json:"safe_title"`
Img string `json:"img"`
}
func Xkcd(target string, tc *twitch.Client) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
response, err := http.Get("https://xkcd.com/info.0.json")
if err != nil {
sugar.Error(err)
}
responseData, err := io.ReadAll(response.Body)
if err != nil {
sugar.Error(err)
}
var responseObject xkcdResponse
json.Unmarshal(responseData, &responseObject)
reply := fmt.Sprint("Current Xkcd #", responseObject.Num, " Title: ", responseObject.SafeTitle, " ", responseObject.Img)
common.Send(target, reply, tc)
}
func RandomXkcd(target string, tc *twitch.Client) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
comicNum := fmt.Sprint(common.GenerateRandomNumber(2655))
response, err := http.Get(fmt.Sprint("http://xkcd.com/" + comicNum + "/info.0.json"))
if err != nil {
sugar.Error(err)
}
responseData, err := io.ReadAll(response.Body)
if err != nil {
sugar.Error(err)
}
var responseObject xkcdResponse
json.Unmarshal(responseData, &responseObject)
reply := fmt.Sprint("Random Xkcd #", responseObject.Num, " Title: ", responseObject.SafeTitle, " ", responseObject.Img)
common.Send(target, reply, tc)
}

View file

@ -1,16 +0,0 @@
package common
var (
tempCommands = 0
)
// CommandUsed is called on every command incremenenting tempCommands.
func CommandUsed() {
tempCommands++
}
// GetCommandsUsed returns the amount of commands that have been used
// since the last restart.
func GetCommandsUsed() int {
return tempCommands
}

View file

@ -1,26 +0,0 @@
package common
import "github.com/gempir/go-twitch-irc/v3"
// ElevatedPrivsMessage is checking a given message twitch.PrivateMessage
// if it came from a moderator/vip/or broadcaster and returns a bool
func ElevatedPrivsMessage(message twitch.PrivateMessage) bool {
if message.User.Badges["moderator"] == 1 ||
message.User.Badges["vip"] == 1 ||
message.User.Badges["broadcaster"] == 1 {
return true
} else {
return false
}
}
// ModPrivsMessage is checking a given message twitch.PrivateMessage
// if it came from a moderator or broadcaster and returns a bool
func ModPrivsMessage(message twitch.PrivateMessage) bool {
if message.User.Badges["moderator"] == 1 ||
message.User.Badges["broadcaster"] == 1 {
return true
} else {
return false
}
}

View file

@ -1,38 +0,0 @@
package common
import (
"fmt"
"math/rand"
"strconv"
"time"
)
// StrGenerateRandomNumber generates a random number from
// a given max value as a string
func StrGenerateRandomNumber(max string) int {
num, err := strconv.Atoi(max)
if num < 1 {
return 0
}
if err != nil {
fmt.Printf("Supplied value %v is not a number", num)
return 0
} else {
rand.Seed(time.Now().UnixNano())
return rand.Intn(num)
}
}
// GenerateRandomNumber returns a random number from
// a given max value as a int
func GenerateRandomNumber(max int) int {
rand.Seed(time.Now().UnixNano())
return rand.Intn(max)
}
// GenerateRandomNumberRange returns a random number
// over a given minimum and maximum range.
func GenerateRandomNumberRange(min int, max int) int {
return (rand.Intn(max-min) + min)
}

View file

@ -1,180 +0,0 @@
package common
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"github.com/gempir/go-twitch-irc/v3"
"go.uber.org/zap"
)
// 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 checkMessage(text string) (bool, string) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
// {"message": "AHAHAHAHA LUL"}
reqBody, err := json.Marshal(map[string]string{
"message": text,
})
if err != nil {
log.Fatal(err)
}
resp, err := http.Post(banPhraseUrl, "application/json", bytes.NewBuffer(reqBody))
if err != nil {
log.Panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Panic(err)
}
var responseObject banphraseResponse
json.Unmarshal(body, &responseObject)
// 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.
func Send(target, message string, tc *twitch.Client) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
// Message we are trying to send is empty.
if len(message) == 0 {
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 := checkMessage(message)
if messageBanned {
// Bad message, replace message and log it.
tc.Say(target, "[BANPHRASED] monkaS")
sugar.Infow("banned message detected",
"target channel", target,
"message", message,
"ban reason", banReason,
)
return
} else {
// 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:]
tc.Say(target, firstMessage)
tc.Say(target, secondMessage)
return
}
// Message was fine.
tc.Say(target, message)
return
}
}
// SendNoLimit does not check for the maximum message size.
// Used in sending commands from the database since the command has to have
// been gotten in there somehow. So it fits. Still checks for banphrases.
func SendNoLimit(target, message string, tc *twitch.Client) {
sugar := zap.NewExample().Sugar()
defer sugar.Sync()
// Message we are trying to send is empty.
if len(message) == 0 {
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 := checkMessage(message)
if messageBanned {
// Bad message, replace message and log it.
tc.Say(target, "[BANPHRASED] monkaS")
sugar.Infow("banned message detected",
"target channel", target,
"message", message,
"ban reason", banReason,
)
return
} else {
// 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.
// Message was fine.
tc.Say(target, message)
return
}
}

View file

@ -1,15 +0,0 @@
package common
import "time"
var (
uptime time.Time
)
func StartTime() {
uptime = time.Now()
}
func GetUptime() time.Time {
return uptime
}

View file

@ -1,211 +0,0 @@
package data
import (
"context"
"database/sql"
"errors"
"time"
)
type Channel struct {
ID int `json:"id"`
AddedAt time.Time `json:"-"`
Login string `json:"login"`
TwitchID string `json:"twitchid"`
}
type ChannelModel struct {
DB *sql.DB
}
// Get takes the login name for a channel and queries the database for an
// existing entry with that login value. If it exists it returns a
// pointer to a Channel.
func (c ChannelModel) Get(login string) (*Channel, error) {
query := `
SELECT id, added_at, login, twitchid
FROM channels
WHERE login = $1`
var channel Channel
err := c.DB.QueryRow(query, login).Scan(
&channel.ID,
&channel.AddedAt,
&channel.Login,
&channel.TwitchID,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
return &channel, nil
}
// Insert takes in a channel struct and inserts it into the database.
func (c ChannelModel) Insert(channel *Channel) error {
query := `
INSERT INTO channels(login, twitchid)
VALUES ($1, $2)
ON CONFLICT (login)
DO NOTHING
RETURNING id, added_at;
`
args := []interface{}{channel.Login, channel.TwitchID}
// Execute the query returning the number of affected rows.
result, err := c.DB.Exec(query, args...)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrChannelRecordAlreadyExists
}
return nil
}
// GetAll() returns a pointer to a slice of all channels (`[]*Channel`) in the database.
func (c ChannelModel) GetAll() ([]*Channel, error) {
query := `
SELECT id, added_at, login, twitchid
FROM channels
ORDER BY id`
// Create a context with 3 seconds timeout.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Use QueryContext() the context and query. This returns a
// sql.Rows resultset containing our channels.
rows, err := c.DB.QueryContext(ctx, query)
if err != nil {
return nil, err
}
// Need to defer a call to rows.Close() to ensure the resultset
// is closed before GetAll() returns.
defer rows.Close()
// Initialize an empty slice to hold the data.
channels := []*Channel{}
// Iterate over the resultset.
for rows.Next() {
// Initialize an empty Channel struct where we put on
// a single channel value.
var channel Channel
// Scan the values onto the channel struct
err := rows.Scan(
&channel.ID,
&channel.AddedAt,
&channel.Login,
&channel.TwitchID,
)
if err != nil {
return nil, err
}
// Add the single movie struct onto the slice.
channels = append(channels, &channel)
}
// When rows.Next() finishes call rows.Err() to retrieve any errors.
if err = rows.Err(); err != nil {
return nil, err
}
return channels, nil
}
// GetJoinable() returns a slice of channel names (Channel.Login) in the database.
func (c ChannelModel) GetJoinable() ([]string, error) {
query := `
SELECT login
FROM channels
ORDER BY id`
// Create a context with 3 seconds timeout.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Use QueryContext() the context and query. This returns a
// sql.Rows resultset containing our channels.
rows, err := c.DB.QueryContext(ctx, query)
if err != nil {
return nil, err
}
// Need to defer a call to rows.Close() to ensure the resultset
// is closed before GetAll() returns.
defer rows.Close()
// Initialize an empty slice to hold the data.
channels := []string{}
// Iterate over the resultset.
for rows.Next() {
// Initialize an empty Channel struct where we put on
// a single channel value.
var channel Channel
// Scan the values onto the channel struct
err := rows.Scan(
&channel.Login,
)
if err != nil {
return nil, err
}
// Add the single movie struct onto the slice.
channels = append(channels, channel.Login)
}
// When rows.Next() finishes call rows.Err() to retrieve any errors.
if err = rows.Err(); err != nil {
return nil, err
}
return channels, nil
}
// Delete takes in a login name and queries the database and if there is an
// entry with that login name deletes the entry.
func (c ChannelModel) Delete(login string) error {
// Prepare the statement.
query := `
DELETE FROM channels
WHERE login = $1`
// Execute the query returning the number of affected rows.
result, err := c.DB.Exec(query, login)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// We want atleast 1, if it is 0 the entry did not exist.
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}

View file

@ -1,205 +0,0 @@
package data
import (
"database/sql"
"errors"
)
type Command struct {
ID int `json:"id"`
Name string `json:"name"`
Text string `json:"text,omitempty"`
Category string `json:"category,omitempty"`
Level int `json:"level,omitempty"`
Help string `json:"help,omitempty"`
}
type CommandModel struct {
DB *sql.DB
}
// Get tries to find a command in the database with the provided name.
func (c CommandModel) Get(name string) (*Command, error) {
query := `
SELECT id, name, text, category, level, help
FROM commands
WHERE name = $1`
var command Command
err := c.DB.QueryRow(query, name).Scan(
&command.ID,
&command.Name,
&command.Text,
&command.Category,
&command.Level,
&command.Help,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
return &command, nil
}
// Insert adds a command into the database.
func (c CommandModel) Insert(command *Command) error {
query := `
INSERT into commands(name, text, category, level, help)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (name)
DO NOTHING
RETURNING id;
`
args := []interface{}{command.Name, command.Text, command.Category, command.Level, command.Help}
result, err := c.DB.Exec(query, args...)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrCommandRecordAlreadyExists
}
return nil
}
func (c CommandModel) Update(command *Command) error {
query := `
UPDATE commands
SET text = $2
WHERE name = $1
RETURNING id`
args := []interface{}{
command.Name,
command.Text,
}
err := c.DB.QueryRow(query, args...).Scan(&command.ID)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return ErrEditConflict
default:
return err
}
}
return nil
}
// SetCategory queries the database for an entry with the provided name,
// if there is one it updates the categories level with the provided level.
func (c CommandModel) SetCategory(name string, category string) error {
query := `
UPDATE commands
SET category = $2
WHERE name = $1`
result, err := c.DB.Exec(query, name, category)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}
// SetLevel queries the database for an entry with the provided name,
// if there is one it updates the entrys level with the provided level.
func (c CommandModel) SetLevel(name string, level int) error {
query := `
UPDATE commands
SET level = $2
WHERE name = $1`
result, err := c.DB.Exec(query, name, level)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}
// SetHelp sets the help text for a given name of a command in the database.
func (c CommandModel) SetHelp(name string, helptext string) error {
query := `
UPDATE commands
SET help = $2
WHERE name = $1`
result, err := c.DB.Exec(query, name, helptext)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}
// Delete takes in a command name and queries the database for an entry with
// the same name and tries to delete that entry.
func (c CommandModel) Delete(name string) error {
// Prepare the statement.
query := `
DELETE FROM commands
WHERE name = $1`
// Execute the query returning the number of affected rows.
result, err := c.DB.Exec(query, name)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// We want atleast 1, if it is 0 the entry did not exist.
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}

View file

@ -1,64 +0,0 @@
package data
import (
"database/sql"
"errors"
)
var (
ErrRecordNotFound = errors.New("record not found")
ErrChannelRecordAlreadyExists = errors.New("channel already in database")
ErrEditConflict = errors.New("edit conflict")
ErrCommandRecordAlreadyExists = errors.New("command already exists")
ErrLastFMUserRecordAlreadyExists = errors.New("lastfm connection already set")
ErrUserAlreadyExists = errors.New("user already in database")
)
// struct Models wraps the models, making them callable
// as app.models.Channels.Get(login)
type Models struct {
Channels interface {
Insert(channel *Channel) error
Get(login string) (*Channel, error)
GetAll() ([]*Channel, error)
GetJoinable() ([]string, error)
Delete(login string) error
}
Users interface {
Insert(login, twitchId string) error
Get(login string) (*User, error)
Check(twitchId string) (*User, error)
SetLevel(login string, level int) error
GetLevel(twitchId string) (int, error)
SetLocation(twitchId, location string) error
GetLocation(twitchId string) (string, error)
SetLastFM(login, lastfmUser string) error
GetLastFM(login string) (string, error)
Delete(login string) error
}
Commands interface {
Get(name string) (*Command, error)
Insert(command *Command) error
Update(command *Command) error
SetLevel(name string, level int) error
SetCategory(name, category string) error
SetHelp(name, helptext string) error
Delete(name string) error
}
Timers interface {
Get(name string) (*Timer, error)
Insert(timer *Timer) error
Update(timer *Timer) error
GetAll() ([]*Timer, error)
Delete(name string) error
}
}
func NewModels(db *sql.DB) Models {
return Models{
Channels: ChannelModel{DB: db},
Users: UserModel{DB: db},
Commands: CommandModel{DB: db},
Timers: TimerModel{DB: db},
}
}

View file

@ -1,185 +0,0 @@
package data
import (
"context"
"database/sql"
"errors"
"time"
)
type Timer struct {
ID int `json:"id" redis:"timer-id"`
Name string `json:"name" redis:"timer-name"`
CronName string `redis:"timer-cronname"`
Text string `json:"text" redis:"timer-text"`
Channel string `json:"channel" redis:"timer-channel"`
Repeat string `json:"repeat" redis:"timer-repeat"`
}
type TimerModel struct {
DB *sql.DB
}
func (t TimerModel) Get(name string) (*Timer, error) {
query := `
SELECT id, name, text, channel, repeat
FROM timers
WHERE name = $1
`
var timer Timer
err := t.DB.QueryRow(query, name).Scan(
&timer.ID,
&timer.Name,
&timer.Text,
&timer.Channel,
&timer.Repeat,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
return &timer, nil
}
// Insert adds a command into the database.
func (t TimerModel) Insert(timer *Timer) error {
query := `
INSERT into timers(name, text, channel, repeat)
VALUES ($1, $2, $3, $4)
RETURNING id;
`
args := []interface{}{timer.Name, timer.Text, timer.Channel, timer.Repeat}
result, err := t.DB.Exec(query, args...)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrCommandRecordAlreadyExists
}
return nil
}
// GetAll() returns a pointer to a slice of all channels (`[]*Channel`) in the database.
func (t TimerModel) GetAll() ([]*Timer, error) {
query := `
SELECT id, name, text, channel, repeat
FROM timers
ORDER BY id`
// Create a context with 3 seconds timeout.
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Use QueryContext() the context and query. This returns a
// sql.Rows resultset containing our channels.
rows, err := t.DB.QueryContext(ctx, query)
if err != nil {
return nil, err
}
// Need to defer a call to rows.Close() to ensure the resultset
// is closed before GetAll() returns.
defer rows.Close()
// Initialize an empty slice to hold the data.
timers := []*Timer{}
// Iterate over the resultset.
for rows.Next() {
// Initialize an empty Channel struct where we put on
// a single channel value.
var timer Timer
// Scan the values onto the channel struct
err := rows.Scan(
&timer.ID,
&timer.Name,
&timer.Text,
&timer.Channel,
&timer.Repeat,
)
if err != nil {
return nil, err
}
// Add the single movie struct onto the slice.
timers = append(timers, &timer)
}
// When rows.Next() finishes call rows.Err() to retrieve any errors.
if err = rows.Err(); err != nil {
return nil, err
}
return timers, nil
}
func (t TimerModel) Update(timer *Timer) error {
query := `
UPDATE timers
SET text = $2, channel = $3, repeat = $4
WHERE name = $1
RETURNING id`
args := []interface{}{
timer.Name,
timer.Text,
timer.Channel,
timer.Repeat,
}
err := t.DB.QueryRow(query, args...).Scan(&timer.ID)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return ErrEditConflict
default:
return err
}
}
return nil
}
// Delete takes in a command name and queries the database for an entry with
// the same name and tries to delete that entry.
func (t TimerModel) Delete(name string) error {
// Prepare the statement.
query := `
DELETE FROM timers
WHERE name = $1`
// Execute the query returning the number of affected rows.
result, err := t.DB.Exec(query, name)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// We want atleast 1, if it is 0 the entry did not exist.
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}

View file

@ -1,296 +0,0 @@
package data
import (
"database/sql"
"errors"
"time"
)
type User struct {
ID int `json:"id"`
AddedAt time.Time `json:"-"`
Login string `json:"login"`
TwitchID string `json:"twitchid"`
Level int `json:"level"`
Location string `json:"location,omitempty"`
LastFMUsername string `json:"lastfm_username,omitempty"`
}
type UserModel struct {
DB *sql.DB
}
// Insert inserts a user model into the database.
func (u UserModel) Insert(login, twitchId string) error {
query := `
INSERT INTO users(login, twitchid)
VALUES ($1, $2)
ON CONFLICT (login)
DO NOTHING
RETURNING id, added_at;
`
args := []interface{}{login, twitchId}
// Execute the query returning the number of affected rows.
result, err := u.DB.Exec(query, args...)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrUserAlreadyExists
}
return nil
}
// SetLocation searches the database for a record with the provided login value
// and if that exists sets the location to the supplied
func (u UserModel) SetLocation(twitchId, location string) error {
query := `
UPDATE users
SET location = $2
WHERE twitchId = $1`
result, err := u.DB.Exec(query, twitchId, location)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// We want atleast 1, if it is 0 the entry did not exist.
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}
// SetLocation searches the database for a record with the provided login value
// and if that exists sets the location to the supplied
func (u UserModel) GetLocation(twitchId string) (string, error) {
query := `
SELECT location
FROM users
WHERE twitchid = $1`
var user User
err := u.DB.QueryRow(query, twitchId).Scan(
&user.Location,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return "", ErrRecordNotFound
default:
return "", err
}
}
return user.Location, nil
}
// SetLocation searches the database for a record with the provided login value
// and if that exists sets the location to the supplied
func (u UserModel) SetLastFM(login, lastfmUser string) error {
query := `
UPDATE users
SET lastfm_username = $2
WHERE login = $1`
result, err := u.DB.Exec(query, login, lastfmUser)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// We want atleast 1, if it is 0 the entry did not exist.
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}
// SetLocation searches the database for a record with the provided login value
// and if that exists sets the location to the supplied
func (u UserModel) GetLastFM(login string) (string, error) {
query := `
SELECT lastfm_username
FROM users
WHERE login = $1`
var user User
err := u.DB.QueryRow(query, login).Scan(
&user.LastFMUsername,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return "", ErrRecordNotFound
default:
return "", err
}
}
return user.LastFMUsername, nil
}
// SetLocation searches the database for a record with the provided login value
// and if that exists sets the location to the supplied
func (u UserModel) GetLevel(twitchId string) (int, error) {
query := `
SELECT level
FROM users
WHERE twitchid = $1`
var user User
err := u.DB.QueryRow(query, twitchId).Scan(
&user.Level,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return -1, ErrRecordNotFound
default:
return -1, err
}
}
return user.Level, nil
}
// Setlevel searches the database for a record with the provided login value
// and if that exists sets the level to the supplied level value.
func (u UserModel) SetLevel(login string, level int) error {
query := `
UPDATE users
SET level = $2
WHERE login = $1`
// err := u.DB.QueryRow(query, args...).Scan(&user)
result, err := u.DB.Exec(query, login, level)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// We want atleast 1, if it is 0 the entry did not exist.
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}
// Get searches the database for a login name and returns the user struct on success.
func (u UserModel) Get(login string) (*User, error) {
query := `
SELECT id, added_at, login, twitchid, level, location
FROM users
WHERE login = $1`
var user User
err := u.DB.QueryRow(query, login).Scan(
&user.ID,
&user.AddedAt,
&user.Login,
&user.TwitchID,
&user.Level,
&user.Location,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
return &user, nil
}
// Check checks the database for a record with the given login name.
func (u UserModel) Check(twitchId string) (*User, error) {
query := `
SELECT id, login
FROM users
WHERE twitchid = $1`
var user User
err := u.DB.QueryRow(query, twitchId).Scan(
&user.ID,
&user.Login,
)
if err != nil {
switch {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrRecordNotFound
default:
return nil, err
}
}
return &user, nil
}
// Delete searches the database for a value with the supplied login name and if
// one exists deletes the record, returning any errors that might occur.
func (u UserModel) Delete(login string) error {
// Prepare the statement.
query := `
DELETE FROM users
WHERE login = $1`
// Execute the query returning the number of affected rows.
result, err := u.DB.Exec(query, login)
if err != nil {
return err
}
// Check how many rows were affected.
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
// We want atleast 1, if it is 0 the entry did not exist.
if rowsAffected == 0 {
return ErrRecordNotFound
}
return nil
}

View file

@ -1,11 +0,0 @@
package humanize
import (
"time"
"github.com/dustin/go-humanize"
)
func Time(t time.Time) string {
return humanize.Time(t)
}

View file

@ -1 +0,0 @@
DROP TABLE IF EXISTS channels;

View file

@ -1,13 +0,0 @@
CREATE TABLE IF NOT EXISTS channels (
id bigserial PRIMARY KEY,
added_at timestamp(0) with time zone NOT NULL DEFAULT NOW(),
login text UNIQUE NOT NULL,
twitchid text NOT NULL
);
INSERT INTO channels (added_at,login,twitchid) VALUES
(NOW(),'nourylul','31437432'),
(NOW(),'nourybot','596581605'),
(NOW(),'uudelleenkytkeytynyt','465178364'),
(NOW(),'xnoury','197780373');

View file

@ -1 +0,0 @@
DROP TABLE IF EXISTS users;

View file

@ -1,19 +0,0 @@
CREATE TABLE IF NOT EXISTS users (
id bigserial PRIMARY KEY,
added_at timestamp(0) with time zone NOT NULL DEFAULT NOW(),
login text UNIQUE NOT NULL,
twitchid text NOT NULL,
level integer,
location text,
lastfm_username text
);
INSERT INTO users (added_at,login,twitchid,"level") VALUES
(NOW(),'nourylul','31437432',1000),
(NOW(),'nourybot','596581605',1000),
(NOW(),'uudelleenkytkeytynyt','465178364',1000),
(NOW(),'xnoury','197780373',500),
(NOW(),'noemience','135447564',500);
UPDATE users SET location = 'vilnius' WHERE login = 'nourylul';
UPDATE users SET lastfm_username = 'nouryqt' WHERE login = 'nourylul';

View file

@ -1 +0,0 @@
DROP TABLE IF EXISTS commands;

View file

@ -1,39 +0,0 @@
CREATE TABLE IF NOT EXISTS commands (
id bigserial PRIMARY KEY,
name text UNIQUE NOT NULL,
text text NOT NULL,
category text NOT NULL,
level integer NOT NULL,
help text NOT NULL
);
INSERT INTO commands (name,"text","category","level","help") VALUES
('repeat','xset r rate 175 50','default',0,'Command to set my keyboard repeat rate'),
('xset','xset r rate 175 50','default',0,'Command to set my keyboard repeat rate'),
('eurkey','setxkbmap -layout eu','default',0,'Command to enable the EURKey keyboard layout'),
('clueless','ch02 ch21 ch31','default',0,'Clueless'),
('justinfan','64537','default',0,'pajaDink :tf:'),
('kek','lmao','default',0,'kek'),
('lmao','kek','default',0,'lmao'),
('streamlink','https://haste.zneix.eu/udajirixep put this in ~/.config/streamlink/config on Linux (or %appdata%\streamlink\streamlinkrc on Windows)','default',0,'Returns a optimized streamlink config for Twitch.'),
('gyazo','Gyazo is the worst screenshot uploader in human history. At best, its inconvenient, slow, and missing features: at worst, its a bandwidth-draining malware risk for everyone who views your images. There is absolutely no reason to use it unless youre too lazy to spend 5 minutes installing another program.','pasta',250,'Dumb copy pasta about gyazo being garbage.'),
('arch','Your friend isnt wrong. Being on the actual latest up to date software, having a single unified community repository for out of repo software (AUR) instead of a bunch of scattered broken PPAs for extra software, not having so many hard dependencies that removing GNOME removes basic system utilities, broader customization support and other things is indeed, pretty nice.','pasta',250,'Copy pasta about arch having the superior package manager.'),
('arch2','One time I was ordering coffee and suddenly realised the barista didnt know I use Arch. Needless to say, I stopped mid-order to inform her that I do indeed use Arch. I must have spoken louder than I intended because the whole café instantly erupted into a prolonged applause. I walked outside with my head held high. I never did finish my order that day, but just knowing that everyone around me was aware that I use Arch was more energising than a simple cup of coffee could ever be.','pasta',250,'Copy pasta about arch linux users.'),
('feelsdankman','⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⢀⣾⣿⣿⣿⣿⣷⣄⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⣿⣿⠟⢁⣴⣿⣿⣿⣿⣿⣿⣿⣿⣦⡈⢻⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⡿⠁⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠙⢿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⡿⠃⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⡈⢻⣿⣿⣿⣿ ⣿⣿⣿⣿⡿⢁⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠹⣿⣿⣿ ⣿⣿⣿⣿⠁⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠿⠿⠿⠿⠆⠘⢿⣿ ⣿⣿⠟⠉⠄⠄⠄⠄⢤⣀⣦⣤⣤⣤⣤⣀⣀⡀⠄⠄⡀⠄⠄⠄⠄⠄⠄⠄⠙ ⣿⠃⠄⠄⠄⠄⠄⠄⠙⠿⣿⣿⠋⠩⠉⠉⢹⣿⣧⣤⣴⣶⣷⣿⠟⠛⠛⣿⣷ ⠇⠄⠄⠄⠄⠄⠄⠄⠄⠄⠁⠒⠄⠄⠄⠄⠈⠉⠛⢻⣿⣿⢿⠁⠄⠄⠁⠘⢁ ⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⣂⣀⣐⣂⣐⣒⣃⠠⠥⠤⠴⠶⠖⠦⠤⠖⢂⣽ ⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠛⠂⠐⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⣠⣴⣶⣿⣿ ⠃⣠⣄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⣠⣤⣄⠚⢿⣿⣿⣿⣿ ⣾⣿⣿⣿⣶⣦⣤⣤⣄⣀⣀⣀⣀⣀⣀⣠⣤⣤⣶⣿⣿⣿⣿⣷⡄⢻⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠈⣿⣿⣿','ascii',500,'Posts a FeelsDankMan ascii'),
('dankhug','⣼⣿⣿⣿⣷⡄⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣾⣿⣿⣿⣿⣿⣄⠀⠀⠀⠀⠀⠀ ⣿⣿⣿⣿⣿⣿⣆⠀⠀⠀⠀⠀⠀⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣧⠀⠀⠀⠀⠀ ⣿⣿⣿⣿⣿⣿⣿⣧⡀⠀⠀⠀⣠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⠀⠀⠀ ⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⠀⠀ ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⠺⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠿⠟⠛⠛⠀⠀ ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠆⠒⠀⠀⠶⢶⣶⣶⣭⣤⠹⠟⣛⢉⣉⣉⣀⣀ ⣿⣿⣾⣿⣶⣶⣶⣶⣶⣶⣿⣿⣶⠀⢬⣒⣂⡀⠀⠀⠀⠀⣈⣉⣉⣉⣉⡉⠅ ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⢭⣭⠭⠭⠉⠠⣷⣆⣂⣐⣐⣒⣒⡈ ⢿⣿⣿⣿⠋⢁⣄⡈⠉⠛⠛⠻⡿⠟⢠⡻⣿⣿⣛⣛⡋⠉⣀⠤⣚⠙⠛⠉⠁ ⠀⠙⠛⠛⠀⠘⠛⠛⠛⠛⠋⠀⠨⠀⠀⠀⠒⠒⠒⠒⠒⠒⠒⠊⡀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⠰⣾⣿⣿⣷⣦⠀⠀⠀⠀⠀⠀⠀⢀⣠⠴⢊⣭⣦⡀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⣠⣌⠻⣿⣿⣷⣞⣠⣖⣠⣶⣴⣶⣶⣶⣾⣿⣿⣿⣿⡀⠀ ⠀⢀⣀⣀⣠⣴⣾⣿⣿⣷⣌⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⢻⣿⣷⡁ ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢻⣶⣬⣭⣉⣛⠛⠛⢛⣛⣉⣭⣴⣾⣿⣿⣿⡇','ascii',500,'Posts a dankHug ascii'),
('shotgun','⡏⠛⢿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣧⣀⡀⠄⠹⠟⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣧⠄⢈⡄⣄⠄⠙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⠄⢸⣧⠘⢹⣦⣄⠈⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⠄⢸⣿⡇⢸⣿⣿⣿⣶⣄⠉⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⠄⢸⣿⣷⠄⣿⣿⣿⣿⣿⣷⣦⡈⣙⠟⠉⠉⠙⠋⠉⠹⣿⣿ ⣿⣿⣿⠄⢸⣿⣿⡄⠸⣿⣿⣿⣿⣿⣿⡿⠃⠄⠄⣀⠄⢠⣀⠄⡨⣹ ⣿⣿⣿⠄⢸⣿⣿⣇⠄⠹⣿⣿⣿⣿⣿⠁⠄⠄⠄⠈⠄⠄⠄⠄⠠⣾ ⣿⣿⣿⠄⠈⣿⣿⣿⣆⠄⠈⠛⠿⣿⣿⠄⠄⠄⠄⠄⠄⠄⠄⠄⢀⣿ ⣿⣿⣿⠄⠄⣿⣿⣿⣿⣦⣀⠄⠄⠈⠉⠄⠄⠄⠄⠄⠄⠄⠤⣶⣿⣿ ⣿⣿⣿⠄⠄⢻⣿⣿⣿⣿⣿⠷⠂⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠘⣿⣿ ⣿⣿⣿⣇⠄⠈⠻⣿⣿⠟⠁⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⢸⣿ ⣿⣿⣿⣿⣦⠄⠄⠈⠋⠄⠄⣠⣄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⣼⣿','ascii',500,'Posts an ascii of pepe sucking on a shotgun.'),
('rope','⣿⣿⣿⡇⠄⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⡇⠄⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⡇⠄⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⡇⠄⣿⣿⣿⡿⠟⠋⣉⣉⣉⡙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⠃⠄⠹⠟⣡⣶⡿⢟⣛⣛⡻⢿⣦⣩⣤⣤⣤⣬⡉⢻⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⠄⢀⢤⣾⣿⣿⣿⣿⡿⠿⠿⠿⢮⡃⣛⣛⡻⠿⢿⠈⣿⣿⣿⣿⣿⣿⣿ ⣿⡟⢡⣴⣯⣿⣿⣿⣉⠤⣤⣭⣶⣶⣶⣮⣔⡈⠛⠛⠛⢓⠦⠈⢻⣿⣿⣿⣿⣿ ⠏⣠⣿⣿⣿⣿⣿⣿⣿⣯⡪⢛⠿⢿⣿⣿⣿⡿⣼⣿⣿⣿⣶⣮⣄⠙⣿⣿⣿⣿ ⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣾⡭⠴⣶⣶⣽⣽⣛⡿⠿⠿⠿⠿⠇⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⣿⠿⠿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣝⣛⢛⡛⢋⣥⣴⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⢿⠱⣿⣿⣛⠾⣭⣛⡿⢿⣿⣿⣿⣿⣿⣿⣿⡀⣿⣿⣿⣿⣿⣿⣿ ⠑⠽⡻⢿⣿⣮⣽⣷⣶⣯⣽⣳⠮⣽⣟⣲⠯⢭⣿⣛⣛⣿⡇⢸⣿⣿⣿⣿⣿⣿ ⠄⠄⠈⠑⠊⠉⠟⣻⠿⣿⣿⣿⣿⣷⣾⣭⣿⣛⠷⠶⠶⠂⣴⣿⣿⣿⣿⣿⣿⣿ ⠄⠄⠄⠄⠄⠄⠄⠄⠁⠙⠒⠙⠯⠍⠙⢉⣉⣡⣶⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿','ascii',500,'Posts an ascii of pepe roping out of desperation'),
('porosad','⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠀⢀⣠⣤⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠒⣛⠐⣠⣾⣿⣤⣾⣿⣿⣿⣿⣷⣠⣤⣤⢀⣀⣖⠒⠒⠀⠀⠀⠀⠀ ⠀⠀⠀⢀⣘⣘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣬⡕⠀⠀⠀⠀⣀⠠⠀⠀ ⠀⣠⣾⣿⣿⣿⣿⠿⠿⣿⣿⣿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣷⣄⠀⠀⠀⠀⠀⠀ ⠐⢛⣿⡟⠋⢉⠉⠀⠔⣿⣿⣿⠆⠀⠩⡉⠙⢛⣿⠿⠿⠿⢛⣃⣀⡀⠀⠀⠀ ⠀⣾⣟⠁⢤⣀⣔⣤⣼⣿⣿⣿⣆⣀⠂⠴⣶⡁⠸⣿⣶⣾⣿⣿⣿⣿⠷⠂⠀ ⠀⣿⡿⡿⣧⣵⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣝⡛⣸⣿⣿⣿⣿⣿⣿⣷⡀⠀ ⠀⢿⢃⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠸⣿⣿⣿⣿⣿⣿⣿⡇⠀ ⠀⠻⢸⣿⣿⣿⣿⣿⣿⠋⠻⣿⣿⡻⢿⣿⣿⣿⣿⡆⣿⣿⣿⠿⣿⠟⢿⡇⠀ ⠀⠠⣸⣿⣿⠟⣩⣈⣩⣴⣶⣌⣁⣄⡉⠻⣿⣿⣿⣧⢸⣿⣧⣬⣤⣤⣦⣤⠀ ⠀⠀⠸⢫⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⢻⡇⣼⣿⣿⣿⣿⣿⣿⡟⠀ ⠀⠀⠀⠈⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣱⣿⣿⣿⣿⣿⣿⡟⠀⠀ ⠀⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁⠀⠀ ⠀⠀⠀⠀⠀⠈⠉⠉⠛⠛⠛⠛⠛⠛⠉⠉⠉⠛⠛⠉⠁⠀⠀⠈⠉⠁⠀⠀⠀ ','ascii',500,'Posts a PoroSad ascii.. Why is he always sad? PoroSad'),
('toucan','░░░░░░░░▄▄▄▀▀▀▄▄███▄░░░░░░░░░░ ░░░░░▄▀▀░░░░░░░▐░▀██▌░░░░░░░░░ ░░░▄▀░░░░▄▄███░▌▀▀░▀█░░░░░░░░░ ░░▄█░░▄▀▀▒▒▒▒▒▄▐░░░░█▌░░░░░░░░ ░▐█▀▄▀▄▄▄▄▀▀▀▀▌░░░░░▐█▄░░░░░░░ ░▌▄▄▀▀░░░░░░░░▌░░░░▄███████▄░░ ░░░░░░░░░░░░░▐░░░░▐███████████ ░░░░░le░░░░░░░▐░░░░▐███████████ ░░░░toucan░░░░░░▀▄░░░▐██████████ ░░░░has arrived░░░░░░▀▄▄███████████','ascii',500,'le budget toucan has arrived <(*)'),
('harrypottah','⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠛⠋⣉⣠⣤⣤⣴⣦⣤⡈⠛⢿⣿⣿⣿⣿ ⣿⣿⠋⢉⣉⣉⡉⠛⠛⠟⠉⣠⣄⣚⡛⠿⢿⣿⣿⣿⣿⡛⢿⣧⠄⢿⣿⣿⣿ .⣿⣿⣄⠘⢿⣿⣿⣿⣿⣶⣶⣦⣬⣍⣙⠛⢶⣾⣿⣿⣿⠇⠈⢻⠄⢸⣿⣿⣿ ⣿⣿⣿⣶⣄⣉⠛⠿⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⡛⠂⠄⣦⣤⣾⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⠛⠒⠂⠄⠄⠈⣉⣉⣉⣙⣛⣛⠛⠿⠿⠷⣦⣄⡈⠛⢿⣿⣿ ⣿⣿⣿⣿⣿⣿⠄⠄⢀⣶⣿⣿⣿⣿⣿⣯⣿⣿⣿⣿⣷⣶⠄⢀⣈⠄⠄⣿⣿ ⣿⣿⣿⣿⣿⣿⠄⠄⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢏⣴⣾⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⠄⢀⣾⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣧⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⠄⠄⠻⢿⣿⡟⠋⠉⡁⠄⢉⡢⠿⠿⣫⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⠄⠄⠄⠄⢻⣿⣦⣬⣉⣛⣿⠛⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⠄⠄⠄⠄⠸⣿⣛⣻⣿⡟⠁⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⡟⢻⢻⡿⢻⡟⠛⢿⠛⠻⡟⠿⢻⣿⠛⠻⡿⠛⢿⠛⠛⠛⠛⢻⠛⣿⠛⡟⣿ ⡇⢈⢸⠇⠈⡇⠄⣸⠄⢀⣷⠄⣾⣿⠄⣀⡇⣶⢸⡇⢸⣿⢸⡿⠄⢹⠄⡁⣿ ⣧⣼⣼⣤⣧⣥⣤⣼⣤⣤⣿⣤⣿⣿⣤⣿⣧⣀⣼⣧⣼⣿⣼⣧⣼⣼⣤⣧⣿','ascii',500,'Posts forsens Harry potter ascii'),
('borgir','⣿⣿⣿⣿⠄⠄⠄⠄⠄⠄⠄⠄⣀⡀⠄⠄⠄⣀⣀⡀⠄⠄⠄⠄⢹⣿⣿⣿⣿ ⣿⣿⣿⣿⠄⠄⠄⠄⢀⣴⣾⣿⣿⡿⠿⠾⣿⣿⣿⣿⣿⣦⡄⠄⠄⠉⠙⠛⢿ ⣿⣿⣿⡿⠑⠢⠄⢠⣿⣿⣿⣛⠻⠷⠦⠄⢈⣿⠋⠉⡉⠉⢡⠄⣤⣤⣤⠄⠘ ⣿⣿⣿⣷⠶⣤⣰⣾⣿⣿⣿⣿⣷⣶⣶⣾⣿⣿⣄⡉⠁⣠⡽⠈⠉⠉⠉⠄⣰ ⣿⣿⠟⠋⡞⠛⠋⢿⣿⣿⣿⣿⣿⣿⣷⣥⣍⣉⣹⣿⣿⣿⠁⣤⣤⣤⣴⣾⣿ ⠄⠞⠓⠂⠄⠂⠄⠄⠄⠈⠉⢛⣋⣀⣀⠄⠈⠉⠛⠻⣿⣿⣤⣾⡿⠏⠉⠈⣿ ⠄⠄⠄⠄⠄⢀⣤⣶⣶⣾⣿⣿⣿⣿⣿⣿⣿⣿⣦⠄⠄⠙⠓⠄⢛⣆⠄⠄⠈ ⠄⠄⠄⣤⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠉⣠⣄⠄⠄⢠⣭⣭⣷⣤⣄ ⠄⠄⣸⣿⣿⣿⣿⣿⣿⣿⡿⠿⠟⠛⠋⠉⠄⠐⠚⠛⢉⠄⣴⣶⣾⡛⠿⣿⣿ ⢀⣾⣯⣉⣀⡀⠄⠄⠄⠄⠄⢀⣀⣀⣀⣠⡤⠴⠶⠾⣟⠹⠏⠛⠛⠛⠄⠙⠿ ⡿⠿⠿⠿⠿⠿⠿⣶⣦⣭⣭⣽⠏⠁⠄⠄⠄⠄⢤⣶⣶⡘⠚⠄⠄⠄⠄⠄⠄ ⣿⣿⠉⡙⠛⣿⡟⠛⠙⠻⣿⡏⢉⠛⢿⣿⠛⢋⠛⣿⣿⠉⣿⡏⢉⠛⢿⣿⣿ ⣿⣿⠄⣅⠐⣿⠄⢾⡿⠄⣿⡇⠈⠁⣼⡇⠰⣏⠉⣿⣿⠄⣿⡇⠈⠁⣼⣿⣿ ⣿⣿⣤⣥⣤⣿⣧⣤⣤⣼⣿⣧⣼⣤⣼⣿⣤⣬⣤⣿⣿⣤⣿⣧⣼⣤⣼⣿⣿','ascii',500,'Posts a Borgir ascii'),
('dankwave','FeelsDankMan PowerUpR DANK WAVE▂▃▄▅▆▇██▇▆▅▄▃▂▂▃▄▅▆▇██▇▆▅▄▃▂▂▃▄▅▆▇██▇▆▅▄▃▂▂▃▄▅▆▇██▇▆▅▄▃▂▂▃▄▅▆▇██▇▆▅▄▃▂','ascii',500,'dank wave coming through'),
('forsenhead','⠄⠄⠄⠄⠄⠄⢠⣤⣀⣤⣤⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡀⠄ ⠄⠄⠄⠄⠄⠔⣺⣿⣿⣿⣿⣿⢿⡉⠁⠉⠉⠛⢿⣿⣿⣿⣿⣿⣿⣿⡷⡀ ⠄⠄⠄⠄⣀⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣷⣿⣿⣿⣿⣿⠟⠛⡇⣥ ⠄⠄⠄⠄⣿⣿⣿⣿⣿⣿⣟⠝⠻⠿⣿⣿⣟⣹⣿⣿⣿⣿⣿⣿⣿⣶⣿⣿ ⠄⠄⠄⣰⣻⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶⣶⣿⣿⢿⣸⣿⡟⠿⣿⣿⣿⣿⣿ ⠄⠄⠄⢼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣫⣾⣿⣿⣿⣷⣬⣿⣿⣿⣿ ⠄⠄⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⢩⣽⣿⣹⣿⣿⣿⣿⣿⣿⣿⣿ ⠄⠄⢿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠿⠿⠛⠄⠩⠛⠛⠿⢿⣿⣿⣿⣿⣿⣿⣿ ⠄⠄⠄⠙⠻⢟⣿⣿⡿⠉⠄⠄⠄⠄⠄⠄⠄⠄⠄⠴⠞⢻⣿⣿⣿⣿⣿⣿ ⠄⠄⠄⠄⠄⠙⠿⠟⠄⠄⠄⠄⢠⣤⣤⣠⣠⣄⡂⠄⢀⣼⣿⣿⣿⣿⣿⣿ ⠄⠄⠄⠄⠄⠄⠄⠄⣾⣧⡀⢀⡀⠈⠉⠻⢿⡟⠁⣷⣿⣿⣿⣿⣿⣿⣿⣿ ⠄⠄⠄⠄⠄⠄⠄⠄⣿⢿⢿⣿⣷⣶⣶⣤⣿⢡⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⠄⠄⠄⠄⠄⠄⠄⢰⣿⠄⠙⠿⣿⣿⣿⣿⠄⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⠄⠄⠄⠄⠄⠄⠄⠄⠙⠄⠄⠄⠄⠄⡻⠇⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿','ascii',500,'Posts a forsenHead ascii'),
('hewillnever','⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣏⡛⢿⣿⣿⣿⣿⣿⣿⡙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⣿⣦⣉⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣯⣍⣙⡛⠿⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣶⣶⣬⣿⣿⣿⡿⠛⠛⠛⣛⣋⣙⠛⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠃⠰⢾⣶⣧⣴⢦⣌⣶⣦⠘⣿⣿⣿⣿⣿⣿⣿ ⠿⠿⠿⠿⠿⠿⢻⣿⣯⠄⠠⠶⠆⢸⠋⠠⣦⠄⢹⣿⣿⠄⣿⣿⣿⣿⣿⣿⣿ ⣾⣿⣿⣿⣿⣿⣿⣿⡟⢠⣶⣶⣶⣿⣶⣬⣉⣀⠾⢿⣿⡄⠛⠛⠻⢿⣿⣿⣿ ⣿⢿⣿⣿⠛⠿⠿⠉⠁⠘⠻⠯⠄⢹⣛⣿⣟⣩⣷⣦⢹⡇⣸⣷⠄⠄⣀⠙⠛ ⢓⣚⡃⣩⣭⠄⣀⣀⢀⠄⠄⢐⠙⠊⣿⣶⣒⣿⡟⢋⡼⢁⣿⣿⠄⠄⠘⠗⠄ ⢚⣛⣫⣿⣽⣿⣽⠗⢺⢖⣴⣄⡙⠛⠛⠛⠛⠛⢛⣉⣤⣾⣿⣿⠄⢸⡄⠸⣿ ⣿⣿⣯⣟⣛⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠄⠸⣷⠄⠻ ⣿⣟⣷⣿⣿⠷⠾⠿⠿⠿⠿⠿⠟⠛⠛⠛⠛⠻⠿⠛⠋⠉⠉⠁⠄⠄⠁⠄⠄ ⢉⣁⣠⣤⣤⣤⣶⣶⣶⣶⣶⣶⣶⣶⣶⣶⡶⠒⠄⠄⢤⣀⣀⣀⣀⣀⠄⠄⠄','ascii',500,'Posts a he will never ascii'),
('mylifeisgazatu','⠀⠀⠀⠀⠀⣠⣿⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣼⣿⣿⣷⡀⠀⠀⠀⠀ ⠀⠀⠀⠀⣾⣿⣿⣿⣿⣿⣧⡀⠀⠀⠀⠀⠀⠀⢰⣿⣿⣿⣿⣿⣿⣄⠀⠀⠀ ⠀⠀⠀⣼⣿⣿⣿⣿⣿⣿⣿⣷⡀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣆⠀⠀ ⠀⠀⠘⠛⣛⣛⣻⣿⡛⠛⠛⠉⠁⠀⠀⠀⠀⠛⢛⣛⣛⣿⣟⠛⠛⠋⠉⠀⠀ ⠀⠀⠀⠀⠙⠻⠉⠀⠹⢷⣶⡿⠉⠹⠇⠀⠀⠀⠈⠛⠏⠁⠈⠿⣶⣾⠏⠉⠿ ⠀⠀⠀⠀⠀⢠⡤⠤⠤⠀⠉⠒⠔⠂⠀⠀⠀⠀⠀⠀⣤⠤⠤⠀⠈⠑⠢⠒⠀ ⢠⣀⠀⠀⠀⠀⠀⠠⠀⢀⣠⣀⠀⠀⠀⣄⡀⠀⠀⠀⠀⠀⠀⠀⣀⣄⡀⠀⠀ ⣿⣿⣿⣿⣶⣶⣶⣶⣿⣿⣿⣿⡆⠀⢸⣿⣿⣿⣷⣶⣶⣶⣾⣿⣿⣿⣷⠀⠀ ⠀⣀⡀⠀⡀⠀⡀⢀⡀⠀⠀⢰⠀⢰⡆⢰⣞⠂⠀⡀⠀⠀⠀⣶⠀⠀⡀⠀⠀ ⠀⣿⢻⡟⣿⠀⢿⣸⠇⠀⠀⢸⠀⢸⡇⢸⡏⠀⣿⠽⠇⠀⠀⣿⠀⠾⣽⡁⠀ ⠀⠛⠘⠃⠛⠀⢸⡟⠀⠀⠀⠘⠂⠘⠃⠘⠃⠀⠛⠛⠃⠀⠀⠛⠀⠛⠚⠃⠀ ⠀⢠⣤⣦⡄⠀⢠⣶⡀⠀⠰⠶⣶⠆⠀⢰⣦⠀⠀⠶⣶⠶⠀⢰⡆⠀⣦⠀⠀ ⠀⣿⡱⢶⡆⠀⣾⣿⣧⠀⢀⣼⠋⠀⢀⣿⣿⡆⠀⠀⣿⠀⠀⢸⡇⢀⡿⠀⠀ ⠀⠘⠛⠛⠃⠘⠋⠀⠛⠀⠚⠓⠒⠂⠘⠃⠈⠛⠀⠀⠛⠀⠀⠈⠛⠛⠃⠀⠀','ascii',500,'Probably the worlds smolest ascii miniDank'),
('ohno','⣿⣿⣿⠋⡡⢲⣶⣮⣙⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠛⢉⣠⣶⣿⠆⣿⣿ ⣿⣿⠇⡔⠄⣿⣿⣿⣿⣆⠟⡛⠋⠉⠉⢉⠍⣟⡩⡅⢀⣴⣿⣿⣿⡟⣼⣿⣿ ⡿⠋⠰⡧⡀⣿⣿⠿⠛⠉⠄⠄⣠⣴⣶⣶⣿⣿⣷⣦⣿⣿⣿⣿⡟⣾⣿⣿⣿ ⢁⠂⠄⣳⠇⠈⠁⠄⠄⠄⣠⣴⣶⣿⣿⢿⣿⣿⣿⣿⣿⣟⢹⡽⢸⣿⣿⣿⣿ ⠃⠄⠠⠿⠆⠄⠄⠄⠄⢀⣿⠟⢥⣾⡿⣿⣿⠿⢿⣿⣿⠿⡘⢷⡜⢿⣿⣿⣿ ⠄⠄⣤⣶⠆⠄⠄⠄⢀⣾⣿⣸⣽⣝⠁⣾⡹⡧⢿⣽⣿⣦⣄⠹⣿⣄⢻⣿⣿ ⠄⠄⣿⣿⠄⠄⠄⢀⣼⣿⡟⣿⣿⣿⡀⣿⣿⣷⢸⣿⣿⣿⣿⡷⠈⣿⡄⣿⣿ ⠄⠄⠈⠄⠄⢀⣴⣿⣿⣿⠇⠉⠄⠈⠱⣿⡿⣇⣼⡟⠉⠉⠉⢿⠄⣿⣷⢹⣿ ⠄⣠⣴⣦⣴⣿⣿⣿⣿⠏⡄⠄⠄⠄⠄⣿⣯⣡⣿⡅⠄⠄⣸⠏⢰⣿⣿⡆⣿ ⣿⣿⣿⣿⣿⣿⡿⠛⢑⣻⣿⣶⣶⣶⠂⠙⠛⠛⡟⠳⣶⣾⡟⠄⢸⣿⣿⡇⣿ ⣿⣿⣿⣿⣿⡏⠄⠄⠉⠛⠿⣿⠿⠋⢶⣤⠄⠄⢁⣴⣿⣿⠁⠄⣸⣿⣿⢃⣿ ⣿⣿⣿⣿⣿⣷⠄⠄⠄⡠⣞⢴⣾⣽⣽⣿⡿⠄⣾⣿⣿⠃⠄⠄⣿⣿⣿⢸⣿ ⣿⣿⣿⡿⠟⠋⠐⣤⣞⣁⠄⠁⠉⠐⠛⠉⠄⠄⠈⠉⠁⠄⠄⠄⠘⣿⣿⡈⢿ ⣿⠟⠋⠄⠄⠄⠄⠄⠙⠋⠰⠄⢀⡀⡀⠄⠄⠄⠄⠄⠄⠄⠄⠄⠄⠙⣿⣿⣄','ascii',500,'Posts a Ohno ascii'),
('weirddoc','⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠁⠀⠀⠀⠀⠀⠹⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⣰⣆⠀⠀⠀⣤⠀⠀⠻⣿⣿⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⢛⣟⣛⣁⠀⠀⣿⣿⡄⠀⢸⣿⡄⠀⢀⣭⣭⣭⡛⢿⣿⣿⣿ ⣿⣿⣿⣿⡟⣱⣿⣿⣿⣿⣤⣀⡟⠙⠀⠀⠘⢹⣧⣤⣿⣿⣿⣿⠿⠎⠙⣿⣿ ⣿⣿⣿⣿⣷⣮⡛⢶⣮⣭⣿⣿⣿⣿⣷⣶⣿⣿⣟⣛⣩⣥⣴⡶⣢⣴⣾⣿⣿ ⣿⣿⣿⣿⣿⣿⣿⣶⣾⣭⣟⣻⠿⢿⣿⣿⣿⡟⣛⣿⣭⣿⣶⣿⣿⣿⣿⣿⣿ ⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠂⠈⢹⣿⣿⣧⣈⡁⠈⠉⠉⠉⠉⠉⠛⠛⠿ ⣿⣿⣿⠿⠿⠿⠋⠉⠁⠁⢀⣠⣶⣿⣿⣿⣿⣿⣿⠙⠛⠻⠶⠦⢄⠀⠀⠀⠀ ⠁⠁⠀⠀⠀⢀⣠⣴⠶⠟⠛⠉⠉⢠⣿⣿⣿⣿⣿⡝⣷⣾⣶⠆⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠒⣉⣁⣤⣶⣴⠀⠀⠀⣸⣿⣿⣿⣿⣿⣇⢺⣿⠏⠀⠀⠀⠀⣠⣾ ⣷⣤⠀⠀⠀⠈⠻⣿⣿⠟⠀⠀⢀⣿⣿⣿⣿⣿⣿⣿⡄⣏⠀⠀⠀⣰⣾⣿⣿ ⣿⣿⣷⣤⡀⠀⠀⠈⠁⠀⠀⠀⣼⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣿⣟⡟⠿⣿⣿ ⣿⣿⣾⣿⣿⣿⣶⡶⠂⠀⠀⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢋⣼⣿ ⣿⣿⣿⡜⣾⣿⣿⡁⠀⠀⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠩⣛⣛⣛⣴⣿⣿⣿','ascii',500,'Posts a WeirdDoc ascii'),
('weirddude','⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣴⣿⣿⠀⠀⠀⠀⠀⠀⣀⣴⣾⣿⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠀⢠⢞⣻⡿⢋⣉⡀⠀⠀⢀⣠⣬⠽⠿⣟⡻⠀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⢀⣵⣿⣿⣾⣿⣿⠿⣷⣶⣜⢭⣶⣾⣿⣷⠾⡥⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⠸⣿⣿⣿⣟⣫⡽⣟⡫⠿⢯⣥⣘⠋⣪⠶⠾⢿⣷⣔⣂⠀⠀ ⠀⠀⠀⠀⢀⣴⣿⣿⣿⣿⣭⢕⡾⣿⣄⣀⣠⣿⡿⠿⣿⣤⣠⠴⠿⢿⡛⠁⠀ ⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣷⣤⣴⣤⣍⡙⣩⣴⡾⣷⣶⣶⡿⠛⠉⠀⠀⠀ ⠀⠀⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠭⠷⣿⣿⣿⣿⣶⣶⣶⣶⣿⡶⠀⠀⠀ ⠀⠀⠀⠸⣿⣿⣿⣿⣿⣿⣿⣿⢰⣞⣛⣛⣒⣀⡀⠭⠭⠭⠥⠤⠤⢐⣛⠁⠀ ⠀⠀⠀⠀⠈⠛⠿⠿⢿⣿⣷⣝⣶⣶⣶⣶⣶⣶⣦⣭⣭⣭⣭⠭⠉⠉⠀⠀⠀ ⠀⠀⣠⣤⣤⣤⣤⣤⣤⣌⠻⣿⣿⣿⣿⣿⣿⣿⡿⠿⠟⢛⣥⣤⣤⣀⠀⠀⠀ ⢀⣿⣿⣿⠁⢸⣿⣿⣿⣿⣷⣶⣮⣭⣭⣭⣽⣷⣶⣶⣾⣿⣿⡏⠻⢿⣷⣆⠀ ⠀⠻⣿⣿⡀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠀⡹⠟⠁ ⠀⠀⠈⠛⠿⣦⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⣴⠞⠀⠀⠀ ⠀⠀⢈⣵⣦⠹⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⣵⣶⣤⠀⠀','ascii',500,'Posts a WeirdDude ascii'),
('beefalarm','⡏⢀⣶⡶⠒⠒⢶⣶⡄⠄⣰⣶⠶⠶⠶⠂⢠⣶⡶⠶⠶⠖⠄⣴⣶⠶⠶⠶⠂ ⠁⣸⣿⣇⣀⣠⡞⠛⣡⢇⣿⣿⣄⣋⡉⡉⣸⣿⣧⣈⣁⢀⢠⣿⣿⣀⣀⡀⢻ ⠄⣿⣿⢡⣦⠈⣿⣷⠈⣸⣿⠏⢭⣿⠥⢃⣿⡿⠩⢭⡵⠎⣸⣿⠋⢉⠉⣡⣼ ⠘⠛⠛⠒⣒⣚⣛⣭⣆⠻⠛⢛⣒⣒⢀⠘⢛⠿⢓⡒⠂⣀⣛⡛⢸⣿⣿⣿⣿ ⠿⢿⣿⣿⣿⣿⣿⣿⠏⠾⠿⠿⠿⠿⠿⠿⠿⣿⣷⡝⢿⣿⣿⡿⠿⢿⠿⠯⠩ ⠾⠷⠮⣿⣿⣿⣿⡿⠄⠄⠄⠄⠄⠄⠄⠄⢸⣿⣿⡇⢸⣿⣿⣿⣫⣥⡄⠄⠄ ⣭⣟⣿⣿⣿⣿⣿⡇⠄⠄⠄⠄⠄⠄⠄⠄⢸⣿⣿⣇⢸⣿⣿⣿⣿⠷⢖⡀⠄ ⠷⠶⣿⣿⣿⣿⣿⠄⠄⠄⠄⣶⠄⠄⠄⢸⢸⣿⣿⣿⢸⣿⣿⣿⣿⣛⣛⠄⠄ ⢟⣻⣽⣿⣿⣿⡇⠄⠄⠄⢰⣿⠄⠄⠄⠸⢸⣿⣿⣿⠸⣿⣿⣿⡿⢿⣶⠄⠄ ⣭⣶⣶⣾⣿⣿⠄⣤⣤⣤⣬⣥⣤⣤⣤⣤⣼⣿⣿⣿⣤⣿⣿⣿⣿⣷⣮⣤⣠ ⣿⣿⡿⠿⠿⠃⢰⠿⢿⣿⣿⣿⡿⠿⠿⣿⣿⡿⠿⣿⡇⣿⠿⠿⢿⣿⠿⠿⠿ ⣾⡟⣰⠛⣇⠄⠄⣾⢰⣿⣿⡟⣱⠛⡇⣿⡏⣾⢫⡻⣦⠉⣼⢻⡀⡟⡰⢻⡇ ⠋⣰⡃⠂⣿⠄⢠⡟⠘⠛⠋⣱⡃⠃⣷⠙⢱⡷⢭⡜⠁⢀⡇⠄⡇⢠⠁⣸⠃ ⠰⠏⢉⠉⠻⠄⠸⠧⠤⠄⠰⠋⠉⠉⠿⠄⠼⠁⠄⠿⠈⠸⠁⠄⠷⠃⠄⠿⢀','ascii',500,'Posts a beef alarm ascii'),
('feelswowman','⠀⠀⠀⠀⠀⠀⠀⢀⣠⣴⣶⣶⣶⣦⣤⡀⠀⣀⣤⣤⣴⣤⣄⡀⠀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⠀⣰⣿⣿⠿⢟⣛⣛⣛⣛⠿⣎⢻⣿⣿⣿⣿⣿⣷⡀⠀⠀⠀⠀ ⠀⠀⠀⠀⠀⣼⣿⣿⡷⣺⣵⣾⡿⠟⠷⢿⣦⡐⠶⡶⣒⣦⣶⣴⣤⣆⣀⠀⠀ ⠀⠀⠀⣠⢸⣿⣿⣭⣾⣿⣿⣿⡅⠈⠐⠀⢻⣿⣷⣸⣿⣿⣿⠋⠀⠍⢻⣷⣄ ⠀⢀⣾⣧⣾⣿⣿⣎⠿⣿⣿⣿⣷⣦⣤⣴⣿⣿⣿⣿⣿⣿⣿⣦⣁⣀⣼⣿⡿ ⠀⣾⣿⣿⣿⣿⣿⣿⣷⣌⣙⠛⠿⢿⣿⣿⣿⣿⠿⣻⣿⣿⣿⣿⣿⣿⡿⠟⠁ ⢸⣿⣿⣿⣿⣿⠟⣛⠻⣿⣿⣿⣶⣦⣤⣤⣴⣾⣿⣷⣍⣙⠛⣛⣋⡉⠀⠀⠀ ⢸⣿⣿⣿⣿⣿⢸⣟⢷⣍⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⠀⠀ ⢸⠏⡌⡟⡉⡿⡂⡙⢷⣌⠻⠶⣬⣝⡛⠿⠿⢿⣿⣿⣿⣿⣿⣿⡿⠿⢟⣀⠀ ⠸⢠⣧⣴⡇⢡⡇⣿⣦⣙⡻⠶⣶⣬⣙⣛⣓⠶⠶⠶⠶⠶⠶⠶⠶⢛⡛⣅⡀ ⠐⠘⣿⣿⣷⣿⣧⡙⠻⢿⣿⣷⣶⣤⣭⣍⣉⣛⣛⣛⣛⣛⣛⡛⢛⣛⠅⠋⠀ ⢲⣤⣘⠻⣿⣿⣿⣿⣿⣶⡌⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠛⠭⠶⢟⡁⠀⠀⠀ ⢸⣿⣿⡷⢈⡻⠿⣿⡿⠿⢑⣒⣂⣦⣤⣄⣐⣒⣢⡾⣹⣿⣿⣿⣿⡇⠀⠀⠀ ⢸⣿⣿⢣⣿⣿⣷⣶⣶⡇⢿⣿⣿⣿⣿⣿⣿⣿⡿⢡⣿⣿⣿⣿⡿⠀⠀⠀⠀','ascii',500,'Posts a FeelsWowMan ascii');

View file

@ -1 +0,0 @@
DROP TABLE IF EXISTS timers;

View file

@ -1,16 +0,0 @@
CREATE TABLE IF NOT EXISTS timers (
id bigserial PRIMARY KEY,
name text NOT NULL,
text text NOT NULL,
channel text NOT NULL,
repeat text NOT NULL
);
INSERT INTO timers (name,"text",channel,repeat) VALUES
('nourylul-60m','timer every 60 minutes :)','nourylul','60m'),
('nourybot-60m','timer every 60 minutes :)','nourybot','60m'),
('nourybot-1h','timer every 1 hour :)','nourybot','1h'),
('xnoury-60m','timer every 420 minutes :)','xnoury','420m'),
('xnoury-1h','timer every 1 hour :)','xnoury','1h'),
('xnoury-15m','180 minutes timer :)','xnoury','180m');

View file

@ -1,43 +0,0 @@
# Migrations
Tool: [golang-migrate](https://github.com/golang-migrate/migrate/tree/master/cmd/migrate)
## Create Database
```sql
$ sudo -u postgres psql
psql (14.3)
Type "help" for help.
postgres=# CREATE DATABASE nourybot;
CREATE DATABASE
postgres=# \c nourybot;
You are now connected to database "nourybot" as user "postgres".
nourybot=# CREATE ROLE username WITH LOGIN PASSWORD 'password';
CREATE ROLE
nourybot=# CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION
nourybot=#
```
## Connect to Database
```sh
$ psql --host=localhost --dbname=nourybot --username=username
psql (14.3)
Type "help" for help.
nourybot=>
```
## Apply migrations
```sh
$ migrate -path=./migrations -database="postgres://username:password@localhost/nourybot?sslmode=disable" up
```
```sh
$ migrate -path=./migrations -database="postgres://username:password@localhost/nourybot?sslmode=disable" down
```
## Fix Dirty database
```sh
$ migrate -path=./migrations -database="postgres://username:password@localhost/nourybot?sslmode=disable" force 1
```