package main import ( "fmt" "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 OR IGNORE INTO users (channelID, points, isModerator, isStreamer) VALUES (?, ?, ?, ?)", channelID, 0, isModerator, isStreamer) if err != nil { panic("Error inserting user: " + 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"` IsChatOwner bool `json:"isChatOwner"` IsChatModerator bool `json:"isChatModerator"` } type Snippet struct { PublishedAt string `json:"publishedAt"` TextMessageDetails TextMessageDetails `json:"textMessageDetails"` } type TextMessageDetails struct { MessageText string `json:"messageText"` } func scrape(liveChatID string) { for { if streaming == true { // Check for chat messages 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=" + configFile.ApiKey) if err != nil { 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 { println("Error getting chat messages: ", response.Status) return } else { // Iterate through each live chat message for _, item := range responseJSON.Items { println("Processing message from ", item.AutorDetails.DisplayName) println("Message: ", item.Snippet.TextMessageDetails.MessageText) // Check if the message starts with !verify if strings.HasPrefix(item.Snippet.TextMessageDetails.MessageText, "!link ") { println("User ", item.AutorDetails.DisplayName, " is verifying") // Get the channel ID channelID := item.AutorDetails.ChannelID // Check if the user is in pendingSessions pendingSession, ok := pendingSessions[strings.TrimPrefix(item.Snippet.TextMessageDetails.MessageText, "!link ")] if !ok { // This user does not need to be verified continue } // Add the user to the sessions map sessions[pendingSession.IP] = channelID initUser(channelID, item.AutorDetails.IsChatModerator, item.AutorDetails.IsChatOwner) // 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 { println("Error parsing time: " + err.Error()) } else if !ok || publishedTime.Before(earliestSentMessage) { if publishedTime.After(streamingSince) { println("New message from ", item.AutorDetails.DisplayName) unclaimedMessages[item.AutorDetails.ChannelID] = publishedTime } } } } // Close the response body err := response.Body.Close() if err != nil { 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 } func getLiveChatID(videoID string) string { response, err := http.Get("https://www.googleapis.com/youtube/v3/videos?part=liveStreamingDetails&id=" + videoID + "&key=" + configFile.ApiKey) if err != nil { panic("Error getting live chat ID: " + err.Error()) } var responseJSON map[string]interface{} err = json.NewDecoder(response.Body).Decode(&responseJSON) if err != nil { panic("Error decoding JSON: " + err.Error()) } println(responseJSON) liveChatID := responseJSON["items"].([]interface{})[0].(map[string]interface{})["liveStreamingDetails"].(map[string]interface{})["activeLiveChatId"].(string) return liveChatID } 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, CheckOrigin: func(r *http.Request) bool { if r.Header.Get("Origin") == configFile.Origin { return true } else { return false } }, } ) 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 = time.Unix(0, 0) bets = nil } func startBet(q string, a []string, d time.Time) { bettingOpen = true question = q possibleAnswers = make(map[int]string) for i, answer := range a { possibleAnswers[i] = answer } timeToBet = d bets = make(map[string]Bet) } var ( stakesMultiplier int64 bettingOpen bool question string possibleAnswers map[int]string timeToBet time.Time bets map[string]Bet ) type Bet struct { amount int64 answer int } type Config struct { ApiKey string `json:"key"` Origin string `json:"origin"` 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 { println("Error upgrading connection: " + err.Error()) } // Create a new nonce nonce := make([]byte, 16) _, err = rand.Read(nonce) if err != nil { println("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 { return } } }() // Send the nonce err = conn.WriteJSON(gin.H{ "type": "nonce", "nonce": nonceBase64, }) if err != nil { return } _, nonceResponse, err := conn.ReadMessage() if err != nil { return } read = true jsonResponse := make(map[string]interface{}) err = json.Unmarshal(nonceResponse, &jsonResponse) if err != nil { println("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 { return } err = conn.Close() if err != nil { return } // 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 { return } } }() _, _, 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.Now().Add(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.Unix(), }) 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 } // 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 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() videoID, ok := data["videoID"].(string) if !ok { c.JSON(400, gin.H{"error": "Invalid JSON"}) return } // Start the chat message checker println("Starting chat message checker: ", videoID) go scrape(getLiveChatID(videoID)) // 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] } println("Start server on " + address) go func() { println("Shounic CMD started") for { var input string println("Enter command: \n" + "1. Stop server\n" + "2. Start stream\n" + "3. End stream\n" + "4. Force login\n" + "5. Force logout\n" + "6. Get user's IP") print("> ") _, err := fmt.Scanln(&input) if err != nil { println("Error reading input: " + err.Error()) } switch input { case "1": println("OK: Stopping server") os.Exit(0) case "2": if !streaming { streaming = true var videoID string print("Enter video ID: ") _, err := fmt.Scanln(&videoID) if err != nil { println("Error reading input: " + err.Error()) } go scrape(getLiveChatID(videoID)) println("OK: Stream started") } else { println("ERR: Stream already started") } case "3": streaming = false println("OK: Stream ended") case "4": print("Enter channel ID: ") var channelID string _, err := fmt.Scanln(&channelID) if err != nil { println("Error reading input: " + err.Error()) } print("Enter IP: ") var ip string _, err = fmt.Scanln(&ip) if err != nil { println("Error reading input: " + err.Error()) } print("Is moderator? (y/n): ") var isModerator string _, err = fmt.Scanln(&isModerator) if err != nil { println("Error reading input: " + err.Error()) } print("Is streamer? (y/n): ") var isStreamer string _, err = fmt.Scanln(&isStreamer) if err != nil { println("Error reading input: " + err.Error()) } sessions[ip] = channelID initUser(channelID, isModerator == "y", isStreamer == "y") println("OK: Forced login") case "5": print("Enter IP: ") var ip string _, err := fmt.Scanln(&ip) if err != nil { println("Error reading input: " + err.Error()) } delete(sessions, ip) println("OK: Forced logout") case "6": print("Enter channel ID: ") var channelID string _, err := fmt.Scanln(&channelID) if err != nil { println("Error reading input: " + err.Error()) } for key, value := range sessions { if value == channelID { println("IP: " + key) } } println("OK: Got user's IP") default: println("ERR: Invalid command") } } }() err = router.Run(address) if err != nil { panic("Error starting server: " + err.Error()) } }