mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-10-03 07:13:24 +00:00
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:
1 parent
26a5c41d8e
commit
eebd034fbc
4 files changed
+160
-19
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user