feat: refactor client into reusable ts3client library

This commit is contained in:
Jose Luis Montañes Ojados
2026-01-15 22:06:35 +01:00
parent 7878ad3d5b
commit 02318b1490
10 changed files with 1050 additions and 47 deletions

93
cmd/example/main.go Normal file
View File

@@ -0,0 +1,93 @@
package main
import (
"flag"
"log"
"os"
"os/signal"
"syscall"
"go-ts/pkg/ts3client"
)
func main() {
serverAddr := flag.String("server", "127.0.0.1:9987", "TeamSpeak 3 Server Address")
nickname := flag.String("nickname", "GoBot", "Nickname")
flag.Parse()
log.Printf("=== TeamSpeak Library Example ===")
log.Printf("Server: %s", *serverAddr)
log.Printf("Nickname: %s", *nickname)
// Create client
client := ts3client.New(*serverAddr, ts3client.Config{
Nickname: *nickname,
})
// Register event handlers
client.On(ts3client.EventConnected, func(e *ts3client.ConnectedEvent) {
log.Printf("✓ Connected! ClientID=%d, Server=%s", e.ClientID, e.ServerName)
})
client.On(ts3client.EventChannelList, func(e *ts3client.ChannelListEvent) {
log.Printf("✓ Received %d channels:", len(e.Channels))
for _, ch := range e.Channels {
log.Printf(" [%d] %s", ch.ID, ch.Name)
}
// Join first non-default channel
for _, ch := range e.Channels {
if ch.ID != 1 && ch.ParentID == 0 {
log.Printf("Joining channel: %s (ID=%d)", ch.Name, ch.ID)
client.JoinChannel(ch.ID)
break
}
}
})
client.On(ts3client.EventMessage, func(e *ts3client.MessageEvent) {
log.Printf("[%s] %s: %s", e.TargetMode.String(), e.SenderName, e.Message)
// Echo back channel messages
if e.TargetMode == ts3client.MessageTargetChannel {
client.SendChannelMessage("Echo: " + e.Message)
}
})
client.On(ts3client.EventClientEnter, func(e *ts3client.ClientEnterEvent) {
log.Printf("→ Client joined: %s (ID=%d)", e.Nickname, e.ClientID)
})
client.On(ts3client.EventClientLeft, func(e *ts3client.ClientLeftEvent) {
log.Printf("← Client left: ID=%d (%s)", e.ClientID, e.Reason)
})
client.On(ts3client.EventAudio, func(e *ts3client.AudioEvent) {
// Example: Echo audio (reduce volume)
for i := range e.PCM {
e.PCM[i] = e.PCM[i] / 4
}
client.SendAudio(e.PCM)
})
client.On(ts3client.EventError, func(e *ts3client.ErrorEvent) {
if e.ID != "0" { // ID 0 = OK
log.Printf("! Server Error: [%s] %s", e.ID, e.Message)
}
})
// Handle shutdown
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
log.Println("Shutting down...")
client.Disconnect()
os.Exit(0)
}()
// Connect (blocks until error or disconnect)
if err := client.Connect(); err != nil {
log.Fatalf("Connection error: %v", err)
}
}

BIN
example.exe Normal file

Binary file not shown.

View File

@@ -17,6 +17,9 @@ type Channel struct {
Order uint64 Order uint64
} }
// EventHandler is called when events occur
type EventHandler func(eventType string, data map[string]any)
type Client struct { type Client struct {
Conn *transport.TS3Conn Conn *transport.TS3Conn
Handshake *HandshakeState Handshake *HandshakeState
@@ -29,7 +32,8 @@ type Client struct {
VoicePacketID uint16 VoicePacketID uint16
// State // State
Connected bool Connected bool
ServerName string
// Fragment reassembly // Fragment reassembly
FragmentBuffer []byte FragmentBuffer []byte
@@ -43,6 +47,12 @@ 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
// Event handler for public API
eventHandler EventHandler
// Done channel to signal shutdown
done chan struct{}
} }
func NewClient(nickname string) *Client { func NewClient(nickname string) *Client {
@@ -52,6 +62,29 @@ func NewClient(nickname string) *Client {
VoicePacketID: 1, VoicePacketID: 1,
Channels: make(map[uint64]*Channel), Channels: make(map[uint64]*Channel),
VoiceDecoders: make(map[uint16]*opus.Decoder), VoiceDecoders: make(map[uint16]*opus.Decoder),
done: make(chan struct{}),
}
}
// SetEventHandler sets the callback for events
func (c *Client) SetEventHandler(handler EventHandler) {
c.eventHandler = handler
}
// emitEvent sends an event to the handler if set
func (c *Client) emitEvent(eventType string, data map[string]any) {
if c.eventHandler != nil {
c.eventHandler(eventType, data)
}
}
// Stop signals the client to stop its loops
func (c *Client) Stop() {
select {
case <-c.done:
// Already closed
default:
close(c.done)
} }
} }
@@ -85,11 +118,21 @@ func (c *Client) Connect(address string) error {
for { for {
select { select {
case <-c.done:
log.Println("Client loop stopped")
return nil
case pkt := <-pktChan: case pkt := <-pktChan:
if pkt == nil {
// Channel closed
return nil
}
if err := c.handlePacket(pkt); err != nil { if err := c.handlePacket(pkt); err != nil {
log.Printf("Error handling packet: %v", err) log.Printf("Error handling packet: %v", err)
} }
case <-ticker.C: case <-ticker.C:
if !c.Connected {
continue // Don't send pings if not connected yet
}
ping := protocol.NewPacket(protocol.PacketTypePing, nil) ping := protocol.NewPacket(protocol.PacketTypePing, nil)
c.PacketIDCounterC2S++ c.PacketIDCounterC2S++
ping.Header.PacketID = c.PacketIDCounterC2S ping.Header.PacketID = c.PacketIDCounterC2S

View File

@@ -172,8 +172,13 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error {
log.Printf("Assigned ClientID: %d", c.ClientID) log.Printf("Assigned ClientID: %d", c.ClientID)
} }
if name, ok := args["virtualserver_name"]; ok { if name, ok := args["virtualserver_name"]; ok {
log.Printf("Server Name: %s", protocol.Unescape(name)) 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": case "channellist":
// Parse channel info // Parse channel info
ch := &Channel{} ch := &Channel{}
@@ -193,13 +198,18 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error {
log.Printf("Channel: [%d] NameRaw=%q Order=%d Args=%v", ch.ID, ch.Name, ch.Order, args) log.Printf("Channel: [%d] NameRaw=%q Order=%d Args=%v", ch.ID, ch.Name, ch.Order, args)
case "channellistfinished": case "channellistfinished":
log.Printf("=== Channel List Complete (%d channels) ===", len(c.Channels)) log.Printf("=== Channel List Complete (%d channels) ===", len(c.Channels))
var channelList []*Channel
var targetChan *Channel var targetChan *Channel
for _, ch := range c.Channels { for _, ch := range c.Channels {
log.Printf(" - [%d] %s (parent=%d)", ch.ID, ch.Name, ch.ParentID) log.Printf(" - [%d] %s (parent=%d)", ch.ID, ch.Name, ch.ParentID)
channelList = append(channelList, ch)
if ch.Name == "Test" { if ch.Name == "Test" {
targetChan = ch targetChan = ch
} }
} }
c.emitEvent("channel_list", map[string]any{
"channels": channelList,
})
if targetChan == nil { if targetChan == nil {
if ch, ok := c.Channels[2]; ok { if ch, ok := c.Channels[2]; ok {
@@ -226,34 +236,65 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error {
case "notifycliententerview": case "notifycliententerview":
// A client entered the server // A client entered the server
nick := "" nick := ""
clientID := uint16(0)
channelID := uint64(0)
if n, ok := args["client_nickname"]; ok { if n, ok := args["client_nickname"]; ok {
nick = protocol.Unescape(n) nick = protocol.Unescape(n)
log.Printf("Client entered: %s", nick)
} }
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": case "notifytextmessage":
// targetmode: 1=Private, 2=Channel, 3=Server // targetmode: 1=Private, 2=Channel, 3=Server
msg := "" msg := ""
invoker := "Unknown" invoker := "Unknown"
var invokerID uint16
var targetModeInt int
if m, ok := args["msg"]; ok { if m, ok := args["msg"]; ok {
msg = protocol.Unescape(m) msg = protocol.Unescape(m)
} }
if name, ok := args["invokername"]; ok { if name, ok := args["invokername"]; ok {
invoker = protocol.Unescape(name) invoker = protocol.Unescape(name)
} }
if iid, ok := args["invokerid"]; ok {
var id uint64
fmt.Sscanf(iid, "%d", &id)
invokerID = uint16(id)
}
targetMode := "Unknown" targetMode := "Unknown"
if tm, ok := args["targetmode"]; ok { if tm, ok := args["targetmode"]; ok {
switch tm { switch tm {
case "1": case "1":
targetMode = "Private" targetMode = "Private"
targetModeInt = 1
case "2": case "2":
targetMode = "Channel" targetMode = "Channel"
targetModeInt = 2
case "3": case "3":
targetMode = "Server" targetMode = "Server"
targetModeInt = 3
} }
} }
log.Printf("[Chat][%s] %s: %s", targetMode, invoker, msg) 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": case "notifyclientchatcomposing":
// Someone is typing // Someone is typing
@@ -266,10 +307,21 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error {
case "notifyclientmoved": case "notifyclientmoved":
// Client moved to another channel // Client moved to another channel
clid := args["clid"] var clientID uint16
ctid := args["ctid"] var channelID uint64
// reasonid: 0=switched, 1=moved, 2=timeout, 3=kick, 4=unknown if cid, ok := args["clid"]; ok {
log.Printf("Client %s moved to Channel %s", clid, ctid) 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": case "notifyclientchannelgroupchanged":
// Client channel group changed // Client channel group changed
@@ -306,6 +358,40 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error {
return c.SendCommand(cmd) 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": case "notifyclientupdated":
// Client updated (e.g. muted/unmuted) // Client updated (e.g. muted/unmuted)
clid := args["clid"] clid := args["clid"]
@@ -316,6 +402,10 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error {
id := args["id"] id := args["id"]
msg := protocol.Unescape(args["msg"]) msg := protocol.Unescape(args["msg"])
log.Printf("SERVER ERROR: ID=%s MSG=%s", id, 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": case "notifyservergrouplist", "notifychannelgrouplist", "notifyclientneededpermissions":
// Ignore verbose noisy setup commands // Ignore verbose noisy setup commands

View File

@@ -2,6 +2,7 @@ package client
import ( import (
"encoding/binary" "encoding/binary"
"fmt"
"log" "log"
"go-ts/pkg/protocol" "go-ts/pkg/protocol"
@@ -66,7 +67,7 @@ func (c *Client) handleVoice(pkt *protocol.Packet) {
// Only process Opus packets // Only process Opus packets
if codec != CodecOpusVoice && codec != CodecOpusMusic { if codec != CodecOpusVoice && codec != CodecOpusMusic {
log.Printf("Received non-Opus voice packet (Codec=%d). Ignoring echo.", codec) log.Printf("Received non-Opus voice packet (Codec=%d). Ignoring.", codec)
return return
} }
@@ -97,26 +98,32 @@ func (c *Client) handleVoice(pkt *protocol.Packet) {
} }
pcm = pcm[:n*channels] pcm = pcm[:n*channels]
if n != 960 { // 3. Emit audio event instead of auto-echo
log.Printf("WARNING: Unusual Opus frame size: %d samples (expected 960 for 20ms)", n) c.emitEvent("audio", map[string]any{
"senderID": vid,
"codec": int(codec),
"pcm": pcm,
"channels": channels,
})
}
// SendVoice sends PCM audio data to the server
// PCM must be 48kHz, 960 samples for 20ms frame (mono)
func (c *Client) SendVoice(pcm []int16) error {
if c.Conn == nil {
return fmt.Errorf("not connected")
} }
// 3. Process PCM: Reduce Volume (divide by 4) channels := 1
for i := range pcm { codec := uint8(CodecOpusVoice)
pcm[i] = pcm[i] / 10
}
// 4. Get or Create Encoder // Get or Create Encoder
if c.VoiceEncoder == nil { if c.VoiceEncoder == nil {
var err error var err error
app := opus.AppVoIP app := opus.AppVoIP
if channels == 2 {
app = opus.AppAudio
}
encoder, err := opus.NewEncoder(48000, channels, app) encoder, err := opus.NewEncoder(48000, channels, app)
if err != nil { if err != nil {
log.Printf("Failed to create Opus encoder: %v", err) return fmt.Errorf("failed to create Opus encoder: %w", err)
return
} }
// Optimize Quality // Optimize Quality
@@ -126,31 +133,28 @@ func (c *Client) handleVoice(pkt *protocol.Packet) {
c.VoiceEncoder = encoder c.VoiceEncoder = encoder
} }
// 5. Encode PCM to Opus // Encode PCM to Opus
encoded := make([]byte, 1024) encoded := make([]byte, 1024)
nEnc, err := c.VoiceEncoder.Encode(pcm, encoded) nEnc, err := c.VoiceEncoder.Encode(pcm, encoded)
if err != nil { if err != nil {
log.Printf("Opus encode error: %v", err) return fmt.Errorf("opus encode error: %w", err)
return
} }
encoded = encoded[:nEnc] encoded = encoded[:nEnc]
// log.Printf("Voice Processed (CGO): VID=%d, In=%d bytes, PCM=%d samples, Out=%d bytes", vid, len(voiceData), n, nEnc) // Build voice packet (Client -> Server)
// 6. Build echo packet (Client -> Server)
// Payload format: [VId(2)] [Codec(1)] [Data...] // Payload format: [VId(2)] [Codec(1)] [Data...]
echoData := make([]byte, 2+1+len(encoded)) voiceData := make([]byte, 2+1+len(encoded))
c.VoicePacketID++ // Increment counter before using it c.VoicePacketID++ // Increment counter before using it
// Correctly set VId in Payload to be the Sequence Number (not ClientID) // Set VId in Payload to be the Sequence Number (not ClientID)
binary.BigEndian.PutUint16(echoData[0:2], c.VoicePacketID) binary.BigEndian.PutUint16(voiceData[0:2], c.VoicePacketID)
echoData[2] = codec voiceData[2] = codec
copy(echoData[3:], encoded) copy(voiceData[3:], encoded)
echoPkt := protocol.NewPacket(protocol.PacketTypeVoice, echoData) pkt := protocol.NewPacket(protocol.PacketTypeVoice, voiceData)
echoPkt.Header.PacketID = c.VoicePacketID pkt.Header.PacketID = c.VoicePacketID
echoPkt.Header.ClientID = c.ClientID pkt.Header.ClientID = c.ClientID
// Encrypt voice packet // Encrypt voice packet
if c.Handshake != nil && len(c.Handshake.SharedIV) > 0 { if c.Handshake != nil && len(c.Handshake.SharedIV) > 0 {
@@ -159,27 +163,26 @@ func (c *Client) handleVoice(pkt *protocol.Packet) {
SharedMac: c.Handshake.SharedMac, SharedMac: c.Handshake.SharedMac,
GenerationID: 0, GenerationID: 0,
} }
key, nonce := crypto.GenerateKeyNonce(&echoPkt.Header, true) key, nonce := crypto.GenerateKeyNonce(&pkt.Header, true)
meta := make([]byte, 5) meta := make([]byte, 5)
binary.BigEndian.PutUint16(meta[0:2], echoPkt.Header.PacketID) binary.BigEndian.PutUint16(meta[0:2], pkt.Header.PacketID)
binary.BigEndian.PutUint16(meta[2:4], echoPkt.Header.ClientID) binary.BigEndian.PutUint16(meta[2:4], pkt.Header.ClientID)
meta[4] = echoPkt.Header.Type meta[4] = pkt.Header.Type
encData, mac, err := protocol.EncryptEAX(key, nonce, meta, echoPkt.Data) encData, mac, err := protocol.EncryptEAX(key, nonce, meta, pkt.Data)
if err != nil { if err != nil {
log.Printf("Voice encryption failed: %v", err) return fmt.Errorf("voice encryption failed: %w", err)
return
} }
echoPkt.Data = encData pkt.Data = encData
copy(echoPkt.Header.MAC[:], mac) copy(pkt.Header.MAC[:], mac)
} else { } else {
if c.Handshake != nil && len(c.Handshake.SharedMac) > 0 { if c.Handshake != nil && len(c.Handshake.SharedMac) > 0 {
copy(echoPkt.Header.MAC[:], c.Handshake.SharedMac) copy(pkt.Header.MAC[:], c.Handshake.SharedMac)
} else { } else {
echoPkt.Header.MAC = protocol.HandshakeMac pkt.Header.MAC = protocol.HandshakeMac
} }
} }
c.Conn.SendPacket(echoPkt) return c.Conn.SendPacket(pkt)
} }

368
pkg/ts3client/client.go Normal file
View File

@@ -0,0 +1,368 @@
package ts3client
import (
"fmt"
"log"
"sync"
"time"
"go-ts/internal/client"
)
// Client is the main TeamSpeak 3 client
type Client struct {
address string
config Config
// Internal client
internal *client.Client
// Event handlers
handlers map[EventType][]any
mu sync.RWMutex
// State
connected bool
channels map[uint64]*Channel
clients map[uint16]*ClientInfo
serverInfo *ServerInfo
selfInfo *SelfInfo
channelsMu sync.RWMutex
clientsMu sync.RWMutex
}
// New creates a new TeamSpeak client
func New(address string, config Config) *Client {
// Apply defaults
if config.Nickname == "" {
config.Nickname = "GoTS3Bot"
}
if config.SecurityLevel == 0 {
config.SecurityLevel = 8
}
if config.Version == "" {
config.Version = "3.6.2 [Build: 1690976575]"
}
if config.Platform == "" {
config.Platform = "Windows"
}
if config.HWID == "" {
config.HWID = "1234567890"
}
return &Client{
address: address,
config: config,
handlers: make(map[EventType][]any),
channels: make(map[uint64]*Channel),
clients: make(map[uint16]*ClientInfo),
}
}
// On registers an event handler
// The handler function signature must match the event type:
// - EventConnected: func(*ConnectedEvent)
// - EventMessage: func(*MessageEvent)
// - EventAudio: func(*AudioEvent)
// - etc.
func (c *Client) On(event EventType, handler any) {
c.mu.Lock()
defer c.mu.Unlock()
c.handlers[event] = append(c.handlers[event], handler)
}
// emit calls all handlers registered for the given event
func (c *Client) emit(event EventType, data any) {
c.mu.RLock()
handlers := c.handlers[event]
c.mu.RUnlock()
for _, h := range handlers {
switch event {
case EventConnected:
if fn, ok := h.(func(*ConnectedEvent)); ok {
fn(data.(*ConnectedEvent))
}
case EventDisconnected:
if fn, ok := h.(func(*DisconnectedEvent)); ok {
fn(data.(*DisconnectedEvent))
}
case EventMessage:
if fn, ok := h.(func(*MessageEvent)); ok {
fn(data.(*MessageEvent))
}
case EventClientEnter:
if fn, ok := h.(func(*ClientEnterEvent)); ok {
fn(data.(*ClientEnterEvent))
}
case EventClientLeft:
if fn, ok := h.(func(*ClientLeftEvent)); ok {
fn(data.(*ClientLeftEvent))
}
case EventClientMoved:
if fn, ok := h.(func(*ClientMovedEvent)); ok {
fn(data.(*ClientMovedEvent))
}
case EventChannelList:
if fn, ok := h.(func(*ChannelListEvent)); ok {
fn(data.(*ChannelListEvent))
}
case EventAudio:
if fn, ok := h.(func(*AudioEvent)); ok {
fn(data.(*AudioEvent))
}
case EventError:
if fn, ok := h.(func(*ErrorEvent)); ok {
fn(data.(*ErrorEvent))
}
}
}
}
// Connect establishes a connection to the TeamSpeak server
// This method blocks until disconnected or an error occurs
func (c *Client) Connect() error {
c.internal = client.NewClient(c.config.Nickname)
// Set event callback on internal client
c.internal.SetEventHandler(c.handleInternalEvent)
log.Printf("Connecting to %s as %s...", c.address, c.config.Nickname)
err := c.internal.Connect(c.address)
if err != nil {
c.emit(EventDisconnected, &DisconnectedEvent{Reason: err.Error()})
return err
}
return nil
}
// ConnectAsync connects in the background and returns immediately
func (c *Client) ConnectAsync() <-chan error {
errChan := make(chan error, 1)
go func() {
if err := c.Connect(); err != nil {
errChan <- err
}
close(errChan)
}()
// Give it a moment to start
time.Sleep(100 * time.Millisecond)
return errChan
}
// Disconnect closes the connection gracefully
func (c *Client) Disconnect() {
if c.internal != nil {
// Send disconnect command to server
c.sendDisconnect("leaving")
// Small delay to allow packet to be sent
time.Sleep(100 * time.Millisecond)
// Stop the internal loop
c.internal.Stop()
if c.internal.Conn != nil {
c.internal.Conn.Close()
}
}
c.connected = false
c.emit(EventDisconnected, &DisconnectedEvent{Reason: "client disconnect"})
}
// sendDisconnect sends the disconnect command to the server
func (c *Client) sendDisconnect(reason string) {
if c.internal == nil {
return
}
// Use internal client's SendCommand
cmd := "clientdisconnect reasonid=8 reasonmsg=" + escapeTS3(reason)
log.Printf("Sending disconnect: %s", cmd)
if err := c.internal.SendCommandString(cmd); err != nil {
log.Printf("Error sending disconnect: %v", err)
}
}
type disconnectCommand struct {
reason string
}
func (d *disconnectCommand) encode() string {
return "clientdisconnect reasonid=8 reasonmsg=" + escapeTS3(d.reason)
}
func escapeTS3(s string) string {
// Basic escape for TS3 protocol
result := ""
for _, r := range s {
switch r {
case '\\':
result += "\\\\"
case '/':
result += "\\/"
case ' ':
result += "\\s"
case '|':
result += "\\p"
default:
result += string(r)
}
}
return result
}
// IsConnected returns true if the client is connected
func (c *Client) IsConnected() bool {
return c.connected
}
// handleInternalEvent processes events from the internal client
func (c *Client) handleInternalEvent(eventType string, data map[string]any) {
switch eventType {
case "connected":
c.connected = true
clientID := uint16(0)
serverName := ""
if v, ok := data["clientID"].(uint16); ok {
clientID = v
}
if v, ok := data["serverName"].(string); ok {
serverName = v
}
c.selfInfo = &SelfInfo{ClientID: clientID, Nickname: c.config.Nickname}
c.emit(EventConnected, &ConnectedEvent{
ClientID: clientID,
ServerName: serverName,
})
case "message":
targetMode := MessageTarget(1)
if v, ok := data["targetMode"].(int); ok {
targetMode = MessageTarget(v)
}
c.emit(EventMessage, &MessageEvent{
SenderID: getUint16(data, "senderID"),
SenderName: getString(data, "senderName"),
Message: getString(data, "message"),
TargetMode: targetMode,
})
case "client_enter":
info := &ClientInfo{
ID: getUint16(data, "clientID"),
Nickname: getString(data, "nickname"),
ChannelID: getUint64(data, "channelID"),
}
c.clientsMu.Lock()
c.clients[info.ID] = info
c.clientsMu.Unlock()
c.emit(EventClientEnter, &ClientEnterEvent{
ClientID: info.ID,
Nickname: info.Nickname,
ChannelID: info.ChannelID,
})
case "client_left":
clientID := getUint16(data, "clientID")
c.clientsMu.Lock()
delete(c.clients, clientID)
c.clientsMu.Unlock()
c.emit(EventClientLeft, &ClientLeftEvent{
ClientID: clientID,
Reason: getString(data, "reason"),
})
case "client_moved":
c.emit(EventClientMoved, &ClientMovedEvent{
ClientID: getUint16(data, "clientID"),
ChannelID: getUint64(data, "channelID"),
})
case "channel_list":
if channels, ok := data["channels"].([]*client.Channel); ok {
c.channelsMu.Lock()
var chList []*Channel
for _, ch := range channels {
converted := &Channel{
ID: ch.ID,
ParentID: ch.ParentID,
Name: ch.Name,
Order: ch.Order,
}
c.channels[ch.ID] = converted
chList = append(chList, converted)
}
c.channelsMu.Unlock()
c.emit(EventChannelList, &ChannelListEvent{Channels: chList})
}
case "audio":
c.emit(EventAudio, &AudioEvent{
SenderID: getUint16(data, "senderID"),
Codec: AudioCodec(getInt(data, "codec")),
PCM: getPCM(data, "pcm"),
Channels: getInt(data, "channels"),
})
case "error":
c.emit(EventError, &ErrorEvent{
ID: getString(data, "id"),
Message: getString(data, "message"),
})
}
}
// Helper functions for type conversion
func getString(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
func getUint16(m map[string]any, key string) uint16 {
if v, ok := m[key].(uint16); ok {
return v
}
if v, ok := m[key].(int); ok {
return uint16(v)
}
return 0
}
func getUint64(m map[string]any, key string) uint64 {
if v, ok := m[key].(uint64); ok {
return v
}
if v, ok := m[key].(int); ok {
return uint64(v)
}
return 0
}
func getInt(m map[string]any, key string) int {
if v, ok := m[key].(int); ok {
return v
}
return 0
}
func getPCM(m map[string]any, key string) []int16 {
if v, ok := m[key].([]int16); ok {
return v
}
return nil
}
// WaitForConnection waits until the client is connected or timeout
func (c *Client) WaitForConnection(timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if c.connected {
return nil
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("connection timeout")
}

237
pkg/ts3client/commands.go Normal file
View File

@@ -0,0 +1,237 @@
package ts3client
import (
"fmt"
"go-ts/pkg/protocol"
)
// =============================================================================
// Channel Methods
// =============================================================================
// GetChannels returns all known channels
func (c *Client) GetChannels() []*Channel {
c.channelsMu.RLock()
defer c.channelsMu.RUnlock()
channels := make([]*Channel, 0, len(c.channels))
for _, ch := range c.channels {
channels = append(channels, ch)
}
return channels
}
// GetChannel returns a channel by ID
func (c *Client) GetChannel(id uint64) *Channel {
c.channelsMu.RLock()
defer c.channelsMu.RUnlock()
return c.channels[id]
}
// GetCurrentChannel returns the client's current channel
func (c *Client) GetCurrentChannel() *Channel {
if c.selfInfo == nil {
return nil
}
return c.GetChannel(c.selfInfo.ChannelID)
}
// JoinChannel moves the client to the specified channel
func (c *Client) JoinChannel(channelID uint64) error {
return c.JoinChannelWithPassword(channelID, "")
}
// JoinChannelWithPassword moves the client to a password-protected channel
func (c *Client) JoinChannelWithPassword(channelID uint64, password string) error {
if c.internal == nil || c.selfInfo == nil {
return fmt.Errorf("not connected")
}
cmd := protocol.NewCommand("clientmove")
cmd.AddParam("clid", fmt.Sprintf("%d", c.selfInfo.ClientID))
cmd.AddParam("cid", fmt.Sprintf("%d", channelID))
cmd.AddParam("cpw", password)
err := c.internal.SendCommand(cmd)
if err == nil && c.selfInfo != nil {
c.selfInfo.ChannelID = channelID
}
return err
}
// =============================================================================
// Message Methods
// =============================================================================
// SendChannelMessage sends a message to the current channel
func (c *Client) SendChannelMessage(message string) error {
if c.internal == nil {
return fmt.Errorf("not connected")
}
cmd := protocol.NewCommand("sendtextmessage")
cmd.AddParam("targetmode", "2") // Channel
cmd.AddParam("msg", message)
return c.internal.SendCommand(cmd)
}
// SendPrivateMessage sends a private message to a specific client
func (c *Client) SendPrivateMessage(clientID uint16, message string) error {
if c.internal == nil {
return fmt.Errorf("not connected")
}
cmd := protocol.NewCommand("sendtextmessage")
cmd.AddParam("targetmode", "1") // Private
cmd.AddParam("target", fmt.Sprintf("%d", clientID))
cmd.AddParam("msg", message)
return c.internal.SendCommand(cmd)
}
// SendServerMessage sends a message to the entire server
func (c *Client) SendServerMessage(message string) error {
if c.internal == nil {
return fmt.Errorf("not connected")
}
cmd := protocol.NewCommand("sendtextmessage")
cmd.AddParam("targetmode", "3") // Server
cmd.AddParam("msg", message)
return c.internal.SendCommand(cmd)
}
// =============================================================================
// Audio Methods
// =============================================================================
// SendAudio sends PCM audio data to the server
// PCM must be 48kHz, mono (960 samples for 20ms frame)
func (c *Client) SendAudio(pcm []int16) error {
if c.internal == nil {
return fmt.Errorf("not connected")
}
return c.internal.SendVoice(pcm)
}
// SetInputMuted mutes or unmutes the microphone
func (c *Client) SetInputMuted(muted bool) error {
if c.internal == nil {
return fmt.Errorf("not connected")
}
val := "0"
if muted {
val = "1"
}
cmd := protocol.NewCommand("clientupdate")
cmd.AddParam("client_input_muted", val)
return c.internal.SendCommand(cmd)
}
// SetOutputMuted mutes or unmutes the speaker
func (c *Client) SetOutputMuted(muted bool) error {
if c.internal == nil {
return fmt.Errorf("not connected")
}
val := "0"
if muted {
val = "1"
}
cmd := protocol.NewCommand("clientupdate")
cmd.AddParam("client_output_muted", val)
return c.internal.SendCommand(cmd)
}
// =============================================================================
// Client Methods
// =============================================================================
// GetClients returns all connected clients
func (c *Client) GetClients() []*ClientInfo {
c.clientsMu.RLock()
defer c.clientsMu.RUnlock()
clients := make([]*ClientInfo, 0, len(c.clients))
for _, cl := range c.clients {
clients = append(clients, cl)
}
return clients
}
// GetClientByID returns a client by ID
func (c *Client) GetClientByID(id uint16) *ClientInfo {
c.clientsMu.RLock()
defer c.clientsMu.RUnlock()
return c.clients[id]
}
// KickFromChannel kicks a client from their current channel
func (c *Client) KickFromChannel(clientID uint16, reason string) error {
if c.internal == nil {
return fmt.Errorf("not connected")
}
cmd := protocol.NewCommand("clientkick")
cmd.AddParam("clid", fmt.Sprintf("%d", clientID))
cmd.AddParam("reasonid", "4") // Kick from channel
cmd.AddParam("reasonmsg", reason)
return c.internal.SendCommand(cmd)
}
// KickFromServer kicks a client from the server
func (c *Client) KickFromServer(clientID uint16, reason string) error {
if c.internal == nil {
return fmt.Errorf("not connected")
}
cmd := protocol.NewCommand("clientkick")
cmd.AddParam("clid", fmt.Sprintf("%d", clientID))
cmd.AddParam("reasonid", "5") // Kick from server
cmd.AddParam("reasonmsg", reason)
return c.internal.SendCommand(cmd)
}
// =============================================================================
// Info Methods
// =============================================================================
// GetServerInfo returns server information
func (c *Client) GetServerInfo() *ServerInfo {
return c.serverInfo
}
// GetSelfInfo returns our own client information
func (c *Client) GetSelfInfo() *SelfInfo {
return c.selfInfo
}
// SetNickname changes the client's nickname
func (c *Client) SetNickname(name string) error {
if c.internal == nil {
return fmt.Errorf("not connected")
}
cmd := protocol.NewCommand("clientupdate")
cmd.AddParam("client_nickname", name)
err := c.internal.SendCommand(cmd)
if err == nil {
c.config.Nickname = name
if c.selfInfo != nil {
c.selfInfo.Nickname = name
}
}
return err
}

114
pkg/ts3client/events.go Normal file
View File

@@ -0,0 +1,114 @@
package ts3client
// EventType represents the type of event
type EventType string
const (
// Connection events
EventConnected EventType = "connected"
EventDisconnected EventType = "disconnected"
// Message events
EventMessage EventType = "message"
// Client events
EventClientEnter EventType = "client_enter"
EventClientLeft EventType = "client_left"
EventClientMoved EventType = "client_moved"
// Channel events
EventChannelList EventType = "channel_list"
// Audio events
EventAudio EventType = "audio"
// Error events
EventError EventType = "error"
)
// ConnectedEvent is emitted when the client successfully connects
type ConnectedEvent struct {
ClientID uint16
ServerName string
}
// DisconnectedEvent is emitted when the client disconnects
type DisconnectedEvent struct {
Reason string
}
// MessageEvent is emitted when a text message is received
type MessageEvent struct {
SenderID uint16
SenderName string
Message string
TargetMode MessageTarget // Private, Channel, or Server
}
// MessageTarget represents the target type of a message
type MessageTarget int
const (
MessageTargetPrivate MessageTarget = 1
MessageTargetChannel MessageTarget = 2
MessageTargetServer MessageTarget = 3
)
func (m MessageTarget) String() string {
switch m {
case MessageTargetPrivate:
return "Private"
case MessageTargetChannel:
return "Channel"
case MessageTargetServer:
return "Server"
default:
return "Unknown"
}
}
// ClientEnterEvent is emitted when a client enters the server
type ClientEnterEvent struct {
ClientID uint16
Nickname string
ChannelID uint64
}
// ClientLeftEvent is emitted when a client leaves the server
type ClientLeftEvent struct {
ClientID uint16
Reason string
}
// ClientMovedEvent is emitted when a client moves to a different channel
type ClientMovedEvent struct {
ClientID uint16
ChannelID uint64
}
// ChannelListEvent is emitted when the channel list is received
type ChannelListEvent struct {
Channels []*Channel
}
// AudioEvent is emitted when voice data is received
type AudioEvent struct {
SenderID uint16
Codec AudioCodec
PCM []int16 // Decoded PCM data (48kHz, mono or stereo)
Channels int // 1 for mono, 2 for stereo
}
// AudioCodec represents the audio codec type
type AudioCodec int
const (
CodecOpusVoice AudioCodec = 4
CodecOpusMusic AudioCodec = 5
)
// ErrorEvent is emitted when the server reports an error
type ErrorEvent struct {
ID string
Message string
}

54
pkg/ts3client/types.go Normal file
View File

@@ -0,0 +1,54 @@
package ts3client
// Channel represents a TeamSpeak channel
type Channel struct {
ID uint64
ParentID uint64
Name string
Order uint64
}
// Client represents a connected client
type ClientInfo struct {
ID uint16
Nickname string
ChannelID uint64
}
// ServerInfo contains server information
type ServerInfo struct {
Name string
WelcomeMessage string
Platform string
Version string
MaxClients int
ClientsOnline int
ChannelsOnline int
}
// SelfInfo contains our own client information
type SelfInfo struct {
ClientID uint16
Nickname string
ChannelID uint64
}
// Config contains client configuration options
type Config struct {
Nickname string
SecurityLevel int // Default: 8
Version string // Default: "3.6.2 [Build: 1690976575]"
Platform string // Default: "Windows"
HWID string // Default: generated
}
// DefaultConfig returns a Config with sensible defaults
func DefaultConfig() Config {
return Config{
Nickname: "GoTS3Bot",
SecurityLevel: 8,
Version: "3.6.2 [Build: 1690976575]",
Platform: "Windows",
HWID: "1234567890",
}
}

View File

@@ -2,4 +2,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:PKG_CONFIG_PATH = "D:\esto_al_path\msys64\mingw64\lib\pkgconfig"
Write-Host "Starting TeamSpeak Client (Windows Native)..." -ForegroundColor Cyan Write-Host "Starting TeamSpeak Client (Windows Native)..." -ForegroundColor Cyan
go run ./cmd/client/main.go --server localhost:9987 # go run ./cmd/client/main.go --server localhost:9987
go run ./cmd/example --server localhost:9987