mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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:
1 parent
577c0e3a70
commit
11f458fee5
18 files changed
+490
-328
No files matched your search
+1
-1
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"><Alice & Bob></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,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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package misc
|
||||
package random
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -13,7 +13,18 @@ const (
|
||||
gachaRemoteFPS = 24
|
||||
gachaRemoteWidth = 640
|
||||
gachaRemoteHeight = 360
|
||||
gachaRemoteDuration = 7
|
||||
)
|
||||
|
||||
// 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")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"><Alice & Bob></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)
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,4 @@
|
||||
package misc
|
||||
package random
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user