package teamspeak import ( "context" "errors" "log/slog" "strings" "sync" "time" "github.com/honeybbq/teamspeak-go/crypto" "github.com/honeybbq/teamspeak-go/discovery" "github.com/honeybbq/teamspeak-go/transport" ) var errAlreadyConnectingOrConnected = errors.New("already connecting or connected") // ClientStatus represents the current connection state of the client. type ClientStatus int const ( StatusDisconnected ClientStatus = iota StatusConnecting StatusConnected ) // AddrResolver resolves a TeamSpeak server address to host:port endpoints. // Implementations may replace the default chain (SRV, TSDNS, direct). type AddrResolver interface { Resolve(ctx context.Context, addr string) ([]discovery.ResolvedAddr, error) } // CommandMiddleware wraps the final command sender; it may alter or drop commands. type CommandMiddleware func(next func(string) error) func(string) error // EventMiddleware wraps event dispatch; it may observe or replace notifications. type EventMiddleware func(next func(any)) func(any) type clientInitOptions struct { serverPassword string defaultChannel string defaultChannelPassword string } // Client is the TeamSpeak 3 client. type Client struct { resolver AddrResolver finalCmdHandler func(string) error crypt *crypto.Crypt connectedChan chan struct{} connectedErr error // handshake error stored before closing connectedChan evtQueue []any evtQueueMu sync.Mutex evtCond *sync.Cond evtDone chan struct{} ftTrack *fileTransferTracker logger *slog.Logger handler *transport.PacketHandler cmdTrack *commandTracker throttle *commandThrottle clients map[uint16]ClientInfo finalEvtHandler func(any) addr string nickname string clientInitOptions clientInitOptions textMsgHandlers []func(TextMessage) cmdMiddlewares []CommandMiddleware eventMiddlewares []EventMiddleware clientEnterHandlers []func(ClientInfo) clientLeaveHandlers []func(ClientLeftViewEvent) clientMoveHandlers []func(ClientMovedEvent) connectedHandlers []func() disconnectedHandlers []func(error) pokedHandlers []func(PokeEvent) kickedHandlers []func(string) voiceDataHandlers []func(VoiceDataEvent) status ClientStatus mu sync.Mutex clid uint16 } // NewClient creates a new TeamSpeak 3 client. func NewClient(identity *crypto.Identity, addr string, nickname string, options ...ClientOption) *Client { crypt := crypto.NewCrypt(identity) c := &Client{ crypt: crypt, status: StatusDisconnected, logger: slog.Default(), addr: addr, nickname: nickname, clients: make(map[uint16]ClientInfo), throttle: newCommandThrottle(), cmdTrack: newCommandTracker(), ftTrack: newFileTransferTracker(), connectedChan: make(chan struct{}), evtDone: make(chan struct{}), } c.evtCond = sync.NewCond(&c.evtQueueMu) c.startEventLoop() for _, opt := range options { opt(c) } c.handler = transport.NewPacketHandler(c.crypt, c.logger) if c.resolver == nil { c.resolver = discovery.NewResolver(c.logger) } c.handler.OnPacket = c.handlePacket c.handler.OnClosed = c.handleConnectionClosed c.rebuildMiddlewareChains() return c } type ClientOption func(*Client) func WithLogger(logger *slog.Logger) ClientOption { return func(c *Client) { if logger != nil { c.logger = logger } } } // WithResolver sets a custom address resolver used by Connect. func WithResolver(r AddrResolver) ClientOption { return func(c *Client) { c.resolver = r } } func WithCommandMiddleware(mw ...CommandMiddleware) ClientOption { return func(c *Client) { c.cmdMiddlewares = append(c.cmdMiddlewares, mw...) } } func WithEventMiddleware(mw ...EventMiddleware) ClientOption { return func(c *Client) { c.eventMiddlewares = append(c.eventMiddlewares, mw...) } } // WithServerPassword configures the server password sent during clientinit. func WithServerPassword(password string) ClientOption { return func(c *Client) { c.clientInitOptions.serverPassword = password } } // WithDefaultChannel configures the default channel requested during clientinit. func WithDefaultChannel(channel string) ClientOption { return func(c *Client) { c.clientInitOptions.defaultChannel = channel } } // WithDefaultChannelPassword configures the password for the default channel. func WithDefaultChannelPassword(password string) ClientOption { return func(c *Client) { c.clientInitOptions.defaultChannelPassword = password } } // Connect starts the UDP session and handshake to the server. func (c *Client) Connect() error { c.mu.Lock() if c.status != StatusDisconnected { c.mu.Unlock() return errAlreadyConnectingOrConnected } finalAddr := c.resetForConnectLocked() c.status = StatusConnecting c.mu.Unlock() targetAddr, source := c.resolveConnectTarget(finalAddr) c.logger.Info("connecting to server", slog.String("address", targetAddr), slog.String("source", source)) return c.handler.Connect(targetAddr) } // Disconnect gracefully closes the connection. func (c *Client) Disconnect() error { c.mu.Lock() oldStatus := c.status if oldStatus == StatusDisconnected { c.mu.Unlock() return nil } connectErr := c.connectedErr c.status = StatusDisconnected handlers := c.disconnectedHandlers c.mu.Unlock() c.logger.Info("disconnecting from server") if oldStatus == StatusConnected { _ = c.ExecCommand("clientdisconnect reasonmsg=Shutdown", 1*time.Second) } err := c.handler.Close() // When disconnecting during the handshake phase, pass the stored handshake // error (e.g. wrong password, banned) so callbacks receive a useful message. handlerErr := error(nil) if oldStatus == StatusConnecting && connectErr != nil { handlerErr = connectErr } // Invoke disconnected handlers here; handleConnectionClosed skips them once // status is already Disconnected to avoid duplicate callbacks. for _, h := range handlers { go h(handlerErr) } return err } func (c *Client) resetForConnectLocked() string { if c.handler != nil { _ = c.handler.Close() } identity := c.crypt.Identity c.crypt = crypto.NewCrypt(identity) c.handler = transport.NewPacketHandler(c.crypt, c.logger) c.handler.OnPacket = c.handlePacket c.handler.OnClosed = c.handleConnectionClosed c.connectedChan = make(chan struct{}) // Note: connectedErr is NOT reset here so that handleConnectionClosed // can still access the handshake error when the server closes the // connection after a failed clientinit (e.g. wrong password). c.cmdTrack.reset() c.ftTrack.reset() c.clients = make(map[uint16]ClientInfo) c.clid = 0 finalAddr := c.addr if !strings.Contains(finalAddr, ":") { c.logger.Debug("no port specified, using default port 9987") finalAddr += ":9987" } return finalAddr } func (c *Client) resolveConnectTarget(fallbackAddr string) (string, string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() resolved, err := c.resolver.Resolve(ctx, c.addr) if err != nil { c.logger.Warn("address resolution failed, falling back to direct", slog.Any("error", err)) return fallbackAddr, "Fallback" } return resolved[0].Addr, resolved[0].Source } func (c *Client) handleConnectionClosed(err error) { c.mu.Lock() if c.status == StatusDisconnected { c.mu.Unlock() return } wasConnecting := c.status == StatusConnecting connectErr := c.connectedErr c.status = StatusDisconnected handlers := c.disconnectedHandlers c.mu.Unlock() // If we were still in the handshake phase, unblock WaitConnected so the // caller can observe the failure instead of blocking forever. if wasConnecting { c.signalConnected(err) } // When the connection drops after a handshake error (e.g. wrong password), // the transport-level err is typically nil. Use the stored handshake error // so the disconnected handlers receive a meaningful message. handlerErr := err if handlerErr == nil && connectErr != nil { handlerErr = connectErr } for _, h := range handlers { go h(handlerErr) } } // signalConnected closes connectedChan (if not already closed) and stores err. // Safe to call from any goroutine; acquires c.mu internally. // A non-nil error never overwrites an existing non-nil error, so the original // handshake error (e.g. wrong password) is preserved even if a later transport // close also calls signalConnected with a nil or different error. func (c *Client) signalConnected(err error) { c.mu.Lock() c.logger.Info("signalConnected called", slog.Any("error", err), slog.Any("existingErr", c.connectedErr), slog.Int("status", int(c.status))) if err != nil || c.connectedErr == nil { c.connectedErr = err } select { case <-c.connectedChan: c.logger.Info("signalConnected: channel already closed") default: close(c.connectedChan) c.logger.Info("signalConnected: channel closed successfully") } c.mu.Unlock() } // startEventLoop 启动事件消费协程,保证所有事件在同一个协程中按顺序处理。 // 消费协程的生命周期与 Client 相同(NewClient 时启动), // 通过 c.evtDone channel 控制退出。 func (c *Client) startEventLoop() { go func() { for { // 等待队列非空或 done 信号 c.evtQueueMu.Lock() for len(c.evtQueue) == 0 { select { case <-c.evtDone: c.evtQueueMu.Unlock() return default: } c.evtCond.Wait() } // 取出所有待处理事件 events := make([]any, len(c.evtQueue)) copy(events, c.evtQueue) c.evtQueue = nil c.evtQueueMu.Unlock() // 在无锁状态下逐个分发事件 for _, evt := range events { c.dispatchEvent(evt) } } }() } // notifyEvent 将事件放入队列,由事件消费协程顺序处理。 // 线程安全,可从任何 goroutine 调用。 func (c *Client) notifyEvent(evt any) { c.evtQueueMu.Lock() c.evtQueue = append(c.evtQueue, evt) c.evtQueueMu.Unlock() c.evtCond.Signal() }