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

225 lines
5.6 KiB
Go

package teamspeak
import (
"context"
"errors"
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"time"
"github.com/honeybbq/teamspeak-go/commands"
)
var (
errFileTransferFailed = errors.New("file transfer failed")
errUnexpectedRespType = errors.New("unexpected response type")
errFileTransferTimedOut = errors.New("timeout waiting for file transfer notification")
)
// fileTransferTracker correlates clientftfid with notifystart* responses.
type fileTransferTracker struct {
pending map[uint16]chan any
mu sync.Mutex
nextID uint16
}
func newFileTransferTracker() *fileTransferTracker {
return &fileTransferTracker{
pending: make(map[uint16]chan any),
}
}
func (t *fileTransferTracker) register() (uint16, <-chan any) {
t.mu.Lock()
t.nextID++
if t.nextID == 0 {
t.nextID++
}
cftid := t.nextID
ch := make(chan any, 1)
t.pending[cftid] = ch
t.mu.Unlock()
return cftid, ch
}
func (t *fileTransferTracker) unregister(cftid uint16) {
t.mu.Lock()
delete(t.pending, cftid)
t.mu.Unlock()
}
func (t *fileTransferTracker) notify(cftid uint16, v any) {
t.mu.Lock()
if ch, ok := t.pending[cftid]; ok {
ch <- v
}
t.mu.Unlock()
}
func (t *fileTransferTracker) reset() {
t.mu.Lock()
t.pending = make(map[uint16]chan any)
t.mu.Unlock()
}
// FileTransferInitUpload sends ftinitupload to the server and waits for the
// notifystartupload response containing the TCP port and transfer key.
func (c *Client) FileTransferInitUpload(
channelID uint64, path string, password string, size uint64, overwrite bool,
) (*FileUploadInfo, error) {
cftid, ch := c.ftTrack.register()
defer c.ftTrack.unregister(cftid)
targetPath := path
if !strings.HasPrefix(targetPath, "/") {
targetPath = "/" + targetPath
}
overwriteVal := "0"
if overwrite {
overwriteVal = "1"
}
cmd := commands.BuildCommand("ftinitupload", map[string]string{
"cid": strconv.FormatUint(channelID, 10),
"name": targetPath,
"cpw": password,
"size": strconv.FormatUint(size, 10),
"clientftfid": strconv.Itoa(int(cftid)),
"overwrite": overwriteVal,
"resume": "0",
})
err := c.ExecCommand(cmd, 10*time.Second)
if err != nil {
return nil, err
}
select {
case res := <-ch:
switch v := res.(type) {
case FileUploadInfo:
return &v, nil
case FileTransferStatusInfo:
return nil, fmt.Errorf("%w: %s (status=%d)", errFileTransferFailed, v.Message, v.Status)
default:
return nil, fmt.Errorf("%w: %T", errUnexpectedRespType, v)
}
case <-time.After(10 * time.Second):
return nil, errFileTransferTimedOut
}
}
// FileTransferInitDownload sends ftinitdownload to the server and waits for the
// notifystartdownload response containing the TCP port and transfer key.
func (c *Client) FileTransferInitDownload(channelID uint64, path string, password string) (*FileDownloadInfo, error) {
cftid, ch := c.ftTrack.register()
defer c.ftTrack.unregister(cftid)
targetPath := path
if !strings.HasPrefix(targetPath, "/") {
targetPath = "/" + targetPath
}
cmd := commands.BuildCommand("ftinitdownload", map[string]string{
"cid": strconv.FormatUint(channelID, 10),
"name": targetPath,
"cpw": password,
"clientftfid": strconv.Itoa(int(cftid)),
"seekpos": "0",
})
err := c.ExecCommand(cmd, 10*time.Second)
if err != nil {
return nil, err
}
select {
case res := <-ch:
switch v := res.(type) {
case FileDownloadInfo:
return &v, nil
case FileTransferStatusInfo:
return nil, fmt.Errorf("%w: %s (status=%d)", errFileTransferFailed, v.Message, v.Status)
default:
return nil, fmt.Errorf("%w: %T", errUnexpectedRespType, v)
}
case <-time.After(10 * time.Second):
return nil, errFileTransferTimedOut
}
}
// FileTransferDeleteFile sends ftdeletefile to delete files on the server.
func (c *Client) FileTransferDeleteFile(channelID uint64, paths []string) error {
if len(paths) == 0 {
return nil
}
pathStr := strings.Join(paths, "|")
cmd := commands.BuildCommand("ftdeletefile", map[string]string{
"cid": strconv.FormatUint(channelID, 10),
"cpw": "",
"name": pathStr,
})
return c.ExecCommand(cmd, 10*time.Second)
}
// DialFileTransfer opens TCP to the TeamSpeak file-transfer port
// and performs the ftkey handshake. The caller is responsible for closing the
// returned connection.
func DialFileTransfer(host string, port uint16, key string) (net.Conn, error) {
addr := net.JoinHostPort(host, strconv.Itoa(int(port)))
dialer := &net.Dialer{Timeout: 10 * time.Second}
conn, err := dialer.DialContext(context.Background(), "tcp", addr)
if err != nil {
return nil, fmt.Errorf("failed to connect to file transfer server %s: %w", addr, err)
}
_, err = conn.Write([]byte(key))
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("failed to send transfer key: %w", err)
}
return conn, nil
}
// UploadFileData transfers data to the server using credentials from FileTransferInitUpload.
func UploadFileData(host string, info *FileUploadInfo, data io.Reader) error {
conn, err := DialFileTransfer(host, info.Port, info.FileTransferKey)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
_, err = io.Copy(conn, data)
if err != nil {
return fmt.Errorf("failed to upload file data: %w", err)
}
return nil
}
// DownloadFileData receives data from the server using credentials from FileTransferInitDownload.
func DownloadFileData(host string, info *FileDownloadInfo, dest io.Writer) error {
conn, err := DialFileTransfer(host, info.Port, info.FileTransferKey)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
_, err = io.Copy(dest, conn)
if err != nil {
return fmt.Errorf("failed to download file data: %w", err)
}
return nil
}