Files
tiennm99bot/internal/modules/validate.go
T
tiennm99 6f30d13b2d feat(noitu): add the noitu HTML5 Telegram game
Serve the BotFather game noitu from the bot's HTTP server under
/games/noitu when GAME_BASE_URL is set. /noitu sends the game, Play
answers with a signed, expiring link, and the server validates every
word against the embedded noitu dictionary (CC BY-SA 4.0), runs the
bot opponent and turn timer, and reports the score with setGameScore.

Modules can now register game-short-name callbacks and HTTP routes.
2026-10-09 14:32:57 +07:00

109 lines
3.4 KiB
Go

package modules
import (
"fmt"
"regexp"
"strings"
"unicode/utf8"
)
var commandNameRe = regexp.MustCompile(`^[a-z0-9_]{1,32}$`)
// gameShortNameRe mirrors BotFather's rule for a game's short name.
var gameShortNameRe = regexp.MustCompile(`^[A-Za-z0-9_]{3,64}$`)
const telegramCommandDescriptionMaxRunes = 256
func validateCommand(c Command) error {
if !commandNameRe.MatchString(c.Name) {
return fmt.Errorf("command name %q must match %s", c.Name, commandNameRe)
}
switch c.Visibility {
case VisibilityPublic, VisibilityProtected, VisibilityPrivate, VisibilityUnlisted:
default:
return fmt.Errorf("command %q: unknown visibility %d", c.Name, c.Visibility)
}
if strings.TrimSpace(c.Description) == "" {
return fmt.Errorf("command %q: description is required", c.Name)
}
if strings.ContainsAny(c.Description, "\r\n") {
return fmt.Errorf("command %q: description must be single-line", c.Name)
}
if strings.ContainsAny(c.Parameters, "\r\n") {
return fmt.Errorf("command %q: parameters must be single-line", c.Name)
}
if c.Visibility == VisibilityPublic && utf8.RuneCountInString(c.TelegramMenuDescription()) > telegramCommandDescriptionMaxRunes {
return fmt.Errorf("command %q: Telegram menu description exceeds %d characters", c.Name, telegramCommandDescriptionMaxRunes)
}
if c.Handler == nil {
return fmt.Errorf("command %q: handler is nil", c.Name)
}
return nil
}
func validateCron(c Cron) error {
if c.Name == "" {
return fmt.Errorf("cron: name is required")
}
if c.Handler == nil {
return fmt.Errorf("cron %q: handler is nil", c.Name)
}
return nil
}
func validateCallback(c Callback) error {
if strings.TrimSpace(c.Prefix) == "" {
return fmt.Errorf("callback: prefix is required")
}
if len(c.Prefix) > 32 || strings.ContainsAny(c.Prefix, "\r\n\x00") {
return fmt.Errorf("callback prefix %q is invalid", c.Prefix)
}
switch c.Visibility {
case VisibilityPublic, VisibilityProtected, VisibilityPrivate, VisibilityUnlisted:
default:
return fmt.Errorf("callback prefix %q: unknown visibility %d", c.Prefix, c.Visibility)
}
if c.Handler == nil {
return fmt.Errorf("callback prefix %q: handler is nil", c.Prefix)
}
return nil
}
func validVisibility(v Visibility) bool {
switch v {
case VisibilityPublic, VisibilityProtected, VisibilityPrivate, VisibilityUnlisted:
return true
}
return false
}
func validateGame(g Game) error {
if !gameShortNameRe.MatchString(g.ShortName) {
return fmt.Errorf("game short name %q must match %s", g.ShortName, gameShortNameRe)
}
if !validVisibility(g.Visibility) {
return fmt.Errorf("game %q: unknown visibility %d", g.ShortName, g.Visibility)
}
if g.Handler == nil {
return fmt.Errorf("game %q: handler is nil", g.ShortName)
}
return nil
}
// validateRoute keeps a module's HTTP routes inside its own /games/<module>/
// subtree. Method and host patterns are refused so the prefix check below sees
// the path itself.
func validateRoute(module string, r Route) error {
if r.Handler == nil {
return fmt.Errorf("route %q: handler is nil", r.Pattern)
}
if r.Pattern == "" || !strings.HasPrefix(r.Pattern, "/") || strings.ContainsAny(r.Pattern, " \t\r\n") {
return fmt.Errorf("route %q: pattern must be a path starting with / (no method or host)", r.Pattern)
}
prefix := "/games/" + module + "/"
if !strings.HasPrefix(r.Pattern, prefix) {
return fmt.Errorf("route %q: pattern must start with %s", r.Pattern, prefix)
}
return nil
}