diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..20912ac --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -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 }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..dc980fb --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a4ee56d --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8e8eb37 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..c0e632e --- /dev/null +++ b/main.go @@ -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 + } + } +}