Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions cmd/release_counter.go
Original file line number Diff line number Diff line change
@@ -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)` +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Regex alternative \bwhen\s+will\s+you\b is too broad.

This pattern matches any "when will you" phrase regardless of context — e.g., "when will you be back?" or "when will you respond?" — producing false positives that inflate the counter. All other alternatives require a release-related keyword; this one does not.

🐛 Proposed fix: require a release-related keyword
-	`|(\bwhen\s+will\s+you\b)` +
+	`|(\bwhen\s+will\s+you\b[^.]{0,80}\b(?:update|release|version|app)\b)` +
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
`|(\bwhen\s+will\s+you\b)` +
`|(\bwhen\s+will\s+you\b[^.]{0,80}\b(?:update|release|version|app)\b)` +
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/release_counter.go` at line 25, Update the release-detection regex in the
pattern construction around `when will you` so that alternative only matches
when paired with a release-related keyword, consistent with the other
alternatives. Preserve legitimate release-counting matches while excluding
unrelated phrases such as questions about returning or responding.

`|(\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()
Comment on lines +36 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Race condition allows cooldown bypass.

The mutex is released between the cooldown check (line 38) and the DB increment (line 44). Two concurrent message handlers can both pass the cooldown check before either sets lastTriggeredAt, causing multiple counter increments within the 10-minute window. Since both Discord and Telegram share the same handler instance, cross-platform concurrent messages can trigger this.

🔒 Proposed fix: hold the lock for the entire operation
 func (h *ReleaseCounterHandler) Increment(isTelegram bool) (string, error) {
 	h.mu.Lock()
+	defer h.mu.Unlock()
+
 	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
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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()
func (h *ReleaseCounterHandler) Increment(isTelegram bool) (string, error) {
h.mu.Lock()
defer h.mu.Unlock()
if time.Since(h.lastTriggeredAt) < releaseCooldown {
return "", nil
}
count, err := h.DB.IncrementReleaseCounter()
if err != nil {
return "", fmt.Errorf("incrementing release counter: %w", err)
}
h.lastTriggeredAt = time.Now()
return formatCounter(count, isTelegram), nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/release_counter.go` around lines 36 - 42, Update
ReleaseCounterHandler.Increment to hold h.mu continuously from the cooldown
check through the database increment and lastTriggeredAt update, releasing it
only after the entire operation completes. Preserve the existing early return
for active cooldowns and ensure both Discord and Telegram calls are serialized
through the shared handler.


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)
}
24 changes: 24 additions & 0 deletions db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
12 changes: 9 additions & 3 deletions discord/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 {
Expand All @@ -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(),
}

Expand Down Expand Up @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions discord/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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
}
Comment on lines +116 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Release question check can intercept moderation commands.

The MatchReleaseQuestion check runs before the chatModPattern check (line 129). Since the regex matches any "when will you" phrase, a message like !ban @user when will you release would be intercepted by the release counter handler, silently skipping the ban. This affects both Discord and Telegram handlers.

🐛 Proposed fix: skip `!`-prefixed messages
-	if cmd.MatchReleaseQuestion(content) {
+	if !strings.HasPrefix(content, "!") && cmd.MatchReleaseQuestion(content) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
}
if !strings.HasPrefix(content, "!") && 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
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@discord/handlers.go` around lines 116 - 127, Update the release-question
handling in the Discord and Telegram handlers to bypass MatchReleaseQuestion for
messages prefixed with “!”. Ensure such messages continue to the chatModPattern
moderation check, while preserving the existing release-counter behavior for
non-command messages.


matches := chatModPattern.FindStringSubmatch(content)
if matches == nil {
return
Expand Down Expand Up @@ -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 {
Expand Down
5 changes: 3 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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))
Expand All @@ -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))
Expand Down
9 changes: 6 additions & 3 deletions telegram/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,17 @@ 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
}

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 {
Expand All @@ -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(),
}

Expand Down Expand Up @@ -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(
Expand Down
26 changes: 26 additions & 0 deletions telegram/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Comment on lines +44 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Release question check can intercept moderation commands (same as Discord).

The MatchReleaseQuestion check runs before the chatModPattern check (line 57). A !-prefixed moderation command containing "when will you" would be intercepted, silently skipping the moderation action.

🐛 Proposed fix: skip `!`-prefixed messages
-	if cmd.MatchReleaseQuestion(content) {
+	if !strings.HasPrefix(content, "!") && cmd.MatchReleaseQuestion(content) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
}
if !strings.HasPrefix(content, "!") && 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
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@telegram/handlers.go` around lines 44 - 55, Update the MatchReleaseQuestion
handling in the Telegram message flow to skip messages beginning with “!” so
moderation commands reach the chatModPattern check. Preserve the existing
release-counter and public-reply behavior for non-command messages, and leave
moderation handling unchanged.


matches := chatModPattern.FindStringSubmatch(content)
if matches == nil {
// No action triggered, so don't log this message
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
Expand Down
Loading