feat(misc): add /xlt1 đơn xin lỗi T1 petition template

Sequel to /ff: the same fan who panicked and surrendered mid-series now
files an administrative petition because the team came back. Punchlines
are quoted back as the sender's own words and retracted rather than
asserted fresh.

Shaped as a Vietnamese đơn từ parody — quốc hiệu, Kính gửi, Nội dung sự
việc, Tôi xin cam kết, signature block — since the joke is the
bureaucratic form, not the wording. Carries no scoreline or title count
so it does not go stale next split.

Reuses senderMention for the Tôi tên là field and the signature, which
made the trongTruongHopUpdate test helper name wrong; renamed it
messageFrom now that two command families share it.
This commit is contained in:
2026-09-02 16:03:20 +07:00
parent 51b2082799
commit 317088ae92
5 changed files with 135 additions and 16 deletions
+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`, `/stickerid` |
| `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/ff`, `/the_answer`, `/trongtruonghop` + `/tth`, `/trongtruonghopvng` + `/tthvng` disclaimers |
| `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/ff`, `/xlt1`, `/the_answer`, `/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 18002199 only |
| `wordle` | Daily Wordle game |
| `loldle` | League-of-Legends "guess the champion" |
+46 -10
View File
@@ -368,11 +368,11 @@ func TestWheelOfNames_ForwardsMessageThreadID(t *testing.T) {
}
}
// trongTruongHopUpdate is the inline counterpart of testutil.NewPrivateMessage
// 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
// NewPrivateMessage and overwriting From.
func trongTruongHopUpdate(t *testing.T, text string, from *models.User) *models.Update {
func messageFrom(t *testing.T, text string, from *models.User) *models.Update {
t.Helper()
u := testutil.NewPrivateMessage(from.ID, text)
u.Message.From = from
@@ -381,7 +381,7 @@ func trongTruongHopUpdate(t *testing.T, text string, from *models.User) *models.
func TestTrongTruongHop_DefaultText(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/trongtruonghop",
rb.Bot.ProcessUpdate(context.Background(), messageFrom(t, "/trongtruonghop",
&models.User{ID: 7, Username: "boss", FirstName: "Boss"}))
got := rb.LastSent().Text()
@@ -393,7 +393,7 @@ func TestTrongTruongHop_DefaultText(t *testing.T) {
func TestTrongTruongHop_CustomArg(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/trongtruonghop Acme Corp",
rb.Bot.ProcessUpdate(context.Background(), messageFrom(t, "/trongtruonghop Acme Corp",
&models.User{ID: 7, Username: "boss", FirstName: "Boss"}))
got := rb.LastSent().Text()
@@ -410,7 +410,7 @@ func TestTrongTruongHop_CustomArg(t *testing.T) {
func TestTTHAlias_CustomArg(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/tth Acme Corp",
rb.Bot.ProcessUpdate(context.Background(), messageFrom(t, "/tth Acme Corp",
&models.User{ID: 7, Username: "boss", FirstName: "Boss"}))
got := rb.LastSent().Text()
@@ -424,7 +424,7 @@ func TestTTHAlias_CustomArg(t *testing.T) {
func TestTrongTruongHop_HTMLEscapesArg(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/trongtruonghop <script>",
rb.Bot.ProcessUpdate(context.Background(), messageFrom(t, "/trongtruonghop <script>",
&models.User{ID: 7, Username: "boss", FirstName: "Boss"}))
got := rb.LastSent().Text()
@@ -438,7 +438,7 @@ func TestTrongTruongHop_HTMLEscapesArg(t *testing.T) {
func TestTrongTruongHopVNG_DefaultText(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/trongtruonghopvng ignored arg",
rb.Bot.ProcessUpdate(context.Background(), messageFrom(t, "/trongtruonghopvng ignored arg",
&models.User{ID: 7, Username: "boss", FirstName: "Boss"}))
got := rb.LastSent().Text()
@@ -450,7 +450,7 @@ func TestTrongTruongHopVNG_DefaultText(t *testing.T) {
func TestTTHVNGAlias_DefaultText(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/tthvng ignored arg",
rb.Bot.ProcessUpdate(context.Background(), messageFrom(t, "/tthvng ignored arg",
&models.User{ID: 7, Username: "boss", FirstName: "Boss"}))
got := rb.LastSent().Text()
@@ -462,7 +462,7 @@ func TestTTHVNGAlias_DefaultText(t *testing.T) {
func TestTrongTruongHop_NoUsernameFallsBackToDisplayNameMention(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/trongtruonghop",
rb.Bot.ProcessUpdate(context.Background(), messageFrom(t, "/trongtruonghop",
&models.User{ID: 42, FirstName: "Anh", LastName: "Le"}))
got := rb.LastSent().Text()
@@ -474,7 +474,7 @@ func TestTrongTruongHop_NoUsernameFallsBackToDisplayNameMention(t *testing.T) {
func TestTrongTruongHopVNG_NoUsernameFallsBackToDisplayNameMention(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/trongtruonghopvng ignored arg",
rb.Bot.ProcessUpdate(context.Background(), messageFrom(t, "/trongtruonghopvng ignored arg",
&models.User{ID: 42, FirstName: "Anh", LastName: "Le"}))
got := rb.LastSent().Text()
@@ -514,6 +514,42 @@ func TestFF_UppercaseFormDispatches(t *testing.T) {
}
}
func TestXLT1_DeniedToNonAdmin(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/xlt1"))
if calls := rb.Sent(); len(calls) != 0 {
t.Errorf("non-admin /xlt1 replied: %+v", calls)
}
}
func TestXLT1_RepliesPetitionWithSenderMention(t *testing.T) {
rb, _ := installMisc(t, 999)
// Args are ignored — only the sender mention is substituted, into both the
// "Tôi tên là" field and the signature block.
rb.Bot.ProcessUpdate(context.Background(), messageFrom(t, "/xlt1 ignored arg",
&models.User{ID: 999, Username: "miti99"}))
got := rb.LastSent().Text()
if want := fmt.Sprintf(xlt1Template, "@miti99", "@miti99"); got != want {
t.Errorf("/xlt1 reply = %q, want the xlt1 petition", got)
}
if n := strings.Count(got, "@miti99"); n != 2 {
t.Errorf("reply mentions sender %d times, want 2: %q", n, got)
}
}
// Rendered with parse_mode HTML for the tg://user mention, so any raw <, > or &
// left in the prose would make Telegram reject the send.
func TestXLT1_TemplateHasNoUnescapedHTML(t *testing.T) {
for _, ch := range []string{"<", ">", "&"} {
if strings.Contains(xlt1Template, ch) {
t.Errorf("xlt1Template contains raw %q, unsafe for parse_mode HTML", ch)
}
}
}
func TestTheAnswer_OwnerOnly(t *testing.T) {
rb, _ := installMisc(t, 999)
+7 -5
View File
@@ -1,8 +1,9 @@
// Package misc is a small stub module that proves the framework end-to-end:
// /ping (public, exercises KV write), /ping_stats (protected, exercises KV
// read), /random (public random picker), /wheelofnames (public wheel picker
// with optional GIF), /ff (protected give-up-on-T1 rant), /the_answer (private
// easter egg), and small public disclaimer commands.
// with optional GIF), /ff (protected give-up-on-T1 rant), /xlt1 (protected
// apologise-to-T1 petition, the sequel to /ff), /the_answer (private easter
// egg), and small public disclaimer commands.
package misc
import (
@@ -55,6 +56,7 @@ func New(deps modules.Deps) modules.Module {
randomCommand(),
wheelOfNamesCommand(),
ffCommand(),
xlt1Command(),
theAnswerCommand(),
disclaimerCommand("trongtruonghop", "Phát biểu disclaimer mặc định", defaultTarget, true),
disclaimerCommand("tth", "Phát biểu disclaimer mặc định", defaultTarget, true),
@@ -110,9 +112,9 @@ func pingStatsCommand(store storage.DocStore[lastPing]) modules.Command {
}
}
// senderMention renders the mention used inside disclaimer templates. Prefer
// @username; when absent, link the sender's display name so Telegram still
// mentions the account.
// senderMention renders the mention used inside the disclaimer and /xlt1
// templates. Prefer @username; when absent, link the sender's display name so
// Telegram still mentions the account.
func senderMention(u *models.User) string {
if u == nil {
return "thành viên"
+1
View File
@@ -26,6 +26,7 @@ func TestNew_RegistersExpectedCommands(t *testing.T) {
"random": modules.VisibilityPublic,
"wheelofnames": modules.VisibilityPublic,
"ff": modules.VisibilityProtected,
"xlt1": modules.VisibilityProtected,
"the_answer": modules.VisibilityPrivate,
"trongtruonghop": modules.VisibilityPublic,
"tth": modules.VisibilityPublic,
+80
View File
@@ -0,0 +1,80 @@
package misc
import (
"context"
"fmt"
"github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/modules/util/chathelper"
)
// xlt1Template is the "đơn xin lỗi T1" văn mẫu /xlt1 replies with. Two %s slots,
// both the sender mention: the "Tôi tên là" field and the signature block.
//
// The joke is the form, not the words: Vietnamese apology memes parody an
// administrative petition — quốc hiệu, "Kính gửi", "Nội dung sự việc",
// "Tôi xin cam kết", a signature block — and the laugh comes from that shape
// wrapping something trivial. So the bureaucratic scaffolding stays intact even
// where it reads stiff; trimming it is what would break the bit.
//
// It is deliberately the sequel to ffTemplate: the same fan who panicked and
// typed /ff mid-series now has to file paperwork because the team came back.
// That is why the punchlines are quoted back as things the sender said
// ("Tếu 1", "rạp xiếc trung ương", "gói Premium hết hạn từ tháng 11") rather
// than asserted — /xlt1 retracts what /ff claimed.
//
// No concrete title counts or tournament results appear anywhere: a static
// template that names a scoreline goes stale the next split, while "sự thật đã
// chứng minh tôi sai" stays true every season.
//
// Rendered as HTML for the tg://user mention, so the prose must stay free of
// raw <, > and & or Telegram rejects the send.
const xlt1Template = `CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM
Độc lập Tự do Hạnh phúc
━━━━━━━ oOo ━━━━━━━
🙏 ĐƠN XIN LỖI 🙏
Kính gửi:
Ban lãnh đạo T1 Esports
Toàn thể Tê Con gần xa
Tôi tên là: %s
Chức vụ: Fan ruột (bán thời gian), chuyên gia trù đội nhà
Địa chỉ: Nhóm chat này
NỘI DUNG SỰ VIỆC:
Trong lúc trận đấu còn chưa kết thúc, tôi đã hoảng loạn gõ /ff, gọi đội nhà là "Tếu 1", là "rạp xiếc trung ương", là "di sản văn hóa hài kịch của LCK". Tôi còn tuyên bố chắc nịch rằng "gói Premium hết hạn từ tháng 11", rằng năm nay hết vía, rồi tắt stream đi ngủ cho đỡ đau.
Sau đó sự thật đã chứng minh tôi sai. Toàn bộ trách nhiệm thuộc về tôi và cái miệng của tôi.
TÔI XIN CAM KẾT:
1. Không trù đội nhà trước khi Nexus nổ.
2. Không tắt stream sớm, dù bảng tỉ số có xấu tới đâu.
3. Không đếm cúp hộ người khác khi tủ cúp nhà mình còn đang trống.
4. Chỉ nhắc lại chuyện "hết thời" khi... thật sự hết thời.
Kính mong Ban lãnh đạo và toàn thể cộng đồng xem xét, đại xá cho tôi lần này.
Tôi xin trân trọng cảm ơn! 🙏
Người làm đơn
%s`
func xlt1Command() modules.Command {
return modules.Command{
Name: "xlt1",
Visibility: modules.VisibilityProtected,
Description: "Văn mẫu đơn xin lỗi T1 — dành cho lúc trù xong phải quay xe",
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
if update.Message == nil {
return nil
}
mention := senderMention(update.Message.From)
return chathelper.ReplyHTML(ctx, b, update.Message,
fmt.Sprintf(xlt1Template, mention, mention))
},
}
}