mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 00:52:40 +00:00
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:
@@ -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 150–400ms 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user