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

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