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.
Gespeicherte Abonnenten ({{len .Subs}})
User-ID | Username | Seit | Aktion |
{{range .Subs}}
{{.UserID}} |
{{.Username}} |
{{.AddedAt}} |
|
{{else}}
Keine Abonnenten gespeichert. |
{{end}}
Infos
- Slash-Commands:
/subscribe
, /unsubscribe
- User-Kontext-Command: „Zu Empfängern hinzufügen“ (optional)
- DMs können fehlschlagen, wenn Nutzer DMs blockiert.
`
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
}