mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
- Add forex plugin (Frankfurter v2 API) with rate lookups, analysis, DM-based alerts, and daily cron poll. Backfills 1 year of history on startup for moving averages and buy signal scoring. - Fix bot hang caused by SQLite lock contention in reminder polling: rows cursor was held open while writing to the same DB. Collect results first, close cursor, then process. Same fix in milkcarton. - Add sync retry loop so the bot reconnects after network drops instead of silently exiting. StopSync() for clean Ctrl+C shutdown. - Add panic recovery to all dispatch, syncer, and cron paths. - Make all HTTP-calling plugin commands async (goroutines) so a slow or dead external API cannot block the message dispatch pipeline. Affects: lookup, stocks, forex, anime, movies, concerts, gaming, retro, wotd, urls, howami. - Extract DisplayName to Base, add db.Exec helper, convert silent error discards across the codebase. - Fix UNO mercy-kill bug (eliminated bot continues playing), adventure DM nag spam, stats column mismatch, per-call regex/replacer allocs. - Update README: forex commands, Finance section, 47 plugins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
465 lines
13 KiB
Go
465 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])
|
|
|
|
// DB-only subcommands
|
|
switch sub {
|
|
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]))
|
|
}
|
|
|
|
// API-calling subcommands run async
|
|
go func() {
|
|
var err error
|
|
switch sub {
|
|
case "watch":
|
|
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
|
err = p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts watch <artist>")
|
|
} else {
|
|
err = p.handleWatch(ctx, strings.TrimSpace(parts[1]))
|
|
}
|
|
default:
|
|
err = p.handleSearch(ctx, args)
|
|
}
|
|
if err != nil {
|
|
slog.Error("concerts: handler error", "err", err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
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)
|
|
db.Exec("concerts: cache write",
|
|
`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(),
|
|
)
|
|
|
|
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")
|
|
}
|