diff --git a/bot.ps1 b/bot.ps1 index a929bc8..0ef6fab 100644 --- a/bot.ps1 +++ b/bot.ps1 @@ -1,2 +1,5 @@ +$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 GrokBot --voice Ara \ No newline at end of file +go run ./cmd/voicebot --server localhost:9987 --nickname Eva --voice Ara --greeting "Hola!" \ No newline at end of file diff --git a/bot2.ps1 b/bot2.ps1 new file mode 100644 index 0000000..799753e --- /dev/null +++ b/bot2.ps1 @@ -0,0 +1,2 @@ +$env:XAI_API_KEY = "xai-TyecBoTLlFNL0Qxwnb0eRainG8hKTpJGtnCziMhm1tTyB1FrLpZm0gHNYA9qqqX21JsXStN1f9DseLdJ" +go run ./cmd/voicebot --server localhost:9987 --nickname Adam --voice Rex --greeting " " \ No newline at end of file diff --git a/cmd/voicebot/main.go b/cmd/voicebot/main.go index 029f21f..39fb01f 100644 --- a/cmd/voicebot/main.go +++ b/cmd/voicebot/main.go @@ -5,6 +5,7 @@ import ( "log" "os" "os/signal" + "strings" "sync" "syscall" "time" @@ -25,20 +26,23 @@ type VoiceSession struct { // Bot manages the TeamSpeak connection and xAI sessions type Bot struct { - ts3 *ts3client.Client - apiKey string - voice string - prompt string + ts3 *ts3client.Client + apiKey string + voice string + prompt string + greeting string // Optional greeting when user joins selfID uint16 // Our own ClientID sessions map[uint16]*VoiceSession sessionsMu sync.RWMutex + startTime time.Time } 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)") flag.Parse() apiKey := os.Getenv("XAI_API_KEY") @@ -52,10 +56,12 @@ func main() { 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.", - sessions: make(map[uint16]*VoiceSession), + 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(), } // Create TeamSpeak client @@ -83,7 +89,7 @@ func main() { } // Create xAI session for this user - go bot.createSession(e.ClientID, e.Nickname) + go bot.createSession(e.ClientID, e.Nickname, bot.greeting) }) bot.ts3.On(ts3client.EventClientLeft, func(e *ts3client.ClientLeftEvent) { @@ -95,10 +101,9 @@ func main() { bot.ts3.On(ts3client.EventAudio, func(e *ts3client.AudioEvent) { // Forward audio from TeamSpeak to all xAI sessions - // In a real implementation, you'd want to track which user - // is speaking and only send to their session + // Forward audio ONLY to the sender's session bot.sessionsMu.RLock() - for _, session := range bot.sessions { + if session, ok := bot.sessions[e.SenderID]; ok { if session.XAI != nil && session.XAI.IsConnected() { session.XAI.SendAudio(e.PCM) } @@ -113,33 +118,64 @@ func main() { }) // Handle shutdown + shutdownDone := make(chan struct{}) go func() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) <-sigChan log.Println("Cerrando...") - // Close all xAI sessions + // 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() + // Wait for audio senders to stop + time.Sleep(200 * time.Millisecond) + + // Now disconnect from TeamSpeak bot.ts3.Disconnect() - // os.Exit(0) + + // Signal main that we are done + close(shutdownDone) }() + // Start global audio mixer + go bot.runAudioMixer(shutdownDone) + // Connect to TeamSpeak if err := bot.ts3.Connect(); err != nil { - log.Fatalf("Error de conexión: %v", err) + // 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) { +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 @@ -152,7 +188,8 @@ func (b *Bot) createSession(clientID uint16, nickname string) { } // Start audio sender goroutine with proper 20ms timing - go b.audioSender(session) + // Global audio mixer handles sending + // go b.audioSender(session) xaiClient := xai.New(b.apiKey) @@ -184,15 +221,18 @@ func (b *Bot) createSession(clientID uint16, nickname string) { // Clear audio queue when user starts speaking (interruption) xaiClient.OnSpeechStarted(func() { - 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] Audio queue cleared (user interruption)") + // 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 @@ -218,26 +258,74 @@ func (b *Bot) createSession(clientID uint16, nickname string) { 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) + } + } } -// audioSender sends audio frames to TeamSpeak with proper 20ms timing -func (b *Bot) audioSender(session *VoiceSession) { +// runAudioMixer mixes audio from all active sessions and sends it to TeamSpeak +func (b *Bot) runAudioMixer(stop <-chan struct{}) { ticker := time.NewTicker(20 * time.Millisecond) defer ticker.Stop() + mixedFrame := make([]int16, 960) + for { select { - case <-session.done: + case <-stop: return case <-ticker.C: - // Try to get a frame from the queue - select { - case frame := <-session.AudioQueue: - if err := b.ts3.SendAudio(frame); err != nil { - log.Printf("[Session] Error enviando audio: %v", err) + 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) } - default: - // No frame available, that's ok } } } diff --git a/internal/client/client.go b/internal/client/client.go index f7cc6b8..977c02a 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -2,6 +2,7 @@ package client import ( "log" + "sync" "time" "go-ts/pkg/protocol" @@ -45,8 +46,9 @@ type Client struct { Channels map[uint64]*Channel // Audio - VoiceDecoders map[uint16]*opus.Decoder // Map VID (sender ID) to decoder - VoiceEncoder *opus.Encoder // Encoder for outgoing audio + VoiceDecoders map[uint16]*opus.Decoder // Map VID (sender ID) to decoder + VoiceEncoder *opus.Encoder // Encoder for outgoing audio + VoiceEncoderMu sync.Mutex // Protects VoiceEncoder // Event handler for public API eventHandler EventHandler diff --git a/internal/client/commands.go b/internal/client/commands.go index a354c59..faf42b9 100644 --- a/internal/client/commands.go +++ b/internal/client/commands.go @@ -154,264 +154,269 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error { log.Printf("Command: %s", cmdStr) - // Parse Command - cmd, args := protocol.ParseCommand([]byte(cmdStr)) + // Parse Commands (possibly multiple piped items) + commands := protocol.ParseCommands([]byte(cmdStr)) - 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 + 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) } - } - 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 + 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 { - 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 + if targetChan == nil { + if ch, ok := c.Channels[2]; ok { + log.Printf("Name parsing failed. Defaulting to Channel 2 as 'Test'.") + targetChan = ch + } } - moveCmd := protocol.NewCommand("clientmove") - moveCmd.AddParam("clid", fmt.Sprintf("%d", c.ClientID)) - moveCmd.AddParam("cid", fmt.Sprintf("%d", targetChan.ID)) - moveCmd.AddParam("cpw", "") + if targetChan != nil { + log.Printf("Found target channel 'Test' (ID=%d). Joining...", targetChan.ID) - 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) - } + if c.ClientID == 0 { + log.Println("ERROR: ClientID is 0. Cannot join channel. 'initserver' missing?") + return nil + } - 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 + 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) } - } - - 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" + 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 rmsg, ok := args["reasonmsg"]; ok { - if rmsg != "" { - reason = protocol.Unescape(rmsg) + 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) } - 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 } diff --git a/internal/client/voice.go b/internal/client/voice.go index 5317708..4b5b366 100644 --- a/internal/client/voice.go +++ b/internal/client/voice.go @@ -117,6 +117,10 @@ func (c *Client) SendVoice(pcm []int16) error { channels := 1 codec := uint8(CodecOpusVoice) + // Protect shared encoder + c.VoiceEncoderMu.Lock() + defer c.VoiceEncoderMu.Unlock() + // Get or Create Encoder if c.VoiceEncoder == nil { var err error diff --git a/pkg/protocol/commands.go b/pkg/protocol/commands.go index ffe90af..deb4d09 100644 --- a/pkg/protocol/commands.go +++ b/pkg/protocol/commands.go @@ -23,6 +23,31 @@ func ParseCommand(data []byte) (string, map[string]string) { return cmd, args } +// ParseCommands parses response that may contain multiple items separated by pipe (|) +func ParseCommands(data []byte) []*Command { + s := string(data) + // TS3 uses pipe | to separate list items + items := strings.Split(s, "|") + + cmds := make([]*Command, 0, len(items)) + + // First item contains the command name + name, args := ParseCommand([]byte(items[0])) + cmds = append(cmds, &Command{Name: name, Params: args}) + + // Subsequent items reuse the same command name + for _, item := range items[1:] { + // Hack: Prepend command name to reuse ParseCommand logic + // or better: manually parse args. + // Since ParseCommand splits by space, we can just use "DUMMY " + item + // ensuring we trim properly. + _, itemArgs := ParseCommand([]byte("CMD " + strings.TrimSpace(item))) + cmds = append(cmds, &Command{Name: name, Params: itemArgs}) + } + + return cmds +} + // Unescape TS3 string func Unescape(s string) string { r := strings.NewReplacer( diff --git a/pkg/ts3client/client.go b/pkg/ts3client/client.go index 29ef87c..14291fa 100644 --- a/pkg/ts3client/client.go +++ b/pkg/ts3client/client.go @@ -132,9 +132,11 @@ func (c *Client) Connect() error { err := c.internal.Connect(c.address) if err != nil { c.emit(EventDisconnected, &DisconnectedEvent{Reason: err.Error()}) + log.Printf("[TS3Client] Connect returning with error: %v", err) return err } + log.Printf("[TS3Client] Connect returning cleanly") return nil } @@ -154,18 +156,24 @@ func (c *Client) ConnectAsync() <-chan error { // Disconnect closes the connection gracefully func (c *Client) Disconnect() { + log.Println("[Disconnect] Starting disconnect sequence...") if c.internal != nil { // Send disconnect command to server + log.Println("[Disconnect] Sending disconnect command...") c.sendDisconnect("leaving") - // Small delay to allow packet to be sent - time.Sleep(100 * time.Millisecond) + // Wait for packet to be sent and ACKed - the internal loop must still be running + log.Println("[Disconnect] Waiting for disconnect to be processed...") + time.Sleep(1000 * time.Millisecond) // Stop the internal loop + log.Println("[Disconnect] Stopping internal loop...") c.internal.Stop() if c.internal.Conn != nil { + log.Println("[Disconnect] Closing connection...") c.internal.Conn.Close() } } c.connected = false + log.Println("[Disconnect] Done") c.emit(EventDisconnected, &DisconnectedEvent{Reason: "client disconnect"}) } diff --git a/pkg/xai/client.go b/pkg/xai/client.go index 9d98973..894427c 100644 --- a/pkg/xai/client.go +++ b/pkg/xai/client.go @@ -129,6 +129,35 @@ func (c *Client) SendAudio(pcm []int16) error { return c.sendJSON(msg) } +// SendText sends a text message to trigger a Grok response +func (c *Client) SendText(text string) error { + // Create conversation item with text + createMsg := ConversationItemCreate{ + Type: "conversation.item.create", + Item: ConversationItem{ + Type: "message", + Role: "user", + Content: []ItemContent{ + {Type: "input_text", Text: text}, + }, + }, + } + + if err := c.sendJSON(createMsg); err != nil { + return err + } + + // Request response + responseMsg := ResponseCreate{ + Type: "response.create", + Response: ResponseSettings{ + Modalities: []string{"text", "audio"}, + }, + } + + return c.sendJSON(responseMsg) +} + // Close closes the WebSocket connection func (c *Client) Close() { c.mu.Lock() @@ -179,6 +208,13 @@ func (c *Client) receiveLoop() { _, message, err := c.conn.ReadMessage() if err != nil { + // Check if closed intentionally + select { + case <-c.done: + return + default: + } + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { log.Println("[xAI] Connection closed normally") } else { diff --git a/voicebot.exe b/voicebot.exe index 78924cd..5a56780 100644 Binary files a/voicebot.exe and b/voicebot.exe differ