Skip to content
Merged
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
20 changes: 16 additions & 4 deletions src/boost/boost.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
118 changes: 116 additions & 2 deletions src/boost/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}
3 changes: 3 additions & 0 deletions src/dc/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
27 changes: 22 additions & 5 deletions src/dc/client_disgo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
121 changes: 121 additions & 0 deletions src/dc/debounced_client.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading
Loading