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

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",
}
}