diff --git a/README.md b/README.md index c7ce66e..7e86010 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`, `/the_answer`, `/trongtruonghop` + `/tth` disclaimer | +| `misc` | `/ping`, `/ping_stats`, `/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 bdfbd14..8d81a2e 100644 --- a/internal/modules/misc/handlers_test.go +++ b/internal/modules/misc/handlers_test.go @@ -89,6 +89,48 @@ func TestPingStats_DeniedToNonAdmin(t *testing.T) { } } +func TestWheelOfNames_UsageWhenMissingOptions(t *testing.T) { + for _, text := range []string{"/wheelofnames", "/wheelofnames , ,"} { + 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 != wheelOfNamesUsage { + t.Errorf("wheelofnames reply = %q, want usage %q", got, wheelOfNamesUsage) + } + }) + } +} + +func TestWheelOfNames_SingleOption(t *testing.T) { + rb, _ := installMisc(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice")) + + if got := rb.LastSent().Text(); got != "Alice" { + t.Errorf("wheelofnames reply = %q, want Alice", got) + } +} + +func TestWheelOfNames_PicksFromTrimmedOptions(t *testing.T) { + rb, _ := installMisc(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice, Bob, Carol")) + + got := rb.LastSent().Text() + if got != "Alice" && got != "Bob" && got != "Carol" { + t.Errorf("wheelofnames reply = %q, want one of Alice/Bob/Carol", got) + } +} + +func TestWheelOfNames_IgnoresEmptySegments(t *testing.T) { + rb, _ := installMisc(t, 999) + rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames , Alice , , Bob ,")) + + got := rb.LastSent().Text() + if got != "Alice" && got != "Bob" { + t.Errorf("wheelofnames reply = %q, want Alice or Bob", got) + } +} + // 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 9fbb1fd..181d604 100644 --- a/internal/modules/misc/misc.go +++ b/internal/modules/misc/misc.go @@ -1,6 +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), /the_answer (private easter egg). +// read), /wheelofnames (public random picker), /the_answer (private easter +// egg). package misc import ( @@ -8,6 +9,7 @@ import ( "errors" "fmt" "html" + "math/rand/v2" "strings" "time" @@ -31,6 +33,8 @@ 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. @@ -46,6 +50,7 @@ func New(deps modules.Deps) modules.Module { Commands: []modules.Command{ pingCommand(store), pingStatsCommand(store), + wheelOfNamesCommand(), theAnswerCommand(), trongTruongHopCommand("trongtruonghop"), trongTruongHopCommand("tth"), @@ -99,6 +104,35 @@ 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 4ec61f3..aaa0aa0 100644 --- a/internal/modules/misc/misc_test.go +++ b/internal/modules/misc/misc_test.go @@ -24,6 +24,7 @@ func TestNew_RegistersExpectedCommands(t *testing.T) { want := map[string]modules.Visibility{ "ping": modules.VisibilityPublic, "ping_stats": modules.VisibilityProtected, + "wheelofnames": modules.VisibilityPublic, "the_answer": modules.VisibilityPrivate, "trongtruonghop": modules.VisibilityPublic, "tth": modules.VisibilityPublic, diff --git a/telegram-commands.json b/telegram-commands.json index 32826a0..de5d3d0 100644 --- a/telegram-commands.json +++ b/telegram-commands.json @@ -8,6 +8,10 @@ "command": "ping", "description": "Health check; replies pong" }, + { + "command": "wheelofnames", + "description": "Pick one random comma-separated option" + }, { "command": "trongtruonghop", "description": "Phát biểu disclaimer cho thành viên hiện tại"