mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-07-24 18:20:16 +00:00
feat(misc): add random command and wheel draft streaming
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user