Compare commits

...

8 commits
v1.0.1 ... main

7 changed files with 309 additions and 81 deletions

View file

@ -38,16 +38,3 @@ jobs:
run: |
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
echo "version is: ${{ env.VERSION }}"
- name: Update Helm chart version
shell: bash
run: sed -i 's/0\.0\.0/${{ env.VERSION }}/g' helm/ts-activity/Chart.yaml
- name: Build and push Helm chart
uses: goodsmileduck/helm-push-action@ec9f29cbf16a4773438b3ea98790aa5b5ca3e749
env:
SOURCE_DIR: './helm'
CHART_FOLDER: 'ts-activity'
CHARTMUSEUM_URL: 'https://charts.momoperes.ca'
CHARTMUSEUM_USER: '${{ secrets.CHARTMUSEUM_USER }}'
CHARTMUSEUM_PASSWORD: ${{ secrets.CHARTMUSEUM_PASSWORD }}

1
.gitignore vendored
View file

@ -1 +1,2 @@
.envrc
/values-dev.yaml

51
README.md Normal file
View file

@ -0,0 +1,51 @@
# ts-activity
This program will post notifications to Discord when someone joins or leaves your TeamSpeak server.
![image](https://github.com/aramperes/ts-activity/assets/6775216/bab942c3-b7d7-4b5e-8d14-4d69383bc856)
## Configuration
You will have to create ServerQuery credentials on an account that has permissions to login & view clients in the server. You can do this from the `Tools -> ServerQuery Login` menu in TeamSpeak 3.
This program is configured using environment variables:
- `TS_QUERY_ADDR`: Address to the TeamSpeak ServerQuery port. Example: `127.0.0.1:10011`
- `TS_QUERY_USER`: The username you selected for ServerQuery in the setup
- `TS_QUERY_PASS`: The password TeamSpeak generated for ServerQuery in the setup
- `TS_QUERY_SERVER_ID`: Virtual server ID to monitor. Defaults to `1`
- `TS_DISCORD_WEBHOOK`: Webhook URL for Discord. You can create this from the channel "Integrations" page
- `TS_DISCORD_AVATAR`: Optional URL for Discord bot avatar
- `TS_DISCORD_USERNAME`: Optional nickname for Discord bot
## Run it
[![docker hub](https://img.shields.io/docker/v/aramperes/ts-activity?color=%232496ed&label=docker%20hub&logo=docker&logoColor=fff&sort=semver)](https://hub.docker.com/r/aramperes/ts-activity)
To build and run locally:
```sh
go mod download
go run .
```
Or, using the [Docker image](https://hub.docker.com/r/aramperes/ts-activity):
```sh
docker run --rm --name ts-activity \
-e TS_DISCORD_WEBHOOK='https://discord.com/api/webhooks/...' \
-e TS_QUERY_ADDR=127.0.0.1:10011 \
-e TS_QUERY_USER=Jeff \
-e TS_QUERY_PASS=******* \
aramperes/ts-activity
```
There is also a Helm chart. You can create a `Secret` containing `username`, `password`, and `discord`, and then:
```sh
helm upgrade --install ts-activity momoperes/ts-activity \
--set config.serverQueryAddr=teamspeak:10011 \
--set config.discordUsername=Jeff \
--set config.serverQuerySecret=ts-activity \
--set config.webhookSecret=ts-activity
```

262
cmd.go
View file

@ -1,35 +1,115 @@
package main
import (
"errors"
"fmt"
"log"
"os"
"slices"
"strconv"
"strings"
"github.com/gtuk/discordwebhook"
"github.com/multiplay/go-ts3"
)
// App holds the configuration
type App struct {
discordURL string
discordUsername string
discordAvatarURL *string
tsQueryAddr string
tsQueryUser string
tsQueryPass string
tsQueryServerID int
spotLightGfxFormat string
spotLightIDMap map[string]int
}
func appFromEnv() (*App, error) {
discordURL := os.Getenv("TS_DISCORD_WEBHOOK")
if discordURL == "" {
return nil, errors.New("must configure: TS_DISCORD_WEBHOOK")
}
discordUsername := os.Getenv("TS_DISCORD_USERNAME")
if discordUsername == "" {
discordUsername = "TeamSpeak"
}
var discordAvatarURL *string = nil
if val, ok := os.LookupEnv("TS_DISCORD_AVATAR"); ok {
discordAvatarURL = &val
}
tsQueryAddr := os.Getenv("TS_QUERY_ADDR")
if tsQueryAddr == "" {
return nil, errors.New("must configure: TS_QUERY_ADDR")
}
tsQueryUser := os.Getenv("TS_QUERY_USER")
if tsQueryUser == "" {
return nil, errors.New("must configure: TS_QUERY_USER")
}
tsQueryPass := os.Getenv("TS_QUERY_PASS")
if tsQueryPass == "" {
return nil, errors.New("must configure: TS_QUERY_PASS")
}
tsQueryServerID := 1
if val, ok := os.LookupEnv("TS_QUERY_SERVER_ID"); ok {
val, err := strconv.Atoi(val)
if err == nil {
tsQueryServerID = val
} else {
return nil, errors.New("invalid TS_QUERY_SERVER_ID, must be int")
}
}
spotLightGfxFormat := os.Getenv("TS_SPOTLIGHT_GFX_FMT")
// TODO: Load from environment variable
spotLightIDMap := make(map[string]int)
spotLightIDMap["rb+mT/4bh37gHzQYqTgBiEHG2IA="] = 0
spotLightIDMap["sA3fHhvqmlSuFYtMoVYseRQI2DE="] = 0
spotLightIDMap["9K6JV7kWaRU+4HFRkXrBZNjSmRA="] = 1
spotLightIDMap["pFclzBx0w2UmwPd91VvaXJjYCYA="] = 2
spotLightIDMap["tvjlpKqvcyQSCCVkT0TJ28uwdaQ="] = 3
spotLightIDMap["SLLvtjVBmSoIzpMhlxnLa9CWoOU="] = 4
spotLightIDMap["7EU/Up++D9+8SQk0sNchEuKPufw="] = 5
spotLightIDMap["SyldxnLYWHJOUj3HnEsXGF6B0T4="] = 5
spotLightIDMap["Mc/TdoNhddKdGtB55DSZrYk3NWc="] = 6
spotLightIDMap["xOWMWG/TpkbV8XjahqqoQLsHHpA="] = 7
spotLightIDMap["G4kg1LKJElM5LIpoeA6gN7DMl0c="] = 7
spotLightIDMap["wuQ907NtzqL4uxhLk3P/TCpkXF0="] = 8
return &App{
discordURL: discordURL,
discordUsername: discordUsername,
discordAvatarURL: discordAvatarURL,
tsQueryAddr: tsQueryAddr,
tsQueryUser: tsQueryUser,
tsQueryPass: tsQueryPass,
tsQueryServerID: tsQueryServerID,
spotLightGfxFormat: spotLightGfxFormat,
spotLightIDMap: spotLightIDMap,
}, nil
}
func main() {
discord := os.Getenv("TS_DISCORD_WEBHOOK")
if discord == "" {
log.Fatal("Must configure: TS_DISCORD_WEBHOOK")
app, err := appFromEnv()
if err != nil {
log.Fatal(err)
}
// Connect and login
c, err := ts3.NewClient(os.Getenv("TS_QUERY_ADDR"))
c, err := ts3.NewClient(app.tsQueryAddr)
if err != nil {
log.Fatal(err)
}
defer c.Close()
user := os.Getenv("TS_QUERY_USER")
pass := os.Getenv("TS_QUERY_PASS")
if err := c.Login(user, pass); err != nil {
if err := c.Login(app.tsQueryUser, app.tsQueryPass); err != nil {
log.Fatal(err)
}
if err := c.Use(1); err != nil {
if err := c.Use(app.tsQueryServerID); err != nil {
log.Fatal(err)
}
@ -50,7 +130,9 @@ func main() {
log.Fatal(err)
}
clientMap := make(map[int]string)
clientMap := make(map[string]string)
clientDatabaseIDs := make(map[string]string)
clientUniqueIDs := make(map[string]string)
log.Println("Online clients:")
for _, client := range cl {
@ -58,88 +140,166 @@ func main() {
continue
}
log.Println("-", client)
clientMap[client.ID] = client.Nickname
clientMap[strconv.Itoa(client.ID)] = client.Nickname
clientDatabaseIDs[strconv.Itoa(client.ID)] = strconv.Itoa(client.DatabaseID)
uid, err := getClientUniqueId(c, strconv.Itoa(client.DatabaseID))
if err != nil {
log.Fatal(err)
}
clientUniqueIDs[strconv.Itoa(client.ID)] = uid
log.Println(" - UID:", uid)
}
// Update the banner on startup with the currently online users.
app.updateSpotLight(c, mapValues(clientUniqueIDs))
// Listen for client updates
notifs := c.Notifications()
for {
event := <-notifs
log.Println("=>", event)
if event.Type == "cliententerview" {
if event.Data["client_type"] != "0" {
continue
}
clientId, err := strconv.Atoi(event.Data["clid"])
if err != nil {
log.Println("Failed to get client ID:", err)
clientID, ok := event.Data["clid"]
if !ok {
log.Println("User has no client id", event.Data)
continue
}
clientDBID, ok := event.Data["client_database_id"]
if !ok {
log.Println("User has no client database id", event.Data)
continue
}
clientNick, ok := event.Data["client_nickname"]
if !ok {
log.Println("User has no nickname:", clientId)
log.Println("User has no nickname:", clientID)
continue
}
_, previous := clientMap[clientId]
clientMap[clientId] = clientNick
_, previous := clientMap[clientID]
clientMap[clientID] = clientNick
if !previous {
ClientConnected(discord, clientNick)
app.clientConnected(clientNick)
clientDatabaseIDs[clientID] = clientDBID
uid, err := getClientUniqueId(c, clientDBID)
if err != nil {
log.Fatal(err)
}
clientUniqueIDs[clientID] = uid
app.updateSpotLight(c, mapValues(clientUniqueIDs))
}
} else if event.Type == "clientleftview" {
clientId, err := strconv.Atoi(event.Data["clid"])
if err != nil {
log.Println("Failed to get client ID:", err)
continue
}
clientNick, ok := clientMap[clientId]
clientID, ok := event.Data["clid"]
if !ok {
log.Println("Unknown user left:", clientId)
log.Println("User has no client id", event.Data)
continue
}
delete(clientMap, clientId)
ClientDisconnected(discord, clientNick)
clientNick, ok := clientMap[clientID]
if !ok {
log.Println("Unknown user left:", clientID)
continue
}
delete(clientMap, clientID)
delete(clientDatabaseIDs, clientID)
delete(clientUniqueIDs, clientID)
app.updateSpotLight(c, mapValues(clientUniqueIDs))
app.clientDisconnected(clientNick)
}
}
}
func ClientConnected(discord string, nick string) {
bot := os.Getenv("TS_DISCORD_USERNAME")
if bot == "" {
bot = "Jeff"
func (app *App) sendWebhook(content string) {
message := discordwebhook.Message{
Username: &app.discordUsername,
Content: &content,
AvatarUrl: app.discordAvatarURL,
}
if err := discordwebhook.SendMessage(app.discordURL, message); err != nil {
log.Println("Failed to log Discord message:", err)
}
}
func (app *App) clientConnected(nick string) {
content := fmt.Sprintf("Client connected: %s", nick)
message := discordwebhook.Message{
Username: &bot,
Content: &content,
}
if err := discordwebhook.SendMessage(discord, message); err != nil {
log.Println("Failed to log Discord message:", err)
}
}
func ClientDisconnected(discord string, nick string) {
bot := os.Getenv("TS_DISCORD_USERNAME")
if bot == "" {
bot = "Jeff"
app.sendWebhook(content)
}
func (app *App) clientDisconnected(nick string) {
content := fmt.Sprintf("Client disconnected: %s", nick)
message := discordwebhook.Message{
Username: &bot,
Content: &content,
app.sendWebhook(content)
}
if err := discordwebhook.SendMessage(discord, message); err != nil {
log.Println("Failed to log Discord message:", err)
func (app *App) updateSpotLight(c *ts3.Client, connectedUIDs []string) {
if app.spotLightGfxFormat == "" {
return
}
spotLightIDs := make([]int, 0)
for _, uid := range connectedUIDs {
spotLightID, ok := app.spotLightIDMap[uid]
if ok {
spotLightIDs = append(spotLightIDs, spotLightID)
}
}
if len(spotLightIDs) == 0 {
updateBanner(c, fmt.Sprintf(app.spotLightGfxFormat, "empty"))
return
}
slices.Sort(spotLightIDs)
slices.Compact(spotLightIDs)
spotLightIDStrings := make([]string, len(spotLightIDs))
for idx, id := range spotLightIDs {
spotLightIDStrings[idx] = strconv.Itoa(id)
}
joined := strings.Join(spotLightIDStrings, "_")
updateBanner(c, fmt.Sprintf(app.spotLightGfxFormat, joined))
}
func updateBanner(c *ts3.Client, gfx string) {
err := c.Server.Edit(ts3.NewArg("virtualserver_hostbanner_gfx_url", gfx))
if err != nil {
log.Println("Failed to update banner:", gfx, err)
} else {
log.Println("Updated banner:", gfx)
}
}
func getClientUniqueId(c *ts3.Client, dbID string) (string, error) {
var uid = struct {
UID string `ms:"cluid"`
}{}
_, err := c.ExecCmd(ts3.NewCmd("clientgetnamefromdbid").WithArgs(ts3.NewArg("cldbid", dbID)).WithResponse(&uid))
if err != nil {
return "", err
}
return uid.UID, nil
}
func mapValues(m map[string]string) []string {
v := make([]string, 0, len(m))
for _, val := range m {
v = append(v, val)
}
return v
}

View file

@ -5,4 +5,4 @@ description: Post TeamSpeak events to Discord
type: application
version: "0.0.0"
appVersion: "0.0.0"
appVersion: "v0.0.0"

View file

@ -5,9 +5,7 @@ metadata:
labels:
{{- include "ts-activity.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: 1
{{- end }}
selector:
matchLabels:
{{- include "ts-activity.selectorLabels" . | nindent 6 }}
@ -31,20 +29,36 @@ spec:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default "latest" }}"
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
env:
{{- with .Values.config.discordUsername }}
- name: TS_DISCORD_USERNAME
value: {{ . | quote }}
{{- end }}
{{- with .Values.config.discordAvatar }}
- name: TS_DISCORD_AVATAR
value: {{ . | quote }}
{{- end }}
- name: TS_QUERY_SERVER_ID
value: {{ .Values.config.serverQueryId | quote }}
- name: TS_QUERY_ADDR
value: "{{ .Values.config.serverQueryAddr | required "must provide serverQueryAddr" }}"
- name: TS_QUERY_USER
valueFrom:
secretKeyRef:
key: username
name: "{{ .Values.config.serverQuerySecret | required "must provide serverQuerySecret" }}"
- name: TS_QUERY_PASS
valueFrom:
secretKeyRef:
key: password
name: "{{ .Values.config.serverQuerySecret | required "must provide serverQuerySecret" }}"
- name: TS_DISCORD_WEBHOOK
valueFrom:
secretKeyRef:
key: discord
name: "{{ .Values.config.webhookSecret | required "must provide webhookSecret" }}"
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{- with .Values.nodeSelector }}

View file

@ -5,9 +5,24 @@
image:
repository: aramperes/ts-activity
pullPolicy: IfNotPresent
# Overrides the image tag whose default is latest.
# Overrides the image tag whose default is the chart appVersion.
tag: ""
config:
# Discord username displayed in webhook messages.
# Defaults to 'Jeff'
discordUsername: ""
# Discord avatar displayed in webhook messages.
discordAvatar: ""
# Address to plain ServerQuery. Usually <ts_host>:10011
serverQueryAddr: ""
# Secret containing 'username' and 'password' for ServerQuery.
serverQuerySecret: ""
# Secret containing 'discord' with the Webhook URL.
webhookSecret: ""
# TeamSpeak virtual server ID.
serverQueryId: 1
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""