Files
2026-07-20 19:01:03 +08:00

363 lines
10 KiB
Go

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()
}