From 45f28afe59f6e610c257ec87df25581d07741ce3 Mon Sep 17 00:00:00 2001 From: Michael McCarty Date: Fri, 11 Sep 2026 23:42:01 -0700 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20fix:=20rollback=20run=20time=20?= =?UTF-8?q?and=20auto-unarchive=20threads=20on=20fail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/boost/boost_button_reactions.go | 13 ++++++++ src/boost/chicken_run_test.go | 48 +++++++++++++++++++++++++++++ src/dc/client_disgo.go | 13 +++++++- src/dc/errors.go | 11 +++++++ src/dc/errors_test.go | 13 ++++++++ 5 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/boost/boost_button_reactions.go b/src/boost/boost_button_reactions.go index 343d0dc2..ac246fc4 100644 --- a/src/boost/boost_button_reactions.go +++ b/src/boost/boost_button_reactions.go @@ -394,6 +394,7 @@ func buttonReactionRunChickens(client dc.Client, contract *Contract, cUserID str go func() { client := client + sendSuccess := false for _, location := range contract.Location { contract.mutex.Lock() components, _ := buildCRMessageComponents(contract, location.RoleMention) @@ -418,6 +419,7 @@ func buttonReactionRunChickens(client dc.Client, contract *Contract, cUserID str continue } + sendSuccess = true contract.mutex.Lock() setChickenRunMessageID(contract, location.ChannelID, newMsg.ID) contract.CRNoticeCount++ @@ -438,6 +440,17 @@ func buttonReactionRunChickens(client dc.Client, contract *Contract, cUserID str } } } + + if !sendSuccess { + // Rollback RunChickensTime so the user isn't permanently locked out of retrying + contract.mutex.Lock() + if booster := contract.Boosters[userID]; booster != nil { + booster.RunChickensTime = time.Time{} + } + contract.mutex.Unlock() + } else { + saveData(contract.ContractHash) + } }() str = "You've asked for Chicken Runs, now what...\n...\nMaybe.. check on your habs and gusset?\nI'm sure you've already forced a game sync so no need to remind about that." return true, str diff --git a/src/boost/chicken_run_test.go b/src/boost/chicken_run_test.go index ff23319b..54d71333 100644 --- a/src/boost/chicken_run_test.go +++ b/src/boost/chicken_run_test.go @@ -1,12 +1,14 @@ package boost import ( + "errors" "slices" "strings" "testing" "time" "github.com/mkmccarty/TokenTimeBoostBot/src/dc" + "github.com/mkmccarty/TokenTimeBoostBot/src/dc/dctest" ) func TestRanCoopAndBuildChickenRunLists(t *testing.T) { @@ -199,3 +201,49 @@ func TestBuildCRMessageComponentsCompleted(t *testing.T) { t.Errorf("expected completion message with Player1, got %q", textDisplay.Content) } } + +func TestButtonReactionRunChickensSendFailureRollback(t *testing.T) { + c := &Contract{ + ContractHash: "test-hash-rollback", + Order: []string{"user1", "user2"}, + Location: []*LocationData{ + { + GuildID: "guild1", + ChannelID: "channel1", + }, + }, + CRMessageIDs: make(map[string]string), + Boosters: map[string]*Booster{ + "user1": { + UserID: "user1", + Nick: "Player1", + BoostState: BoostStateBoosted, + }, + "user2": { + UserID: "user2", + Nick: "Player2", + BoostState: BoostStateBoosted, + }, + }, + } + + client := dctest.New() + client.SendErr = errors.New("failed to send CR message") + + // Trigger buttonReactionRunChickens + ok, _ := buttonReactionRunChickens(client, c, "user1") + if !ok { + t.Fatalf("expected buttonReactionRunChickens to return true") + } + + // Wait for goroutine to finish + time.Sleep(50 * time.Millisecond) + + c.mutex.Lock() + runTime := c.Boosters["user1"].RunChickensTime + c.mutex.Unlock() + + if !runTime.IsZero() { + t.Errorf("expected RunChickensTime to be rolled back to zero on send error, got %v", runTime) + } +} diff --git a/src/dc/client_disgo.go b/src/dc/client_disgo.go index c620d367..e0ba26b5 100644 --- a/src/dc/client_disgo.go +++ b/src/dc/client_disgo.go @@ -2,10 +2,12 @@ package dc import ( "context" + "errors" "github.com/disgoorg/disgo/bot" "github.com/disgoorg/disgo/discord" "github.com/disgoorg/disgo/gateway" + "github.com/disgoorg/disgo/rest" "github.com/disgoorg/snowflake/v2" ) @@ -52,7 +54,16 @@ func (c *disgoClient) SendMessage(channelID string, m Message) (*MessageRef, err } msg, err := c.bot.Rest.CreateMessage(ids[0], m.toMessageCreate()) if err != nil { - return nil, wrapAPIError(err) + var restErr *rest.Error + if errors.As(err, &restErr) && restErr.Code == ErrCodeThreadArchived { + unarchived := false + if _, updateErr := c.bot.Rest.UpdateChannel(ids[0], discord.GuildThreadUpdate{Archived: &unarchived}); updateErr == nil { + msg, err = c.bot.Rest.CreateMessage(ids[0], m.toMessageCreate()) + } + } + if err != nil { + return nil, wrapAPIError(err) + } } return messageRefFrom(msg), nil } diff --git a/src/dc/errors.go b/src/dc/errors.go index 04fcea77..8715dd9c 100644 --- a/src/dc/errors.go +++ b/src/dc/errors.go @@ -19,6 +19,9 @@ const ( // ErrCodeMissingPermissions means the bot can see the channel but is not // allowed the action it attempted. ErrCodeMissingPermissions = 50013 + // ErrCodeThreadArchived means the thread is archived and must be unarchived + // before messages can be sent to it. + ErrCodeThreadArchived = 50083 ) // APIError is a rejected Discord REST call. Code is Discord's own error code @@ -96,3 +99,11 @@ func IsUnknownChannel(err error) bool { } return apiErr.Code == ErrCodeUnknownChannel || apiErr.StatusCode == 404 } + +// IsThreadArchived reports whether err is Discord refusing a message send +// because the target thread is archived. +func IsThreadArchived(err error) bool { + apiErr, ok := AsAPIError(err) + return ok && apiErr.Code == ErrCodeThreadArchived +} + diff --git a/src/dc/errors_test.go b/src/dc/errors_test.go index bf1e97d5..94bc2b90 100644 --- a/src/dc/errors_test.go +++ b/src/dc/errors_test.go @@ -84,3 +84,16 @@ func TestIsUnknownChannel(t *testing.T) { t.Fatal("missing permissions is not unknown channel") } } + +func TestIsThreadArchived(t *testing.T) { + if !IsThreadArchived(restError(400, ErrCodeThreadArchived, "Thread is archived")) { + t.Fatal("expected thread is archived") + } + if IsThreadArchived(restError(404, ErrCodeUnknownChannel, "Unknown Channel")) { + t.Fatal("unknown channel is not thread archived") + } + if IsThreadArchived(errors.New("nope")) { + t.Fatal("a plain error is not thread archived") + } +} + From c5f66883349f1ec465416a75350f6b7a66fa1bc0 Mon Sep 17 00:00:00 2001 From: Michael McCarty Date: Sat, 12 Sep 2026 07:48:43 -0700 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=A8fix:=20remove=20trailing=20blank?= =?UTF-8?q?=20lines=20in=20errors=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dc/errors.go | 1 - src/dc/errors_test.go | 1 - 2 files changed, 2 deletions(-) diff --git a/src/dc/errors.go b/src/dc/errors.go index 8715dd9c..0b4298b3 100644 --- a/src/dc/errors.go +++ b/src/dc/errors.go @@ -106,4 +106,3 @@ func IsThreadArchived(err error) bool { apiErr, ok := AsAPIError(err) return ok && apiErr.Code == ErrCodeThreadArchived } - diff --git a/src/dc/errors_test.go b/src/dc/errors_test.go index 94bc2b90..605ee03e 100644 --- a/src/dc/errors_test.go +++ b/src/dc/errors_test.go @@ -96,4 +96,3 @@ func TestIsThreadArchived(t *testing.T) { t.Fatal("a plain error is not thread archived") } } -