first commit
This commit is contained in:
commit
fccfe2a4c8
26 files changed
+1819
No files matched your search
@@ -0,0 +1,319 @@
|
||||
package ts3
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Query struct {
|
||||
Addr string
|
||||
User string
|
||||
Password string
|
||||
VoicePort int
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
ID int
|
||||
DatabaseID int
|
||||
Nick string
|
||||
UID string
|
||||
IP string
|
||||
}
|
||||
|
||||
type queryConn struct {
|
||||
c net.Conn
|
||||
r *bufio.Reader
|
||||
w *bufio.Writer
|
||||
}
|
||||
|
||||
type QueryError struct {
|
||||
ID int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *QueryError) Error() string { return fmt.Sprintf("TeamSpeak query error %d: %s", e.ID, e.Msg) }
|
||||
|
||||
func (q *Query) connect(ctx context.Context) (*queryConn, error) {
|
||||
d := net.Dialer{Timeout: q.Timeout}
|
||||
c, err := d.DialContext(ctx, "tcp", q.Addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
qc := &queryConn{c: c, r: bufio.NewReaderSize(c, 64*1024), w: bufio.NewWriterSize(c, 16*1024)}
|
||||
if _, err = qc.run(ctx, "login client_login_name="+escape(q.User)+" client_login_password="+escape(q.Password)); err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err = qc.run(ctx, "use port="+strconv.Itoa(q.VoicePort)); err != nil {
|
||||
c.Close()
|
||||
return nil, err
|
||||
}
|
||||
return qc, nil
|
||||
}
|
||||
|
||||
func (q *Query) withConn(ctx context.Context, fn func(*queryConn) error) error {
|
||||
qc, err := q.connect(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer qc.close()
|
||||
return fn(qc)
|
||||
}
|
||||
|
||||
func (qc *queryConn) close() {
|
||||
_ = qc.c.SetWriteDeadline(time.Now().Add(time.Second))
|
||||
_, _ = io.WriteString(qc.c, "quit\n")
|
||||
_ = qc.c.Close()
|
||||
}
|
||||
|
||||
func (qc *queryConn) run(ctx context.Context, command string) ([]string, error) {
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
_ = qc.c.SetDeadline(deadline)
|
||||
}
|
||||
if _, err := qc.w.WriteString(command + "\n"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := qc.w.Flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lines []string
|
||||
for {
|
||||
line, err := qc.r.ReadString('\n')
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "error ") {
|
||||
fields := parseFields(strings.TrimPrefix(line, "error "))
|
||||
id, _ := strconv.Atoi(fields["id"])
|
||||
if id != 0 {
|
||||
return lines, &QueryError{ID: id, Msg: fields["msg"]}
|
||||
}
|
||||
return lines, nil
|
||||
}
|
||||
// Initial TS3 greeting arrives before the first command reply. It is not data.
|
||||
if line == "TS3" || strings.HasPrefix(line, "Welcome to the TeamSpeak 3 ServerQuery interface") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Query) ListClients(ctx context.Context) ([]Client, error) {
|
||||
var out []Client
|
||||
err := q.withConn(ctx, func(qc *queryConn) error {
|
||||
var err error
|
||||
out, err = listClients(ctx, qc)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func listClients(ctx context.Context, qc *queryConn) ([]Client, error) {
|
||||
lines, err := qc.run(ctx, "clientlist -ip -uid")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Client
|
||||
for _, line := range lines {
|
||||
for _, row := range strings.Split(line, "|") {
|
||||
f := parseFields(row)
|
||||
if f["client_type"] != "0" {
|
||||
continue
|
||||
}
|
||||
id, err1 := strconv.Atoi(f["clid"])
|
||||
dbid, err2 := strconv.Atoi(f["client_database_id"])
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Client{ID: id, DatabaseID: dbid, Nick: f["client_nickname"], UID: f["client_unique_identifier"], IP: f["connection_client_ip"]})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (q *Query) ClientsByIP(ctx context.Context, ip net.IP) ([]Client, error) {
|
||||
clients, err := q.ListClients(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Client
|
||||
for _, c := range clients {
|
||||
if sameIP(ip, net.ParseIP(c.IP)) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (q *Query) CountOnline(ctx context.Context) (int, error) {
|
||||
clients, err := q.ListClients(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(clients), nil
|
||||
}
|
||||
|
||||
func (q *Query) SendPrivateMessage(ctx context.Context, clid int, message string) error {
|
||||
return q.withConn(ctx, func(qc *queryConn) error {
|
||||
_, err := qc.run(ctx, "sendtextmessage targetmode=1 target="+strconv.Itoa(clid)+" msg="+escape(message))
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func (q *Query) CreateChannelForClient(ctx context.Context, expected Client, adminGroupID int, name, topic, password string) (int, error) {
|
||||
var cid int
|
||||
err := q.withConn(ctx, func(qc *queryConn) error {
|
||||
clients, err := listClients(ctx, qc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for _, c := range clients {
|
||||
sameIdentity := c.DatabaseID == expected.DatabaseID
|
||||
if expected.UID != "" {
|
||||
sameIdentity = sameIdentity && c.UID == expected.UID
|
||||
}
|
||||
if sameIdentity && sameIP(net.ParseIP(c.IP), net.ParseIP(expected.IP)) {
|
||||
expected = c // refresh clid in case the client reconnected
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return errors.New("client disconnected or changed")
|
||||
}
|
||||
|
||||
cmd := "channelcreate channel_name=" + escape(name) + " channel_topic=" + escape(topic) + " channel_codec=4 channel_flag_permanent=1"
|
||||
if password != "" {
|
||||
cmd += " channel_password=" + escape(password)
|
||||
}
|
||||
lines, err := qc.run(ctx, cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, line := range lines {
|
||||
f := parseFields(line)
|
||||
if v := f["cid"]; v != "" {
|
||||
cid, _ = strconv.Atoi(v)
|
||||
}
|
||||
}
|
||||
if cid == 0 {
|
||||
return errors.New("TeamSpeak did not return a channel id")
|
||||
}
|
||||
|
||||
rollback := func() { _, _ = qc.run(ctx, "channeldelete cid="+strconv.Itoa(cid)+" force=1") }
|
||||
if _, err = qc.run(ctx, "setclientchannelgroup cgid="+strconv.Itoa(adminGroupID)+" cid="+strconv.Itoa(cid)+" cldbid="+strconv.Itoa(expected.DatabaseID)); err != nil {
|
||||
rollback()
|
||||
return err
|
||||
}
|
||||
if _, err = qc.run(ctx, "clientmove clid="+strconv.Itoa(expected.ID)+" cid="+strconv.Itoa(cid)); err != nil {
|
||||
rollback()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return cid, err
|
||||
}
|
||||
|
||||
func parseFields(s string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
for _, p := range strings.Fields(s) {
|
||||
k, v, ok := strings.Cut(p, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[k] = unescape(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func escape(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '\\':
|
||||
b.WriteString(`\\`)
|
||||
case '/':
|
||||
b.WriteString(`\/`)
|
||||
case ' ':
|
||||
b.WriteString(`\s`)
|
||||
case '|':
|
||||
b.WriteString(`\p`)
|
||||
case '\a':
|
||||
b.WriteString(`\a`)
|
||||
case '\b':
|
||||
b.WriteString(`\b`)
|
||||
case '\f':
|
||||
b.WriteString(`\f`)
|
||||
case '\n':
|
||||
b.WriteString(`\n`)
|
||||
case '\r':
|
||||
b.WriteString(`\r`)
|
||||
case '\t':
|
||||
b.WriteString(`\t`)
|
||||
case '\v':
|
||||
b.WriteString(`\v`)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func unescape(s string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] != '\\' || i+1 >= len(s) {
|
||||
b.WriteByte(s[i])
|
||||
continue
|
||||
}
|
||||
i++
|
||||
switch s[i] {
|
||||
case '\\':
|
||||
b.WriteByte('\\')
|
||||
case '/':
|
||||
b.WriteByte('/')
|
||||
case 's':
|
||||
b.WriteByte(' ')
|
||||
case 'p':
|
||||
b.WriteByte('|')
|
||||
case 'a':
|
||||
b.WriteByte('\a')
|
||||
case 'b':
|
||||
b.WriteByte('\b')
|
||||
case 'f':
|
||||
b.WriteByte('\f')
|
||||
case 'n':
|
||||
b.WriteByte('\n')
|
||||
case 'r':
|
||||
b.WriteByte('\r')
|
||||
case 't':
|
||||
b.WriteByte('\t')
|
||||
case 'v':
|
||||
b.WriteByte('\v')
|
||||
default:
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func sameIP(a, b net.IP) bool {
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return a.Equal(b)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package ts3
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEscapeRoundTrip(t *testing.T) {
|
||||
values := []string{"abc", "a b|c", "slash/and\\backslash", "ąęść", "x\ny\tz"}
|
||||
for _, v := range values {
|
||||
if got := unescape(escape(v)); got != v {
|
||||
t.Fatalf("roundtrip %q -> %q", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFields(t *testing.T) {
|
||||
f := parseFields(`clid=12 client_nickname=Jan\sKowalski connection_client_ip=1.2.3.4`)
|
||||
if f["client_nickname"] != "Jan Kowalski" || f["clid"] != "12" {
|
||||
t.Fatalf("bad fields: %#v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateChannelUsesDatabaseIDAndRefreshesClientID(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
commands := make(chan []string, 1)
|
||||
go func() {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
_, _ = io.WriteString(c, "TS3\nWelcome to the TeamSpeak 3 ServerQuery interface\n")
|
||||
r := bufio.NewReader(c)
|
||||
var got []string
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
got = append(got, line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "login "):
|
||||
_, _ = io.WriteString(c, "error id=0 msg=ok\n")
|
||||
case strings.HasPrefix(line, "use "):
|
||||
_, _ = io.WriteString(c, "error id=0 msg=ok\n")
|
||||
case line == "clientlist -ip -uid":
|
||||
_, _ = io.WriteString(c, "clid=8 cid=1 client_database_id=42 client_nickname=Jan\\sKowalski client_type=0 client_unique_identifier=abc connection_client_ip=1.2.3.4\nerror id=0 msg=ok\n")
|
||||
case strings.HasPrefix(line, "channelcreate "):
|
||||
_, _ = io.WriteString(c, "cid=321\nerror id=0 msg=ok\n")
|
||||
case strings.HasPrefix(line, "setclientchannelgroup "):
|
||||
_, _ = io.WriteString(c, "error id=0 msg=ok\n")
|
||||
case strings.HasPrefix(line, "clientmove "):
|
||||
_, _ = io.WriteString(c, "error id=0 msg=ok\n")
|
||||
case line == "quit":
|
||||
commands <- got
|
||||
return
|
||||
}
|
||||
}
|
||||
commands <- got
|
||||
}()
|
||||
|
||||
q := &Query{Addr: ln.Addr().String(), User: "u", Password: "p", VoicePort: 9987, Timeout: 2 * time.Second}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
cid, err := q.CreateChannelForClient(ctx, Client{ID: 7, DatabaseID: 42, Nick: "old", UID: "abc", IP: "1.2.3.4"}, 5, "Moj kanal", "temat", "haslo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cid != 321 {
|
||||
t.Fatalf("cid=%d", cid)
|
||||
}
|
||||
got := <-commands
|
||||
joined := strings.Join(got, "\n")
|
||||
if !strings.Contains(joined, "setclientchannelgroup cgid=5 cid=321 cldbid=42") {
|
||||
t.Fatalf("missing channel group command:\n%s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "clientmove clid=8 cid=321") {
|
||||
t.Fatalf("expected refreshed clid:\n%s", joined)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user