feat(misc): add /gacha Genshin-style wish picker

Options take an optional *4 or *5 rarity tag; untagged options are 3 stars.
A pick rolls a tier at Genshin base rates over the tiers present, then an
option uniformly within it, so untagged input matches /random. The wish
animation renders as MP4 on the wheelofnames service at /api/gacha, with a
text fallback when the renderer is unset or fails.
This commit is contained in:
tiennm99 committed 2026-10-01 13:02:46 +07:00
1 parent 53ca1e59b3
commit 858eaa066b
11 files changed
+540 -5

No files matched your search

+3 -2
View File
@@ -38,8 +38,9 @@ STICKER_PACK_NAME=miti99_by_miti99bot
# Without it every /lol* fetch fails; the stale cache covers ≤60 min.
LOL_PANDASCORE_TOKEN=
# Optional /wheelofnames GIF renderer. Standard deployment:
# https://github.com/tiennm99/wheelofnames. Leave blank to fall back to text
# Optional /wheelofnames GIF and /gacha MP4 renderer. Standard deployment:
# https://github.com/tiennm99/wheelofnames. Set the full /api/gif endpoint;
# /gacha calls /api/gacha on the same service. Leave blank to fall back to text
# selection.
WHEELOFNAMES_API_URL=
# Bearer token matching the wheelofnames service API_TOKEN when URL is set.
+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` (admin), `/stickerid` (owner) |
| `misc` | `/ping`, `/ping_stats` (admin), `/random`, `/wheelofnames`, `/ff` (admin), `/xlt1`, `/giaxang` (Petrolimex retail fuel prices), `/the_answer` (owner), `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers |
| `misc` | `/ping`, `/ping_stats` (admin), `/random`, `/wheelofnames`, `/gacha` (Genshin-style wish; tag `*4`/`*5` for rarity), `/ff` (admin), `/xlt1`, `/giaxang` (Petrolimex retail fuel prices), `/the_answer` (owner), `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers |
| `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) |
+1
View File
@@ -67,6 +67,7 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"gold_topup": "<vnd_amount>",
"gold_buy": "<luong>",
"gold_sell": "<luong>",
"gacha": "<option,...>",
"lol": "[date]",
"loldle": "[champion]",
"monkeyd_crawl": "<url> [font_size]",
+1 -1
View File
@@ -14,7 +14,7 @@ services:
MODULES: ${MODULES} # CSV; empty = all modules
OWNER_ID: ${OWNER_ID} # Telegram user id for owner-only commands
ADMIN_IDS: ${ADMIN_IDS} # CSV of admin Telegram user ids
WHEELOFNAMES_API_URL: ${WHEELOFNAMES_API_URL:-} # Optional full /api/gif endpoint
WHEELOFNAMES_API_URL: ${WHEELOFNAMES_API_URL:-} # Optional full /api/gif endpoint (/gacha uses /api/gacha)
WHEELOFNAMES_API_TOKEN: ${WHEELOFNAMES_API_TOKEN:-} # Optional bearer token for that service
# SOURCE_COMMIT is intentionally not declared here. Coolify provides it
# at runtime via its generated env file; declaring it here with Compose
+7
View File
@@ -85,6 +85,13 @@ render or upload failure edits that same message into the plain text winner
instead. With no renderer configured there is no holding message — the winner
reply is immediate.
`/gacha` uses the same service and token: the bot swaps the URL's last path
segment, so `.../api/gif` becomes `.../api/gacha`. It renders a 7-second
`640x360` silent MP4 wish animation, posts `Wishing...` while it renders, and
falls back to a text reply such as `★★★★★ Pizza` on the same failures. Options
tagged `*4` or `*5` are rarer, following Genshin base rates (5★ 0.6%, 4★ 5.1%,
3★ 94.3%) over the tiers present; untagged options are 3★.
## 1. MongoDB Atlas (M0)
1. Create a free **M0** cluster (512 MB — ample for the tiny paper-trading KV).
+80
View File
@@ -0,0 +1,80 @@
package misc
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
)
const (
gachaRemoteFPS = 24
gachaRemoteWidth = 640
gachaRemoteHeight = 360
gachaRemoteDuration = 7
)
type gachaAPIRequest struct {
Label string `json:"label"`
Rarity int `json:"rarity"`
FPS int `json:"fps"`
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) {
endpoint, err := wheelAPIEndpoint(rawURL)
if err != nil {
return nil, err
}
base := *endpoint
base.Path = strings.TrimSuffix(base.Path, "/")
base.RawPath = ""
return base.ResolveReference(&url.URL{Path: "gacha"}), 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)
if err != nil {
return nil, err
}
if strings.TrimSpace(label) == "" {
return nil, fmt.Errorf("gacha api label empty")
}
if rarity < gachaMinRarity || rarity > gachaMaxRarity {
return nil, fmt.Errorf("gacha api rarity %d out of range", rarity)
}
body, err := json.Marshal(gachaAPIRequest{
Label: label,
Rarity: rarity,
FPS: gachaRemoteFPS,
Width: gachaRemoteWidth,
})
if err != nil {
return nil, fmt.Errorf("gacha api request encode failed: %w", err)
}
return c.post(ctx, endpoint, body, "video/mp4", isMP4)
}
// isMP4 checks for the ISO base media "ftyp" box that opens every MP4.
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)
if err != nil {
return wheelAnimation{}, err
}
return wheelAnimation{
Data: data,
Duration: gachaRemoteDuration,
Width: gachaRemoteWidth,
Height: gachaRemoteHeight,
}, nil
}
@@ -0,0 +1,74 @@
package misc
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestGachaAPIEndpoint_SwapsLastPathSegment(t *testing.T) {
for raw, want := range map[string]string{
"https://render.example/api/gif": "https://render.example/api/gacha",
"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)
if err != nil {
t.Fatalf("gachaAPIEndpoint(%q): %v", raw, err)
}
if got.String() != want {
t.Errorf("gachaAPIEndpoint(%q) = %q, want %q", raw, got, want)
}
}
if _, err := gachaAPIEndpoint(""); err != errWheelAPINotConfigured {
t.Fatalf("empty url err = %v, want errWheelAPINotConfigured", err)
}
}
func TestWheelAPIClient_RenderGachaRejectsInvalidInput(t *testing.T) {
client := wheelAPIClient{URL: "https://example.com/api/gif"}
for _, tc := range []struct {
name string
label string
rarity int
}{
{name: "blank label", label: " ", rarity: 3},
{name: "rarity too low", label: "a", rarity: 2},
{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 {
t.Fatal("RenderGacha returned nil error")
}
})
}
}
func TestWheelAPIClient_RenderGachaReturnsErrorsForBadResponses(t *testing.T) {
for _, tc := range []struct {
name string
status int
contentType string
body []byte
}{
{name: "server error", status: http.StatusInternalServerError, contentType: "text/plain", body: []byte("bad")},
{name: "gif instead of mp4", status: http.StatusOK, contentType: "image/gif", body: []byte("GIF89a")},
{name: "mislabeled mp4", status: http.StatusOK, contentType: "video/mp4", body: []byte("not an mp4")},
{name: "empty mp4", status: http.StatusOK, contentType: "video/mp4", body: nil},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", tc.contentType)
w.WriteHeader(tc.status)
_, _ = w.Write(tc.body)
}))
defer server.Close()
client := wheelAPIClient{HTTP: server.Client(), URL: server.URL + "/api/gif"}
if _, err := client.RenderGacha(context.Background(), "a", 3); err == nil {
t.Fatal("RenderGacha returned nil error")
}
})
}
}
+146
View File
@@ -0,0 +1,146 @@
package misc
import (
"bytes"
"context"
"errors"
"math/rand/v2"
"regexp"
"strings"
"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 (
gachaUsage = "Usage: /gacha <option,...>\nTag rarity with *4 or *5, e.g. /gacha Pizza*5, Pho*4, Rice"
gachaPlaceholder = "Wishing..."
gachaFilename = "gacha.mp4"
gachaMinRarity = 3
gachaMaxRarity = 5
gachaDefaultRarity = gachaMinRarity
)
// gachaTierRates are Genshin Impact's base single-wish odds per mille. A
// pick first rolls a tier among the tiers present, then an option uniformly
// within it, so untagged input is a plain uniform pick.
var gachaTierRates = map[int]int{5: 6, 4: 51, 3: 943}
// gachaRarityTag matches a trailing "*3", "*4", or "*5" rarity tag.
var gachaRarityTag = regexp.MustCompile(`\s*\*\s*([345])$`)
type gachaOption struct {
Label string
Rarity int
}
// parseGachaOptions splits comma-separated options and strips each one's
// rarity tag. Untagged options are 3★; options left blank after the tag is
// removed are dropped.
func parseGachaOptions(arg string) []gachaOption {
parts := splitWheelOptions(arg)
out := make([]gachaOption, 0, len(parts))
for _, part := range parts {
option := gachaOption{Label: part, Rarity: gachaDefaultRarity}
if m := gachaRarityTag.FindStringSubmatchIndex(part); m != nil {
option.Label = strings.TrimSpace(part[:m[0]])
option.Rarity = int(part[m[2]] - '0')
}
if option.Label != "" {
out = append(out, option)
}
}
return out
}
// pickGachaOption returns the index of the wished option. rng may be nil to
// use the global source.
func pickGachaOption(options []gachaOption, rng *rand.Rand) int {
intN := rand.IntN
if rng != nil {
intN = rng.IntN
}
byRarity := map[int][]int{}
total := 0
for i, option := range options {
if len(byRarity[option.Rarity]) == 0 {
total += gachaTierRates[option.Rarity]
}
byRarity[option.Rarity] = append(byRarity[option.Rarity], i)
}
roll := intN(total)
for rarity := gachaMaxRarity; rarity >= gachaMinRarity; rarity-- {
tier := byRarity[rarity]
if len(tier) == 0 {
continue
}
if roll < gachaTierRates[rarity] {
return tier[intN(len(tier))]
}
roll -= gachaTierRates[rarity]
}
return 0 // unreachable: roll < total, the sum of present tier rates.
}
// gachaResultText renders an option as its stars followed by its label.
func gachaResultText(option gachaOption) string {
return strings.Repeat("★", option.Rarity) + " " + option.Label
}
func gachaCommand() modules.Command {
return modules.Command{
Name: "gacha",
Visibility: modules.VisibilityPublic,
Description: "Wish for one comma-separated option, Genshin style; tag *4 or *5 for rarity",
Parameters: "<option,...>",
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
if update.Message == nil {
return nil
}
options := parseGachaOptions(chathelper.ArgAfterCommand(update.Message.Text))
if len(options) == 0 {
return chathelper.Reply(ctx, b, update.Message, gachaUsage)
}
winner := pickGachaOption(options, nil)
results := make([]string, len(options))
for i, option := range options {
results[i] = gachaResultText(option)
}
placeholder := sendRenderPlaceholder(ctx, b, update.Message, gachaPlaceholder)
animation, err := renderGachaAnimation(ctx, options[winner].Label, options[winner].Rarity)
if err != nil {
if !errors.Is(err, errWheelAPINotConfigured) {
log.Warn("gacha remote render failed", "err", err)
}
return replaceWheelPlaceholder(ctx, b, update.Message, placeholder, results[winner])
}
_, err = b.SendAnimation(ctx, &bot.SendAnimationParams{
ChatID: update.Message.Chat.ID,
MessageThreadID: update.Message.MessageThreadID,
Animation: &models.InputFileUpload{
Filename: gachaFilename,
Data: bytes.NewReader(animation.Data),
},
Duration: animation.Duration,
Width: animation.Width,
Height: animation.Height,
Caption: wheelResultCaption(results, winner),
ParseMode: models.ParseModeHTML,
})
if err != nil {
log.Warn("gacha send animation failed", "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)
}
return nil
},
}
}
+223
View File
@@ -0,0 +1,223 @@
package misc
import (
"context"
"encoding/json"
"math/rand/v2"
"net/http"
"net/http/httptest"
"reflect"
"testing"
"github.com/tiennm99/miti99bot/internal/testutil"
)
var mp4Bytes = []byte("\x00\x00\x00\x18ftypisom-remote")
func TestParseGachaOptions(t *testing.T) {
got := parseGachaOptions(" Pizza*5, Pho *4 , Rice, Bun * 3, *5, , Cake*7, a*b ")
want := []gachaOption{
{Label: "Pizza", Rarity: 5},
{Label: "Pho", Rarity: 4},
{Label: "Rice", Rarity: 3},
{Label: "Bun", Rarity: 3},
{Label: "Cake*7", Rarity: 3},
{Label: "a*b", Rarity: 3},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("parseGachaOptions() = %#v, want %#v", got, want)
}
}
// One option per tier must land near the Genshin base rates.
func TestPickGachaOption_FollowsTierRates(t *testing.T) {
options := []gachaOption{{"five", 5}, {"four", 4}, {"three", 3}}
rng := rand.New(rand.NewPCG(1, 2))
const rolls = 200_000
counts := make([]int, len(options))
for range rolls {
counts[pickGachaOption(options, rng)]++
}
for i, wantPerMille := range []float64{6, 51, 943} {
got := float64(counts[i]) / rolls * 1000
if got < wantPerMille*0.85 || got > wantPerMille*1.15 {
t.Errorf("%s rate = %.2f‰, want about %.0f‰", options[i].Label, got, wantPerMille)
}
}
}
func TestPickGachaOption_UniformWithinTier(t *testing.T) {
options := []gachaOption{{"a", 3}, {"b", 3}, {"c", 3}, {"d", 3}}
rng := rand.New(rand.NewPCG(3, 4))
counts := make([]int, len(options))
for range 40_000 {
counts[pickGachaOption(options, rng)]++
}
for i, n := range counts {
if n < 9_000 || n > 11_000 {
t.Errorf("option %d picked %d times, want about 10000", i, n)
}
}
}
// Missing tiers do not dilute the odds: with only 4★ and 5★ options, the
// 4★ share is 51/(51+6).
func TestPickGachaOption_RenormalisesPresentTiers(t *testing.T) {
options := []gachaOption{{"five", 5}, {"four", 4}}
rng := rand.New(rand.NewPCG(5, 6))
five := 0
const rolls = 100_000
for range rolls {
if pickGachaOption(options, rng) == 0 {
five++
}
}
got := float64(five) / rolls
if want := 6.0 / 57; got < want*0.9 || got > want*1.1 {
t.Fatalf("5★ share = %.4f, want about %.4f", got, want)
}
}
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.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, text))
if got := rb.LastSent().Text(); got != gachaUsage {
t.Errorf("gacha reply = %q, want usage %q", got, gachaUsage)
}
})
}
}
func TestGacha_NotConfiguredRepliesWithStars(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha Pizza*5"))
calls := rb.Sent()
if len(calls) != 1 || calls[0].Method != "sendMessage" || calls[0].Text() != "★★★★★ Pizza" {
t.Fatalf("calls = %+v, want a single ★★★★★ Pizza reply", calls)
}
}
func TestGacha_UsesRemoteAPIWhenConfigured(t *testing.T) {
var got gachaAPIRequest
var gotPath, gotAuthorization string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = 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", "video/mp4")
_, _ = w.Write(mp4Bytes)
}))
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, "/gacha Pho*4"))
if gotPath != "/api/gacha" {
t.Fatalf("path = %q, want /api/gacha", gotPath)
}
if gotAuthorization != "Bearer remote-token" {
t.Fatalf("Authorization = %q, want bearer token", gotAuthorization)
}
want := gachaAPIRequest{Label: "Pho", Rarity: 4, FPS: gachaRemoteFPS, Width: gachaRemoteWidth}
if got != want {
t.Fatalf("request = %+v, want %+v", got, want)
}
sent := rb.Sent()
if len(sent) != 3 {
t.Fatalf("calls = %+v, want placeholder, sendAnimation, deleteMessage", sent)
}
if sent[0].Method != "sendMessage" || sent[0].Text() != gachaPlaceholder {
t.Fatalf("first call = %+v, want placeholder %q", sent[0], gachaPlaceholder)
}
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)
}
if want := `Result: <span class="tg-spoiler">★★★★ Pho</span>`; call.Form["caption"] != want {
t.Fatalf("caption = %q, want %q", call.Form["caption"], want)
}
for field, want := range map[string]string{"parse_mode": "HTML", "duration": "7", "width": "640", "height": "360"} {
if got := call.Form[field]; got != want {
t.Fatalf("%s = %q, want %q", field, got, want)
}
}
}
func TestGacha_RemoteFailureFallsBackToText(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.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/gacha Rice"))
sent := rb.Sent()
if len(sent) != 2 {
t.Fatalf("calls = %+v, want placeholder then in-place edit", sent)
}
if sent[1].Method != "editMessageText" || sent[1].Text() != "★★★ Rice" {
t.Fatalf("fallback call = %+v, want editMessageText ★★★ Rice", sent[1])
}
}
func TestGacha_SendAnimationFailureFallsBackToText(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write(mp4Bytes)
}))
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, "/gacha Rice"))
call := rb.LastSent()
if call.Method != "editMessageText" || call.Text() != "★★★ Rice" {
t.Fatalf("fallback call = %+v, want editMessageText ★★★ Rice", call)
}
}
func TestGacha_ForwardsMessageThreadID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "video/mp4")
_, _ = w.Write(mp4Bytes)
}))
defer server.Close()
t.Setenv(wheelOfNamesAPIURLEnv, server.URL+"/api/gif")
rb, _ := installMisc(t, 999)
update := testutil.NewSupergroupMessage(-100, 7, "/gacha Rice")
update.Message.MessageThreadID = 42
rb.Bot.ProcessUpdate(context.Background(), update)
animations := 0
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 call.Method == "sendAnimation" {
animations++
}
}
}
if animations != 1 {
t.Fatalf("sendAnimation calls = %d, want 1", animations)
}
}
+3 -1
View File
@@ -1,7 +1,8 @@
// 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), /ff (protected give-up-on-T1 rant), /xlt1 (public
// picker with optional GIF), /gacha (public Genshin-style wish picker with
// optional MP4), /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.
@@ -56,6 +57,7 @@ func New(deps modules.Deps) modules.Module {
pingStatsCommand(store),
randomCommand(),
wheelOfNamesCommand(),
gachaCommand(),
ffCommand(),
xlt1Command(),
giaxangCommand(),
+1
View File
@@ -25,6 +25,7 @@ func TestNew_RegistersExpectedCommands(t *testing.T) {
"ping_stats": modules.VisibilityProtected,
"random": modules.VisibilityPublic,
"wheelofnames": modules.VisibilityPublic,
"gacha": modules.VisibilityPublic,
"ff": modules.VisibilityProtected,
"xlt1": modules.VisibilityPublic,
"giaxang": modules.VisibilityPublic,