feat(misc): add /trongtruonghop disclaimer command

Vietnamese disclaimer one-liner. Interpolates a target name (default "VNG")
and the sender — @username when set, else tg://user?id link with the display
name. User-supplied text HTML-escaped; reply routed through ReplyHTML to keep
forum-topic threading.
This commit is contained in:
tiennm99 committed 2026-05-16 14:51:26 +07:00
1 parent 3dc6248726
commit ee5a0cc954
7 files changed
+393 -4

No files matched your search

+79
View File
@@ -6,6 +6,8 @@ import (
"testing"
"time"
"github.com/go-telegram/bot/models"
"github.com/tiennm99/miti99bot/internal/modules"
"github.com/tiennm99/miti99bot/internal/storage"
"github.com/tiennm99/miti99bot/internal/testutil"
@@ -86,6 +88,83 @@ func TestMstats_DeniedToNonAdmin(t *testing.T) {
}
}
// trongTruongHopUpdate 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 {
t.Helper()
u := testutil.NewPrivateMessage(from.ID, text)
u.Message.From = from
return u
}
func TestTrongTruongHop_DefaultArgUsesVNG(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/trongtruonghop",
&models.User{ID: 7, Username: "boss", FirstName: "Boss"}))
got := rb.LastSent().Text()
if !strings.Contains(got, "VNG") {
t.Errorf("reply missing default target VNG: %q", got)
}
if n := strings.Count(got, "@boss"); n != 2 {
t.Errorf("reply mentions @boss %d times, want 2: %q", n, got)
}
}
func TestTrongTruongHop_CustomArg(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/trongtruonghop Acme Corp",
&models.User{ID: 7, Username: "boss", FirstName: "Boss"}))
got := rb.LastSent().Text()
if !strings.Contains(got, "Acme Corp") {
t.Errorf("reply missing custom arg Acme Corp: %q", got)
}
if strings.Contains(got, "VNG") {
t.Errorf("reply unexpectedly contains default VNG: %q", got)
}
}
func TestTrongTruongHop_HTMLEscapesArg(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/trongtruonghop <script>",
&models.User{ID: 7, Username: "boss", FirstName: "Boss"}))
got := rb.LastSent().Text()
if !strings.Contains(got, "&lt;script&gt;") {
t.Errorf("reply did not HTML-escape arg: %q", got)
}
if strings.Contains(got, "<script>") {
t.Errorf("reply leaked raw <script>: %q", got)
}
}
func TestTrongTruongHop_NoUsernameFallsBackToLink(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/trongtruonghop",
&models.User{ID: 42, FirstName: "Anh"})) // no Username
got := rb.LastSent().Text()
wantLink := `<a href="tg://user?id=42">Anh</a>`
if n := strings.Count(got, wantLink); n != 2 {
t.Errorf("reply contains link %q %d times, want 2: %q", wantLink, n, got)
}
}
func TestTrongTruongHop_EmptyDisplayNameFallsBackToThanhVien(t *testing.T) {
rb, _ := installMisc(t, 999)
rb.Bot.ProcessUpdate(context.Background(), trongTruongHopUpdate(t, "/trongtruonghop",
&models.User{ID: 42})) // no Username, no FirstName/LastName
got := rb.LastSent().Text()
wantLink := `<a href="tg://user?id=42">thành viên</a>`
if n := strings.Count(got, wantLink); n != 2 {
t.Errorf("reply contains fallback link %d times, want 2: %q", n, got)
}
}
func TestFortytwo_OwnerOnly(t *testing.T) {
rb, _ := installMisc(t, 999)
+50
View File
@@ -7,6 +7,8 @@ import (
"context"
"errors"
"fmt"
"html"
"strings"
"time"
"github.com/go-telegram/bot"
@@ -21,6 +23,14 @@ import (
// lastPingKey is the per-module KV key /ping writes and /mstats reads.
const lastPingKey = "last_ping"
// defaultTarget is the substituted "investigator" name when /trongtruonghop is
// invoked without an argument.
const defaultTarget = "VNG"
// trongTruongHopTemplate is the disclaimer rendered by /trongtruonghop. Three
// %s slots: target (escaped), sender mention, sender mention.
const trongTruongHopTemplate = "Trong trường hợp nhóm này bị điều tra bởi %s, %s khẳng định không liên quan tới nhóm hoặc những cá nhân khác trong nhóm này. %s không rõ tại sao lại có mặt ở đây vào thời điểm này, có lẽ tài khoản đã được thêm bởi một bên thứ ba."
// lastPing mirrors the JS bot's wire format: { at: <ms-since-epoch number> }.
// Stored as int64 ms-epoch (not time.Time → RFC3339) so a future cross-runtime
// KV export/import migration round-trips byte-for-byte.
@@ -36,6 +46,7 @@ func New(deps modules.Deps) modules.Module {
pingCommand(deps),
mstatsCommand(deps),
fortytwoCommand(),
trongTruongHopCommand(),
},
}
}
@@ -87,6 +98,45 @@ func mstatsCommand(deps modules.Deps) modules.Command {
}
}
// senderMention renders the mention used inside the trongtruonghop template.
// Prefer @username (Telegram resolves it server-side and enforces a safe
// charset). Fall back to a tg://user?id link with the user's display name when
// the account has no username; escape the name because first/last names can
// legitimately contain '<' or '&'.
func senderMention(u *models.User) string {
if u == nil {
return "thành viên"
}
if u.Username != "" {
return "@" + u.Username
}
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
if name == "" {
name = "thành viên"
}
return fmt.Sprintf(`<a href="tg://user?id=%d">%s</a>`, u.ID, html.EscapeString(name))
}
func trongTruongHopCommand() modules.Command {
return modules.Command{
Name: "trongtruonghop",
Visibility: modules.VisibilityPublic,
Description: "Phát biểu disclaimer cho thành viên hiện tại",
Handler: func(ctx context.Context, b *bot.Bot, update *models.Update) error {
if update.Message == nil || update.Message.From == nil {
return nil
}
arg := strings.TrimSpace(chathelper.ArgAfterCommand(update.Message.Text))
if arg == "" {
arg = defaultTarget
}
mention := senderMention(update.Message.From)
text := fmt.Sprintf(trongTruongHopTemplate, html.EscapeString(arg), mention, mention)
return chathelper.ReplyHTML(ctx, b, update.Message, text)
},
}
}
func fortytwoCommand() modules.Command {
return modules.Command{
Name: "fortytwo",
+4 -3
View File
@@ -18,9 +18,10 @@ func TestNew_RegistersExpectedCommands(t *testing.T) {
mod := New(deps)
want := map[string]modules.Visibility{
"ping": modules.VisibilityPublic,
"mstats": modules.VisibilityProtected,
"fortytwo": modules.VisibilityPrivate,
"ping": modules.VisibilityPublic,
"mstats": modules.VisibilityProtected,
"fortytwo": modules.VisibilityPrivate,
"trongtruonghop": modules.VisibilityPublic,
}
if len(mod.Commands) != len(want) {
t.Fatalf("commands count = %d, want %d", len(mod.Commands), len(want))