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:
2026-05-16 14:51:26 +07:00
parent 145a7261f5
commit 464dfd47b9
7 changed files with 393 additions and 4 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ Plug-n-play Telegram bot framework in Go. Runs on AWS Lambda + DynamoDB + EventB
| Module | What it does |
|---|---|
| `util` | `/help`, `/info`, `/stickerid` |
| `misc` | Coin flip, dice, RNG utilities |
| `misc` | `/ping`, `/mstats`, `/trongtruonghop` disclaimer |
| `wordle` | Daily Wordle game |
| `loldle` | League-of-Legends "guess the champion" |
| `lolschedule` | Pro-match schedule + daily push |
+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))
@@ -0,0 +1,138 @@
# Phase 01 — Add `/trongtruonghop` to misc module
**Status:** Planned
**Priority:** Low (additive, no migration, no infra change)
**Mode:** fast
## Context links
- Module under change: `internal/modules/misc/misc.go`
- Helper API used: `internal/modules/util/chathelper/chathelper.go` (`ArgAfterCommand`, `ReplyHTML`)
- Visibility / validation rules: `internal/modules/module.go`, `internal/modules/validate.go`
- Forum-topic reply-routing fix that mandates `chathelper.Reply*` over raw `SendMessage`: commit 3a12615
## Overview
Stateless command. Two interpolation points (`<text>`, `@<sender>` × 2) into a fixed Vietnamese template. No KV. No new dependency. No new helper.
## Key insights
- `chathelper.ArgAfterCommand` already strips command + `@botname` correctly — covers `/trongtruonghop arg`, `/trongtruonghop@miti99bot arg`, etc.
- `chathelper.ReplyHTML` already forwards `MessageThreadID` (forum-topic safe). Do NOT bypass.
- Telegram's HTML parser accepts `@username` literally (it's not a tag) and resolves the mention server-side. Mixing `@username` with `<a href="tg://user?id=…">Name</a>` in the same message is allowed and standard.
- `From.Username` can be empty (account never set one). `From.FirstName` is also optional (deleted accounts). Both can be empty simultaneously — handle.
## Requirements
### Functional
- Command name: `trongtruonghop`, Visibility: `VisibilityPublic`, Description: `"Phát biểu disclaimer cho thành viên hiện tại"` (Vietnamese — keep short, fits `/help`).
- On `/trongtruonghop [text]`:
1. `arg := strings.TrimSpace(chathelper.ArgAfterCommand(msg.Text))`
2. If `arg == ""``arg = defaultTarget` (= `"VNG"`).
3. Resolve sender mention from `msg.From` (see algorithm below).
4. Send single HTML message via `chathelper.ReplyHTML`.
- If `msg == nil` or `msg.From == nil` → return `nil` (silent skip).
### Sender-mention algorithm
```go
func senderMention(u *models.User) string {
if u.Username != "" {
return "@" + u.Username // safe verbatim; charset is [A-Za-z0-9_]
}
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))
}
```
### Template
Package-level `const`:
```go
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."
const defaultTarget = "VNG"
```
Render: `fmt.Sprintf(trongTruongHopTemplate, html.EscapeString(arg), mention, mention)`.
## Architecture
No new types, no state, no new files. Single command added to `New(deps modules.Deps)` in `misc.go`. `deps.KV` not used by this command but `New` already receives it for the other two — no signature change.
## Related code files
**Modify:**
- `internal/modules/misc/misc.go`
- `internal/modules/misc/misc_test.go`
- `internal/modules/misc/handlers_test.go`
- `README.md` (misc-row description only)
**Create:** none.
**Delete:** none.
## Implementation steps
1. **misc.go**
- Add imports: `fmt`, `html`, `strings` (only those not already imported).
- Add `const trongTruongHopTemplate` and `const defaultTarget` near the existing `const lastPingKey`.
- Add private function `senderMention(*models.User) string` (algorithm above).
- Add `trongTruongHopCommand() modules.Command` (no `deps` needed — stateless).
- Append it to the `Commands` slice in `New`.
2. **misc_test.go**
- Extend the `want` map in `TestNew_RegistersExpectedCommands` with `"trongtruonghop": modules.VisibilityPublic`. The existing length check then implicitly verifies registration.
3. **handlers_test.go** — new test cases (reuse existing `installMisc`):
- `TestTrongTruongHop_DefaultArgUsesVNG`: send `/trongtruonghop` from user 999 with username `boss`. Assert reply contains `"VNG"` and `"@boss"` (occurring twice).
- `TestTrongTruongHop_CustomArg`: send `/trongtruonghop Acme Corp`. Assert reply contains `"Acme Corp"` and not `"VNG"`.
- `TestTrongTruongHop_HTMLEscapesArg`: send `/trongtruonghop <script>`. Assert reply contains `&lt;script&gt;` and not the literal `<script>`.
- `TestTrongTruongHop_NoUsernameFallsBackToLink`: build a custom update where `From.Username == ""`, `FirstName == "Anh"`. Assert reply contains `<a href="tg://user?id=42">Anh</a>` (twice).
- `TestTrongTruongHop_EmptyDisplayNameFallsBackToThanhVien`: `Username == ""`, `FirstName == ""`, `LastName == ""`. Assert reply contains `>thành viên</a>`.
Note: existing `testutil.NewPrivateMessage` sets `FirstName: "Test"` and no `Username` — for username-bearing cases, build the update inline (see `NewPrivateMessage` source for the shape, ~10 lines).
4. **README.md** — change misc table row from `Coin flip, dice, RNG utilities` to `Coin flip, dice, RNG utilities, /trongtruonghop disclaimer`.
5. Compile + lint + test:
- `go vet ./...`
- `make test`
- `golangci-lint run ./...`
## Todo list
- [ ] Add constants + helper + command in `misc.go`
- [ ] Register command in `New`
- [ ] Update `misc_test.go` `want` map
- [ ] Add 5 handler-level tests in `handlers_test.go`
- [ ] Update README misc row
- [ ] `go vet`, `make test`, lint clean
- [ ] Smoke-test in a real Telegram group post-deploy (manual)
## Success criteria
- All new tests pass; existing tests still pass.
- `/help` automatically lists the command (registry-driven, no extra work).
- Sending `/trongtruonghop` in any chat (private / group / supergroup / forum-topic) produces exactly one reply that mentions the sender twice and the target once, all routed to the originating topic.
## Risk assessment
| Risk | Mitigation |
|---|---|
| HTML parse error if escape is forgotten | Centralise escape in the `Sprintf` call; covered by `TestTrongTruongHop_HTMLEscapesArg`. |
| `@<sender>` for username-bearing user breaks if username contains unexpected chars | Telegram enforces `[A-Za-z0-9_]{5,32}` server-side — no escaping required. Documented in handler comment. |
| Test that constructs a custom `Update` drifts from `testutil.NewPrivateMessage` shape | Keep the custom builder local to the test file; only override `From` fields. Don't add a public helper for one caller. |
## Security considerations
- Auth: none — public command.
- Input handling: user-supplied `<text>` is HTML-escaped before interpolation. No SQL / KV / shell surface.
- Output: HTML mode. Mention link uses Telegram-internal `tg://user?id=` scheme, which the client resolves locally — no external network call.
## Next steps
After this phase merges + deploys, no follow-up. Command is self-contained.
@@ -0,0 +1,79 @@
# /trongtruonghop — disclaimer one-liner in misc module
**Date:** 2026-05-16
**Slug:** `260516-1409-trongtruonghop-command`
**Status:** Planned
**Mode:** fast (single phase, well-scoped add-on to existing module)
## Goal
Add a public `/trongtruonghop` command to the `misc` module. When invoked it
replies with a fixed Vietnamese disclaimer template, interpolating:
- `<text>` — argument after the command. Empty → `VNG`.
- `@<sender>` — mention of the user who sent the command. Resolved from
`update.Message.From` (Telegram guarantees this on group/private text
messages).
Output (single message):
```
Trong trường hợp nhóm này bị điều tra bởi <text>, @<sender> 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. @<sender> 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.
```
## Phases
| # | Phase | File |
|---|-------|------|
| 01 | Add `trongtruonghop` command to misc module | `phase-01-add-trongtruonghop-command.md` |
## Files
- `internal/modules/misc/misc.go` — register new `trongtruonghopCommand()` in `New`; implement handler.
- `internal/modules/misc/handlers_test.go` — add coverage for default arg, custom arg, username vs no-username sender, HTML-escape of arg.
- `internal/modules/misc/misc_test.go` — extend `TestNew_RegistersExpectedCommands` map with the new command (VisibilityPublic).
- `README.md` — bump `misc` row description (single line) to mention the new command.
## Non-goals
- No KV interaction (this command is stateless).
- No new helpers in `chathelper``ArgAfterCommand` + `ReplyHTML` already cover everything we need.
- No localization framework — template is Vietnamese-only and inlined as a constant.
- No rate limiting beyond what the dispatcher already provides (it doesn't, and this is fine; output is a single short message).
## Decisions (locked)
| Question | Decision | Reason |
|---|---|---|
| Reply mode (plain vs HTML)? | **HTML** via `chathelper.ReplyHTML` | We need to mention users who lack a `@username` — only Telegram HTML `<a href="tg://user?id=…">` works for that. Plain `@username` for users with one is rendered verbatim by HTML mode and Telegram still resolves it. Single code path for both cases. |
| `@<sender>` formatting | If `From.Username != ""``@<username>` (literal, no HTML wrap). Else → `<a href="tg://user?id=<ID>">First Last</a>`. | Mirrors how Telegram itself renders mentions; `@username` is a native entity. `tg://user?id=` link is the documented fallback for username-less accounts. |
| Display name when no username | `strings.TrimSpace(FirstName + " " + LastName)`; if still empty → `"thành viên"` | Defensive — Telegram allows accounts with no first name (rare; deleted accounts). |
| Default `<text>` when arg empty | `"VNG"` (user spec) | Stored as a package-level const `defaultTarget` for visibility. |
| Visibility | `VisibilityPublic` | Joke/disclaimer command — usable by anyone in any chat the bot is in. |
| `<text>` sanitisation | `html.EscapeString` on the arg before interpolating | Arg is user-controlled. Without escaping, `<text>` containing `<` breaks the HTML parser and Telegram rejects the send (400). |
| Mention sanitisation | `@<username>` is `[A-Za-z0-9_]{5,32}` — safe verbatim. Display name path → `html.EscapeString` on the trimmed name. | First/last names can legitimately contain `<` / `&`. |
| `update.Message.From == nil` guard | Return nil (no reply) | Matches existing misc handlers' defensive shape. Channel posts (no `From`) are the only realistic path; we don't want to spam them. |
| Forum-topic routing | Use `chathelper.ReplyHTML(ctx, b, msg, …)` — it already forwards `MessageThreadID` | Locked by 3a12615 — every new reply MUST go through these helpers. |
## Success criteria
1. `/trongtruonghop` (no arg) in a private chat → reply contains `VNG` literally and `@<sender>` twice.
2. `/trongtruonghop SomeCompany` → reply contains `SomeCompany` and `@<sender>` twice.
3. `/trongtruonghop <script>` → reply renders `&lt;script&gt;` (verify via the recording bot's captured `Text`).
4. Sender without `Username` → reply contains `<a href="tg://user?id=…">FirstName</a>` instead of `@username`.
5. Sender with `Username` → reply contains `@username` (no `<a>` tag).
6. `make vet` + `make test` clean; `golangci-lint run ./...` clean.
7. `/help` lists `trongtruonghop` under `misc` (auto — registry-driven, no help-template change needed).
## Risks
| Risk | Mitigation |
|---|---|
| Telegram rejects HTML on malformed entity (e.g. unclosed `<a>`) | Build the mention via a single small helper that returns a closed tag; unit-test the helper directly. |
| User pastes very long text → message > 4096-char Telegram limit | The template is ~250 chars; arg would need to be ~3.6k to overflow. Acceptable risk for a joke command. Telegram returns 400 which `bot.SendMessage` propagates as an error; the dispatcher logs it. No silent failure. |
| Vietnamese diacritics in the template encoding | Source files are UTF-8 (verified in existing `wordle`/`loldle` strings). Inline the string verbatim — no escape sequences. |
| Command name `trongtruonghop` is unfamiliar | 14 chars, lowercase, alphanumeric — passes `validateCommand` regex per `internal/modules/validate_test.go:32`. |
## Unresolved questions
None.
@@ -0,0 +1,42 @@
# Code review — `/trongtruonghop` command
**Date:** 2026-05-16 14:26
**Scope:** unstaged diff (4 files, +134 / -4)
**Verdict:** ship as-is.
## Spec compliance
All 8 acceptance criteria verified against `internal/modules/misc/misc.go` and the new tests:
| # | Criterion | Verified at |
|---|---|---|
| 1 | Default `<text>``VNG` | `misc.go:130-132`; test `TestTrongTruongHop_DefaultArgUsesVNG` |
| 2 | Custom arg preserved + HTML-escaped | `misc.go:129,134`; tests `_CustomArg`, `_HTMLEscapesArg` |
| 3 | Username → `@username` literal (×2) | `misc.go:110-112`; `_DefaultArgUsesVNG` asserts `Count(@boss)==2` |
| 4 | No username → `<a href="tg://user?id=…">FirstName</a>` (×2) | `misc.go:113-117`; `_NoUsernameFallsBackToLink` |
| 5 | Empty display name → `thành viên` | `misc.go:113-116`; `_EmptyDisplayNameFallsBackToThanhVien` |
| 6 | Reply via `chathelper.ReplyHTML` (forum-topic safe) | `misc.go:135`; helper forwards `MessageThreadID` at `chathelper.go:81-83` |
| 7 | Silent return on `Message==nil`/`From==nil` | `misc.go:126-128` |
| 8 | Existing misc commands untouched | diff only appends a slice entry at `misc.go:49`; no edits to ping/mstats/fortytwo handlers |
## Adversarial checks
- **HTML injection on `<text>`:** `html.EscapeString` applied at the `Sprintf` call site (`misc.go:134`). All three `%s` slots are escape-safe: target is escaped; both mentions are either `@`+ASCII-restricted username or a closed `<a>` tag with an escaped name.
- **`html.EscapeString` vs. Telegram HTML spec:** stdlib emits `&#34;` / `&#39;` for `"` / `'`. Telegram's HTML parser accepts **all numeric character references** in addition to the four named entities (`&lt;`, `&gt;`, `&amp;`, `&quot;`), so the numeric forms render correctly. Confirmed via Telegram docs / community references. Net: stdlib is the right tool — slightly over-escapes vs. the minimum Telegram requires, which is strictly safer, not broken.
- **`@username` charset:** Telegram enforces `[A-Za-z0-9_]{5,32}`. None of those bytes need HTML escaping. The "safe verbatim" assertion holds for real Telegram traffic. (Test-only hostile `Username` would break — not a production threat.)
- **`tg://user?id=%d`:** `u.ID` is `int64``%d` is injection-proof.
- **Blast radius in `misc.go`:** no shared state, no init-order dependency, no signature change. New const + helper + factory func; only mutation is appending to the `Commands` slice. `TestNew_RegistersExpectedCommands` length-check would have caught any drop.
- **Test determinism:** no time/random/parallel. Inline `trongTruongHopUpdate` reuses `NewPrivateMessage` then overwrites `From`; the `botCommandEntity` is keyed off `text`, not `From`, so override is safe. All 5 new tests pass (re-ran locally, 0.033s).
- **README row:** previous text ("Coin flip, dice, RNG utilities") never matched the actual module — fix is accurate.
## Minor observations (non-blocking)
- `senderMention` `u == nil` branch is defensive dead code given the handler's `From == nil` guard; harmless and cheap.
- 4096-char Telegram limit: template ~260 chars + 2× mention (≤ ~70) + arg. User would need a ~3.7 KB arg to overflow; `b.SendMessage` would surface the 400 as a returned error, which the dispatcher logs. Acceptable.
## Unresolved questions
None.
**Status:** DONE
**Summary:** Implementation matches spec, escape boundaries are correct, no regression risk to sibling commands. Ship it.