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..6667b3b
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,34 @@
+# -------- 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/vocalforgenews
+
+# 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/vocalforgenews /bin/vocalforgenews
+#COPY ./static /data/static
+#COPY ./dynamicsrc /dynamicsrc
+
+# Default listens on :8080 – siehe main.go
+EXPOSE 8080
+
+# Environment defaults; können per compose überschrieben werden
+ENV HTTP_ADDR=:8080 \
+ DB_PATH=/data/subs.db \
+ APPLICATION_ID=0 \
+ DISCORD_TOKEN=0
+
+VOLUME /data
+
+ENTRYPOINT ["/bin/vocalforgenews"]
\ No newline at end of file
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..acf4882
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,23 @@
+module git.send.nrw/sendnrw/vocalforge-news
+
+go 1.24.4
+
+require (
+ github.com/bwmarrin/discordgo v0.29.0
+ modernc.org/sqlite v1.38.2
+)
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/gorilla/websocket v1.4.2 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
+ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
+ golang.org/x/sys v0.34.0 // indirect
+ modernc.org/libc v1.66.3 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..2b9e0c7
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,60 @@
+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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
+github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+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/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
+golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
+golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
+golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
+golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
+golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+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=
+golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
+golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
+modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
+modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
+modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
+modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
+modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
+modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
+modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..8b82b8b
--- /dev/null
+++ b/main.go
@@ -0,0 +1,489 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "html/template"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "strings"
+ "time"
+
+ "github.com/bwmarrin/discordgo"
+ _ "modernc.org/sqlite"
+)
+
+type Server struct {
+ dg *discordgo.Session
+ tmpl *template.Template
+ db *sql.DB
+}
+
+func getenv(k, d string) string {
+ if v := os.Getenv(k); v != "" {
+ return v
+ }
+ return d
+}
+
+const page = `
+
+
+ Discord Webposter (mit Subscribers)
+
+
+
+
Discord Webposter
+ {{if .Status}}
{{.Status}}
{{end}}
+
+
+
Einzel-DM senden
+
+
Tipp: User-ID bekommst du per Rechtsklick (Entwicklermodus an) — oder Nutzer klickt deinen User-Kontext-Command.
+
+
+
Bulk-DM an Abonnenten
+
+
+
+
+
+
Gespeicherte Abonnenten ({{len .Subs}})
+
+ User-ID | Username | Seit | Aktion |
+
+ {{range .Subs}}
+
+ {{.UserID}} |
+ {{.Username}} |
+ {{.AddedAt}} |
+
+
+ |
+
+ {{else}}
+ Keine Abonnenten gespeichert. |
+ {{end}}
+
+
+
+
+
+
Infos
+
+ - Slash-Commands:
/subscribe
, /unsubscribe
+ - User-Kontext-Command: „Zu Empfängern hinzufügen“ (optional)
+ - DMs können fehlschlagen, wenn Nutzer DMs blockiert.
+
+
+
+`
+
+func main() {
+ token := os.Getenv("DISCORD_TOKEN")
+ appID := os.Getenv("APPLICATION_ID")
+ if token == "" || appID == "" {
+ log.Fatal("Bitte DISCORD_TOKEN und APPLICATION_ID setzen.")
+ }
+ httpAddr := getenv("HTTP_ADDR", ":8080")
+ dbPath := getenv("DB_PATH", "./subs.db")
+
+ // DB
+ db, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ log.Fatalf("DB open: %v", err)
+ }
+ if err := initDB(db); err != nil {
+ log.Fatalf("DB init: %v", err)
+ }
+
+ // Discord
+ dg, err := discordgo.New("Bot " + token)
+ if err != nil {
+ log.Fatalf("Discord session: %v", err)
+ }
+ // Für Interactions keine speziellen Privileged Intents nötig, wir lauschen nur auf Commands
+ dg.Identify.Intents = 0
+
+ // Interaction-Handler
+ s := &Server{dg: dg, db: db, tmpl: template.Must(template.New("page").Parse(page))}
+ dg.AddHandler(s.onInteraction)
+
+ // Öffnen
+ if err := dg.Open(); err != nil {
+ log.Fatalf("Websocket: %v", err)
+ }
+ log.Println("Discord: eingeloggt.")
+
+ // Commands registrieren (global)
+ if err := upsertCommands(dg, appID); err != nil {
+ log.Fatalf("Commands: %v", err)
+ }
+ log.Println("Slash & User-Kontext-Commands registriert.")
+
+ // HTTP
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", s.handleIndex)
+ mux.HandleFunc("/send-dm", s.handleSendDM)
+ mux.HandleFunc("/send-bulk", s.handleSendBulk)
+ mux.HandleFunc("/unsubscribe", s.handleUnsub)
+
+ srv := &http.Server{
+ Addr: httpAddr,
+ Handler: logRequests(mux),
+ ReadTimeout: 10 * time.Second,
+ WriteTimeout: 120 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+ go func() {
+ log.Printf("HTTP: lausche auf %s", httpAddr)
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Fatalf("HTTP: %v", err)
+ }
+ }()
+
+ // Shutdown
+ stop := make(chan os.Signal, 1)
+ signal.Notify(stop, os.Interrupt)
+ <-stop
+ log.Println("Beende…")
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = srv.Shutdown(ctx)
+ _ = dg.Close()
+ _ = db.Close()
+}
+
+func initDB(db *sql.DB) error {
+ _, err := db.Exec(`
+CREATE TABLE IF NOT EXISTS subscribers (
+ user_id TEXT PRIMARY KEY,
+ username TEXT,
+ added_at TEXT DEFAULT CURRENT_TIMESTAMP
+);
+`)
+ return err
+}
+
+// onInteraction verarbeitet Slash-Commands (/subscribe, /unsubscribe)
+// und den User-Kontext-Command („Zu Empfängern hinzufügen“).
+func (s *Server) onInteraction(_ *discordgo.Session, i *discordgo.InteractionCreate) {
+ // Nur Application-Commands behandeln
+ if i.Type != discordgo.InteractionApplicationCommand {
+ return
+ }
+
+ // WICHTIG: Methode AUFRUFEN, nicht das Feld referenzieren
+ cmd := i.ApplicationCommandData() // enthält Name, CommandType, Resolved, TargetID, ...
+
+ switch cmd.CommandType {
+ case discordgo.UserApplicationCommand:
+ // Rechtsklick auf User → Apps → Dein Command
+ targetID := cmd.TargetID
+ if targetID == "" {
+ replyEphemeral(s.dg, i, "Kein Zielbenutzer erhalten.")
+ return
+ }
+
+ // Optional: Username aus Resolved ziehen (falls mitgeliefert)
+ username := ""
+ if cmd.Resolved != nil {
+ if u, ok := cmd.Resolved.Users[targetID]; ok && u != nil {
+ username = userLabel(u)
+ }
+ }
+
+ if err := s.addSub(targetID, username); err != nil {
+ replyEphemeral(s.dg, i, "Fehler: "+err.Error())
+ return
+ }
+ replyEphemeral(s.dg, i, "✅ Nutzer wurde zu den Empfängern hinzugefügt.")
+
+ case discordgo.ChatApplicationCommand:
+ // Slash-Commands: /subscribe, /unsubscribe
+ switch cmd.Name {
+ case "subscribe":
+ u := actor(i)
+ if u == nil {
+ replyEphemeral(s.dg, i, "Konnte deinen Benutzer nicht ermitteln.")
+ return
+ }
+ if err := s.addSub(u.ID, userLabel(u)); err != nil {
+ replyEphemeral(s.dg, i, "Fehler beim Subscribe: "+err.Error())
+ return
+ }
+ replyEphemeral(s.dg, i, "✅ Du erhältst nun DMs. Mit `/unsubscribe` meldest du dich ab.")
+
+ case "unsubscribe":
+ u := actor(i)
+ if u == nil {
+ replyEphemeral(s.dg, i, "Konnte deinen Benutzer nicht ermitteln.")
+ return
+ }
+ if err := s.removeSub(u.ID); err != nil {
+ replyEphemeral(s.dg, i, "Fehler beim Unsubscribe: "+err.Error())
+ return
+ }
+ replyEphemeral(s.dg, i, "✅ Du erhältst keine DMs mehr.")
+ }
+
+ case discordgo.MessageApplicationCommand:
+ // (optional) Kontextmenü auf Nachrichten – hier nicht genutzt
+ return
+
+ default:
+ // Fallback/Abwärtskompatibilität:
+ if cmd.TargetID != "" && cmd.Resolved != nil && len(cmd.Resolved.Users) > 0 {
+ targetID := cmd.TargetID
+ username := ""
+ if u, ok := cmd.Resolved.Users[targetID]; ok && u != nil {
+ username = userLabel(u)
+ }
+ if err := s.addSub(targetID, username); err != nil {
+ replyEphemeral(s.dg, i, "Fehler: "+err.Error())
+ return
+ }
+ replyEphemeral(s.dg, i, "✅ Nutzer wurde zu den Empfängern hinzugefügt.")
+ return
+ }
+ log.Printf("Unbekannter ApplicationCommandType: %v (Name=%q)", cmd.CommandType, cmd.Name)
+ replyEphemeral(s.dg, i, "Unbekannter Command-Typ.")
+ }
+}
+
+// ---- Web ----
+
+type subRow struct {
+ UserID string
+ Username string
+ AddedAt string
+}
+
+func actor(i *discordgo.InteractionCreate) *discordgo.User {
+ if i.Member != nil && i.Member.User != nil {
+ return i.Member.User
+ }
+ return i.User
+}
+
+func replyEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, content string) {
+ _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
+ Type: discordgo.InteractionResponseChannelMessageWithSource,
+ Data: &discordgo.InteractionResponseData{
+ Content: content,
+ Flags: discordgo.MessageFlagsEphemeral,
+ },
+ })
+}
+
+func userLabel(u *discordgo.User) string {
+ if u == nil {
+ return ""
+ }
+ if u.Discriminator != "" && u.Discriminator != "0" {
+ return fmt.Sprintf("%s#%s", u.Username, u.Discriminator)
+ }
+ return u.Username
+}
+
+func upsertCommands(s *discordgo.Session, appID string) error {
+ // Slash-Commands
+ if _, err := s.ApplicationCommandCreate(appID, "", &discordgo.ApplicationCommand{
+ Name: "subscribe",
+ Description: "Opt-in: Nachrichten per DM erhalten",
+ Type: discordgo.ChatApplicationCommand, // Slash
+ }); err != nil {
+ return err
+ }
+ if _, err := s.ApplicationCommandCreate(appID, "", &discordgo.ApplicationCommand{
+ Name: "unsubscribe",
+ Description: "Opt-out: Keine DMs mehr erhalten",
+ Type: discordgo.ChatApplicationCommand, // Slash
+ }); err != nil {
+ return err
+ }
+
+ // User-Kontext-Command (Rechtsklick auf User)
+ if _, err := s.ApplicationCommandCreate(appID, "", &discordgo.ApplicationCommand{
+ Name: "Zu Empfängern hinzufügen",
+ Type: discordgo.UserApplicationCommand, // User-Context
+ }); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+ subs, _ := s.listSubs(1000)
+ data := map[string]any{
+ "Status": "",
+ "Error": false,
+ "Subs": subs,
+ "PrefillUserID": "",
+ }
+ _ = s.tmpl.Execute(w, data)
+}
+
+func (s *Server) handleSendDM(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ userID := strings.TrimSpace(r.Form.Get("user_id"))
+ msg := strings.TrimSpace(r.Form.Get("message"))
+ if userID == "" || msg == "" {
+ s.renderStatus(w, "Bitte User-ID und Nachricht angeben.", true, "", nil)
+ return
+ }
+ err := sendDM(s.dg, userID, msg)
+ status := "DM gesendet ✅"
+ if err != nil {
+ status = "Senden fehlgeschlagen: " + err.Error()
+ }
+ subs, _ := s.listSubs(1000)
+ s.renderStatus(w, status, err != nil, userID, subs)
+}
+
+func (s *Server) handleSendBulk(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ msg := strings.TrimSpace(r.Form.Get("message"))
+ if msg == "" {
+ s.renderStatus(w, "Nachricht fehlt.", true, "", nil)
+ return
+ }
+ limit := 1000
+ if l := strings.TrimSpace(r.Form.Get("limit")); l != "" {
+ fmt.Sscanf(l, "%d", &limit)
+ }
+ subs, _ := s.listSubs(limit)
+ if len(subs) == 0 {
+ s.renderStatus(w, "Keine Abonnenten vorhanden.", true, "", subs)
+ return
+ }
+ ok, fail := 0, 0
+ for i, sub := range subs {
+ if err := sendDM(s.dg, sub.UserID, msg); err != nil {
+ log.Printf("DM an %s fehlgeschlagen: %v", sub.UserID, err)
+ fail++
+ } else {
+ ok++
+ }
+ // Schonend: kleiner Delay (Rate-Limit freundlich)
+ if i < len(subs)-1 {
+ time.Sleep(1200 * time.Millisecond)
+ }
+ }
+ status := fmt.Sprintf("Bulk fertig: %d erfolgreich, %d fehlgeschlagen.", ok, fail)
+ s.renderStatus(w, status, fail > 0, "", subs)
+}
+
+func (s *Server) handleUnsub(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ uid := strings.TrimSpace(r.Form.Get("user_id"))
+ if uid != "" {
+ _ = s.removeSub(uid)
+ }
+ http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func (s *Server) renderStatus(w http.ResponseWriter, status string, isErr bool, prefillUserID string, subs []subRow) {
+ if subs == nil {
+ subs, _ = s.listSubs(1000)
+ }
+ data := map[string]any{
+ "Status": status,
+ "Error": isErr,
+ "Subs": subs,
+ "PrefillUserID": prefillUserID,
+ }
+ _ = s.tmpl.Execute(w, data)
+}
+
+func logRequests(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ next.ServeHTTP(w, r)
+ log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
+ })
+}
+
+// ---- DB Helpers ----
+
+func (s *Server) addSub(userID, username string) error {
+ _, err := s.db.Exec(`INSERT INTO subscribers(user_id, username) VALUES(?, ?)
+ ON CONFLICT(user_id) DO UPDATE SET username=excluded.username`, userID, username)
+ return err
+}
+
+func (s *Server) removeSub(userID string) error {
+ _, err := s.db.Exec(`DELETE FROM subscribers WHERE user_id=?`, userID)
+ return err
+}
+
+func (s *Server) listSubs(limit int) ([]subRow, error) {
+ rows, err := s.db.Query(`SELECT user_id, COALESCE(username,''), added_at
+ FROM subscribers ORDER BY added_at DESC LIMIT ?`, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []subRow
+ for rows.Next() {
+ var r subRow
+ if err := rows.Scan(&r.UserID, &r.Username, &r.AddedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, r)
+ }
+ return out, nil
+}
+
+// ---- DM Helper ----
+
+func sendDM(s *discordgo.Session, userID, msg string) error {
+ ch, err := s.UserChannelCreate(userID)
+ if err != nil {
+ return err
+ }
+ _, err = s.ChannelMessageSend(ch.ID, msg)
+ return err
+}