feat(misc): hold the wheel spin with a placeholder message

A remote wheel render takes several seconds, during which /wheelofnames
looked unresponsive. Post "Spinning..." first, then let the result take
its place: the GIF replaces it (send-then-delete, since Telegram cannot
edit text into media) and any render or upload failure edits the same
message into the plain text winner. A rejected edit still falls back to a
fresh reply so the chat never stays stuck on "Spinning...".

No placeholder when no renderer is configured — the winner reply is
already immediate there and would only flash.

Adds SendText/EditText/DeleteMessage to chathelper; Reply now delegates
to SendText.
This commit is contained in:
tiennm99 committed 2026-08-27 14:42:07 +07:00
1 parent 26a5c41d8e
commit eebd034fbc
4 files changed
+160 -19

No files matched your search

+6
View File
@@ -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).
+69 -15
View File
@@ -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)
}
}
+42 -2
View File
@@ -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 <option,...>"
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: <span class="tg-spoiler">` + html.EscapeString(result) + `</span>`
+43 -2
View File
@@ -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
}