diff --git a/docs/deploy-coolify-selfhosted.md b/docs/deploy-coolify-selfhosted.md index 5f07fea..183a85c 100644 --- a/docs/deploy-coolify-selfhosted.md +++ b/docs/deploy-coolify-selfhosted.md @@ -72,6 +72,12 @@ service is unset, unavailable, unauthorized, or returns a non-GIF response, `/wheelofnames` falls back to the same plain text winner reply as `/random`. Successful GIF replies include the result behind Telegram spoiler formatting. +When a renderer is configured, the bot posts a `Spinning...` holding message +first, because the render takes several seconds. The GIF then replaces it; a +render or upload failure edits that same message into the plain text winner +instead. With no renderer configured there is no holding message — the winner +reply is immediate. + ## 1. MongoDB Atlas (M0) 1. Create a free **M0** cluster (512 MB — ample for the tiny paper-trading KV). diff --git a/internal/modules/misc/handlers_test.go b/internal/modules/misc/handlers_test.go index 0aa1714..f552de4 100644 --- a/internal/modules/misc/handlers_test.go +++ b/internal/modules/misc/handlers_test.go @@ -214,7 +214,19 @@ func TestWheelOfNames_UsesRemoteAPIWhenConfigured(t *testing.T) { } assertWheelRemoteDefaults(t, got) - call := rb.LastSent() + sent := rb.Sent() + if len(sent) != 3 { + t.Fatalf("calls = %+v, want placeholder, sendAnimation, deleteMessage", sent) + } + if sent[0].Method != "sendMessage" || sent[0].Text() != wheelPlaceholder { + t.Fatalf("first call = %+v, want placeholder %q", sent[0], wheelPlaceholder) + } + // The placeholder cannot be edited into media, so it is dropped once the + // animation lands and the animation itself carries the result. + if sent[2].Method != "deleteMessage" || sent[2].Form["message_id"] != "1" { + t.Fatalf("last call = %+v, want deleteMessage of the placeholder", sent[2]) + } + call := sent[1] if call.Method != "sendAnimation" { t.Fatalf("method = %q, want sendAnimation", call.Method) } @@ -252,19 +264,52 @@ func TestWheelOfNames_RemoteFailureFallsBackToRandomReply(t *testing.T) { if calls != 1 { t.Fatalf("remote calls = %d, want 1", calls) } + sent := rb.Sent() + if len(sent) != 2 { + t.Fatalf("calls = %+v, want placeholder then in-place edit", sent) + } + if sent[0].Method != "sendMessage" || sent[0].Text() != wheelPlaceholder { + t.Fatalf("first call = %+v, want placeholder %q", sent[0], wheelPlaceholder) + } + if sent[1].Method != "editMessageText" || sent[1].Text() != "Alice" { + t.Fatalf("fallback call = %+v, want editMessageText Alice", sent[1]) + } + if got := sent[1].Form["message_id"]; got != "1" { + t.Fatalf("edited message_id = %q, want the placeholder id 1", got) + } +} + +// A failed edit still has to deliver the winner, so the handler falls back to a +// fresh reply rather than leaving "Spinning..." on screen forever. +func TestWheelOfNames_PlaceholderEditFailureFallsBackToReply(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "no", http.StatusInternalServerError) + })) + defer server.Close() + t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif") + + rb, _ := installMisc(t, 999) + rb.FailMethod("editMessageText", http.StatusInternalServerError, "") + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) + call := rb.LastSent() if call.Method != "sendMessage" || call.Text() != "Alice" { t.Fatalf("fallback call = %+v, want sendMessage Alice", call) } } +// Without a renderer there is nothing to wait for, so the winner must land +// straight away with no placeholder flashing before it. func TestWheelOfNames_NotConfiguredFallsBackToRandomReply(t *testing.T) { rb, _ := installMisc(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) - call := rb.LastSent() - if call.Method != "sendMessage" || call.Text() != "Alice" { - t.Fatalf("fallback call = %+v, want sendMessage Alice", call) + calls := rb.Sent() + if len(calls) != 1 { + t.Fatalf("calls = %+v, want a single winner reply", calls) + } + if calls[0].Method != "sendMessage" || calls[0].Text() != "Alice" { + t.Fatalf("fallback call = %+v, want sendMessage Alice", calls[0]) } } @@ -281,14 +326,19 @@ func TestWheelOfNames_SendAnimationFailureFallsBackToRandomReply(t *testing.T) { rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) calls := rb.Sent() - if len(calls) != 2 { - t.Fatalf("calls = %+v, want sendAnimation then sendMessage", calls) + if len(calls) != 3 { + t.Fatalf("calls = %+v, want placeholder, sendAnimation, in-place edit", calls) } - if calls[0].Method != "sendAnimation" { - t.Fatalf("first method = %q, want sendAnimation", calls[0].Method) + if calls[0].Method != "sendMessage" || calls[0].Text() != wheelPlaceholder { + t.Fatalf("first call = %+v, want placeholder %q", calls[0], wheelPlaceholder) } - if calls[1].Method != "sendMessage" || calls[1].Text() != "Alice" { - t.Fatalf("fallback call = %+v, want sendMessage Alice", calls[1]) + if calls[1].Method != "sendAnimation" { + t.Fatalf("second method = %q, want sendAnimation", calls[1].Method) + } + // The placeholder survives a failed animation precisely so the winner can + // replace it instead of stranding "Spinning..." in the chat. + if calls[2].Method != "editMessageText" || calls[2].Text() != "Alice" { + t.Fatalf("fallback call = %+v, want editMessageText Alice", calls[2]) } } @@ -305,12 +355,16 @@ func TestWheelOfNames_ForwardsMessageThreadID(t *testing.T) { update.Message.MessageThreadID = 42 rb.Bot.ProcessUpdate(context.Background(), update) - call := rb.LastSent() - if call.Method != "sendAnimation" { - t.Fatalf("method = %q, want sendAnimation", call.Method) + for _, call := range rb.Sent() { + switch call.Method { + case "sendMessage", "sendAnimation": + if got := call.Form["message_thread_id"]; got != "42" { + t.Fatalf("%s message_thread_id = %q, want 42", call.Method, got) + } + } } - if got := call.Form["message_thread_id"]; got != "42" { - t.Fatalf("message_thread_id = %q, want 42", got) + if got := rb.Sent()[1].Method; got != "sendAnimation" { + t.Fatalf("second method = %q, want sendAnimation", got) } } diff --git a/internal/modules/misc/wheelofnames_command.go b/internal/modules/misc/wheelofnames_command.go index 0e88cf4..a32e274 100644 --- a/internal/modules/misc/wheelofnames_command.go +++ b/internal/modules/misc/wheelofnames_command.go @@ -35,12 +35,13 @@ func wheelOfNamesCommand() modules.Command { return chathelper.Reply(ctx, b, update.Message, wheelUsage) } winner := pickWheelOption(options) + placeholder := sendWheelPlaceholder(ctx, b, update.Message) animation, err := renderWheelOfNamesAnimation(ctx, options, winner) if err != nil { if !errors.Is(err, errWheelAPINotConfigured) { log.Warn("wheelofnames remote render failed", "err", err) } - return chathelper.Reply(ctx, b, update.Message, options[winner]) + return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, options[winner]) } _, err = b.SendAnimation(ctx, &bot.SendAnimationParams{ ChatID: update.Message.Chat.ID, @@ -57,7 +58,12 @@ func wheelOfNamesCommand() modules.Command { }) if err != nil { log.Warn("wheelofnames send animation failed", "chat", update.Message.Chat.ID, "err", err) - return chathelper.Reply(ctx, b, update.Message, options[winner]) + return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, options[winner]) + } + // The animation carries the result, so the placeholder is retired + // only once it is safely delivered. + if err := chathelper.DeleteMessage(ctx, b, update.Message, placeholder); err != nil { + log.Warn("wheelofnames placeholder delete failed", "chat", update.Message.Chat.ID, "err", err) } return nil }, @@ -66,6 +72,40 @@ func wheelOfNamesCommand() modules.Command { const wheelUsage = "Usage: /wheelofnames " +const wheelPlaceholder = "Spinning..." + +// sendWheelPlaceholder posts the "Spinning..." holding message and returns its +// id, or 0 when none was posted. +// +// It is skipped unless a renderer is configured: without one the winner reply +// is immediate, and a placeholder would only flash. A failed placeholder is +// non-fatal — the spin still resolves, just without the holding message. +func sendWheelPlaceholder(ctx context.Context, b *bot.Bot, msg *models.Message) int { + if _, err := wheelAPIEndpoint(newWheelAPIClientFromEnv().URL); err != nil { + return 0 + } + id, err := chathelper.SendText(ctx, b, msg, wheelPlaceholder) + if err != nil { + log.Warn("wheelofnames placeholder send failed", "chat", msg.Chat.ID, "err", err) + return 0 + } + return id +} + +// replaceWheelPlaceholder resolves the spin in place by editing the holding +// message to the winner, falling back to a fresh reply when there is no +// placeholder to edit or the edit is rejected. +func replaceWheelPlaceholder(ctx context.Context, b *bot.Bot, msg *models.Message, placeholder int, winner string) error { + if placeholder != 0 { + err := chathelper.EditText(ctx, b, msg, placeholder, winner) + if err == nil { + return nil + } + log.Warn("wheelofnames placeholder edit failed", "chat", msg.Chat.ID, "err", err) + } + return chathelper.Reply(ctx, b, msg, winner) +} + func wheelResultCaption(options []string, winner int) string { result := padWheelResultCaption(options, truncateWheelResultCaption(options[winner])) return `Result: ` + html.EscapeString(result) + `` diff --git a/internal/modules/util/chathelper/chathelper.go b/internal/modules/util/chathelper/chathelper.go index db60bb9..bffe9e9 100644 --- a/internal/modules/util/chathelper/chathelper.go +++ b/internal/modules/util/chathelper/chathelper.go @@ -123,14 +123,55 @@ func FetchContext(ctx context.Context) (context.Context, context.CancelFunc) { // message_thread_id to the General topic — that mis-routing is the precise // reason this helper takes the whole message instead of just a chat ID. func Reply(ctx context.Context, b *bot.Bot, msg *models.Message, text string) error { + _, err := SendText(ctx, b, msg, text) + return err +} + +// SendText behaves like Reply but returns the id of the message it sent, so a +// caller that posts a progress placeholder can later edit or delete it with +// EditText or DeleteMessage. Returns 0 when nothing was sent. +func SendText(ctx context.Context, b *bot.Bot, msg *models.Message, text string) (int, error) { if msg == nil { - return nil + return 0, nil } - _, err := b.SendMessage(ctx, &bot.SendMessageParams{ + sent, err := b.SendMessage(ctx, &bot.SendMessageParams{ ChatID: msg.Chat.ID, MessageThreadID: msg.MessageThreadID, Text: text, }) + if err != nil { + return 0, err + } + return sent.ID, nil +} + +// EditText replaces the text of messageID in the chat msg came from. +// +// The chat comes from the inbound message rather than from whatever the send +// call returned, because that is the chat the bot is addressing; the returned +// message only supplies the id. No-op when there is no message to edit. +func EditText(ctx context.Context, b *bot.Bot, msg *models.Message, messageID int, text string) error { + if msg == nil || messageID == 0 { + return nil + } + _, err := b.EditMessageText(ctx, &bot.EditMessageTextParams{ + ChatID: msg.Chat.ID, + MessageID: messageID, + Text: text, + }) + return err +} + +// DeleteMessage removes messageID from the chat msg came from. No-op when +// there is no message to delete. +func DeleteMessage(ctx context.Context, b *bot.Bot, msg *models.Message, messageID int) error { + if msg == nil || messageID == 0 { + return nil + } + _, err := b.DeleteMessage(ctx, &bot.DeleteMessageParams{ + ChatID: msg.Chat.ID, + MessageID: messageID, + }) return err }