diff --git a/src/boost/boost.go b/src/boost/boost.go index 42cab10f..fbac6b08 100644 --- a/src/boost/boost.go +++ b/src/boost/boost.go @@ -1492,6 +1492,9 @@ func RemoveFarmerByMention(client dc.Client, guildID string, channelID string, o } } + sinkChanged := false + creatorChanged := false + // If the farmer is on the waitlist, remove them from it removalIndex := slices.Index(contract.WaitlistBoosters, userID) if removalIndex != -1 { @@ -1525,8 +1528,6 @@ func RemoveFarmerByMention(client dc.Client, guildID string, channelID string, o } } - sinkChanged := false - // Remove the booster from the contract if userID == contract.Banker.BoostingSinkUserID { sinkChanged = true @@ -1580,7 +1581,12 @@ func RemoveFarmerByMention(client dc.Client, guildID string, channelID string, o } } - if userID == contract.CreatorID[0] { + oldCreator := "" + if len(contract.CreatorID) > 0 { + oldCreator = contract.CreatorID[0] + } + + if userID == oldCreator { // Reassign CreatorID to the Bot, then if there's a non-guest, make them the coordinator contract.CreatorID[0] = config.DiscordAppID for _, el := range contract.Order { @@ -1591,6 +1597,9 @@ func RemoveFarmerByMention(client dc.Client, guildID string, channelID string, o } } } + if contract.CreatorID[0] != oldCreator { + creatorChanged = true + } } if contract.State != ContractStateSignup { @@ -1642,7 +1651,10 @@ func RemoveFarmerByMention(client dc.Client, guildID string, channelID string, o } } - redrawSignup = (contract.State == ContractStateSignup) && (previousBoosters == contract.CoopSize || len(contract.Boosters) == contract.CoopSize || contract.CreatorID[0] == config.DiscordAppID) + fullnessChanged := (previousBoosters == contract.CoopSize) != (len(contract.Boosters) == contract.CoopSize) + emptyChanged := (previousBoosters == 0) != (len(contract.Boosters) == 0) + + redrawSignup = (contract.State == ContractStateSignup) && (fullnessChanged || creatorChanged || sinkChanged || emptyChanged) refreshBoostListMessage(client, contract, redrawSignup) CheckAndPublishAMQPBoosterChange(contract, userID, boosterNick, "booster_remove") diff --git a/src/boost/contract_test.go b/src/boost/contract_test.go index 8f0d4d71..970f00ff 100644 --- a/src/boost/contract_test.go +++ b/src/boost/contract_test.go @@ -1192,8 +1192,8 @@ func TestRemoveFarmerByMention_CollapsesEditsWithWaitlist(t *testing.T) { if listEdits != 1 { t.Errorf("expected exactly 1 list message edit, got %d", listEdits) } - if rxEdits > 1 { - t.Errorf("expected at most 1 reaction message edit, got %d", rxEdits) + if rxEdits != 0 { + t.Errorf("expected 0 reaction message edits when waitlist keeps contract full, got %d", rxEdits) } } @@ -1255,3 +1255,117 @@ func TestRemoveFarmerByMention_MultipleLocationsNoN2(t *testing.T) { t.Errorf("expected exactly 1 edit for channel 2, got %d", ch2Edits) } } + +func TestRemoveFarmerByMention_FullToNotFullEditsSignup(t *testing.T) { + client := dctest.New(). + WithGuild("guild1", "Guild 1"). + WithChannel("channel1", "guild1", "contract-channel"). + WithUser("100000000000000001", "farmer1", "Farmer One"). + WithUser("100000000000000002", "farmer2", "Farmer Two") + + contract := &Contract{ + ContractHash: "test-hash-remove-fulltonotfull", + ContractID: "test-contract", + CoopID: "test-coop", + CoopSize: 2, + State: ContractStateSignup, + BoostOrder: ContractOrderSignup, + CreatorID: []string{"100000000000000001"}, + Order: []string{"100000000000000001", "100000000000000002"}, + Boosters: map[string]*Booster{ + "100000000000000001": {UserID: "100000000000000001", Name: "Farmer One", Nick: "farmer1"}, + "100000000000000002": {UserID: "100000000000000002", Name: "Farmer Two", Nick: "farmer2"}, + }, + Location: []*LocationData{ + {GuildID: "guild1", ChannelID: "channel1", ListMsgID: "msg-list-1", ReactionID: "msg-rx-1"}, + }, + } + Contracts[contract.ContractHash] = contract + defer delete(Contracts, contract.ContractHash) + + client.Calls = nil + + err := RemoveFarmerByMention(client, "guild1", "channel1", "100000000000000002", "<@100000000000000002>") + if err != nil { + t.Fatalf("unexpected error removing farmer: %v", err) + } + + listEdits := 0 + rxEdits := 0 + for _, call := range client.Calls { + if call.Method == "EditMessage" { + if len(call.Args) > 1 { + switch call.Args[1] { + case "msg-list-1": + listEdits++ + case "msg-rx-1": + rxEdits++ + } + } + } + } + + if listEdits != 1 { + t.Errorf("expected exactly 1 list message edit, got %d", listEdits) + } + if rxEdits != 1 { + t.Errorf("expected exactly 1 reaction message edit when transitioning from full to not full, got %d", rxEdits) + } +} + +func TestRemoveFarmerByMention_NonFullDoesNotEditReaction(t *testing.T) { + client := dctest.New(). + WithGuild("guild1", "Guild 1"). + WithChannel("channel1", "guild1", "contract-channel"). + WithUser("100000000000000001", "farmer1", "Farmer One"). + WithUser("100000000000000002", "farmer2", "Farmer Two") + + contract := &Contract{ + ContractHash: "test-hash-remove-nonfull", + ContractID: "test-contract", + CoopID: "test-coop", + CoopSize: 5, + State: ContractStateSignup, + BoostOrder: ContractOrderSignup, + CreatorID: []string{"100000000000000001"}, + Order: []string{"100000000000000001", "100000000000000002"}, + Boosters: map[string]*Booster{ + "100000000000000001": {UserID: "100000000000000001", Name: "Farmer One", Nick: "farmer1"}, + "100000000000000002": {UserID: "100000000000000002", Name: "Farmer Two", Nick: "farmer2"}, + }, + Location: []*LocationData{ + {GuildID: "guild1", ChannelID: "channel1", ListMsgID: "msg-list-1", ReactionID: "msg-rx-1"}, + }, + } + Contracts[contract.ContractHash] = contract + defer delete(Contracts, contract.ContractHash) + + client.Calls = nil + + err := RemoveFarmerByMention(client, "guild1", "channel1", "100000000000000002", "<@100000000000000002>") + if err != nil { + t.Fatalf("unexpected error removing farmer: %v", err) + } + + listEdits := 0 + rxEdits := 0 + for _, call := range client.Calls { + if call.Method == "EditMessage" { + if len(call.Args) > 1 { + switch call.Args[1] { + case "msg-list-1": + listEdits++ + case "msg-rx-1": + rxEdits++ + } + } + } + } + + if listEdits != 1 { + t.Errorf("expected exactly 1 list message edit, got %d", listEdits) + } + if rxEdits != 0 { + t.Errorf("expected 0 reaction message edits when non-full contract has a member leave, got %d", rxEdits) + } +} diff --git a/src/dc/bot.go b/src/dc/bot.go index 4a2fae07..477a0ce8 100644 --- a/src/dc/bot.go +++ b/src/dc/bot.go @@ -182,6 +182,9 @@ func (b *Bot) Open() error { // Close disconnects from the gateway. disgo's shutdown reports no error, so // the error return exists only to keep the facade's shape. func (b *Bot) Close() error { + if b.client != nil && b.client.debouncer != nil { + b.client.debouncer.Flush() + } b.gateway.Close(context.Background()) return nil } diff --git a/src/dc/client_disgo.go b/src/dc/client_disgo.go index e0ba26b5..a937e490 100644 --- a/src/dc/client_disgo.go +++ b/src/dc/client_disgo.go @@ -19,12 +19,15 @@ import ( // Discord and from saved contract data, so a bad one is data to report, not a // programming mistake to crash on. type disgoClient struct { - bot *bot.Client + bot *bot.Client + debouncer *MessageEditDebouncer } // newDisgoClient wraps a live disgo client as a Client. func newDisgoClient(b *bot.Client) *disgoClient { - return &disgoClient{bot: b} + c := &disgoClient{bot: b} + c.debouncer = NewMessageEditDebouncer(DefaultEditDebounceWindow, c.editMessageDirect) + return c } // IsSnowflake reports whether s is a valid Discord snowflake ID. @@ -68,8 +71,8 @@ func (c *disgoClient) SendMessage(channelID string, m Message) (*MessageRef, err return messageRefFrom(msg), nil } -// EditMessage replaces the content of an existing message. -func (c *disgoClient) EditMessage(channelID, messageID string, m Message) (*MessageRef, error) { +// editMessageDirect performs the immediate Discord REST API call to update a message. +func (c *disgoClient) editMessageDirect(channelID, messageID string, m Message) (*MessageRef, error) { ids, err := parseIDs(channelID, messageID) if err != nil { return nil, err @@ -81,8 +84,22 @@ func (c *disgoClient) EditMessage(channelID, messageID string, m Message) (*Mess return messageRefFrom(msg), nil } -// DeleteMessage removes a message. +// EditMessage replaces the content of an existing message with debouncing. +func (c *disgoClient) EditMessage(channelID, messageID string, m Message) (*MessageRef, error) { + if _, err := parseIDs(channelID, messageID); err != nil { + return nil, err + } + if c.debouncer != nil { + return c.debouncer.Edit(channelID, messageID, m) + } + return c.editMessageDirect(channelID, messageID, m) +} + +// DeleteMessage removes a message and cancels any pending debounced edit. func (c *disgoClient) DeleteMessage(channelID, messageID string) error { + if c.debouncer != nil { + c.debouncer.Cancel(channelID, messageID) + } ids, err := parseIDs(channelID, messageID) if err != nil { return err diff --git a/src/dc/debounced_client.go b/src/dc/debounced_client.go new file mode 100644 index 00000000..6f4c48ff --- /dev/null +++ b/src/dc/debounced_client.go @@ -0,0 +1,121 @@ +package dc + +import ( + "sync" + "time" +) + +// DefaultEditDebounceWindow is the duration rapid edits to the same message are debounced. +const DefaultEditDebounceWindow = 500 * time.Millisecond + +type pendingEdit struct { + channelID string + messageID string + msg Message + timer *time.Timer +} + +// MessageEditDebouncer coalesces and delays rapid consecutive edits to the same +// message, executing only the latest payload when the debounce window settles. +type MessageEditDebouncer struct { + mu sync.Mutex + window time.Duration + pending map[string]*pendingEdit + executeFn func(channelID, messageID string, m Message) (*MessageRef, error) +} + +// NewMessageEditDebouncer creates a new debouncer with the given window and execution function. +func NewMessageEditDebouncer(window time.Duration, executeFn func(channelID, messageID string, m Message) (*MessageRef, error)) *MessageEditDebouncer { + if window <= 0 { + window = DefaultEditDebounceWindow + } + return &MessageEditDebouncer{ + window: window, + pending: make(map[string]*pendingEdit), + executeFn: executeFn, + } +} + +// Edit schedules or updates a debounced message edit for the given channelID and messageID. +func (d *MessageEditDebouncer) Edit(channelID, messageID string, m Message) (*MessageRef, error) { + key := channelID + "/" + messageID + + d.mu.Lock() + defer d.mu.Unlock() + + if entry, exists := d.pending[key]; exists { + entry.msg = m + if entry.timer != nil { + entry.timer.Stop() + } + entry.timer = time.AfterFunc(d.window, func() { + d.flushKey(key) + }) + } else { + entry := &pendingEdit{ + channelID: channelID, + messageID: messageID, + msg: m, + } + entry.timer = time.AfterFunc(d.window, func() { + d.flushKey(key) + }) + d.pending[key] = entry + } + + return &MessageRef{ + ID: messageID, + ChannelID: channelID, + }, nil +} + +// Cancel removes and stops any pending debounced edit for the given message. +func (d *MessageEditDebouncer) Cancel(channelID, messageID string) { + key := channelID + "/" + messageID + + d.mu.Lock() + defer d.mu.Unlock() + + if entry, exists := d.pending[key]; exists { + if entry.timer != nil { + entry.timer.Stop() + } + delete(d.pending, key) + } +} + +// flushKey executes a single pending edit for a specific message key. +func (d *MessageEditDebouncer) flushKey(key string) { + d.mu.Lock() + entry, exists := d.pending[key] + if !exists { + d.mu.Unlock() + return + } + delete(d.pending, key) + d.mu.Unlock() + + if entry != nil && d.executeFn != nil { + _, _ = d.executeFn(entry.channelID, entry.messageID, entry.msg) + } +} + +// Flush immediately dispatches all currently pending message edits. +func (d *MessageEditDebouncer) Flush() { + d.mu.Lock() + entries := make([]*pendingEdit, 0, len(d.pending)) + for key, entry := range d.pending { + if entry.timer != nil { + entry.timer.Stop() + } + entries = append(entries, entry) + delete(d.pending, key) + } + d.mu.Unlock() + + if d.executeFn != nil { + for _, entry := range entries { + _, _ = d.executeFn(entry.channelID, entry.messageID, entry.msg) + } + } +} diff --git a/src/dc/debounced_client_test.go b/src/dc/debounced_client_test.go new file mode 100644 index 00000000..ca474457 --- /dev/null +++ b/src/dc/debounced_client_test.go @@ -0,0 +1,138 @@ +package dc + +import ( + "sync" + "testing" + "time" +) + +func TestMessageEditDebouncer_CoalescesRapidEdits(t *testing.T) { + var mu sync.Mutex + calls := make([]Message, 0) + + execFn := func(channelID, messageID string, m Message) (*MessageRef, error) { + mu.Lock() + defer mu.Unlock() + calls = append(calls, m) + return &MessageRef{ID: messageID, ChannelID: channelID}, nil + } + + debouncer := NewMessageEditDebouncer(50*time.Millisecond, execFn) + + // Send 5 rapid edits in a row + for i := 1; i <= 5; i++ { + _, err := debouncer.Edit("ch1", "msg1", Message{Content: "edit-" + string(rune('0'+i))}) + if err != nil { + t.Fatalf("unexpected edit error: %v", err) + } + time.Sleep(10 * time.Millisecond) + } + + // Before window expires, no execution should have happened yet + mu.Lock() + if len(calls) != 0 { + t.Fatalf("expected 0 calls before debounce settles, got %d", len(calls)) + } + mu.Unlock() + + // Wait for debounce window to settle (50ms after last edit) + time.Sleep(80 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if len(calls) != 1 { + t.Fatalf("expected exactly 1 coalesced call, got %d", len(calls)) + } + if calls[0].Content != "edit-5" { + t.Errorf("expected final content %q, got %q", "edit-5", calls[0].Content) + } +} + +func TestMessageEditDebouncer_IndependentMessages(t *testing.T) { + var mu sync.Mutex + calls := make(map[string]string) + + execFn := func(channelID, messageID string, m Message) (*MessageRef, error) { + mu.Lock() + defer mu.Unlock() + calls[channelID+"/"+messageID] = m.Content + return &MessageRef{ID: messageID, ChannelID: channelID}, nil + } + + debouncer := NewMessageEditDebouncer(40*time.Millisecond, execFn) + + _, _ = debouncer.Edit("ch1", "msg1", Message{Content: "msg1-content"}) + _, _ = debouncer.Edit("ch1", "msg2", Message{Content: "msg2-content"}) + _, _ = debouncer.Edit("ch2", "msg1", Message{Content: "ch2-msg1-content"}) + + time.Sleep(70 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if len(calls) != 3 { + t.Fatalf("expected 3 calls for 3 independent messages, got %d", len(calls)) + } + if calls["ch1/msg1"] != "msg1-content" { + t.Errorf("expected ch1/msg1 to be %q, got %q", "msg1-content", calls["ch1/msg1"]) + } + if calls["ch1/msg2"] != "msg2-content" { + t.Errorf("expected ch1/msg2 to be %q, got %q", "msg2-content", calls["ch1/msg2"]) + } + if calls["ch2/msg1"] != "ch2-msg1-content" { + t.Errorf("expected ch2/msg1 to be %q, got %q", "ch2-msg1-content", calls["ch2/msg1"]) + } +} + +func TestMessageEditDebouncer_Cancel(t *testing.T) { + var mu sync.Mutex + calls := 0 + + execFn := func(channelID, messageID string, m Message) (*MessageRef, error) { + mu.Lock() + defer mu.Unlock() + calls++ + return &MessageRef{ID: messageID, ChannelID: channelID}, nil + } + + debouncer := NewMessageEditDebouncer(50*time.Millisecond, execFn) + + _, _ = debouncer.Edit("ch1", "msg1", Message{Content: "will be cancelled"}) + debouncer.Cancel("ch1", "msg1") + + time.Sleep(80 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + if calls != 0 { + t.Errorf("expected 0 calls after cancel, got %d", calls) + } +} + +func TestMessageEditDebouncer_Flush(t *testing.T) { + var mu sync.Mutex + calls := make(map[string]string) + + execFn := func(channelID, messageID string, m Message) (*MessageRef, error) { + mu.Lock() + defer mu.Unlock() + calls[channelID+"/"+messageID] = m.Content + return &MessageRef{ID: messageID, ChannelID: channelID}, nil + } + + debouncer := NewMessageEditDebouncer(5*time.Second, execFn) + + _, _ = debouncer.Edit("ch1", "msg1", Message{Content: "flushed-1"}) + _, _ = debouncer.Edit("ch1", "msg2", Message{Content: "flushed-2"}) + + // Immediate flush before the 5s window + debouncer.Flush() + + mu.Lock() + defer mu.Unlock() + if len(calls) != 2 { + t.Fatalf("expected 2 flushed calls, got %d", len(calls)) + } + if calls["ch1/msg1"] != "flushed-1" || calls["ch1/msg2"] != "flushed-2" { + t.Errorf("unexpected flushed content: %v", calls) + } +}