mirror of
https://github.com/lyx0/nourybot.git
synced 2024-11-13 19:49:55 +01:00
add database models, add timer functionality
This commit is contained in:
parent
ec86f396bd
commit
b330d5aa56
|
@ -114,7 +114,16 @@ func (app *application) handleCommand(message twitch.PrivateMessage) {
|
|||
case "xkcd":
|
||||
reply, _ = commands.Xkcd()
|
||||
|
||||
// ##################
|
||||
case "timer":
|
||||
switch cmdParams[1] {
|
||||
case "add":
|
||||
app.AddTimer(cmdParams[2], cmdParams[3], message)
|
||||
case "edit":
|
||||
app.EditTimer(cmdParams[2], cmdParams[3], message)
|
||||
case "delete":
|
||||
app.DeleteTimer(cmdParams[2], message)
|
||||
}
|
||||
|
||||
// Check if the commandName exists as the "name" of a command in the database.
|
||||
// if it doesnt then ignore it.
|
||||
// ##################
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
"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/rs/zerolog/log"
|
||||
|
||||
|
@ -37,7 +38,7 @@ type application struct {
|
|||
HelixClient *helix.Client
|
||||
Log *zap.SugaredLogger
|
||||
Db *sql.DB
|
||||
// Models data.Models
|
||||
Models data.Models
|
||||
Scheduler *cron.Cron
|
||||
// Rdb *redis.Client
|
||||
}
|
||||
|
@ -128,6 +129,8 @@ func main() {
|
|||
HelixClient: helixClient,
|
||||
Log: sugar,
|
||||
Db: db,
|
||||
Models: data.NewModels(db),
|
||||
Scheduler: cron.New(),
|
||||
}
|
||||
|
||||
// Received a PrivateMessage (normal chat message).
|
||||
|
@ -180,6 +183,12 @@ func main() {
|
|||
"Database", db.Stats(),
|
||||
"Helix", helixResp,
|
||||
)
|
||||
|
||||
// Load the initial timers from the database.
|
||||
app.InitialTimers()
|
||||
|
||||
// Start the timers.
|
||||
app.Scheduler.Start()
|
||||
})
|
||||
// Actually connect to chat.
|
||||
err = app.TwitchClient.Connect()
|
||||
|
|
277
cmd/nourybot/timer.go
Normal file
277
cmd/nourybot/timer.go
Normal file
|
@ -0,0 +1,277 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gempir/go-twitch-irc/v4"
|
||||
"github.com/lyx0/nourybot/internal/data"
|
||||
)
|
||||
|
||||
// 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, repeat string, message twitch.PrivateMessage) {
|
||||
cmdParams := strings.SplitN(message.Message, " ", 500)
|
||||
// prefixLength is the length of `()add timer` plus +2 (for the space and zero based)
|
||||
prefixLength := 13
|
||||
|
||||
// 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[prefixLength+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.Log.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.Log.Errorw("Error inserting new timer into database",
|
||||
"timer", timer,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
reply := fmt.Sprintln("Something went wrong FeelsBadMan")
|
||||
app.Send(message.Channel, reply)
|
||||
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.Log.Infow("Added new timer",
|
||||
"timer", timer,
|
||||
)
|
||||
|
||||
reply := fmt.Sprintf("Successfully added timer %s repeating every %s", name, repeat)
|
||||
app.Send(message.Channel, reply)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
app.Log.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"
|
||||
app.Send(message.Channel, reply)
|
||||
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, repeat string, message twitch.PrivateMessage) {
|
||||
// Check if a timer with that name is in the database.
|
||||
app.Log.Info(name)
|
||||
|
||||
old, err := app.Models.Timers.Get(name)
|
||||
if err != nil {
|
||||
app.Log.Errorw("Could not get timer",
|
||||
"timer", old,
|
||||
"error", err,
|
||||
)
|
||||
reply := "Something went wrong FeelsBadMan"
|
||||
app.Send(message.Channel, reply)
|
||||
return
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// Delete the old timer
|
||||
// -----------------------
|
||||
cronName := fmt.Sprintf("%s%s", message.Channel, name)
|
||||
app.Scheduler.RemoveJob(cronName)
|
||||
|
||||
err = app.Models.Timers.Delete(name)
|
||||
if err != nil {
|
||||
app.Log.Errorw("Error deleting timer from database",
|
||||
"name", name,
|
||||
"cronName", cronName,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
reply := fmt.Sprintln("Something went wrong FeelsBadMan")
|
||||
app.Send(message.Channel, reply)
|
||||
return
|
||||
}
|
||||
|
||||
// -----------------------
|
||||
// Add the new timer
|
||||
// -----------------------
|
||||
cmdParams := strings.SplitN(message.Message, " ", 500)
|
||||
// prefixLength is the length of `()editcommand` plus +2 (for the space and zero based)
|
||||
prefixLength := 14
|
||||
|
||||
// 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[prefixLength+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.Log.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.Log.Errorw("Error inserting new timer into database",
|
||||
"timer", timer,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
reply := fmt.Sprintln("Something went wrong FeelsBadMan")
|
||||
app.Send(message.Channel, reply)
|
||||
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.Log.Infow("Updated a timer",
|
||||
"Name", name,
|
||||
"Channel", message.Channel,
|
||||
"Old timer", old,
|
||||
"New timer", timer,
|
||||
)
|
||||
|
||||
reply := fmt.Sprintf("Successfully updated timer %s", name)
|
||||
app.Send(message.Channel, reply)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
app.Log.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"
|
||||
app.Send(message.Channel, reply)
|
||||
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.Log.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)
|
||||
|
||||
// Add new value to the slice
|
||||
ts = append(ts, v)
|
||||
|
||||
app.Scheduler.AddFunc(repeating, func() { app.newPrivateMessageTimer(v.Channel, v.Text) }, cronName)
|
||||
}
|
||||
|
||||
app.Log.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) {
|
||||
app.Send(channel, text)
|
||||
}
|
||||
|
||||
// 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.Log.Infow("Deleting timer",
|
||||
"name", name,
|
||||
"message.Channel", message.Channel,
|
||||
"cronName", cronName,
|
||||
)
|
||||
|
||||
err := app.Models.Timers.Delete(name)
|
||||
if err != nil {
|
||||
app.Log.Errorw("Error deleting timer from database",
|
||||
"name", name,
|
||||
"cronName", cronName,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
reply := fmt.Sprintln("Something went wrong FeelsBadMan")
|
||||
app.Send(message.Channel, reply)
|
||||
return
|
||||
}
|
||||
|
||||
reply := fmt.Sprintf("Deleted timer with name %s", name)
|
||||
app.Send(message.Channel, reply)
|
||||
}
|
211
internal/data/channel.go
Normal file
211
internal/data/channel.go
Normal file
|
@ -0,0 +1,211 @@
|
|||
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
|
||||
}
|
205
internal/data/commands.go
Normal file
205
internal/data/commands.go
Normal file
|
@ -0,0 +1,205 @@
|
|||
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
|
||||
}
|
64
internal/data/models.go
Normal file
64
internal/data/models.go
Normal file
|
@ -0,0 +1,64 @@
|
|||
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},
|
||||
}
|
||||
}
|
185
internal/data/timers.go
Normal file
185
internal/data/timers.go
Normal file
|
@ -0,0 +1,185 @@
|
|||
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
|
||||
}
|
296
internal/data/user.go
Normal file
296
internal/data/user.go
Normal file
|
@ -0,0 +1,296 @@
|
|||
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
|
||||
}
|
Loading…
Reference in a new issue