mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 03:13:46 +00:00
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:
1 parent
53ca1e59b3
commit
858eaa066b
11 files changed
+540
-5
No files matched your search
+3
-2
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user