package main import ( "errors" "log" "os" "strconv" "strings" "time" "crypto/rand" "database/sql" "encoding/base64" "encoding/json" "net/http" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" _ "modernc.org/sqlite" ) func initUser(channelID string, isModerator bool, isStreamer bool) { _, err := conn.Exec("INSERT INTO users (channelID, points, isModerator, isStreamer) VALUES (?, ?, ?, ?)", channelID, 0, isModerator, isStreamer) if err != nil { panic("Error inserting moderator: " + err.Error()) } } func getPoints(channelID string) int64 { var points int64 err := conn.QueryRow("SELECT points FROM users WHERE channelID = ?", channelID).Scan(&points) if err != nil { panic("Error querying database: " + err.Error()) } return points } func updatePoints(channelID string, points int64) { _, err := conn.Exec("UPDATE users SET points = ? WHERE channelID = ?", points, channelID) if err != nil { panic("Error updating user points: " + err.Error()) } } func addPoints(channelID string, points int64) { updatePoints(channelID, getPoints(channelID)+points) } func minusPoints(channelID string, points int64) { updatePoints(channelID, getPoints(channelID)-points) } //goland:noinspection GoUnusedFunction func userIsStreamer(channelID string) bool { var isStreamer bool err := conn.QueryRow("SELECT isStreamer FROM users WHERE channelID = ?", channelID).Scan(&isStreamer) if err != nil { panic("Error querying database: " + err.Error()) } return isStreamer } func userIsModerator(channelID string) bool { var isModerator bool err := conn.QueryRow("SELECT isModerator FROM users WHERE channelID = ?", channelID).Scan(&isModerator) if err != nil { panic("Error querying database: " + err.Error()) } return isModerator } type LiveChatMessages struct { Items []Items `json:"items"` } type Items struct { AutorDetails AuthorDetails `json:"authorDetails"` Snippet Snippet `json:"snippet"` } type AuthorDetails struct { DisplayName string `json:"displayName"` ChannelID string `json:"channelId"` } type Snippet struct { PublishedAt string `json:"publishedAt"` TextMessageDetails TextMessageDetails `json:"textMessageDetails"` } type TextMessageDetails struct { MessageText string `json:"messageText"` } func scrape(liveChatID string, key string) { for { if streaming == true { // Check for chat messages log.Println("Scary, we are expending a Google API credit (on stream)!") response, err := http.Get("https://www.googleapis.com/youtube/v3/liveChat/messages?liveChatId=" + liveChatID + "&part=snippet,authorDetails&key=" + key) if err != nil { log.Println("Error getting chat messages: " + err.Error() + ", trying again in 20 seconds") time.Sleep(time.Second * 20) continue } // Read the response var responseJSON LiveChatMessages err = json.NewDecoder(response.Body).Decode(&responseJSON) if err != nil { panic("Error decoding JSON: " + err.Error()) } // Check the status code if response.StatusCode != 200 { log.Println("Error getting chat messages: ", response.Status, responseJSON) return } else { // Iterate through each live chat message for _, item := range responseJSON.Items { log.Println("Processing message from ", item.AutorDetails.DisplayName) // Check if the message starts with !verify if strings.HasPrefix(item.Snippet.TextMessageDetails.MessageText, "!verify ") { // Get the channel ID channelID := item.AutorDetails.ChannelID // Check if the user is in pendingSessions pendingSession, ok := pendingSessions[strings.TrimPrefix("!verify ", item.Snippet.TextMessageDetails.MessageText)] if !ok { // This user does not need to be verified continue } // Add the user to the sessions map sessions[pendingSession.IP] = channelID // Call the event callback pendingSession.EventCallback() // Remove the user from the pendingSessions map delete(pendingSessions, strings.TrimPrefix("!verify ", item.Snippet.TextMessageDetails.MessageText)) } else { earliestSentMessage, ok := unclaimedMessages[item.AutorDetails.ChannelID] publishedTime, err := time.Parse(time.RFC3339Nano, item.Snippet.PublishedAt) if err != nil { log.Println("Error parsing time: " + err.Error()) } else if !ok || publishedTime.Before(earliestSentMessage) { if publishedTime.After(streamingSince) { log.Println("New message from ", item.AutorDetails.DisplayName) unclaimedMessages[item.AutorDetails.ChannelID] = publishedTime } } } } // Close the response body err := response.Body.Close() if err != nil { log.Println("Error closing response body: " + err.Error()) } // Wait for the rate because Google likes to screw us over time.Sleep(time.Second * time.Duration(configFile.Rate)) } } else { return } } } func getUserSecondDifference(channelID string) (int64, bool) { earliestSentMessage, ok := unclaimedMessages[channelID] if !ok { return 0, false } return int64(time.Now().Sub(earliestSentMessage).Seconds()), true } type PendingSession struct { IP string EventCallback func() } var ( conn *sql.DB unclaimedMessages = make(map[string]time.Time) sessions = make(map[string]string) pendingSessions = make(map[string]PendingSession) streamingSince time.Time configFile Config streaming bool upgrade = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, } ) func giveBetPoints(correct int) { for better, bet := range bets { if bet.answer == correct { addPoints(better, bet.amount*stakesMultiplier) } } endBet() } func endBet() { bettingOpen = false question = "" possibleAnswers = nil timeToBet = 0 bets = nil } func startBet(q string, a []string, d time.Duration) { bettingOpen = true question = q possibleAnswers = make(map[int]string) for i, answer := range a { possibleAnswers[i] = answer } timeToBet = d bets = make(map[string]Bet) } func getLivestream(channelID string) string { // Get the channel's livestream response, err := http.Get("https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=" + channelID + "&eventType=live&type=video&key=" + configFile.ApiKey) if err != nil { panic("Error getting livestream: " + err.Error()) } // Read the response var responseJSON map[string]interface{} err = json.NewDecoder(response.Body).Decode(&responseJSON) if err != nil { panic("Error decoding JSON: " + err.Error()) } // Check the status code if response.StatusCode != 200 { panic("Error getting livestream: " + response.Status) } // Get the video ID items := responseJSON["items"].([]interface{}) if len(items) == 0 { return "" } videoID := items[0].(map[string]interface{})["id"].(map[string]interface{})["videoId"].(string) return videoID } var ( stakesMultiplier int64 bettingOpen bool question string possibleAnswers map[int]string timeToBet time.Duration bets map[string]Bet ) type Bet struct { amount int64 answer int } type Config struct { ApiKey string `json:"key"` Rate float64 `json:"rate"` Multiplier float64 `json:"multiplier"` } func main() { // Connect to the database var err error conn, err = sql.Open("sqlite", "database.db") if err != nil { panic("Error connecting to database: " + err.Error()) } // Read in config.json configBytes, err := os.ReadFile("config.json") if err != nil { panic("Error reading config.json: " + err.Error()) } // Parse the JSON err = json.Unmarshal(configBytes, &configFile) if err != nil { panic("Error parsing config.json: " + err.Error()) } // Create the user table if it doesn't exist _, err = conn.Exec("CREATE TABLE IF NOT EXISTS users (channelID TEXT NOT NULL UNIQUE, points INTEGER NOT NULL, isModerator BOOLEAN NOT NULL DEFAULT FALSE, isStreamer BOOLEAN NOT NULL DEFAULT FALSE)") if err != nil { panic("Error creating users table: " + err.Error()) } // Add shounic by default _, err = conn.Exec("INSERT OR IGNORE INTO users (channelID, points, isModerator, isStreamer) VALUES ('UCHlTEt24Yb4ylJFuWz7hXIw', 0, 1, 1)") if err != nil { panic("Error inserting shounic: " + err.Error()) } // Set up the router gin.SetMode(gin.ReleaseMode) router := gin.New() // Set up the routes router.Static("/static", "./static") router.LoadHTMLGlob("./templates/*") // Ugh, why do we have to do this legally router.GET("/delete", func(c *gin.Context) { // Get the user's channel ID id, ok := sessions[c.ClientIP()] if !ok { c.JSON(403, gin.H{"error": "Invalid token"}) return } // Delete everything from every map with the user's channel ID delete(unclaimedMessages, id) _, err := conn.Exec("DELETE FROM users WHERE channelID = ?", id) if err != nil { panic("Error deleting user: " + err.Error()) } // Delete all access tokens with the user's channel ID for key, value := range sessions { if value == id { delete(sessions, key) } } c.JSON(200, "Data deleted") }) // Link the user's account router.GET("/api/link", func(c *gin.Context) { // Kick over to a WebSocket conn, err := upgrade.Upgrade(c.Writer, c.Request, nil) if err != nil { panic("Error upgrading connection: " + err.Error()) } // Create a new nonce nonce := make([]byte, 16) _, err = rand.Read(nonce) if err != nil { panic("Error generating nonce: " + err.Error()) } // Base64 encode the nonce nonceBase64 := base64.StdEncoding.EncodeToString(nonce) // Set a read deadline var read bool go func() { time.Sleep(time.Second * 5) if !read { err := conn.WriteJSON(gin.H{ "type": "error", "error": "Did not respond to nonce", }) if err != nil { panic("Error writing back to WebSocket: " + err.Error()) } } }() // Send the nonce err = conn.WriteJSON(gin.H{ "type": "nonce", "nonce": nonceBase64, }) if err != nil { return } _, nonceResponse, err := conn.ReadMessage() if err != nil { if !errors.Is(err, websocket.ErrCloseSent) { panic("Error reading nonce response: " + err.Error()) } else { return } } read = true jsonResponse := make(map[string]interface{}) err = json.Unmarshal(nonceResponse, &jsonResponse) if err != nil { panic("Error unmarshalling nonce response: " + err.Error()) } heartbeatStop := make(chan struct{}) // If the response is ok, add the user to the pendingSessions map and start heartbeats if jsonResponse["type"] == "success" { pendingSessions[nonceBase64] = PendingSession{ IP: c.ClientIP(), EventCallback: func() { // Write back to the WebSocket err = conn.WriteJSON(gin.H{"type": "success"}) if err != nil { panic("Error writing back to WebSocket: " + err.Error()) } err = conn.Close() if err != nil { panic("Error closing WebSocket: " + err.Error()) } // Tell the heartbeat loop to stop close(heartbeatStop) }, } } for { select { case <-heartbeatStop: return case <-time.After(time.Second * 5): err := conn.WriteJSON(gin.H{ "type": "ping", }) if err != nil { return } var read bool go func() { time.Sleep(time.Second * 5) if !read { err := conn.WriteJSON(gin.H{ "type": "error", "error": "Did not respond to ping", }) if err != nil { panic("Error writing back to WebSocket: " + err.Error()) } } }() _, _, err = conn.ReadMessage() if err != nil { return } read = true } } }) // Set up the betting route router.POST("/api/bet", func(c *gin.Context) { var data map[string]interface{} err := c.BindJSON(&data) if err != nil { c.JSON(400, gin.H{"error": "Invalid JSON"}) return } // Check if betting is open if bettingOpen { // Get the user's channel ID id, ok := sessions[c.ClientIP()] if !ok { c.JSON(403, gin.H{"error": "Invalid token"}) return } // Get the user's bet amount amountFloat, ok := data["amount"].(float64) if !ok { c.JSON(400, gin.H{"error": "Invalid bet"}) return } amount := int64(amountFloat) // Check if the user has enough points if getPoints(id) < amount { c.JSON(400, gin.H{"error": "Not enough points"}) return } // Get the user's bet answer answerFloat, ok := data["answer"].(float64) if !ok { c.JSON(400, gin.H{"error": "Invalid answer"}) return } answer := int(answerFloat) // Check if the answer is valid if _, ok := possibleAnswers[answer]; !ok { c.JSON(400, gin.H{"error": "Invalid answer"}) return } // Add the bet to the bets map bets[id] = Bet{ amount: amount, answer: answer, } // Subtract the points from the user minusPoints(id, amount) c.JSON(200, gin.H{"message": "Bet placed"}) } else { c.JSON(400, gin.H{"error": "Betting is closed"}) } }) router.POST("/api/startBet", func(c *gin.Context) { // Parse the JSON var data map[string]interface{} err := c.BindJSON(&data) if err != nil { c.JSON(400, gin.H{"error": "Invalid JSON"}) return } // Get the user's channel ID id, ok := sessions[c.ClientIP()] if !ok { c.JSON(403, gin.H{"error": "Invalid token"}) return } // Check if the user is a moderator if !userIsModerator(id) { c.JSON(403, gin.H{"error": "You must be a moderator to start a bet"}) return } // Check if betting is open if bettingOpen { c.JSON(206, gin.H{"error": "There is already a running bet"}) return } question := data["question"].(string) possibleAnswersRaw := data["possible"].([]interface{}) var possibleAnswers []string for _, answer := range possibleAnswersRaw { possibleAnswers = append(possibleAnswers, answer.(string)) } timeToBetFloat := data["timeToBet"].(float64) timeToBet := time.Duration(timeToBetFloat) * time.Second startBet(question, possibleAnswers, timeToBet) c.JSON(200, gin.H{"message": "Bet started"}) }) // Define the route for /api/getCurrentBet router.GET("/api/getCurrentBet", func(c *gin.Context) { if bettingOpen { var possibleAnswersList []string for _, answer := range possibleAnswers { possibleAnswersList = append(possibleAnswersList, answer) } c.JSON(200, gin.H{ "question": question, "possible": possibleAnswersList, "timeToBet": timeToBet.Seconds(), }) return } else { c.JSON(206, gin.H{"error": "There is not a running bet"}) return } }) // Define the route for /api/endBet router.POST("/api/endBet", func(c *gin.Context) { // Parse the JSON var data map[string]interface{} err := c.BindJSON(&data) if err != nil { c.JSON(400, gin.H{"error": "Invalid JSON"}) return } // Authenticate the user accessToken := c.GetHeader("Authorization") if accessToken == "" { c.JSON(400, gin.H{"error": "No token provided"}) return } // Check if the user is a moderator if !userIsModerator(accessToken) { c.JSON(403, gin.H{"error": "You must be a moderator to end a bet"}) return } correctFloat, ok := data["correct"].(float64) if !ok { c.JSON(400, gin.H{"error": "Invalid correct answer"}) return } correct := int(correctFloat) // Give the points to the correct betters giveBetPoints(correct) c.JSON(200, gin.H{"message": "Bet ended"}) }) // Define the route for /api/claimUnclaimedPoints router.GET("/api/claimUnclaimedPoints", func(c *gin.Context) { // Get the user's channel ID id, ok := sessions[c.ClientIP()] if !ok { c.JSON(403, gin.H{"error": "Invalid token"}) return } // Get the user's second difference secondDifference, ok := getUserSecondDifference(id) if !ok { c.JSON(200, gin.H{"points": "0"}) return } // Clear the earliest sent message delete(unclaimedMessages, id) // Issue the points addPoints(id, secondDifference*int64(configFile.Multiplier)) // Return the points c.JSON(200, gin.H{"points": strconv.FormatInt(secondDifference, 10)}) }) // Define the route for /api/getUnclaimedPoints router.GET("/api/getUnclaimedPoints", func(c *gin.Context) { // Get the user's channel ID id, ok := sessions[c.ClientIP()] if !ok { c.JSON(403, gin.H{"error": "Invalid token"}) return } // Get the user's second difference secondDifference, ok := getUserSecondDifference(id) if !ok { c.JSON(200, gin.H{"points": "0"}) return } c.JSON(200, gin.H{"points": strconv.FormatInt(secondDifference, 10)}) }) // Define the route for /api/getPoints router.GET("/api/getPoints", func(c *gin.Context) { // Get the user's channel ID id, ok := sessions[c.ClientIP()] if !ok { c.JSON(403, gin.H{"error": "Invalid token"}) return } c.JSON(200, gin.H{"points": getPoints(id)}) }) // Define the route for /api/startStream router.GET("/api/startStream", func(c *gin.Context) { // Parse the JSON var data map[string]interface{} err := c.BindJSON(&data) if err != nil { c.JSON(400, gin.H{"error": "Invalid JSON"}) return } // Get the user's channel ID id, ok := sessions[c.ClientIP()] if !ok { c.JSON(403, gin.H{"error": "Invalid token"}) return } // Check if the user is a streamer if !userIsModerator(id) { c.JSON(403, gin.H{"error": "You must be a moderator to start a stream"}) return } // Set the live status streaming = true streamingSince = time.Now() liveChatID, ok := data["liveChatID"].(string) if !ok { c.JSON(400, gin.H{"error": "Invalid JSON"}) return } // Start the chat message checker log.Println("Starting chat message checker: ", liveChatID) go scrape(liveChatID, configFile.ApiKey) // Return 200 c.JSON(200, gin.H{"message": "Stream started"}) }) // Define the route for /api/endStream router.GET("/api/endStream", func(c *gin.Context) { // Authenticate the user accessToken := c.GetHeader("Authorization") if accessToken == "" { c.JSON(400, gin.H{"error": "No token provided"}) return } // Check if the user is a moderator if !userIsModerator(accessToken) { c.JSON(403, gin.H{"error": "You must be a moderator to end a stream"}) return } // Set the live status streaming = false // Clear all unclaimed points for key := range unclaimedMessages { delete(unclaimedMessages, key) } // Clear the bets endBet() // Return 200 c.JSON(200, gin.H{"message": "Stream ended"}) }) router.GET("/api/loggedIn", func(c *gin.Context) { // Get the user's channel ID _, ok := sessions[c.ClientIP()] if !ok { c.JSON(403, gin.H{"loggedIn": false}) return } c.JSON(200, gin.H{"loggedIn": true}) }) // Now some static routes router.GET("/", func(c *gin.Context) { c.HTML(200, "index.html", gin.H{}) }) router.GET("/admin", func(c *gin.Context) { c.HTML(200, "admin.html", gin.H{}) }) router.GET("/link", func(c *gin.Context) { c.HTML(200, "link.html", gin.H{}) }) router.GET("/bet", func(c *gin.Context) { c.HTML(200, "bet.html", gin.H{}) }) router.GET("/privacy", func(c *gin.Context) { c.HTML(200, "privacy.html", gin.H{}) }) router.GET("/tos", func(c *gin.Context) { c.HTML(200, "tos.html", gin.H{}) }) // Start the server var address string if len(os.Args) < 2 { address = ":8080" } else { address = os.Args[1] } log.Println("Start server on " + address) err = router.Run(address) if err != nil { panic("Error starting server: " + err.Error()) } }