feat: Implement core voicebot functionality with TeamSpeak 3 and xAI integration.

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-16 10:39:27 +01:00
parent aa8c0dbcbc
commit fb17813dcb
10 changed files with 460 additions and 287 deletions

View File

@@ -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" $env:XAI_API_KEY = "xai-TyecBoTLlFNL0Qxwnb0eRainG8hKTpJGtnCziMhm1tTyB1FrLpZm0gHNYA9qqqX21JsXStN1f9DseLdJ"
go run ./cmd/voicebot --server localhost:9987 --nickname GrokBot --voice Ara go run ./cmd/voicebot --server localhost:9987 --nickname Eva --voice Ara --greeting "Hola!"

2
bot2.ps1 Normal file
View File

@@ -0,0 +1,2 @@
$env:XAI_API_KEY = "xai-TyecBoTLlFNL0Qxwnb0eRainG8hKTpJGtnCziMhm1tTyB1FrLpZm0gHNYA9qqqX21JsXStN1f9DseLdJ"
go run ./cmd/voicebot --server localhost:9987 --nickname Adam --voice Rex --greeting " "

View File

@@ -5,6 +5,7 @@ import (
"log" "log"
"os" "os"
"os/signal" "os/signal"
"strings"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@@ -25,20 +26,23 @@ type VoiceSession struct {
// Bot manages the TeamSpeak connection and xAI sessions // Bot manages the TeamSpeak connection and xAI sessions
type Bot struct { type Bot struct {
ts3 *ts3client.Client ts3 *ts3client.Client
apiKey string apiKey string
voice string voice string
prompt string prompt string
greeting string // Optional greeting when user joins
selfID uint16 // Our own ClientID selfID uint16 // Our own ClientID
sessions map[uint16]*VoiceSession sessions map[uint16]*VoiceSession
sessionsMu sync.RWMutex sessionsMu sync.RWMutex
startTime time.Time
} }
func main() { func main() {
serverAddr := flag.String("server", "127.0.0.1:9987", "TeamSpeak 3 Server Address") serverAddr := flag.String("server", "127.0.0.1:9987", "TeamSpeak 3 Server Address")
nickname := flag.String("nickname", "GrokBot", "Bot nickname") nickname := flag.String("nickname", "GrokBot", "Bot nickname")
voice := flag.String("voice", xai.VoiceAra, "xAI voice (Ara, Rex, Sal, Eve, Leo)") 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() flag.Parse()
apiKey := os.Getenv("XAI_API_KEY") apiKey := os.Getenv("XAI_API_KEY")
@@ -52,10 +56,12 @@ func main() {
log.Printf("Voice: %s", *voice) log.Printf("Voice: %s", *voice)
bot := &Bot{ bot := &Bot{
apiKey: apiKey, apiKey: apiKey,
voice: *voice, voice: *voice,
prompt: "Eres Grok, un asistente de voz amigable y útil. Responde de forma concisa y natural.", prompt: "Eres Grok, un asistente de voz amigable y útil. Responde de forma concisa y natural.",
sessions: make(map[uint16]*VoiceSession), greeting: *greeting,
sessions: make(map[uint16]*VoiceSession),
startTime: time.Now(),
} }
// Create TeamSpeak client // Create TeamSpeak client
@@ -83,7 +89,7 @@ func main() {
} }
// Create xAI session for this user // 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) { bot.ts3.On(ts3client.EventClientLeft, func(e *ts3client.ClientLeftEvent) {
@@ -95,10 +101,9 @@ func main() {
bot.ts3.On(ts3client.EventAudio, func(e *ts3client.AudioEvent) { bot.ts3.On(ts3client.EventAudio, func(e *ts3client.AudioEvent) {
// Forward audio from TeamSpeak to all xAI sessions // Forward audio from TeamSpeak to all xAI sessions
// In a real implementation, you'd want to track which user // Forward audio ONLY to the sender's session
// is speaking and only send to their session
bot.sessionsMu.RLock() bot.sessionsMu.RLock()
for _, session := range bot.sessions { if session, ok := bot.sessions[e.SenderID]; ok {
if session.XAI != nil && session.XAI.IsConnected() { if session.XAI != nil && session.XAI.IsConnected() {
session.XAI.SendAudio(e.PCM) session.XAI.SendAudio(e.PCM)
} }
@@ -113,33 +118,64 @@ func main() {
}) })
// Handle shutdown // Handle shutdown
shutdownDone := make(chan struct{})
go func() { go func() {
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan <-sigChan
log.Println("Cerrando...") log.Println("Cerrando...")
// Close all xAI sessions // Close all xAI sessions and audio senders first
bot.sessionsMu.Lock() bot.sessionsMu.Lock()
for _, session := range bot.sessions { 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 { if session.XAI != nil {
session.XAI.Close() session.XAI.Close()
} }
} }
bot.sessionsMu.Unlock() bot.sessionsMu.Unlock()
// Wait for audio senders to stop
time.Sleep(200 * time.Millisecond)
// Now disconnect from TeamSpeak
bot.ts3.Disconnect() 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 // Connect to TeamSpeak
if err := bot.ts3.Connect(); err != nil { 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 // 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) log.Printf("[Session] Creando sesión xAI para %s...", nickname)
// Create session with audio queue // 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 // 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) 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) // Clear audio queue when user starts speaking (interruption)
xaiClient.OnSpeechStarted(func() { xaiClient.OnSpeechStarted(func() {
b.sessionsMu.Lock() // Disable queue clearing for now to prevent cutting off greetings due to sensitive VAD
// Clear the buffer /*
session.AudioBuffer = session.AudioBuffer[:0] b.sessionsMu.Lock()
// Drain the queue // Clear the buffer
for len(session.AudioQueue) > 0 { session.AudioBuffer = session.AudioBuffer[:0]
<-session.AudioQueue // Drain the queue
} for len(session.AudioQueue) > 0 {
b.sessionsMu.Unlock() <-session.AudioQueue
log.Printf("[Session] Audio queue cleared (user interruption)") }
b.sessionsMu.Unlock()
*/
log.Printf("[Session] Speech started by %s (VAD) - Ignoring interruption to ensure playback", nickname)
}) })
// Connect to xAI // Connect to xAI
@@ -218,26 +258,74 @@ func (b *Bot) createSession(clientID uint16, nickname string) {
b.sessionsMu.Unlock() b.sessionsMu.Unlock()
log.Printf("[Session] ✓ Sesión xAI activa para %s", nickname) 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 // runAudioMixer mixes audio from all active sessions and sends it to TeamSpeak
func (b *Bot) audioSender(session *VoiceSession) { func (b *Bot) runAudioMixer(stop <-chan struct{}) {
ticker := time.NewTicker(20 * time.Millisecond) ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop() defer ticker.Stop()
mixedFrame := make([]int16, 960)
for { for {
select { select {
case <-session.done: case <-stop:
return return
case <-ticker.C: case <-ticker.C:
// Try to get a frame from the queue hasAudio := false
select {
case frame := <-session.AudioQueue: // Zero output buffer
if err := b.ts3.SendAudio(frame); err != nil { for i := range mixedFrame {
log.Printf("[Session] Error enviando audio: %v", err) 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
} }
} }
} }

View File

@@ -2,6 +2,7 @@ package client
import ( import (
"log" "log"
"sync"
"time" "time"
"go-ts/pkg/protocol" "go-ts/pkg/protocol"
@@ -45,8 +46,9 @@ type Client struct {
Channels map[uint64]*Channel Channels map[uint64]*Channel
// Audio // Audio
VoiceDecoders map[uint16]*opus.Decoder // Map VID (sender ID) to decoder VoiceDecoders map[uint16]*opus.Decoder // Map VID (sender ID) to decoder
VoiceEncoder *opus.Encoder // Encoder for outgoing audio VoiceEncoder *opus.Encoder // Encoder for outgoing audio
VoiceEncoderMu sync.Mutex // Protects VoiceEncoder
// Event handler for public API // Event handler for public API
eventHandler EventHandler eventHandler EventHandler

View File

@@ -154,264 +154,269 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error {
log.Printf("Command: %s", cmdStr) log.Printf("Command: %s", cmdStr)
// Parse Command // Parse Commands (possibly multiple piped items)
cmd, args := protocol.ParseCommand([]byte(cmdStr)) commands := protocol.ParseCommands([]byte(cmdStr))
switch cmd { for _, command := range commands {
case "initivexpand2": cmd := command.Name
err := c.Handshake.ProcessInitivexpand2(args) args := command.Params
if err != nil {
log.Printf("Error processing initivexpand2: %v", err) switch cmd {
} case "initivexpand2":
case "initserver": err := c.Handshake.ProcessInitivexpand2(args)
// Server sends this after clientinit - contains our clientID if err != nil {
if cid, ok := args["aclid"]; ok { log.Printf("Error processing initivexpand2: %v", err)
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
} }
} case "initserver":
c.emitEvent("channel_list", map[string]any{ // Server sends this after clientinit - contains our clientID
"channels": channelList, if cid, ok := args["aclid"]; ok {
}) var id uint64
fmt.Sscanf(cid, "%d", &id)
if targetChan == nil { c.ClientID = uint16(id)
if ch, ok := c.Channels[2]; ok { log.Printf("Assigned ClientID: %d", c.ClientID)
log.Printf("Name parsing failed. Defaulting to Channel 2 as 'Test'.")
targetChan = ch
} }
} 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 targetChan == nil {
log.Printf("Found target channel 'Test' (ID=%d). Joining...", targetChan.ID) if ch, ok := c.Channels[2]; ok {
log.Printf("Name parsing failed. Defaulting to Channel 2 as 'Test'.")
if c.ClientID == 0 { targetChan = ch
log.Println("ERROR: ClientID is 0. Cannot join channel. 'initserver' missing?") }
return nil
} }
moveCmd := protocol.NewCommand("clientmove") if targetChan != nil {
moveCmd.AddParam("clid", fmt.Sprintf("%d", c.ClientID)) log.Printf("Found target channel 'Test' (ID=%d). Joining...", targetChan.ID)
moveCmd.AddParam("cid", fmt.Sprintf("%d", targetChan.ID))
moveCmd.AddParam("cpw", "")
return c.SendCommand(moveCmd) if c.ClientID == 0 {
} log.Println("ERROR: ClientID is 0. Cannot join channel. 'initserver' missing?")
case "notifycliententerview": return nil
// 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" moveCmd := protocol.NewCommand("clientmove")
if tm, ok := args["targetmode"]; ok { moveCmd.AddParam("clid", fmt.Sprintf("%d", c.ClientID))
switch tm { moveCmd.AddParam("cid", fmt.Sprintf("%d", targetChan.ID))
case "1": moveCmd.AddParam("cpw", "")
targetMode = "Private"
targetModeInt = 1 return c.SendCommand(moveCmd)
case "2":
targetMode = "Channel"
targetModeInt = 2
case "3":
targetMode = "Server"
targetModeInt = 3
} }
} case "notifycliententerview":
// A client entered the server
log.Printf("[Chat][%s] %s: %s", targetMode, invoker, msg) nick := ""
c.emitEvent("message", map[string]any{ clientID := uint16(0)
"senderID": invokerID, channelID := uint64(0)
"senderName": invoker, if n, ok := args["client_nickname"]; ok {
"message": msg, nick = protocol.Unescape(n)
"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 cid, ok := args["clid"]; ok {
if rmsg, ok := args["reasonmsg"]; ok { var id uint64
if rmsg != "" { fmt.Sscanf(cid, "%d", &id)
reason = protocol.Unescape(rmsg) 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) } // End for loop
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)
}
return nil return nil
} }

View File

@@ -117,6 +117,10 @@ func (c *Client) SendVoice(pcm []int16) error {
channels := 1 channels := 1
codec := uint8(CodecOpusVoice) codec := uint8(CodecOpusVoice)
// Protect shared encoder
c.VoiceEncoderMu.Lock()
defer c.VoiceEncoderMu.Unlock()
// Get or Create Encoder // Get or Create Encoder
if c.VoiceEncoder == nil { if c.VoiceEncoder == nil {
var err error var err error

View File

@@ -23,6 +23,31 @@ func ParseCommand(data []byte) (string, map[string]string) {
return cmd, args 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 // Unescape TS3 string
func Unescape(s string) string { func Unescape(s string) string {
r := strings.NewReplacer( r := strings.NewReplacer(

View File

@@ -132,9 +132,11 @@ func (c *Client) Connect() error {
err := c.internal.Connect(c.address) err := c.internal.Connect(c.address)
if err != nil { if err != nil {
c.emit(EventDisconnected, &DisconnectedEvent{Reason: err.Error()}) c.emit(EventDisconnected, &DisconnectedEvent{Reason: err.Error()})
log.Printf("[TS3Client] Connect returning with error: %v", err)
return err return err
} }
log.Printf("[TS3Client] Connect returning cleanly")
return nil return nil
} }
@@ -154,18 +156,24 @@ func (c *Client) ConnectAsync() <-chan error {
// Disconnect closes the connection gracefully // Disconnect closes the connection gracefully
func (c *Client) Disconnect() { func (c *Client) Disconnect() {
log.Println("[Disconnect] Starting disconnect sequence...")
if c.internal != nil { if c.internal != nil {
// Send disconnect command to server // Send disconnect command to server
log.Println("[Disconnect] Sending disconnect command...")
c.sendDisconnect("leaving") c.sendDisconnect("leaving")
// Small delay to allow packet to be sent // Wait for packet to be sent and ACKed - the internal loop must still be running
time.Sleep(100 * time.Millisecond) log.Println("[Disconnect] Waiting for disconnect to be processed...")
time.Sleep(1000 * time.Millisecond)
// Stop the internal loop // Stop the internal loop
log.Println("[Disconnect] Stopping internal loop...")
c.internal.Stop() c.internal.Stop()
if c.internal.Conn != nil { if c.internal.Conn != nil {
log.Println("[Disconnect] Closing connection...")
c.internal.Conn.Close() c.internal.Conn.Close()
} }
} }
c.connected = false c.connected = false
log.Println("[Disconnect] Done")
c.emit(EventDisconnected, &DisconnectedEvent{Reason: "client disconnect"}) c.emit(EventDisconnected, &DisconnectedEvent{Reason: "client disconnect"})
} }

View File

@@ -129,6 +129,35 @@ func (c *Client) SendAudio(pcm []int16) error {
return c.sendJSON(msg) 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 // Close closes the WebSocket connection
func (c *Client) Close() { func (c *Client) Close() {
c.mu.Lock() c.mu.Lock()
@@ -179,6 +208,13 @@ func (c *Client) receiveLoop() {
_, message, err := c.conn.ReadMessage() _, message, err := c.conn.ReadMessage()
if err != nil { if err != nil {
// Check if closed intentionally
select {
case <-c.done:
return
default:
}
if websocket.IsCloseError(err, websocket.CloseNormalClosure) { if websocket.IsCloseError(err, websocket.CloseNormalClosure) {
log.Println("[xAI] Connection closed normally") log.Println("[xAI] Connection closed normally")
} else { } else {

Binary file not shown.