mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Add rate limiting, API response caching, and fix progress bar panic
- Rate limit !weather (5/day), !concerts (10/day), fix !translate default to 20/day - Add generic api_cache table with CacheGet/CacheSet helpers - Cache !weather (1h), !wiki, !define, !tv (24h), !upcoming movies, !anime season/upcoming (24h) - Fix ProgressBar panic on negative Repeat count when XP exceeds level threshold - All rate limits configurable via RATELIMIT_* env vars Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,4 +36,6 @@ FEATURE_SHADE=
|
||||
FEATURE_TRIVIA=true # set to "false" to disable trivia
|
||||
|
||||
# ---- Rate Limits (per user per day) ----
|
||||
RATELIMIT_WEATHER=5
|
||||
RATELIMIT_TRANSLATE=20
|
||||
RATELIMIT_CONCERTS=10
|
||||
|
||||
@@ -82,6 +82,34 @@ func MarkJobCompleted(jobName, dateKey string) {
|
||||
}
|
||||
}
|
||||
|
||||
// CacheGet returns cached data for the given key if it exists and is within ttlSeconds.
|
||||
// Returns empty string if not cached or expired.
|
||||
func CacheGet(key string, ttlSeconds int) string {
|
||||
d := Get()
|
||||
var data string
|
||||
err := d.QueryRow(
|
||||
`SELECT data FROM api_cache WHERE cache_key = ? AND cached_at > unixepoch() - ?`,
|
||||
key, ttlSeconds,
|
||||
).Scan(&data)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// CacheSet stores data in the generic API cache.
|
||||
func CacheSet(key, data string) {
|
||||
d := Get()
|
||||
_, err := d.Exec(
|
||||
`INSERT INTO api_cache (cache_key, data, cached_at) VALUES (?, ?, unixepoch())
|
||||
ON CONFLICT(cache_key) DO UPDATE SET data = ?, cached_at = unixepoch()`,
|
||||
key, data, data,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("cache set", "key", key, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
const schema = `
|
||||
-- Users & XP
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
@@ -507,6 +535,13 @@ CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
count INTEGER DEFAULT 0,
|
||||
PRIMARY KEY (user_id, action, date)
|
||||
);
|
||||
|
||||
-- Generic API response cache
|
||||
CREATE TABLE IF NOT EXISTS api_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL,
|
||||
cached_at INTEGER DEFAULT (unixepoch())
|
||||
);
|
||||
`
|
||||
|
||||
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||
|
||||
@@ -365,6 +365,12 @@ func (p *AnimePlugin) handleUnwatch(ctx MessageContext, idStr string) error {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -392,10 +398,18 @@ func (p *AnimePlugin) handleSeason(ctx MessageContext) error {
|
||||
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())
|
||||
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
|
||||
@@ -423,7 +437,9 @@ func (p *AnimePlugin) handleUpcoming(ctx MessageContext) error {
|
||||
sb.WriteString(fmt.Sprintf("%d. %s [%s]\n", i+1, title, info))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
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.
|
||||
|
||||
@@ -46,15 +46,17 @@ 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) *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,
|
||||
},
|
||||
@@ -113,7 +115,18 @@ func (p *ConcertsPlugin) handleSearch(ctx MessageContext, artist string) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Concert lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
// Rate limit check
|
||||
// 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 {
|
||||
|
||||
@@ -97,12 +97,14 @@ var diceRe = regexp.MustCompile(`(?i)^(\d+)?d(\d+)([+-]\d+)?$`)
|
||||
// FunPlugin provides various fun and utility commands.
|
||||
type FunPlugin struct {
|
||||
Base
|
||||
rateLimiter *RateLimitsPlugin
|
||||
}
|
||||
|
||||
// NewFunPlugin creates a new FunPlugin.
|
||||
func NewFunPlugin(client *mautrix.Client) *FunPlugin {
|
||||
func NewFunPlugin(client *mautrix.Client, rateLimiter *RateLimitsPlugin) *FunPlugin {
|
||||
return &FunPlugin{
|
||||
Base: NewBase(client),
|
||||
rateLimiter: rateLimiter,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,6 +426,23 @@ func (p *FunPlugin) handleWeather(ctx MessageContext) error {
|
||||
return p.SendMessage(ctx.RoomID, "Weather service is not configured (missing API key).")
|
||||
}
|
||||
|
||||
// Rate limit (configurable, default 5/day)
|
||||
weatherLimit := 5
|
||||
if v := os.Getenv("RATELIMIT_WEATHER"); v != "" {
|
||||
if n, err := fmt.Sscanf(v, "%d", &weatherLimit); n != 1 || err != nil {
|
||||
weatherLimit = 5
|
||||
}
|
||||
}
|
||||
if p.rateLimiter != nil && !p.rateLimiter.CheckLimit(ctx.Sender, "weather", weatherLimit) {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Weather lookup rate limit reached for today.")
|
||||
}
|
||||
|
||||
// Check 1-hour cache (weather data doesn't change that fast)
|
||||
cacheKey := "weather:" + strings.ToLower(location)
|
||||
if cached := db.CacheGet(cacheKey, 3600); cached != "" {
|
||||
return p.SendMessage(ctx.RoomID, cached)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s&units=metric",
|
||||
url.QueryEscape(location), apiKey)
|
||||
|
||||
@@ -484,7 +503,9 @@ func (p *FunPlugin) handleWeather(ctx MessageContext) error {
|
||||
sb.WriteString(fmt.Sprintf(" Humidity: %d%%\n", weather.Main.Humidity))
|
||||
sb.WriteString(fmt.Sprintf(" Wind: %.1f m/s\n", weather.Wind.Speed))
|
||||
|
||||
return p.SendMessage(ctx.RoomID, sb.String())
|
||||
msg := sb.String()
|
||||
db.CacheSet(cacheKey, msg)
|
||||
return p.SendMessage(ctx.RoomID, msg)
|
||||
}
|
||||
|
||||
func (p *FunPlugin) handleDadJoke(ctx MessageContext) error {
|
||||
|
||||
@@ -67,6 +67,12 @@ func (p *LookupPlugin) handleWiki(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !wiki <topic>")
|
||||
}
|
||||
|
||||
// Check 24h cache
|
||||
cacheKey := "wiki:" + strings.ToLower(topic)
|
||||
if cached := db.CacheGet(cacheKey, 86400); cached != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, cached)
|
||||
}
|
||||
|
||||
encoded := url.PathEscape(strings.ReplaceAll(topic, " ", "_"))
|
||||
apiURL := fmt.Sprintf("https://en.wikipedia.org/api/rest_v1/page/summary/%s", encoded)
|
||||
|
||||
@@ -108,6 +114,7 @@ func (p *LookupPlugin) handleWiki(ctx MessageContext) error {
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("%s\n\n%s\n\n%s", result.Title, extract, result.ContentURLs.Desktop.Page)
|
||||
db.CacheSet(cacheKey, msg)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
@@ -117,6 +124,12 @@ func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word>")
|
||||
}
|
||||
|
||||
// Check 24h cache
|
||||
cacheKey := "define:" + strings.ToLower(word)
|
||||
if cached := db.CacheGet(cacheKey, 86400); cached != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, cached)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.dictionaryapi.dev/api/v2/entries/en/%s", url.PathEscape(word))
|
||||
|
||||
resp, err := httpClient.Get(apiURL)
|
||||
@@ -170,7 +183,9 @@ func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
msg := sb.String()
|
||||
db.CacheSet(cacheKey, msg)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *LookupPlugin) handleUrban(ctx MessageContext) error {
|
||||
@@ -280,9 +295,15 @@ func (p *LookupPlugin) handleTranslate(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Translation service is not configured.")
|
||||
}
|
||||
|
||||
// Rate limit: 10 translations per day
|
||||
if p.rateLimiter != nil && !p.rateLimiter.CheckLimit(ctx.Sender, "translate", 10) {
|
||||
remaining := p.rateLimiter.Remaining(ctx.Sender, "translate", 10)
|
||||
// Rate limit (configurable, default 20/day to match TS version)
|
||||
translateLimit := 20
|
||||
if v := os.Getenv("RATELIMIT_TRANSLATE"); v != "" {
|
||||
if n, err := fmt.Sscanf(v, "%d", &translateLimit); n != 1 || err != nil {
|
||||
translateLimit = 20
|
||||
}
|
||||
}
|
||||
if p.rateLimiter != nil && !p.rateLimiter.CheckLimit(ctx.Sender, "translate", translateLimit) {
|
||||
remaining := p.rateLimiter.Remaining(ctx.Sender, "translate", translateLimit)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||
fmt.Sprintf("Translation rate limit reached. %d remaining today.", remaining))
|
||||
}
|
||||
|
||||
@@ -290,6 +290,12 @@ func (p *MoviesPlugin) handleTV(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "TV lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
// Check 24h cache
|
||||
cacheKey := "tv:" + strings.ToLower(query)
|
||||
if cached := db.CacheGet(cacheKey, 86400); cached != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, cached)
|
||||
}
|
||||
|
||||
encoded := url.QueryEscape(query)
|
||||
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/search/tv?api_key=%s&query=%s&page=1",
|
||||
p.apiKey, encoded)
|
||||
@@ -311,7 +317,9 @@ func (p *MoviesPlugin) handleTV(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch TV show details.")
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, p.formatTV(detail))
|
||||
msg := p.formatTV(detail)
|
||||
db.CacheSet(cacheKey, msg)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) fetchTVDetail(tvID int) (*tmdbTVDetail, error) {
|
||||
@@ -474,6 +482,12 @@ func (p *MoviesPlugin) handleUpcoming(ctx MessageContext) error {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Movie lookups are not configured (missing API key).")
|
||||
}
|
||||
|
||||
// Check 6h cache
|
||||
cacheKey := "upcoming_movies"
|
||||
if cached := db.CacheGet(cacheKey, 86400); cached != "" {
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, cached)
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/movie/upcoming?api_key=%s®ion=US&page=1", p.apiKey)
|
||||
|
||||
var resp tmdbUpcomingResponse
|
||||
@@ -511,7 +525,9 @@ func (p *MoviesPlugin) handleUpcoming(ctx MessageContext) error {
|
||||
sb.WriteString(fmt.Sprintf("%d. %s (%s)%s\n", i+1, title, dateStr, ratingStr))
|
||||
}
|
||||
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||
msg := sb.String()
|
||||
db.CacheSet(cacheKey, msg)
|
||||
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||
}
|
||||
|
||||
func (p *MoviesPlugin) tmdbGet(apiURL string, target interface{}) error {
|
||||
|
||||
4
main.go
4
main.go
@@ -86,7 +86,7 @@ func main() {
|
||||
registry.Register(plugin.NewTriviaPlugin(client))
|
||||
registry.Register(plugin.NewRemindersPlugin(client))
|
||||
registry.Register(plugin.NewPresencePlugin(client))
|
||||
registry.Register(plugin.NewFunPlugin(client))
|
||||
registry.Register(plugin.NewFunPlugin(client, ratePlugin))
|
||||
registry.Register(plugin.NewToolsPlugin(client))
|
||||
registry.Register(plugin.NewUserPlugin(client))
|
||||
|
||||
@@ -95,7 +95,7 @@ func main() {
|
||||
registry.Register(plugin.NewLookupPlugin(client, ratePlugin))
|
||||
registry.Register(plugin.NewCountdownPlugin(client))
|
||||
registry.Register(plugin.NewStocksPlugin(client))
|
||||
registry.Register(plugin.NewConcertsPlugin(client))
|
||||
registry.Register(plugin.NewConcertsPlugin(client, ratePlugin))
|
||||
registry.Register(plugin.NewAnimePlugin(client))
|
||||
registry.Register(plugin.NewMoviesPlugin(client))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user