This commit is contained in:
2025-08-09 23:47:03 +02:00
parent 3df782561d
commit eb0f3e0ccd
5 changed files with 433 additions and 0 deletions

View File

@@ -0,0 +1,51 @@
name: release-tag
on:
push:
branches:
- 'main'
jobs:
release-image:
runs-on: ubuntu-fast
env:
DOCKER_ORG: ${{ vars.DOCKER_ORG }}
DOCKER_LATEST: latest
RUNNER_TOOL_CACHE: /toolcache
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v2
with: # replace it with your local IP
config-inline: |
[registry."${{ vars.DOCKER_REGISTRY }}"]
http = true
insecure = true
- name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Get Meta
id: meta
run: |
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile
platforms: |
linux/amd64
push: true
tags: | # replace it with your local IP and tags
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# -------- Dockerfile (Multi-Stage Build) --------
# 1. Builder-Stage
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /bin/autovoice
# 2. Runtime-Stage
FROM alpine:3.22
# HTTPS-Callouts in Alpine brauchen ca-certificates
RUN apk add --no-cache ca-certificates
#RUN mkdir /data
#RUN mkdir /dynamicsrc
#RUN mkdir /tempsrc
COPY --from=builder /bin/autovoice /bin/autovoice
#COPY ./static /data/static
# COPY ./static /tempsrc/static
#COPY ./dynamicsrc /dynamicsrc
# Default listens on :8080 siehe main.go
EXPOSE 8080
# Environment defaults; können per compose überschrieben werden
ENV LOBBY_CHANNEL_ID=0 \
DISCORD_TOKEN=0 \
GUILD_ID=0 \
TIMEOUT_MIN=1
ENTRYPOINT ["/bin/autovoice"]

11
go.mod Normal file
View File

@@ -0,0 +1,11 @@
module discord-auto-voice
go 1.24.4
require github.com/bwmarrin/discordgo v0.29.0
require (
github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect
)

12
go.sum Normal file
View File

@@ -0,0 +1,12 @@
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

324
main.go Normal file
View File

@@ -0,0 +1,324 @@
package main
import (
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/bwmarrin/discordgo"
)
const (
// optional: Voice-Channels unter dieser Kategorie anlegen (leer lassen = keine Kategorie)
envCategoryID = "CATEGORY_ID"
// wie oft wir prüfen, ob der Channel leer ist
pollInterval = 15 * time.Second
)
var lobbyID = os.Getenv("LOBBY_CHANNEL_ID")
var discordToken = os.Getenv("DISCORD_TOKEN")
// /makevc Optionen:
// - name (string, optional) → Name des Voice-Channels
// - user_limit (int, optional) → Max. User (0 = unbegrenzt)
// - timeout_min (int, optional) → Auto-Delete wenn so lange leer (Default 60)
var slashCommands = []*discordgo.ApplicationCommand{
{
Name: "makevc",
Description: "Erstellt einen privaten Voice-Channel nur für dich (Auto-Cleanup)",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "name",
Description: "Name des Channels (z.B. 'Mein Raum')",
Required: false,
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "user_limit",
Description: "Max. Nutzer (0=unbegrenzt)",
Required: false,
MaxValue: 99, // statt ptrF(99)
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "timeout_min",
Description: "Löschen nach X Minuten Inaktivität",
Required: false,
MaxValue: 480,
},
},
},
}
func onVoiceStateUpdate(s *discordgo.Session, e *discordgo.VoiceStateUpdate) {
// nur Join-Events in die Lobby interessieren
if e.UserID == "" || e.ChannelID != lobbyID || e.BeforeUpdate != nil && e.BeforeUpdate.ChannelID == lobbyID {
return
}
// Bots ignorieren
m, err := s.GuildMember(e.GuildID, e.UserID)
if err == nil && m.User.Bot {
return
}
// Channel anlegen wie im /makevc-Handler:
categoryID := os.Getenv(envCategoryID)
name := "🔒 " + safeDisplayName(m)
userLimit := 0
timeoutMin := 60
if v := os.Getenv("TIMEOUT_MIN"); v != "" {
fmt.Sscanf(v, "%d", &timeoutMin)
}
everyoneID := e.GuildID
allowOwner := int64(discordgo.PermissionViewChannel |
discordgo.PermissionVoiceConnect |
discordgo.PermissionVoiceSpeak |
discordgo.PermissionVoiceStreamVideo |
discordgo.PermissionVoiceUseVAD)
denyEveryone := int64(discordgo.PermissionViewChannel |
discordgo.PermissionVoiceConnect)
newChan, err := s.GuildChannelCreateComplex(e.GuildID, discordgo.GuildChannelCreateData{
Name: name,
Type: discordgo.ChannelTypeGuildVoice,
ParentID: categoryID,
UserLimit: userLimit,
Bitrate: 64000,
PermissionOverwrites: []*discordgo.PermissionOverwrite{
{ID: everyoneID, Type: discordgo.PermissionOverwriteTypeRole, Deny: denyEveryone},
{ID: e.UserID, Type: discordgo.PermissionOverwriteTypeMember, Allow: allowOwner},
},
})
if err != nil {
log.Printf("VC-Anlage fehlgeschlagen: %v", err)
return
}
// User rüber bewegen
if moveErr := s.GuildMemberMove(e.GuildID, e.UserID, &newChan.ID); moveErr != nil {
log.Printf("Move fehlgeschlagen: %v", moveErr)
}
// Auto-Cleanup starten
go watchAndCleanup(s, e.GuildID, newChan.ID, time.Duration(timeoutMin)*time.Minute)
}
func safeDisplayName(m *discordgo.Member) string {
if m == nil {
return "privat"
}
if m.Nick != "" {
return m.Nick
}
if m.User.GlobalName != "" {
return m.User.GlobalName
}
return m.User.Username
}
func main() {
token := discordToken
if token == "" {
log.Fatal("Bitte setze DISCORD_TOKEN")
}
guildID := os.Getenv("GUILD_ID") // optional: für schnelle Command-Registrierung
categoryID := os.Getenv(envCategoryID)
s, err := discordgo.New("Bot " + token)
if err != nil {
log.Fatalf("Session fehlgeschlagen: %v", err)
}
// Intents für Slash + Voice-State-Events
s.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildVoiceStates
s.AddHandler(onVoiceStateUpdate) // ← neu
// Handlers
s.AddHandler(func(_ *discordgo.Session, r *discordgo.Ready) {
log.Printf("Eingeloggt als %s", r.User.Username)
})
s.AddHandler(onInteractionCreate(categoryID))
if err := s.Open(); err != nil {
log.Fatalf("Gateway-Start fehlgeschlagen: %v", err)
}
log.Println("Bot online. Ctrl+C zum Beenden.")
// Slash-Commands registrieren (Guild-spezifisch = sofort sichtbar)
appID := s.State.User.ID
created := make([]*discordgo.ApplicationCommand, 0, len(slashCommands))
for _, cmd := range slashCommands {
c, err := s.ApplicationCommandCreate(appID, guildID, cmd)
if err != nil {
log.Fatalf("Command-Registrierung fehlgeschlagen: %v", err)
}
created = append(created, c)
}
// Shutdown
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Println("Fahre herunter…")
for _, c := range created {
_ = s.ApplicationCommandDelete(appID, guildID, c.ID)
}
_ = s.Close()
}
func onInteractionCreate(categoryID string) func(s *discordgo.Session, i *discordgo.InteractionCreate) {
return func(s *discordgo.Session, i *discordgo.InteractionCreate) {
if i.Type != discordgo.InteractionApplicationCommand {
return
}
if i.ApplicationCommandData().Name != "makevc" {
return
}
// Options lesen
var name string
userLimit := 0
timeoutMin := 60
for _, o := range i.ApplicationCommandData().Options {
switch o.Name {
case "name":
name = o.StringValue()
case "user_limit":
userLimit = int(o.IntValue())
case "timeout_min":
timeoutMin = int(o.IntValue())
}
}
user := i.User
if user == nil && i.Member != nil {
user = i.Member.User
}
if name == "" {
name = fmt.Sprintf("🔒 %s", displayName(i))
}
// Permission Overwrites: @everyone deny, Owner allow
guildID := i.GuildID
everyoneID := guildID // @everyone Rolle hat die ID der Guild
allowOwner := int64(discordgo.PermissionViewChannel |
discordgo.PermissionVoiceConnect |
discordgo.PermissionVoiceSpeak |
discordgo.PermissionVoiceStreamVideo |
discordgo.PermissionVoiceUseVAD)
denyEveryone := int64(discordgo.PermissionViewChannel |
discordgo.PermissionVoiceConnect)
newChan, err := s.GuildChannelCreateComplex(guildID, discordgo.GuildChannelCreateData{
Name: name,
Type: discordgo.ChannelTypeGuildVoice,
// optional unter Kategorie
ParentID: categoryID,
// Userlimit (0 = unlimited)
UserLimit: userLimit,
// Bitrate (optional): 64kbps ist sicher für die meisten Server
Bitrate: 64000,
PermissionOverwrites: []*discordgo.PermissionOverwrite{
{
ID: everyoneID,
Type: discordgo.PermissionOverwriteTypeRole,
Deny: denyEveryone,
Allow: 0,
},
{
ID: user.ID,
Type: discordgo.PermissionOverwriteTypeMember,
Allow: allowOwner,
Deny: 0,
},
},
})
if err != nil {
errMsg := "Konnte Voice-Channel nicht erstellen. Fehlen dem Bot die Rechte **Manage Channels**?"
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: errMsg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
// Sofort antworten (ephemeral)
msg := fmt.Sprintf("✅ Voice-Channel **%s** erstellt. Ich lösche ihn, wenn er leer ist (Timeout: %d min).", newChan.Name, timeoutMin)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: msg,
Flags: discordgo.MessageFlagsEphemeral,
},
})
// Auto-Cleanup starten
go watchAndCleanup(s, i.GuildID, newChan.ID, time.Duration(timeoutMin)*time.Minute)
}
}
func displayName(i *discordgo.InteractionCreate) string {
if i.Member != nil && i.Member.Nick != "" {
return i.Member.Nick
}
if i.Member != nil && i.Member.User.GlobalName != "" {
return i.Member.User.GlobalName
}
if i.Member != nil {
return i.Member.User.Username
}
if i.User != nil && i.User.GlobalName != "" {
return i.User.GlobalName
}
if i.User != nil {
return i.User.Username
}
return "privat"
}
// Prüft regelmäßig, ob der Channel leer ist. Wenn er für `timeout` leer bleibt, wird er gelöscht.
// Sobald wieder jemand drin ist, wird der Timer zurückgesetzt.
func watchAndCleanup(s *discordgo.Session, guildID, channelID string, timeout time.Duration) {
lastActive := time.Now()
ticker := time.NewTicker(pollInterval)
defer ticker.Stop()
for range ticker.C {
// Guild-State holen (State Cache kann fehlen → REST-Fallback)
g, err := s.State.Guild(guildID)
if err != nil {
// als Fallback Guild live ziehen
g, err = s.Guild(guildID)
if err != nil {
// Wenn wir die Guild nicht bekommen, versuche später erneut
continue
}
}
occupied := false
for _, vs := range g.VoiceStates {
if vs.ChannelID == channelID {
occupied = true
break
}
}
if occupied {
lastActive = time.Now()
continue
}
// keiner im Channel
if time.Since(lastActive) >= timeout {
_, _ = s.ChannelDelete(channelID)
return
}
}
}