diff --git a/bot2.ps1 b/bot2.ps1 index 799753e..ae8176f 100644 --- a/bot2.ps1 +++ b/bot2.ps1 @@ -1,2 +1,12 @@ +# Fix UTF-8 encoding for PowerShell +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 +chcp 65001 > $null + +$env:PATH = "D:\esto_al_path\msys64\mingw64\bin;$env:PATH" +$env:PKG_CONFIG_PATH = "D:\esto_al_path\msys64\mingw64\lib\pkgconfig" + $env:XAI_API_KEY = "xai-TyecBoTLlFNL0Qxwnb0eRainG8hKTpJGtnCziMhm1tTyB1FrLpZm0gHNYA9qqqX21JsXStN1f9DseLdJ" -go run ./cmd/voicebot --server localhost:9987 --nickname Adam --voice Rex --greeting " " \ No newline at end of file +# go run ./cmd/voicebot --server localhost:9987 --nickname Adam --voice Rex --greeting " " --room "test" + +go run ./cmd/voicebot --server ts.vlazaro.es:9987 --nickname Adam --voice Rex --greeting " " --room "Room #1" \ No newline at end of file diff --git a/cmd/voicebot/main.go b/cmd/voicebot/main.go index 39fb01f..377fe43 100644 --- a/cmd/voicebot/main.go +++ b/cmd/voicebot/main.go @@ -2,10 +2,10 @@ package main import ( "flag" + "fmt" "log" "os" "os/signal" - "strings" "sync" "syscall" "time" @@ -14,35 +14,48 @@ import ( "go-ts/pkg/xai" ) -// VoiceSession represents an active xAI voice session for a user -type VoiceSession struct { - ClientID uint16 - Nickname string - XAI *xai.Client - AudioBuffer []int16 // Buffer to accumulate audio samples - AudioQueue chan []int16 // Queue for sending audio with proper timing - done chan struct{} // Signal to stop audio sender +// UserInfo tracks connected users (no individual sessions) +type UserInfo struct { + ClientID uint16 + Nickname string } -// Bot manages the TeamSpeak connection and xAI sessions +// Bot manages the TeamSpeak connection and single global xAI session type Bot struct { ts3 *ts3client.Client apiKey string voice string prompt string - greeting string // Optional greeting when user joins + greeting string - selfID uint16 // Our own ClientID - sessions map[uint16]*VoiceSession - sessionsMu sync.RWMutex - startTime time.Time + selfID uint16 + users map[uint16]*UserInfo + usersMu sync.RWMutex + startTime time.Time + + // Global xAI Session (one for all users) + globalXAI *xai.Client + globalMu sync.Mutex + + // Input audio mixing (multiple users → one stream) + inputBuffer []int16 + inputMu sync.Mutex + lastInputTime time.Time + + // Output audio (xAI response → TeamSpeak) + outputQueue chan []int16 + outputBuffer []int16 + outputMu sync.Mutex + + done chan struct{} } func main() { serverAddr := flag.String("server", "127.0.0.1:9987", "TeamSpeak 3 Server Address") nickname := flag.String("nickname", "GrokBot", "Bot nickname") voice := flag.String("voice", xai.VoiceAra, "xAI voice (Ara, Rex, Sal, Eve, Leo)") - greeting := flag.String("greeting", "Saluda brevemente al usuario que acaba de unirse.", "Greeting message (empty to disable)") + greeting := flag.String("greeting", "", "Greeting message when users join (empty to disable)") + room := flag.String("room", "", "Channel name to join after connecting (empty = stay in default)") flag.Parse() apiKey := os.Getenv("XAI_API_KEY") @@ -50,18 +63,21 @@ func main() { log.Fatal("XAI_API_KEY environment variable not set") } - log.Println("=== xAI Voice Bot for TeamSpeak ===") + log.Println("=== xAI Voice Bot for TeamSpeak (Unified Session) ===") log.Printf("Server: %s", *serverAddr) log.Printf("Nickname: %s", *nickname) log.Printf("Voice: %s", *voice) bot := &Bot{ - apiKey: apiKey, - voice: *voice, - prompt: "Eres Grok, un asistente de voz amigable y útil. Responde de forma concisa y natural.", - greeting: *greeting, - sessions: make(map[uint16]*VoiceSession), - startTime: time.Now(), + apiKey: apiKey, + voice: *voice, + prompt: "Eres Grok, un asistente de voz amigable y útil en un canal de TeamSpeak. Puedes escuchar a múltiples personas hablando. Responde de forma concisa y natural. Si varias personas hablan, trata de entender el contexto de la conversación grupal.", + greeting: *greeting, + users: make(map[uint16]*UserInfo), + startTime: time.Now(), + inputBuffer: make([]int16, 0, 960*50), // ~1 second buffer + outputQueue: make(chan []int16, 500), // ~10 seconds of audio + done: make(chan struct{}), } // Create TeamSpeak client @@ -71,44 +87,81 @@ func main() { // Register event handlers bot.ts3.On(ts3client.EventConnected, func(e *ts3client.ConnectedEvent) { - bot.selfID = e.ClientID // Store our own ID + bot.selfID = e.ClientID log.Printf("✓ Conectado a TeamSpeak! ClientID=%d, Server=%s", e.ClientID, e.ServerName) + + // Initialize global xAI session after connecting + go func() { + if err := bot.initGlobalSession(); err != nil { + log.Printf("[Global] Error iniciando sesión xAI: %v", err) + } + }() }) bot.ts3.On(ts3client.EventChannelList, func(e *ts3client.ChannelListEvent) { log.Printf("✓ %d canales disponibles", len(e.Channels)) + + // Join specified room if provided + if *room != "" { + go func() { + // Small delay to ensure connection is fully established + time.Sleep(500 * time.Millisecond) + ch := bot.ts3.GetChannelByName(*room) + if ch != nil { + log.Printf("[Room] Uniéndose al canal: %s (ID=%d)", ch.Name, ch.ID) + if err := bot.ts3.JoinChannel(ch.ID); err != nil { + log.Printf("[Room] Error al unirse al canal: %v", err) + } else { + log.Printf("[Room] ✓ Unido al canal: %s", ch.Name) + } + } else { + log.Printf("[Room] ⚠ Canal no encontrado: %s", *room) + } + }() + } }) bot.ts3.On(ts3client.EventClientEnter, func(e *ts3client.ClientEnterEvent) { - log.Printf("→ Usuario entró: %s (ID=%d)", e.Nickname, e.ClientID) - - // Don't create session for ourselves (compare by ID, not nickname) + // Don't track ourselves if e.ClientID == bot.selfID { log.Printf(" (Soy yo, ignorando)") return } - // Create xAI session for this user - go bot.createSession(e.ClientID, e.Nickname, bot.greeting) + log.Printf("→ Usuario entró: %s (ID=%d)", e.Nickname, e.ClientID) + + bot.usersMu.Lock() + bot.users[e.ClientID] = &UserInfo{ + ClientID: e.ClientID, + Nickname: e.Nickname, + } + bot.usersMu.Unlock() + + // Notify xAI about new user (if past startup grace period) + if bot.greeting != "" && time.Since(bot.startTime) > 3*time.Second { + bot.globalMu.Lock() + if bot.globalXAI != nil && bot.globalXAI.IsConnected() { + msg := fmt.Sprintf("%s. El usuario %s acaba de unirse al canal.", bot.greeting, e.Nickname) + if err := bot.globalXAI.SendText(msg); err != nil { + log.Printf("[Global] Error enviando notificación: %v", err) + } + } + bot.globalMu.Unlock() + } }) bot.ts3.On(ts3client.EventClientLeft, func(e *ts3client.ClientLeftEvent) { - log.Printf("← Usuario salió: ID=%d (%s)", e.ClientID, e.Reason) - - // Close xAI session for this user - bot.closeSession(e.ClientID) + bot.usersMu.Lock() + if user, ok := bot.users[e.ClientID]; ok { + log.Printf("← Usuario salió: %s (ID=%d, %s)", user.Nickname, e.ClientID, e.Reason) + delete(bot.users, e.ClientID) + } + bot.usersMu.Unlock() }) + // Audio handler: Mix ALL incoming audio into unified buffer bot.ts3.On(ts3client.EventAudio, func(e *ts3client.AudioEvent) { - // Forward audio from TeamSpeak to all xAI sessions - // Forward audio ONLY to the sender's session - bot.sessionsMu.RLock() - if session, ok := bot.sessions[e.SenderID]; ok { - if session.XAI != nil && session.XAI.IsConnected() { - session.XAI.SendAudio(e.PCM) - } - } - bot.sessionsMu.RUnlock() + bot.handleInputAudio(e.SenderID, e.PCM) }) bot.ts3.On(ts3client.EventError, func(e *ts3client.ErrorEvent) { @@ -125,222 +178,176 @@ func main() { <-sigChan log.Println("Cerrando...") - // Close all xAI sessions and audio senders first - bot.sessionsMu.Lock() - for _, session := range bot.sessions { - // Close audio sender first - select { - case <-session.done: - // Already closed - default: - close(session.done) - } - // Then close xAI - if session.XAI != nil { - session.XAI.Close() - } - } - bot.sessionsMu.Unlock() + // Signal all goroutines to stop + close(bot.done) - // Wait for audio senders to stop + // Close global xAI session + bot.globalMu.Lock() + if bot.globalXAI != nil { + bot.globalXAI.Close() + } + bot.globalMu.Unlock() + + // Wait for goroutines time.Sleep(200 * time.Millisecond) - // Now disconnect from TeamSpeak + // Disconnect from TeamSpeak bot.ts3.Disconnect() - // Signal main that we are done close(shutdownDone) }() - // Start global audio mixer - go bot.runAudioMixer(shutdownDone) + // Start input sender (sends mixed audio to xAI) + go bot.runInputSender() + + // Start output mixer (sends xAI audio to TeamSpeak) + go bot.runOutputSender() // Connect to TeamSpeak if err := bot.ts3.Connect(); err != nil { - // If connect returns error, check if it's because we're shutting down select { case <-shutdownDone: - // Normal shutdown log.Println("Conexión cerrada por shutdown") default: log.Fatalf("Error de conexión: %v", err) } } - // Wait for shutdown to complete if we returned from Connect cleanly log.Println("Esperando confirmación final de shutdown...") <-shutdownDone log.Println("Shutdown completado. Saliendo.") os.Exit(0) } -// createSession creates a new xAI voice session for a user -func (b *Bot) createSession(clientID uint16, nickname string, greeting string) { - log.Printf("[Session] Creando sesión xAI para %s...", nickname) - - // Create session with audio queue - session := &VoiceSession{ - ClientID: clientID, - Nickname: nickname, - AudioBuffer: make([]int16, 0, 960*10), - AudioQueue: make(chan []int16, 500), // Buffer up to 500 frames (~10 sec) - done: make(chan struct{}), - } - - // Start audio sender goroutine with proper 20ms timing - // Global audio mixer handles sending - // go b.audioSender(session) +// initGlobalSession creates the single xAI session for all users +func (b *Bot) initGlobalSession() error { + log.Println("[Global] Iniciando sesión xAI global...") xaiClient := xai.New(b.apiKey) - // Set up audio callback - buffer and queue in 960-sample chunks + // Handle output audio from xAI → buffer for TeamSpeak xaiClient.OnAudio(func(pcm []int16) { - b.sessionsMu.Lock() - session.AudioBuffer = append(session.AudioBuffer, pcm...) + b.outputMu.Lock() + b.outputBuffer = append(b.outputBuffer, pcm...) // Queue complete 960-sample frames - for len(session.AudioBuffer) >= 960 { + for len(b.outputBuffer) >= 960 { frame := make([]int16, 960) - copy(frame, session.AudioBuffer[:960]) - session.AudioBuffer = session.AudioBuffer[960:] + copy(frame, b.outputBuffer[:960]) + b.outputBuffer = b.outputBuffer[960:] - // Non-blocking send to queue select { - case session.AudioQueue <- frame: + case b.outputQueue <- frame: default: - // Queue full, drop frame + // Queue full, drop oldest } } - b.sessionsMu.Unlock() + b.outputMu.Unlock() }) - // Set up transcript callback for logging + // Log transcripts xaiClient.OnTranscript(func(text string) { log.Printf("[Grok] %s", text) }) - // Clear audio queue when user starts speaking (interruption) - xaiClient.OnSpeechStarted(func() { - // Disable queue clearing for now to prevent cutting off greetings due to sensitive VAD - /* - b.sessionsMu.Lock() - // Clear the buffer - session.AudioBuffer = session.AudioBuffer[:0] - // Drain the queue - for len(session.AudioQueue) > 0 { - <-session.AudioQueue - } - b.sessionsMu.Unlock() - */ - log.Printf("[Session] Speech started by %s (VAD) - Ignoring interruption to ensure playback", nickname) - }) - // Connect to xAI if err := xaiClient.Connect(); err != nil { - log.Printf("[Session] Error conectando a xAI: %v", err) - close(session.done) - return + return fmt.Errorf("connect: %w", err) } // Configure the session if err := xaiClient.ConfigureSession(b.voice, b.prompt); err != nil { - log.Printf("[Session] Error configurando sesión: %v", err) xaiClient.Close() - close(session.done) - return + return fmt.Errorf("configure: %w", err) } - // Store the xAI client in session - session.XAI = xaiClient + b.globalMu.Lock() + b.globalXAI = xaiClient + b.globalMu.Unlock() - b.sessionsMu.Lock() - b.sessions[clientID] = session - b.sessionsMu.Unlock() - - log.Printf("[Session] ✓ Sesión xAI activa para %s", nickname) - - // Send greeting to start conversation (if configured) - // Send greeting to start conversation (if configured) - if strings.TrimSpace(greeting) != "" { - // Only greet if we are past the startup grace period (3 seconds) - // This prevents "Greeting Storm" when joining a channel with existing users - if time.Since(b.startTime) > 3*time.Second { - go func() { - time.Sleep(500 * time.Millisecond) // Small delay for session to stabilize - if err := xaiClient.SendText(greeting); err != nil { - log.Printf("[Session] Error enviando saludo: %v", err) - } - }() - } else { - log.Printf("[Session] Omitiendo saludo inicial para %s (sesión existente detectada en arranque)", nickname) - } - } + log.Println("[Global] ✓ Sesión xAI global activa") + return nil } -// runAudioMixer mixes audio from all active sessions and sends it to TeamSpeak -func (b *Bot) runAudioMixer(stop <-chan struct{}) { +// handleInputAudio mixes incoming audio from any user into the unified buffer +func (b *Bot) handleInputAudio(senderID uint16, pcm []int16) { + b.inputMu.Lock() + defer b.inputMu.Unlock() + + // Extend buffer if needed + neededLen := len(pcm) + currentLen := len(b.inputBuffer) + + if currentLen < neededLen { + // Extend with zeros + b.inputBuffer = append(b.inputBuffer, make([]int16, neededLen-currentLen)...) + } + + // Mix (add with clipping protection) + for i, sample := range pcm { + val := int32(b.inputBuffer[i]) + int32(sample) + if val > 32767 { + val = 32767 + } + if val < -32768 { + val = -32768 + } + b.inputBuffer[i] = int16(val) + } + + b.lastInputTime = time.Now() +} + +// runInputSender sends buffered audio to xAI every 20ms +func (b *Bot) runInputSender() { ticker := time.NewTicker(20 * time.Millisecond) defer ticker.Stop() - mixedFrame := make([]int16, 960) + for { + select { + case <-b.done: + return + case <-ticker.C: + b.inputMu.Lock() + if len(b.inputBuffer) >= 960 { + // Extract one frame + frame := make([]int16, 960) + copy(frame, b.inputBuffer[:960]) + // Shift buffer (remove consumed samples) + b.inputBuffer = b.inputBuffer[960:] + b.inputMu.Unlock() + + // Send to global xAI session + b.globalMu.Lock() + if b.globalXAI != nil && b.globalXAI.IsConnected() { + b.globalXAI.SendAudio(frame) + } + b.globalMu.Unlock() + } else { + b.inputMu.Unlock() + } + } + } +} + +// runOutputSender sends xAI audio responses to TeamSpeak with proper timing +func (b *Bot) runOutputSender() { + ticker := time.NewTicker(20 * time.Millisecond) + defer ticker.Stop() for { select { - case <-stop: + case <-b.done: return case <-ticker.C: - hasAudio := false - - // Zero output buffer - for i := range mixedFrame { - mixedFrame[i] = 0 - } - - b.sessionsMu.RLock() - for _, session := range b.sessions { - // Try to get a frame from the queue - select { - case frame := <-session.AudioQueue: - hasAudio = true - // Mix (Sum and Clamp) - for i := 0; i < 960; i++ { - if i >= len(frame) { - break - } - val := int32(mixedFrame[i]) + int32(frame[i]) - if val > 32767 { - val = 32767 - } - if val < -32768 { - val = -32768 - } - mixedFrame[i] = int16(val) - } - default: - // No audio from this session - } - } - b.sessionsMu.RUnlock() - - if hasAudio { - if err := b.ts3.SendAudio(mixedFrame); err != nil { - log.Printf("[Mixer] Error sending audio: %v", err) + select { + case frame := <-b.outputQueue: + if err := b.ts3.SendAudio(frame); err != nil { + log.Printf("[Output] Error sending audio: %v", err) } + default: + // No audio to send } } } } - -// closeSession closes an xAI session for a user -func (b *Bot) closeSession(clientID uint16) { - b.sessionsMu.Lock() - defer b.sessionsMu.Unlock() - - if session, ok := b.sessions[clientID]; ok { - log.Printf("[Session] Cerrando sesión xAI para %s", session.Nickname) - if session.XAI != nil { - session.XAI.Close() - } - delete(b.sessions, clientID) - } -} diff --git a/internal/client/client.go b/internal/client/client.go index 977c02a..c148888 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -36,11 +36,10 @@ type Client struct { Connected bool ServerName string - // Fragment reassembly - FragmentBuffer []byte - FragmentStartPktID uint16 - FragmentCompressed bool - Fragmenting bool + // Fragment reassembly (packet queue like ts3j) + CommandQueue map[uint16]*protocol.Packet // Packets waiting for reassembly + ExpectedCommandPID uint16 // Next expected packet ID + FragmentState bool // Toggle: true = collecting, false = ready // Server Data Channels map[uint64]*Channel @@ -64,6 +63,8 @@ func NewClient(nickname string) *Client { VoicePacketID: 1, Channels: make(map[uint64]*Channel), VoiceDecoders: make(map[uint16]*opus.Decoder), + CommandQueue: make(map[uint16]*protocol.Packet), + ExpectedCommandPID: 0, done: make(chan struct{}), } } diff --git a/internal/client/commands.go b/internal/client/commands.go index faf42b9..de81810 100644 --- a/internal/client/commands.go +++ b/internal/client/commands.go @@ -5,12 +5,35 @@ import ( "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. @@ -75,71 +98,142 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error { c.Connected = true } - // Fragment reassembly logic: - // - First fragment: Fragmented=true, optionally Compressed=true -> start buffer - // - Middle fragments: Fragmented=false, Compressed=false -> append to buffer - // - Last fragment: Fragmented=true -> append and process - isFragmented := pkt.Header.FlagFragmented() + // 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) + } - if isFragmented && !c.Fragmenting { - // First fragment - start collecting - c.Fragmenting = true - c.FragmentBuffer = make([]byte, 0, 4096) - c.FragmentBuffer = append(c.FragmentBuffer, data...) - c.FragmentStartPktID = pkt.Header.PacketID - c.FragmentCompressed = pkt.Header.FlagCompressed() - log.Printf("Fragment start (PID=%d, Compressed=%v, Len=%d)", pkt.Header.PacketID, c.FragmentCompressed, len(data)) - return nil // Wait for more fragments - } else if c.Fragmenting && !isFragmented { - // Middle fragment - append - c.FragmentBuffer = append(c.FragmentBuffer, data...) - log.Printf("Fragment continue (PID=%d, TotalLen=%d)", pkt.Header.PacketID, len(c.FragmentBuffer)) - return nil // Wait for more fragments - } else if c.Fragmenting && isFragmented { - // Last fragment - complete reassembly - c.FragmentBuffer = append(c.FragmentBuffer, data...) - log.Printf("Fragment end (PID=%d, TotalLen=%d)", pkt.Header.PacketID, len(c.FragmentBuffer)) - data = c.FragmentBuffer + // Try to process packets in order + for { + nextPkt, ok := c.CommandQueue[c.ExpectedCommandPID] + if !ok { + // Missing packet, wait for it + break + } - // Decompress if first fragment was compressed - if c.FragmentCompressed { - decompressed, err := quicklz.Decompress(data) - if err != nil { - log.Printf("QuickLZ decompression of fragmented data failed: %v", err) - // Fallback to raw data + 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 { - log.Printf("Decompressed fragmented: %d -> %d bytes", len(data), len(decompressed)) - data = decompressed + // 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 + } } } - // Reset fragment state - c.Fragmenting = false - c.FragmentBuffer = nil - } else { - // Non-fragmented packet - decompress if needed - if pkt.Header.FlagCompressed() { - decompressed, err := quicklz.Decompress(data) - if err != nil { - log.Printf("QuickLZ decompression failed: %v (falling back to raw)", err) - // Fallback to raw data - might not be compressed despite flag - } 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 + // 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 { - if len(cmdStr) > 100 { - return cmdStr[:100] + preview := cmdStr + if len(preview) > 100 { + preview = preview[:100] } - return cmdStr + return sanitizeForLog(preview) }()) // Fix Garbage Headers (TS3 often sends binary garbage before command) @@ -152,7 +246,7 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error { cmdStr = cmdStr[validStart:] } - log.Printf("Command: %s", cmdStr) + log.Printf("Command: %s", sanitizeForLog(cmdStr)) // Parse Commands (possibly multiple piped items) commands := protocol.ParseCommands([]byte(cmdStr)) diff --git a/pkg/ts3client/commands.go b/pkg/ts3client/commands.go index 0e59ca7..208de13 100644 --- a/pkg/ts3client/commands.go +++ b/pkg/ts3client/commands.go @@ -2,6 +2,8 @@ package ts3client import ( "fmt" + "log" + "strings" "go-ts/pkg/protocol" ) @@ -29,6 +31,34 @@ func (c *Client) GetChannel(id uint64) *Channel { return c.channels[id] } +// GetChannelByName returns the first channel matching the given name (case-insensitive substring match) +func (c *Client) GetChannelByName(name string) *Channel { + c.channelsMu.RLock() + defer c.channelsMu.RUnlock() + + // Debug: log all available channels + log.Printf("[GetChannelByName] Searching for: %q, Available channels (%d):", name, len(c.channels)) + for id, ch := range c.channels { + log.Printf("[GetChannelByName] - [%d] %q", id, ch.Name) + } + + // First try exact match + for _, ch := range c.channels { + if ch.Name == name { + return ch + } + } + + // Then try case-insensitive contains + nameLower := strings.ToLower(name) + for _, ch := range c.channels { + if strings.Contains(strings.ToLower(ch.Name), nameLower) { + return ch + } + } + return nil +} + // GetCurrentChannel returns the client's current channel func (c *Client) GetCurrentChannel() *Channel { if c.selfInfo == nil { diff --git a/voicebot.exe b/voicebot.exe index 5a56780..abf0e0d 100644 Binary files a/voicebot.exe and b/voicebot.exe differ