This commit is contained in:
51
.gitea/workflows/registry.yml
Normal file
51
.gitea/workflows/registry.yml
Normal 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
34
Dockerfile
Normal 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
23
go.mod
Normal 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
60
go.sum
Normal 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
489
main.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user