shoGambler/main.go
2025-03-24 18:56:52 +00:00

806 lines
19 KiB
Go

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())
}
}