mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-12 12:19:35 +00:00
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:
@@ -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 |
|
||||
|
||||
@@ -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, "<script>") {
|
||||
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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 `<script>` 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 `<script>` (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 `"` / `'` for `"` / `'`. Telegram's HTML parser accepts **all numeric character references** in addition to the four named entities (`<`, `>`, `&`, `"`), 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.
|
||||
Reference in New Issue
Block a user