363 lines
10 KiB
Go
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()
|
|
}
|