Files
gogobee/internal/plugin/anime.go
prosolis 2c191ec464 Fix scheduled posts only broadcasting to first room
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>
2026-03-09 00:09:11 -07:00

567 lines
16 KiB
Go

package plugin
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// jikanAnime represents an anime entry from the Jikan API.
type jikanAnime struct {
MalID int `json:"mal_id"`
URL string `json:"url"`
Title string `json:"title"`
TitleEng string `json:"title_english"`
Type string `json:"type"`
Episodes int `json:"episodes"`
Status string `json:"status"`
Score float64 `json:"score"`
Synopsis string `json:"synopsis"`
Aired struct {
String string `json:"string"`
From string `json:"from"`
To string `json:"to"`
} `json:"aired"`
Broadcast struct {
Day string `json:"day"`
Time string `json:"time"`
TZ string `json:"timezone"`
} `json:"broadcast"`
Genres []struct {
Name string `json:"name"`
} `json:"genres"`
Studios []struct {
Name string `json:"name"`
} `json:"studios"`
Season string `json:"season"`
Year int `json:"year"`
}
// jikanSearchResponse is the Jikan search API response.
type jikanSearchResponse struct {
Data []jikanAnime `json:"data"`
Pagination struct {
HasNext int `json:"has_next_page"`
} `json:"pagination"`
}
// jikanSeasonResponse is the Jikan season API response.
type jikanSeasonResponse struct {
Data []jikanAnime `json:"data"`
}
// AnimePlugin provides anime lookups via the Jikan/MAL API.
type AnimePlugin struct {
Base
httpClient *http.Client
mu sync.Mutex
lastCall time.Time // for rate limiting Jikan (400ms between calls)
}
// NewAnimePlugin creates a new AnimePlugin.
func NewAnimePlugin(client *mautrix.Client) *AnimePlugin {
return &AnimePlugin{
Base: NewBase(client),
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (p *AnimePlugin) Name() string { return "anime" }
func (p *AnimePlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "anime", Description: "Search for anime info", Usage: "!anime <title>", Category: "Entertainment"},
{Name: "anime watch", Description: "Add anime to your watchlist", Usage: "!anime watch <title>", Category: "Entertainment"},
{Name: "anime watching", Description: "List your anime watchlist", Usage: "!anime watching", Category: "Entertainment"},
{Name: "anime unwatch", Description: "Remove anime from watchlist by MAL ID", Usage: "!anime unwatch <id>", Category: "Entertainment"},
{Name: "anime season", Description: "Show current season anime", Usage: "!anime season", Category: "Entertainment"},
{Name: "anime upcoming", Description: "Show upcoming anime", Usage: "!anime upcoming", Category: "Entertainment"},
}
}
func (p *AnimePlugin) Init() error { return nil }
func (p *AnimePlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *AnimePlugin) OnMessage(ctx MessageContext) error {
if !p.IsCommand(ctx.Body, "anime") {
return nil
}
args := p.GetArgs(ctx.Body, "anime")
if args == "" {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Usage: !anime <title> | !anime watch|watching|unwatch|season|upcoming")
}
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: !anime watch <title>")
}
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: !anime unwatch <id>")
}
return p.handleUnwatch(ctx, strings.TrimSpace(parts[1]))
case "season":
return p.handleSeason(ctx)
case "upcoming":
return p.handleUpcoming(ctx)
default:
return p.handleSearch(ctx, args)
}
}
// rateLimit enforces the 400ms delay between Jikan API calls.
func (p *AnimePlugin) rateLimit() {
p.mu.Lock()
defer p.mu.Unlock()
elapsed := time.Since(p.lastCall)
if elapsed < 400*time.Millisecond {
time.Sleep(400*time.Millisecond - elapsed)
}
p.lastCall = time.Now()
}
func (p *AnimePlugin) jikanGet(apiURL string, target interface{}) error {
p.rateLimit()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
if err != nil {
return err
}
resp, err := p.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("jikan returned status %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(target)
}
func (p *AnimePlugin) handleSearch(ctx MessageContext, query string) error {
d := db.Get()
// Search via API
encoded := url.QueryEscape(query)
apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?q=%s&limit=3&sfw=true", encoded)
var searchResp jikanSearchResponse
if err := p.jikanGet(apiURL, &searchResp); err != nil {
slog.Error("anime: search failed", "query", query, "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for anime.")
}
if len(searchResp.Data) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No anime found for \"%s\".", query))
}
anime := searchResp.Data[0]
// Cache the result
data, _ := json.Marshal(anime)
if _, err := d.Exec(
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
anime.MalID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
); err != nil {
slog.Error("anime: cache write", "err", err)
}
return p.SendReply(ctx.RoomID, ctx.EventID, p.formatAnime(anime))
}
func (p *AnimePlugin) formatAnime(a jikanAnime) string {
var sb strings.Builder
title := a.Title
if a.TitleEng != "" && a.TitleEng != a.Title {
title = fmt.Sprintf("%s (%s)", a.TitleEng, a.Title)
}
sb.WriteString(fmt.Sprintf("%s\n", title))
sb.WriteString(fmt.Sprintf("MAL ID: %d\n", a.MalID))
if a.Type != "" {
sb.WriteString(fmt.Sprintf("Type: %s", a.Type))
if a.Episodes > 0 {
sb.WriteString(fmt.Sprintf(" (%d episodes)", a.Episodes))
}
sb.WriteString("\n")
}
if a.Status != "" {
sb.WriteString(fmt.Sprintf("Status: %s\n", a.Status))
}
if a.Score > 0 {
sb.WriteString(fmt.Sprintf("Score: %.2f/10\n", a.Score))
}
if a.Aired.String != "" {
sb.WriteString(fmt.Sprintf("Aired: %s\n", a.Aired.String))
}
if len(a.Genres) > 0 {
genres := make([]string, len(a.Genres))
for i, g := range a.Genres {
genres[i] = g.Name
}
sb.WriteString(fmt.Sprintf("Genres: %s\n", strings.Join(genres, ", ")))
}
if len(a.Studios) > 0 {
studios := make([]string, len(a.Studios))
for i, s := range a.Studios {
studios[i] = s.Name
}
sb.WriteString(fmt.Sprintf("Studios: %s\n", strings.Join(studios, ", ")))
}
if a.Broadcast.Day != "" {
sb.WriteString(fmt.Sprintf("Broadcast: %s %s (%s)\n", a.Broadcast.Day, a.Broadcast.Time, a.Broadcast.TZ))
}
if a.Synopsis != "" {
synopsis := a.Synopsis
if len(synopsis) > 300 {
synopsis = synopsis[:300] + "..."
}
sb.WriteString(fmt.Sprintf("\n%s\n", synopsis))
}
if a.URL != "" {
sb.WriteString(fmt.Sprintf("\n%s", a.URL))
}
return sb.String()
}
func (p *AnimePlugin) handleWatch(ctx MessageContext, title string) error {
// Search for the anime first to get its MAL ID
encoded := url.QueryEscape(title)
apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?q=%s&limit=1&sfw=true", encoded)
var searchResp jikanSearchResponse
if err := p.jikanGet(apiURL, &searchResp); err != nil {
slog.Error("anime: watch search failed", "title", title, "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for anime.")
}
if len(searchResp.Data) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No anime found for \"%s\".", title))
}
anime := searchResp.Data[0]
d := db.Get()
_, err := d.Exec(
`INSERT INTO anime_watchlist (user_id, mal_id, title, room_id) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, mal_id) DO NOTHING`,
string(ctx.Sender), anime.MalID, anime.Title, string(ctx.RoomID),
)
if err != nil {
slog.Error("anime: watchlist add", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add to watchlist.")
}
// Also cache the anime data
data, _ := json.Marshal(anime)
_, _ = d.Exec(
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
anime.MalID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
)
displayTitle := anime.Title
if anime.TitleEng != "" {
displayTitle = anime.TitleEng
}
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Added \"%s\" (MAL ID: %d) to your watchlist.", displayTitle, anime.MalID))
}
func (p *AnimePlugin) handleWatching(ctx MessageContext) error {
d := db.Get()
rows, err := d.Query(
`SELECT mal_id, title FROM anime_watchlist WHERE user_id = ? ORDER BY title`,
string(ctx.Sender),
)
if err != nil {
slog.Error("anime: watching list", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watchlist.")
}
defer rows.Close()
var sb strings.Builder
sb.WriteString("Your Anime Watchlist:\n\n")
count := 0
for rows.Next() {
var malID int
var title string
if err := rows.Scan(&malID, &title); err != nil {
continue
}
sb.WriteString(fmt.Sprintf(" [%d] %s\n", malID, title))
count++
}
if count == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
"Your anime watchlist is empty. Use !anime watch <title> to add anime.")
}
sb.WriteString("\nUse !anime unwatch <id> to remove an entry.")
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}
func (p *AnimePlugin) handleUnwatch(ctx MessageContext, idStr string) error {
malID, err := strconv.Atoi(idStr)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Please provide a valid MAL ID number. Check !anime watching for your list.")
}
d := db.Get()
res, err := d.Exec(
`DELETE FROM anime_watchlist WHERE user_id = ? AND mal_id = ?`,
string(ctx.Sender), malID,
)
if err != nil {
slog.Error("anime: unwatch", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove from watchlist.")
}
n, _ := res.RowsAffected()
if n == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("MAL ID %d is not in your watchlist.", malID))
}
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Removed MAL ID %d from your watchlist.", malID))
}
func (p *AnimePlugin) handleSeason(ctx MessageContext) error {
// Check 6h cache
cacheKey := "anime_season"
if cached := db.CacheGet(cacheKey, 86400); cached != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, cached)
}
apiURL := "https://api.jikan.moe/v4/seasons/now?limit=10&sfw=true"
var resp jikanSeasonResponse
if err := p.jikanGet(apiURL, &resp); err != nil {
slog.Error("anime: season fetch failed", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch current season anime.")
}
if len(resp.Data) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "No anime found for the current season.")
}
var sb strings.Builder
sb.WriteString("Current Season Anime:\n\n")
for i, a := range resp.Data {
title := a.Title
if a.TitleEng != "" {
title = a.TitleEng
}
scoreStr := "N/A"
if a.Score > 0 {
scoreStr = fmt.Sprintf("%.1f", a.Score)
}
sb.WriteString(fmt.Sprintf("%d. %s [%s] - Score: %s\n", i+1, title, a.Type, scoreStr))
}
msg := sb.String()
db.CacheSet(cacheKey, msg)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
func (p *AnimePlugin) handleUpcoming(ctx MessageContext) error {
// Check 6h cache
cacheKey := "anime_upcoming"
if cached := db.CacheGet(cacheKey, 86400); cached != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, cached)
}
apiURL := "https://api.jikan.moe/v4/seasons/upcoming?limit=10&sfw=true"
var resp jikanSeasonResponse
if err := p.jikanGet(apiURL, &resp); err != nil {
slog.Error("anime: upcoming fetch failed", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch upcoming anime.")
}
if len(resp.Data) == 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "No upcoming anime found.")
}
var sb strings.Builder
sb.WriteString("Upcoming Anime:\n\n")
for i, a := range resp.Data {
title := a.Title
if a.TitleEng != "" {
title = a.TitleEng
}
info := a.Type
if a.Aired.String != "" {
info += " | " + a.Aired.String
}
sb.WriteString(fmt.Sprintf("%d. %s [%s]\n", i+1, title, info))
}
msg := sb.String()
db.CacheSet(cacheKey, msg)
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
// PostDailyReleases checks broadcast days and DMs watchers about anime airing today.
// Intended to be called by the scheduler daily.
func (p *AnimePlugin) PostDailyReleases(roomID id.RoomID) {
todayKey := fmt.Sprintf("%s:%s", time.Now().UTC().Format("2006-01-02"), roomID)
if db.JobCompleted("anime_releases", todayKey) {
slog.Info("anime: already sent daily releases", "date", todayKey)
return
}
success := false
defer func() {
if success {
db.MarkJobCompleted("anime_releases", todayKey)
}
}()
d := db.Get()
today := strings.ToLower(time.Now().Weekday().String())
// Jikan uses plural day names like "Mondays", "Tuesdays", etc.
todayPlural := today + "s"
// Get all unique MAL IDs from watchlists
rows, err := d.Query(`SELECT DISTINCT mal_id, title FROM anime_watchlist`)
if err != nil {
slog.Error("anime: daily releases query", "err", err)
return
}
type animeEntry struct {
malID int
title string
}
var watchedAnime []animeEntry
for rows.Next() {
var e animeEntry
if err := rows.Scan(&e.malID, &e.title); err != nil {
continue
}
watchedAnime = append(watchedAnime, e)
}
rows.Close()
for _, entry := range watchedAnime {
// Check cache or fetch anime details
var animeData jikanAnime
var cached string
var cachedAt int64
err := d.QueryRow(
`SELECT data, cached_at FROM anime_cache WHERE mal_id = ?`, entry.malID,
).Scan(&cached, &cachedAt)
needsFetch := err != nil || time.Now().Unix()-cachedAt > 24*3600
if !needsFetch {
if json.Unmarshal([]byte(cached), &animeData) != nil {
needsFetch = true
}
}
if needsFetch {
apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d", entry.malID)
var resp struct {
Data jikanAnime `json:"data"`
}
if err := p.jikanGet(apiURL, &resp); err != nil {
slog.Error("anime: daily fetch", "mal_id", entry.malID, "err", err)
continue
}
animeData = resp.Data
// Update cache
data, _ := json.Marshal(animeData)
_, _ = d.Exec(
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
entry.malID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
)
}
// Check if this anime broadcasts today
broadcastDay := strings.ToLower(animeData.Broadcast.Day)
if broadcastDay != todayPlural && broadcastDay != today {
continue
}
// Only notify for currently airing anime
if animeData.Status != "Currently Airing" {
continue
}
// Find watchers for this anime
wRows, err := d.Query(
`SELECT user_id FROM anime_watchlist WHERE mal_id = ?`, entry.malID,
)
if err != nil {
continue
}
displayTitle := animeData.Title
if animeData.TitleEng != "" {
displayTitle = animeData.TitleEng
}
msg := fmt.Sprintf("New episode alert! %s airs today (%s %s %s).\n%s",
displayTitle, animeData.Broadcast.Day, animeData.Broadcast.Time,
animeData.Broadcast.TZ, animeData.URL)
for wRows.Next() {
var uid string
if err := wRows.Scan(&uid); err != nil {
continue
}
if err := p.SendDM(id.UserID(uid), msg); err != nil {
slog.Error("anime: DM watcher", "user", uid, "err", err)
}
}
wRows.Close()
}
success = true
slog.Info("anime: daily releases check completed")
}