diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb22183..3d979d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,9 +32,3 @@ 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 }}" diff --git a/.gitignore b/.gitignore index 3e26fff..7a6353d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ .envrc -/values-dev.yaml diff --git a/README.md b/README.md deleted file mode 100644 index f10f5d5..0000000 --- a/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# 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 -``` diff --git a/cmd.go b/cmd.go index 647ba60..97ff074 100644 --- a/cmd.go +++ b/cmd.go @@ -1,115 +1,35 @@ 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() { - app, err := appFromEnv() - if err != nil { - log.Fatal(err) + discord := os.Getenv("TS_DISCORD_WEBHOOK") + if discord == "" { + log.Fatal("Must configure: TS_DISCORD_WEBHOOK") } // Connect and login - c, err := ts3.NewClient(app.tsQueryAddr) + c, err := ts3.NewClient(os.Getenv("TS_QUERY_ADDR")) if err != nil { log.Fatal(err) } defer c.Close() - if err := c.Login(app.tsQueryUser, app.tsQueryPass); err != nil { + user := os.Getenv("TS_QUERY_USER") + pass := os.Getenv("TS_QUERY_PASS") + if err := c.Login(user, pass); err != nil { log.Fatal(err) } - if err := c.Use(app.tsQueryServerID); err != nil { + if err := c.Use(1); err != nil { log.Fatal(err) } @@ -130,9 +50,7 @@ func main() { log.Fatal(err) } - clientMap := make(map[string]string) - clientDatabaseIDs := make(map[string]string) - clientUniqueIDs := make(map[string]string) + clientMap := make(map[int]string) log.Println("Online clients:") for _, client := range cl { @@ -140,166 +58,88 @@ func main() { continue } log.Println("-", client) - 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) + clientMap[client.ID] = client.Nickname } - // 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, 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) + clientId, err := strconv.Atoi(event.Data["clid"]) + if err != nil { + log.Println("Failed to get client ID:", err) 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 { - 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)) + ClientConnected(discord, clientNick) } } else if event.Type == "clientleftview" { - clientID, ok := event.Data["clid"] - if !ok { - log.Println("User has no client id", event.Data) + clientId, err := strconv.Atoi(event.Data["clid"]) + if err != nil { + log.Println("Failed to get client ID:", err) continue } - clientNick, ok := clientMap[clientID] + clientNick, ok := clientMap[clientId] if !ok { - log.Println("Unknown user left:", clientID) + log.Println("Unknown user left:", clientId) continue } - delete(clientMap, clientID) - delete(clientDatabaseIDs, clientID) - delete(clientUniqueIDs, clientID) - app.updateSpotLight(c, mapValues(clientUniqueIDs)) - app.clientDisconnected(clientNick) + delete(clientMap, clientId) + ClientDisconnected(discord, clientNick) } } } -func (app *App) sendWebhook(content string) { - message := discordwebhook.Message{ - Username: &app.discordUsername, - Content: &content, - AvatarUrl: app.discordAvatarURL, +func ClientConnected(discord string, nick string) { + bot := os.Getenv("TS_DISCORD_USERNAME") + if bot == "" { + bot = "Jeff" } - if err := discordwebhook.SendMessage(app.discordURL, message); err != nil { + 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 (app *App) clientConnected(nick string) { - content := fmt.Sprintf("Client connected: %s", nick) - app.sendWebhook(content) -} +func ClientDisconnected(discord string, nick string) { + bot := os.Getenv("TS_DISCORD_USERNAME") + if bot == "" { + bot = "Jeff" + } -func (app *App) clientDisconnected(nick string) { content := fmt.Sprintf("Client disconnected: %s", nick) - app.sendWebhook(content) -} - -func (app *App) updateSpotLight(c *ts3.Client, connectedUIDs []string) { - if app.spotLightGfxFormat == "" { - return + message := discordwebhook.Message{ + Username: &bot, + Content: &content, } - 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) + if err := discordwebhook.SendMessage(discord, message); err != nil { + log.Println("Failed to log Discord message:", err) } } - -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 -} diff --git a/helm/ts-activity/.helmignore b/helm/ts-activity/.helmignore deleted file mode 100644 index 0e8a0eb..0000000 --- a/helm/ts-activity/.helmignore +++ /dev/null @@ -1,23 +0,0 @@ -# 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/ diff --git a/helm/ts-activity/Chart.yaml b/helm/ts-activity/Chart.yaml deleted file mode 100644 index 378029b..0000000 --- a/helm/ts-activity/Chart.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v2 -name: ts-activity -description: Post TeamSpeak events to Discord - -type: application - -version: "0.0.0" -appVersion: "v0.0.0" diff --git a/helm/ts-activity/templates/_helpers.tpl b/helm/ts-activity/templates/_helpers.tpl deleted file mode 100644 index 9665e88..0000000 --- a/helm/ts-activity/templates/_helpers.tpl +++ /dev/null @@ -1,62 +0,0 @@ -{{/* -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 }} diff --git a/helm/ts-activity/templates/deployment.yaml b/helm/ts-activity/templates/deployment.yaml deleted file mode 100644 index ef8efe5..0000000 --- a/helm/ts-activity/templates/deployment.yaml +++ /dev/null @@ -1,75 +0,0 @@ -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 }} diff --git a/helm/ts-activity/templates/serviceaccount.yaml b/helm/ts-activity/templates/serviceaccount.yaml deleted file mode 100644 index f102664..0000000 --- a/helm/ts-activity/templates/serviceaccount.yaml +++ /dev/null @@ -1,12 +0,0 @@ -{{- 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 }} diff --git a/helm/ts-activity/values.yaml b/helm/ts-activity/values.yaml deleted file mode 100644 index 3aad80e..0000000 --- a/helm/ts-activity/values.yaml +++ /dev/null @@ -1,68 +0,0 @@ -# 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 :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: {}