init
All checks were successful
release-tag / release-image (push) Successful in 2m6s

This commit is contained in:
2025-08-14 19:11:49 +02:00
parent 0e9e08e75e
commit 31e12a3546
5 changed files with 657 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 }}

34
Dockerfile Normal file
View File

@@ -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"]

23
go.mod Normal file
View File

@@ -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
)

60
go.sum Normal file
View File

@@ -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=

489
main.go Normal file
View File

@@ -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 = `<!doctype html>
<html lang="de"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Discord Webposter (mit Subscribers)</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;margin:2rem}
.wrap{max-width:900px;margin:0 auto}
.grid{display:grid;gap:1rem}
.two{grid-template-columns:1fr;gap:1rem}
@media(min-width:900px){.two{grid-template-columns:2fr 1fr}}
input,textarea{width:100%;padding:.7rem;border:1px solid #ccc;border-radius:.5rem}
textarea{min-height:8rem}
button{padding:.7rem 1rem;border:0;border-radius:.5rem;cursor:pointer}
.primary{background:#5865F2;color:#fff}
.muted{background:#eee}
table{width:100%;border-collapse:collapse}
th,td{padding:.5rem;border-bottom:1px solid #eee;text-align:left}
.status{padding:.7rem 1rem;border-radius:.5rem;margin:1rem 0}
.ok{background:#e8f5e9;color:#1b5e20}.err{background:#ffebee;color:#b71c1c}
.card{border:1px solid #eee;border-radius:.75rem;padding:1rem}
small{color:#666}
</style>
</head><body>
<div class="wrap">
<h1>Discord Webposter</h1>
{{if .Status}}<div class="status {{if .Error}}err{{else}}ok{{end}}">{{.Status}}</div>{{end}}
<div class="grid two">
<div class="card">
<h2>Einzel-DM senden</h2>
<form method="POST" action="/send-dm">
<label>User-ID</label>
<input name="user_id" placeholder="1234567890" required value="{{.PrefillUserID}}">
<label>Nachricht</label>
<textarea name="message" placeholder="Deine Nachricht…" required></textarea>
<div style="display:flex;gap:.5rem;margin-top:.5rem">
<button class="primary" type="submit">DM senden</button>
<button class="muted" type="reset">Zurücksetzen</button>
</div>
</form>
<p><small>Tipp: User-ID bekommst du per Rechtsklick (Entwicklermodus an) — oder Nutzer klickt deinen User-Kontext-Command.</small></p>
</div>
<div class="card">
<h2>Bulk-DM an Abonnenten</h2>
<form method="POST" action="/send-bulk">
<label>Nachricht</label>
<textarea name="message" placeholder="Wird an alle subscribers geschickt…" required></textarea>
<label>Max. Empfänger (optional, Standard: 1000)</label>
<input name="limit" type="number" min="1" placeholder="1000">
<div style="display:flex;gap:.5rem;margin-top:.5rem">
<button class="primary" type="submit">Bulk senden</button>
</div>
<p><small>Schonend gesendet mit kurzem Delay (Rate-Limits).</small></p>
</form>
</div>
</div>
<div class="card" style="margin-top:1rem">
<h2>Gespeicherte Abonnenten ({{len .Subs}})</h2>
<table>
<thead><tr><th>User-ID</th><th>Username</th><th>Seit</th><th>Aktion</th></tr></thead>
<tbody>
{{range .Subs}}
<tr>
<td>{{.UserID}}</td>
<td>{{.Username}}</td>
<td>{{.AddedAt}}</td>
<td>
<form method="POST" action="/unsubscribe" style="display:inline">
<input type="hidden" name="user_id" value="{{.UserID}}">
<button type="submit">Entfernen</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="4"><em>Keine Abonnenten gespeichert.</em></td></tr>
{{end}}
</tbody>
</table>
</div>
<div class="card" style="margin-top:1rem">
<h2>Infos</h2>
<ul>
<li>Slash-Commands: <code>/subscribe</code>, <code>/unsubscribe</code></li>
<li>User-Kontext-Command: „Zu Empfängern hinzufügen“ (optional)</li>
<li>DMs können fehlschlagen, wenn Nutzer DMs blockiert.</li>
</ul>
</div>
</div>
</body></html>`
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
}