Files
vocalforge-news/main.go
jbergner 31e12a3546
All checks were successful
release-tag / release-image (push) Successful in 2m6s
init
2025-08-14 19:11:49 +02:00

490 lines
14 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)
// Ö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
}