mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Rewrite from TypeScript to Go
Complete rewrite of the Freebee Matrix bot as GogoBee using mautrix-go. - E2EE with goolm (pure Go, no CGo/libolm) and cross-signing bootstrap - 35+ plugins with dependency injection and ordered registration - SQLite storage via modernc.org/sqlite (no CGo) - Scheduler via robfig/cron for WOTD, holidays, birthdays, releases, etc. - Optional LLM integration (Ollama) for sentiment, profanity, roasts, vibes - Threaded trivia, three-tier profanity tracking, WOTD usage verification - Multi-country holiday support with deduplication - Quadratic XP curve, configurable bot display name, encrypted DMs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
550
internal/plugin/anime.go
Normal file
550
internal/plugin/anime.go
Normal file
@@ -0,0 +1,550 @@
|
||||
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 {
|
||||
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))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
func (p *AnimePlugin) handleUpcoming(ctx MessageContext) error {
|
||||
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))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
}
|
||||
|
||||
// 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 := time.Now().UTC().Format("2006-01-02")
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user