feat: Implement core voicebot functionality with TeamSpeak 3 and xAI integration.
This commit is contained in:
5
bot.ps1
5
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"
|
$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
2
bot2.ps1
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
$env:XAI_API_KEY = "xai-TyecBoTLlFNL0Qxwnb0eRainG8hKTpJGtnCziMhm1tTyB1FrLpZm0gHNYA9qqqX21JsXStN1f9DseLdJ"
|
||||||
|
go run ./cmd/voicebot --server localhost:9987 --nickname Adam --voice Rex --greeting " "
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -29,16 +30,19 @@ type Bot struct {
|
|||||||
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")
|
||||||
@@ -55,7 +59,9 @@ func main() {
|
|||||||
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.",
|
||||||
|
greeting: *greeting,
|
||||||
sessions: make(map[uint16]*VoiceSession),
|
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 {
|
||||||
|
// 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)
|
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,6 +221,8 @@ 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() {
|
||||||
|
// Disable queue clearing for now to prevent cutting off greetings due to sensitive VAD
|
||||||
|
/*
|
||||||
b.sessionsMu.Lock()
|
b.sessionsMu.Lock()
|
||||||
// Clear the buffer
|
// Clear the buffer
|
||||||
session.AudioBuffer = session.AudioBuffer[:0]
|
session.AudioBuffer = session.AudioBuffer[:0]
|
||||||
@@ -192,7 +231,8 @@ func (b *Bot) createSession(clientID uint16, nickname string) {
|
|||||||
<-session.AudioQueue
|
<-session.AudioQueue
|
||||||
}
|
}
|
||||||
b.sessionsMu.Unlock()
|
b.sessionsMu.Unlock()
|
||||||
log.Printf("[Session] Audio queue cleared (user interruption)")
|
*/
|
||||||
|
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:
|
||||||
|
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
|
// Try to get a frame from the queue
|
||||||
select {
|
select {
|
||||||
case frame := <-session.AudioQueue:
|
case frame := <-session.AudioQueue:
|
||||||
if err := b.ts3.SendAudio(frame); err != nil {
|
hasAudio = true
|
||||||
log.Printf("[Session] Error enviando audio: %v", err)
|
// 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:
|
default:
|
||||||
// No frame available, that's ok
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go-ts/pkg/protocol"
|
"go-ts/pkg/protocol"
|
||||||
@@ -47,6 +48,7 @@ type Client struct {
|
|||||||
// 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
|
||||||
|
|||||||
@@ -154,8 +154,12 @@ 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))
|
||||||
|
|
||||||
|
for _, command := range commands {
|
||||||
|
cmd := command.Name
|
||||||
|
args := command.Params
|
||||||
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "initivexpand2":
|
case "initivexpand2":
|
||||||
@@ -412,6 +416,7 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error {
|
|||||||
default:
|
default:
|
||||||
log.Printf("Unhandled command: %s Args: %v", cmd, args)
|
log.Printf("Unhandled command: %s Args: %v", cmd, args)
|
||||||
}
|
}
|
||||||
|
} // End for loop
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
BIN
voicebot.exe
BIN
voicebot.exe
Binary file not shown.
Reference in New Issue
Block a user