diff --git a/cmd/example/main.go b/cmd/example/main.go new file mode 100644 index 0000000..8f00d8e --- /dev/null +++ b/cmd/example/main.go @@ -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) + } +} diff --git a/example.exe b/example.exe new file mode 100644 index 0000000..7595fee Binary files /dev/null and b/example.exe differ diff --git a/internal/client/client.go b/internal/client/client.go index 99fde2a..f7cc6b8 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -17,6 +17,9 @@ type Channel struct { Order uint64 } +// EventHandler is called when events occur +type EventHandler func(eventType string, data map[string]any) + type Client struct { Conn *transport.TS3Conn Handshake *HandshakeState @@ -29,7 +32,8 @@ type Client struct { VoicePacketID uint16 // State - Connected bool + Connected bool + ServerName string // Fragment reassembly FragmentBuffer []byte @@ -43,6 +47,12 @@ type Client struct { // Audio VoiceDecoders map[uint16]*opus.Decoder // Map VID (sender ID) to decoder 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 { @@ -52,6 +62,29 @@ func NewClient(nickname string) *Client { VoicePacketID: 1, Channels: make(map[uint64]*Channel), 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 { select { + case <-c.done: + log.Println("Client loop stopped") + return nil case pkt := <-pktChan: + if pkt == nil { + // Channel closed + return nil + } if err := c.handlePacket(pkt); err != nil { log.Printf("Error handling packet: %v", err) } case <-ticker.C: + if !c.Connected { + continue // Don't send pings if not connected yet + } ping := protocol.NewPacket(protocol.PacketTypePing, nil) c.PacketIDCounterC2S++ ping.Header.PacketID = c.PacketIDCounterC2S diff --git a/internal/client/commands.go b/internal/client/commands.go index a53525a..a354c59 100644 --- a/internal/client/commands.go +++ b/internal/client/commands.go @@ -172,8 +172,13 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error { log.Printf("Assigned ClientID: %d", c.ClientID) } 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": // Parse channel info 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) case "channellistfinished": log.Printf("=== Channel List Complete (%d channels) ===", len(c.Channels)) + var channelList []*Channel var targetChan *Channel for _, ch := range c.Channels { log.Printf(" - [%d] %s (parent=%d)", ch.ID, ch.Name, ch.ParentID) + channelList = append(channelList, ch) if ch.Name == "Test" { targetChan = ch } } + c.emitEvent("channel_list", map[string]any{ + "channels": channelList, + }) if targetChan == nil { if ch, ok := c.Channels[2]; ok { @@ -226,34 +236,65 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error { case "notifycliententerview": // A client entered the server nick := "" + clientID := uint16(0) + channelID := uint64(0) if n, ok := args["client_nickname"]; ok { 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": // targetmode: 1=Private, 2=Channel, 3=Server msg := "" invoker := "Unknown" + var invokerID uint16 + var targetModeInt int if m, ok := args["msg"]; ok { msg = protocol.Unescape(m) } if name, ok := args["invokername"]; ok { invoker = protocol.Unescape(name) } + if iid, ok := args["invokerid"]; ok { + var id uint64 + fmt.Sscanf(iid, "%d", &id) + invokerID = uint16(id) + } targetMode := "Unknown" if tm, ok := args["targetmode"]; ok { switch tm { case "1": targetMode = "Private" + targetModeInt = 1 case "2": targetMode = "Channel" + targetModeInt = 2 case "3": targetMode = "Server" + targetModeInt = 3 } } 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": // Someone is typing @@ -266,10 +307,21 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error { case "notifyclientmoved": // Client moved to another channel - clid := args["clid"] - ctid := args["ctid"] - // reasonid: 0=switched, 1=moved, 2=timeout, 3=kick, 4=unknown - log.Printf("Client %s moved to Channel %s", clid, ctid) + var clientID uint16 + var channelID uint64 + 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 %d moved to Channel %d", clientID, channelID) + c.emitEvent("client_moved", map[string]any{ + "clientID": clientID, + "channelID": channelID, + }) case "notifyclientchannelgroupchanged": // Client channel group changed @@ -306,6 +358,40 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error { 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": // Client updated (e.g. muted/unmuted) clid := args["clid"] @@ -316,6 +402,10 @@ func (c *Client) handleCommand(pkt *protocol.Packet) error { id := args["id"] msg := protocol.Unescape(args["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": // Ignore verbose noisy setup commands diff --git a/internal/client/voice.go b/internal/client/voice.go index 9b2e33d..5317708 100644 --- a/internal/client/voice.go +++ b/internal/client/voice.go @@ -2,6 +2,7 @@ package client import ( "encoding/binary" + "fmt" "log" "go-ts/pkg/protocol" @@ -66,7 +67,7 @@ func (c *Client) handleVoice(pkt *protocol.Packet) { // Only process Opus packets 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 } @@ -97,26 +98,32 @@ func (c *Client) handleVoice(pkt *protocol.Packet) { } pcm = pcm[:n*channels] - if n != 960 { - log.Printf("WARNING: Unusual Opus frame size: %d samples (expected 960 for 20ms)", n) + // 3. Emit audio event instead of auto-echo + 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) - for i := range pcm { - pcm[i] = pcm[i] / 10 - } + channels := 1 + codec := uint8(CodecOpusVoice) - // 4. Get or Create Encoder + // Get or Create Encoder if c.VoiceEncoder == nil { var err error app := opus.AppVoIP - if channels == 2 { - app = opus.AppAudio - } encoder, err := opus.NewEncoder(48000, channels, app) if err != nil { - log.Printf("Failed to create Opus encoder: %v", err) - return + return fmt.Errorf("failed to create Opus encoder: %w", err) } // Optimize Quality @@ -126,31 +133,28 @@ func (c *Client) handleVoice(pkt *protocol.Packet) { c.VoiceEncoder = encoder } - // 5. Encode PCM to Opus + // Encode PCM to Opus encoded := make([]byte, 1024) nEnc, err := c.VoiceEncoder.Encode(pcm, encoded) if err != nil { - log.Printf("Opus encode error: %v", err) - return + return fmt.Errorf("opus encode error: %w", err) } encoded = encoded[:nEnc] - // log.Printf("Voice Processed (CGO): VID=%d, In=%d bytes, PCM=%d samples, Out=%d bytes", vid, len(voiceData), n, nEnc) - - // 6. Build echo packet (Client -> Server) + // Build voice packet (Client -> Server) // 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 - // Correctly set VId in Payload to be the Sequence Number (not ClientID) - binary.BigEndian.PutUint16(echoData[0:2], c.VoicePacketID) - echoData[2] = codec - copy(echoData[3:], encoded) + // Set VId in Payload to be the Sequence Number (not ClientID) + binary.BigEndian.PutUint16(voiceData[0:2], c.VoicePacketID) + voiceData[2] = codec + copy(voiceData[3:], encoded) - echoPkt := protocol.NewPacket(protocol.PacketTypeVoice, echoData) - echoPkt.Header.PacketID = c.VoicePacketID - echoPkt.Header.ClientID = c.ClientID + pkt := protocol.NewPacket(protocol.PacketTypeVoice, voiceData) + pkt.Header.PacketID = c.VoicePacketID + pkt.Header.ClientID = c.ClientID // Encrypt voice packet if c.Handshake != nil && len(c.Handshake.SharedIV) > 0 { @@ -159,27 +163,26 @@ func (c *Client) handleVoice(pkt *protocol.Packet) { SharedMac: c.Handshake.SharedMac, GenerationID: 0, } - key, nonce := crypto.GenerateKeyNonce(&echoPkt.Header, true) + key, nonce := crypto.GenerateKeyNonce(&pkt.Header, true) meta := make([]byte, 5) - binary.BigEndian.PutUint16(meta[0:2], echoPkt.Header.PacketID) - binary.BigEndian.PutUint16(meta[2:4], echoPkt.Header.ClientID) - meta[4] = echoPkt.Header.Type + binary.BigEndian.PutUint16(meta[0:2], pkt.Header.PacketID) + binary.BigEndian.PutUint16(meta[2:4], pkt.Header.ClientID) + 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 { - log.Printf("Voice encryption failed: %v", err) - return + return fmt.Errorf("voice encryption failed: %w", err) } - echoPkt.Data = encData - copy(echoPkt.Header.MAC[:], mac) + pkt.Data = encData + copy(pkt.Header.MAC[:], mac) } else { if c.Handshake != nil && len(c.Handshake.SharedMac) > 0 { - copy(echoPkt.Header.MAC[:], c.Handshake.SharedMac) + copy(pkt.Header.MAC[:], c.Handshake.SharedMac) } else { - echoPkt.Header.MAC = protocol.HandshakeMac + pkt.Header.MAC = protocol.HandshakeMac } } - c.Conn.SendPacket(echoPkt) + return c.Conn.SendPacket(pkt) } diff --git a/pkg/ts3client/client.go b/pkg/ts3client/client.go new file mode 100644 index 0000000..29ef87c --- /dev/null +++ b/pkg/ts3client/client.go @@ -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") +} diff --git a/pkg/ts3client/commands.go b/pkg/ts3client/commands.go new file mode 100644 index 0000000..0e59ca7 --- /dev/null +++ b/pkg/ts3client/commands.go @@ -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 +} diff --git a/pkg/ts3client/events.go b/pkg/ts3client/events.go new file mode 100644 index 0000000..1d3eafa --- /dev/null +++ b/pkg/ts3client/events.go @@ -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 +} diff --git a/pkg/ts3client/types.go b/pkg/ts3client/types.go new file mode 100644 index 0000000..243c2b6 --- /dev/null +++ b/pkg/ts3client/types.go @@ -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", + } +} diff --git a/run.ps1 b/run.ps1 index 22bb02c..b5ef970 100644 --- a/run.ps1 +++ b/run.ps1 @@ -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" 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