feat(random): group the random pickers into a random module and add /gachabeta

/random, /wheelofnames, and /gacha move from misc into a new random module
with unchanged command names, so usage stats carry over. The new unlisted
/gachabeta takes the same input as /gacha and renders the toon-shaded beta
wish from /api/gachabeta on the same service, with the same text fallback.
This commit is contained in:
tiennm99 committed 2026-10-01 15:17:11 +07:00
1 parent 577c0e3a70
commit 11f458fee5
18 files changed
+493 -331

No files matched your search

+1 -1
View File
@@ -15,7 +15,7 @@ MONGO_DATABASE=miti99bot
# Comma-separated module list. Empty = load every module, including any module
# added later — so list them explicitly when a deployment should only gain a new
# module deliberately.
MODULES=util,misc,amlich,wordle,loldle,lol,stock,gold,coin,stats,monkeyd,sticker,alias,blacklist
MODULES=util,misc,random,amlich,wordle,loldle,lol,stock,gold,coin,stats,monkeyd,sticker,alias,blacklist
# Telegram user id for owner-only commands (renamed from BOT_OWNER_ID).
OWNER_ID=
# Comma-separated admin Telegram user ids (renamed from ADMIN_USER_IDS).
+4 -2
View File
@@ -8,7 +8,8 @@ Atlas via long polling and an in-process cron scheduler.
| Module | What it does |
|---|---|
| `util` | `/help`, `/info` (admin), `/stickerid` (owner) |
| `misc` | `/ping`, `/ping_stats` (admin), `/random`, `/wheelofnames`, `/gacha` (Genshin-style wish; options are 5★ by default, prefix `4*` or `3*`), `/ff` (admin), `/xlt1`, `/giaxang` (Petrolimex retail fuel prices), `/the_answer` (owner), `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers |
| `misc` | `/ping`, `/ping_stats` (admin), `/ff` (admin), `/xlt1`, `/giaxang` (Petrolimex retail fuel prices), `/the_answer` (owner), `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers |
| `random` | Pick one comma-separated option at random: `/random` (text), `/wheelofnames` (wheel GIF), `/gacha` (Genshin-style wish; options are 5★ by default, prefix `4*` or `3*`), `/gachabeta` (unlisted; toon-shaded beta wish with S/SS/SSS ranks). The animations use the optional wheelofnames renderer |
| `amlich` | Vietnamese lunar calendar: `/amlich` (dương lịch → âm lịch, defaults to today), `/duonglich` (âm lịch → dương lịch, `nhuan` flag for leap months); dates accept `d`, `d/m`, or `d/m/yyyy` — missing parts fill from today in the input's calendar. Years 1800–2199 only |
| `wordle` | Daily Wordle game: `/wordle [word]`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` |
| `loldle` | League-of-Legends "guess the champion": `/loldle [champion]`, `/loldle_giveup`, `/loldle_stats`, `/loldle_setmax` (owner) |
@@ -25,7 +26,8 @@ Atlas via long polling and an in-process cron scheduler.
Commands marked (admin) require a user ID in `ADMIN_IDS` or the owner; commands
marked (owner) require `OWNER_ID`. Both kinds are hidden from `/help` and the
Telegram command menu. Every other command is public.
Telegram command menu. Commands marked (unlisted) can be run by anyone but are
also hidden from `/help` and the menu. Every other command is public.
Choose modules with the `MODULES` environment variable, a comma-separated list;
empty loads every module.
+2
View File
@@ -31,6 +31,7 @@ import (
"github.com/tiennm99/miti99bot/internal/modules/loldle"
"github.com/tiennm99/miti99bot/internal/modules/misc"
"github.com/tiennm99/miti99bot/internal/modules/monkeyd"
"github.com/tiennm99/miti99bot/internal/modules/random"
"github.com/tiennm99/miti99bot/internal/modules/stats"
"github.com/tiennm99/miti99bot/internal/modules/sticker"
"github.com/tiennm99/miti99bot/internal/modules/stock"
@@ -92,6 +93,7 @@ func factories() map[string]modules.Factory {
return map[string]modules.Factory{
"util": util.New,
"misc": misc.New,
"random": random.New,
"amlich": amlich.New,
"monkeyd": monkeyd.New,
"wordle": wordle.New,
+5
View File
@@ -93,6 +93,11 @@ option is equally likely, as with `/random`; the rarity only sets what the
animation and reply show. Options are 5★ by default; prefix `4*` or `3*` to
lower one, e.g. `/gacha Pizza, 4* Pho, 3* Rice`.
The unlisted `/gachabeta` takes the same input and renders the 8-second beta
style from `.../api/gachabeta` on the same service (a toon-shaded sky, a comet
bursting through a cloud, and an `S`/`SS`/`SSS` rank for 3★/4★/5★), with the
same text fallback.
## 1. MongoDB Atlas (M0)
1. Create a free **M0** cluster (512 MB — ample for the tiny paper-trading KV).
-278
View File
@@ -2,11 +2,7 @@ package misc
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
@@ -94,280 +90,6 @@ 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) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text))
if got := rb.LastSent().Text(); got != wheelUsage {
t.Errorf("wheelofnames reply = %q, want usage %q", got, wheelUsage)
}
})
}
}
func TestWheelOfNames_ResultCaptionEscapesHTML(t *testing.T) {
got := wheelResultCaption([]string{`<Alice & Bob>`}, 0)
want := `Result: <span class="tg-spoiler">&lt;Alice &amp; Bob&gt;</span>`
if got != want {
t.Fatalf("wheelResultCaption() = %q, want %q", got, want)
}
}
func TestWheelOfNames_ResultCaptionTruncatesLongResult(t *testing.T) {
got := wheelResultCaption([]string{strings.Repeat("a", wheelResultCaptionMaxRunes+1)}, 0)
want := `Result: <span class="tg-spoiler">` + strings.Repeat("a", wheelResultCaptionMaxRunes) + `...</span>`
if got != want {
t.Fatalf("wheelResultCaption() length = %d, want truncated caption length %d", len(got), len(want))
}
}
func TestWheelOfNames_ResultCaptionPadsShortWinnerToLongestOption(t *testing.T) {
options := []string{"Bob", "Alexandria"}
got := wheelResultCaption(options, 0)
want := `Result: <span class="tg-spoiler">___Bob____</span>`
if got != want {
t.Fatalf("wheelResultCaption() = %q, want %q", got, want)
}
if longest := wheelResultCaption(options, 1); len([]rune(longest)) != len([]rune(got)) {
t.Fatalf("caption lengths differ: winner %q vs %q", got, longest)
}
}
func TestWheelOfNames_UsesRemoteAPIWhenConfigured(t *testing.T) {
var got wheelAPIRequest
var gotAuthorization string
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/api/gif" {
t.Errorf("path = %q, want /api/gif", r.URL.Path)
}
gotAuthorization = r.Header.Get("Authorization")
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("Decode request body: %v", err)
}
w.Header().Set("Content-Type", "image/gif")
_, _ = w.Write([]byte("GIF89a-remote"))
}))
defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
t.Setenv(wheelOfNamesAPITokenEnv, "remote-token")
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice, Bob, Carol"))
if calls != 1 {
t.Fatalf("remote calls = %d, want 1", calls)
}
if gotAuthorization != "Bearer remote-token" {
t.Fatalf("Authorization = %q, want bearer token", gotAuthorization)
}
if !slices.Equal(got.Options, []string{"Alice", "Bob", "Carol"}) {
t.Fatalf("options = %#v, want parsed options", got.Options)
}
if got.WinnerIndex < 0 || got.WinnerIndex >= len(got.Options) {
t.Fatalf("winnerIndex = %d, want in range", got.WinnerIndex)
}
assertWheelRemoteDefaults(t, got)
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)
}
wantCaption := wheelResultCaption(got.Options, got.WinnerIndex)
if got := call.Form["caption"]; got != wantCaption {
t.Fatalf("caption = %q, want %q", got, wantCaption)
}
if got := call.Form["parse_mode"]; got != "HTML" {
t.Fatalf("parse_mode = %q, want HTML", got)
}
if got := call.Form["duration"]; got != "7" {
t.Fatalf("duration = %q, want 7", got)
}
if got := call.Form["width"]; got != "512" {
t.Fatalf("width = %q, want 512", got)
}
if got := call.Form["height"]; got != "512" {
t.Fatalf("height = %q, want 512", got)
}
}
func TestWheelOfNames_RemoteFailureFallsBackToRandomReply(t *testing.T) {
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
http.Error(w, "no", http.StatusInternalServerError)
}))
defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
t.Setenv(wheelOfNamesAPITokenEnv, "remote-token")
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice"))
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"))
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])
}
}
func TestWheelOfNames_SendAnimationFailureFallsBackToRandomReply(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/gif")
_, _ = w.Write([]byte("GIF89a-remote"))
}))
defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
rb, _ := installMisc(t, 999)
rb.FailMethod("sendAnimation", http.StatusInternalServerError, "")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice"))
calls := rb.Sent()
if len(calls) != 3 {
t.Fatalf("calls = %+v, want placeholder, sendAnimation, in-place edit", calls)
}
if calls[0].Method != "sendMessage" || calls[0].Text() != wheelPlaceholder {
t.Fatalf("first call = %+v, want placeholder %q", calls[0], wheelPlaceholder)
}
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])
}
}
func TestWheelOfNames_ForwardsMessageThreadID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/gif")
_, _ = w.Write([]byte("GIF89a-remote"))
}))
defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
rb, _ := installMisc(t, 999)
update := testutil.NewSupergroupMessage(-100, 7, "/wheelofnames Alice")
update.Message.MessageThreadID = 42
rb.Bot.ProcessUpdate(context.Background(), update)
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 := rb.Sent()[1].Method; got != "sendAnimation" {
t.Fatalf("second method = %q, want sendAnimation", got)
}
}
// messageFrom 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 -6
View File
@@ -1,8 +1,6 @@
// Package misc is a small module that proves the framework end-to-end:
// /ping (public, exercises a store write), /ping_stats (protected, exercises a
// store read), /random (public random picker), /wheelofnames (public wheel
// picker with optional GIF), /gacha (public Genshin-style wish picker with
// optional MP4), /ff (protected give-up-on-T1 rant), /xlt1 (public
// store read), /ff (protected give-up-on-T1 rant), /xlt1 (public
// apologise-to-T1 petition, the sequel to /ff), /giaxang (public Petrolimex
// retail fuel prices), /the_answer (private easter egg), and the public
// disclaimer commands /trongtruonghop, /tth, /trongtruonghopvng, and /tthvng.
@@ -55,9 +53,6 @@ func New(deps modules.Deps) modules.Module {
Commands: []modules.Command{
pingCommand(store),
pingStatsCommand(store),
randomCommand(),
wheelOfNamesCommand(),
gachaCommand(),
ffCommand(),
xlt1Command(),
giaxangCommand(),
-3
View File
@@ -23,9 +23,6 @@ func TestNew_RegistersExpectedCommands(t *testing.T) {
want := map[string]modules.Visibility{
"ping": modules.VisibilityPublic,
"ping_stats": modules.VisibilityProtected,
"random": modules.VisibilityPublic,
"wheelofnames": modules.VisibilityPublic,
"gacha": modules.VisibilityPublic,
"ff": modules.VisibilityProtected,
"xlt1": modules.VisibilityPublic,
"giaxang": modules.VisibilityPublic,
+29 -16
View File
@@ -1,4 +1,4 @@
package misc
package random
import (
"bytes"
@@ -10,10 +10,21 @@ import (
)
const (
gachaRemoteFPS = 24
gachaRemoteWidth = 640
gachaRemoteHeight = 360
gachaRemoteDuration = 7
gachaRemoteFPS = 24
gachaRemoteWidth = 640
gachaRemoteHeight = 360
)
// gachaStyle is one wish animation the renderer serves: its path next to
// /api/gif and the clip length in seconds.
type gachaStyle struct {
Path string
Duration int
}
var (
gachaStyleWish = gachaStyle{Path: "gacha", Duration: 7}
gachaStyleBeta = gachaStyle{Path: "gachabeta", Duration: 8}
)
type gachaAPIRequest struct {
@@ -23,10 +34,11 @@ type gachaAPIRequest struct {
Width int `json:"width"`
}
// gachaAPIEndpoint derives the wish renderer from the configured wheel
// endpoint: the same service serves /api/gacha next to /api/gif, so the last
// path segment is swapped and no second URL needs configuring.
func gachaAPIEndpoint(rawURL string) (*url.URL, error) {
// gachaAPIEndpoint derives a wish renderer from the configured wheel
// endpoint: the same service serves /api/gacha and /api/gachabeta next to
// /api/gif, so the last path segment is swapped and no second URL needs
// configuring.
func gachaAPIEndpoint(rawURL, path string) (*url.URL, error) {
endpoint, err := wheelAPIEndpoint(rawURL)
if err != nil {
return nil, err
@@ -34,12 +46,13 @@ func gachaAPIEndpoint(rawURL string) (*url.URL, error) {
base := *endpoint
base.Path = strings.TrimSuffix(base.Path, "/")
base.RawPath = ""
return base.ResolveReference(&url.URL{Path: "gacha"}), nil
return base.ResolveReference(&url.URL{Path: path}), nil
}
// RenderGacha returns the MP4 wish animation revealing label at rarity stars.
func (c wheelAPIClient) RenderGacha(ctx context.Context, label string, rarity int) ([]byte, error) {
endpoint, err := gachaAPIEndpoint(c.URL)
// RenderGacha returns the MP4 wish animation at path revealing label at
// rarity stars.
func (c wheelAPIClient) RenderGacha(ctx context.Context, path, label string, rarity int) ([]byte, error) {
endpoint, err := gachaAPIEndpoint(c.URL, path)
if err != nil {
return nil, err
}
@@ -66,14 +79,14 @@ func isMP4(data []byte) bool {
return len(data) >= 8 && bytes.Equal(data[4:8], []byte("ftyp"))
}
func renderGachaAnimation(ctx context.Context, label string, rarity int) (wheelAnimation, error) {
data, err := newWheelAPIClientFromEnv().RenderGacha(ctx, label, rarity)
func renderGachaAnimation(ctx context.Context, style gachaStyle, label string, rarity int) (wheelAnimation, error) {
data, err := newWheelAPIClientFromEnv().RenderGacha(ctx, style.Path, label, rarity)
if err != nil {
return wheelAnimation{}, err
}
return wheelAnimation{
Data: data,
Duration: gachaRemoteDuration,
Duration: style.Duration,
Width: gachaRemoteWidth,
Height: gachaRemoteHeight,
}, nil
@@ -1,4 +1,4 @@
package misc
package random
import (
"context"
@@ -13,7 +13,7 @@ func TestGachaAPIEndpoint_SwapsLastPathSegment(t *testing.T) {
"https://render.example/api/gif/": "https://render.example/api/gacha",
"http://10.0.0.5:3000/api/gif": "http://10.0.0.5:3000/api/gacha",
} {
got, err := gachaAPIEndpoint(raw)
got, err := gachaAPIEndpoint(raw, "gacha")
if err != nil {
t.Fatalf("gachaAPIEndpoint(%q): %v", raw, err)
}
@@ -21,7 +21,7 @@ func TestGachaAPIEndpoint_SwapsLastPathSegment(t *testing.T) {
t.Errorf("gachaAPIEndpoint(%q) = %q, want %q", raw, got, want)
}
}
if _, err := gachaAPIEndpoint(""); err != errWheelAPINotConfigured {
if _, err := gachaAPIEndpoint("", "gacha"); err != errWheelAPINotConfigured {
t.Fatalf("empty url err = %v, want errWheelAPINotConfigured", err)
}
}
@@ -38,7 +38,7 @@ func TestWheelAPIClient_RenderGachaRejectsInvalidInput(t *testing.T) {
{name: "rarity too high", label: "a", rarity: 6},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := client.RenderGacha(context.Background(), tc.label, tc.rarity); err == nil {
if _, err := client.RenderGacha(context.Background(), "gacha", tc.label, tc.rarity); err == nil {
t.Fatal("RenderGacha returned nil error")
}
})
@@ -66,7 +66,7 @@ func TestWheelAPIClient_RenderGachaReturnsErrorsForBadResponses(t *testing.T) {
defer server.Close()
client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"}
if _, err := client.RenderGacha(context.Background(), "a", 3); err == nil {
if _, err := client.RenderGacha(context.Background(), "gacha", "a", 3); err == nil {
t.Fatal("RenderGacha returned nil error")
}
})
+26 -9
View File
@@ -1,4 +1,4 @@
package misc
package random
import (
"bytes"
@@ -18,6 +18,7 @@ import (
const (
gachaUsage = "Usage: /gacha <option,...>\nOptions are 5* by default; prefix 4* or 3* to lower one, e.g. /gacha Pizza, 4* Pho, 3* Rice"
gachaBetaUsage = "Usage: /gachabeta <option,...>\nOptions are 5* by default; prefix 4* or 3* to lower one, e.g. /gachabeta Pizza, 4* Pho, 3* Rice"
gachaPlaceholder = "Wishing..."
gachaFilename = "gacha.mp4"
@@ -59,10 +60,26 @@ func gachaResultText(option gachaOption) string {
}
func gachaCommand() modules.Command {
return newGachaCommand("gacha", modules.VisibilityPublic,
"Wish for one comma-separated option, Genshin style; 5* by default, prefix 4* or 3*",
gachaUsage, gachaStyleWish)
}
// gachaBetaCommand trials the toon-shaded beta wish. It is unlisted: anyone
// can run it, but it stays out of the command menu and /help.
func gachaBetaCommand() modules.Command {
return newGachaCommand("gachabeta", modules.VisibilityUnlisted,
"Beta wish: toon sky, comet through a cloud, S/SS/SSS rank; 5* by default, prefix 4* or 3*",
gachaBetaUsage, gachaStyleBeta)
}
// newGachaCommand builds a wish command that picks one option and renders it
// in style, falling back to a text reply when rendering fails.
func newGachaCommand(name string, visibility modules.Visibility, description, usage string, style gachaStyle) modules.Command {
return modules.Command{
Name: "gacha",
Visibility: modules.VisibilityPublic,
Description: "Wish for one comma-separated option, Genshin style; 5* by default, prefix 4* or 3*",
Name: name,
Visibility: visibility,
Description: description,
Parameters: "<option,...>",
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
if update.Message == nil {
@@ -70,7 +87,7 @@ func gachaCommand() modules.Command {
}
options := parseGachaOptions(chathelper.ArgAfterCommand(update.Message.Text))
if len(options) == 0 {
return chathelper.Reply(ctx, b, update.Message, gachaUsage)
return chathelper.Reply(ctx, b, update.Message, usage)
}
// Every option is equally likely; the rarity tag only styles the
// wish animation and the result text.
@@ -81,10 +98,10 @@ func gachaCommand() modules.Command {
}
placeholder := sendRenderPlaceholder(ctx, b, update.Message, gachaPlaceholder)
animation, err := renderGachaAnimation(ctx, options[winner].Label, options[winner].Rarity)
animation, err := renderGachaAnimation(ctx, style, options[winner].Label, options[winner].Rarity)
if err != nil {
if !errors.Is(err, errWheelAPINotConfigured) {
log.Warn("gacha remote render failed", "err", err)
log.Warn("gacha remote render failed", "command", name, "err", err)
}
return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, results[winner])
}
@@ -102,11 +119,11 @@ func gachaCommand() modules.Command {
ParseMode: models.ParseModeHTML,
})
if err != nil {
log.Warn("gacha send animation failed", "chat", update.Message.Chat.ID, "err", err)
log.Warn("gacha send animation failed", "command", name, "chat", update.Message.Chat.ID, "err", err)
return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, results[winner])
}
if err := chathelper.DeleteMessage(ctx, b, update.Message, placeholder); err != nil {
log.Warn("gacha placeholder delete failed", "chat", update.Message.Chat.ID, "err", err)
log.Warn("gacha placeholder delete failed", "command", name, "chat", update.Message.Chat.ID, "err", err)
}
return nil
},
+55 -7
View File
@@ -1,4 +1,4 @@
package misc
package random
import (
"context"
@@ -33,7 +33,7 @@ func TestParseGachaOptions(t *testing.T) {
func TestGacha_EmptyArgsRepliesUsage(t *testing.T) {
for _, text := range []string{"/gacha", "/gacha , ,", "/gacha 5*"} {
t.Run(text, func(t *testing.T) {
rb, _ := installMisc(t, 999)
rb := installRandom(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text))
if got := rb.LastSent().Text(); got != gachaUsage {
@@ -44,7 +44,7 @@ func TestGacha_EmptyArgsRepliesUsage(t *testing.T) {
}
func TestGacha_NotConfiguredRepliesWithStars(t *testing.T) {
rb, _ := installMisc(t, 999)
rb := installRandom(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha Pizza"))
calls := rb.Sent()
@@ -69,7 +69,7 @@ func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) {
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
t.Setenv(wheelOfNamesAPITokenEnv, "remote-token")
rb, _ := installMisc(t, 999)
rb := installRandom(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha 4* Pho"))
if gotPath != "/api/gacha" {
@@ -114,7 +114,7 @@ func TestGacha_RemoteFailureFallsBackToText(t *testing.T) {
defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
rb, _ := installMisc(t, 999)
rb := installRandom(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha 3* Rice"))
sent := rb.Sent()
@@ -134,7 +134,7 @@ func TestGacha_SendAnimationFailureFallsBackToText(t *testing.T) {
defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
rb, _ := installMisc(t, 999)
rb := installRandom(t, 999)
rb.FailMethod("sendAnimation", http.StatusInternalServerError, "")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha 3* Rice"))
@@ -152,7 +152,7 @@ func TestGacha_ForwardsMessageThreadID(t *testing.T) {
defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
rb, _ := installMisc(t, 999)
rb := installRandom(t, 999)
update := testutil.NewSupergroupMessage(-100, 7, "/gacha 3* Rice")
update.Message.MessageThreadID = 42
rb.Bot.ProcessUpdate(context.Background(), update)
@@ -173,3 +173,51 @@ func TestGacha_ForwardsMessageThreadID(t *testing.T) {
t.Fatalf("sendAnimation calls = %d, want 1", animations)
}
}
func TestGachaBeta_UsesBetaRendererAndAnyoneCanRunIt(t *testing.T) {
var gotPath string
var got gachaAPIRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("Decode request body: %v", err)
}
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write(mp4Bytes)
}))
defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
// 7 is neither the owner (999) nor an admin.
rb := installRandom(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gachabeta Bún bò"))
if gotPath != "/api/gachabeta" {
t.Fatalf("path = %q, want /api/gachabeta", gotPath)
}
if want := (gachaAPIRequest{Label: "Bún bò", Rarity: 5, FPS: gachaRemoteFPS, Width: gachaRemoteWidth}); got != want {
t.Fatalf("request = %+v, want %+v", got, want)
}
var animation *testutil.SentCall
sent := rb.Sent()
for i := range sent {
if sent[i].Method == "sendAnimation" {
animation = &sent[i]
}
}
if animation == nil {
t.Fatalf("calls = %+v, want a sendAnimation", rb.Sent())
}
if got := animation.Form["duration"]; got != "8" {
t.Fatalf("duration = %q, want 8", got)
}
}
func TestGachaBeta_EmptyArgsRepliesBetaUsage(t *testing.T) {
rb := installRandom(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gachabeta"))
if got := rb.LastSent().Text(); got != gachaBetaUsage {
t.Errorf("gachabeta reply = %q, want usage %q", got, gachaBetaUsage)
}
}
+306
View File
@@ -0,0 +1,306 @@
package random
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
)
// installRandom wires the random module to a recording bot with a fresh
// in-memory store and an Auth whose owner is ownerID.
func installRandom(t *testing.T, ownerID int64) *testutil.RecordingBot {
t.Helper()
rb := testutil.NewRecordingBot(t)
mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection("random")})
reg := &modules.Registry{
Modules: []modules.Module{{Name: "random", Commands: mod.Commands}},
AllCommands: map[string]modules.Command{},
}
for _, c := range mod.Commands {
reg.AllCommands[c.Name] = c
}
modules.Install(rb.Bot, reg, modules.Auth{BotOwnerID: ownerID})
return rb
}
func TestRandom_UsageWhenMissingOptions(t *testing.T) {
for _, text := range []string{"/random", "/random , ,"} {
t.Run(text, func(t *testing.T) {
rb := installRandom(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 := installRandom(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 := installRandom(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 := installRandom(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) {
rb := installRandom(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text))
if got := rb.LastSent().Text(); got != wheelUsage {
t.Errorf("wheelofnames reply = %q, want usage %q", got, wheelUsage)
}
})
}
}
func TestWheelOfNames_ResultCaptionEscapesHTML(t *testing.T) {
got := wheelResultCaption([]string{`<Alice & Bob>`}, 0)
want := `Result: <span class="tg-spoiler">&lt;Alice &amp; Bob&gt;</span>`
if got != want {
t.Fatalf("wheelResultCaption() = %q, want %q", got, want)
}
}
func TestWheelOfNames_ResultCaptionTruncatesLongResult(t *testing.T) {
got := wheelResultCaption([]string{strings.Repeat("a", wheelResultCaptionMaxRunes+1)}, 0)
want := `Result: <span class="tg-spoiler">` + strings.Repeat("a", wheelResultCaptionMaxRunes) + `...</span>`
if got != want {
t.Fatalf("wheelResultCaption() length = %d, want truncated caption length %d", len(got), len(want))
}
}
func TestWheelOfNames_ResultCaptionPadsShortWinnerToLongestOption(t *testing.T) {
options := []string{"Bob", "Alexandria"}
got := wheelResultCaption(options, 0)
want := `Result: <span class="tg-spoiler">___Bob____</span>`
if got != want {
t.Fatalf("wheelResultCaption() = %q, want %q", got, want)
}
if longest := wheelResultCaption(options, 1); len([]rune(longest)) != len([]rune(got)) {
t.Fatalf("caption lengths differ: winner %q vs %q", got, longest)
}
}
func TestWheelOfNames_UsesRemoteAPIWhenConfigured(t *testing.T) {
var got wheelAPIRequest
var gotAuthorization string
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/api/gif" {
t.Errorf("path = %q, want /api/gif", r.URL.Path)
}
gotAuthorization = r.Header.Get("Authorization")
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Errorf("Decode request body: %v", err)
}
w.Header().Set("Content-Type", "image/gif")
_, _ = w.Write([]byte("GIF89a-remote"))
}))
defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
t.Setenv(wheelOfNamesAPITokenEnv, "remote-token")
rb := installRandom(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice, Bob, Carol"))
if calls != 1 {
t.Fatalf("remote calls = %d, want 1", calls)
}
if gotAuthorization != "Bearer remote-token" {
t.Fatalf("Authorization = %q, want bearer token", gotAuthorization)
}
if !slices.Equal(got.Options, []string{"Alice", "Bob", "Carol"}) {
t.Fatalf("options = %#v, want parsed options", got.Options)
}
if got.WinnerIndex < 0 || got.WinnerIndex >= len(got.Options) {
t.Fatalf("winnerIndex = %d, want in range", got.WinnerIndex)
}
assertWheelRemoteDefaults(t, got)
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)
}
wantCaption := wheelResultCaption(got.Options, got.WinnerIndex)
if got := call.Form["caption"]; got != wantCaption {
t.Fatalf("caption = %q, want %q", got, wantCaption)
}
if got := call.Form["parse_mode"]; got != "HTML" {
t.Fatalf("parse_mode = %q, want HTML", got)
}
if got := call.Form["duration"]; got != "7" {
t.Fatalf("duration = %q, want 7", got)
}
if got := call.Form["width"]; got != "512" {
t.Fatalf("width = %q, want 512", got)
}
if got := call.Form["height"]; got != "512" {
t.Fatalf("height = %q, want 512", got)
}
}
func TestWheelOfNames_RemoteFailureFallsBackToRandomReply(t *testing.T) {
var calls int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
http.Error(w, "no", http.StatusInternalServerError)
}))
defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
t.Setenv(wheelOfNamesAPITokenEnv, "remote-token")
rb := installRandom(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice"))
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 := installRandom(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 := installRandom(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice"))
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])
}
}
func TestWheelOfNames_SendAnimationFailureFallsBackToRandomReply(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/gif")
_, _ = w.Write([]byte("GIF89a-remote"))
}))
defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
rb := installRandom(t, 999)
rb.FailMethod("sendAnimation", http.StatusInternalServerError, "")
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/wheelofnames Alice"))
calls := rb.Sent()
if len(calls) != 3 {
t.Fatalf("calls = %+v, want placeholder, sendAnimation, in-place edit", calls)
}
if calls[0].Method != "sendMessage" || calls[0].Text() != wheelPlaceholder {
t.Fatalf("first call = %+v, want placeholder %q", calls[0], wheelPlaceholder)
}
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])
}
}
func TestWheelOfNames_ForwardsMessageThreadID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "image/gif")
_, _ = w.Write([]byte("GIF89a-remote"))
}))
defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
rb := installRandom(t, 999)
update := testutil.NewSupergroupMessage(-100, 7, "/wheelofnames Alice")
update.Message.MessageThreadID = 42
rb.Bot.ProcessUpdate(context.Background(), update)
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 := rb.Sent()[1].Method; got != "sendAnimation" {
t.Fatalf("second method = %q, want sendAnimation", got)
}
}
+20
View File
@@ -0,0 +1,20 @@
// Package random groups the commands that pick one option at random: /random
// (plain text pick), /wheelofnames (wheel GIF when a renderer is configured),
// /gacha (Genshin-style wish MP4), and the unlisted /gachabeta (toon-shaded
// beta wish). The animated commands share the optional wheelofnames renderer
// and fall back to a text reply without it.
package random
import "github.com/tiennm99/miti99bot/internal/modules"
// New is the module Factory. The commands keep no state, so deps is unused.
func New(_ modules.Deps) modules.Module {
return modules.Module{
Commands: []modules.Command{
randomCommand(),
wheelOfNamesCommand(),
gachaCommand(),
gachaBetaCommand(),
},
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
package misc
package random
import (
"context"
+35
View File
@@ -0,0 +1,35 @@
package random
import (
"testing"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
)
func TestNew_RegistersRandomGroup(t *testing.T) {
mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection("random")})
want := map[string]modules.Visibility{
"random": modules.VisibilityPublic,
"wheelofnames": modules.VisibilityPublic,
"gacha": modules.VisibilityPublic,
"gachabeta": modules.VisibilityUnlisted,
}
if len(mod.Commands) != len(want) {
t.Fatalf("commands count = %d, want %d", len(mod.Commands), len(want))
}
for _, c := range mod.Commands {
v, ok := want[c.Name]
if !ok {
t.Errorf("unexpected command %q", c.Name)
continue
}
if c.Visibility != v {
t.Errorf("command %q visibility = %d, want %d", c.Name, c.Visibility, v)
}
if c.Handler == nil {
t.Errorf("command %q has nil handler", c.Name)
}
}
}
@@ -1,4 +1,4 @@
package misc
package random
import (
"bytes"
@@ -1,4 +1,4 @@
package misc
package random
import (
"bytes"
@@ -1,4 +1,4 @@
package misc
package random
import (
"bytes"