diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3361da --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +//[07:41:31] [Client thread/INFO]: [CHAT] {"server":"mini208R","gametype":"HOUSING","mode":"dynamic","map":"Base"} + +https://playerdb.co/api/player/minecraft/illyum +https://playerdb.co/ + + +[CHAT] ONLINE: angryhacks + +### When to look up stats +- on private message / dm +- Party Invite +- Guild Invite +- Friend Invite +- When you invite someone to party +- When party leader/other member invites party +- When party joins (/stream open) +- Duel Request (that specific duel's stats) + +### Trackers +- Tips? +- WDR Reports? + +### Other Stuff To Add +If you screenshot a leaderboard, it will check the screenshots folder and try to detect if a screenshot has a leaderboard in it, then it will load the leaderboard in and check for those people's stats and display them off to the side (daily only since other lb are on api) + + + +## Building: + +#### Windows +```bash +set CGO_ENABLED=0 +go build -ldflags="-s -w" +upx --best --lzma hudly.exe +``` + +#### Mac (intel x86_64) +```bash +set GOOS=darwin +set GOARCH=amd64 +set CGO_ENABLED=0 +go build -ldflags="-s -w" +upx --best --lzma --force-macos hudly +``` +NOTE: macOS is currently not supported + +#### Mac (apple silicon ARM) +```bash +set GOOS=darwin +set GOARCH=arm64 +set CGO_ENABLED=0 +go build -ldflags="-s -w" +upx --best --lzma --force-macos hudly +``` +NOTE: macOS is currently not supported + + +# TODOS/Limitation +- (api_key) Incorrect structure (api headers don't exist if key is invalid) so you get the wrong error code +- (build) Requires google's UUID library (too big for my liking) +- (client) No keep-alive implemented +- (client) No room closure detection +- (client) You can't see and send data (sender needs to have 2 clients, 1 to host and 1 to read) +- (client/config) Hard coded ip address / port +- (config) No Config (hard code key) +- (demo) Only in-memory uuid cache +- (demo) Lunar Client ONLY (default log location only) +- (demo) Requires working key to function +- (demo) Windows client sender ONLY (not correct log path locator) +- (demo) does NOT show nicked players (doesn't crash) +- (gui) Just terminal for now +- (hypixel_api) No cache +- (player) Only bedwars stats +- (server) Terrible status messages +- (server/server-config) Hard coded port +- (uuid cache) no lifetime (probably isn't needed but still) \ No newline at end of file diff --git a/SINGLEFILE.go b/SINGLEFILE.go new file mode 100644 index 0000000..37e06e1 --- /dev/null +++ b/SINGLEFILE.go @@ -0,0 +1,1647 @@ +package main + +import ( + "bufio" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "github.com/google/uuid" + "io" + "io/ioutil" + "log" + "net" + "net/http" + "os" + "os/exec" + "path" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" + "sync" + "time" +) + +var key = "ccebff0f-939a-4afe-b5b3-30a7a665ee38" + +func main() { + var demoApp = NewDemoApp(key) + demoApp.Start() +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// LogBuf +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +type LogBuffer struct { + strings []string + size int +} + +func NewLogBuffer(size int) *LogBuffer { + return &LogBuffer{ + strings: make([]string, 0, size), + size: size, + } +} + +func (l *LogBuffer) Add(s string) { + if len(l.strings) == l.size { + l.strings = l.strings[1:] + } + l.strings = append(l.strings, s) +} + +func (l *LogBuffer) Get() []string { + return l.strings +} + +func (l *LogBuffer) GetLast() (string, error) { + if len(l.strings) == 0 { + return "", fmt.Errorf("log buffer is empty") + } + return l.strings[len(l.strings)-1], nil +} + +func (l *LogBuffer) GetSecondToLast() (string, error) { + if len(l.strings) < 2 { + return "", fmt.Errorf("log buffer does not have enough lines") + } + return l.strings[len(l.strings)-2], nil +} + +func (l *LogBuffer) GetLineStepsBack(x int) (string, error) { + if x < 0 || x >= len(l.strings) { + return "", fmt.Errorf("log buffer does not have enough lines to step back %d times", x) + } + return l.strings[len(l.strings)-1-x], nil +} + +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// Custom Logger +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +var AppLogger = NewCustomLogger() + +// CustomLogger wraps the standard logger +type CustomLogger struct { + logger *log.Logger +} + +// NewCustomLogger initializes the custom logger +func NewCustomLogger() *CustomLogger { + // Create a logger that writes to stdout with no flags + return &CustomLogger{ + logger: log.New(os.Stdout, "", 0), // we will handle formatting manually + } +} + +// logFormat retrieves the function name, file, and line number +func logFormat() string { + // Use the runtime.Caller to retrieve caller info + pc, file, line, ok := runtime.Caller(2) + if !ok { + file = "unknown" + line = 0 + } + // Get function name + funcName := runtime.FuncForPC(pc).Name() + + // Format timestamp + timestamp := time.Now().Format("2006-01-02 15:04:05") + + // Return formatted log prefix (timestamp, file, line, and function name) + return fmt.Sprintf("%s - %s:%d - %s: ", timestamp, file, line, funcName) +} + +// Info logs informational messages +func (c *CustomLogger) Info(msg string) { + c.logger.Println(logFormat() + "INFO: " + msg) +} + +// Error logs error messages +func (c *CustomLogger) Error(msg string) { + c.logger.Println(logFormat() + "ERROR: " + msg) +} + +// Example usage of the custom logger +func showcase() { + logger := NewCustomLogger() + + logger.Info("This is an info message") + logger.Error("This is an error message") +} + +//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// Main +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +type PlayerWrapper struct { + Player Player `json:"player"` +} + +func replaceCorruptedRune(msg string) string { + runes := []rune(msg) + for i, r := range runes { + if r == '�' { + runes[i] = '§' + } + } + return string(runes) +} + +func clearTerminal() { + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/c", "cls") + } else { + cmd = exec.Command("clear") + } + cmd.Stdout = os.Stdout + cmd.Run() +} + +func calcRatio(numerator, denominator int) float64 { + if denominator == 0 { + return float64(numerator) + } + return float64(numerator) / float64(denominator) +} + +type DemoApp struct { + Client *Client + API *HypixelApi + MemCache *MemoryCache + LogBuf *LogBuffer + PartyBuilder []map[string]interface{} +} + +func NewDemoApp(key string) *DemoApp { + var api_key = NewAPIKey(key) + app := &DemoApp{ + API: NewAPI(*api_key), + MemCache: &MemoryCache{}, + LogBuf: NewLogBuffer(10), + PartyBuilder: []map[string]interface{}{}, + } + app.MemCache.Init() + return app +} + +func (app *DemoApp) FetchMCPlayer(name string) (*FetchedPlayerResult, error) { + asyncFetcher := NewPlayerFetcher( + name, + app.MemCache, + 2, + 2*time.Second, + 5*time.Second, + ) + data, err := asyncFetcher.FetchPlayerData() + if err != nil { + return nil, err + } + return data, nil +} + +func (app *DemoApp) onFileEmit(line string) { + msg := strings.TrimSpace(line) + if len(msg) < 34 { + return + } + submsg := msg[33:] + if len(submsg) != 0 { + app.LogBuf.Add(submsg) + } + + OnlinePrefix := "[CHAT] ONLINE: " + PartyListSeparatorLinePrefix := "[CHAT] -----------------------------------------------------" + //PartyMemberCountPrefix := "[CHAT] Party Members (" + PartyLeaderPrefix := "[CHAT] Party Leader: " + PartyListMembersPrefix := "[CHAT] Party Members: " + + if strings.HasPrefix(submsg, OnlinePrefix) { // Online Message + newsubmsg := strings.TrimPrefix(submsg, OnlinePrefix) + players := strings.Split(newsubmsg, ",") + var online []CacheResult + for _, player := range players { + playerName := strings.TrimSpace(player) + plr, err := app.FetchMCPlayer(playerName) + res_name := plr.Name + res_uuid := plr.UUID + if err != nil { + fmt.Println(fmt.Sprintf("Error fetching UUID: %v", err)) + continue + } + fmt.Printf("UUID of player %s: %s\n", res_name, res_uuid) + res_player := CacheResult{ + UUID: plr.UUID, + Name: plr.Name, + } + online = append(online, res_player) + //names, err := GetNameFromUUID(playerUUID) + //if err != nil { + // log.Fatalf("Error fetching names from UUID: %v", err) + //} + //fmt.Printf("Name history for UUID %s: %v\n", playerUUID, names) + } + app.sendPartyList(online) + + } else if strings.HasPrefix(submsg, PartyListSeparatorLinePrefix) { // Party List + last, _ := app.LogBuf.GetSecondToLast() + // TODO: Check if moderators + if !strings.HasPrefix(last, PartyListMembersPrefix) { + return + } + + PartyMembersMsg := strings.TrimPrefix(last, PartyListMembersPrefix) + var ppl []CacheResult + for _, player := range strings.Split(PartyMembersMsg, ",") { + playerName := strings.TrimSpace(strings.TrimSuffix(player, " ?")) + if strings.HasPrefix(playerName, "[") { + playerName = strings.Split(playerName, " ")[1] + } + plr, err := app.FetchMCPlayer(playerName) + if err != nil { + log.Fatalf("Error fetching Player: %v", err) + continue + } + res_name := plr.Name + res_uuid := plr.UUID + + res_player := CacheResult{ + UUID: plr.UUID, + Name: plr.Name, + } + + fmt.Printf("UUID of player %s: %s\n", res_name, res_uuid) + ppl = append(ppl, res_player) + + //playerName := strings.TrimSpace(player) + //playerUUID, err := GetUUIDFromName(playerName) + //if err != nil { + // log.Fatalf("Error fetching UUID: %v", err) + // return + //} + //fmt.Printf("UUID of player %s: %s\n", playerName, playerUUID) + //cachedPlayer := CachedUuid{playerUUID, playerName, playerName, time.Now()} + //UuidCache.Add(&cachedPlayer) + } + + // Parse Party Leader + leaders_msg, err := app.LogBuf.GetLineStepsBack(2) + if err != nil { + println("Unable to find party leader message") + return + } + PartyLeaderMsg := strings.TrimPrefix(leaders_msg, PartyLeaderPrefix) + playerName := strings.TrimSpace(strings.TrimSuffix(PartyLeaderMsg, " ?")) + if strings.HasPrefix(playerName, "[") { + playerName = strings.Split(playerName, " ")[1] + } + + plr, err := app.FetchMCPlayer(playerName) + if err != nil { + log.Fatalf("Error fetching Player: %v", err) + } + res_name := plr.Name + res_uuid := plr.UUID + + res_player := CacheResult{ + UUID: plr.UUID, + Name: plr.Name, + } + + fmt.Printf("UUID of player %s: %s\n", res_name, res_uuid) + ppl = append(ppl, res_player) + + // Parse Party Count + //party_count_msg, err := LogBuf.GetLineStepsBack(4) + //if err != nil { + // println("Unable to find party count message") + // return + //} + //PartyCountMsg := strings.TrimPrefix(party_count_msg, PartyMemberCountPrefix) + //count_str := strings.TrimSuffix(PartyCountMsg, ")") + //count, err := strconv.Atoi(count_str) + //if err != nil { + // println("Unable to parse party count message - Invalid number used") + // return + //} + //print("Expected ") + //print(count) + //print(" party members\n") + app.sendPartyList(ppl) + return + } + + println(submsg) +} + +func (app *DemoApp) tailFile(path string, lineCh chan<- string) { + file, err := os.Open(path) + if err != nil { + log.Fatalf("Failed to open file: %v", err) + } + defer file.Close() + + file.Seek(0, 2) + + reader := bufio.NewReader(file) + + for { + line, err := reader.ReadString('\n') + if err != nil { + time.Sleep(100 * time.Millisecond) + continue + } + lineCh <- line + } +} + +func (app *DemoApp) sendPartyList(ppl []CacheResult) { + for _, user := range ppl { + res, err := app.API.GetPlayerResponse(user.UUID) + if err != nil { + log.Fatalf("Failed to get player data: %v", err) + } + + res.Player.Stats.Bedwars.WLR = calcRatio(res.Player.Stats.Bedwars.Wins, res.Player.Stats.Bedwars.Losses) + res.Player.Stats.Bedwars.KDR = calcRatio(res.Player.Stats.Bedwars.Kills, res.Player.Stats.Bedwars.Deaths) + res.Player.Stats.Bedwars.FKDR = calcRatio(res.Player.Stats.Bedwars.FinalKills, res.Player.Stats.Bedwars.FinalDeaths) + res.Player.Stats.Bedwars.BBLR = calcRatio(res.Player.Stats.Bedwars.BedsBroken, res.Player.Stats.Bedwars.BedsLost) + + playerJSON, err := json.Marshal(res) + if err != nil { + log.Fatalf("Failed to marshal player data: %v", err) + } + + var playerMap map[string]interface{} + json.Unmarshal(playerJSON, &playerMap) + app.PartyBuilder = append(app.PartyBuilder, playerMap) + + message, err := json.Marshal(app.PartyBuilder) + if err != nil { + log.Fatalf("Failed to marshal stuff: %v", err) + } + err = app.Client.SendData(string(message)) + if err != nil { + log.Printf("Error sending data: %v", err) + } + fmt.Println("Sent stuff:", app.PartyBuilder) + println("Sending Done!") + } + + // Clear buffer so it only sends the current party list, not previous party lists + app.PartyBuilder = []map[string]interface{}{} +} + +func (app *DemoApp) Start() { + var cmd string + fmt.Print(" | CREATE\n | JOIN\nEnter Choice:\n>") + fmt.Scanln(&cmd) + + if cmd != "CREATE" && cmd != "JOIN" { + fmt.Println("Invalid command.") + return + } + + var err error + app.Client, err = NewClient("chat.itzilly.com", uuid.New().String()) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + return + } + + if cmd == "CREATE" { + app.CreateRoom() + } else if cmd == "JOIN" { + err := app.JoinRoom() + if err != nil { + return + } + } + fmt.Printf("[DEV] Joined Branches\n") + + app.Client.ListenForData() + for { + select { + case data, ok := <-app.Client.DataChannel: + if !ok { + fmt.Println("Data channel closed, exiting...") + return + } + app.HandleData(data) + } + } + + fmt.Println("Closing app") +} + +func (app *DemoApp) CreateRoom() { + var err error + code, err := app.Client.CreateRoom("") + if err != nil { + log.Fatal(err) + return + } + fmt.Println("Created room:", code) + + err = app.Client.JoinRoom(code, "password") + if err != nil { + log.Fatal(err) + return + } + println("Connected to room") + + path := os.Getenv("USERPROFILE") + logPath := filepath.Join(path, ".lunarclient", "offline", "multiver", "logs", "latest.log") + + fmt.Println("Reading log file from:", logPath) + + lineCh := make(chan string) + go app.tailFile(logPath, lineCh) + + // TODO: Do this in a different goroutine so that you can still listen to data from your own client + + for { + select { + case line := <-lineCh: + app.onFileEmit(replaceCorruptedRune(line)) + } + } +} + +func (app *DemoApp) JoinRoom() error { + var code string + var password string + fmt.Print("Enter Room Code:\n>") + fmt.Scanln(&code) + + fmt.Print("Enter Room Password:\n>") + fmt.Scanln(&password) + + err := app.Client.JoinRoom(code, password) + if err != nil { + log.Fatal(err) + return err + } + fmt.Println("Joined room:", code) + return nil +} + +func (app *DemoApp) HandleData(data string) { + var playerWrappers []PlayerWrapper + + err := json.Unmarshal([]byte(data), &playerWrappers) + if err != nil { + fmt.Println("Error unmarshalling data:", err) + return + } + + var players []*Player + for _, wrapper := range playerWrappers { + players = append(players, &wrapper.Player) + } + + app.DisplayPlayers(players) +} + +func (app *DemoApp) DisplayPlayers(players []*Player) { + clearTerminal() + + fmt.Printf("| %-20s | %-10s | %-10s |\n", "Player Name", "Bedwars Level", "FKDR") + fmt.Println("|----------------------|------------|------------|") + + for _, player := range players { + fmt.Printf("| %-20s | %-10d | %10.3f |\n", + player.DisplayName, + player.Achievements.BedwarsLevel, + player.Stats.Bedwars.FKDR) + } + + fmt.Println("|----------------------|------------|------------|") +} + +//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// client +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +const PORT = ":5518" + +type PacketID int + +const ( + CONNECT_REQUEST PacketID = iota + CONNECT_RESPONSE + JOIN_REQUEST + JOIN_RESPONSE + CREATE_REQUEST + CREATE_RESPONSE + READ_DATA + SEND_DATA_REQUEST + SEND_DATA_RESPONSE + LEAVE_ROOM +) + +// Client represents a client connection to the server. +type Client struct { + Conn net.Conn + ClientID string + RoomCode string + DataChannel chan string +} + +// NewClient creates a new client and connects to the server. +func NewClient(serverAddr, clientID string) (*Client, error) { + conn, err := net.Dial("tcp", serverAddr+PORT) + if err != nil { + return nil, fmt.Errorf("failed to connect to server: %w", err) + } + return &Client{ + Conn: conn, + ClientID: clientID, + DataChannel: make(chan string), + }, nil +} + +// CreateRoom creates a new room on the server. +func (c *Client) CreateRoom(password string) (string, error) { + request := CreateRoomRequestPacket{ + UserID: c.ClientID, + Password: password, + } + + if err := c.sendPacket(CREATE_REQUEST, request); err != nil { + return "", err + } + + var response CreateRoomResponsePacket + if err := c.receivePacket(CREATE_RESPONSE, &response); err != nil { + return "", err + } + + if !response.Success { + return "", errors.New(response.Reason) + } + + c.RoomCode = response.RoomCode + return response.RoomCode, nil +} + +// JoinRoom joins an existing room on the server. +func (c *Client) JoinRoom(roomCode, password string) error { + request := JoinRequestPacket{ + UserID: c.ClientID, + RoomCode: roomCode, + Password: password, + } + + if err := c.sendPacket(JOIN_REQUEST, request); err != nil { + return err + } + + var response JoinRequestResponsePacket + if err := c.receivePacket(JOIN_RESPONSE, &response); err != nil { + return err + } + + if !response.Success { + return errors.New(response.Reason) + } + + c.RoomCode = roomCode + return nil +} + +func (c *Client) ListenForData() { + go func() { + for { + var dataPacket SendDataRequestPacket + err := c.receivePacket(READ_DATA, &dataPacket) + if err != nil { + if err == io.EOF { + fmt.Println("Connection closed by the server") + close(c.DataChannel) + return + } + fmt.Printf("Error receiving data: %v\n", err) + close(c.DataChannel) + return + } + + // Send the received data to the channel + c.DataChannel <- dataPacket.Data + } + }() +} + +// SendData sends a message to all other clients in the room. +func (c *Client) SendData(data string) error { + request := SendDataRequestPacket{ + UserID: c.ClientID, + Data: data, + } + + if err := c.sendPacket(SEND_DATA_REQUEST, request); err != nil { + return fmt.Errorf("failed to send data packet: %w", err) + } + + var response SendDataResponsePacket + if err := c.receivePacket(SEND_DATA_RESPONSE, &response); err != nil { + return fmt.Errorf("failed to receive response for sent data: %w", err) + } + + if !response.Success { + return errors.New("server failed to process the data request: " + response.Reason) + } + + return nil +} + +// LeaveRoom disconnects the client from the current room. +func (c *Client) LeaveRoom() error { + return c.sendPacket(LEAVE_ROOM, nil) +} + +// Close closes the connection to the server. +func (c *Client) Close() { + c.Conn.Close() +} + +// sendPacket sends a packet with a given ID and data to the server. +func (c *Client) sendPacket(packetID PacketID, data interface{}) error { + packetData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("failed to encode packet: %w", err) + } + return writePacket(c.Conn, byte(packetID), packetData) +} + +// receivePacket reads a response from the server for a given packet ID. +func (c *Client) receivePacket(expected PacketID, v interface{}) error { + packetID, data, err := readPacket(c.Conn) + if err != nil { + return err + } + if packetID != expected { + return fmt.Errorf("unexpected packet ID: got %v, want %v", packetID, expected) + } + return json.Unmarshal(data, v) +} + +// readPacket reads a packet from the connection. +func readPacket(conn net.Conn) (PacketID, []byte, error) { + var length uint32 + if err := binary.Read(conn, binary.BigEndian, &length); err != nil { + return 0, nil, err + } + + var messageType byte + if err := binary.Read(conn, binary.BigEndian, &messageType); err != nil { + return 0, nil, err + } + + data := make([]byte, length-5) + if _, err := io.ReadFull(conn, data); err != nil { + return 0, nil, err + } + + return PacketID(messageType), data, nil +} + +// writePacket writes a packet to the connection. +func writePacket(conn net.Conn, messageType byte, data []byte) error { + length := uint32(5 + len(data)) + if err := binary.Write(conn, binary.BigEndian, length); err != nil { + return err + } + if err := binary.Write(conn, binary.BigEndian, messageType); err != nil { + return err + } + _, err := conn.Write(data) + return err +} + +type CreateRoomRequestPacket struct { + UserID string + Password string +} + +type CreateRoomResponsePacket struct { + Success bool + Reason string + RoomCode string +} + +type JoinRequestPacket struct { + UserID string + RoomCode string + Password string +} + +type JoinRequestResponsePacket struct { + Success bool + Reason string + CurrentData string +} + +type SendDataRequestPacket struct { + UserID string + Data string +} + +type SendDataResponsePacket struct { + Success bool + Reason string +} + +//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// config +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +type FileConfig struct { + Path string + Name string + Type string +} + +func GetDefaultConfigPath() string { + var filePath string + switch os_name := runtime.GOOS; os_name { + case "windows": + appData := os.Getenv("APPDATA") + filePath = path.Join(appData, "hudly", ".conf") + case "darwin": + homePath := os.Getenv("HOME") + filePath = path.Join(homePath, "Library", "Application Support", "hudly", ".conf") + case "linux": + homePath := os.Getenv("HOME") + filePath = path.Join(homePath, ".config", "hudly", ".conf") + default: + panic("unknown operating system") + } + return filePath +} + +type Config struct { + ConfigVersion int + PlayerName string + PlayerUUID string + AltNames []string + AltUUIDs []string + AppFeaturesConfig AppFeaturesConfig +} + +type AppFeaturesConfig struct { + CheckGuildChat bool + CheckOfficerChat bool + CheckPartyChat bool + CheckPartyList bool + CheckLobbyMessages bool + CheckQueueMessages bool + CheckInGameMessages bool +} + +func GetDefaultConfig() *Config { + return &Config{ + ConfigVersion: 1, + PlayerName: "", + PlayerUUID: "", + AltNames: []string{}, + AltUUIDs: []string{}, + AppFeaturesConfig: AppFeaturesConfig{ + CheckGuildChat: true, + CheckOfficerChat: false, + CheckPartyChat: true, + CheckPartyList: true, + CheckLobbyMessages: false, + CheckQueueMessages: true, + CheckInGameMessages: true, + }, + } +} + +// SaveConfig saves the given config struct to the file in JSON format +func SaveConfig(config *Config, filePath string) error { + // Convert the config struct to JSON + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to serialize config: %v", err) + } + + // Write the JSON data to a file + err = ioutil.WriteFile(filePath, data, 0644) + if err != nil { + return fmt.Errorf("failed to write config to file: %v", err) + } + + return nil +} + +// LoadConfig loads the config from the given file path +func LoadConfig(filePath string) (*Config, error) { + // Check if the file exists + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return nil, fmt.Errorf("config file does not exist: %s", filePath) + } + + // Read the file content + data, err := ioutil.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %v", err) + } + + // Create a Config object + config := &Config{} + + // Deserialize the JSON data into the Config object + err = json.Unmarshal(data, config) + if err != nil { + return nil, fmt.Errorf("failed to deserialize config: %v", err) + } + + return config, nil +} + +//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// api +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +const BASE_URL = "https://api.hypixel.net/v2/" +const PLAYER_ENDPOINT_URL = "player?" + +type PlayerResponse struct { + Success bool `json:"success"` + Cause string `json:"cause,omitempty"` + Player Player `json:"player"` +} + +type HypixelApi struct { + Key APIKey +} + +func NewAPI(apiKey APIKey) *HypixelApi { + return &HypixelApi{ + Key: apiKey, + } +} + +func (api *HypixelApi) GetPlayer(uuid string) (*Player, error) { + _, err := api.canDoRequest() + if err != nil { + return nil, err + } + _, err = api.GetPlayerResponse(uuid) + if err != nil { + return nil, err + } + return nil, nil +} + +func (api *HypixelApi) GetPlayerResponse(uuid string) (*PlayerResponse, error) { + _, err := api.canDoRequest() + if err != nil { + return nil, err + } + + url := BASE_URL + PLAYER_ENDPOINT_URL + "uuid=" + uuid + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Add("API-Key", raw_key) + + playerResponse, err := api.doPlayerRequest(req) + + if !playerResponse.Success { + return nil, NewSomeError(playerResponse.Cause, url) + } + + return playerResponse, nil +} + +func (api *HypixelApi) canDoRequest() (bool, error) { + return true, nil + if api.Key.UsesLeft < 1 { + return false, NewAPIKeyRateLimitedException(raw_key, api.Key.UsesLeft, "Key throttle") + } + return true, nil +} + +func (api *HypixelApi) doPlayerRequest(request *http.Request) (*PlayerResponse, error) { + res, err := http.DefaultClient.Do(request) + if err != nil { + return nil, err + } + usesLeft, err := strconv.Atoi(res.Header.Get("ratelimit-remaining")) + + resetStr := res.Header.Get("ratelimit-reset") + resetInt, err := strconv.ParseInt(resetStr, 10, 64) + + if err != nil { + log.Fatalf("Error parsing ratelimit-reset: %v", err) + } + + resetTime := time.Unix(resetInt, 0) + + api.Key.UsesLeft = usesLeft + api.Key.ResetDelay = resetTime.Sub(time.Now()) + api.Key.TotalUses++ + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var playerResponse PlayerResponse + err = json.Unmarshal(body, &playerResponse) + if err != nil { + return nil, err + } + return &playerResponse, nil +} + +//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// api +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +var raw_key = "" + +type APIKey struct { + TotalUses int + UsesLeft int + ResetDelay time.Duration +} + +func NewAPIKey(key string) *APIKey { + raw_key = key + k := &APIKey{TotalUses: 0, UsesLeft: 0, ResetDelay: 0} + _, err := k.TestKey() + if err != nil { + return k + } + return k +} + +func (api *APIKey) TestKey() (bool, error) { + url := BASE_URL + PLAYER_ENDPOINT_URL + "uuid=" + "d245a6e2-349d-405a-b801-48f06d39c9a9" // tqrm + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, err + } + req.Header.Add("API-Key", raw_key) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return false, err + } + usesLeft, err := strconv.Atoi(res.Header.Get("ratelimit-remaining")) + + resetStr := res.Header.Get("ratelimit-reset") + resetInt, err := strconv.ParseInt(resetStr, 10, 64) + + if err != nil { + log.Fatalf("Error parsing ratelimit-reset: %v", err) + } + + resetTime := time.Unix(resetInt, 0) + + api.UsesLeft = usesLeft + api.ResetDelay = resetTime.Sub(time.Now()) + api.TotalUses++ + + body, err := io.ReadAll(res.Body) + if err != nil { + return false, err + } + defer res.Body.Close() + + var playerResponse PlayerResponse + err = json.Unmarshal(body, &playerResponse) + if err != nil { + return false, err + } + + if playerResponse.Success { + return true, nil + } + return false, NewSomeError(url, playerResponse.Cause) +} + +//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// player +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +type Player struct { + ID string `json:"id"` + UUID string `json:"uuid"` + PlayerName string `json:"playername"` + DisplayName string `json:"displayname"` + NetworkExp json.Number `json:"networkExp"` + KarmaExp json.Number `json:"karma"` + AchievementPoints json.Number `json:"achievementPoints"` + UserLanguage string `json:"userLanguage"` + RankPlusColor string `json:"rankPlusColor"` + + Achievements Achievements `json:"achievements"` + Stats Stats `json:"stats"` +} + +type Achievements struct { + BedwarsLevel int `json:"bedwars_level"` +} + +type Stats struct { + Bedwars Bedwars `json:"Bedwars"` +} + +type Bedwars struct { + Experience json.Number `json:"Experience"` + CurrentWinstreak int `json:"winstreak"` // If this isn't here, they have winstreak disabled (flag) + + Wins int `json:"wins_bedwars"` + Losses int `json:"losses_bedwars"` + WLR float64 + + Kills int `json:"kills_bedwars"` + Deaths int `json:"deaths_bedwars"` + KDR float64 + + FinalKills int `json:"final_kills_bedwars"` + FinalDeaths int `json:"final_deaths_bedwars"` + FKDR float64 + + BedsBroken int `json:"beds_broken_bedwars"` + BedsLost int `json:"beds_lost_bedwars"` + BBLR float64 +} + +// TODO: set defaults (kdr, fkdr, bblr, wlr) + +//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// response_errors +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// Errors: +// SomeError - Couldn't reach the endpoint (maybe internet issues or blocked from hypxiel? or cloudflare is down, etc) +// APIKeyInvalid - Thrown when the key is invalid +// APIKeyRateLimited - Thrown when you're being rate limited (reached your call limit) +// PlayerNotFoundError - When hypxiel can't find the target player +// MalformedUuid - When hypixel returns a malformed UUID error +// MalformedPlayer - When hypixel returns a malformed player error +// PlayerNotFound - When a target player isn't found (usually by uuid) +// TimeOutError - When the api call takes longer than the specified timeout to return +// JsonUnmartialingError - When custom unmartialing functions throw errors (NOT a wrapper for generic json unmartial error) +// UnknownError - When something that isn't already here happens + +// SomeError is thrown when the endpoint cannot be reached (e.g., internet issues, blocked from Hypixel, or Cloudflare is down) +type SomeError struct { + URL string + Cause string +} + +// NewSomeError creates a new instance of SomeError +func NewSomeError(url, cause string) *SomeError { + return &SomeError{ + URL: url, + Cause: cause, + } +} + +// Error returns the error message for SomeError +func (e *SomeError) Error() string { + return fmt.Sprintf("Unable to reach endpoint: URL: '%s', Cause: '%s'", e.URL, e.Cause) +} + +// IsSomeError checks if the error is a SomeError +func IsSomeError(err error) bool { + var targetErr *SomeError + return errors.As(err, &targetErr) +} + +// APIKeyInvalidException is thrown when the HypixelApi key is invalid +type APIKeyInvalidException struct { + APIKey string + Cause string +} + +// NewAPIKeyInvalidException creates a new instance of APIKeyInvalidException +func NewAPIKeyInvalidException(apiKey, cause string) *APIKeyInvalidException { + return &APIKeyInvalidException{ + APIKey: apiKey, + Cause: cause, + } +} + +// PlayerNotFoundException is thrown when the HypixelApi cannot find the target player +type PlayerNotFoundException struct { + URL string + PlayerID string + Cause string +} + +// NewPlayerNotFoundException helper function to create PlayerNotFoundException error +func NewPlayerNotFoundException(url, id, cause string) *PlayerNotFoundException { + return &PlayerNotFoundException{ + URL: url, + PlayerID: id, + Cause: cause, + } +} + +// Error returns the error message for PlayerNotFoundException +func (e *PlayerNotFoundException) Error() string { + return fmt.Sprintf("Player not found: Player ID: '%s', fetch cause: '%s', fetched at URL: '%s'", e.PlayerID, e.Cause, e.URL) +} + +// IsPlayerNotFoundException checks if the error is a PlayerNotFoundException error +func IsPlayerNotFoundException(err error) bool { + var targetErr *PlayerNotFoundException + return errors.As(err, &targetErr) +} + +// Error returns the error message for APIKeyInvalidException +func (e *APIKeyInvalidException) Error() string { + return fmt.Sprintf("HypixelApi key invalid: Key: '%s', Cause: '%s'", e.APIKey, e.Cause) +} + +// IsAPIKeyInvalidException checks if the error is an APIKeyInvalidException +func IsAPIKeyInvalidException(err error) bool { + var targetErr *APIKeyInvalidException + return errors.As(err, &targetErr) +} + +// APIKeyRateLimitedException is thrown when the HypixelApi key has reached its call limit +type APIKeyRateLimitedException struct { + APIKey string + RateLimit int + Cause string +} + +// NewAPIKeyRateLimitedException creates a new instance of APIKeyRateLimitedException +func NewAPIKeyRateLimitedException(apiKey string, rateLimit int, cause string) *APIKeyRateLimitedException { + return &APIKeyRateLimitedException{ + APIKey: apiKey, + RateLimit: rateLimit, + Cause: cause, + } +} + +// Error returns the error message for APIKeyRateLimitedException +func (e *APIKeyRateLimitedException) Error() string { + return fmt.Sprintf("HypixelApi key rate limited: Key: '%s', Rate Limit: '%d', Cause: '%s'", e.APIKey, e.RateLimit, e.Cause) +} + +// IsAPIKeyRateLimitedException checks if the error is an APIKeyRateLimitedException +func IsAPIKeyRateLimitedException(err error) bool { + var targetErr *APIKeyRateLimitedException + return errors.As(err, &targetErr) +} + +// MalformedUUIDException is thrown when Hypixel returns a malformed UUID error +type MalformedUUIDException struct { + UUID string + Cause string +} + +// NewMalformedUUIDException creates a new instance of MalformedUUIDException +func NewMalformedUUIDException(uuid, cause string) *MalformedUUIDException { + return &MalformedUUIDException{ + UUID: uuid, + Cause: cause, + } +} + +// Error returns the error message for MalformedUUIDException +func (e *MalformedUUIDException) Error() string { + return fmt.Sprintf("Malformed UUID: UUID: '%s', Cause: '%s'", e.UUID, e.Cause) +} + +// IsMalformedUUIDException checks if the error is a MalformedUUIDException +func IsMalformedUUIDException(err error) bool { + var targetErr *MalformedUUIDException + return errors.As(err, &targetErr) +} + +// MalformedPlayerException is thrown when Hypixel returns a malformed player error +type MalformedPlayerException struct { + PlayerData string + Cause string +} + +// NewMalformedPlayerException creates a new instance of MalformedPlayerException +func NewMalformedPlayerException(playerData, cause string) *MalformedPlayerException { + return &MalformedPlayerException{ + PlayerData: playerData, + Cause: cause, + } +} + +// Error returns the error message for MalformedPlayerException +func (e *MalformedPlayerException) Error() string { + return fmt.Sprintf("Malformed player data: Data: '%s', Cause: '%s'", e.PlayerData, e.Cause) +} + +// IsMalformedPlayerException checks if the error is a MalformedPlayerException +func IsMalformedPlayerException(err error) bool { + var targetErr *MalformedPlayerException + return errors.As(err, &targetErr) +} + +// TimeOutException is thrown when the HypixelApi call takes longer than the specified timeout to return +type TimeOutException struct { + Duration time.Duration + URL string + Cause string +} + +// NewTimeOutException creates a new instance of TimeOutException +func NewTimeOutException(duration time.Duration, url, cause string) *TimeOutException { + return &TimeOutException{ + Duration: duration, + URL: url, + Cause: cause, + } +} + +// Error returns the error message for TimeOutException +func (e *TimeOutException) Error() string { + return fmt.Sprintf("Request timed out after '%s' when accessing URL '%s': Cause: '%s'", e.Duration, e.URL, e.Cause) +} + +// IsTimeOutException checks if the error is a TimeOutException +func IsTimeOutException(err error) bool { + var targetErr *TimeOutException + return errors.As(err, &targetErr) +} + +// JSONUnmarshallingException is thrown when custom unmarshalling functions encounter errors +type JSONUnmarshallingException struct { + Data string + Cause string +} + +// NewJSONUnmarshallingException creates a new instance of JSONUnmarshallingException +func NewJSONUnmarshallingException(data, cause string) *JSONUnmarshallingException { + return &JSONUnmarshallingException{ + Data: data, + Cause: cause, + } +} + +// Error returns the error message for JSONUnmarshallingException +func (e *JSONUnmarshallingException) Error() string { + return fmt.Sprintf("JSON unmarshalling error: Data: '%s', Cause: '%s'", e.Data, e.Cause) +} + +// IsJSONUnmarshallingException checks if the error is a JSONUnmarshallingException +func IsJSONUnmarshallingException(err error) bool { + var targetErr *JSONUnmarshallingException + return errors.As(err, &targetErr) +} + +// UnknownErrorException is thrown when an unspecified error occurs +type UnknownErrorException struct { + Cause string +} + +// NewUnknownErrorException creates a new instance of UnknownErrorException +func NewUnknownErrorException(cause string) *UnknownErrorException { + return &UnknownErrorException{ + Cause: cause, + } +} + +// Error returns the error message for UnknownErrorException +func (e *UnknownErrorException) Error() string { + return fmt.Sprintf("Unknown error occurred: Cause: '%s'", e.Cause) +} + +// IsUnknownErrorException checks if the error is an UnknownErrorException +func IsUnknownErrorException(err error) bool { + var targetErr *UnknownErrorException + return errors.As(err, &targetErr) +} + +//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// async player fetcher +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// AsyncPlayerFetcher is responsible for fetching Minecraft player data asynchronously +type AsyncPlayerFetcher struct { + playerName string + retries int + retryDelay time.Duration + timeout time.Duration + cache ICache +} + +// NewAsyncPlayerFetcher creates a new AsyncPlayerFetcher with an abstract cache (ICache) +func NewAsyncPlayerFetcher(playerName string, cache ICache, retries int, retryDelay time.Duration, timeout time.Duration) *AsyncPlayerFetcher { + cache.Init() + return &AsyncPlayerFetcher{ + playerName: playerName, + retries: retries, + retryDelay: retryDelay, + timeout: timeout, + cache: cache, + } +} + +// FetchPlayerData fetches the player data asynchronously using channels +func (pf *AsyncPlayerFetcher) FetchPlayerData(resultChan chan *FetchedPlayerResult, errorChan chan error) { + go func() { + cachedData, found := pf.cache.Get(pf.playerName) + if found { + resultChan <- (*FetchedPlayerResult)(cachedData) + return + } + + // If not in cache, make request to Mojang API + var player FetchedPlayerResult + for i := 0; i < pf.retries; i++ { + resp, err := pf.makeRequest(pf.playerName) + if err == nil { + defer resp.Body.Close() + // Decode the response into FetchedPlayerResult + if err := json.NewDecoder(resp.Body).Decode(&player); err == nil { + // Store the result in the cache and return the data + pf.cache.Set(pf.playerName, (*CacheResult)(&player)) + pf.cache.Sync() + resultChan <- &player + return + } + } + time.Sleep(pf.retryDelay) + } + errorChan <- errors.New("Failed to fetch player data after retries") + }() +} + +// makeRequest performs the HTTP request to Mojang API +func (pf *AsyncPlayerFetcher) makeRequest(playerName string) (*http.Response, error) { + client := http.Client{Timeout: pf.timeout} + url := "https://api.mojang.com/users/profiles/minecraft/" + playerName + resp, err := client.Get(url) + if err != nil || resp.StatusCode != http.StatusOK { + return nil, errors.New("Request failed") + } + return resp, nil +} + +//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// cache +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +type CacheResult struct { + UUID string `json:"id"` + Name string `json:"name"` +} + +type ICache interface { + Init() + Load() + Get(key string) (*CacheResult, bool) + Set(key string, data *CacheResult) + Save() + Sync() + Purge() + Clear() +} + +// MemoryCache implementation +type MemoryCache struct { + cache map[string]*CacheResult + mu sync.RWMutex +} + +// Init initializes the cache (no-op for MemoryCache) +func (c *MemoryCache) Init() { + c.cache = make(map[string]*CacheResult) +} + +// Load loads the cache (no-op for MemoryCache) +func (c *MemoryCache) Load() {} + +// Get retrieves an item from the cache +func (c *MemoryCache) Get(key string) (*CacheResult, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + value, found := c.cache[key] + if !found { + return nil, false + } + return value, true +} + +// Set stores an item in the cache +func (c *MemoryCache) Set(key string, data *CacheResult) { + c.mu.Lock() + defer c.mu.Unlock() + c.cache[key] = data +} + +// Save saves the cache (no-op for MemoryCache) +func (c *MemoryCache) Save() {} + +// Sync syncs the cache (no-op for MemoryCache) +func (c *MemoryCache) Sync() {} + +// Purge will be implemented later +func (c *MemoryCache) Purge() {} + +// Clear clears the cache +func (c *MemoryCache) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.cache = make(map[string]*CacheResult) +} + +// JsonFileCache implementation +type JsonFileCache struct { + filename string + cache map[CacheResult]interface{} + mu sync.RWMutex +} + +// Init initializes the cache +func (c *JsonFileCache) Init() { + c.cache = make(map[CacheResult]interface{}) +} + +// Load loads the cache from a JSON file +func (c *JsonFileCache) Load() { + c.mu.Lock() + defer c.mu.Unlock() + + file, err := os.Open(c.filename) + if err != nil { + log.Println("Error opening file:", err) + return + } + defer file.Close() + + byteValue, err := io.ReadAll(file) + if err != nil { + log.Println("Error reading file:", err) + return + } + + err = json.Unmarshal(byteValue, &c.cache) + if err != nil { + log.Println("Error unmarshalling JSON:", err) + } +} + +// Get retrieves an item from the cache +func (c *JsonFileCache) Get(key string) (*CacheResult, bool) { + //c.mu.RLock() + //defer c.mu.RUnlock() + //value, found := c.cache[key] + //if !found { + // return nil, false + //} + //return value, true + return nil, false +} + +// Set stores an item in the cache +func (c *JsonFileCache) Set(key string, data *CacheResult) { + //c.mu.Lock() + //defer c.mu.Unlock() + //c.cache[key] = data +} + +// Save saves the cache to a JSON file +func (c *JsonFileCache) Save() { + c.mu.Lock() + defer c.mu.Unlock() + + byteValue, err := json.MarshalIndent(c.cache, "", " ") + if err != nil { + log.Println("Error marshalling JSON:", err) + return + } + + err = os.WriteFile(c.filename, byteValue, 0644) + if err != nil { + log.Println("Error writing file:", err) + } +} + +// Sync is the same as Save for the JsonFileCache +func (c *JsonFileCache) Sync() { + c.Save() +} + +// Purge will be implemented later +func (c *JsonFileCache) Purge() {} + +// Clear clears the cache +func (c *JsonFileCache) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.cache = make(map[CacheResult]interface{}) +} + +//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// player fetcher +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// PlayerFetcher is responsible for fetching Minecraft player data synchronously +type PlayerFetcher struct { + playerName string + retries int + retryDelay time.Duration + timeout time.Duration + cache ICache +} + +type FetchedPlayerResult struct { + UUID string `json:"id"` + Name string `json:"name"` +} + +// NewPlayerFetcher creates a new PlayerFetcher with an abstract cache (ICache) +func NewPlayerFetcher(playerName string, cache ICache, retries int, retryDelay time.Duration, timeout time.Duration) *PlayerFetcher { + cache.Init() + return &PlayerFetcher{ + playerName: playerName, + retries: retries, + retryDelay: retryDelay, + timeout: timeout, + cache: cache, + } +} + +// FetchPlayerData fetches the player data synchronously +func (pf *PlayerFetcher) FetchPlayerData() (*FetchedPlayerResult, error) { + //cachedData, found := pf.cache.Get(pf.playerName) + //if found { + // return &FetchedPlayerResult{}, nil + //} + + var player FetchedPlayerResult + for i := 0; i < pf.retries; i++ { + resp, err := pf.makeRequest(pf.playerName) + if err == nil { + defer resp.Body.Close() + if err := json.NewDecoder(resp.Body).Decode(&player); err == nil { + // pf.cache.Set(pf.playerName, player) + // pf.cache.Sync() + return &player, nil + } + } + time.Sleep(pf.retryDelay) + } + return nil, errors.New("Failed to fetch player data after retries") +} + +// makeRequest performs the HTTP request to Mojang API +func (pf *PlayerFetcher) makeRequest(playerName string) (*http.Response, error) { + client := http.Client{Timeout: pf.timeout} + url := "https://api.mojang.com/users/profiles/minecraft/" + playerName + resp, err := client.Get(url) + if err != nil || resp.StatusCode != http.StatusOK { + return nil, errors.New("Request failed") + } + return resp, nil +} + +//- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +// tools +// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +// IsValidUsername checks if the username is valid +func IsValidUsername(username string) bool { + if len(username) < 3 || len(username) > 16 { + return false + } + validChars := "abcdefghijklmnopqrstuvwxyz1234567890_" + for _, char := range strings.ToLower(username) { + if !strings.ContainsRune(validChars, char) { + return false + } + } + return true +} + +// IsValidUUID checks if a UUID is valid +func IsValidUUID(uuid string) bool { + matched, _ := regexp.MatchString("^[0-9a-fA-F]{32}$", strings.ReplaceAll(uuid, "-", "")) + return matched +} + +// UndashUUID removes dashes from UUIDs +func UndashUUID(uuid string) string { + return strings.ReplaceAll(uuid, "-", "") +} + +// DashUUID adds dashes to UUIDs at standard positions +func DashUUID(uuid string) string { + return uuid[:8] + "-" + uuid[8:12] + "-" + uuid[12:16] + "-" + uuid[16:20] + "-" + uuid[20:] +} diff --git a/app/app.go b/app/app.go deleted file mode 100644 index 06ab7d0..0000000 --- a/app/app.go +++ /dev/null @@ -1 +0,0 @@ -package main diff --git a/app/demo.go b/app/demo.go new file mode 100644 index 0000000..2c8f9ec --- /dev/null +++ b/app/demo.go @@ -0,0 +1,8 @@ +package main + +var key = "f6999283-43ba-413e-a04d-32dbde98f423" + +func main() { + var demoApp = NewDemoApp(key) + demoApp.Start() +} diff --git a/app/logbuf.go b/app/logbuf.go new file mode 100644 index 0000000..7a27afc --- /dev/null +++ b/app/logbuf.go @@ -0,0 +1,50 @@ +package main + +import "fmt" + +type LogBuffer struct { + strings []string + size int +} + +func NewLogBuffer(size int) *LogBuffer { + return &LogBuffer{ + strings: make([]string, 0, size), + size: size, + } +} + +func (l *LogBuffer) Add(s string) { + if len(l.strings) == l.size { + l.strings = l.strings[1:] + } + l.strings = append(l.strings, s) +} + +func (l *LogBuffer) Get() []string { + return l.strings +} + +func (l *LogBuffer) GetLast() (string, error) { + if len(l.strings) == 0 { + return "", fmt.Errorf("log buffer is empty") + } + return l.strings[len(l.strings)-1], nil +} + +func (l *LogBuffer) GetSecondToLast() (string, error) { + if len(l.strings) < 2 { + return "", fmt.Errorf("log buffer does not have enough lines") + } + return l.strings[len(l.strings)-2], nil +} + +func (l *LogBuffer) GetLineStepsBack(x int) (string, error) { + if x < 0 || x >= len(l.strings) { + return "", fmt.Errorf("log buffer does not have enough lines to step back %d times", x) + } + return l.strings[len(l.strings)-1-x], nil +} + +// +//var LogBuf = NewLogBuffer(10) diff --git a/app/logger.go b/app/logger.go new file mode 100644 index 0000000..1f02f91 --- /dev/null +++ b/app/logger.go @@ -0,0 +1,60 @@ +package main + +import ( + "fmt" + "log" + "os" + "runtime" + "time" +) + +var AppLogger = NewCustomLogger() + +// CustomLogger wraps the standard logger +type CustomLogger struct { + logger *log.Logger +} + +// NewCustomLogger initializes the custom logger +func NewCustomLogger() *CustomLogger { + // Create a logger that writes to stdout with no flags + return &CustomLogger{ + logger: log.New(os.Stdout, "", 0), // we will handle formatting manually + } +} + +// logFormat retrieves the function name, file, and line number +func logFormat() string { + // Use the runtime.Caller to retrieve caller info + pc, file, line, ok := runtime.Caller(2) + if !ok { + file = "unknown" + line = 0 + } + // Get function name + funcName := runtime.FuncForPC(pc).Name() + + // Format timestamp + timestamp := time.Now().Format("2006-01-02 15:04:05") + + // Return formatted log prefix (timestamp, file, line, and function name) + return fmt.Sprintf("%s - %s:%d - %s: ", timestamp, file, line, funcName) +} + +// Info logs informational messages +func (c *CustomLogger) Info(msg string) { + c.logger.Println(logFormat() + "INFO: " + msg) +} + +// Error logs error messages +func (c *CustomLogger) Error(msg string) { + c.logger.Println(logFormat() + "ERROR: " + msg) +} + +// Example usage of the custom logger +func showcase() { + logger := NewCustomLogger() + + logger.Info("This is an info message") + logger.Error("This is an error message") +} diff --git a/app/main.go b/app/main.go index 1b6ffab..d4a995b 100644 --- a/app/main.go +++ b/app/main.go @@ -1,75 +1,404 @@ package main import ( + "bufio" + "encoding/json" "fmt" + "github.com/google/uuid" + netclient "hudly/client" "hudly/hypixel" "hudly/mcfetch" "log" "os" + "os/exec" + "path/filepath" + "runtime" + "strings" "time" ) -var key = "9634ea92-80f0-482f-aebd-b082c6ed6f19" -var uuid = "5328930e-d411-49cb-90ad-4e5c7b27dd86" +type PlayerWrapper struct { + Player hypixel.Player `json:"player"` +} -func demo() { - // Ensure a username is provided as a command-line argument - if len(os.Args) < 2 { - log.Fatal("Please provide a Minecraft username as a command-line argument.") +func replaceCorruptedRune(msg string) string { + runes := []rune(msg) + for i, r := range runes { + if r == '�' { + runes[i] = '§' + } + } + return string(runes) +} + +func clearTerminal() { + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/c", "cls") + } else { + cmd = exec.Command("clear") + } + cmd.Stdout = os.Stdout + cmd.Run() +} + +func calcRatio(numerator, denominator int) float64 { + if denominator == 0 { + return float64(numerator) + } + return float64(numerator) / float64(denominator) +} + +type DemoApp struct { + Client *netclient.Client + API *hypixel.HypixelApi + MemCache *mcfetch.MemoryCache + LogBuf *LogBuffer + PartyBuilder []map[string]interface{} +} + +func NewDemoApp(key string) *DemoApp { + var api_key = hypixel.NewAPIKey(key) + app := &DemoApp{ + API: hypixel.NewAPI(*api_key), + MemCache: &mcfetch.MemoryCache{}, + LogBuf: NewLogBuffer(10), + PartyBuilder: []map[string]interface{}{}, + } + app.MemCache.Init() + return app +} + +func (app *DemoApp) FetchMCPlayer(name string) (*mcfetch.FetchedPlayerResult, error) { + asyncFetcher := mcfetch.NewPlayerFetcher( + name, + app.MemCache, + 2, + 2*time.Second, + 5*time.Second, + ) + data, err := asyncFetcher.FetchPlayerData() + if err != nil { + return nil, err + } + return data, nil +} + +func (app *DemoApp) onFileEmit(line string) { + msg := strings.TrimSpace(line) + if len(msg) < 34 { + return + } + submsg := msg[33:] + if len(submsg) != 0 { + app.LogBuf.Add(submsg) } - // Get the username from the command-line arguments - username := os.Args[1] + OnlinePrefix := "[CHAT] ONLINE: " + PartyListSeparatorLinePrefix := "[CHAT] -----------------------------------------------------" + //PartyMemberCountPrefix := "[CHAT] Party Members (" + PartyLeaderPrefix := "[CHAT] Party Leader: " + PartyListMembersPrefix := "[CHAT] Party Members: " - thing := hypixel.NewAPIKey(key) - api := hypixel.NewAPI(*thing) - thing.UsesLeft = 11 + if strings.HasPrefix(submsg, OnlinePrefix) { // Online Message + newsubmsg := strings.TrimPrefix(submsg, OnlinePrefix) + players := strings.Split(newsubmsg, ",") + var online []mcfetch.CacheResult + for _, player := range players { + playerName := strings.TrimSpace(player) + plr, err := app.FetchMCPlayer(playerName) + res_name := plr.Name + res_uuid := plr.UUID + if err != nil { + fmt.Println(fmt.Sprintf("Error fetching UUID: %v", err)) + continue + } + fmt.Printf("UUID of player %s: %s\n", res_name, res_uuid) + res_player := mcfetch.CacheResult{ + UUID: plr.UUID, + Name: plr.Name, + } + online = append(online, res_player) + //names, err := GetNameFromUUID(playerUUID) + //if err != nil { + // log.Fatalf("Error fetching names from UUID: %v", err) + //} + //fmt.Printf("Name history for UUID %s: %v\n", playerUUID, names) + } + app.sendPartyList(online) - // Create a MemoryCache instance - memCache := &mcfetch.MemoryCache{} - memCache.Init() - - // Create a channel to receive the result - resultChan := make(chan map[string]interface{}) - errorChan := make(chan error) - - // Create an AsyncPlayerFetcher for asynchronous data fetching with MemoryCache - asyncFetcher := mcfetch.NewAsyncPlayerFetcher( - username, // Minecraft username or UUID - memCache, // Pass the memory cache instance - 2, // Number of retries - 2*time.Second, // Retry delay - 5*time.Second, // Request timeout - ) - - // Start asynchronous data fetching - asyncFetcher.FetchPlayerData(resultChan, errorChan) - - // Non-blocking code execution (do something else while waiting) - fmt.Println("Fetching data asynchronously...") - - var userID string - // Block until we receive data or an error - select { - case data := <-resultChan: - fmt.Printf("Player data: %+v\n", data) - - // Check if "uuid" exists and is not nil - if uuid, ok := data["id"].(string); ok { - userID = uuid - } else { - fmt.Println(fmt.Sprintf("%+v", data)) - log.Fatal("UUID not found or invalid for player") + } else if strings.HasPrefix(submsg, PartyListSeparatorLinePrefix) { // Party List + last, _ := app.LogBuf.GetSecondToLast() + // TODO: Check if moderators + if !strings.HasPrefix(last, PartyListMembersPrefix) { + return } - case err := <-errorChan: - log.Fatal(err) + PartyMembersMsg := strings.TrimPrefix(last, PartyListMembersPrefix) + var ppl []mcfetch.CacheResult + for _, player := range strings.Split(PartyMembersMsg, ",") { + playerName := strings.TrimSpace(strings.TrimSuffix(player, " ?")) + if strings.HasPrefix(playerName, "[") { + playerName = strings.Split(playerName, " ")[1] + } + plr, err := app.FetchMCPlayer(playerName) + if err != nil { + log.Fatalf("Error fetching Player: %v", err) + continue + } + res_name := plr.Name + res_uuid := plr.UUID + + res_player := mcfetch.CacheResult{ + UUID: plr.UUID, + Name: plr.Name, + } + + fmt.Printf("UUID of player %s: %s\n", res_name, res_uuid) + ppl = append(ppl, res_player) + + //playerName := strings.TrimSpace(player) + //playerUUID, err := GetUUIDFromName(playerName) + //if err != nil { + // log.Fatalf("Error fetching UUID: %v", err) + // return + //} + //fmt.Printf("UUID of player %s: %s\n", playerName, playerUUID) + //cachedPlayer := CachedUuid{playerUUID, playerName, playerName, time.Now()} + //UuidCache.Add(&cachedPlayer) + } + + // Parse Party Leader + leaders_msg, err := app.LogBuf.GetLineStepsBack(2) + if err != nil { + println("Unable to find party leader message") + return + } + PartyLeaderMsg := strings.TrimPrefix(leaders_msg, PartyLeaderPrefix) + playerName := strings.TrimSpace(strings.TrimSuffix(PartyLeaderMsg, " ?")) + if strings.HasPrefix(playerName, "[") { + playerName = strings.Split(playerName, " ")[1] + } + + plr, err := app.FetchMCPlayer(playerName) + if err != nil { + log.Fatalf("Error fetching Player: %v", err) + } + res_name := plr.Name + res_uuid := plr.UUID + + res_player := mcfetch.CacheResult{ + UUID: plr.UUID, + Name: plr.Name, + } + + fmt.Printf("UUID of player %s: %s\n", res_name, res_uuid) + ppl = append(ppl, res_player) + + // Parse Party Count + //party_count_msg, err := LogBuf.GetLineStepsBack(4) + //if err != nil { + // println("Unable to find party count message") + // return + //} + //PartyCountMsg := strings.TrimPrefix(party_count_msg, PartyMemberCountPrefix) + //count_str := strings.TrimSuffix(PartyCountMsg, ")") + //count, err := strconv.Atoi(count_str) + //if err != nil { + // println("Unable to parse party count message - Invalid number used") + // return + //} + //print("Expected ") + //print(count) + //print(" party members\n") + app.sendPartyList(ppl) + return } - // Use the Hypixel API to get additional player data - res, err := api.GetPlayerResponse(userID) - if err != nil { - panic(err) - } - fmt.Println(fmt.Sprintf("%+v", res)) + println(submsg) +} + +func (app *DemoApp) tailFile(path string, lineCh chan<- string) { + file, err := os.Open(path) + if err != nil { + log.Fatalf("Failed to open file: %v", err) + } + defer file.Close() + + file.Seek(0, 2) + + reader := bufio.NewReader(file) + + for { + line, err := reader.ReadString('\n') + if err != nil { + time.Sleep(100 * time.Millisecond) + continue + } + lineCh <- line + } +} + +func (app *DemoApp) sendPartyList(ppl []mcfetch.CacheResult) { + for _, user := range ppl { + res, err := app.API.GetPlayerResponse(user.UUID) + if err != nil { + log.Fatalf("Failed to get player data: %v", err) + } + + res.Player.Stats.Bedwars.WLR = calcRatio(res.Player.Stats.Bedwars.Wins, res.Player.Stats.Bedwars.Losses) + res.Player.Stats.Bedwars.KDR = calcRatio(res.Player.Stats.Bedwars.Kills, res.Player.Stats.Bedwars.Deaths) + res.Player.Stats.Bedwars.FKDR = calcRatio(res.Player.Stats.Bedwars.FinalKills, res.Player.Stats.Bedwars.FinalDeaths) + res.Player.Stats.Bedwars.BBLR = calcRatio(res.Player.Stats.Bedwars.BedsBroken, res.Player.Stats.Bedwars.BedsLost) + + playerJSON, err := json.Marshal(res) + if err != nil { + log.Fatalf("Failed to marshal player data: %v", err) + } + + var playerMap map[string]interface{} + json.Unmarshal(playerJSON, &playerMap) + app.PartyBuilder = append(app.PartyBuilder, playerMap) + + message, err := json.Marshal(app.PartyBuilder) + if err != nil { + log.Fatalf("Failed to marshal stuff: %v", err) + } + err = app.Client.SendData(string(message)) + if err != nil { + log.Printf("Error sending data: %v", err) + } + fmt.Println("Sent stuff:", app.PartyBuilder) + println("Sending Done!") + } + + // Clear buffer so it only sends the current party list, not previous party lists + app.PartyBuilder = []map[string]interface{}{} +} + +func (app *DemoApp) Start() { + var cmd string + fmt.Print(" | CREATE\n | JOIN\nEnter Choice:\n>") + fmt.Scanln(&cmd) + + if cmd != "CREATE" && cmd != "JOIN" { + fmt.Println("Invalid command.") + return + } + + var err error + app.Client, err = netclient.NewClient("chat.itzilly.com", uuid.New().String()) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + return + } + + if cmd == "CREATE" { + app.CreateRoom() + } else if cmd == "JOIN" { + err := app.JoinRoom() + if err != nil { + return + } + } + fmt.Printf("[DEV] Joined Branches\n") + + app.Client.ListenForData() + for { + select { + case data, ok := <-app.Client.DataChannel: + if !ok { + fmt.Println("Data channel closed, exiting...") + return + } + app.HandleData(data) + } + } + + fmt.Println("Closing app") +} + +func (app *DemoApp) CreateRoom() { + var err error + code, err := app.Client.CreateRoom("") + if err != nil { + log.Fatal(err) + return + } + fmt.Println("Created room:", code) + + err = app.Client.JoinRoom(code, "password") + if err != nil { + log.Fatal(err) + return + } + println("Connected to room") + + path := os.Getenv("USERPROFILE") + logPath := filepath.Join(path, ".lunarclient", "offline", "multiver", "logs", "latest.log") + + fmt.Println("Reading log file from:", logPath) + + lineCh := make(chan string) + go app.tailFile(logPath, lineCh) + + // TODO: Do this in a different goroutine so that you can still listen to data from your own client + + for { + select { + case line := <-lineCh: + app.onFileEmit(replaceCorruptedRune(line)) + } + } +} + +func (app *DemoApp) JoinRoom() error { + var code string + var password string + fmt.Print("Enter Room Code:\n>") + fmt.Scanln(&code) + + fmt.Print("Enter Room Password:\n>") + fmt.Scanln(&password) + + err := app.Client.JoinRoom(code, password) + if err != nil { + log.Fatal(err) + return err + } + fmt.Println("Joined room:", code) + return nil +} + +func (app *DemoApp) HandleData(data string) { + var playerWrappers []PlayerWrapper + + err := json.Unmarshal([]byte(data), &playerWrappers) + if err != nil { + fmt.Println("Error unmarshalling data:", err) + return + } + + var players []*hypixel.Player + for _, wrapper := range playerWrappers { + players = append(players, &wrapper.Player) + } + + app.DisplayPlayers(players) +} + +func (app *DemoApp) DisplayPlayers(players []*hypixel.Player) { + clearTerminal() + + fmt.Printf("| %-20s | %-10s | %-10s |\n", "Player Name", "Bedwars Level", "FKDR") + fmt.Println("|----------------------|------------|------------|") + + for _, player := range players { + fmt.Printf("| %-20s | %-10d | %10.3f |\n", + player.DisplayName, + player.Achievements.BedwarsLevel, + player.Stats.Bedwars.FKDR) + } + + fmt.Println("|----------------------|------------|------------|") } diff --git a/client/client.go b/client/client.go index 7a79240..f8c9c66 100644 --- a/client/client.go +++ b/client/client.go @@ -1,75 +1,239 @@ package client import ( - "bufio" + "encoding/binary" + "encoding/json" + "errors" "fmt" + "io" "net" - "os" - "strings" ) -// Connect to the chat server -func connectToServer(address string) (net.Conn, error) { - conn, err := net.Dial("tcp", address) +const PORT = ":5518" + +type PacketID int + +const ( + CONNECT_REQUEST PacketID = iota + CONNECT_RESPONSE + JOIN_REQUEST + JOIN_RESPONSE + CREATE_REQUEST + CREATE_RESPONSE + READ_DATA + SEND_DATA_REQUEST + SEND_DATA_RESPONSE + LEAVE_ROOM +) + +// Client represents a client connection to the server. +type Client struct { + Conn net.Conn + ClientID string + RoomCode string + DataChannel chan string +} + +// NewClient creates a new client and connects to the server. +func NewClient(serverAddr, clientID string) (*Client, error) { + conn, err := net.Dial("tcp", serverAddr+PORT) if err != nil { - return nil, fmt.Errorf("could not connect to server: %v", err) + return nil, fmt.Errorf("failed to connect to server: %w", err) } - return conn, nil + return &Client{ + Conn: conn, + ClientID: clientID, + DataChannel: make(chan string), + }, nil } -// Read input from the terminal and send it to the server -func readInputAndSend(conn net.Conn) { - reader := bufio.NewReader(os.Stdin) - for { - fmt.Print("> ") - text, _ := reader.ReadString('\n') - text = strings.TrimSpace(text) - - // Send the command to the server - _, err := conn.Write([]byte(text + "\n")) - if err != nil { - fmt.Println("Error sending message:", err) - return - } - - // If the user types 'quit', exit the program - if text == "quit" { - fmt.Println("Goodbye!") - return - } +// CreateRoom creates a new room on the server. +func (c *Client) CreateRoom(password string) (string, error) { + request := CreateRoomRequestPacket{ + UserID: c.ClientID, + Password: password, } + + if err := c.sendPacket(CREATE_REQUEST, request); err != nil { + return "", err + } + + var response CreateRoomResponsePacket + if err := c.receivePacket(CREATE_RESPONSE, &response); err != nil { + return "", err + } + + if !response.Success { + return "", errors.New(response.Reason) + } + + c.RoomCode = response.RoomCode + return response.RoomCode, nil } -// Listen for incoming messages from the server -func listenForMessages(conn net.Conn) { - reader := bufio.NewReader(conn) - for { - message, err := reader.ReadString('\n') - if err != nil { - fmt.Println("Disconnected from server.") - return - } - fmt.Print(message) +// JoinRoom joins an existing room on the server. +func (c *Client) JoinRoom(roomCode, password string) error { + request := JoinRequestPacket{ + UserID: c.ClientID, + RoomCode: roomCode, + Password: password, } + + if err := c.sendPacket(JOIN_REQUEST, request); err != nil { + return err + } + + var response JoinRequestResponsePacket + if err := c.receivePacket(JOIN_RESPONSE, &response); err != nil { + return err + } + + if !response.Success { + return errors.New(response.Reason) + } + + c.RoomCode = roomCode + return nil } -func main() { - if len(os.Args) < 2 { - fmt.Println("Usage: go run client.go ") - return +func (c *Client) ListenForData() { + go func() { + for { + var dataPacket SendDataRequestPacket + err := c.receivePacket(READ_DATA, &dataPacket) + if err != nil { + if err == io.EOF { + fmt.Println("Connection closed by the server") + close(c.DataChannel) + return + } + fmt.Printf("Error receiving data: %v\n", err) + close(c.DataChannel) + return + } + + // Send the received data to the channel + c.DataChannel <- dataPacket.Data + } + }() +} + +// SendData sends a message to all other clients in the room. +func (c *Client) SendData(data string) error { + request := SendDataRequestPacket{ + UserID: c.ClientID, + Data: data, } - serverAddress := os.Args[1] - conn, err := connectToServer(serverAddress) + if err := c.sendPacket(SEND_DATA_REQUEST, request); err != nil { + return fmt.Errorf("failed to send data packet: %w", err) + } + + var response SendDataResponsePacket + if err := c.receivePacket(SEND_DATA_RESPONSE, &response); err != nil { + return fmt.Errorf("failed to receive response for sent data: %w", err) + } + + if !response.Success { + return errors.New("server failed to process the data request: " + response.Reason) + } + + return nil +} + +// LeaveRoom disconnects the client from the current room. +func (c *Client) LeaveRoom() error { + return c.sendPacket(LEAVE_ROOM, nil) +} + +// Close closes the connection to the server. +func (c *Client) Close() { + c.Conn.Close() +} + +// sendPacket sends a packet with a given ID and data to the server. +func (c *Client) sendPacket(packetID PacketID, data interface{}) error { + packetData, err := json.Marshal(data) if err != nil { - fmt.Println(err) - return + return fmt.Errorf("failed to encode packet: %w", err) } - defer conn.Close() - - // Start a goroutine to listen for incoming messages from the server - go listenForMessages(conn) - - // Read input from the terminal and send it to the server - readInputAndSend(conn) + return writePacket(c.Conn, byte(packetID), packetData) +} + +// receivePacket reads a response from the server for a given packet ID. +func (c *Client) receivePacket(expected PacketID, v interface{}) error { + packetID, data, err := readPacket(c.Conn) + if err != nil { + return err + } + if packetID != expected { + return fmt.Errorf("unexpected packet ID: got %v, want %v", packetID, expected) + } + return json.Unmarshal(data, v) +} + +// readPacket reads a packet from the connection. +func readPacket(conn net.Conn) (PacketID, []byte, error) { + var length uint32 + if err := binary.Read(conn, binary.BigEndian, &length); err != nil { + return 0, nil, err + } + + var messageType byte + if err := binary.Read(conn, binary.BigEndian, &messageType); err != nil { + return 0, nil, err + } + + data := make([]byte, length-5) + if _, err := io.ReadFull(conn, data); err != nil { + return 0, nil, err + } + + return PacketID(messageType), data, nil +} + +// writePacket writes a packet to the connection. +func writePacket(conn net.Conn, messageType byte, data []byte) error { + length := uint32(5 + len(data)) + if err := binary.Write(conn, binary.BigEndian, length); err != nil { + return err + } + if err := binary.Write(conn, binary.BigEndian, messageType); err != nil { + return err + } + _, err := conn.Write(data) + return err +} + +type CreateRoomRequestPacket struct { + UserID string + Password string +} + +type CreateRoomResponsePacket struct { + Success bool + Reason string + RoomCode string +} + +type JoinRequestPacket struct { + UserID string + RoomCode string + Password string +} + +type JoinRequestResponsePacket struct { + Success bool + Reason string + CurrentData string +} + +type SendDataRequestPacket struct { + UserID string + Data string +} + +type SendDataResponsePacket struct { + Success bool + Reason string } diff --git a/app/config/config.go b/config/config.go similarity index 55% rename from app/config/config.go rename to config/config.go index b700341..e069171 100644 --- a/app/config/config.go +++ b/config/config.go @@ -1,6 +1,9 @@ package config import ( + "encoding/json" + "fmt" + "io/ioutil" "os" "path" "runtime" @@ -67,3 +70,45 @@ func GetDefaultConfig() *Config { }, } } + +// SaveConfig saves the given config struct to the file in JSON format +func SaveConfig(config *Config, filePath string) error { + // Convert the config struct to JSON + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("failed to serialize config: %v", err) + } + + // Write the JSON data to a file + err = ioutil.WriteFile(filePath, data, 0644) + if err != nil { + return fmt.Errorf("failed to write config to file: %v", err) + } + + return nil +} + +// LoadConfig loads the config from the given file path +func LoadConfig(filePath string) (*Config, error) { + // Check if the file exists + if _, err := os.Stat(filePath); os.IsNotExist(err) { + return nil, fmt.Errorf("config file does not exist: %s", filePath) + } + + // Read the file content + data, err := ioutil.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %v", err) + } + + // Create a Config object + config := &Config{} + + // Deserialize the JSON data into the Config object + err = json.Unmarshal(data, config) + if err != nil { + return nil, fmt.Errorf("failed to deserialize config: %v", err) + } + + return config, nil +} diff --git a/go.mod b/go.mod index 7c247de..5fa8ab3 100644 --- a/go.mod +++ b/go.mod @@ -1 +1,8 @@ module hudly + +go 1.23.0 + +require ( + github.com/google/uuid v1.6.0 +) + diff --git a/hypixel/tst.go b/hypixel/tst.go deleted file mode 100644 index 0806e26..0000000 --- a/hypixel/tst.go +++ /dev/null @@ -1,5 +0,0 @@ -package hypixel - -func DoThing() int { - return 1 -} diff --git a/mcfetch/async_player_fetcher.go b/mcfetch/async_player_fetcher.go index 8c483d2..70a2a6f 100644 --- a/mcfetch/async_player_fetcher.go +++ b/mcfetch/async_player_fetcher.go @@ -28,24 +28,27 @@ func NewAsyncPlayerFetcher(playerName string, cache ICache, retries int, retryDe } } -// FetchPlayerData fetches the player data asynchronously -func (pf *AsyncPlayerFetcher) FetchPlayerData(resultChan chan map[string]interface{}, errorChan chan error) { +// FetchPlayerData fetches the player data asynchronously using channels +func (pf *AsyncPlayerFetcher) FetchPlayerData(resultChan chan *FetchedPlayerResult, errorChan chan error) { go func() { cachedData, found := pf.cache.Get(pf.playerName) if found { - resultChan <- cachedData + resultChan <- (*FetchedPlayerResult)(cachedData) return } - var data map[string]interface{} + // If not in cache, make request to Mojang API + var player FetchedPlayerResult for i := 0; i < pf.retries; i++ { resp, err := pf.makeRequest(pf.playerName) if err == nil { defer resp.Body.Close() - if err := json.NewDecoder(resp.Body).Decode(&data); err == nil { - pf.cache.Set(pf.playerName, data) + // Decode the response into FetchedPlayerResult + if err := json.NewDecoder(resp.Body).Decode(&player); err == nil { + // Store the result in the cache and return the data + pf.cache.Set(pf.playerName, (*CacheResult)(&player)) pf.cache.Sync() - resultChan <- data + resultChan <- &player return } } diff --git a/mcfetch/cache.go b/mcfetch/cache.go index 7d52a7f..c63be5a 100644 --- a/mcfetch/cache.go +++ b/mcfetch/cache.go @@ -8,11 +8,16 @@ import ( "sync" ) +type CacheResult struct { + UUID string `json:"id"` + Name string `json:"name"` +} + type ICache interface { Init() Load() - Get(key string) (map[string]interface{}, bool) - Set(key string, data map[string]interface{}) + Get(key string) (*CacheResult, bool) + Set(key string, data *CacheResult) Save() Sync() Purge() @@ -21,31 +26,31 @@ type ICache interface { // MemoryCache implementation type MemoryCache struct { - cache map[string]interface{} + cache map[string]*CacheResult mu sync.RWMutex } // Init initializes the cache (no-op for MemoryCache) func (c *MemoryCache) Init() { - c.cache = make(map[string]interface{}) + c.cache = make(map[string]*CacheResult) } // Load loads the cache (no-op for MemoryCache) func (c *MemoryCache) Load() {} // Get retrieves an item from the cache -func (c *MemoryCache) Get(key string) (map[string]interface{}, bool) { +func (c *MemoryCache) Get(key string) (*CacheResult, bool) { c.mu.RLock() defer c.mu.RUnlock() value, found := c.cache[key] if !found { return nil, false } - return value.(map[string]interface{}), true + return value, true } // Set stores an item in the cache -func (c *MemoryCache) Set(key string, data map[string]interface{}) { +func (c *MemoryCache) Set(key string, data *CacheResult) { c.mu.Lock() defer c.mu.Unlock() c.cache[key] = data @@ -64,19 +69,19 @@ func (c *MemoryCache) Purge() {} func (c *MemoryCache) Clear() { c.mu.Lock() defer c.mu.Unlock() - c.cache = make(map[string]interface{}) + c.cache = make(map[string]*CacheResult) } // JsonFileCache implementation type JsonFileCache struct { filename string - cache map[string]interface{} + cache map[CacheResult]interface{} mu sync.RWMutex } // Init initializes the cache func (c *JsonFileCache) Init() { - c.cache = make(map[string]interface{}) + c.cache = make(map[CacheResult]interface{}) } // Load loads the cache from a JSON file @@ -104,21 +109,22 @@ func (c *JsonFileCache) Load() { } // Get retrieves an item from the cache -func (c *JsonFileCache) Get(key string) (map[string]interface{}, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - value, found := c.cache[key] - if !found { - return nil, false - } - return value.(map[string]interface{}), true +func (c *JsonFileCache) Get(key string) (*CacheResult, bool) { + //c.mu.RLock() + //defer c.mu.RUnlock() + //value, found := c.cache[key] + //if !found { + // return nil, false + //} + //return value, true + return nil, false } // Set stores an item in the cache -func (c *JsonFileCache) Set(key string, data map[string]interface{}) { - c.mu.Lock() - defer c.mu.Unlock() - c.cache[key] = data +func (c *JsonFileCache) Set(key string, data *CacheResult) { + //c.mu.Lock() + //defer c.mu.Unlock() + //c.cache[key] = data } // Save saves the cache to a JSON file @@ -150,5 +156,5 @@ func (c *JsonFileCache) Purge() {} func (c *JsonFileCache) Clear() { c.mu.Lock() defer c.mu.Unlock() - c.cache = make(map[string]interface{}) + c.cache = make(map[CacheResult]interface{}) } diff --git a/mcfetch/player_fetcher.go b/mcfetch/player_fetcher.go index a736df8..0b8df15 100644 --- a/mcfetch/player_fetcher.go +++ b/mcfetch/player_fetcher.go @@ -16,6 +16,11 @@ type PlayerFetcher struct { cache ICache } +type FetchedPlayerResult struct { + UUID string `json:"id"` + Name string `json:"name"` +} + // NewPlayerFetcher creates a new PlayerFetcher with an abstract cache (ICache) func NewPlayerFetcher(playerName string, cache ICache, retries int, retryDelay time.Duration, timeout time.Duration) *PlayerFetcher { cache.Init() @@ -29,21 +34,21 @@ func NewPlayerFetcher(playerName string, cache ICache, retries int, retryDelay t } // FetchPlayerData fetches the player data synchronously -func (pf *PlayerFetcher) FetchPlayerData() (map[string]interface{}, error) { - cachedData, found := pf.cache.Get(pf.playerName) - if found { - return cachedData, nil - } +func (pf *PlayerFetcher) FetchPlayerData() (*FetchedPlayerResult, error) { + //cachedData, found := pf.cache.Get(pf.playerName) + //if found { + // return &FetchedPlayerResult{}, nil + //} - var data map[string]interface{} + var player FetchedPlayerResult for i := 0; i < pf.retries; i++ { resp, err := pf.makeRequest(pf.playerName) if err == nil { defer resp.Body.Close() - if err := json.NewDecoder(resp.Body).Decode(&data); err == nil { - pf.cache.Set(pf.playerName, data) - pf.cache.Sync() - return data, nil + if err := json.NewDecoder(resp.Body).Decode(&player); err == nil { + // pf.cache.Set(pf.playerName, player) + // pf.cache.Sync() + return &player, nil } } time.Sleep(pf.retryDelay) diff --git a/server/server.go b/server/server.go index 460d46a..d0f0357 100644 --- a/server/server.go +++ b/server/server.go @@ -1,201 +1,99 @@ package main import ( - "bufio" + "encoding/binary" "encoding/json" "fmt" + "io" + "log" "math/rand" "net" - "strings" "sync" "time" ) +const PORT = ":5518" + +type PacketID int + +const ( + CONNECT_REQUEST PacketID = iota + CONNECT_RESPONSE + JOIN_REQUEST + JOIN_RESPONSE + CREATE_REQUEST + CREATE_RESPONSE + READ_DATA + SEND_DATA_REQUEST + SEND_DATA_RESPONSE + LEAVE_ROOM +) + type Client struct { - conn net.Conn - username string - room *Room + Conn net.Conn + ClientID string + RoomCode string } type Room struct { - code string - password string - clients map[*Client]bool - lock sync.Mutex + Code string + Password string + Clients []*Client } -var ( - rooms = make(map[string]*Room) - mu sync.Mutex -) +var rooms = map[string]*Room{} +var clients = map[string]*Client{} +var mu sync.Mutex -// Helper function to generate a 4-hexadecimal room code -func generateRoomCode() string { - rand.Seed(time.Now().UnixNano()) - return fmt.Sprintf("%04x", rand.Intn(0xFFFF)) +func readPacket(conn net.Conn) (PacketID, []byte, error) { + // First, read the length of the packet (4 bytes) + var length uint32 + if err := binary.Read(conn, binary.BigEndian, &length); err != nil { + return 0, nil, err + } + + // Then read the packet ID (1 byte) + var messageType byte + if err := binary.Read(conn, binary.BigEndian, &messageType); err != nil { + return 0, nil, err + } + + // Read the remaining data (length - 5 bytes, since 4 bytes for length and 1 byte for messageType) + data := make([]byte, length-5) + if _, err := io.ReadFull(conn, data); err != nil { + return 0, nil, err + } + + return PacketID(int(messageType)), data, nil } -// Handle client connection -func handleClient(client *Client) { - defer client.conn.Close() +func writePacket(conn net.Conn, messageType byte, data []byte) error { + // Calculate the total length of the packet + // 4 bytes for length, 1 byte for messageType + length := uint32(5 + len(data)) - reader := bufio.NewReader(client.conn) - - for { - line, err := reader.ReadString('\n') - if err != nil { - if client.room != nil { - leaveRoom(client) - } - fmt.Println("Client disconnected:", client.conn.RemoteAddr()) - return - } - processCommand(client, strings.TrimSpace(line)) + // Write the length and the message type + if err := binary.Write(conn, binary.BigEndian, length); err != nil { + return err } + if err := binary.Write(conn, binary.BigEndian, messageType); err != nil { + return err + } + + // Write the data + _, err := conn.Write(data) + return err } -// Process client commands -func processCommand(client *Client, input string) { - parts := strings.SplitN(input, " ", 2) - if len(parts) < 1 { - return - } - - cmd := strings.ToUpper(parts[0]) - args := "" - if len(parts) > 1 { - args = parts[1] - } - - switch cmd { - case "CREATE": - createRoom(client, args) - case "JOIN": - joinRoom(client, args) - case "LEAVE": - leaveRoom(client) - case "MESSAGE": - sendMessage(client, args) - case "SYNC": - handleSync(client, args) - default: - client.conn.Write([]byte("Unknown command\n")) - } -} - -// Create a room with an optional password -func createRoom(client *Client, args string) { - mu.Lock() - defer mu.Unlock() - - password := "" - if args != "" { - password = args // Treat all input after CREATE as a password - } - - roomCode := generateRoomCode() // Room code is generated automatically - room := &Room{ - code: roomCode, - password: password, - clients: make(map[*Client]bool), - } - - rooms[roomCode] = room - client.conn.Write([]byte(fmt.Sprintf("Room created with code: %s\n", roomCode))) -} - -// Join a room using its 4-hexadecimal code and optional password -func joinRoom(client *Client, args string) { - parts := strings.SplitN(args, " ", 2) - roomCode := parts[0] - password := "" - if len(parts) == 2 { - password = parts[1] - } - - mu.Lock() - room, exists := rooms[roomCode] - mu.Unlock() - - if !exists { - client.conn.Write([]byte("Room not found\n")) - return - } - - if room.password != "" && room.password != password { - client.conn.Write([]byte("Incorrect password\n")) - return - } - - room.lock.Lock() - room.clients[client] = true - client.room = room - room.lock.Unlock() - - client.conn.Write([]byte(fmt.Sprintf("Joined room: %s\n", roomCode))) - broadcastMessage(client.room, fmt.Sprintf("%s has joined the room\n", client.username)) -} - -// Leave the current room -func leaveRoom(client *Client) { - if client.room == nil { - client.conn.Write([]byte("You are not in any room\n")) - return - } - - client.room.lock.Lock() - delete(client.room.clients, client) - client.room.lock.Unlock() - - broadcastMessage(client.room, fmt.Sprintf("%s has left the room\n", client.username)) - client.conn.Write([]byte("You have left the room\n")) - client.room = nil -} - -// Send a message to all clients in the current room -func sendMessage(client *Client, message string) { - if client.room == nil { - client.conn.Write([]byte("You are not in any room\n")) - return - } - - timestamp := time.Now().Format("2006-01-02 15:04:05") - formattedMessage := fmt.Sprintf("[%s] %s: %s\n", timestamp, client.username, message) - broadcastMessage(client.room, formattedMessage) -} - -// Broadcast a message to all clients in the room -func broadcastMessage(room *Room, message string) { - room.lock.Lock() - defer room.lock.Unlock() - for client := range room.clients { - client.conn.Write([]byte(message)) - } -} - -// Handle the SYNC command, expecting a JSON payload -func handleSync(client *Client, payload string) { - var data map[string]interface{} - err := json.Unmarshal([]byte(payload), &data) - if err != nil { - client.conn.Write([]byte("Invalid JSON\n")) - return - } - - // You can process the JSON payload here as needed - client.conn.Write([]byte("Sync received\n")) -} - -// Start the server -func startServer() { - listener, err := net.Listen("tcp", ":5518") +func main() { + listener, err := net.Listen("tcp", PORT) if err != nil { fmt.Println("Error starting server:", err) return } defer listener.Close() - fmt.Println("Server started on port 5518") + fmt.Println("Server started on port", PORT) for { conn, err := listener.Accept() @@ -203,21 +101,338 @@ func startServer() { fmt.Println("Error accepting connection:", err) continue } - - go func() { - conn.Write([]byte("Enter your username: ")) - username, _ := bufio.NewReader(conn).ReadString('\n') - username = strings.TrimSpace(username) - client := &Client{ - conn: conn, - username: username, - } - conn.Write([]byte(fmt.Sprintf("Welcome %s!\n", username))) - handleClient(client) - }() + go handleClient(conn) } } -func main() { - startServer() +type ConnectionRequestPacket struct { + UserID string + RoomCode string + Password string +} + +type ConnectionResponsePacket struct { + Success bool + Reason string +} + +type CreateRoomRequestPacket struct { + UserID string + Password string +} + +type CreateRoomResponsePacket struct { + Success bool + Reason string + RoomCode string +} + +type JoinRequestPacket struct { + UserID string + Password string + RoomCode string +} + +type JoinRequestResponsePacket struct { + Success bool + Reason string + CurrentData string +} + +type SendDataRequestPacket struct { + UserID string + Data string +} + +type ForwardDataPacket struct { + Data string +} + +type SendDataResponsePacket struct { + Success bool + Reason string +} + +func handleClient(conn net.Conn) { + // defer conn.Close() + + for { + + var packetId PacketID + packetId, data, err := readPacket(conn) + if err != nil { + fmt.Println("Error reading packet (handleclient) :", err) + handleLeaveRoom(conn) + return + } + + switch packetId { + case CONNECT_REQUEST: + var packet ConnectionRequestPacket + if err := json.Unmarshal(data, &packet); err != nil { + fmt.Println("Error decoding connection request:", err) + return + } + handleConnectRequest(conn, packet) + case CREATE_REQUEST: + var packet CreateRoomRequestPacket + if err := json.Unmarshal(data, &packet); err != nil { + fmt.Println("Error decoding connection request:", err) + return + } + handleCreateRoomRequest(conn, packet) + case JOIN_REQUEST: + var packet JoinRequestPacket + if err := json.Unmarshal(data, &packet); err != nil { + fmt.Println("Error decoding connection request:", err) + return + } + handleJoinRequest(conn, packet) + case SEND_DATA_REQUEST: + var packet SendDataRequestPacket + if err := json.Unmarshal(data, &packet); err != nil { + fmt.Println("Error decoding connection request:", err) + return + } + handleSendData(conn, packet) + case LEAVE_ROOM: + handleLeaveRoom(conn) + } + } +} + +func handleConnectRequest(conn net.Conn, packet ConnectionRequestPacket) { + fmt.Println("Incoming connection request") + room := findRoom(packet.RoomCode) + if room == nil { + writePacket(conn, byte(CONNECT_RESPONSE), encodeResponsePacket(ConnectionResponsePacket{ + Success: false, + Reason: "room does not exist", + })) + fmt.Println("Attempted room does not exist") + return + } + + if room.Password == "" || packet.Password == room.Password { + writePacket(conn, byte(CONNECT_RESPONSE), encodeResponsePacket(ConnectionResponsePacket{ + Success: true, + Reason: "", + })) + fmt.Println("Invalid password") + return + } + + writePacket(conn, byte(CONNECT_RESPONSE), encodeResponsePacket(ConnectionResponsePacket{ + Success: false, + Reason: "invalid password", + })) + fmt.Println("Connected user to room ") +} + +func handleCreateRoomRequest(conn net.Conn, packet CreateRoomRequestPacket) { + if !isValidPassword(packet.Password) { + writePacket(conn, byte(CREATE_RESPONSE), encodeResponsePacket(CreateRoomResponsePacket{ + Success: false, + Reason: "invalid password", + RoomCode: "", + })) + return + } + code := generateRandomRoomCode() + + room := &Room{ + Code: code, + Password: packet.Password, + Clients: []*Client{}, + } + + mu.Lock() + rooms[code] = room + mu.Unlock() + + writePacket(conn, byte(CREATE_RESPONSE), encodeResponsePacket(CreateRoomResponsePacket{ + Success: true, + Reason: "", + RoomCode: code, + })) +} + +func handleJoinRequest(conn net.Conn, packet JoinRequestPacket) { + room := findRoom(packet.RoomCode) + if room == nil { + writePacket(conn, byte(JOIN_RESPONSE), encodeResponsePacket(JoinRequestResponsePacket{ + Success: false, + Reason: "room not found", + })) + return + } + + if !isValidPassword(packet.Password) { + writePacket(conn, byte(JOIN_RESPONSE), encodeResponsePacket(JoinRequestResponsePacket{ + Success: false, + Reason: "invalid password", + })) + return + } + + if room.Password == "" || room.Password == packet.Password { + client := &Client{ + Conn: conn, + ClientID: packet.UserID, + RoomCode: packet.RoomCode, + } + + mu.Lock() + room.Clients = append(room.Clients, client) + clients[packet.UserID] = client + mu.Unlock() + + writePacket(conn, byte(JOIN_RESPONSE), encodeResponsePacket(JoinRequestResponsePacket{ + Success: true, + Reason: "", + CurrentData: "{}", + // TODO: Send current data + })) + return + } + + writePacket(conn, byte(JOIN_RESPONSE), encodeResponsePacket(JoinRequestResponsePacket{ + Success: false, + Reason: "invalid password", + })) + return +} + +func handleSendData(conn net.Conn, packet SendDataRequestPacket) { + fmt.Println("Incoming send data request") + mu.Lock() + client, exists := clients[packet.UserID] + mu.Unlock() + + if !exists || client.RoomCode == "" { + writePacket(conn, byte(SEND_DATA_RESPONSE), encodeResponsePacket(SendDataResponsePacket{ + Success: false, + Reason: "client not in a room", + })) + return + } + + room := findRoom(client.RoomCode) + if room == nil { + writePacket(conn, byte(SEND_DATA_RESPONSE), encodeResponsePacket(SendDataResponsePacket{ + Success: false, + Reason: "room not found", + })) + return + } + + dataPacket := encodeResponsePacket(ForwardDataPacket{ + Data: packet.Data, + }) + + mu.Lock() + for _, roomClient := range room.Clients { + // if roomClient.ClientID != packet.UserID { + // // Send data to all other clients except the sender + // writePacket(roomClient.Conn, byte(READ_DATA), dataPacket) + // } + writePacket(roomClient.Conn, byte(READ_DATA), dataPacket) + } + mu.Unlock() + + writePacket(conn, byte(SEND_DATA_RESPONSE), encodeResponsePacket(SendDataResponsePacket{ + Success: true, + Reason: "", + })) +} + +func handleLeaveRoom(conn net.Conn) { + mu.Lock() + defer mu.Unlock() + + var leavingClient *Client + for _, client := range clients { + if client.Conn == conn { + leavingClient = client + break + } + } + if leavingClient == nil { + fmt.Println("Client not found") + return + } + + room := rooms[leavingClient.RoomCode] + if room == nil { + fmt.Println("Room not found") + return + } + + for i, client := range room.Clients { + if client == leavingClient { + room.Clients = append(room.Clients[:i], room.Clients[i+1:]...) + break + } + } + + // Delete the room if its empty + if len(room.Clients) == 0 { + delete(rooms, room.Code) + fmt.Println("Room", room.Code, "deleted") + } + + delete(clients, leavingClient.ClientID) + + done := make(chan struct{}) + go func() { + leavingClient.Conn.Close() + close(done) + }() + + select { + case <-done: + case <-time.After(5 * time.Second): + log.Fatalln("Timeout while closing leaving client's connection") + } + + fmt.Println("Client", leavingClient.ClientID, "left the room and connection closed") +} + +func findRoom(code string) *Room { + mu.Lock() + defer mu.Unlock() + return rooms[code] +} + +func encodeResponsePacket(packet interface{}) []byte { + data, err := json.Marshal(packet) + if err != nil { + fmt.Println("Error encoding response packet:", err) + return nil + } + return data +} + +func generateRandomRoomCode() string { + validChars := "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + code := make([]byte, 4) + for i := range code { + code[i] = validChars[rand.Intn(len(validChars))] + } + return string(code) +} + +func isValidPassword(password string) bool { + validChars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789?!()[]{}<>-_" + charMap := make(map[rune]bool) + for _, char := range validChars { + charMap[char] = true + } + + for _, char := range password { + if !charMap[char] { + return false + } + } + return true }