package teamspeak import ( "context" "errors" "fmt" "log" "log/slog" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/honeybbq/teamspeak-go/commands" "github.com/honeybbq/teamspeak-go/handshake" "github.com/honeybbq/teamspeak-go/transport" ) var ( errTeamSpeakCommand = errors.New("TeamSpeak server error") errCommandTimed = errors.New("command timeout") voicePacketCount uint64 ) type commandResult struct { Err error Data []map[string]string } // commandTracker matches return_code values to pending commands and response rows. type commandTracker struct { pending map[uint32]chan commandResult collecting map[uint32][]map[string]string mu sync.Mutex nextRC uint32 } func newCommandTracker() *commandTracker { return &commandTracker{ pending: make(map[uint32]chan commandResult), collecting: make(map[uint32][]map[string]string), } } func (t *commandTracker) register() (uint32, <-chan commandResult) { rc := atomic.AddUint32(&t.nextRC, 1) ch := make(chan commandResult, 1) t.mu.Lock() t.pending[rc] = ch t.mu.Unlock() return rc, ch } func (t *commandTracker) unregister(rc uint32) { t.mu.Lock() delete(t.pending, rc) t.mu.Unlock() } // collect appends a parameter row to the pending command with the largest return_code. func (t *commandTracker) collect(params map[string]string) { t.mu.Lock() defer t.mu.Unlock() var maxRC uint32 for rc := range t.pending { if rc > maxRC { maxRC = rc } } if maxRC > 0 { t.collecting[maxRC] = append(t.collecting[maxRC], params) } } func (t *commandTracker) resolve(rc uint32, err error) { t.mu.Lock() defer t.mu.Unlock() if ch, ok := t.pending[rc]; ok { data := t.collecting[rc] delete(t.collecting, rc) ch <- commandResult{Data: data, Err: err} } } func (t *commandTracker) reset() { t.mu.Lock() t.pending = make(map[uint32]chan commandResult) t.collecting = make(map[uint32][]map[string]string) t.mu.Unlock() } func (c *Client) handlePacket(p *transport.Packet) { c.logger.Debug("received packet", slog.Uint64("type", uint64(p.Type())), slog.Int("length", len(p.Data))) switch p.Type() { case transport.PacketTypeInit1: c.logger.Debug("processing init1 packet") response := handshake.ProcessInit1(c.crypt, p.Data) if response != nil { c.logger.Debug("sending init1 response") err := c.handler.SendPacket(byte(transport.PacketTypeInit1), response, 0) if err != nil { c.logger.Warn("failed to send init1 response", slog.Any("error", err)) } } case transport.PacketTypeCommand, transport.PacketTypeCommandLow: if len(p.Data) == 0 { return } dataStr := string(p.Data) c.logger.Debug("received command data", slog.String("data", dataStr)) c.handleCommandLines(dataStr) case transport.PacketTypeVoice, transport.PacketTypeVoiceWhisper: // Voice payload format: [packetID(2 BE)] [clientID(2 BE)] [codec(1)] [opusData...]. if len(p.Data) >= 5 { packetID := uint16(p.Data[0])<<8 | uint16(p.Data[1]) clientID := uint16(p.Data[2])<<8 | uint16(p.Data[3]) codec := p.Data[4] count := atomic.AddUint64(&voicePacketCount, 1) if count <= 5 || count%250 == 0 { log.Printf("[TSVoice] packet count=%d client=%d codec=%d sequence=%d payload=%d whisper=%t", count, clientID, codec, packetID, len(p.Data)-5, p.Type() == transport.PacketTypeVoiceWhisper) } opusData := make([]byte, len(p.Data)-5) copy(opusData, p.Data[5:]) c.notifyEvent(VoiceDataEvent{ ClientID: clientID, Data: opusData, Codec: codec, Sequence: packetID, IsWhisper: p.Type() == transport.PacketTypeVoiceWhisper, }) } case transport.PacketTypePing, transport.PacketTypePong, transport.PacketTypeAck, transport.PacketTypeAckLow: return } } func (c *Client) handleCommandLines(s string) { if s == "" { return } start := 0 for i := 0; i <= len(s); i++ { if i == len(s) || s[i] == '\n' || s[i] == 0x00 { part := strings.TrimSuffix(s[start:i], "\r") if part != "" { rows := splitCommandRows(part) for _, row := range rows { c.handleCommand(row) } } start = i + 1 } } } func (c *Client) handleCommand(s string) { cmd := commands.ParseCommand(s) if cmd == nil { return } // Nameless rows are the data rows of list responses (clientlist, // channellist), which arrive as bare key=value pairs. Collect them for the // pending command instead of dropping them. if cmd.Name == "" { if len(cmd.Params) > 0 { c.cmdTrack.collect(cmd.Params) } return } c.logger.Debug("processing command", slog.String("name", cmd.Name), slog.Any("params", cmd.Params)) if strings.HasPrefix(cmd.Name, "notify") { c.handleNotification(cmd) return } switch cmd.Name { case "clientinitiv": c.handleHandshakeInitIV(cmd) case "initivexpand2": c.handleHandshakeExpand2(cmd) case "initserver": c.handleInitServer(cmd) case "error": c.handleError(cmd) default: c.cmdTrack.collect(cmd.Params) c.logger.Debug("unhandled or data command", slog.String("name", cmd.Name), slog.Any("params", cmd.Params)) } } func (c *Client) handleError(cmd *commands.Command) { id := cmd.Params["id"] msg := cmd.Params["msg"] rcStr := cmd.Params["return_code"] var err error if id != "0" { err = fmt.Errorf("%w: %s (id=%s)", errTeamSpeakCommand, msg, id) c.logger.Error("server returned error", slog.String("id", id), slog.String("message", msg)) // Fatal server errors (e.g. banned) during the handshake are handled // below via signalConnected + transport close. The separate // go c.Disconnect() is only needed when already fully connected. if id == "3329" { c.mu.Lock() isConnecting := c.status == StatusConnecting c.mu.Unlock() if !isConnecting { c.logger.Warn("fatal connection error detected, closing connection", slog.String("id", id)) go func() { disconnectErr := c.Disconnect() if disconnectErr != nil { c.logger.Warn("disconnect after fatal error failed", slog.Any("error", disconnectErr)) } }() } } } // Handshake-phase errors (e.g. password error 1028, banned 3329) arrive // without a return_code. When we are still in the connecting phase, // store the error, unblock WaitConnected, and close the transport so // the connection is cleaned up promptly. if rcStr == "" && id != "0" { c.mu.Lock() isConnecting := c.status == StatusConnecting c.mu.Unlock() if isConnecting { c.logger.Info("handshake error detected, unblocking WaitConnected", slog.String("id", id), slog.String("message", msg)) c.signalConnected(err) // Close transport to trigger handleConnectionClosed and clean up. // handleConnectionClosed will fire disconnected handlers with the // stored handshake error. go func() { if closeErr := c.handler.Close(); closeErr != nil { c.logger.Warn("transport close after handshake error failed", slog.Any("error", closeErr)) } }() } } if rcStr != "" { rc, parseErr := strconv.ParseUint(rcStr, 10, 32) if parseErr == nil { c.cmdTrack.resolve(uint32(rc), err) } } } // SendCommandNoWait sends a command without waiting for return_code. func (c *Client) SendCommandNoWait(cmd string) error { err := c.throttle.wait(context.Background()) if err != nil { return err } c.logger.Debug("sending command without waiting", slog.String("raw", cmd)) return c.finalCmdHandler(cmd) } // ExecCommand sends a command and waits for its return_code response. func (c *Client) ExecCommand(cmd string, timeout time.Duration) error { _, err := c.ExecCommandWithResponse(cmd, timeout) return err } // ExecCommandWithResponse sends a command and waits for its return_code response and data. func (c *Client) ExecCommandWithResponse(cmd string, timeout time.Duration) ([]map[string]string, error) { rc, ch := c.cmdTrack.register() defer c.cmdTrack.unregister(rc) withReturnCode := cmd if !strings.Contains(cmd, "return_code=") { withReturnCode = fmt.Sprintf("%s return_code=%d", cmd, rc) } c.logger.Debug("sending command", slog.String("raw", withReturnCode)) err := c.throttle.wait(context.Background()) if err != nil { return nil, err } err = c.finalCmdHandler(withReturnCode) if err != nil { return nil, err } select { case res := <-ch: return res.Data, res.Err case <-time.After(timeout): return nil, fmt.Errorf("%w: %s", errCommandTimed, cmd) } }