Compare commits

...

9 commits
v1.0.0 ... main

Author SHA1 Message Date
7aba36c064 Remove broken Helm chart repo 2024-11-06 22:21:20 -05:00
abfc92385a Spotlight feature 2024-11-06 22:17:58 -05:00
9fbbd2cd22 Refactoring & allow Discord avatar, custom server ID 2023-07-22 19:20:55 -04:00
71ed01df58 Clean-up 2023-07-22 12:33:20 -04:00
7e93beb132
Create README.md 2023-07-21 23:45:13 -04:00
db407c0ef5 Fix appVersion 2023-07-21 23:29:25 -04:00
71618fc8c3 Add configs to Helm chart 2023-07-21 23:24:55 -04:00
976b8dc0a9 Simplify client map 2023-07-21 22:00:56 -04:00
eafdbff4d5 Add Helm chart 2023-07-21 21:25:18 -04:00
10 changed files with 517 additions and 51 deletions

View file

@ -32,3 +32,9 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Get the release version from the tag
if: env.VERSION == ''
run: |
echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
echo "version is: ${{ env.VERSION }}"

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

@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View file

@ -0,0 +1,8 @@
apiVersion: v2
name: ts-activity
description: Post TeamSpeak events to Discord
type: application
version: "0.0.0"
appVersion: "v0.0.0"

View file

@ -0,0 +1,62 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "ts-activity.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "ts-activity.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "ts-activity.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "ts-activity.labels" -}}
helm.sh/chart: {{ include "ts-activity.chart" . }}
{{ include "ts-activity.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "ts-activity.selectorLabels" -}}
app.kubernetes.io/name: {{ include "ts-activity.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "ts-activity.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "ts-activity.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,75 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "ts-activity.fullname" . }}
labels:
{{- include "ts-activity.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
{{- include "ts-activity.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "ts-activity.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "ts-activity.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
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 }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View file

@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "ts-activity.serviceAccountName" . }}
labels:
{{- include "ts-activity.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,68 @@
# Default values for ts-activity.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
image:
repository: aramperes/ts-activity
pullPolicy: IfNotPresent
# 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: ""
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
podAnnotations: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
nodeSelector: {}
tolerations: []
affinity: {}