mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
All scheduled post dedup keys (wotd, holidays, gaming, anime, movies, concerts, birthdays) were global — after posting to the first room, the job was marked complete and the second room was skipped. Fixed by including the room ID in each dedup key so posts go to all broadcast rooms. Birthday XP grants and DMs remain once-per-user-per-year. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
455 lines
13 KiB
Go
455 lines
13 KiB
Go
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gogobee/internal/db"
|
|
|
|
"maunium.net/go/mautrix"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// bandsintownEvent represents an event from the Bandsintown API.
|
|
type bandsintownEvent struct {
|
|
ID string `json:"id"`
|
|
URL string `json:"url"`
|
|
DateTime string `json:"datetime"`
|
|
Title string `json:"title"`
|
|
Venue struct {
|
|
Name string `json:"name"`
|
|
City string `json:"city"`
|
|
Region string `json:"region"`
|
|
Country string `json:"country"`
|
|
Latitude string `json:"latitude"`
|
|
Longitude string `json:"longitude"`
|
|
} `json:"venue"`
|
|
Lineup []string `json:"lineup"`
|
|
OnSaleAt string `json:"on_sale_datetime"`
|
|
Offers []struct {
|
|
Type string `json:"type"`
|
|
URL string `json:"url"`
|
|
Status string `json:"status"`
|
|
} `json:"offers"`
|
|
}
|
|
|
|
// ConcertsPlugin provides concert lookups via Bandsintown.
|
|
type ConcertsPlugin struct {
|
|
Base
|
|
apiKey string
|
|
httpClient *http.Client
|
|
rateLimiter *RateLimitsPlugin
|
|
mu sync.Mutex
|
|
cooldowns map[string]time.Time // keyed by userID+artist
|
|
}
|
|
|
|
// NewConcertsPlugin creates a new ConcertsPlugin.
|
|
func NewConcertsPlugin(client *mautrix.Client, rateLimiter *RateLimitsPlugin) *ConcertsPlugin {
|
|
return &ConcertsPlugin{
|
|
Base: NewBase(client),
|
|
apiKey: os.Getenv("BANDSINTOWN_API_KEY"),
|
|
rateLimiter: rateLimiter,
|
|
httpClient: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
cooldowns: make(map[string]time.Time),
|
|
}
|
|
}
|
|
|
|
func (p *ConcertsPlugin) Name() string { return "concerts" }
|
|
|
|
func (p *ConcertsPlugin) Commands() []CommandDef {
|
|
return []CommandDef{
|
|
{Name: "concerts", Description: "Search upcoming concerts for an artist", Usage: "!concerts <artist>", Category: "Entertainment"},
|
|
{Name: "concerts watch", Description: "Watch an artist for concert updates", Usage: "!concerts watch <artist>", Category: "Entertainment"},
|
|
{Name: "concerts watching", Description: "List your watched artists", Usage: "!concerts watching", Category: "Entertainment"},
|
|
{Name: "concerts unwatch", Description: "Stop watching an artist", Usage: "!concerts unwatch <artist>", Category: "Entertainment"},
|
|
}
|
|
}
|
|
|
|
func (p *ConcertsPlugin) Init() error { return nil }
|
|
|
|
func (p *ConcertsPlugin) OnReaction(_ ReactionContext) error { return nil }
|
|
|
|
func (p *ConcertsPlugin) OnMessage(ctx MessageContext) error {
|
|
if !p.IsCommand(ctx.Body, "concerts") {
|
|
return nil
|
|
}
|
|
|
|
args := p.GetArgs(ctx.Body, "concerts")
|
|
if args == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts <artist> | !concerts watch|watching|unwatch <artist>")
|
|
}
|
|
|
|
parts := strings.SplitN(args, " ", 2)
|
|
sub := strings.ToLower(parts[0])
|
|
|
|
switch sub {
|
|
case "watch":
|
|
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts watch <artist>")
|
|
}
|
|
return p.handleWatch(ctx, strings.TrimSpace(parts[1]))
|
|
case "watching":
|
|
return p.handleWatching(ctx)
|
|
case "unwatch":
|
|
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts unwatch <artist>")
|
|
}
|
|
return p.handleUnwatch(ctx, strings.TrimSpace(parts[1]))
|
|
default:
|
|
return p.handleSearch(ctx, args)
|
|
}
|
|
}
|
|
|
|
func (p *ConcertsPlugin) handleSearch(ctx MessageContext, artist string) error {
|
|
if p.apiKey == "" {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Concert lookups are not configured (missing API key).")
|
|
}
|
|
|
|
// Daily rate limit (configurable, default 10/day to match TS version)
|
|
concertLimit := 10
|
|
if v := os.Getenv("RATELIMIT_CONCERTS"); v != "" {
|
|
if n, err := fmt.Sscanf(v, "%d", &concertLimit); n != 1 || err != nil {
|
|
concertLimit = 10
|
|
}
|
|
}
|
|
if p.rateLimiter != nil && !p.rateLimiter.CheckLimit(ctx.Sender, "concerts", concertLimit) {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Concert search rate limit reached for today.")
|
|
}
|
|
|
|
// Per-search cooldown
|
|
cooldownKey := string(ctx.Sender) + ":" + strings.ToLower(artist)
|
|
p.mu.Lock()
|
|
if last, ok := p.cooldowns[cooldownKey]; ok && time.Since(last) < 30*time.Second {
|
|
p.mu.Unlock()
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Please wait a moment before searching for this artist again.")
|
|
}
|
|
p.cooldowns[cooldownKey] = time.Now()
|
|
p.mu.Unlock()
|
|
|
|
events, err := p.fetchEvents(artist)
|
|
if err != nil {
|
|
slog.Error("concerts: fetch failed", "artist", artist, "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch concert data.")
|
|
}
|
|
|
|
if len(events) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No upcoming concerts found for %s.", artist))
|
|
}
|
|
|
|
// Show up to 5 events
|
|
limit := 5
|
|
if len(events) < limit {
|
|
limit = len(events)
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("Upcoming concerts for %s:\n\n", artist))
|
|
|
|
for i := 0; i < limit; i++ {
|
|
ev := events[i]
|
|
dateStr := p.formatEventDate(ev.DateTime)
|
|
location := p.formatLocation(ev.Venue.City, ev.Venue.Region, ev.Venue.Country)
|
|
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, dateStr))
|
|
sb.WriteString(fmt.Sprintf(" Venue: %s\n", ev.Venue.Name))
|
|
sb.WriteString(fmt.Sprintf(" Location: %s\n", location))
|
|
if ev.URL != "" {
|
|
sb.WriteString(fmt.Sprintf(" Tickets: %s\n", ev.URL))
|
|
}
|
|
if i < limit-1 {
|
|
sb.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
if len(events) > limit {
|
|
sb.WriteString(fmt.Sprintf("\n...and %d more events.", len(events)-limit))
|
|
}
|
|
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
func (p *ConcertsPlugin) fetchEvents(artist string) ([]bandsintownEvent, error) {
|
|
d := db.Get()
|
|
artistKey := strings.ToLower(artist)
|
|
|
|
// Check cache (6h TTL)
|
|
var cached string
|
|
var cachedAt int64
|
|
err := d.QueryRow(
|
|
`SELECT data, cached_at FROM concerts_cache WHERE artist = ?`, artistKey,
|
|
).Scan(&cached, &cachedAt)
|
|
if err == nil && time.Now().Unix()-cachedAt < 6*3600 {
|
|
var events []bandsintownEvent
|
|
if json.Unmarshal([]byte(cached), &events) == nil {
|
|
return events, nil
|
|
}
|
|
}
|
|
|
|
// Fetch from API
|
|
encoded := url.PathEscape(artist)
|
|
apiURL := fmt.Sprintf("https://rest.bandsintown.com/artists/%s/events?app_id=%s&date=upcoming",
|
|
encoded, p.apiKey)
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := p.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("bandsintown returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var events []bandsintownEvent
|
|
if err := json.NewDecoder(resp.Body).Decode(&events); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Update cache
|
|
data, _ := json.Marshal(events)
|
|
_, err = d.Exec(
|
|
`INSERT INTO concerts_cache (artist, data, cached_at) VALUES (?, ?, ?)
|
|
ON CONFLICT(artist) DO UPDATE SET data = ?, cached_at = ?`,
|
|
artistKey, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
|
)
|
|
if err != nil {
|
|
slog.Error("concerts: cache write", "err", err)
|
|
}
|
|
|
|
return events, nil
|
|
}
|
|
|
|
func (p *ConcertsPlugin) formatEventDate(dateStr string) string {
|
|
layouts := []string{
|
|
"2006-01-02T15:04:05",
|
|
time.RFC3339,
|
|
"2006-01-02",
|
|
}
|
|
for _, layout := range layouts {
|
|
if t, err := time.Parse(layout, dateStr); err == nil {
|
|
return t.Format("Mon, Jan 2, 2006 3:04 PM")
|
|
}
|
|
}
|
|
return dateStr
|
|
}
|
|
|
|
func (p *ConcertsPlugin) formatLocation(city, region, country string) string {
|
|
parts := []string{}
|
|
if city != "" {
|
|
parts = append(parts, city)
|
|
}
|
|
if region != "" {
|
|
parts = append(parts, region)
|
|
}
|
|
if country != "" {
|
|
parts = append(parts, country)
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func (p *ConcertsPlugin) handleWatch(ctx MessageContext, artist string) error {
|
|
d := db.Get()
|
|
artistKey := strings.ToLower(artist)
|
|
_, err := d.Exec(
|
|
`INSERT INTO concert_watchlist (user_id, artist, room_id) VALUES (?, ?, ?)
|
|
ON CONFLICT(user_id, artist) DO NOTHING`,
|
|
string(ctx.Sender), artistKey, string(ctx.RoomID),
|
|
)
|
|
if err != nil {
|
|
slog.Error("concerts: watch add", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add artist to watchlist.")
|
|
}
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Now watching %s for concert updates.", artist))
|
|
}
|
|
|
|
func (p *ConcertsPlugin) handleWatching(ctx MessageContext) error {
|
|
d := db.Get()
|
|
rows, err := d.Query(
|
|
`SELECT artist FROM concert_watchlist WHERE user_id = ? ORDER BY artist`,
|
|
string(ctx.Sender),
|
|
)
|
|
if err != nil {
|
|
slog.Error("concerts: watching list", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watchlist.")
|
|
}
|
|
defer rows.Close()
|
|
|
|
var artists []string
|
|
for rows.Next() {
|
|
var a string
|
|
if err := rows.Scan(&a); err != nil {
|
|
continue
|
|
}
|
|
artists = append(artists, a)
|
|
}
|
|
|
|
if len(artists) == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not watching any artists. Use !concerts watch <artist> to start.")
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString("Your Concert Watchlist:\n")
|
|
for _, a := range artists {
|
|
sb.WriteString(fmt.Sprintf(" - %s\n", a))
|
|
}
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
|
}
|
|
|
|
func (p *ConcertsPlugin) handleUnwatch(ctx MessageContext, artist string) error {
|
|
d := db.Get()
|
|
artistKey := strings.ToLower(artist)
|
|
res, err := d.Exec(
|
|
`DELETE FROM concert_watchlist WHERE user_id = ? AND artist = ?`,
|
|
string(ctx.Sender), artistKey,
|
|
)
|
|
if err != nil {
|
|
slog.Error("concerts: unwatch", "err", err)
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove artist from watchlist.")
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s is not in your watchlist.", artist))
|
|
}
|
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Stopped watching %s.", artist))
|
|
}
|
|
|
|
// PostWeeklyDigest sends a weekly concert digest to a room and DMs watchers about upcoming shows.
|
|
// Intended to be called by the scheduler on Sundays.
|
|
func (p *ConcertsPlugin) PostWeeklyDigest(roomID id.RoomID) {
|
|
if p.apiKey == "" {
|
|
slog.Warn("concerts: skipping weekly digest, no API key configured")
|
|
return
|
|
}
|
|
|
|
year, week := time.Now().UTC().ISOWeek()
|
|
weekKey := fmt.Sprintf("%d-W%02d:%s", year, week, roomID)
|
|
if db.JobCompleted("concert_digest", weekKey) {
|
|
slog.Info("concerts: already sent digest this week", "week", weekKey)
|
|
return
|
|
}
|
|
// Mark completed at the end only if we succeed (not deferred)
|
|
success := false
|
|
defer func() {
|
|
if success {
|
|
db.MarkJobCompleted("concert_digest", weekKey)
|
|
}
|
|
}()
|
|
|
|
d := db.Get()
|
|
|
|
// Get all unique watched artists
|
|
rows, err := d.Query(`SELECT DISTINCT artist FROM concert_watchlist`)
|
|
if err != nil {
|
|
slog.Error("concerts: weekly digest query", "err", err)
|
|
return
|
|
}
|
|
|
|
type watcherInfo struct {
|
|
userID id.UserID
|
|
roomID id.RoomID
|
|
}
|
|
|
|
artistWatchers := make(map[string][]watcherInfo)
|
|
var allArtists []string
|
|
|
|
// First collect all artists
|
|
for rows.Next() {
|
|
var artist string
|
|
if err := rows.Scan(&artist); err != nil {
|
|
continue
|
|
}
|
|
allArtists = append(allArtists, artist)
|
|
}
|
|
rows.Close()
|
|
|
|
// Get watchers per artist
|
|
for _, artist := range allArtists {
|
|
wRows, err := d.Query(
|
|
`SELECT user_id, room_id FROM concert_watchlist WHERE artist = ?`, artist,
|
|
)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for wRows.Next() {
|
|
var uid, rid string
|
|
if err := wRows.Scan(&uid, &rid); err != nil {
|
|
continue
|
|
}
|
|
artistWatchers[artist] = append(artistWatchers[artist], watcherInfo{
|
|
userID: id.UserID(uid),
|
|
roomID: id.RoomID(rid),
|
|
})
|
|
}
|
|
wRows.Close()
|
|
}
|
|
|
|
// For each artist, fetch events and notify watchers
|
|
for artist, watchers := range artistWatchers {
|
|
events, err := p.fetchEvents(artist)
|
|
if err != nil {
|
|
slog.Error("concerts: weekly digest fetch", "artist", artist, "err", err)
|
|
continue
|
|
}
|
|
|
|
if len(events) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Filter to events in the next 2 weeks
|
|
now := time.Now()
|
|
twoWeeks := now.Add(14 * 24 * time.Hour)
|
|
var upcoming []bandsintownEvent
|
|
for _, ev := range events {
|
|
t, err := time.Parse("2006-01-02T15:04:05", ev.DateTime)
|
|
if err != nil {
|
|
t, err = time.Parse(time.RFC3339, ev.DateTime)
|
|
}
|
|
if err == nil && t.After(now) && t.Before(twoWeeks) {
|
|
upcoming = append(upcoming, ev)
|
|
}
|
|
}
|
|
|
|
if len(upcoming) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Build message
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("Upcoming shows for %s (next 2 weeks):\n\n", artist))
|
|
for i, ev := range upcoming {
|
|
if i >= 5 {
|
|
sb.WriteString(fmt.Sprintf("\n...and %d more.", len(upcoming)-5))
|
|
break
|
|
}
|
|
dateStr := p.formatEventDate(ev.DateTime)
|
|
location := p.formatLocation(ev.Venue.City, ev.Venue.Region, ev.Venue.Country)
|
|
sb.WriteString(fmt.Sprintf("- %s @ %s (%s)\n", dateStr, ev.Venue.Name, location))
|
|
}
|
|
|
|
msg := sb.String()
|
|
for _, w := range watchers {
|
|
if err := p.SendDM(w.userID, msg); err != nil {
|
|
slog.Error("concerts: DM watcher", "user", w.userID, "err", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
success = true
|
|
slog.Info("concerts: weekly digest completed")
|
|
}
|