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>
567 lines
16 KiB
Go
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")
|
|
}
|