diff --git a/cmd/bot/commands.go b/cmd/bot/commands.go new file mode 100644 index 0000000..6c36e45 --- /dev/null +++ b/cmd/bot/commands.go @@ -0,0 +1,561 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/gempir/go-twitch-irc/v4" + "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 + case "bttv": + if msgLen < 2 { + common.Send(target, "Not enough arguments provided. Usage: ()bttv ", 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 to + 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 + case "ffz": + if msgLen < 2 { + common.Send(target, "Not enough arguments provided. Usage: ()ffz ", app.TwitchClient) + return + } else { + commands.Ffz(target, cmdParams[1], app.TwitchClient) + return + } + + // ()followage + 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 + case "firstline": + if msgLen == 1 { + common.Send(target, "Usage: ()firstline ", 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 + case "fl": + if msgLen == 1 { + common.Send(target, "Usage: ()firstline ", 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 ", app.TwitchClient) + return + } else { + app.commandHelp(target, cmdParams[1], message.User.Name) + return + } + + // ()ping + case "ping": + commands.Ping(target, app.TwitchClient) + return + + // Thumbnail + // ()preview + case "preview": + if msgLen < 2 { + common.Send(target, "Not enough arguments provided. Usage: ()preview ", 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 + case "seventv": + if msgLen < 2 { + common.Send(target, "Not enough arguments provided. Usage: ()seventv ", app.TwitchClient) + return + } else { + commands.Seventv(target, cmdParams[1], app.TwitchClient) + return + } + + // ()thumbnail + case "thumbnail": + if msgLen < 2 { + common.Send(target, "Not enough arguments provided. Usage: ()thumbnail ", app.TwitchClient) + return + } else { + commands.Preview(target, cmdParams[1], app.TwitchClient) + return + } + // ()7tv + case "7tv": + if msgLen < 2 { + common.Send(target, "Not enough arguments provided. Usage: ()seventv ", app.TwitchClient) + return + } else { + commands.Seventv(target, cmdParams[1], app.TwitchClient) + return + } + + // ()tweet + case "tweet": + if msgLen < 2 { + common.Send(target, "Not enough arguments provided. Usage: ()tweet ", 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 . ()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 . ()help ph for more info", app.TwitchClient) + return + } else { + commands.Phonetic(target, message.Message[4:len(message.Message)], app.TwitchClient) + return + } + + // ()weather + 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 + // ()debug command + 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 + 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 ", + "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 ", + "followage": "Returns how long a given user has been following a channel. Example usage: ()followage ", + "firstline": "Returns the first message a user has sent in a given channel. Aliases: firstline, fl. Example usage: ()firstline ", + "fl": "Returns the first message a user has sent in a given channel. Aliases: firstline, fl. Example usage: ()fl ", + "help": "Returns more information about a command and its usage. 4Head Example usage: ()help ", + "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 ", + "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 ", + "tweet": "Returns the latest tweet for a provided user. Example usage: ()tweet ", + "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) +} diff --git a/cmd/bot/user.go b/cmd/bot/user.go new file mode 100644 index 0000000..9e620b0 --- /dev/null +++ b/cmd/bot/user.go @@ -0,0 +1,213 @@ +package main + +import ( + "fmt" + "strconv" + + "github.com/gempir/go-twitch-irc/v4" + "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 to register. Otherwise use ()weather 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 to register. (Not yet implemented) Otherwise use ()lastfm 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) +} diff --git a/go.mod b/go.mod index 6d4e08e..5a33327 100644 --- a/go.mod +++ b/go.mod @@ -3,31 +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/v4 v4.0.0 - github.com/google/uuid v1.3.1 github.com/jakecoffman/cron v0.0.0-20190106200828-7e2009c226a5 - github.com/lib/pq v1.10.9 - github.com/rs/zerolog v1.29.1 + github.com/joho/godotenv v1.4.0 + github.com/julienschmidt/httprouter v1.3.0 + github.com/lib/pq v1.10.6 + github.com/nicklaw5/helix v1.25.0 + github.com/redis/go-redis/v9 v9.0.3 github.com/shkh/lastfm-go v0.0.0-20191215035245-89a801c244e0 - github.com/wader/goutubedl v0.0.0-20230924165737-427b7fa536e6 - go.uber.org/zap v1.24.0 - gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df + go.uber.org/zap v1.21.0 ) require ( - github.com/golang-jwt/jwt/v4 v4.0.0 // indirect - github.com/nicklaw5/helix/v2 v2.25.1 // indirect - go.uber.org/atomic v1.7.0 // indirect - go.uber.org/multierr v1.6.0 // indirect - gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect -) - -require ( - github.com/briandowns/openweathermap v0.19.0 - github.com/dustin/go-humanize v1.0.1 + 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/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 + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.8.0 // indirect ) diff --git a/go.sum b/go.sum index cb05f7a..a5b0dad 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,20 @@ github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= -github.com/briandowns/openweathermap v0.19.0 h1:nkopLMEtZLxbZI1th6dOG6xkajpszofqf53r5K8mT9k= -github.com/briandowns/openweathermap v0.19.0/go.mod h1:0GLnknqicWxXnGi1IqoOaZIw+kIe5hkt+YM5WY3j8+0= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +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/bsm/ginkgo/v2 v2.7.0 h1:ItPMPH90RbmZJt5GtkcNvIRuGEdwlBItdNVoyzaNQao= +github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= +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/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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= -github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g= +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/v4 v4.0.0 h1:sHVIvbWOv9nHXGEErilclxASv0AaQEr/r/f9C0B9aO8= github.com/gempir/go-twitch-irc/v4 v4.0.0/go.mod h1:QsOMMAk470uxQ7EYD9GJBGAVqM/jDrXBNbuePfTauzg= -github.com/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/golang-jwt/jwt/v4 v4.0.0 h1:RAqyYixv1p7uEnocuy8P1nru5wprCh/MH2BIlW5z5/o= diff --git a/internal/commands/bttvemotes.go b/internal/commands/bttvemotes.go new file mode 100644 index 0000000..2180fff --- /dev/null +++ b/internal/commands/bttvemotes.go @@ -0,0 +1,20 @@ +package commands + +import ( + "github.com/gempir/go-twitch-irc/v4" + "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) +} diff --git a/internal/commands/echo.go b/internal/commands/echo.go new file mode 100644 index 0000000..96090e2 --- /dev/null +++ b/internal/commands/echo.go @@ -0,0 +1,10 @@ +package commands + +import ( + "github.com/gempir/go-twitch-irc/v4" + "github.com/lyx0/nourybot/internal/common" +) + +func Echo(target, message string, tc *twitch.Client) { + common.Send(target, message, tc) +} diff --git a/internal/commands/ffz.go b/internal/commands/ffz.go index 2ad11ee..011bd43 100644 --- a/internal/commands/ffz.go +++ b/internal/commands/ffz.go @@ -2,8 +2,9 @@ package commands import "fmt" -func Ffz(query string) string { - reply := fmt.Sprintf("https://www.frankerfacez.com/emoticons/?q=%s&sort=count-desc&days=0", query) + "github.com/gempir/go-twitch-irc/v4" + "github.com/lyx0/nourybot/internal/common" +) return reply } diff --git a/internal/commands/ffzemotes.go b/internal/commands/ffzemotes.go new file mode 100644 index 0000000..0ff5226 --- /dev/null +++ b/internal/commands/ffzemotes.go @@ -0,0 +1,20 @@ +package commands + +import ( + "github.com/gempir/go-twitch-irc/v4" + "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) +} diff --git a/internal/commands/firstline.go b/internal/commands/firstline.go new file mode 100644 index 0000000..679559e --- /dev/null +++ b/internal/commands/firstline.go @@ -0,0 +1,20 @@ +package commands + +import ( + "fmt" + + "github.com/gempir/go-twitch-irc/v4" + "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) +} diff --git a/internal/commands/followage.go b/internal/commands/followage.go new file mode 100644 index 0000000..683a1e9 --- /dev/null +++ b/internal/commands/followage.go @@ -0,0 +1,21 @@ +package commands + +import ( + "github.com/gempir/go-twitch-irc/v4" + "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) +} diff --git a/internal/commands/phonetic.go b/internal/commands/phonetic.go index 3a67cd9..21bf350 100644 --- a/internal/commands/phonetic.go +++ b/internal/commands/phonetic.go @@ -1,6 +1,8 @@ package commands -import "fmt" +import ( + "fmt" +) var cm = map[string]string{ "`": "ё", diff --git a/internal/commands/preview.go b/internal/commands/preview.go index 60d6b26..cc25158 100644 --- a/internal/commands/preview.go +++ b/internal/commands/preview.go @@ -2,6 +2,7 @@ package commands import ( "fmt" + "github.com/lyx0/nourybot/internal/common" ) diff --git a/internal/commands/seventv.go b/internal/commands/seventv.go index 572e12a..011bd43 100644 --- a/internal/commands/seventv.go +++ b/internal/commands/seventv.go @@ -2,8 +2,9 @@ package commands import "fmt" -func SevenTV(query string) string { - reply := fmt.Sprintf("https://7tv.app/emotes?page=1&query=%s", query) + "github.com/gempir/go-twitch-irc/v4" + "github.com/lyx0/nourybot/internal/common" +) return reply } diff --git a/internal/commands/tweet.go b/internal/commands/tweet.go new file mode 100644 index 0000000..0fe75ce --- /dev/null +++ b/internal/commands/tweet.go @@ -0,0 +1,20 @@ +package commands + +import ( + "github.com/gempir/go-twitch-irc/v4" + "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) +} diff --git a/internal/common/privs.go b/internal/common/privs.go new file mode 100644 index 0000000..0fc2aca --- /dev/null +++ b/internal/common/privs.go @@ -0,0 +1,26 @@ +package common + +import "github.com/gempir/go-twitch-irc/v4" + +// 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 + } +} diff --git a/internal/common/send.go b/internal/common/send.go new file mode 100644 index 0000000..67e8a1e --- /dev/null +++ b/internal/common/send.go @@ -0,0 +1,180 @@ +package common + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + + "github.com/gempir/go-twitch-irc/v4" + "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 + } +}