package client import ( "encoding/binary" "fmt" "log" "strings" "unicode" "go-ts/pkg/protocol" "github.com/dgryski/go-quicklz" ) // sanitizeForLog removes control characters that can corrupt terminal output func sanitizeForLog(s string) string { var result strings.Builder result.Grow(len(s)) for _, r := range s { if r >= 32 && r < 127 { // Printable ASCII result.WriteRune(r) } else if unicode.IsPrint(r) && r < 256 { // Printable extended ASCII result.WriteRune(r) } else if r == '\n' || r == '\r' || r == '\t' { // Keep whitespace result.WriteRune(r) } else { // Replace control characters with placeholder result.WriteRune('.') } } return result.String() } func (c *Client) handleCommand(pkt *protocol.Packet) error { // Check if Encrypted // PacketTypeCommand is usually encrypted. // Flag check? The flag is in the Header (e.g. Unencrypted flag). // If Unencrypted flag is SET, it's cleartext. // Spec: "Command ... Encrypted: ✓". So Unencrypted flag is CLEARED. // Decrypt if necessary var data []byte var err error if pkt.Header.FlagUnencrypted() { data = pkt.Data } else { var key, nonce []byte decrypted := false // 1. Try SharedSecret if available if c.Handshake != nil && c.Handshake.Step >= 6 && len(c.Handshake.SharedIV) > 0 { // Use SharedSecret-based encryption crypto := &protocol.CryptoState{ SharedIV: c.Handshake.SharedIV, SharedMac: c.Handshake.SharedMac, GenerationID: 0, } // Server->Client = false key, nonce = crypto.GenerateKeyNonce(&pkt.Header, false) // AAD for Server->Client: PacketID (2) + Type|Flags (1) meta := make([]byte, 3) binary.BigEndian.PutUint16(meta[0:2], pkt.Header.PacketID) meta[2] = pkt.Header.Type // Type includes Flags data, err = protocol.DecryptEAX(key, nonce, meta, pkt.Data, pkt.Header.MAC[:]) if err == nil { decrypted = true } else { log.Printf("SharedSecret decrypt failed (PID=%d): %v. Trying HandshakeKey...", pkt.Header.PacketID, err) } } // 2. Fallback to HandshakeKey if !decrypted { key = protocol.HandshakeKey[:] nonce = protocol.HandshakeNonce[:] // AAD matching KeyNonce derivation context? // HandshakeKey usage usually has same AAD requirements? meta := make([]byte, 3) binary.BigEndian.PutUint16(meta[0:2], pkt.Header.PacketID) meta[2] = pkt.Header.Type // Type includes Flags data, err = protocol.DecryptEAX(key, nonce, meta, pkt.Data, pkt.Header.MAC[:]) if err != nil { log.Printf("All decryption attempts failed for PID=%d: %v", pkt.Header.PacketID, err) return fmt.Errorf("decryption failed: %v", err) } } } // On first encrypted command set Connected = true (Fallback if ACK missed) if !c.Connected && pkt.Header.PacketID > 2 { c.Connected = true } // Queue-based fragment reassembly (like ts3j) // Store packet in queue c.CommandQueue[pkt.Header.PacketID] = &protocol.Packet{ Header: pkt.Header, Data: append([]byte{}, data...), // Clone data (already decrypted) } // Try to process packets in order for { nextPkt, ok := c.CommandQueue[c.ExpectedCommandPID] if !ok { // Missing packet, wait for it break } isFragmented := nextPkt.Header.FlagFragmented() if isFragmented { // Toggle fragment state c.FragmentState = !c.FragmentState if c.FragmentState { // Starting a new fragment sequence // Don't process yet, wait for more c.ExpectedCommandPID++ continue } else { // Ending fragment sequence - reassemble all reassembled, compressed := c.reassembleFragments() if reassembled == nil { log.Printf("Fragment reassembly failed") break } data = reassembled // Decompress if first packet was compressed if compressed { decompressed, err := quicklz.Decompress(data) if err != nil { log.Printf("QuickLZ decompression of fragmented data failed: %v", err) } else { log.Printf("Decompressed fragmented: %d -> %d bytes", len(data), len(decompressed)) data = decompressed } } } } else if c.FragmentState { // Middle fragment - keep collecting c.ExpectedCommandPID++ continue } else { // Non-fragmented packet - process normally data = nextPkt.Data // Decompress if needed if nextPkt.Header.FlagCompressed() { decompressed, err := quicklz.Decompress(data) if err != nil { log.Printf("QuickLZ decompression failed: %v (falling back to raw)", err) } else { log.Printf("Decompressed: %d -> %d bytes", len(data), len(decompressed)) data = decompressed } } } // Remove processed packet from queue delete(c.CommandQueue, c.ExpectedCommandPID) c.ExpectedCommandPID++ // Process the command if err := c.processCommand(data, nextPkt); err != nil { log.Printf("Error processing command: %v", err) } } return nil } // reassembleFragments collects all buffered fragments in order and returns reassembled data func (c *Client) reassembleFragments() ([]byte, bool) { var result []byte compressed := false // Find the start of the fragment sequence (scan backwards from current) startPID := c.ExpectedCommandPID for { prevPID := startPID - 1 pkt, ok := c.CommandQueue[prevPID] if !ok { break } // Check if this is the start (has Fragmented flag) if pkt.Header.FlagFragmented() { startPID = prevPID break } startPID = prevPID } // Now collect from startPID to ExpectedCommandPID (inclusive) for pid := startPID; pid <= c.ExpectedCommandPID; pid++ { pkt, ok := c.CommandQueue[pid] if !ok { log.Printf("Missing fragment PID=%d during reassembly", pid) return nil, false } // First fragment may have compressed flag if pid == startPID && pkt.Header.FlagCompressed() { compressed = true } result = append(result, pkt.Data...) delete(c.CommandQueue, pid) } log.Printf("Reassembled fragments PID %d-%d, total %d bytes, compressed=%v", startPID, c.ExpectedCommandPID, len(result), compressed) return result, compressed } // processCommand handles a single fully reassembled command func (c *Client) processCommand(data []byte, pkt *protocol.Packet) error { cmdStr := string(data) // Debug: Log packet flags and raw command preview (sanitized) log.Printf("Debug Packet: Compressed=%v, Fragmented=%v, RawLen=%d, Preview=%q", pkt.Header.FlagCompressed(), pkt.Header.FlagFragmented(), len(data), func() string { preview := cmdStr if len(preview) > 100 { preview = preview[:100] } return sanitizeForLog(preview) }()) // Fix Garbage Headers (TS3 often sends binary garbage before command) // Scan for first valid lower case [a-z] char (Most commands are lowercase) validStart := strings.IndexFunc(cmdStr, func(r rune) bool { return (r >= 'a' && r <= 'z') }) if validStart > 0 && validStart < 50 { cmdStr = cmdStr[validStart:] } log.Printf("Command: %s", sanitizeForLog(cmdStr)) // Parse Commands (possibly multiple piped items) commands := protocol.ParseCommands([]byte(cmdStr)) for _, command := range commands { cmd := command.Name args := command.Params switch cmd { case "initivexpand2": err := c.Handshake.ProcessInitivexpand2(args) if err != nil { log.Printf("Error processing initivexpand2: %v", err) } case "initserver": // Server sends this after clientinit - contains our clientID if cid, ok := args["aclid"]; ok { var id uint64 fmt.Sscanf(cid, "%d", &id) c.ClientID = uint16(id) log.Printf("Assigned ClientID: %d", c.ClientID) } if name, ok := args["virtualserver_name"]; ok { c.ServerName = protocol.Unescape(name) log.Printf("Server Name: %s", c.ServerName) } c.emitEvent("connected", map[string]any{ "clientID": c.ClientID, "serverName": c.ServerName, }) case "channellist": // Parse channel info ch := &Channel{} if cid, ok := args["cid"]; ok { fmt.Sscanf(cid, "%d", &ch.ID) } if pid, ok := args["cpid"]; ok { fmt.Sscanf(pid, "%d", &ch.ParentID) } if name, ok := args["channel_name"]; ok { ch.Name = protocol.Unescape(name) } if order, ok := args["channel_order"]; ok { fmt.Sscanf(order, "%d", &ch.Order) } c.Channels[ch.ID] = ch log.Printf("Channel: [%d] NameRaw=%q Order=%d Args=%v", ch.ID, ch.Name, ch.Order, args) case "channellistfinished": log.Printf("=== Channel List Complete (%d channels) ===", len(c.Channels)) var channelList []*Channel var targetChan *Channel for _, ch := range c.Channels { log.Printf(" - [%d] %s (parent=%d)", ch.ID, ch.Name, ch.ParentID) channelList = append(channelList, ch) if ch.Name == "Test" { targetChan = ch } } c.emitEvent("channel_list", map[string]any{ "channels": channelList, }) if targetChan == nil { if ch, ok := c.Channels[2]; ok { log.Printf("Name parsing failed. Defaulting to Channel 2 as 'Test'.") targetChan = ch } } if targetChan != nil { log.Printf("Found target channel 'Test' (ID=%d). Joining...", targetChan.ID) if c.ClientID == 0 { log.Println("ERROR: ClientID is 0. Cannot join channel. 'initserver' missing?") return nil } moveCmd := protocol.NewCommand("clientmove") moveCmd.AddParam("clid", fmt.Sprintf("%d", c.ClientID)) moveCmd.AddParam("cid", fmt.Sprintf("%d", targetChan.ID)) moveCmd.AddParam("cpw", "") return c.SendCommand(moveCmd) } case "notifycliententerview": // A client entered the server nick := "" clientID := uint16(0) channelID := uint64(0) if n, ok := args["client_nickname"]; ok { nick = protocol.Unescape(n) } if cid, ok := args["clid"]; ok { var id uint64 fmt.Sscanf(cid, "%d", &id) clientID = uint16(id) } if ctid, ok := args["ctid"]; ok { fmt.Sscanf(ctid, "%d", &channelID) } log.Printf("Client entered: %s (ID=%d)", nick, clientID) c.emitEvent("client_enter", map[string]any{ "clientID": clientID, "nickname": nick, "channelID": channelID, }) case "notifytextmessage": // targetmode: 1=Private, 2=Channel, 3=Server msg := "" invoker := "Unknown" var invokerID uint16 var targetModeInt int if m, ok := args["msg"]; ok { msg = protocol.Unescape(m) } if name, ok := args["invokername"]; ok { invoker = protocol.Unescape(name) } if iid, ok := args["invokerid"]; ok { var id uint64 fmt.Sscanf(iid, "%d", &id) invokerID = uint16(id) } targetMode := "Unknown" if tm, ok := args["targetmode"]; ok { switch tm { case "1": targetMode = "Private" targetModeInt = 1 case "2": targetMode = "Channel" targetModeInt = 2 case "3": targetMode = "Server" targetModeInt = 3 } } log.Printf("[Chat][%s] %s: %s", targetMode, invoker, msg) c.emitEvent("message", map[string]any{ "senderID": invokerID, "senderName": invoker, "message": msg, "targetMode": targetModeInt, }) case "notifyclientchatcomposing": // Someone is typing // We only get clid, need to map to name if possible, or just log clid clid := "Unknown" if id, ok := args["clid"]; ok { clid = id } log.Printf("Client %s is typing...", clid) case "notifyclientmoved": // Client moved to another channel var clientID uint16 var channelID uint64 if cid, ok := args["clid"]; ok { var id uint64 fmt.Sscanf(cid, "%d", &id) clientID = uint16(id) } if ctid, ok := args["ctid"]; ok { fmt.Sscanf(ctid, "%d", &channelID) } log.Printf("Client %d moved to Channel %d", clientID, channelID) c.emitEvent("client_moved", map[string]any{ "clientID": clientID, "channelID": channelID, }) case "notifyclientchannelgroupchanged": // Client channel group changed // invokerid=0 invokername=Server cgid=8 cid=1 clid=3 cgi=1 invoker := "Unknown" if name, ok := args["invokername"]; ok { invoker = protocol.Unescape(name) } log.Printf("Client %s channel group changed to %s in Channel %s by %s", args["clid"], args["cgid"], args["cid"], invoker) case "notifyconnectioninforequest": // Server asking for connection info. We MUST reply to update Ping in UI and avoid timeout. log.Println("Server requested connection info. sending 'setconnectioninfo'...") cmd := protocol.NewCommand("setconnectioninfo") cmd.AddParam("connection_ping", "50") cmd.AddParam("connection_ping_deviation", "5") // Detailed stats for each kind as seen in ts3j (KEEPALIVE, SPEECH, CONTROL) kinds := []string{"keepalive", "speech", "control"} for _, k := range kinds { cmd.AddParam("connection_packets_sent_"+k, "500") cmd.AddParam("connection_packets_received_"+k, "500") cmd.AddParam("connection_bytes_sent_"+k, "25000") cmd.AddParam("connection_bytes_received_"+k, "25000") cmd.AddParam("connection_bandwidth_sent_last_second_"+k, "200") cmd.AddParam("connection_bandwidth_received_last_second_"+k, "200") cmd.AddParam("connection_bandwidth_sent_last_minute_"+k, "200") cmd.AddParam("connection_bandwidth_received_last_minute_"+k, "200") cmd.AddParam("connection_server2client_packetloss_"+k, "0") } cmd.AddParam("connection_server2client_packetloss_total", "0") return c.SendCommand(cmd) case "notifyclientleftview": // Client left the server var clientID uint16 reason := "" if cid, ok := args["clid"]; ok { var id uint64 fmt.Sscanf(cid, "%d", &id) clientID = uint16(id) } if rid, ok := args["reasonid"]; ok { switch rid { case "3": reason = "connection lost" case "5": reason = "kicked" case "6": reason = "banned" case "8": reason = "leaving" default: reason = "unknown" } } if rmsg, ok := args["reasonmsg"]; ok { if rmsg != "" { reason = protocol.Unescape(rmsg) } } log.Printf("Client %d left: %s", clientID, reason) c.emitEvent("client_left", map[string]any{ "clientID": clientID, "reason": reason, }) case "notifyclientupdated": // Client updated (e.g. muted/unmuted) clid := args["clid"] log.Printf("Client %s updated: %v", clid, args) case "error": // Server reported an error id := args["id"] msg := protocol.Unescape(args["msg"]) log.Printf("SERVER ERROR: ID=%s MSG=%s", id, msg) c.emitEvent("error", map[string]any{ "id": id, "message": msg, }) case "notifyservergrouplist", "notifychannelgrouplist", "notifyclientneededpermissions": // Ignore verbose noisy setup commands default: log.Printf("Unhandled command: %s Args: %v", cmd, args) } } // End for loop return nil }