diff --git a/README.md b/README.md index 7e86010..75c6c02 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Atlas via long polling and an in-process cron scheduler. | Module | What it does | |---|---| | `util` | `/help`, `/info`, `/stickerid` | -| `misc` | `/ping`, `/ping_stats`, `/wheelofnames`, `/the_answer`, `/trongtruonghop` + `/tth` disclaimer | +| `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/the_answer`, `/trongtruonghop` + `/tth` disclaimer | | `wordle` | Daily Wordle game | | `loldle` | League-of-Legends "guess the champion" | | `lol` | Pro-match schedule + daily push | diff --git a/internal/modules/misc/handlers_test.go b/internal/modules/misc/handlers_test.go index 8d81a2e..7bc9dec 100644 --- a/internal/modules/misc/handlers_test.go +++ b/internal/modules/misc/handlers_test.go @@ -36,6 +36,13 @@ func installMisc(t *testing.T, ownerID int64) (*testutil.RecordingBot, storage.D return rb, store } +func withoutWheelDraftDelay(t *testing.T) { + t.Helper() + prev := wheelDraftFrameDelay + wheelDraftFrameDelay = 0 + t.Cleanup(func() { wheelDraftFrameDelay = prev }) +} + func TestPing_RepliesPongAndWritesStore(t *testing.T) { rb, store := installMisc(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(999, "/ping")) @@ -89,6 +96,48 @@ func TestPingStats_DeniedToNonAdmin(t *testing.T) { } } +func TestRandom_UsageWhenMissingOptions(t *testing.T) { + for _, text := range []string{"/random", "/random , ,"} { + t.Run(text, func(t *testing.T) { + rb, _ := installMisc(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text)) + + if got := rb.LastSent().Text(); got != randomUsage { + t.Errorf("random reply = %q, want usage %q", got, randomUsage) + } + }) + } +} + +func TestRandom_SingleOption(t *testing.T) { + rb, _ := installMisc(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/random Alice")) + + if got := rb.LastSent().Text(); got != "Alice" { + t.Errorf("random reply = %q, want Alice", got) + } +} + +func TestRandom_PicksFromTrimmedOptions(t *testing.T) { + rb, _ := installMisc(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/random Alice, Bob, Carol")) + + got := rb.LastSent().Text() + if got != "Alice" && got != "Bob" && got != "Carol" { + t.Errorf("random reply = %q, want one of Alice/Bob/Carol", got) + } +} + +func TestRandom_IgnoresEmptySegments(t *testing.T) { + rb, _ := installMisc(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/random , Alice , , Bob ,")) + + got := rb.LastSent().Text() + if got != "Alice" && got != "Bob" { + t.Errorf("random reply = %q, want Alice or Bob", got) + } +} + func TestWheelOfNames_UsageWhenMissingOptions(t *testing.T) { for _, text := range []string{"/wheelofnames", "/wheelofnames , ,"} { t.Run(text, func(t *testing.T) { @@ -103,6 +152,7 @@ func TestWheelOfNames_UsageWhenMissingOptions(t *testing.T) { } func TestWheelOfNames_SingleOption(t *testing.T) { + withoutWheelDraftDelay(t) rb, _ := installMisc(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) @@ -112,6 +162,7 @@ func TestWheelOfNames_SingleOption(t *testing.T) { } func TestWheelOfNames_PicksFromTrimmedOptions(t *testing.T) { + withoutWheelDraftDelay(t) rb, _ := installMisc(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice, Bob, Carol")) @@ -122,6 +173,7 @@ func TestWheelOfNames_PicksFromTrimmedOptions(t *testing.T) { } func TestWheelOfNames_IgnoresEmptySegments(t *testing.T) { + withoutWheelDraftDelay(t) rb, _ := installMisc(t, 999) rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames , Alice , , Bob ,")) @@ -131,6 +183,49 @@ func TestWheelOfNames_IgnoresEmptySegments(t *testing.T) { } } +func TestWheelOfNames_StreamsDraftsBeforeFinalInPrivateChat(t *testing.T) { + withoutWheelDraftDelay(t) + rb, _ := installMisc(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) + + calls := rb.Sent() + if len(calls) != 7 { + t.Fatalf("calls = %d, want 6 drafts + final sendMessage: %+v", len(calls), calls) + } + draftID := "" + for i := 0; i < 6; i++ { + if calls[i].Method != "sendMessageDraft" { + t.Fatalf("call %d method = %q, want sendMessageDraft", i, calls[i].Method) + } + if !strings.Contains(calls[i].Text(), "Alice") { + t.Errorf("draft %d text = %q, want to preview Alice", i, calls[i].Text()) + } + if got := calls[i].Form["draft_id"]; got == "" || got == "0" { + t.Fatalf("draft %d id = %q, want non-zero", i, got) + } else if draftID == "" { + draftID = got + } else if got != draftID { + t.Fatalf("draft %d id = %q, want same draft id %q", i, got, draftID) + } + } + if calls[6].Method != "sendMessage" || calls[6].Text() != "Alice" { + t.Fatalf("final call = %+v, want sendMessage Alice", calls[6]) + } +} + +func TestWheelOfNames_GroupSkipsDraftsButSendsFinal(t *testing.T) { + rb, _ := installMisc(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewGroupMessage(-100, 7, "/wheelofnames Alice")) + + calls := rb.Sent() + if len(calls) != 1 { + t.Fatalf("calls = %d, want only final sendMessage in groups: %+v", len(calls), calls) + } + if calls[0].Method != "sendMessage" || calls[0].Text() != "Alice" { + t.Fatalf("group call = %+v, want sendMessage Alice", calls[0]) + } +} + // trongTruongHopUpdate is the inline counterpart of testutil.NewPrivateMessage // for cases that need control over From (username, names). The dispatcher // requires a bot_command entity, so we lift that from the helper API by reusing diff --git a/internal/modules/misc/misc.go b/internal/modules/misc/misc.go index 181d604..955a6aa 100644 --- a/internal/modules/misc/misc.go +++ b/internal/modules/misc/misc.go @@ -1,7 +1,7 @@ // Package misc is a small stub module that proves the framework end-to-end: // /ping (public, exercises KV write), /ping_stats (protected, exercises KV -// read), /wheelofnames (public random picker), /the_answer (private easter -// egg). +// read), /random (public random picker), /wheelofnames (public streaming +// random picker), /the_answer (private easter egg). package misc import ( @@ -9,7 +9,6 @@ import ( "errors" "fmt" "html" - "math/rand/v2" "strings" "time" @@ -33,8 +32,6 @@ const defaultTarget = "VNG" // %s slots: target (escaped), sender mention, sender mention. const trongTruongHopTemplate = "Trong trường hợp nhóm này bị điều tra bởi %s, %s khẳng định không liên quan tới nhóm hoặc những cá nhân khác trong nhóm này. %s không rõ tại sao lại có mặt ở đây vào thời điểm này, có lẽ tài khoản đã được thêm bởi một bên thứ ba." -const wheelOfNamesUsage = "Usage: /wheelofnames , , ..." - // lastPing is the value stored at the `last_ping` key: { at: }. // int64 ms-epoch (not time.Time → RFC3339) keeps the on-disk shape compact // and consistent with every other timestamp field in the bot's KV. @@ -50,6 +47,7 @@ func New(deps modules.Deps) modules.Module { Commands: []modules.Command{ pingCommand(store), pingStatsCommand(store), + randomCommand(), wheelOfNamesCommand(), theAnswerCommand(), trongTruongHopCommand("trongtruonghop"), @@ -104,35 +102,6 @@ func pingStatsCommand(store storage.DocStore[lastPing]) modules.Command { } } -func splitWheelOptions(arg string) []string { - parts := strings.Split(arg, ",") - out := make([]string, 0, len(parts)) - for _, part := range parts { - if option := strings.TrimSpace(part); option != "" { - out = append(out, option) - } - } - return out -} - -func wheelOfNamesCommand() modules.Command { - return modules.Command{ - Name: "wheelofnames", - Visibility: modules.VisibilityPublic, - Description: "Pick one random comma-separated option", - Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { - if update.Message == nil { - return nil - } - options := splitWheelOptions(chathelper.ArgAfterCommand(update.Message.Text)) - if len(options) == 0 { - return chathelper.Reply(ctx, b, update.Message, wheelOfNamesUsage) - } - return chathelper.Reply(ctx, b, update.Message, options[rand.N(len(options))]) - }, - } -} - // senderMention renders the mention used inside the trongtruonghop template. // Prefer @username (Telegram resolves it server-side and enforces a safe // charset). Fall back to a tg://user?id link with the user's display name when diff --git a/internal/modules/misc/misc_test.go b/internal/modules/misc/misc_test.go index aaa0aa0..b5e0405 100644 --- a/internal/modules/misc/misc_test.go +++ b/internal/modules/misc/misc_test.go @@ -8,9 +8,8 @@ import ( "github.com/tiennm99/miti99bot/internal/storage" ) -// We test the per-command store behaviour directly — the bot/Telegram side is -// thin (single SendMessage) and exercising it would require a fake bot HTTP -// server. The store interaction is the part with logic worth locking down. +// Store behaviour is tested directly here; Telegram command dispatch is covered +// in handlers_test.go with a recording bot. // newMiscStore returns a fresh in-memory typed lastPing store for tests. func newMiscStore() storage.DocStore[lastPing] { @@ -24,6 +23,7 @@ func TestNew_RegistersExpectedCommands(t *testing.T) { want := map[string]modules.Visibility{ "ping": modules.VisibilityPublic, "ping_stats": modules.VisibilityProtected, + "random": modules.VisibilityPublic, "wheelofnames": modules.VisibilityPublic, "the_answer": modules.VisibilityPrivate, "trongtruonghop": modules.VisibilityPublic, diff --git a/internal/modules/misc/random_picker.go b/internal/modules/misc/random_picker.go new file mode 100644 index 0000000..2aace0a --- /dev/null +++ b/internal/modules/misc/random_picker.go @@ -0,0 +1,141 @@ +package misc + +import ( + "context" + "fmt" + "math/rand/v2" + "strings" + "time" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + + "github.com/tiennm99/miti99bot/internal/log" + "github.com/tiennm99/miti99bot/internal/modules" + "github.com/tiennm99/miti99bot/internal/modules/util/chathelper" +) + +const ( + randomUsage = "Usage: /random , , ..." + wheelOfNamesUsage = "Usage: /wheelofnames , , ..." +) + +var wheelDraftFrameDelay = 500 * time.Millisecond + +func splitWheelOptions(arg string) []string { + parts := strings.Split(arg, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + if option := strings.TrimSpace(part); option != "" { + out = append(out, option) + } + } + return out +} + +func randomCommand() modules.Command { + return modules.Command{ + Name: "random", + Visibility: modules.VisibilityPublic, + Description: "Pick one random comma-separated option", + Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { + if update.Message == nil { + return nil + } + options := splitWheelOptions(chathelper.ArgAfterCommand(update.Message.Text)) + if len(options) == 0 { + return chathelper.Reply(ctx, b, update.Message, randomUsage) + } + return chathelper.Reply(ctx, b, update.Message, options[pickWheelOption(options)]) + }, + } +} + +func wheelOfNamesCommand() modules.Command { + return modules.Command{ + Name: "wheelofnames", + Visibility: modules.VisibilityPublic, + Description: "Spin a suspenseful wheel for comma-separated options", + Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error { + if update.Message == nil { + return nil + } + options := splitWheelOptions(chathelper.ArgAfterCommand(update.Message.Text)) + if len(options) == 0 { + return chathelper.Reply(ctx, b, update.Message, wheelOfNamesUsage) + } + winner := pickWheelOption(options) + streamWheelDrafts(ctx, b, update.Message, options, winner) + return chathelper.Reply(ctx, b, update.Message, options[winner]) + }, + } +} + +func pickWheelOption(options []string) int { + return rand.N(len(options)) +} + +func streamWheelDrafts(ctx context.Context, b *bot.Bot, msg *models.Message, options []string, winner int) { + if msg.Chat.Type != models.ChatTypePrivate { + return + } + draftID := fmt.Sprintf("%d", time.Now().UTC().UnixNano()) + frames := wheelDraftFrames(options, winner) + for i, text := range frames { + ok, err := b.SendMessageDraft(ctx, &bot.SendMessageDraftParams{ + ChatID: msg.Chat.ID, + MessageThreadID: msg.MessageThreadID, + DraftID: draftID, + Text: text, + }) + if err != nil { + log.Warn("wheelofnames draft stream failed", "chat", msg.Chat.ID, "err", err) + return + } + if !ok { + log.Warn("wheelofnames draft stream returned false", "chat", msg.Chat.ID) + return + } + if i < len(frames)-1 && !waitWheelDraftFrame(ctx) { + return + } + } +} + +func wheelDraftFrames(options []string, winner int) []string { + candidates := []string{ + options[(winner+1)%len(options)], + options[(winner+2)%len(options)], + options[(winner+3)%len(options)], + options[(winner+4)%len(options)], + options[(winner+5)%len(options)], + options[winner], + } + labels := []string{ + "Spinning the wheel...", + "Still spinning...", + "Picking up speed...", + "Last few names...", + "Slowing down...", + "Almost there...", + } + frames := make([]string, 0, len(labels)) + for i, label := range labels { + frames = append(frames, fmt.Sprintf("%s\n> %s", label, candidates[i])) + } + return frames +} + +func waitWheelDraftFrame(ctx context.Context) bool { + if wheelDraftFrameDelay <= 0 { + return true + } + timer := time.NewTimer(wheelDraftFrameDelay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/telegram-commands.json b/telegram-commands.json index de5d3d0..e5f914c 100644 --- a/telegram-commands.json +++ b/telegram-commands.json @@ -9,9 +9,13 @@ "description": "Health check; replies pong" }, { - "command": "wheelofnames", + "command": "random", "description": "Pick one random comma-separated option" }, + { + "command": "wheelofnames", + "description": "Spin a suspenseful wheel for comma-separated options" + }, { "command": "trongtruonghop", "description": "Phát biểu disclaimer cho thành viên hiện tại"