Files
vocalforge-news/main.go
jbergner 2ce30f02d2
All checks were successful
release-tag / release-image (push) Successful in 2m3s
Guild-Admin-Fix
2025-08-15 10:11:34 +02:00

598 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
dg.AddHandler(onGuildCreate)
// Ö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 onGuildCreate(s *discordgo.Session, g *discordgo.GuildCreate) {
chID := findWritableTextChannel(s, g.Guild)
if chID == "" {
return
}
embed := &discordgo.MessageEmbed{
Title: "👋 Willkommen!",
Description: "Ich kann dir Updates per DM schicken.\n\n• Tippe `/subscribe` oder\n• klicke den Button unten, um eine DM mit mir zu starten.",
Color: 0x5865F2,
}
components := []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
discordgo.Button{
CustomID: "start_dm",
Label: "DM starten",
Style: discordgo.PrimaryButton,
},
},
},
}
_, _ = s.ChannelMessageSendComplex(chID, &discordgo.MessageSend{
Embed: embed,
Components: components,
})
}
func findWritableTextChannel(s *discordgo.Session, g *discordgo.Guild) string {
chs, err := s.GuildChannels(g.ID)
if err != nil {
return ""
}
for _, c := range chs {
if c.Type == discordgo.ChannelTypeGuildText {
// Grober Check: hat der Bot Sende-Recht in diesem Channel?
perms, err := s.State.UserChannelPermissions(s.State.User.ID, c.ID)
if err == nil && (perms&discordgo.PermissionSendMessages) != 0 {
return c.ID
}
}
}
return ""
}
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
}
func respondEphemeral(dg *discordgo.Session, i *discordgo.InteractionCreate, content string) {
_ = dg.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: content,
Flags: discordgo.MessageFlagsEphemeral,
},
})
}
func sendDM(dg *discordgo.Session, userID, content string) error {
ch, err := dg.UserChannelCreate(userID)
if err != nil {
return err
}
_, err = dg.ChannelMessageSend(ch.ID, content)
return err
}
func isGuildAdminOrManager(dg *discordgo.Session, i *discordgo.InteractionCreate) bool {
if i.GuildID == "" || i.Member == nil || i.Member.User == nil {
return false
}
perms, err := dg.State.UserChannelPermissions(i.Member.User.ID, i.ChannelID)
if err != nil {
// Fallback: keine Berechtigung annehmen
return false
}
if (perms & discordgo.PermissionAdministrator) != 0 {
return true
}
if (perms & discordgo.PermissionManageGuild) != 0 {
return true
}
return false
}
// 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) {
if i.Type == discordgo.InteractionMessageComponent {
switch i.MessageComponentData().CustomID {
case "start_dm":
u := actor(i)
if u == nil {
respondEphemeral(s.dg, i, "Konnte dich nicht ermitteln.")
return
}
// optional: in DB aufnehmen
// _ = s.addSub(u.ID, userLabel(u))
if err := sendDM(s.dg, u.ID, "Hey! Schön, dass du da bist. Ab jetzt kann ich dir DMs schicken. ✨"); err != nil {
respondEphemeral(s.dg, i, "DM konnte nicht zugestellt werden (Privacy-Einstellungen?).")
return
}
respondEphemeral(s.dg, i, "Ich habe dir eine DM geschickt. 👍")
return
}
}
// 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:
if !isGuildAdminOrManager(s.dg, i) {
replyEphemeral(s.dg, i, "Nur Admins/Moderatoren dürfen Empfänger hinzufügen.")
return
}
targetID := cmd.TargetID
if targetID == "" {
replyEphemeral(s.dg, i, "Kein Zielbenutzer erhalten.")
return
}
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. Beachte bitte, das dieser Bot keine eingehenden Nachrichten verarbeitet!")
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 {
// Manage Guild-Recht verlangen (oder PermissionAdministrator)
perms := int64(discordgo.PermissionManageGuild)
// User-Kontext-Command nur für Admins/Mods
_, err := s.ApplicationCommandCreate(appID, "", &discordgo.ApplicationCommand{
Name: "Zu Empfängern hinzufügen",
Type: discordgo.UserApplicationCommand,
DefaultMemberPermissions: &perms,
DMPermission: ptrBool(false), // nicht in DMs
})
if err != nil {
return err
}
// Beispiel: Slash-Commands ohne besondere Einschränkung
_, err = s.ApplicationCommandCreate(appID, "", &discordgo.ApplicationCommand{
Name: "subscribe",
Description: "Opt-in: Nachrichten per DM erhalten",
Type: discordgo.ChatApplicationCommand,
})
if err != nil {
return err
}
_, err = s.ApplicationCommandCreate(appID, "", &discordgo.ApplicationCommand{
Name: "unsubscribe",
Description: "Opt-out: Keine DMs mehr erhalten",
Type: discordgo.ChatApplicationCommand,
})
return err
}
func ptrBool(b bool) *bool { return &b }
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
}