All checks were successful
release-tag / release-image (push) Successful in 2m6s
490 lines
14 KiB
Go
490 lines
14 KiB
Go
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
|
||
}
|