From 31e12a3546cc8495691bfb084e48e963e36b440f Mon Sep 17 00:00:00 2001 From: jbergner Date: Thu, 14 Aug 2025 19:11:49 +0200 Subject: [PATCH] init --- .gitea/workflows/registry.yml | 51 ++++ Dockerfile | 34 +++ go.mod | 23 ++ go.sum | 60 +++++ main.go | 489 ++++++++++++++++++++++++++++++++++ 5 files changed, 657 insertions(+) create mode 100644 .gitea/workflows/registry.yml create mode 100644 Dockerfile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go 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

+
+ + + + +
+ +
+

Schonend gesendet mit kurzem Delay (Rate-Limits).

+
+
+
+ +
+

Gespeicherte Abonnenten ({{len .Subs}})

+ + + + {{range .Subs}} + + + + + + + {{else}} + + {{end}} + +
User-IDUsernameSeitAktion
{{.UserID}}{{.Username}}{{.AddedAt}} +
+ + +
+
Keine Abonnenten gespeichert.
+
+ +
+

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 +}