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
", Category: "Entertainment"},
{Name: "anime watch", Description: "Add anime to your watchlist", Usage: "!anime watch ", 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 ", 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 | !anime watch|watching|unwatch|season|upcoming")
}
parts := strings.SplitN(args, " ", 2)
sub := strings.ToLower(parts[0])
// Subcommands that only touch the DB don't need async
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: !anime unwatch ")
}
return p.handleUnwatch(ctx, strings.TrimSpace(parts[1]))
}
// Subcommands that hit the Jikan API run async to avoid blocking dispatch
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: !anime watch ")
} else {
err = p.handleWatch(ctx, strings.TrimSpace(parts[1]))
}
case "season":
err = p.handleSeason(ctx)
case "upcoming":
err = p.handleUpcoming(ctx)
default:
err = p.handleSearch(ctx, args)
}
if err != nil {
slog.Error("anime: handler error", "err", err)
}
}()
return nil
}
// 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 {
// 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)
db.Exec("anime: cache write",
`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(),
)
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)
db.Exec("anime: cache watchlist entry",
`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 to add anime.")
}
sb.WriteString("\nUse !anime unwatch 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)
db.Exec("anime: update daily cache",
`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")
}