From 8daf64ceb3e679a28b714984f64ac334fb18dd06 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Sat, 22 Apr 2023 05:35:41 +0200 Subject: [PATCH 01/26] added simple twitch bot --- config.yaml | 7 +++++ go.mod | 1 + go.sum | 2 ++ main.go | 12 ++++++++ twitch/handle.go | 62 ++++++++++++++++++++++++++++++++++++++++ twitch/messageHandler.go | 26 +++++++++++++++++ 6 files changed, 110 insertions(+) create mode 100644 twitch/handle.go create mode 100644 twitch/messageHandler.go diff --git a/config.yaml b/config.yaml index a3083f5..e1e1db0 100644 --- a/config.yaml +++ b/config.yaml @@ -5,3 +5,10 @@ additionalConfigs: discord: name: Cake4Everybot credits: Cake4Everybot, developed by @Kesuaheli#5868 and the ideas of the community ♥ + +twitch: + name: c4e_bot + channels: + - kesuaheli + - taomi_ + - c4e_bot diff --git a/go.mod b/go.mod index 1248eba..885c2a5 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.20 require ( github.com/bwmarrin/discordgo v0.27.1 + github.com/gempir/go-twitch-irc v1.1.0 github.com/go-sql-driver/mysql v1.7.0 github.com/spf13/viper v1.15.0 ) diff --git a/go.sum b/go.sum index d05efa2..5e58e25 100644 --- a/go.sum +++ b/go.sum @@ -60,6 +60,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/gempir/go-twitch-irc v1.1.0 h1:Q9gQGI/3yJzYwlYDlFsGJzWfpaqubMExfmBXNpOC6W0= +github.com/gempir/go-twitch-irc v1.1.0/go.mod h1:Pc661rsUSmkQXvI9W2bNyLt4ZrMAgHZPnVwMQEJ0fdo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= diff --git a/main.go b/main.go index 64e0766..1c3475b 100644 --- a/main.go +++ b/main.go @@ -23,8 +23,10 @@ import ( "cake4everybot/config" "cake4everybot/database" "cake4everybot/event" + "cake4everybot/twitch" "github.com/bwmarrin/discordgo" + twitchgo "github.com/gempir/go-twitch-irc" "github.com/spf13/viper" ) @@ -82,6 +84,16 @@ func main() { log.Printf("Error registering events: %v\n", err) } + client := twitchgo.NewClient(viper.GetString("twitch.name"), viper.GetString("twitch.token")) + twitch.Handle(client) + + go func() { + err := client.Connect() + if err != nil { + log.Fatalf("Error on connect to Twitch: %v", err) + } + }() + // Wait to end the bot log.Println("Press Ctrl+C to exit") <-ctx.Done() diff --git a/twitch/handle.go b/twitch/handle.go new file mode 100644 index 0000000..a343526 --- /dev/null +++ b/twitch/handle.go @@ -0,0 +1,62 @@ +// Copyright 2023 Kesuaheli +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package twitch + +import ( + "log" + + twitchgo "github.com/gempir/go-twitch-irc" + "github.com/spf13/viper" +) + +// Handle adds all handler to the client +func Handle(client *twitchgo.Client) { + client.OnConnect(func() { + log.Printf("Twich connected as %s!\n", viper.GetString("twitch.name")) + }) + + channels := viper.GetStringSlice("twitch.channels") + for _, channel := range channels { + client.Join(channel) + } + log.Printf("Channel list set to %v\n", channels) + + client.OnNewMessage(messageHandler) + + client.OnUserJoin(func(channel, user string) { + if user == viper.GetString("twitch.name") { + log.Printf("Connected to %s", channel) + } else { + log.Printf("Twitch: %s joined %s\n", user, channel) + } + }) + client.OnUserPart(func(channel, user string) { + log.Printf("Twitch: %s left %s\n", user, channel) + }) + + client.OnNewNoticeMessage(func(channel string, user twitchgo.User, message twitchgo.Message) { + log.Printf("Twitch [Notice]: %s@%s: %s", user.DisplayName, channel, message.Raw) + }) + client.OnNewRoomstateMessage(func(channel string, user twitchgo.User, message twitchgo.Message) { + log.Printf("Twitch [RoomState]: %s@%s: %s", user.DisplayName, channel, message.Raw) + }) + + client.OnNewUsernoticeMessage(func(channel string, user twitchgo.User, message twitchgo.Message) { + log.Printf("Twitch [UserNotice]: %s@%s: %s", user.DisplayName, channel, message.Raw) + }) + client.OnNewUserstateMessage(func(channel string, user twitchgo.User, message twitchgo.Message) { + log.Printf("Twitch [UserState]: %s@%s: %s", user.DisplayName, channel, message.Raw) + }) +} diff --git a/twitch/messageHandler.go b/twitch/messageHandler.go new file mode 100644 index 0000000..39794be --- /dev/null +++ b/twitch/messageHandler.go @@ -0,0 +1,26 @@ +// Copyright 2023 Kesuaheli +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package twitch + +import ( + "log" + + twitchgo "github.com/gempir/go-twitch-irc" +) + +func messageHandler(channel string, user twitchgo.User, message twitchgo.Message) { + log.Printf("Twitch: %s <%s> %s", channel, user.Username, message.Text) + +} From 094dd7cb488b55c166e4309c47b3c2aecf684185 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Sat, 23 Dec 2023 12:34:34 +0100 Subject: [PATCH 02/26] switch twitch library --- config_env.yaml.example | 6 ++++++ go.mod | 2 +- go.sum | 4 ++-- main.go | 17 ++++++++--------- twitch/handle.go | 37 +++---------------------------------- twitch/messageHandler.go | 6 +++--- 6 files changed, 23 insertions(+), 49 deletions(-) diff --git a/config_env.yaml.example b/config_env.yaml.example index 533b728..fb1e893 100644 --- a/config_env.yaml.example +++ b/config_env.yaml.example @@ -11,3 +11,9 @@ discord: token: PUT.TOKEN.HERE # ID of guild currently used for adding commands guildID: 0 + +twitch: + # username of the bot account + name: twitch_username + # twitch oauth token, starts with "oauth:" + token: diff --git a/go.mod b/go.mod index 7837917..a30a977 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,9 @@ go 1.21 require ( github.com/bwmarrin/discordgo v0.27.1 - github.com/gempir/go-twitch-irc v1.1.0 github.com/go-sql-driver/mysql v1.7.1 github.com/gorilla/mux v1.8.0 + github.com/kesuaheli/twitchgo v0.1.0 github.com/spf13/viper v1.15.0 ) diff --git a/go.sum b/go.sum index 047e463..200eeb3 100644 --- a/go.sum +++ b/go.sum @@ -61,8 +61,6 @@ github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0X github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= -github.com/gempir/go-twitch-irc v1.1.0 h1:Q9gQGI/3yJzYwlYDlFsGJzWfpaqubMExfmBXNpOC6W0= -github.com/gempir/go-twitch-irc v1.1.0/go.mod h1:Pc661rsUSmkQXvI9W2bNyLt4ZrMAgHZPnVwMQEJ0fdo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -137,6 +135,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kesuaheli/twitchgo v0.1.0 h1:vteAnegcvvY6aog8Z5X35BLmu6bUV3Hw/7hnw4D5bLg= +github.com/kesuaheli/twitchgo v0.1.0/go.mod h1:swIW1jJcFa4bWi/9JfUYH4Sf1kAvj+QUcaTPkce+6Ds= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= diff --git a/main.go b/main.go index 0a3a582..5023d52 100644 --- a/main.go +++ b/main.go @@ -27,7 +27,7 @@ import ( "cake4everybot/webserver" "github.com/bwmarrin/discordgo" - twitchgo "github.com/gempir/go-twitch-irc" + "github.com/kesuaheli/twitchgo" "github.com/spf13/viper" ) @@ -93,16 +93,15 @@ func main() { addr := ":8080" webserver.Run(addr, webChan) - client := twitchgo.NewClient(viper.GetString("twitch.name"), viper.GetString("twitch.token")) + client := twitchgo.New(viper.GetString("twitch.name"), viper.GetString("twitch.token")) + client.SetEventChannelMessage(twitch.MessageHandler) + err = client.Connect() + if err != nil { + log.Fatalf("could not open the twitch connection: %v", err) + } + defer client.Close() twitch.Handle(client) - go func() { - err := client.Connect() - if err != nil { - log.Fatalf("Error on connect to Twitch: %v", err) - } - }() - // Wait to end the bot log.Println("Press Ctrl+C to exit") <-ctx.Done() diff --git a/twitch/handle.go b/twitch/handle.go index a343526..5f5b2c7 100644 --- a/twitch/handle.go +++ b/twitch/handle.go @@ -17,46 +17,15 @@ package twitch import ( "log" - twitchgo "github.com/gempir/go-twitch-irc" + "github.com/kesuaheli/twitchgo" "github.com/spf13/viper" ) // Handle adds all handler to the client -func Handle(client *twitchgo.Client) { - client.OnConnect(func() { - log.Printf("Twich connected as %s!\n", viper.GetString("twitch.name")) - }) - +func Handle(bot *twitchgo.Twitch) { channels := viper.GetStringSlice("twitch.channels") for _, channel := range channels { - client.Join(channel) + bot.SendCommandf("JOIN #%s", channel) } log.Printf("Channel list set to %v\n", channels) - - client.OnNewMessage(messageHandler) - - client.OnUserJoin(func(channel, user string) { - if user == viper.GetString("twitch.name") { - log.Printf("Connected to %s", channel) - } else { - log.Printf("Twitch: %s joined %s\n", user, channel) - } - }) - client.OnUserPart(func(channel, user string) { - log.Printf("Twitch: %s left %s\n", user, channel) - }) - - client.OnNewNoticeMessage(func(channel string, user twitchgo.User, message twitchgo.Message) { - log.Printf("Twitch [Notice]: %s@%s: %s", user.DisplayName, channel, message.Raw) - }) - client.OnNewRoomstateMessage(func(channel string, user twitchgo.User, message twitchgo.Message) { - log.Printf("Twitch [RoomState]: %s@%s: %s", user.DisplayName, channel, message.Raw) - }) - - client.OnNewUsernoticeMessage(func(channel string, user twitchgo.User, message twitchgo.Message) { - log.Printf("Twitch [UserNotice]: %s@%s: %s", user.DisplayName, channel, message.Raw) - }) - client.OnNewUserstateMessage(func(channel string, user twitchgo.User, message twitchgo.Message) { - log.Printf("Twitch [UserState]: %s@%s: %s", user.DisplayName, channel, message.Raw) - }) } diff --git a/twitch/messageHandler.go b/twitch/messageHandler.go index 39794be..e535146 100644 --- a/twitch/messageHandler.go +++ b/twitch/messageHandler.go @@ -17,10 +17,10 @@ package twitch import ( "log" - twitchgo "github.com/gempir/go-twitch-irc" + "github.com/kesuaheli/twitchgo" ) -func messageHandler(channel string, user twitchgo.User, message twitchgo.Message) { - log.Printf("Twitch: %s <%s> %s", channel, user.Username, message.Text) +func MessageHandler(t *twitchgo.Twitch, message *twitchgo.Message) { + log.Printf("Twitch: [%s] <%s> %s", message.Command.Arguments[0], message.Source, message.Command.Data) } From 91cc49cdc8995b8bfe78ed94100498568c8a54d4 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Mon, 25 Dec 2023 01:18:08 +0100 Subject: [PATCH 03/26] update twitch library --- go.mod | 2 +- go.sum | 4 ++-- main.go | 2 +- twitch/messageHandler.go | 7 ++++--- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index a30a977..d5c87f0 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/bwmarrin/discordgo v0.27.1 github.com/go-sql-driver/mysql v1.7.1 github.com/gorilla/mux v1.8.0 - github.com/kesuaheli/twitchgo v0.1.0 + github.com/kesuaheli/twitchgo v0.2.2 github.com/spf13/viper v1.15.0 ) diff --git a/go.sum b/go.sum index 200eeb3..b4b7447 100644 --- a/go.sum +++ b/go.sum @@ -135,8 +135,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/kesuaheli/twitchgo v0.1.0 h1:vteAnegcvvY6aog8Z5X35BLmu6bUV3Hw/7hnw4D5bLg= -github.com/kesuaheli/twitchgo v0.1.0/go.mod h1:swIW1jJcFa4bWi/9JfUYH4Sf1kAvj+QUcaTPkce+6Ds= +github.com/kesuaheli/twitchgo v0.2.2 h1:ub/dZO9UqfoUgCiBqs+1gQnFs6VQm1oUNNv+vqjaJwA= +github.com/kesuaheli/twitchgo v0.2.2/go.mod h1:swIW1jJcFa4bWi/9JfUYH4Sf1kAvj+QUcaTPkce+6Ds= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= diff --git a/main.go b/main.go index 5023d52..1d8fa02 100644 --- a/main.go +++ b/main.go @@ -94,7 +94,7 @@ func main() { webserver.Run(addr, webChan) client := twitchgo.New(viper.GetString("twitch.name"), viper.GetString("twitch.token")) - client.SetEventChannelMessage(twitch.MessageHandler) + client.OnChannelMessage(twitch.MessageHandler) err = client.Connect() if err != nil { log.Fatalf("could not open the twitch connection: %v", err) diff --git a/twitch/messageHandler.go b/twitch/messageHandler.go index e535146..505f207 100644 --- a/twitch/messageHandler.go +++ b/twitch/messageHandler.go @@ -20,7 +20,8 @@ import ( "github.com/kesuaheli/twitchgo" ) -func MessageHandler(t *twitchgo.Twitch, message *twitchgo.Message) { - log.Printf("Twitch: [%s] <%s> %s", message.Command.Arguments[0], message.Source, message.Command.Data) - +// MessageHandler handles new messages from the twitch chat(s). It will be called on every new +// message. +func MessageHandler(t *twitchgo.Twitch, channel string, user *twitchgo.User, message string) { + log.Printf("Twitch: [%s] <%s> %s", channel, user.Nickname, message) } From 8a55ab122356b6ab142438a8b5edb4d3b28b51dd Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Tue, 26 Dec 2023 12:33:29 +0100 Subject: [PATCH 04/26] changed twitch logger --- twitch/handle.go | 2 -- twitch/messageHandler.go | 6 ++++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/twitch/handle.go b/twitch/handle.go index 5f5b2c7..06716a3 100644 --- a/twitch/handle.go +++ b/twitch/handle.go @@ -15,8 +15,6 @@ package twitch import ( - "log" - "github.com/kesuaheli/twitchgo" "github.com/spf13/viper" ) diff --git a/twitch/messageHandler.go b/twitch/messageHandler.go index 505f207..1c1cbce 100644 --- a/twitch/messageHandler.go +++ b/twitch/messageHandler.go @@ -15,13 +15,15 @@ package twitch import ( - "log" + logger "log" "github.com/kesuaheli/twitchgo" ) +var log logger.Logger = *logger.New(logger.Writer(), "[Twitch] ", logger.LstdFlags|logger.Lmsgprefix) + // MessageHandler handles new messages from the twitch chat(s). It will be called on every new // message. func MessageHandler(t *twitchgo.Twitch, channel string, user *twitchgo.User, message string) { - log.Printf("Twitch: [%s] <%s> %s", channel, user.Nickname, message) + log.Printf("<%s@%s> %s", user.Nickname, channel, message) } From d9ba56abbb57507d7a7201b97163c0eb78d76971 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Fri, 29 Dec 2023 17:37:27 +0100 Subject: [PATCH 05/26] added basic streamelements api --- config_env.yaml.example | 4 + tools/streamelements/endpoints.go | 150 ++++++++++++++++++++++++++++++ tools/streamelements/se.go | 35 +++++++ tools/streamelements/types.go | 110 ++++++++++++++++++++++ 4 files changed, 299 insertions(+) create mode 100644 tools/streamelements/endpoints.go create mode 100644 tools/streamelements/se.go create mode 100644 tools/streamelements/types.go diff --git a/config_env.yaml.example b/config_env.yaml.example index fb1e893..ebe13c7 100644 --- a/config_env.yaml.example +++ b/config_env.yaml.example @@ -17,3 +17,7 @@ twitch: name: twitch_username # twitch oauth token, starts with "oauth:" token: + +streamelements: + # Streamelements JSON Web Token (JWT) + token: PUT TOKEN.HERE diff --git a/tools/streamelements/endpoints.go b/tools/streamelements/endpoints.go new file mode 100644 index 0000000..3311a11 --- /dev/null +++ b/tools/streamelements/endpoints.go @@ -0,0 +1,150 @@ +package streamelements + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +// GetChannels returns a list of all channels the current user has access to. The current user is +// defined by the used bearer token. +func (se *Streamelements) GetChannels() ([]*Channel1, error) { + var channels []*Channel1 = make([]*Channel1, 0) + + r, err := se.doReq(http.MethodGet, "/users/channels", []byte{}, nil) + if err != nil { + return channels, err + } + + data, err := io.ReadAll(r.Body) + if err != nil { + return channels, err + } + + if r.StatusCode != 200 { + return channels, fmt.Errorf("wrong status code, expected 200 but got %d! Response data: %s", r.StatusCode, string(data)) + } + + err = json.Unmarshal(data, &channels) + return channels, err +} + +// GetChannelDetails returns details for the given channelID (streamelements ID). This only works +// when the current user has access to this channel, otherwise a 403 is returned. +// +// NOTE: Some documentation is missing from streamelements. It appears that this endpoint only +// only returns the current users channel. +func (se *Streamelements) GetChannelDetails(channelID string) (*ChannelDetails, error) { + r, err := se.doReq( + http.MethodGet, + fmt.Sprintf("/channels/%s/details", channelID), + []byte{}, + nil, + ) + if err != nil { + return nil, err + } + + data, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + + if r.StatusCode != 200 { + return nil, fmt.Errorf("wrong status code, expected 200 but got %d! Response data: %s", r.StatusCode, string(data)) + } + + c := &ChannelDetails{} + err = json.Unmarshal(data, c) + return c, err +} + +// GetChannel returns basic details for the given channel. This endpoint does not requiere access to +// the requested channel. +// +// "channel" can be either a streamelements channel ID or the username of a channel. +func (se *Streamelements) GetChannel(channel string) (*SimpleChannelDetails, error) { + r, err := se.doReq( + http.MethodGet, + fmt.Sprintf("/channels/%s", channel), + []byte{}, + nil, + ) + if err != nil { + return nil, err + } + + data, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + + if r.StatusCode != 200 { + return nil, fmt.Errorf("wrong status code, expected 200 but got %d! Response data: %s", r.StatusCode, string(data)) + } + + c := &SimpleChannelDetails{} + err = json.Unmarshal(data, c) + return c, err +} + +// AddPoints modifies (add or remove) the streamelements points for a user. When amount == 0 +// AddPoints is a no-op. +// +// channelID // the streamelements ID of the channel to add the points to +// username // the username to modify +// amount // the amount to modify, amount > 0 adds the points, amount < 0 removes the points. +func (se *Streamelements) AddPoints(channelID, username string, amount int) error { + if amount == 0 { + return nil + } + r, err := se.doReq( + http.MethodPut, + fmt.Sprintf("/points/%s/%s/%d", channelID, username, amount), + []byte("{}"), + map[string]string{"Content-Type": "application/json"}, + ) + if err != nil { + return err + } + + if r.StatusCode == 200 { + return nil + } + + data, err := io.ReadAll(r.Body) + if err != nil { + return fmt.Errorf("wrong status code, expected 200 but got %d! But also failed to read data: %v", r.StatusCode, err) + } + return fmt.Errorf("wrong status code, expected 200 but got %d! Response data: %s", r.StatusCode, string(data)) +} + +// GetPoints returns the current streamelements points for a user. +// +// channelID // the streamelements ID of the channel to get the points from +// username // the username to fetch +func (se *Streamelements) GetPoints(channelID, username string) (*UserPoints, error) { + r, err := se.doReq( + http.MethodGet, + fmt.Sprintf("/points/%s/%s", channelID, username), + []byte{}, + nil, + ) + if err != nil { + return nil, err + } + + data, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + + if r.StatusCode != 200 { + return nil, fmt.Errorf("wrong status code, expected 200 but got %d! Response data: %v", r.StatusCode, string(data)) + } + + up := &UserPoints{} + err = json.Unmarshal(data, up) + return up, err +} diff --git a/tools/streamelements/se.go b/tools/streamelements/se.go new file mode 100644 index 0000000..a7d4bf2 --- /dev/null +++ b/tools/streamelements/se.go @@ -0,0 +1,35 @@ +package streamelements + +import ( + "bytes" + "net/http" + "strings" +) + +// New returns a new Streamelements API connection with the given bearer token. +func New(token string) *Streamelements { + if !strings.HasPrefix(token, "Bearer ") { + token = "Bearer " + token + } + se := &Streamelements{ + c: &http.Client{}, + token: token, + } + return se +} + +// doReq makes a new request with the given properties and parameters. +func (se *Streamelements) doReq(method, path string, body []byte, header map[string]string) (*http.Response, error) { + const baseURL = "https://api.streamelements.com/kappa/v2" + req, err := http.NewRequest(method, baseURL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + for k, v := range header { + req.Header.Set(k, v) + } + req.Header.Set("Authorization", se.token) + + return se.c.Do(req) +} diff --git a/tools/streamelements/types.go b/tools/streamelements/types.go new file mode 100644 index 0000000..110a594 --- /dev/null +++ b/tools/streamelements/types.go @@ -0,0 +1,110 @@ +package streamelements + +import ( + "net/http" + "time" +) + +// Streamelements is the base type for communication with the streamelements API. +type Streamelements struct { + c *http.Client + token string +} + +// CurrentUserChannel represents the return type for the '/channels/me' endpoint. +type CurrentUserChannel struct { + ID string `json:"_id"` + Username string `json:"username"` + AvatarURL string `json:"avatar"` + Channels []*Channel1 `json:"channels"` + PrimaryChannel string `json:"primaryChannel"` + Teams []string `json:"teams"` + LastLogin time.Time `json:"lastLogin"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Suspended bool `json:"suspended"` +} + +// Channel1 represents the return type for the '/users/channels' endpoint. +type Channel1 struct { + SimpleChannelDetails + EmailAddress string `json:"email"` + Type string `json:"type"` + Role string `json:"role"` + Country string `json:"country"` + Moderators []*Moderator `json:"moderators"` + LastLogin time.Time `json:"lastLogin"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Suspended bool `json:"suspended"` +} + +// SimpleChannelDetails represents the return type for the '/channels/{channel}' endpoint. +type SimpleChannelDetails struct { + ID string `json:"_id"` + Profile Profile `json:"profile"` + Provider string `json:"provider"` + ProviderID string `json:"providerId"` + Username string `json:"username"` + Alias string `json:"alias"` + DisplayName string `json:"displayName"` + AvatarURL string `json:"avatar"` + BroadcasterType string `json:"broadcasterType"` + Inactive bool `json:"inactive"` + IsPartner bool `json:"isPartner"` +} + +// ChannelDetails represents the return type for the '/channels/{channel}/details' endpoint. +type ChannelDetails struct { + SimpleChannelDetails + Email string `json:"email"` + ProviderEmails []string `json:"providerEmails"` + Users []User `json:"users"` + Country string `json:"country"` + LastJWTToken string `json:"lastJWTToken"` + AccessToken string `json:"accessToken,omitempty"` + APIToken string `json:"apiToken"` + AB []string `json:"ab"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + LastLogin time.Time `json:"lastLogin"` + Verified bool `json:"verified"` + Suspended bool `json:"suspended"` + NullChannel bool `json:"nullChannel"` +} + +// Profile represents a streamelements user profile. +type Profile struct { + Title string `json:"title"` + HeaderImageURL string `json:"headerImage"` +} + +// Moderator represents a moderator for a channel. +type Moderator struct { + User User13 `json:"user"` + Type string `json:"type"` +} + +// User represents a streamelements user. +type User struct { + User string `json:"user"` + ProviderID string `json:"providerId"` + Role string `json:"role"` +} + +// User13 is a variation of the User type. +type User13 struct { + ID string `json:"_id"` + Username string `json:"username"` + AvatarURL string `json:"avatar"` +} + +// UserPoints represents the return type of the '/points/{channel}/{user}' endpoint. +type UserPoints struct { + ChannelID string `json:"channel"` + Username string `json:"username"` + Points int `json:"points"` + PointsAlltime int `json:"pointsAlltime"` + Watchtime int `json:"watchtime"` + Rank int `json:"rank"` +} From bd2cd4d2d4d57a042c84054d83ad48350cff3f05 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Fri, 29 Dec 2023 22:19:19 +0100 Subject: [PATCH 06/26] moved giveaway to database package --- database/giveaway.go | 167 +++++++++++++++++++ modules/adventcalendar/adventcalendarbase.go | 16 -- modules/adventcalendar/component.go | 15 +- modules/adventcalendar/database.go | 127 -------------- modules/adventcalendar/midnight.go | 15 +- 5 files changed, 183 insertions(+), 157 deletions(-) create mode 100644 database/giveaway.go delete mode 100644 modules/adventcalendar/database.go diff --git a/database/giveaway.go b/database/giveaway.go new file mode 100644 index 0000000..0ef02ac --- /dev/null +++ b/database/giveaway.go @@ -0,0 +1,167 @@ +// Copyright 2023 Kesuaheli +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package database + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "github.com/bwmarrin/discordgo" +) + +// GiveawayEntry represents a giveaway entry from the database +type GiveawayEntry struct { + // Identification of the entry + UserID string + // The current weight or number of tickets in this entry + Weight int + // The day of last entry. Useful to check when only one ticket per day is allowed. + LastEntry time.Time +} + +// ToEmbedField formats the giveaway entry to an discord message embed field. +func (e GiveawayEntry) ToEmbedField() (f *discordgo.MessageEmbedField) { + return &discordgo.MessageEmbedField{ + Name: e.UserID, + Value: fmt.Sprintf("<@%s>\n%d tickets\nlast entry: %s", e.UserID, e.Weight, e.LastEntry.Format(time.DateOnly)), + Inline: true, + } +} + +// GetGiveawayEntry gets the giveaway entry for the given user identifier, if their last entry was +// prefixed with prefix. +// +// If an error occours or it doesn't match prefix, an emtpy GiveawayEntry is returned instead. +func GetGiveawayEntry(prefix, userID string) GiveawayEntry { + var ( + weight int + lastEntryID string + ) + err := QueryRow("SELECT weight,last_entry_id FROM giveaway WHERE id=?", userID).Scan(&weight, &lastEntryID) + if err == sql.ErrNoRows { + return GiveawayEntry{UserID: userID, Weight: 0} + } + if err != nil { + log.Printf("Database failed to get giveaway entries for '%s': %v", userID, err) + return GiveawayEntry{} + } + + if lastEntryID == "" { + return GiveawayEntry{UserID: userID, Weight: weight} + } + + dateValue, ok := strings.CutPrefix(lastEntryID, prefix+"-") + if !ok { + return GiveawayEntry{} + } + + lastEntry, err := time.Parse(time.DateOnly, dateValue) + if err != nil { + log.Printf("could not convert last_entry_id '%s' to time: %v", lastEntryID, err) + return GiveawayEntry{} + } + return GiveawayEntry{userID, weight, lastEntry} +} + +// AddGiveawayWeight adds amount to the given user identifier. +// +// However if their last entry wasn't prefixed with prefix, their weight will be resetted and starts +// at amount. If you dont want it to be resetted check with GetGiveawayEntry first. +// +// If there was no error the modified entry is returned. If there was an error, an emtpy +// GiveawayEntry is returned instead. +func AddGiveawayWeight(prefix, userID string, amount int) GiveawayEntry { + var ( + weight int + lastEntryID string + new bool + ) + err := QueryRow("SELECT weight,last_entry_id FROM giveaway WHERE id=?", userID).Scan(&weight, &lastEntryID) + if err == sql.ErrNoRows { + new = true + } else if err != nil { + log.Printf("Database failed to get giveaway weight for '%s': %v", userID, err) + return GiveawayEntry{} + } + + // validate prefix + if _, ok := strings.CutPrefix(lastEntryID, prefix+"-"); !ok { + weight = 0 + } + + weight += amount + dateValue := time.Now().Format(time.DateOnly) + lastEntryID = fmt.Sprintf("%s-%s", prefix, dateValue) + lastEntry, _ := time.Parse(time.DateOnly, dateValue) + + if new { + _, err = Exec("INSERT INTO giveaway (id,weight,last_entry_id) VALUES (?,?,?)", userID, weight, lastEntryID) + if err != nil { + log.Printf("Database failed to insert giveaway for '%s': %v", userID, err) + return GiveawayEntry{} + } + return GiveawayEntry{userID, weight, lastEntry} + } + _, err = Exec("UPDATE giveaway SET weight=?,last_entry_id=? WHERE id=?", weight, lastEntryID, userID) + if err != nil { + log.Printf("Database failed to update weight (new: %d) for '%s': %v", weight, userID, err) + return GiveawayEntry{} + } + return GiveawayEntry{userID, weight, lastEntry} +} + +// GetGetAllGiveawayEntries gets all giveaway entries that matches prefix. +func GetGetAllGiveawayEntries(prefix string) []GiveawayEntry { + rows, err := Query("SELECT id,weight,last_entry_id FROM giveaway") + if err != nil { + log.Printf("ERROR: could not get entries from database: %v", err) + return []GiveawayEntry{} + } + defer rows.Close() + + var entries []GiveawayEntry + for rows.Next() { + var ( + userID string + weight int + lastEntryID string + ) + err = rows.Scan(&userID, &weight, &lastEntryID) + if err != nil { + log.Printf("Warning: could not scan variables from row") + continue + } + + if lastEntryID == "" { + entries = append(entries, GiveawayEntry{UserID: userID, Weight: weight}) + continue + } + + dateValue, ok := strings.CutPrefix(lastEntryID, prefix+"-") + if !ok { + continue + } + + lastEntry, err := time.Parse(time.DateOnly, dateValue) + if err != nil { + log.Printf("ERROR: could not convert last_entry_id '%s' to time: %v", lastEntryID, err) + continue + } + entries = append(entries, GiveawayEntry{userID, weight, lastEntry}) + } + return entries +} diff --git a/modules/adventcalendar/adventcalendarbase.go b/modules/adventcalendar/adventcalendarbase.go index b2a83cb..b3cc27e 100644 --- a/modules/adventcalendar/adventcalendarbase.go +++ b/modules/adventcalendar/adventcalendarbase.go @@ -16,9 +16,7 @@ package adventcalendar import ( "cake4everybot/util" - "fmt" logger "log" - "time" "github.com/bwmarrin/discordgo" ) @@ -36,17 +34,3 @@ type adventcalendarBase struct { member *discordgo.Member user *discordgo.User } - -type giveawayEntry struct { - userID string - weight int - lastEntry time.Time -} - -func (e giveawayEntry) toEmbedField() (f *discordgo.MessageEmbedField) { - return &discordgo.MessageEmbedField{ - Name: e.userID, - Value: fmt.Sprintf("<@%s>\n%d tickets\nlast entry: %s", e.userID, e.weight, e.lastEntry.Format(time.DateOnly)), - Inline: true, - } -} diff --git a/modules/adventcalendar/component.go b/modules/adventcalendar/component.go index 6ad2bcc..980b9f8 100644 --- a/modules/adventcalendar/component.go +++ b/modules/adventcalendar/component.go @@ -16,6 +16,7 @@ package adventcalendar import ( "cake4everybot/data/lang" + "cake4everybot/database" "cake4everybot/util" "fmt" "strings" @@ -81,18 +82,18 @@ func (c *Component) handlePost(s *discordgo.Session, ids []string) { return } - entry := getEntry(c.user.ID) - if entry.userID != c.user.ID { - log.Printf("ERROR: getEntry() returned with userID '%s' but want '%s'", entry.userID, c.user.ID) + entry := database.GetGiveawayEntry("xmas", c.user.ID) + if entry.UserID != c.user.ID { + log.Printf("ERROR: getEntry() returned with userID '%s' but want '%s'", entry.UserID, c.user.ID) c.ReplyError() return } - if entry.lastEntry.Equal(postTime) { - c.ReplyHiddenSimpleEmbedf(0x5865f2, lang.GetDefault("module.adventcalendar.enter.already_entered"), entry.weight) + if entry.LastEntry.Equal(postTime) { + c.ReplyHiddenSimpleEmbedf(0x5865f2, lang.GetDefault("module.adventcalendar.enter.already_entered"), entry.Weight) return } - entry = addGiveawayWeight(c.user.ID, 1) + entry = database.AddGiveawayWeight("xmas", c.user.ID, 1) - c.ReplyHiddenSimpleEmbedf(0x00FF00, lang.GetDefault("module.adventcalendar.enter.success"), entry.weight) + c.ReplyHiddenSimpleEmbedf(0x00FF00, lang.GetDefault("module.adventcalendar.enter.success"), entry.Weight) } diff --git a/modules/adventcalendar/database.go b/modules/adventcalendar/database.go deleted file mode 100644 index 22cedd8..0000000 --- a/modules/adventcalendar/database.go +++ /dev/null @@ -1,127 +0,0 @@ -// Copyright 2023 Kesuaheli -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package adventcalendar - -import ( - "cake4everybot/database" - "database/sql" - "fmt" - "strings" - "time" -) - -func getEntry(userID string) giveawayEntry { - var ( - weight int - lastEntryID string - ) - err := database.QueryRow("SELECT weight,last_entry_id FROM giveaway WHERE id=?", userID).Scan(&weight, &lastEntryID) - if err == sql.ErrNoRows { - return giveawayEntry{userID: userID, weight: 0} - } - if err != nil { - log.Printf("Database failed to get giveaway entries for '%s': %+v", userID, err) - return giveawayEntry{} - } - - if lastEntryID == "" { - return giveawayEntry{userID: userID, weight: weight} - } - - dateValue, ok := strings.CutPrefix(lastEntryID, "xmas-") - if !ok { - return giveawayEntry{} - } - - lastEntry, err := time.Parse(time.DateOnly, dateValue) - if err != nil { - log.Printf("could not convert last_entry_id '%s' to time: %+v", lastEntryID, err) - return giveawayEntry{} - } - return giveawayEntry{userID, weight, lastEntry} -} - -func addGiveawayWeight(userID string, amount int) giveawayEntry { - var weight int - var new bool - err := database.QueryRow("SELECT weight FROM giveaway WHERE id=?", userID).Scan(&weight) - if err == sql.ErrNoRows { - new = true - } else if err != nil { - log.Printf("Database failed to get giveaway weight for '%s': %+v", userID, err) - return giveawayEntry{} - } - - weight += amount - dateValue := time.Now().Format(time.DateOnly) - lastEntryID := fmt.Sprintf("xmas-%s", dateValue) - lastEntry, _ := time.Parse(time.DateOnly, dateValue) - - if new { - _, err = database.Exec("INSERT INTO giveaway (id,weight,last_entry_id) VALUES (?,?,?)", userID, weight, lastEntryID) - if err != nil { - log.Printf("Database failed to insert giveaway for '%s': %+v", userID, err) - return giveawayEntry{} - } - return giveawayEntry{userID, weight, lastEntry} - } - _, err = database.Exec("UPDATE giveaway SET weight=?,last_entry_id=? WHERE id=?", weight, lastEntryID, userID) - if err != nil { - log.Printf("Database failed to update weight (new: %d) for '%s': %+v", weight, userID, err) - return giveawayEntry{} - } - return giveawayEntry{userID, weight, lastEntry} -} - -func getGetAllEntries() []giveawayEntry { - rows, err := database.Query("SELECT id,weight,last_entry_id FROM giveaway") - if err != nil { - log.Printf("ERROR: could not get entries from database: %+v", err) - return []giveawayEntry{} - } - defer rows.Close() - - var entries []giveawayEntry - for rows.Next() { - var ( - userID string - weight int - lastEntryID string - ) - err = rows.Scan(&userID, &weight, &lastEntryID) - if err != nil { - log.Printf("Warning: could not scan variables from row") - continue - } - - if lastEntryID == "" { - entries = append(entries, giveawayEntry{userID: userID, weight: weight}) - continue - } - - dateValue, ok := strings.CutPrefix(lastEntryID, "xmas-") - if !ok { - continue - } - - lastEntry, err := time.Parse(time.DateOnly, dateValue) - if err != nil { - log.Printf("ERROR: could not convert last_entry_id '%s' to time: %+v", lastEntryID, err) - continue - } - entries = append(entries, giveawayEntry{userID, weight, lastEntry}) - } - return entries -} diff --git a/modules/adventcalendar/midnight.go b/modules/adventcalendar/midnight.go index 8493f9a..3453133 100644 --- a/modules/adventcalendar/midnight.go +++ b/modules/adventcalendar/midnight.go @@ -15,6 +15,7 @@ package adventcalendar import ( + "cake4everybot/database" "cake4everybot/util" "slices" "time" @@ -30,16 +31,16 @@ func Midnight(s *discordgo.Session) { } log.Printf("New Post for %s", t.Format("_2. Jan")) - entries := getGetAllEntries() - slices.SortFunc(entries, func(a, b giveawayEntry) int { - if a.weight < b.weight { + entries := database.GetGetAllGiveawayEntries("xmas") + slices.SortFunc(entries, func(a, b database.GiveawayEntry) int { + if a.Weight < b.Weight { return -1 - } else if a.weight > b.weight { + } else if a.Weight > b.Weight { return 1 } - if a.lastEntry.Before(b.lastEntry) { + if a.LastEntry.Before(b.LastEntry) { return -1 - } else if a.lastEntry.After(b.lastEntry) { + } else if a.LastEntry.After(b.LastEntry) { return 1 } return 0 @@ -47,7 +48,7 @@ func Midnight(s *discordgo.Session) { slices.Reverse(entries) var fields []*discordgo.MessageEmbedField for _, e := range entries { - fields = append(fields, e.toEmbedField()) + fields = append(fields, e.ToEmbedField()) } data := &discordgo.MessageSend{ From e8e55bee334120e70cfe31784543781baa6473c0 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Sat, 30 Dec 2023 00:32:45 +0100 Subject: [PATCH 07/26] added twitch join command --- config.yaml | 4 ++++ data/lang/de.yaml | 8 +++++++ data/lang/en.yaml | 8 +++++++ go.mod | 2 +- go.sum | 4 ++-- main.go | 1 + twitch/handle.go | 4 ++++ twitch/messageHandler.go | 46 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 74 insertions(+), 3 deletions(-) diff --git a/config.yaml b/config.yaml index 843e876..ff8c151 100644 --- a/config.yaml +++ b/config.yaml @@ -40,6 +40,10 @@ event: #emoji.id: #emoji.animated: true + twitch_giveaway: + # The amount of points a single giveaway ticket costs. + ticket_cost: 1000 + webserver: favicon: webserver/favicon.png birthday_hour: 8 # Time to trigger daily birthday check (24h format) diff --git a/data/lang/de.yaml b/data/lang/de.yaml index 5656f07..ef17e75 100644 --- a/data/lang/de.yaml +++ b/data/lang/de.yaml @@ -131,3 +131,11 @@ module: youtube: embed_footer: YouTube Glocke msg.new_vid: "%s hat ein neues Video hochgeladen" + +twitch.command: + generic: + error: Upsi, da ist was schief gelaufen! 🙃 @Kesuaheli Hilfe! + + join: + msg.too_few_points: "@%s du nicht genügend Punkte(%d)! Du brauchst noch %d mehr um den Preis von %d zu bezahlen." + msg.success: "@%s Du hast dir erfolgreich ein Ticket für %d Punkte gekauft. Du hast nun %d Tickets und noch %d Punkte über." diff --git a/data/lang/en.yaml b/data/lang/en.yaml index 17b3fae..798397d 100644 --- a/data/lang/en.yaml +++ b/data/lang/en.yaml @@ -131,3 +131,11 @@ module: youtube: embed_footer: YouTube notification bell msg.new_vid: "%s just uploaded a new video" + +twitch.command: + generic: + error: Whoops, something is not right here! 🙃 @Kesuaheli Help! + + join: + msg.too_few_points: "@%s you don't have enough points (%d)! You need %d more to pay the costs of %d points." + msg.success: "@%s You successfully bought a ticket for %d points. Now you have %d tickets and still %d points." diff --git a/go.mod b/go.mod index d5c87f0..c6171bd 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/bwmarrin/discordgo v0.27.1 github.com/go-sql-driver/mysql v1.7.1 github.com/gorilla/mux v1.8.0 - github.com/kesuaheli/twitchgo v0.2.2 + github.com/kesuaheli/twitchgo v0.2.3 github.com/spf13/viper v1.15.0 ) diff --git a/go.sum b/go.sum index b4b7447..d8dc6ca 100644 --- a/go.sum +++ b/go.sum @@ -135,8 +135,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/kesuaheli/twitchgo v0.2.2 h1:ub/dZO9UqfoUgCiBqs+1gQnFs6VQm1oUNNv+vqjaJwA= -github.com/kesuaheli/twitchgo v0.2.2/go.mod h1:swIW1jJcFa4bWi/9JfUYH4Sf1kAvj+QUcaTPkce+6Ds= +github.com/kesuaheli/twitchgo v0.2.3 h1:5lET3xBX1b4NPgmXdGbQoyTie38eJkGN2SJwJnvSV/U= +github.com/kesuaheli/twitchgo v0.2.3/go.mod h1:swIW1jJcFa4bWi/9JfUYH4Sf1kAvj+QUcaTPkce+6Ds= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= diff --git a/main.go b/main.go index 1d8fa02..4b716e4 100644 --- a/main.go +++ b/main.go @@ -94,6 +94,7 @@ func main() { webserver.Run(addr, webChan) client := twitchgo.New(viper.GetString("twitch.name"), viper.GetString("twitch.token")) + client.OnChannelCommandMessage("join", twitch.HandleCmdJoin) client.OnChannelMessage(twitch.MessageHandler) err = client.Connect() if err != nil { diff --git a/twitch/handle.go b/twitch/handle.go index 06716a3..258e5b6 100644 --- a/twitch/handle.go +++ b/twitch/handle.go @@ -15,6 +15,8 @@ package twitch import ( + "cake4everybot/tools/streamelements" + "github.com/kesuaheli/twitchgo" "github.com/spf13/viper" ) @@ -26,4 +28,6 @@ func Handle(bot *twitchgo.Twitch) { bot.SendCommandf("JOIN #%s", channel) } log.Printf("Channel list set to %v\n", channels) + + se = streamelements.New(viper.GetString("streamelements.token")) } diff --git a/twitch/messageHandler.go b/twitch/messageHandler.go index 1c1cbce..07b8355 100644 --- a/twitch/messageHandler.go +++ b/twitch/messageHandler.go @@ -15,15 +15,61 @@ package twitch import ( + "cake4everybot/data/lang" + "cake4everybot/database" + "cake4everybot/tools/streamelements" logger "log" + "strings" "github.com/kesuaheli/twitchgo" + "github.com/spf13/viper" ) +const tp string = "twitch.command.join." + var log logger.Logger = *logger.New(logger.Writer(), "[Twitch] ", logger.LstdFlags|logger.Lmsgprefix) +var se *streamelements.Streamelements // MessageHandler handles new messages from the twitch chat(s). It will be called on every new // message. func MessageHandler(t *twitchgo.Twitch, channel string, user *twitchgo.User, message string) { log.Printf("<%s@%s> %s", user.Nickname, channel, message) } + +// HandleCmdJoin is the handler for a command in a twitch chat. This handler buys a giveaway ticket +// and removes the configured cost amount for a ticket. +func HandleCmdJoin(t *twitchgo.Twitch, channel string, user *twitchgo.User, args []string) { + channel, _ = strings.CutPrefix(channel, "#") + log.Printf("[%s@%s] executed join command with %d args: %v", user.Nickname, channel, len(args), args) + seChannel, err := se.GetChannel(channel) + if err != nil { + log.Printf("Error getting streamelements channel '%s': %v", channel, err) + t.SendMessage(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + sePoints, err := se.GetPoints(seChannel.ID, user.Nickname) + if err != nil { + log.Printf("Error getting streamelements points '%s(%s)/%s' : %v", seChannel.ID, channel, user.Nickname, err) + t.SendMessage(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + + joinCost := viper.GetInt("event.twitch_giveaway.ticket_cost") + if sePoints.Points < joinCost { + t.SendMessagef(channel, lang.GetDefault(tp+"msg.too_few_points"), user.Nickname, sePoints.Points, joinCost-sePoints.Points, joinCost) + return + } + entry := database.AddGiveawayWeight("tw11", user.Nickname, 1) + if entry.UserID == "" { + log.Println("Error getting database giveaway entry", seChannel.ID, channel, user.Nickname, err) + t.SendMessage(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + err = se.AddPoints(seChannel.ID, user.Nickname, -joinCost) + if err != nil { + log.Printf("Error adding points for '%s(%s)/%s/-%d': %v", seChannel.ID, channel, user.Nickname, joinCost, err) + t.SendMessage(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + t.SendMessagef(channel, lang.GetDefault(tp+"msg.success"), user.Nickname, joinCost, entry.Weight, sePoints.Points-joinCost) +} From b41390dee0005f113f031193c293767b6628557b Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Sat, 30 Dec 2023 01:13:25 +0100 Subject: [PATCH 08/26] moved giveaway draw to database package --- database/giveaway.go | 23 +++++++++++++++++-- .../adventcalendar/handlerSubcommandDraw.go | 14 +---------- modules/adventcalendar/midnight.go | 2 +- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/database/giveaway.go b/database/giveaway.go index 6f10b4c..9e43aa6 100644 --- a/database/giveaway.go +++ b/database/giveaway.go @@ -17,6 +17,7 @@ package database import ( "database/sql" "fmt" + "math/rand" "strings" "time" @@ -132,8 +133,8 @@ func AddGiveawayWeight(prefix, userID string, amount int) GiveawayEntry { return GiveawayEntry{userID, weight, lastEntry} } -// GetGetAllGiveawayEntries gets all giveaway entries that matches prefix. -func GetGetAllGiveawayEntries(prefix string) []GiveawayEntry { +// GetAllGiveawayEntries gets all giveaway entries that matches prefix. +func GetAllGiveawayEntries(prefix string) []GiveawayEntry { rows, err := Query("SELECT id,weight,last_entry_id FROM giveaway") if err != nil { log.Printf("ERROR: could not get entries from database: %v", err) @@ -173,3 +174,21 @@ func GetGetAllGiveawayEntries(prefix string) []GiveawayEntry { } return entries } + +func DrawGiveawayWinner(a []GiveawayEntry) (winner GiveawayEntry, totalTickets int) { + var entries []GiveawayEntry + for _, e := range a { + for i := 0; i < e.Weight; i++ { + entries = append(entries, e) + } + } + totalTickets = len(entries) + if totalTickets == 0 { + return GiveawayEntry{}, 0 + } + + rand.Shuffle(len(entries), func(i, j int) { + entries[i], entries[j] = entries[j], entries[i] + }) + return entries[rand.Intn(totalTickets-1)], totalTickets +} diff --git a/modules/adventcalendar/handlerSubcommandDraw.go b/modules/adventcalendar/handlerSubcommandDraw.go index 1fdbc1a..1e7520d 100644 --- a/modules/adventcalendar/handlerSubcommandDraw.go +++ b/modules/adventcalendar/handlerSubcommandDraw.go @@ -5,29 +5,17 @@ import ( "cake4everybot/database" "cake4everybot/util" "fmt" - "math/rand" "github.com/bwmarrin/discordgo" ) func (cmd Chat) handleSubcommandDraw() { - var entries []database.GiveawayEntry - for _, e := range database.GetGetAllGiveawayEntries("xmas") { - for i := 0; i < e.Weight; i++ { - entries = append(entries, e) - } - } - totalTickets := len(entries) + winner, totalTickets := database.DrawGiveawayWinner(database.GetAllGiveawayEntries("xmas")) if totalTickets == 0 { cmd.ReplyHidden(lang.GetDefault(tp + "msg.no_entries.draw")) return } - rand.Shuffle(len(entries), func(i, j int) { - entries[i], entries[j] = entries[j], entries[i] - }) - winner := entries[rand.Intn(totalTickets-1)] - member, err := cmd.Session.GuildMember(cmd.Interaction.GuildID, winner.UserID) if err != nil { log.Printf("WARN: Could not get winner as member '%s' from guild '%s': %v", cmd.Interaction.GuildID, winner.UserID, err) diff --git a/modules/adventcalendar/midnight.go b/modules/adventcalendar/midnight.go index 1430add..615fec3 100644 --- a/modules/adventcalendar/midnight.go +++ b/modules/adventcalendar/midnight.go @@ -32,7 +32,7 @@ func Midnight(s *discordgo.Session) { } log.Printf("Summary for %s", t.Add(-1*time.Hour).Format("_2. Jan")) - entries := database.GetGetAllGiveawayEntries("xmas") + entries := database.GetAllGiveawayEntries("xmas") slices.SortFunc(entries, func(a, b database.GiveawayEntry) int { if a.Weight < b.Weight { return -1 From 1b990b0787febe68df80d9daf8b778358d0992b0 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Sat, 30 Dec 2023 11:35:45 +0100 Subject: [PATCH 09/26] added comment for Draw GiveawayWinner --- database/giveaway.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/database/giveaway.go b/database/giveaway.go index 9e43aa6..248933b 100644 --- a/database/giveaway.go +++ b/database/giveaway.go @@ -175,9 +175,11 @@ func GetAllGiveawayEntries(prefix string) []GiveawayEntry { return entries } -func DrawGiveawayWinner(a []GiveawayEntry) (winner GiveawayEntry, totalTickets int) { +// DrawGiveawayWinner takes one of the given entries and draw one winner of them. The probability +// is based on their Weight value. A higher Weight means a higher probability. +func DrawGiveawayWinner(e []GiveawayEntry) (winner GiveawayEntry, totalTickets int) { var entries []GiveawayEntry - for _, e := range a { + for _, e := range e { for i := 0; i < e.Weight; i++ { entries = append(entries, e) } From a00a0bab2d7bae0eb194549af05bb1c42b034e23 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Sun, 31 Dec 2023 15:02:22 +0100 Subject: [PATCH 10/26] added giveaway prize system --- database/giveaway.go | 245 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) diff --git a/database/giveaway.go b/database/giveaway.go index 248933b..fff7fa0 100644 --- a/database/giveaway.go +++ b/database/giveaway.go @@ -15,9 +15,13 @@ package database import ( + "bytes" "database/sql" + "encoding/json" "fmt" "math/rand" + "os" + "reflect" "strings" "time" @@ -194,3 +198,244 @@ func DrawGiveawayWinner(e []GiveawayEntry) (winner GiveawayEntry, totalTickets i }) return entries[rand.Intn(totalTickets-1)], totalTickets } + +// GiveawayPrizeType represents the type of a giveaway prize +type GiveawayPrizeType string + +// GiveawayPrizeGroupSort represents the sorting type of a group of prizes +type GiveawayPrizeGroupSort string + +const ( + // A single giveaway prize + GiveawayPrizeTypeSingle GiveawayPrizeType = "single" + // A group contains a pool of giveaway prizes + GiveawayPrizeTypeGroup GiveawayPrizeType = "group" + + // The pool in this group is ordered and prizes should be drawn in ascending order + GiveawayPrizeGroupOrdered GiveawayPrizeGroupSort = "ordered" + // The pool in this group contains a set of prizes that should be drawn in a random order + GiveawayPrizeGroupRandom GiveawayPrizeGroupSort = "random" +) + +// giveawayPrizeInterface is a helper type to un-/marshal a giveaway prize json file +type giveawayPrizeInterface interface { + prizeType() GiveawayPrizeType +} + +// GiveawayPrize represents a general giveaway prize. You can unmarshal to it as well as marshal it +// again. Various functions provide access to modify the pool of prizes. +type GiveawayPrize struct { + giveawayPrizeInterface + filename string +} + +// NewGiveawayPrize reads the file and stores it in a new GiveawayPrize struct. +// +// When modifying something, make sure to call p.SaveFile() to save the changes back to the file. +func NewGiveawayPrize(filename string) (p GiveawayPrize, err error) { + if filename == "" { + return p, fmt.Errorf("argument filename cannot be empty") + } + p.filename = filename + + err = p.ReadFile() + return p, err + +} + +// ReadFile reads the giveaway file from the configured filename and stores it in p +func (p *GiveawayPrize) ReadFile() error { + if p == nil || p.filename == "" { + return fmt.Errorf("cannot read to invalid GiveawayPrize! Make sure to use NewGiveawayPrize()") + } + data, err := os.ReadFile(p.filename) + if err != nil { + return err + } + return json.Unmarshal(data, &p) +} + +// SaveFile saves p in the configured json file +func (p GiveawayPrize) SaveFile() error { + if p.filename == "" { + return fmt.Errorf("cannot save invalid GiveawayPrize! Make sure to use NewGiveawayPrize()") + } + data, err := json.MarshalIndent(p, "", " ") + if err != nil { + return err + } + return os.WriteFile(p.filename, data, 0644) +} + +// HasPrizeAvailable returns whether p has at least one prize without a winner +func (p GiveawayPrize) HasPrizeAvailable() bool { + if p.giveawayPrizeInterface == nil { + return false + } + + switch t := p.giveawayPrizeInterface.(type) { + case *GiveawayPrizeSingle: + return t.Winner == "" + case *GiveawayPrizeGroup: + return t.HasPrizeAvailable() + } + return false +} + +// GetNextPrize returns a pointer to the next giveaway prize. You can make any changes to it but in +// in order to save it back to the file, use p.SaveFile(). +// +// The next prize is determined by the next one which has an empty Winner field. If the prize +// whould be a group which sort is set to random, then one of the prizes in its pool, which has an +// empty Winner filed (or if it is also a group, then when it contains at least one prize without +// a winner), is selected. +// +// When there is no prize available ok will be false. +func (p *GiveawayPrize) GetNextPrize() (*GiveawayPrizeSingle, bool) { + if p == nil || p.giveawayPrizeInterface == nil { + return nil, false + } + + switch t := p.giveawayPrizeInterface.(type) { + case *GiveawayPrizeSingle: + if t.Winner == "" { + return t, true + } + case *GiveawayPrizeGroup: + return t.GetNextPrize() + } + return nil, false +} + +// UnmarshalJSON implements json.Unmarshaler +func (p *GiveawayPrize) UnmarshalJSON(data []byte) error { + var h struct { + Type GiveawayPrizeType `json:"type"` + } + err := json.Unmarshal(data, &h) + if err != nil { + return err + } + + switch h.Type { + case GiveawayPrizeTypeSingle: + var t GiveawayPrizeSingle + err = json.Unmarshal(data, &t) + p.giveawayPrizeInterface = &t + return err + case GiveawayPrizeTypeGroup: + var t GiveawayPrizeGroup + err = json.Unmarshal(data, &t) + p.giveawayPrizeInterface = &t + return err + default: + return &json.UnmarshalTypeError{ + Value: string(h.Type), + Type: reflect.TypeOf(h.Type), + } + } +} + +// MarshalJSON implements json.Marshaler +func (p GiveawayPrize) MarshalJSON() ([]byte, error) { + if p.giveawayPrizeInterface == nil { + return []byte{}, &json.MarshalerError{ + Type: reflect.TypeOf(p), + Err: fmt.Errorf("underlying prize is nil"), + } + } + b := bytes.NewBuffer([]byte{}) + b.WriteByte('{') + const format string = "\"%s\":\"%s\"" + b.WriteString(fmt.Sprintf(format, "type", p.prizeType())) + buf, err := json.Marshal(p.giveawayPrizeInterface) + if err != nil { + return []byte{}, err + } + b.WriteByte(',') + b.Write(buf[1:]) + return b.Bytes(), nil +} + +// GiveawayPrizeSingle represents a single giveaway prize. Its the lowest struct from all giveaway +// structures. +type GiveawayPrizeSingle struct { + // The name of prize + Name string `json:"name"` + + // The identifier of the winner. An empty string means this prize has no winner yet and is + // available. + Winner string `json:"winner,omitempty"` +} + +func (p GiveawayPrizeSingle) prizeType() GiveawayPrizeType { + return GiveawayPrizeTypeSingle +} + +// GiveawayPrizeGroup represents a pool of prizes. The behavior or the order is defined by the Sort +// field. +type GiveawayPrizeGroup struct { + // The order of the pool. Defines in which order to read the prizes in the pool + Sort GiveawayPrizeGroupSort `json:"sort"` + // All prizes that belong to this group + Pool []GiveawayPrize `json:"pool"` +} + +func (pg GiveawayPrizeGroup) prizeType() GiveawayPrizeType { + return GiveawayPrizeTypeGroup +} + +// HasPrizeAvailable returns whether pg contains at least one prize without a winner +func (pg GiveawayPrizeGroup) HasPrizeAvailable() bool { + for _, p := range pg.Pool { + if p.HasPrizeAvailable() { + return true + } + } + return false +} + +// GetNextPrize returns a pointer to the next giveaway prize. You can make any changes to it but in +// in order to save it back to the file, use p.SaveFile(). +// +// The next prize is determined by the next one which has an empty Winner field. If the prize +// whould be a group which sort is set to random, then one of the prizes in its pool, which has an +// empty Winner filed (or if it is also a group, then when it contains at least one prize without +// a winner), is selected. +// +// When there is no prize available ok will be false. +func (pg *GiveawayPrizeGroup) GetNextPrize() (*GiveawayPrizeSingle, bool) { + if pg == nil || len(pg.Pool) == 0 { + return nil, false + } + + switch pg.Sort { + case GiveawayPrizeGroupOrdered: + for i, p := range pg.Pool { + if s, ok := p.GetNextPrize(); ok { + pg.Pool[i] = p + return s, true + } + } + case GiveawayPrizeGroupRandom: + var available []int + for i, p := range pg.Pool { + if p.HasPrizeAvailable() { + available = append(available, i) + } + } + + switch len(available) { + case 0: + case 1: + return pg.Pool[available[0]].GetNextPrize() + default: + rand.Shuffle(len(available), func(i, j int) { + available[i], available[j] = available[j], available[i] + }) + i := available[rand.Intn(len(available)-1)] + return pg.Pool[i].GetNextPrize() + } + } + return nil, false +} From 58345047deab194a7f5cb2efc5bf7fb54dbc4c8d Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Sun, 31 Dec 2023 20:23:03 +0100 Subject: [PATCH 11/26] fix linting --- database/giveaway.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/database/giveaway.go b/database/giveaway.go index fff7fa0..d4159ad 100644 --- a/database/giveaway.go +++ b/database/giveaway.go @@ -206,14 +206,16 @@ type GiveawayPrizeType string type GiveawayPrizeGroupSort string const ( - // A single giveaway prize + // GiveawayPrizeTypeSingle describes a single giveaway prize GiveawayPrizeTypeSingle GiveawayPrizeType = "single" - // A group contains a pool of giveaway prizes + // GiveawayPrizeTypeGroup describes a group containing a pool of giveaway prizes GiveawayPrizeTypeGroup GiveawayPrizeType = "group" - // The pool in this group is ordered and prizes should be drawn in ascending order + // GiveawayPrizeGroupOrdered defines that the pool in this group is ordered and prizes should be + // drawn in ascending order GiveawayPrizeGroupOrdered GiveawayPrizeGroupSort = "ordered" - // The pool in this group contains a set of prizes that should be drawn in a random order + // GiveawayPrizeGroupRandom defines that the pool in this group contains a set of prizes that + // should be drawn in a random order GiveawayPrizeGroupRandom GiveawayPrizeGroupSort = "random" ) From 2619e1e778bf07012a9cf4c73cb42493ad433211 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Mon, 1 Jan 2024 01:59:59 +0100 Subject: [PATCH 12/26] added twitch draw command --- config.yaml | 2 ++ data/lang/de.yaml | 7 +++++- data/lang/en.yaml | 7 +++++- database/giveaway.go | 18 ++++++++++++++ main.go | 1 + twitch/messageHandler.go | 51 +++++++++++++++++++++++++++++++++++++++- 6 files changed, 83 insertions(+), 3 deletions(-) diff --git a/config.yaml b/config.yaml index 97d3711..678483f 100644 --- a/config.yaml +++ b/config.yaml @@ -43,6 +43,8 @@ event: twitch_giveaway: # The amount of points a single giveaway ticket costs. ticket_cost: 1000 + # the filepath for of the json giveaway prizes + prizes: twitch/prizes.json webserver: favicon: webserver/favicon.png diff --git a/data/lang/de.yaml b/data/lang/de.yaml index 55a5bff..dec61ae 100644 --- a/data/lang/de.yaml +++ b/data/lang/de.yaml @@ -147,4 +147,9 @@ twitch.command: join: msg.too_few_points: "@%s du nicht genügend Punkte(%d)! Du brauchst noch %d mehr um den Preis von %d zu bezahlen." - msg.success: "@%s Du hast dir erfolgreich ein Ticket für %d Punkte gekauft. Du hast nun %d Tickets und noch %d Punkte über." + msg.success: "@%s du hast dir erfolgreich ein Ticket für %d Punkte gekauft. Du hast nun %d Tickets und noch %d Punkte über." + + draw: + msg.no_prizes: "@%s es gibt momentan keine Preise zu gewinnen. Du kannst diesen Befehl momentan nicht ausführen." + msg.no_entries: "@%s es gibt momentan keine Einträge und somit kann kein Gewinner gezogen werden." + msg.winner: Glückwunsch! @%s hat %s gewonnen. Du hattest %d/10 Tickets und eine Gewinnchance von %.2f%%. diff --git a/data/lang/en.yaml b/data/lang/en.yaml index 91792e5..bfe2615 100644 --- a/data/lang/en.yaml +++ b/data/lang/en.yaml @@ -147,4 +147,9 @@ twitch.command: join: msg.too_few_points: "@%s you don't have enough points (%d)! You need %d more to pay the costs of %d points." - msg.success: "@%s You successfully bought a ticket for %d points. Now you have %d tickets and still %d points." + msg.success: "@%s you successfully bought a ticket for %d points. Now you have %d tickets and still %d points." + + draw: + msg.no_prizes: "@%s There're currently no prizes available. You can't perfrom this command now." + msg.no_entries: "@%s There're currently no entries and therefore no winner can be drawn." + msg.winner: Congratulations! @%s won %s. You had %d/10 tickets and a win probability of %.2f%%. diff --git a/database/giveaway.go b/database/giveaway.go index d4159ad..1ba60f0 100644 --- a/database/giveaway.go +++ b/database/giveaway.go @@ -90,6 +90,19 @@ func GetGiveawayEntry(prefix, userID string) GiveawayEntry { return GiveawayEntry{userID, weight, lastEntry} } +// DeleteGiveawayEntry deletes the giveaway entry for the given user identifier from the database. +// +// If an error occours it will be returned. However if no datbase entry matched it returns err == +// nil, not err == sql.ErrNoRows. Because sql.ErrNoRows also results in the non-existence of the +// requested row and therefore is treated as a successful call. +func DeleteGiveawayEntry(userID string) error { + _, err := Exec("DELETE FROM giveaway WHERE id=?", userID) + if err == sql.ErrNoRows { + return nil + } + return err +} + // AddGiveawayWeight adds amount to the given user identifier. // // However if their last entry wasn't prefixed with prefix, their weight will be resetted and starts @@ -182,6 +195,11 @@ func GetAllGiveawayEntries(prefix string) []GiveawayEntry { // DrawGiveawayWinner takes one of the given entries and draw one winner of them. The probability // is based on their Weight value. A higher Weight means a higher probability. func DrawGiveawayWinner(e []GiveawayEntry) (winner GiveawayEntry, totalTickets int) { + // skip randomizing when there is only one entry + if len(e) == 1 { + return e[0], e[0].Weight + } + var entries []GiveawayEntry for _, e := range e { for i := 0; i < e.Weight; i++ { diff --git a/main.go b/main.go index 4b716e4..ea2921f 100644 --- a/main.go +++ b/main.go @@ -95,6 +95,7 @@ func main() { client := twitchgo.New(viper.GetString("twitch.name"), viper.GetString("twitch.token")) client.OnChannelCommandMessage("join", twitch.HandleCmdJoin) + client.OnChannelCommandMessage("draw", twitch.HandleCmdDraw) client.OnChannelMessage(twitch.MessageHandler) err = client.Connect() if err != nil { diff --git a/twitch/messageHandler.go b/twitch/messageHandler.go index 07b8355..e2ce492 100644 --- a/twitch/messageHandler.go +++ b/twitch/messageHandler.go @@ -25,7 +25,7 @@ import ( "github.com/spf13/viper" ) -const tp string = "twitch.command.join." +const tp string = "twitch.command." var log logger.Logger = *logger.New(logger.Writer(), "[Twitch] ", logger.LstdFlags|logger.Lmsgprefix) var se *streamelements.Streamelements @@ -40,6 +40,8 @@ func MessageHandler(t *twitchgo.Twitch, channel string, user *twitchgo.User, mes // and removes the configured cost amount for a ticket. func HandleCmdJoin(t *twitchgo.Twitch, channel string, user *twitchgo.User, args []string) { channel, _ = strings.CutPrefix(channel, "#") + const tp = tp + "join." + log.Printf("[%s@%s] executed join command with %d args: %v", user.Nickname, channel, len(args), args) seChannel, err := se.GetChannel(channel) if err != nil { @@ -73,3 +75,50 @@ func HandleCmdJoin(t *twitchgo.Twitch, channel string, user *twitchgo.User, args } t.SendMessagef(channel, lang.GetDefault(tp+"msg.success"), user.Nickname, joinCost, entry.Weight, sePoints.Points-joinCost) } + +// HandleCmdDraw is the handler for the draw command in a twitch chat. This handler selects a random +// winner and removes their tickets. +func HandleCmdDraw(t *twitchgo.Twitch, channel string, user *twitchgo.User, args []string) { + channel, _ = strings.CutPrefix(channel, "#") + const tp = tp + "draw." + + //only accept broadcaster + if channel != user.Nickname { + return + } + + p, err := database.NewGiveawayPrize(viper.GetString("event.twitch_giveaway.prizes")) + if err != nil { + log.Printf("Error reading prizes file: %v", err) + t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + prize, ok := p.GetNextPrize() + if !ok { + t.SendMessagef(channel, lang.GetDefault(tp+"msg.no_prizes"), user.Nickname) + return + } + + winner, totalTickets := database.DrawGiveawayWinner(database.GetAllGiveawayEntries("tw11")) + if totalTickets == 0 { + t.SendMessagef(channel, lang.GetDefault(tp+"msg.no_entries"), user.Nickname) + return + } + + t.SendMessagef(channel, lang.GetDefault(tp+"msg.winner"), winner.UserID, prize.Name, winner.Weight, float64(winner.Weight*100)/float64(totalTickets)) + + err = database.DeleteGiveawayEntry(winner.UserID) + if err != nil { + log.Printf("Error deleting database giveaway entry: %v", err) + t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + + prize.Winner = winner.UserID + err = p.SaveFile() + if err != nil { + log.Printf("Error saving prizes file: %v", err) + t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) + return + } +} From 91ba8a80cedfc886884c730255cd2b5e0dc18f32 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Mon, 1 Jan 2024 07:58:18 +0100 Subject: [PATCH 13/26] added twitch tickets command --- data/lang/de.yaml | 13 ++++++++ data/lang/en.yaml | 13 ++++++++ main.go | 1 + twitch/messageHandler.go | 70 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+) diff --git a/data/lang/de.yaml b/data/lang/de.yaml index dec61ae..ff43729 100644 --- a/data/lang/de.yaml +++ b/data/lang/de.yaml @@ -149,6 +149,19 @@ twitch.command: msg.too_few_points: "@%s du nicht genügend Punkte(%d)! Du brauchst noch %d mehr um den Preis von %d zu bezahlen." msg.success: "@%s du hast dir erfolgreich ein Ticket für %d Punkte gekauft. Du hast nun %d Tickets und noch %d Punkte über." + tickets: + msg.won: "@%s, du hast schon etwas gewonnen und kannst keine Tickets mehr besitzen." + msg.won.user: "@%s %s hat schon etwas gewonnen und kann keine Tickets mehr besitzen." + msg.max_tickets: "@%s, du hast alle 10/10 Tickets gekauft." + msg.max_tickets.user: "@%s, %s hat alle 10/10 Tickets gekauft." + msg.num.0: "@%s, du hast noch keine Tickets." + msg.num.0.user: "@%s, %s hat noch keine Tickets." + msg.num: "@%s, du hast %d/10 Tickets." + msg.num.user: "@%s, %s hat %d/10 Tickets." + msg.extra.need_points: Für ein weiteres Ticket brauchst du noch %d Punkte. + msg.extra.can_buy: Du kannst dir ein weiteres Ticket mit !ticket kaufen. + msg.extra.cooldown: Momentan bist du aber noch %s im Cooldown, bevor du den Ticket-Befehl benutzen kannst. + draw: msg.no_prizes: "@%s es gibt momentan keine Preise zu gewinnen. Du kannst diesen Befehl momentan nicht ausführen." msg.no_entries: "@%s es gibt momentan keine Einträge und somit kann kein Gewinner gezogen werden." diff --git a/data/lang/en.yaml b/data/lang/en.yaml index bfe2615..72617e4 100644 --- a/data/lang/en.yaml +++ b/data/lang/en.yaml @@ -149,6 +149,19 @@ twitch.command: msg.too_few_points: "@%s you don't have enough points (%d)! You need %d more to pay the costs of %d points." msg.success: "@%s you successfully bought a ticket for %d points. Now you have %d tickets and still %d points." + tickets: + msg.won: "@%s, you already won something and can no longer own tickets." + msg.won.user: "@%s %s already won something and can no longer own tickets." + msg.max_tickets: "@%s, you bought all 10/10 tickets." + msg.max_tickets.user: "@%s, %s bought all 10/10 tickets." + msg.num.0: "@%s, you don't have any tickets yet." + msg.num.0.user: "@%s, %s doesn't have any tickets yet." + msg.num: "@%s, you have %d/10 tickets." + msg.num.user: "@%s, %s has %d/10 tickets." + msg.extra.need_points: For your next ticket, you'll need %d points more. + msg.extra.can_buy: You can buy a ticket with !ticket. + msg.extra.cooldown: But right now you're still %s in cooldown, before you can use the ticket command. + draw: msg.no_prizes: "@%s There're currently no prizes available. You can't perfrom this command now." msg.no_entries: "@%s There're currently no entries and therefore no winner can be drawn." diff --git a/main.go b/main.go index ea2921f..9bb828b 100644 --- a/main.go +++ b/main.go @@ -95,6 +95,7 @@ func main() { client := twitchgo.New(viper.GetString("twitch.name"), viper.GetString("twitch.token")) client.OnChannelCommandMessage("join", twitch.HandleCmdJoin) + client.OnChannelCommandMessage("tickets", twitch.HandleCmdTickets) client.OnChannelCommandMessage("draw", twitch.HandleCmdDraw) client.OnChannelMessage(twitch.MessageHandler) err = client.Connect() diff --git a/twitch/messageHandler.go b/twitch/messageHandler.go index e2ce492..679de5d 100644 --- a/twitch/messageHandler.go +++ b/twitch/messageHandler.go @@ -18,8 +18,10 @@ import ( "cake4everybot/data/lang" "cake4everybot/database" "cake4everybot/tools/streamelements" + "fmt" logger "log" "strings" + "time" "github.com/kesuaheli/twitchgo" "github.com/spf13/viper" @@ -76,6 +78,74 @@ func HandleCmdJoin(t *twitchgo.Twitch, channel string, user *twitchgo.User, args t.SendMessagef(channel, lang.GetDefault(tp+"msg.success"), user.Nickname, joinCost, entry.Weight, sePoints.Points-joinCost) } +// HandleCmdTickets is the handler for the tickets command in a twitch chat. This handler simply +// prints the users amount of tickets +func HandleCmdTickets(t *twitchgo.Twitch, channel string, source *twitchgo.User, args []string) { + channel, _ = strings.CutPrefix(channel, "#") + const tp = tp + "tickets." + + var userID string = source.Nickname + if len(args) >= 1 { + if s, _ := strings.CutPrefix(args[0], "@"); s != "" { + userID = strings.ToLower(s) + } + } + + // TODO: check if 'userID' is already a winner + + entry := database.GetGiveawayEntry("tw11", userID) + if entry.Weight >= 10 { + if source.Nickname == userID { + t.SendMessagef(channel, lang.GetDefault(tp+"msg.max_tickets"), source.Nickname) + } else { + t.SendMessagef(channel, lang.GetDefault(tp+"msg.max_tickets.user"), source.Nickname, userID) + } + return + } + if source.Nickname == userID { + if entry.Weight == 0 { + t.SendMessagef(channel, lang.GetDefault(tp+"msg.num.0.user"), source.Nickname, userID) + } else { + t.SendMessagef(channel, lang.GetDefault(tp+"msg.num.user"), source.Nickname, userID, entry.Weight) + } + return + } + var msg string + if entry.Weight == 0 { + msg = fmt.Sprintf(lang.GetDefault(tp+"msg.num.0"), source.Nickname) + } else { + msg = fmt.Sprintf(lang.GetDefault(tp+"msg.num"), source.Nickname, entry.Weight) + } + + var curPoints int + seChannel, err := se.GetChannel(channel) + if err != nil { + log.Printf("Error on getting SE channel: %v", err) + goto skipPoints + } + if sePoints, err := se.GetPoints(seChannel.ID, userID); err == nil { + log.Printf("Error on getting SE points: %v", err) + goto skipPoints + } else { + curPoints = sePoints.Points + } + + if joinCost := viper.GetInt("event.twitch_giveaway.ticket_cost"); joinCost > curPoints { + msg += " " + fmt.Sprintf(lang.GetDefault(tp+"msg.extra.need_points"), source.Nickname, joinCost-curPoints) + } else { + msg += " " + fmt.Sprintf(lang.GetDefault(tp+"msg.extra.can_buy"), source.Nickname) + } +skipPoints: + + // TODO: get cooldown of 'userID' + cooldown := time.Duration(0) + if cooldown > 3*time.Second { + msg += " " + fmt.Sprintf(lang.GetDefault(tp+"msg.extra.cooldown"), cooldown.Truncate(time.Second).String()) + } + + t.SendMessage(channel, msg) +} + // HandleCmdDraw is the handler for the draw command in a twitch chat. This handler selects a random // winner and removes their tickets. func HandleCmdDraw(t *twitchgo.Twitch, channel string, user *twitchgo.User, args []string) { From d679fa7d9ff3e5f22c3badaf8db5a477b1ca329b Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Mon, 1 Jan 2024 11:26:34 +0100 Subject: [PATCH 14/26] added cooldown to twitch commands --- config.yaml | 4 + data/lang/de.yaml | 11 ++ data/lang/en.yaml | 2 +- data/lang/lang.go | 36 +++++- database/giveaway.go | 16 ++- modules/birthday/birthdaybase.go | 2 +- modules/birthday/handlerSubcommandList.go | 2 +- modules/birthday/handlerSubcommandSet.go | 4 +- twitch/messageHandler.go | 128 ++++++++++++++++++++-- 9 files changed, 182 insertions(+), 23 deletions(-) diff --git a/config.yaml b/config.yaml index 678483f..373f64f 100644 --- a/config.yaml +++ b/config.yaml @@ -43,8 +43,12 @@ event: twitch_giveaway: # The amount of points a single giveaway ticket costs. ticket_cost: 1000 + # Cooldown in minutes before beeing able to by another ticket + cooldown: 15 # the filepath for of the json giveaway prizes prizes: twitch/prizes.json + # the filepath for storing the giveaway cooldown times + times: twitch/times.json webserver: favicon: webserver/favicon.png diff --git a/data/lang/de.yaml b/data/lang/de.yaml index ff43729..d1d1ce5 100644 --- a/data/lang/de.yaml +++ b/data/lang/de.yaml @@ -146,6 +146,17 @@ twitch.command: error: Upsi, da ist was schief gelaufen! 🙃 @Kesuaheli Hilfe! join: + msg.no_prizes: "@%s es gibt momentan keine Preise zu gewinnen. Du kannst diesen Befehl momentan nicht ausführen." + msg.won: "@%s, du hast schon etwas gewonnen und kannst keine Tickets mehr kaufen." + msg.max_tickets: "@%s, du hast bereits schon alle 10/10 Tickets gekauft. Lass anderen auch eine Chance ;)" + msg.cooldown: + - "@%s, du musst noch %s warten um dir ein weiteres Ticket kaufen zu können." + - "@%s, du bist mir zu schnell. Warte noch so %s um wieder eins zu kaufen." + - "@%s Ein Ticket kannst du zwar erst in %s kaufen, aber den Stream kannst du in der Zwischenzeit durchgehend schauen ;)" + - Schon wieder, @%s? Du hattest doch erst ein Ticket gekauft. Warte noch %s. + - "@%s Ein weiteres Ticket ist in Arbeit... du kannst es dir in %s abholen." + - "@%s Beep Boop 🤖 Dein Ticket wird gedruckt. Vorraussichtliche Druckzeit: noch %s verbleibend" + - "@%s, damit du mehr vom Stream genießen kannst, kannst du erst in %s wieder ein Ticket kaufen." msg.too_few_points: "@%s du nicht genügend Punkte(%d)! Du brauchst noch %d mehr um den Preis von %d zu bezahlen." msg.success: "@%s du hast dir erfolgreich ein Ticket für %d Punkte gekauft. Du hast nun %d Tickets und noch %d Punkte über." diff --git a/data/lang/en.yaml b/data/lang/en.yaml index 72617e4..bab7587 100644 --- a/data/lang/en.yaml +++ b/data/lang/en.yaml @@ -147,7 +147,7 @@ twitch.command: join: msg.too_few_points: "@%s you don't have enough points (%d)! You need %d more to pay the costs of %d points." - msg.success: "@%s you successfully bought a ticket for %d points. Now you have %d tickets and still %d points." + msg.success: "@%s you successfully bought a ticket for %d points. Now you have %d tickets and %d points left." tickets: msg.won: "@%s, you already won something and can no longer own tickets." diff --git a/data/lang/lang.go b/data/lang/lang.go index b181fdd..5998040 100644 --- a/data/lang/lang.go +++ b/data/lang/lang.go @@ -167,7 +167,7 @@ func GetDefault(key string) string { return Get(key, FallbackLang()) } -// GetSlice returns the configured translation for index i in the +// GetSliceElement returns the configured translation for index i in the // list at key in the given language lang. // - If lang is not a loaded language, Get translates key with the // fallback language. @@ -180,7 +180,7 @@ func GetDefault(key string) string { // // In all four of these 'fail cases', Get will print a warning // message in the log -func GetSlice(key string, i int, lang string) string { +func GetSliceElement(key string, i int, lang string) string { if len(langsMap) == 0 { log.Println() log.Printf("ERROR: Tried to get translation, but no language loaded\n") @@ -200,7 +200,7 @@ func GetSlice(key string, i int, lang string) string { return key } log.Printf("WARNING: language '%s' is not loaded, using '%s' as fallback instead\n", lang, fLang) - return Get(key, fLang) + return GetSliceElement(key, i, fLang) } s := v.GetStringSlice(key) @@ -218,7 +218,35 @@ func GetSlice(key string, i int, lang string) string { return key } log.Printf("WARNING: key '%s' is not defined in language '%s', using '%s' as fallback instead\n", key, lang, fLang) - return Get(key, fLang) + return GetSliceElement(key, i, fLang) +} + +// GetSlice is similar sto GetSliceElement, but instead returns the hole sting slice without any +// checks. +func GetSlice(key string, lang string) []string { + if len(langsMap) == 0 { + log.Println() + log.Printf("ERROR: Tried to get translation, but no language loaded\n") + log.Println() + return []string{key} + } + + lang = Unify(lang) + + v, ok := langsMap[lang] + fLang := FallbackLang() + if !ok { + if lang == fLang { + log.Println() + log.Printf("ERROR: Tried to get key from fallback language ('%s'), but its not load\n", fLang) + log.Println() + return []string{key} + } + log.Printf("WARNING: language '%s' is not loaded, using '%s' as fallback instead\n", lang, fLang) + return GetSlice(key, fLang) + } + + return v.GetStringSlice(key) } // GetLangs returns all loaded languages diff --git a/database/giveaway.go b/database/giveaway.go index 1ba60f0..ecf3ffb 100644 --- a/database/giveaway.go +++ b/database/giveaway.go @@ -289,15 +289,20 @@ func (p GiveawayPrize) SaveFile() error { // HasPrizeAvailable returns whether p has at least one prize without a winner func (p GiveawayPrize) HasPrizeAvailable() bool { + return p.HasPrizeWon("") +} + +// HasPrizeWon returns whether p has at least one prize where the winner is the same as userID +func (p GiveawayPrize) HasPrizeWon(userID string) bool { if p.giveawayPrizeInterface == nil { return false } switch t := p.giveawayPrizeInterface.(type) { case *GiveawayPrizeSingle: - return t.Winner == "" + return t.Winner == userID case *GiveawayPrizeGroup: - return t.HasPrizeAvailable() + return t.HasPrizeWon(userID) } return false } @@ -407,8 +412,13 @@ func (pg GiveawayPrizeGroup) prizeType() GiveawayPrizeType { // HasPrizeAvailable returns whether pg contains at least one prize without a winner func (pg GiveawayPrizeGroup) HasPrizeAvailable() bool { + return pg.HasPrizeWon("") +} + +// HasPrizeWon returns whether pg contains at least one prize where the winner is the same as userID +func (pg GiveawayPrizeGroup) HasPrizeWon(userID string) bool { for _, p := range pg.Pool { - if p.HasPrizeAvailable() { + if p.HasPrizeWon(userID) { return true } } diff --git a/modules/birthday/birthdaybase.go b/modules/birthday/birthdaybase.go index bdea91f..db95b0f 100644 --- a/modules/birthday/birthdaybase.go +++ b/modules/birthday/birthdaybase.go @@ -51,7 +51,7 @@ type birthdayEntry struct { // Returns a readable Form of the date func (b birthdayEntry) String() string { if b.Year == 0 { - month := lang.GetSlice(tp+"month", b.Month-1, lang.FallbackLang()) + month := lang.GetSliceElement(tp+"month", b.Month-1, lang.FallbackLang()) return fmt.Sprintf("%d. %s", b.Day, month) } return fmt.Sprintf("", b.time.Unix()) diff --git a/modules/birthday/handlerSubcommandList.go b/modules/birthday/handlerSubcommandList.go index 1e715b0..230b264 100644 --- a/modules/birthday/handlerSubcommandList.go +++ b/modules/birthday/handlerSubcommandList.go @@ -57,7 +57,7 @@ func (cmd subcommandList) handler() { return } - monthName := lang.GetSlice(tp+"month", month-1, lang.FallbackLang()) + monthName := lang.GetSliceElement(tp+"month", month-1, lang.FallbackLang()) var ( header, key, value string a []any diff --git a/modules/birthday/handlerSubcommandSet.go b/modules/birthday/handlerSubcommandSet.go index a62696a..f40b2a7 100644 --- a/modules/birthday/handlerSubcommandSet.go +++ b/modules/birthday/handlerSubcommandSet.go @@ -253,8 +253,8 @@ func (cmd subcommandSet) handleUpdate(b birthdayEntry, e *discordgo.MessageEmbed // set field when only month is changed case MONTH: f.Name = lang.Get(tp+"msg.set.update.month", lang.FallbackLang()) - mNameBefore := lang.GetSlice(tp+"month", before.Month-1, lang.FallbackLang()) - mName := lang.GetSlice(tp+"month", b.Month-1, lang.FallbackLang()) + mNameBefore := lang.GetSliceElement(tp+"month", before.Month-1, lang.FallbackLang()) + mName := lang.GetSliceElement(tp+"month", b.Month-1, lang.FallbackLang()) f.Value = fmt.Sprintf("%s -> %s", mNameBefore, mName) // set field when only year is changed case YEAR: diff --git a/twitch/messageHandler.go b/twitch/messageHandler.go index 679de5d..eff5444 100644 --- a/twitch/messageHandler.go +++ b/twitch/messageHandler.go @@ -18,8 +18,11 @@ import ( "cake4everybot/data/lang" "cake4everybot/database" "cake4everybot/tools/streamelements" + "encoding/json" "fmt" logger "log" + "math/rand" + "os" "strings" "time" @@ -44,7 +47,79 @@ func HandleCmdJoin(t *twitchgo.Twitch, channel string, user *twitchgo.User, args channel, _ = strings.CutPrefix(channel, "#") const tp = tp + "join." - log.Printf("[%s@%s] executed join command with %d args: %v", user.Nickname, channel, len(args), args) + p, err := database.NewGiveawayPrize(viper.GetString("event.twitch_giveaway.prizes")) + if err != nil { + log.Printf("Error reading prizes file: %v", err) + t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + if !p.HasPrizeAvailable() { + t.SendMessagef(channel, lang.GetDefault(tp+"msg.no_prizes"), user.Nickname) + return + } + if p.HasPrizeWon(user.Nickname) { + t.SendMessagef(channel, lang.GetDefault(tp+"msg.won"), user.Nickname) + return + } + entry := database.GetGiveawayEntry("tw11", user.Nickname) + if entry.UserID == "" { + log.Printf("Error getting database giveaway entry: %v", err) + t.SendMessage(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + if entry.Weight >= 10 { + t.SendMessagef(channel, lang.GetDefault(tp+"msg.max_tickets"), user.Nickname) + return + } + + data, err := os.ReadFile(viper.GetString("event.twitch_giveaway.times")) + if os.IsNotExist(err) { + data = []byte("{}") + } else if err != nil { + log.Printf("Error reading times file: %v", err) + t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + var times = map[string]time.Time{} + err = json.Unmarshal(data, ×) + if err != nil { + log.Printf("Error parsing times file: %v", err) + t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + + m := viper.GetDuration("event.twitch_giveaway.cooldown") + next := times[user.Nickname].Add(m * time.Minute) + cooldown := time.Until(next).Round(time.Second) + + if cooldown > time.Second { + msgs := lang.GetSlice(tp+"msg.cooldown", lang.FallbackLang()) + var i int + if len(msgs) >= 2 { + rand.Shuffle(len(msgs), func(i, j int) { + msgs[i], msgs[j] = msgs[j], msgs[i] + }) + i = rand.Intn(len(msgs) - 1) + } + t.SendMessagef(channel, msgs[i], user.Nickname, cooldown.String()) + return + } + + times[user.Nickname] = time.Now() + data, err = json.Marshal(times) + if err != nil { + log.Printf("Error marshaling times file: %v", err) + t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + + err = os.WriteFile(viper.GetString("event.twitch_giveaway.times"), data, 0644) + if err != nil { + log.Printf("Error writing times file: %v", err) + t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + seChannel, err := se.GetChannel(channel) if err != nil { log.Printf("Error getting streamelements channel '%s': %v", channel, err) @@ -63,9 +138,9 @@ func HandleCmdJoin(t *twitchgo.Twitch, channel string, user *twitchgo.User, args t.SendMessagef(channel, lang.GetDefault(tp+"msg.too_few_points"), user.Nickname, sePoints.Points, joinCost-sePoints.Points, joinCost) return } - entry := database.AddGiveawayWeight("tw11", user.Nickname, 1) + entry = database.AddGiveawayWeight("tw11", user.Nickname, 1) if entry.UserID == "" { - log.Println("Error getting database giveaway entry", seChannel.ID, channel, user.Nickname, err) + log.Printf("Error getting database giveaway entry: %v", err) t.SendMessage(channel, lang.GetDefault("twitch.command.generic.error")) return } @@ -91,7 +166,20 @@ func HandleCmdTickets(t *twitchgo.Twitch, channel string, source *twitchgo.User, } } - // TODO: check if 'userID' is already a winner + p, err := database.NewGiveawayPrize(viper.GetString("event.twitch_giveaway.prizes")) + if err != nil { + log.Printf("Error reading prizes file: %v", err) + t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + if p.HasPrizeWon(userID) { + if source.Nickname == userID { + t.SendMessagef(channel, lang.GetDefault(tp+"msg.won"), source.Nickname) + } else { + t.SendMessagef(channel, lang.GetDefault(tp+"msg.won.user"), source.Nickname, userID) + } + return + } entry := database.GetGiveawayEntry("tw11", userID) if entry.Weight >= 10 { @@ -102,7 +190,7 @@ func HandleCmdTickets(t *twitchgo.Twitch, channel string, source *twitchgo.User, } return } - if source.Nickname == userID { + if source.Nickname != userID { if entry.Weight == 0 { t.SendMessagef(channel, lang.GetDefault(tp+"msg.num.0.user"), source.Nickname, userID) } else { @@ -123,7 +211,7 @@ func HandleCmdTickets(t *twitchgo.Twitch, channel string, source *twitchgo.User, log.Printf("Error on getting SE channel: %v", err) goto skipPoints } - if sePoints, err := se.GetPoints(seChannel.ID, userID); err == nil { + if sePoints, err := se.GetPoints(seChannel.ID, userID); err != nil { log.Printf("Error on getting SE points: %v", err) goto skipPoints } else { @@ -131,16 +219,34 @@ func HandleCmdTickets(t *twitchgo.Twitch, channel string, source *twitchgo.User, } if joinCost := viper.GetInt("event.twitch_giveaway.ticket_cost"); joinCost > curPoints { - msg += " " + fmt.Sprintf(lang.GetDefault(tp+"msg.extra.need_points"), source.Nickname, joinCost-curPoints) + msg += " " + fmt.Sprintf(lang.GetDefault(tp+"msg.extra.need_points"), joinCost-curPoints) } else { - msg += " " + fmt.Sprintf(lang.GetDefault(tp+"msg.extra.can_buy"), source.Nickname) + msg += " " + lang.GetDefault(tp+"msg.extra.can_buy") } skipPoints: - // TODO: get cooldown of 'userID' - cooldown := time.Duration(0) + data, err := os.ReadFile(viper.GetString("event.twitch_giveaway.times")) + if os.IsNotExist(err) { + data = []byte("{}") + } else if err != nil { + log.Printf("Error reading times file: %v", err) + t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + var times = map[string]time.Time{} + err = json.Unmarshal(data, ×) + if err != nil { + log.Printf("Error parsing times file: %v", err) + t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + + m := viper.GetDuration("event.twitch_giveaway.cooldown") + next := times[userID].Add(m * time.Minute) + cooldown := time.Until(next).Round(time.Second) + if cooldown > 3*time.Second { - msg += " " + fmt.Sprintf(lang.GetDefault(tp+"msg.extra.cooldown"), cooldown.Truncate(time.Second).String()) + msg += " " + fmt.Sprintf(lang.GetDefault(tp+"msg.extra.cooldown"), cooldown.String()) } t.SendMessage(channel, msg) From 4337f5fbda5eb3dcea400caeeaf5cb0061b24838 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Mon, 1 Jan 2024 11:59:48 +0100 Subject: [PATCH 15/26] only update time when user has enough points --- twitch/messageHandler.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/twitch/messageHandler.go b/twitch/messageHandler.go index eff5444..f6adeab 100644 --- a/twitch/messageHandler.go +++ b/twitch/messageHandler.go @@ -105,21 +105,6 @@ func HandleCmdJoin(t *twitchgo.Twitch, channel string, user *twitchgo.User, args return } - times[user.Nickname] = time.Now() - data, err = json.Marshal(times) - if err != nil { - log.Printf("Error marshaling times file: %v", err) - t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) - return - } - - err = os.WriteFile(viper.GetString("event.twitch_giveaway.times"), data, 0644) - if err != nil { - log.Printf("Error writing times file: %v", err) - t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) - return - } - seChannel, err := se.GetChannel(channel) if err != nil { log.Printf("Error getting streamelements channel '%s': %v", channel, err) @@ -144,6 +129,21 @@ func HandleCmdJoin(t *twitchgo.Twitch, channel string, user *twitchgo.User, args t.SendMessage(channel, lang.GetDefault("twitch.command.generic.error")) return } + + times[user.Nickname] = time.Now() + data, err = json.Marshal(times) + if err != nil { + log.Printf("Error marshaling times file: %v", err) + t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + err = os.WriteFile(viper.GetString("event.twitch_giveaway.times"), data, 0644) + if err != nil { + log.Printf("Error writing times file: %v", err) + t.SendMessagef(channel, lang.GetDefault("twitch.command.generic.error")) + return + } + err = se.AddPoints(seChannel.ID, user.Nickname, -joinCost) if err != nil { log.Printf("Error adding points for '%s(%s)/%s/-%d': %v", seChannel.ID, channel, user.Nickname, joinCost, err) From 898adb03aa6e984ec0ff8ea788b0bc836b58f823 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Sun, 7 Jan 2024 13:36:44 +0100 Subject: [PATCH 16/26] fixed type --- data/lang/de.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/lang/de.yaml b/data/lang/de.yaml index d1d1ce5..3b268b5 100644 --- a/data/lang/de.yaml +++ b/data/lang/de.yaml @@ -157,7 +157,7 @@ twitch.command: - "@%s Ein weiteres Ticket ist in Arbeit... du kannst es dir in %s abholen." - "@%s Beep Boop 🤖 Dein Ticket wird gedruckt. Vorraussichtliche Druckzeit: noch %s verbleibend" - "@%s, damit du mehr vom Stream genießen kannst, kannst du erst in %s wieder ein Ticket kaufen." - msg.too_few_points: "@%s du nicht genügend Punkte(%d)! Du brauchst noch %d mehr um den Preis von %d zu bezahlen." + msg.too_few_points: "@%s du nicht genügend Punkte (%d)! Du brauchst noch %d mehr um den Preis von %d zu bezahlen." msg.success: "@%s du hast dir erfolgreich ein Ticket für %d Punkte gekauft. Du hast nun %d Tickets und noch %d Punkte über." tickets: From 868fe30692e21c94edf17b0a62d73ce6990a298a Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Sun, 7 Jan 2024 13:38:06 +0100 Subject: [PATCH 17/26] ignore vscode settings --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6e0177d..9bc3a8f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# IDE +.vscode/ + # Binaries for programs and plugins *.exe *.exe~ From abc97baf7d7465e30d93b3c2048edfa9f6eae4cb Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Sun, 7 Jan 2024 22:31:26 +0100 Subject: [PATCH 18/26] added twitch api webhook --- webserver/main.go | 2 ++ webserver/twitch/api.go | 66 ++++++++++++++++++++++++++++++++++++++ webserver/twitch/global.go | 22 +++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 webserver/twitch/api.go create mode 100644 webserver/twitch/global.go diff --git a/webserver/main.go b/webserver/main.go index 2dc4078..d8d193e 100644 --- a/webserver/main.go +++ b/webserver/main.go @@ -15,6 +15,7 @@ package webserver import ( + "cake4everybot/webserver/twitch" "cake4everybot/webserver/youtube" logger "log" "net/http" @@ -33,6 +34,7 @@ func initHTTP() http.Handler { r.NotFoundHandler = http.HandlerFunc(handle404) r.HandleFunc("/favicon.ico", favicon) + r.HandleFunc("/api/twitch_pubsub", twitch.HandlePost).Methods(http.MethodPost) r.HandleFunc("/api/yt_pubsubhubbub/", youtube.HandleGet).Methods("GET") r.HandleFunc("/api/yt_pubsubhubbub/", youtube.HandlePost).Methods("POST") diff --git a/webserver/twitch/api.go b/webserver/twitch/api.go new file mode 100644 index 0000000..52bba69 --- /dev/null +++ b/webserver/twitch/api.go @@ -0,0 +1,66 @@ +package twitch + +import ( + "encoding/json" + "io" + logger "log" + "net/http" +) + +type RawEvent struct { + Challenge string `json:"challenge"` + Subscription Subscription `json:"subscription"` + Event interface{} `json:"event"` +} + +var log = logger.New(logger.Writer(), "[WebTwitch] ", logger.LstdFlags|logger.Lmsgprefix) + +func HandlePost(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("Failed to read body: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + var rEvent RawEvent + err = json.Unmarshal(body, &rEvent) + if err != nil { + log.Printf("Failed to unmarshal body: %v", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + messageType := r.Header.Get("Twitch-Eventsub-Message-Type") + switch messageType { + case "webhook_callback_verification": + handleVerification(w, r, rEvent) + return + case "notification": + log.Printf("Event notification: %+v", rEvent) + default: + log.Printf("Unknown message type '%s'", messageType) + w.WriteHeader(http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) + return +} + +func handleVerification(w http.ResponseWriter, r *http.Request, rEvent RawEvent) { + if rEvent.Challenge == "" { + w.WriteHeader(http.StatusBadRequest) + return + } + broadcaster := rEvent.Subscription.Condition["broadcaster_user_id"] + if broadcaster != "404257324" { + log.Printf("Declined verification for broadcaster '%s'!", broadcaster) + w.WriteHeader(http.StatusConflict) + w.Write([]byte("{\"conflict\":\"that broadcaster is not allowed\"}")) + return + } + + log.Printf("Accepted '%s v%s' for channel %s", rEvent.Subscription.Type, rEvent.Subscription.Version, broadcaster) + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusAccepted) + w.Write([]byte(rEvent.Challenge)) +} diff --git a/webserver/twitch/global.go b/webserver/twitch/global.go new file mode 100644 index 0000000..7ae5dea --- /dev/null +++ b/webserver/twitch/global.go @@ -0,0 +1,22 @@ +package twitch + +import ( + "time" +) + +type Subscription struct { + ID string `json:"id"` + Status string `json:"status"` + Type string `json:"type"` + Version string `json:"version"` + Condition map[string]string `json:"condition"` + Transport SubscriptionTransport `json:"transport"` + CreatedAt time.Time `json:"created_at"` + Cost int `json:"cost"` +} +type SubscriptionTransport struct { + Method string `json:"method"` + WebhookCallbackURI string `json:"callback,omitempty"` + WebhookSecret string `json:"secret,omitempty"` + WebSocketSessionID string `json:"session_id,omitempty"` +} From bfbaabf498c0d9d41c66a1543e72415ac87574f2 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Sun, 7 Jan 2024 23:08:34 +0100 Subject: [PATCH 19/26] added documentation comments --- webserver/twitch/api.go | 17 ++++++++++-- webserver/twitch/global.go | 55 +++++++++++++++++++++++++++++++------- 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/webserver/twitch/api.go b/webserver/twitch/api.go index 52bba69..49b50ea 100644 --- a/webserver/twitch/api.go +++ b/webserver/twitch/api.go @@ -7,14 +7,27 @@ import ( "net/http" ) +// RawEvent represents the http body comming with a call to the /twitch_pubsub enpoints type RawEvent struct { - Challenge string `json:"challenge"` + // Challenge cointains the string to return when receiving a webhook callback verification. + // Otherwise it is an empty string + Challenge string `json:"challenge"` + + // Subscription contains the informations this event is about. Subscription Subscription `json:"subscription"` - Event interface{} `json:"event"` + + // Event is the actual event. + // + // It is not set in a webhook callback verification. + Event interface{} `json:"event"` } var log = logger.New(logger.Writer(), "[WebTwitch] ", logger.LstdFlags|logger.Lmsgprefix) +// HandlePost is the HTTP/POST handler for the Twitch PubSub endpoint. +// +// It is called to handle a webhook comming from twitch. This could be a hub challenge verification +// or a event notification. func HandlePost(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { diff --git a/webserver/twitch/global.go b/webserver/twitch/global.go index 7ae5dea..788503e 100644 --- a/webserver/twitch/global.go +++ b/webserver/twitch/global.go @@ -4,19 +4,56 @@ import ( "time" ) +// Subscription represents a single subscription to an event. type Subscription struct { - ID string `json:"id"` - Status string `json:"status"` - Type string `json:"type"` - Version string `json:"version"` - Condition map[string]string `json:"condition"` + // ID is the unique identifier for this subscription. + ID string `json:"id"` + + // Status is the status of this subscription e.g. it is set to "enabled" when successfully + // verified and active. + Status string `json:"status"` + + // Type is the acual event that triggers. + Type string `json:"type"` + + // Version is the version number of the event defined in the Type field. + Version string `json:"version"` + + // Condition contains a list of key-value-pairs of conditions. The 'key' gives a variable to check + // and 'value' the value to match. For example "broadcaster_user_id":"12345" requires the event + // to be triggered at the channel of user "12345". + Condition map[string]string `json:"condition"` + + // Transport gives information about how this subscription is (or will be) delivered. Transport SubscriptionTransport `json:"transport"` - CreatedAt time.Time `json:"created_at"` - Cost int `json:"cost"` + + // CreatedAt is the timestamp of creation of this subscription. + CreatedAt time.Time `json:"created_at"` + + // The amount points this subscription costs. The cost is added to a global count. Each + // application has a fixed amount of available points to use. + Cost int `json:"cost"` } + +// SubscriptionTransport gives information about how a subscription is (or will be) delivered. type SubscriptionTransport struct { - Method string `json:"method"` + // Method is either set to "webhook" or "websocket". + Method string `json:"method"` + + // WebhookCallbackURI gives the complete URI of the webhook. + // + // Only when Method == "webhook" WebhookCallbackURI string `json:"callback,omitempty"` - WebhookSecret string `json:"secret,omitempty"` + + // WebhookSecret is the secret given with the creation of the subscription to veryfiy its + // correctness. + // + // Only when Method == "webhook" + WebhookSecret string `json:"secret,omitempty"` + + // WebSocketSessionID is the ID the welcome message returns, when connecting to the twitch + // websocket. More information needed. + // + // Only when Method == "websocket" WebSocketSessionID string `json:"session_id,omitempty"` } From f368c0872760a2b335b919dfdb9cf67e3272f0c9 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Sun, 7 Jan 2024 23:10:54 +0100 Subject: [PATCH 20/26] remove redundant return --- webserver/twitch/api.go | 1 - 1 file changed, 1 deletion(-) diff --git a/webserver/twitch/api.go b/webserver/twitch/api.go index 49b50ea..4f42849 100644 --- a/webserver/twitch/api.go +++ b/webserver/twitch/api.go @@ -56,7 +56,6 @@ func HandlePost(w http.ResponseWriter, r *http.Request) { return } w.WriteHeader(http.StatusOK) - return } func handleVerification(w http.ResponseWriter, r *http.Request, rEvent RawEvent) { From 03e1d65070574df3b3a22e553e9581a93828b0c0 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Thu, 11 Jan 2024 21:13:42 +0100 Subject: [PATCH 21/26] moved twitch package refactoring --- event/event.go | 27 ++++++++----- event/scheduledTriggers.go | 22 ++++++---- {twitch => event/twitch}/messageHandler.go | 0 twitch/handle.go => event/twitch/register.go | 5 ++- main.go | 42 ++++++++++---------- 5 files changed, 55 insertions(+), 41 deletions(-) rename {twitch => event/twitch}/messageHandler.go (100%) rename twitch/handle.go => event/twitch/register.go (85%) diff --git a/event/event.go b/event/event.go index e93f167..3dd3f4d 100644 --- a/event/event.go +++ b/event/event.go @@ -17,29 +17,38 @@ package event import ( "cake4everybot/event/command" "cake4everybot/event/component" + "cake4everybot/event/twitch" logger "log" "github.com/bwmarrin/discordgo" + "github.com/kesuaheli/twitchgo" ) var log = *logger.New(logger.Writer(), "[Events] ", logger.LstdFlags|logger.Lmsgprefix) -// Register registers all events, like commands. -func Register(s *discordgo.Session, guildID string) error { - err := command.Register(s, guildID) +// PostRegister registers all events, like commands after the bots are started. +func PostRegister(dc *discordgo.Session, t *twitchgo.Twitch, guildID string) error { + err := command.Register(dc, guildID) if err != nil { return err } component.Register() + twitch.Register(t) + return nil } -// AddListeners adds all event handlers to the given session s. -func AddListeners(s *discordgo.Session, webChan chan struct{}) { - s.AddHandler(handleInteractionCreate) - addVoiceStateListeners(s) +// AddListeners adds all event handlers to the given bots. +func AddListeners(dc *discordgo.Session, t *twitchgo.Twitch, webChan chan struct{}) { + dc.AddHandler(handleInteractionCreate) + addVoiceStateListeners(dc) + + t.OnChannelCommandMessage("join", twitch.HandleCmdJoin) + t.OnChannelCommandMessage("tickets", twitch.HandleCmdTickets) + t.OnChannelCommandMessage("draw", twitch.HandleCmdDraw) + t.OnChannelMessage(twitch.MessageHandler) - addYouTubeListeners(s) - addScheduledTriggers(s, webChan) + addYouTubeListeners(dc) + addScheduledTriggers(dc, t, webChan) } diff --git a/event/scheduledTriggers.go b/event/scheduledTriggers.go index 5cb2ab0..ee76326 100644 --- a/event/scheduledTriggers.go +++ b/event/scheduledTriggers.go @@ -22,15 +22,16 @@ import ( "time" "github.com/bwmarrin/discordgo" + "github.com/kesuaheli/twitchgo" "github.com/spf13/viper" ) -func addScheduledTriggers(s *discordgo.Session, webChan chan struct{}) { - go scheduleFunction(s, 0, 0, +func addScheduledTriggers(dc *discordgo.Session, t *twitchgo.Twitch, webChan chan struct{}) { + go scheduleFunction(dc, t, 0, 0, adventcalendar.Midnight, ) - go scheduleFunction(s, viper.GetInt("event.morning_hour"), viper.GetInt("event.morning_minute"), + go scheduleFunction(dc, t, viper.GetInt("event.morning_hour"), viper.GetInt("event.morning_minute"), birthday.Check, adventcalendar.Post, ) @@ -38,11 +39,11 @@ func addScheduledTriggers(s *discordgo.Session, webChan chan struct{}) { go refreshYoutube(webChan) } -func scheduleFunction(s *discordgo.Session, hour, min int, f ...func(*discordgo.Session)) { - if len(f) == 0 { +func scheduleFunction(dc *discordgo.Session, t *twitchgo.Twitch, hour, min int, callbacks ...interface{}) { + if len(callbacks) == 0 { return } - log.Printf("scheduled %d function(s) for %2d:%02d!", len(f), hour, min) + log.Printf("scheduled %d function(s) for %2d:%02d!", len(callbacks), hour, min) time.Sleep(time.Second * 5) for { now := time.Now() @@ -53,8 +54,13 @@ func scheduleFunction(s *discordgo.Session, hour, min int, f ...func(*discordgo. } time.Sleep(nextRun.Sub(now)) - for _, f := range f { - f(s) + for _, c := range callbacks { + switch f := c.(type) { + case func(*discordgo.Session): + f(dc) + case func(*twitchgo.Twitch): + f(t) + } } } } diff --git a/twitch/messageHandler.go b/event/twitch/messageHandler.go similarity index 100% rename from twitch/messageHandler.go rename to event/twitch/messageHandler.go diff --git a/twitch/handle.go b/event/twitch/register.go similarity index 85% rename from twitch/handle.go rename to event/twitch/register.go index 258e5b6..9ca6f19 100644 --- a/twitch/handle.go +++ b/event/twitch/register.go @@ -21,8 +21,9 @@ import ( "github.com/spf13/viper" ) -// Handle adds all handler to the client -func Handle(bot *twitchgo.Twitch) { +// Register is setting up the twitch bot. Like joining channels and other stuff that is available +// after the bot is connected +func Register(bot *twitchgo.Twitch) { channels := viper.GetStringSlice("twitch.channels") for _, channel := range channels { bot.SendCommandf("JOIN #%s", channel) diff --git a/main.go b/main.go index 9bb828b..0b10e36 100644 --- a/main.go +++ b/main.go @@ -23,7 +23,6 @@ import ( "cake4everybot/config" "cake4everybot/database" "cake4everybot/event" - "cake4everybot/twitch" "cake4everybot/webserver" "github.com/bwmarrin/discordgo" @@ -64,27 +63,38 @@ func main() { database.Connect() defer database.Close() - log.Println("Logging in to Discord") - s, err := discordgo.New("Bot " + viper.GetString("discord.token")) + // initializing Discord and Twitch bots + discordBot, err := discordgo.New("Bot " + viper.GetString("discord.token")) if err != nil { - log.Fatalf("invalid bot parameters: %v", err) + log.Fatalf("invalid discord bot parameters: %v", err) } - s.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { + discordBot.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) { log.Printf("Logged in to Discord as %s#%s\n", s.State.User.Username, s.State.User.Discriminator) }) - event.AddListeners(s, webChan) + twitchBot := twitchgo.New(viper.GetString("twitch.name"), viper.GetString("twitch.token")) + + // adding listeners for events + event.AddListeners(discordBot, twitchBot, webChan) - // open connection to Discord and login - err = s.Open() + // open connection and login to Discord and Twitch + log.Println("Logging in to Discord") + err = discordBot.Open() if err != nil { log.Fatalf("could not open the discord session: %v", err) } - defer s.Close() + defer discordBot.Close() + + log.Println("Logging in to Twitch") + err = twitchBot.Connect() + if err != nil { + log.Fatalf("could not open the twitch connection: %v", err) + } + defer twitchBot.Close() // register all events. - err = event.Register(s, viper.GetString("discord.guildID")) + err = event.PostRegister(discordBot, twitchBot, viper.GetString("discord.guildID")) if err != nil { log.Printf("Error registering events: %v\n", err) } @@ -93,18 +103,6 @@ func main() { addr := ":8080" webserver.Run(addr, webChan) - client := twitchgo.New(viper.GetString("twitch.name"), viper.GetString("twitch.token")) - client.OnChannelCommandMessage("join", twitch.HandleCmdJoin) - client.OnChannelCommandMessage("tickets", twitch.HandleCmdTickets) - client.OnChannelCommandMessage("draw", twitch.HandleCmdDraw) - client.OnChannelMessage(twitch.MessageHandler) - err = client.Connect() - if err != nil { - log.Fatalf("could not open the twitch connection: %v", err) - } - defer client.Close() - twitch.Handle(client) - // Wait to end the bot log.Println("Press Ctrl+C to exit") <-ctx.Done() From 100cc1cae342b39afb635551b7ae54b3d69b44e8 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Thu, 11 Jan 2024 21:45:57 +0100 Subject: [PATCH 22/26] added missing english translations for twitch giveaway --- data/lang/en.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/data/lang/en.yaml b/data/lang/en.yaml index bab7587..03b5734 100644 --- a/data/lang/en.yaml +++ b/data/lang/en.yaml @@ -146,6 +146,17 @@ twitch.command: error: Whoops, something is not right here! 🙃 @Kesuaheli Help! join: + msg.no_prizes: "@%s there's nothing to win at the moment. You can't use this command at the moment." + msg.won: "@%s, you've won a price already and aren't allow to buy more tickets ." + msg.max_tickets: "@%s, you've bought all 10/10 tickets already. Give others a chance too ;)" + msg.cooldown: + - "@%s, you have to wait %s to buy another ticket." + - "@%s, you're too fast! Wait like %s to buy another one." + - "@%s Although you won't be able to buy another ticket for %s, you can watch the stream continuously in the meantime ;)" + - Already, @%s? Didn't you just bought a ticket? Wait another %s. + - "@%s Another ticket is in progress... you can claim it in %s." + - "@%s Beep boop 🤖 Your ticket will be printed. Estimated printing time: %s remaining" + - "@%s, to enjoy more of the stream, you can only buy a ticket again in %s." msg.too_few_points: "@%s you don't have enough points (%d)! You need %d more to pay the costs of %d points." msg.success: "@%s you successfully bought a ticket for %d points. Now you have %d tickets and %d points left." From 4c0e4bd9fed1844b93882725fc1f2153a6e35c31 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Fri, 12 Jan 2024 00:02:38 +0100 Subject: [PATCH 23/26] added twitch message hash verification --- config_env.yaml.example | 2 ++ webserver/twitch/api.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/config_env.yaml.example b/config_env.yaml.example index ebe13c7..7534369 100644 --- a/config_env.yaml.example +++ b/config_env.yaml.example @@ -17,6 +17,8 @@ twitch: name: twitch_username # twitch oauth token, starts with "oauth:" token: + # a custom secret for the webhook, used for verifying hashes + webhookSecret: streamelements: # Streamelements JSON Web Token (JWT) diff --git a/webserver/twitch/api.go b/webserver/twitch/api.go index 4f42849..5c2160c 100644 --- a/webserver/twitch/api.go +++ b/webserver/twitch/api.go @@ -1,10 +1,15 @@ package twitch import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" "encoding/json" "io" logger "log" "net/http" + + "github.com/spf13/viper" ) // RawEvent represents the http body comming with a call to the /twitch_pubsub enpoints @@ -35,6 +40,13 @@ func HandlePost(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } + + // before anything, check the hash + if !verifyTwitchMessage(r.Header, body) { + w.WriteHeader(http.StatusForbidden) + return + } + var rEvent RawEvent err = json.Unmarshal(body, &rEvent) if err != nil { @@ -58,6 +70,24 @@ func HandlePost(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +func verifyTwitchMessage(header http.Header, body []byte) bool { + // read more at https://dev.twitch.tv/docs/eventsub/handling-webhook-events/#verifying-the-event-message + msgID := header.Get("Twitch-Eventsub-Message-Id") + msgTime := header.Get("Twitch-Eventsub-Message-Timestamp") + data := append([]byte(msgID+msgTime), body...) + + h := hmac.New(sha256.New, []byte(viper.GetString("twitch.webhookSecret"))) + h.Write(data) + hmacData := h.Sum(nil) + + hmacHex := make([]byte, 128) + n := hex.Encode(hmacHex, hmacData) + hmacHex = append([]byte("sha256="), hmacHex[:n]...) + + signature := []byte(header.Get("Twitch-Eventsub-Message-Signature")) + return hmac.Equal(hmacHex, signature) +} + func handleVerification(w http.ResponseWriter, r *http.Request, rEvent RawEvent) { if rEvent.Challenge == "" { w.WriteHeader(http.StatusBadRequest) From d02bfaa59bb491d512744cf436bd3ae9fd24a157 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Fri, 12 Jan 2024 20:01:39 +0100 Subject: [PATCH 24/26] added twitch time and duplication verification --- webserver/twitch/api.go | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/webserver/twitch/api.go b/webserver/twitch/api.go index 5c2160c..a24e5ba 100644 --- a/webserver/twitch/api.go +++ b/webserver/twitch/api.go @@ -8,6 +8,8 @@ import ( "io" logger "log" "net/http" + "slices" + "time" "github.com/spf13/viper" ) @@ -27,7 +29,10 @@ type RawEvent struct { Event interface{} `json:"event"` } -var log = logger.New(logger.Writer(), "[WebTwitch] ", logger.LstdFlags|logger.Lmsgprefix) +var ( + log = logger.New(logger.Writer(), "[WebTwitch] ", logger.LstdFlags|logger.Lmsgprefix) + lastMessages = make([]string, 10) +) // HandlePost is the HTTP/POST handler for the Twitch PubSub endpoint. // @@ -85,7 +90,25 @@ func verifyTwitchMessage(header http.Header, body []byte) bool { hmacHex = append([]byte("sha256="), hmacHex[:n]...) signature := []byte(header.Get("Twitch-Eventsub-Message-Signature")) - return hmac.Equal(hmacHex, signature) + if !hmac.Equal(hmacHex, signature) { + return false + } + + t, err := time.Parse(time.RFC3339, msgTime) + if err != nil { + log.Printf("Error parsing timestamp '%s': %v", msgTime, err) + return false + } + + if time.Until(t) > -10*time.Minute { + return false + } + + if slices.Contains(lastMessages, msgID) { + return false + } + lastMessages = append(lastMessages[1:], msgID) + return true } func handleVerification(w http.ResponseWriter, r *http.Request, rEvent RawEvent) { From 121cf8dfc809b54f36a7c3eeb1ee6de6ebcb2eba Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Fri, 12 Jan 2024 20:43:07 +0100 Subject: [PATCH 25/26] updated twitch library --- event/event.go | 6 +++--- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/event/event.go b/event/event.go index 3dd3f4d..18a55c1 100644 --- a/event/event.go +++ b/event/event.go @@ -44,9 +44,9 @@ func AddListeners(dc *discordgo.Session, t *twitchgo.Twitch, webChan chan struct dc.AddHandler(handleInteractionCreate) addVoiceStateListeners(dc) - t.OnChannelCommandMessage("join", twitch.HandleCmdJoin) - t.OnChannelCommandMessage("tickets", twitch.HandleCmdTickets) - t.OnChannelCommandMessage("draw", twitch.HandleCmdDraw) + t.OnChannelCommandMessage("ticket", true, twitch.HandleCmdJoin) + t.OnChannelCommandMessage("tickets", true, twitch.HandleCmdTickets) + t.OnChannelCommandMessage("draw", true, twitch.HandleCmdDraw) t.OnChannelMessage(twitch.MessageHandler) addYouTubeListeners(dc) diff --git a/go.mod b/go.mod index 69d76a1..d82167f 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/bwmarrin/discordgo v0.27.1 github.com/go-sql-driver/mysql v1.7.1 github.com/gorilla/mux v1.8.0 - github.com/kesuaheli/twitchgo v0.2.3 + github.com/kesuaheli/twitchgo v0.2.5 github.com/spf13/viper v1.15.0 ) diff --git a/go.sum b/go.sum index 40a29a3..7861ef3 100644 --- a/go.sum +++ b/go.sum @@ -135,8 +135,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/kesuaheli/twitchgo v0.2.3 h1:5lET3xBX1b4NPgmXdGbQoyTie38eJkGN2SJwJnvSV/U= -github.com/kesuaheli/twitchgo v0.2.3/go.mod h1:swIW1jJcFa4bWi/9JfUYH4Sf1kAvj+QUcaTPkce+6Ds= +github.com/kesuaheli/twitchgo v0.2.5 h1:ZoAQvB/4IUAlNvRHKgq+EVV16GTL8PQoEQ0+bdybW20= +github.com/kesuaheli/twitchgo v0.2.5/go.mod h1:swIW1jJcFa4bWi/9JfUYH4Sf1kAvj+QUcaTPkce+6Ds= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= From ddcf2f4c9d5d918530ac17b87c244cd3e32a3cd5 Mon Sep 17 00:00:00 2001 From: Kesuaheli Date: Fri, 12 Jan 2024 21:13:08 +0100 Subject: [PATCH 26/26] updated twitch library --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d82167f..d7f7748 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/bwmarrin/discordgo v0.27.1 github.com/go-sql-driver/mysql v1.7.1 github.com/gorilla/mux v1.8.0 - github.com/kesuaheli/twitchgo v0.2.5 + github.com/kesuaheli/twitchgo v0.2.6 github.com/spf13/viper v1.15.0 ) diff --git a/go.sum b/go.sum index 7861ef3..d36c98d 100644 --- a/go.sum +++ b/go.sum @@ -135,8 +135,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/kesuaheli/twitchgo v0.2.5 h1:ZoAQvB/4IUAlNvRHKgq+EVV16GTL8PQoEQ0+bdybW20= -github.com/kesuaheli/twitchgo v0.2.5/go.mod h1:swIW1jJcFa4bWi/9JfUYH4Sf1kAvj+QUcaTPkce+6Ds= +github.com/kesuaheli/twitchgo v0.2.6 h1:stvr1AyTJjRXNXQ3gA6kutkwLF/SZkmU1EJrZD6KJvQ= +github.com/kesuaheli/twitchgo v0.2.6/go.mod h1:swIW1jJcFa4bWi/9JfUYH4Sf1kAvj+QUcaTPkce+6Ds= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=