306 lines
8.2 KiB
Go
306 lines
8.2 KiB
Go
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)
|
|
}
|
|
}
|