feat(misc): add random command and wheel draft streaming

This commit is contained in:
2026-07-06 11:26:50 +07:00
parent 4398395e2a
commit 0e39b49e8b
6 changed files with 248 additions and 39 deletions
+1 -1
View File
@@ -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 |
+95
View File
@@ -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
+3 -34
View File
@@ -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 <option1>, <option2>, ..."
// lastPing is the value stored at the `last_ping` key: { at: <ms-since-epoch> }.
// 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
+3 -3
View File
@@ -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,
+141
View File
@@ -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 <option1>, <option2>, ..."
wheelOfNamesUsage = "Usage: /wheelofnames <option1>, <option2>, ..."
)
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
}
}
+5 -1
View File
@@ -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"