Hold'em: route sender-private replies to DM origin + jitter broadcasts

Two changes that were running in prod but hadn't been committed:

1. Sender-private DM routing. When a player invokes a holdem command from
   their DM, the dispatcher rewrites ctx.RoomID to the game room so
   actions affect the right table. But sender-private replies (errors,
   help, status, "unknown command") would then post to the public games
   room instead of back to the DM that asked. Now: the dispatcher saves
   ctx.OriginRoomID = the DM, and a new reply() helper routes sender-
   private messages to OriginRoomID when set, falling back to in-thread
   reply otherwise. (OriginRoomID was added to MessageContext earlier.)

2. broadcastDM jitter. 150-400ms sleep between sends to avoid Matrix
   rate-limit bursts when a game has 4+ players each in their own DM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-28 22:37:19 -07:00
parent 4826b5e466
commit e22d4f8362

View File

@@ -3,6 +3,7 @@ package plugin
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"math/rand/v2"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -98,12 +99,14 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
} }
// If this is a DM, resolve the player's game room so actions route correctly. // If this is a DM, resolve the player's game room so actions route correctly.
// Clear EventID so replies go as plain messages — the room can't resolve DM events. // Save the original DM room into OriginRoomID so sender-private replies (errors,
// help, status) route back via DM rather than broadcasting to the games room.
if isDM { if isDM {
p.mu.Lock() p.mu.Lock()
gameRoom := p.findGameRoom(ctx.Sender) gameRoom := p.findGameRoom(ctx.Sender)
p.mu.Unlock() p.mu.Unlock()
if gameRoom != "" { if gameRoom != "" {
ctx.OriginRoomID = ctx.RoomID
ctx.RoomID = gameRoom ctx.RoomID = gameRoom
ctx.EventID = "" ctx.EventID = ""
} }
@@ -113,7 +116,7 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
if !isDM && !isGamesRoom(ctx.RoomID) { if !isDM && !isGamesRoom(ctx.RoomID) {
gr := gamesRoom() gr := gamesRoom()
if gr != "" { if gr != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!") return p.reply(ctx, "Games are only available in the games channel!")
} }
} }
@@ -139,14 +142,23 @@ func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
case args == "status": case args == "status":
return p.handleStatus(ctx) return p.handleStatus(ctx)
case args == "help": case args == "help":
return p.SendReply(ctx.RoomID, ctx.EventID, renderHelpMessage()) return p.reply(ctx, renderHelpMessage())
case args == "addbot": case args == "addbot":
return p.handleAddBot(ctx) return p.handleAddBot(ctx)
default: default:
return p.SendReply(ctx.RoomID, ctx.EventID, "Unknown command. Try `!holdem help`.") return p.reply(ctx, "Unknown command. Try `!holdem help`.")
} }
} }
// reply routes sender-private responses to the originating DM when the command
// came from a DM (ctx.OriginRoomID set), otherwise replies in-thread.
func (p *HoldemPlugin) reply(ctx MessageContext, msg string) error {
if ctx.OriginRoomID != "" {
return p.SendMessage(ctx.OriginRoomID, msg)
}
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
func (p *HoldemPlugin) isDMRoom(roomID id.RoomID) bool { func (p *HoldemPlugin) isDMRoom(roomID id.RoomID) bool {
// DM rooms typically have exactly 2 members. // DM rooms typically have exactly 2 members.
members := p.RoomMembers(roomID) members := p.RoomMembers(roomID)
@@ -166,11 +178,11 @@ func (p *HoldemPlugin) findGameRoom(userID id.UserID) id.RoomID {
func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bool) error { func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bool) error {
if !isDM { if !isDM {
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips can only be toggled in DM. Send `!holdem tips on/off` directly to me.") return p.reply(ctx, "Tips can only be toggled in DM. Send `!holdem tips on/off` directly to me.")
} }
if p.IsAdmin(ctx.Sender) { if p.IsAdmin(ctx.Sender) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips are always on for admins. 🐝") return p.reply(ctx, "Tips are always on for admins. 🐝")
} }
parts := strings.Fields(args) parts := strings.Fields(args)
@@ -180,18 +192,18 @@ func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bo
if !pref { if !pref {
status = "off" status = "off"
} }
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Tips are currently **%s**. Use `!holdem tips on/off` to change.", status)) return p.reply(ctx, fmt.Sprintf("Tips are currently **%s**. Use `!holdem tips on/off` to change.", status))
} }
switch parts[1] { switch parts[1] {
case "on": case "on":
saveTipsPref(ctx.Sender, true) saveTipsPref(ctx.Sender, true)
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips enabled. You'll receive coaching on your turn.") return p.reply(ctx, "Tips enabled. You'll receive coaching on your turn.")
case "off": case "off":
saveTipsPref(ctx.Sender, false) saveTipsPref(ctx.Sender, false)
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips disabled.") return p.reply(ctx, "Tips disabled.")
default: default:
return p.SendReply(ctx.RoomID, ctx.EventID, "Use `!holdem tips on` or `!holdem tips off`.") return p.reply(ctx, "Use `!holdem tips on` or `!holdem tips off`.")
} }
} }
@@ -249,25 +261,25 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
// Check if already seated. // Check if already seated.
if game.playerByUserID(ctx.Sender) != nil { if game.playerByUserID(ctx.Sender) != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're already at the table.") return p.reply(ctx, "You're already at the table.")
} }
// Max 9 players. // Max 9 players.
if len(game.Players) >= 9 { if len(game.Players) >= 9 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Table is full (9 players max).") return p.reply(ctx, "Table is full (9 players max).")
} }
// Check balance. // Check balance.
balance := p.euro.GetBalance(ctx.Sender) balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(p.cfg.MinBuyin) { if balance < float64(p.cfg.MinBuyin) {
return p.SendReply(ctx.RoomID, ctx.EventID, return p.reply(ctx,
fmt.Sprintf("Minimum buy-in is €%d. Your balance: €%.0f.", p.cfg.MinBuyin, balance)) fmt.Sprintf("Minimum buy-in is €%d. Your balance: €%.0f.", p.cfg.MinBuyin, balance))
} }
// Resolve DM room. // Resolve DM room.
dmRoom, err := p.GetDMRoom(ctx.Sender) dmRoom, err := p.GetDMRoom(ctx.Sender)
if err != nil { if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't open a DM with you. Check your privacy settings.") return p.reply(ctx, "Couldn't open a DM with you. Check your privacy settings.")
} }
// Get display name. // Get display name.
@@ -280,19 +292,19 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
amountStr = strings.TrimPrefix(amountStr, "€") amountStr = strings.TrimPrefix(amountStr, "€")
requested, err := strconv.ParseInt(amountStr, 10, 64) requested, err := strconv.ParseInt(amountStr, 10, 64)
if err != nil || requested <= 0 { if err != nil || requested <= 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, return p.reply(ctx,
fmt.Sprintf("Usage: `!holdem join [amount]` — amount between €%d and €%d.", p.cfg.MinBuyin, p.cfg.MaxBuyin)) fmt.Sprintf("Usage: `!holdem join [amount]` — amount between €%d and €%d.", p.cfg.MinBuyin, p.cfg.MaxBuyin))
} }
if requested < p.cfg.MinBuyin { if requested < p.cfg.MinBuyin {
return p.SendReply(ctx.RoomID, ctx.EventID, return p.reply(ctx,
fmt.Sprintf("Minimum buy-in is €%d.", p.cfg.MinBuyin)) fmt.Sprintf("Minimum buy-in is €%d.", p.cfg.MinBuyin))
} }
if requested > p.cfg.MaxBuyin { if requested > p.cfg.MaxBuyin {
return p.SendReply(ctx.RoomID, ctx.EventID, return p.reply(ctx,
fmt.Sprintf("Maximum buy-in is €%d.", p.cfg.MaxBuyin)) fmt.Sprintf("Maximum buy-in is €%d.", p.cfg.MaxBuyin))
} }
if requested > int64(balance) { if requested > int64(balance) {
return p.SendReply(ctx.RoomID, ctx.EventID, return p.reply(ctx,
fmt.Sprintf("You only have €%.0f.", balance)) fmt.Sprintf("You only have €%.0f.", balance))
} }
buyin = requested buyin = requested
@@ -305,7 +317,7 @@ func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
// Debit buy-in immediately to prevent double-spending across tables. // Debit buy-in immediately to prevent double-spending across tables.
if !p.euro.Debit(ctx.Sender, float64(buyin), "holdem_buyin") { if !p.euro.Debit(ctx.Sender, float64(buyin), "holdem_buyin") {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reserve buy-in.") return p.reply(ctx, "Failed to reserve buy-in.")
} }
state := PlayerActive state := PlayerActive
@@ -343,12 +355,12 @@ func (p *HoldemPlugin) handleLeave(ctx MessageContext) error {
game := p.games[ctx.RoomID] game := p.games[ctx.RoomID]
if game == nil { if game == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No active game here.") return p.reply(ctx, "No active game here.")
} }
player := game.playerByUserID(ctx.Sender) player := game.playerByUserID(ctx.Sender)
if player == nil { if player == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at the table.") return p.reply(ctx, "You're not at the table.")
} }
if !game.HandInProgress || player.SittingOut { if !game.HandInProgress || player.SittingOut {
@@ -401,11 +413,11 @@ func (p *HoldemPlugin) handleStart(ctx MessageContext) error {
game := p.games[ctx.RoomID] game := p.games[ctx.RoomID]
if game == nil { if game == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No players seated. Use `!holdem join` first.") return p.reply(ctx, "No players seated. Use `!holdem join` first.")
} }
if game.HandInProgress { if game.HandInProgress {
return p.SendReply(ctx.RoomID, ctx.EventID, "A hand is already in progress.") return p.reply(ctx, "A hand is already in progress.")
} }
// Count non-sitting-out players. // Count non-sitting-out players.
@@ -421,7 +433,7 @@ func (p *HoldemPlugin) handleStart(ctx MessageContext) error {
if ready == 1 { if ready == 1 {
hint = " Use `!holdem addbot` to add an AI opponent." hint = " Use `!holdem addbot` to add an AI opponent."
} }
return p.SendReply(ctx.RoomID, ctx.EventID, "Need at least 2 players to start."+hint) return p.reply(ctx, "Need at least 2 players to start."+hint)
} }
p.startHand(game) p.startHand(game)
@@ -431,13 +443,13 @@ func (p *HoldemPlugin) handleStart(ctx MessageContext) error {
func (p *HoldemPlugin) handleRaise(ctx MessageContext, args string) error { func (p *HoldemPlugin) handleRaise(ctx MessageContext, args string) error {
parts := strings.Fields(args) parts := strings.Fields(args)
if len(parts) < 2 { if len(parts) < 2 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!holdem raise <amount>`") return p.reply(ctx, "Usage: `!holdem raise <amount>`")
} }
amtStr := strings.TrimPrefix(parts[1], "€") amtStr := strings.TrimPrefix(parts[1], "€")
amount, err := strconv.ParseInt(amtStr, 10, 64) amount, err := strconv.ParseInt(amtStr, 10, 64)
if err != nil || amount <= 0 { if err != nil || amount <= 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Invalid amount. Usage: `!holdem raise <amount>`") return p.reply(ctx, "Invalid amount. Usage: `!holdem raise <amount>`")
} }
return p.handleAction(ctx, "raise", amount) return p.handleAction(ctx, "raise", amount)
@@ -449,21 +461,21 @@ func (p *HoldemPlugin) handleAction(ctx MessageContext, action string, amount in
game := p.games[ctx.RoomID] game := p.games[ctx.RoomID]
if game == nil || !game.HandInProgress { if game == nil || !game.HandInProgress {
return p.SendReply(ctx.RoomID, ctx.EventID, "No hand in progress.") return p.reply(ctx, "No hand in progress.")
} }
seatIdx := game.playerIdx(ctx.Sender) seatIdx := game.playerIdx(ctx.Sender)
if seatIdx < 0 { if seatIdx < 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at this table.") return p.reply(ctx, "You're not at this table.")
} }
if seatIdx != game.ActionIdx { if seatIdx != game.ActionIdx {
return p.SendReply(ctx.RoomID, ctx.EventID, "It's not your turn.") return p.reply(ctx, "It's not your turn.")
} }
player := game.Players[seatIdx] player := game.Players[seatIdx]
if player.State != PlayerActive { if player.State != PlayerActive {
return p.SendReply(ctx.RoomID, ctx.EventID, "You can't act right now.") return p.reply(ctx, "You can't act right now.")
} }
var result ActionResult var result ActionResult
@@ -483,7 +495,7 @@ func (p *HoldemPlugin) handleAction(ctx MessageContext, action string, amount in
} }
if errMsg != "" { if errMsg != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, errMsg) return p.reply(ctx, errMsg)
} }
// Reset action timer. // Reset action timer.
@@ -520,12 +532,12 @@ func (p *HoldemPlugin) handleStatus(ctx MessageContext) error {
game := p.games[ctx.RoomID] game := p.games[ctx.RoomID]
if game == nil { if game == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No active game here.") return p.reply(ctx, "No active game here.")
} }
seatIdx := game.playerIdx(ctx.Sender) seatIdx := game.playerIdx(ctx.Sender)
if seatIdx < 0 { if seatIdx < 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at this table.") return p.reply(ctx, "You're not at this table.")
} }
player := game.Players[seatIdx] player := game.Players[seatIdx]
@@ -556,12 +568,12 @@ func (p *HoldemPlugin) handleAddBot(ctx MessageContext) error {
// Check if NPC already exists. // Check if NPC already exists.
for _, pl := range game.Players { for _, pl := range game.Players {
if pl.IsNPC { if pl.IsNPC {
return p.SendReply(ctx.RoomID, ctx.EventID, "An AI opponent is already at the table.") return p.reply(ctx, "An AI opponent is already at the table.")
} }
} }
if len(game.Players) >= 9 { if len(game.Players) >= 9 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Table is full.") return p.reply(ctx, "Table is full.")
} }
// Get NPC house balance. // Get NPC house balance.
@@ -827,9 +839,10 @@ func (p *HoldemPlugin) doShowdown(game *HoldemGame) {
} }
} }
// Post end announcement to room. // Post end announcement to room and DM each player.
endAnn := renderEndAnnouncement(results, game) endAnn := renderEndAnnouncement(results, game)
p.SendMessage(game.RoomID, endAnn) p.SendMessage(game.RoomID, endAnn)
p.broadcastDM(game, endAnn)
// Settle balances. // Settle balances.
settleNetDeltas(game, p.euro) settleNetDeltas(game, p.euro)
@@ -862,6 +875,7 @@ func (p *HoldemPlugin) finishHand(game *HoldemGame) {
ann, winnerID := awardPotToLastPlayer(game) ann, winnerID := awardPotToLastPlayer(game)
if ann != "" { if ann != "" {
p.SendMessage(game.RoomID, ann) p.SendMessage(game.RoomID, ann)
p.broadcastDM(game, ann)
} }
// Track bot defeats (if NPC won by everyone folding). // Track bot defeats (if NPC won by everyone folding).
@@ -967,6 +981,7 @@ func (p *HoldemPlugin) sendTurnNotifications(game *HoldemGame) {
} }
func (p *HoldemPlugin) broadcastDM(game *HoldemGame, msg string) { func (p *HoldemPlugin) broadcastDM(game *HoldemGame, msg string) {
first := true
for _, pl := range game.Players { for _, pl := range game.Players {
if pl.IsNPC || pl.State == PlayerSatOut { if pl.IsNPC || pl.State == PlayerSatOut {
continue continue
@@ -977,6 +992,11 @@ func (p *HoldemPlugin) broadcastDM(game *HoldemGame, msg string) {
if pl.DMRoomID == game.RoomID { if pl.DMRoomID == game.RoomID {
continue continue
} }
if !first {
// Jitter 150400ms between sends to avoid bursts on the Matrix server.
time.Sleep(150*time.Millisecond + time.Duration(rand.IntN(250))*time.Millisecond)
}
first = false
p.SendMessage(pl.DMRoomID, msg) p.SendMessage(pl.DMRoomID, msg)
} }
} }