diff --git a/cmd/release_counter.go b/cmd/release_counter.go new file mode 100644 index 0000000..93cba25 --- /dev/null +++ b/cmd/release_counter.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "fmt" + "regexp" + "sync" + "time" + + "github.com/MetrolistGroup/metrobot/db" +) + +const releaseCooldown = 10 * time.Minute + +var releaseDatePattern = regexp.MustCompile(`(?i)` + + `(when\b[^.]{0,80}\b(?:update|release|version|app)\b)` + + `|(\beta\b[^.]{0,80}\b(?:update|release|version)\b)` + + `|(\bstill\s+(?:no|waiting)\b[^.]{0,80}\b(?:update|release|version|news)\b)` + + `|(\b(?:waiting|longing|hoping)\s+for\b[^.]{0,80}\b(?:update|release|version)\b)` + + `|(\bany\s+(?:update|release|version|news|eta)\b)` + + `|(\bwhere\s+(?:is|are)\b[^.]{0,80}\b(?:update|release|version|app)\b)` + + `|(\brelease\s+date\b)` + + `|(\bis\s+there\s+a\s+(?:new\s+)?(?:update|release|version)\b)` + + `|(\bwhat'?s\s+the\s+(?:eta|status|release\s+date)\b)` + + `|(\b(?:update|release|version)\s+when\b)` + + `|(\bwhen\s+will\s+you\b)` + + `|(\bhow\s+(?:long|much)\s+(?:until|before|till)\b[^.]{0,80}\b(?:update|release|version)\b)` + + `|(\bis\s+it\s+(?:out|released|available)\b[^.]{0,20}\b(?:yet|already)\b)` + + `|(\bdid\s+it\s+(?:release|come\s+out)\b)`) + +type ReleaseCounterHandler struct { + DB *db.DB + mu sync.Mutex + lastTriggeredAt time.Time +} + +func (h *ReleaseCounterHandler) Increment(isTelegram bool) (string, error) { + h.mu.Lock() + if time.Since(h.lastTriggeredAt) < releaseCooldown { + h.mu.Unlock() + return "", nil + } + h.mu.Unlock() + + count, err := h.DB.IncrementReleaseCounter() + if err != nil { + return "", fmt.Errorf("incrementing release counter: %w", err) + } + + h.mu.Lock() + h.lastTriggeredAt = time.Now() + h.mu.Unlock() + + return formatCounter(count, isTelegram), nil +} + +func (h *ReleaseCounterHandler) Get(isTelegram bool) (string, error) { + count, err := h.DB.GetReleaseCounter() + if err != nil { + return "", fmt.Errorf("getting release counter: %w", err) + } + return formatCounter(count, isTelegram), nil +} + +func formatCounter(count int, isTelegram bool) string { + msg := fmt.Sprintf("Release date question counter: %d", count) + if isTelegram { + return msg + } + return msg +} + +func MatchReleaseQuestion(content string) bool { + return releaseDatePattern.MatchString(content) +} diff --git a/db/db.go b/db/db.go index 4ac8583..44b0aaa 100644 --- a/db/db.go +++ b/db/db.go @@ -118,6 +118,10 @@ func (d *DB) migrate() error { star_count INTEGER NOT NULL DEFAULT 0, timestamp INTEGER NOT NULL )`, + `CREATE TABLE IF NOT EXISTS release_counter ( + id INTEGER PRIMARY KEY CHECK (id = 1), + count INTEGER NOT NULL DEFAULT 0 + )`, } for _, m := range migrations { @@ -666,6 +670,26 @@ func (d *DB) GetAllStarboardEntries() ([]*StarboardEntry, error) { return entries, rows.Err() } +// --- Release Counter --- + +func (d *DB) GetReleaseCounter() (int, error) { + var count int + err := d.conn.QueryRow("SELECT count FROM release_counter WHERE id = 1").Scan(&count) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + return count, err +} + +func (d *DB) IncrementReleaseCounter() (int, error) { + _, err := d.conn.Exec(`INSERT INTO release_counter (id, count) VALUES (1, 1) + ON CONFLICT(id) DO UPDATE SET count = count + 1`) + if err != nil { + return 0, err + } + return d.GetReleaseCounter() +} + type PermaAdminProvider interface { GetPermaAdminIDs(platform string) []string } diff --git a/discord/bot.go b/discord/bot.go index 105cdcd..55b7738 100644 --- a/discord/bot.go +++ b/discord/bot.go @@ -23,8 +23,9 @@ type Bot struct { Moderation *cmd.ModerationHandler Warn *cmd.WarnHandler Admin *cmd.AdminHandler - Ping *cmd.PingHandler - Case *cmd.CaseHandler + Ping *cmd.PingHandler + Case *cmd.CaseHandler + ReleaseCounter *cmd.ReleaseCounterHandler garminProcessor *cmd.GarminProcessor TimedBanRestorer func() @@ -33,7 +34,7 @@ type Bot struct { func New(cfg *config.Config, database *db.DB, logger *zap.Logger, notes *cmd.NotesHandler, version *cmd.VersionHandler, actions *cmd.ActionsHandler, moderation *cmd.ModerationHandler, warn *cmd.WarnHandler, admin *cmd.AdminHandler, ping *cmd.PingHandler, - cases *cmd.CaseHandler, + cases *cmd.CaseHandler, releaseCounter *cmd.ReleaseCounterHandler, ) (*Bot, error) { session, err := discordgo.New("Bot " + cfg.DiscordToken) if err != nil { @@ -55,6 +56,7 @@ func New(cfg *config.Config, database *db.DB, logger *zap.Logger, Admin: admin, Ping: ping, Case: cases, + ReleaseCounter: releaseCounter, garminProcessor: cmd.NewGarminProcessor(), } @@ -422,6 +424,10 @@ func (b *Bot) registerCommands() error { Name: "refreshstarboard", Description: "Refresh all starboard entries by rechecking star counts (admin only)", }, + { + Name: "counter", + Description: "Show the release date question counter", + }, } for _, cmd := range commands { diff --git a/discord/handlers.go b/discord/handlers.go index 110b27a..3737430 100644 --- a/discord/handlers.go +++ b/discord/handlers.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/MetrolistGroup/metrobot/cmd" "github.com/MetrolistGroup/metrobot/util" "github.com/bwmarrin/discordgo" "go.uber.org/zap" @@ -87,6 +88,8 @@ func (b *Bot) onInteractionCreate(s *discordgo.Session, i *discordgo.Interaction b.handlePurge(s, i, opts, callerID) case "refreshstarboard": b.handleRefreshStarboard(s, i, callerID) + case "counter": + b.handleCounter(s, i) } } @@ -110,6 +113,19 @@ func (b *Bot) onMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) return } + if cmd.MatchReleaseQuestion(content) { + text, err := b.ReleaseCounter.Increment(false) + if err != nil { + b.Logger.Error("release counter error", zap.Error(err)) + return + } + if text == "" { + return + } + sendReply(s, m.ChannelID, m.ID, text, false, b.Logger) + return + } + matches := chatModPattern.FindStringSubmatch(content) if matches == nil { return @@ -731,6 +747,16 @@ func (b *Bot) handleRefreshStarboard(s *discordgo.Session, i *discordgo.Interact editDeferredResponse(s, i, "✅ Starboard refreshed successfully.") } +func (b *Bot) handleCounter(s *discordgo.Session, i *discordgo.InteractionCreate) { + text, err := b.ReleaseCounter.Get(false) + if err != nil { + b.Logger.Error("counter error", zap.Error(err)) + respondEphemeral(s, i, "Error fetching counter.") + return + } + respondPublic(s, i, text) +} + // --- Helpers --- func optionMap(opts []*discordgo.ApplicationCommandInteractionDataOption) map[string]*discordgo.ApplicationCommandInteractionDataOption { diff --git a/main.go b/main.go index 8e088ba..e5d549b 100644 --- a/main.go +++ b/main.go @@ -116,6 +116,7 @@ func main() { adminHandler := &cmd.AdminHandler{DB: database} pingHandler := &cmd.PingHandler{} caseHandler := &cmd.CaseHandler{DB: database} + releaseCounterHandler := &cmd.ReleaseCounterHandler{DB: database} // Wire up case handler with moderation handlers moderationHandler.SetCaseHandler(caseHandler) @@ -129,7 +130,7 @@ func main() { discordBot, err := discord.New(cfg, database, logger, notesHandler, versionHandler, actionsHandler, moderationHandler, warnHandler, adminHandler, pingHandler, - caseHandler, + caseHandler, releaseCounterHandler, ) if err != nil { logger.Fatal("failed to create discord bot", zap.Error(err)) @@ -142,7 +143,7 @@ func main() { telegramBot, err := telegram.New(cfg, database, logger, notesHandler, versionHandler, actionsHandler, moderationHandler, warnHandler, adminHandler, pingHandler, - caseHandler, + caseHandler, releaseCounterHandler, ) if err != nil { logger.Fatal("failed to create telegram bot", zap.Error(err)) diff --git a/telegram/bot.go b/telegram/bot.go index 3f38d41..70d0e0c 100644 --- a/telegram/bot.go +++ b/telegram/bot.go @@ -22,8 +22,9 @@ type Bot struct { Moderation *cmd.ModerationHandler Warn *cmd.WarnHandler Admin *cmd.AdminHandler - Ping *cmd.PingHandler - Case *cmd.CaseHandler + Ping *cmd.PingHandler + Case *cmd.CaseHandler + ReleaseCounter *cmd.ReleaseCounterHandler garminProcessor *cmd.GarminProcessor } @@ -31,7 +32,7 @@ type Bot struct { func New(cfg *config.Config, database *db.DB, logger *zap.Logger, notes *cmd.NotesHandler, version *cmd.VersionHandler, actions *cmd.ActionsHandler, moderation *cmd.ModerationHandler, warn *cmd.WarnHandler, admin *cmd.AdminHandler, ping *cmd.PingHandler, - cases *cmd.CaseHandler, + cases *cmd.CaseHandler, releaseCounter *cmd.ReleaseCounterHandler, ) (*Bot, error) { api, err := tgbotapi.NewBotAPI(cfg.TelegramToken) if err != nil { @@ -51,6 +52,7 @@ func New(cfg *config.Config, database *db.DB, logger *zap.Logger, Admin: admin, Ping: ping, Case: cases, + ReleaseCounter: releaseCounter, garminProcessor: cmd.NewGarminProcessor(), } @@ -132,6 +134,7 @@ func (b *Bot) registerCommands() { {Command: "addadmin", Description: "Add a bot admin (permaadmin)"}, {Command: "removeadmin", Description: "Remove a bot admin (permaadmin)"}, {Command: "ping", Description: "Check latency to services"}, + {Command: "counter", Description: "Show the release date question counter"}, } cfg := tgbotapi.NewSetMyCommandsWithScope( diff --git a/telegram/handlers.go b/telegram/handlers.go index da5324d..8e7e29a 100644 --- a/telegram/handlers.go +++ b/telegram/handlers.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/MetrolistGroup/metrobot/cmd" "github.com/MetrolistGroup/metrobot/util" tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" "go.uber.org/zap" @@ -40,6 +41,19 @@ func (b *Bot) handleMessage(msg *tgbotapi.Message) { return } + if cmd.MatchReleaseQuestion(content) { + text, err := b.ReleaseCounter.Increment(true) + if err != nil { + b.Logger.Error("release counter error", zap.Error(err)) + return + } + if text == "" { + return + } + sendPublicReply(b.API, msg.Chat.ID, msg.MessageID, text, "", false, b.Logger) + return + } + matches := chatModPattern.FindStringSubmatch(content) if matches == nil { // No action triggered, so don't log this message @@ -168,6 +182,8 @@ func (b *Bot) handleCommand(msg *tgbotapi.Message, callerID string) { b.tgHandleRemoveAdmin(msg, args, callerID) case "ping": b.tgHandlePing(msg) + case "counter": + b.tgHandleCounter(msg) } } @@ -738,6 +754,16 @@ func (b *Bot) tgHandlePing(msg *tgbotapi.Message) { sendPublicReply(b.API, msg.Chat.ID, msg.MessageID, text, "", false, b.Logger) } +func (b *Bot) tgHandleCounter(msg *tgbotapi.Message) { + text, err := b.ReleaseCounter.Get(true) + if err != nil { + b.Logger.Error("counter error", zap.Error(err)) + sendPublicReply(b.API, msg.Chat.ID, msg.MessageID, "Error fetching counter.", "", false, b.Logger) + return + } + sendPublicReply(b.API, msg.Chat.ID, msg.MessageID, text, "", false, b.Logger) +} + func extractTelegramUserID(msg *tgbotapi.Message, mention string) string { if msg.ReplyToMessage != nil && msg.ReplyToMessage.From != nil { return strconv.FormatInt(msg.ReplyToMessage.From.ID, 10)