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.
This commit is contained in:
tiennm99 committed 2026-10-09 14:32:57 +07:00
1 parent fe552f43e4
commit 6f30d13b2d
52 files changed
+42452 -35

No files matched your search

+1
View File
@@ -73,6 +73,7 @@ func TestCommandDiscovery_AllPublicCommandsHaveSafeMetadata(t *testing.T) {
"loldle": "[champion]",
"monkeyd_crawl": "<url> [font_size]",
"monkeyd_tags": "<url>",
"noitu": "",
"random": "<option,...>",
"addsticker": "[emoji...]",
"alias": "<name>",
+21 -6
View File
@@ -1,7 +1,8 @@
// Command server runs tiennm99bot: it loads configuration from the environment,
// opens the storage backend, builds the module registry, and then serves
// Telegram updates by long polling while an in-process scheduler fires module
// crons. A small HTTP server answers the container health check.
// crons. A small HTTP server answers the container health check and serves
// module routes such as the noitu game page when they are enabled.
package main
import (
@@ -33,6 +34,7 @@ import (
"github.com/tiennm99/tiennm99bot/internal/modules/loldle"
"github.com/tiennm99/tiennm99bot/internal/modules/misc"
"github.com/tiennm99/tiennm99bot/internal/modules/monkeyd"
"github.com/tiennm99/tiennm99bot/internal/modules/noitu"
"github.com/tiennm99/tiennm99bot/internal/modules/random"
"github.com/tiennm99/tiennm99bot/internal/modules/stats"
"github.com/tiennm99/tiennm99bot/internal/modules/sticker"
@@ -98,6 +100,7 @@ func factories() map[string]modules.Factory {
"amlich": amlich.New,
"monkeyd": monkeyd.New,
"wordle": wordle.New,
noitu.ShortName: noitu.New,
"loldle": loldle.New,
lol.CollectionName: lol.New,
coin.CollectionName: coin.New,
@@ -200,15 +203,16 @@ func main() {
GitSHA: resolveCommitSHA(cfg.SourceCommit),
})
handler := server.New()
handler := server.New(serverRoutes(reg)...)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: handler,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
// The only route is GET / (health). It responds instantly, so this
// write deadline is ample and bounds any slow-loris write.
// Routes are GET / (health) plus module routes such as the noitu game
// page and its small JSON API. All respond quickly, so this write
// deadline is ample and bounds any slow-loris write.
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
@@ -220,8 +224,8 @@ func main() {
}
}()
// Long polling is the sole Telegram transport (no webhook, no public
// ingress). Telegram permits exactly one getUpdates consumer per bot token,
// Long polling is the sole Telegram transport (no webhook; public ingress
// only for module HTTP routes such as the noitu game, when enabled). Telegram permits exactly one getUpdates consumer per bot token,
// so deploy exactly one replica. The webhook was cleared at startup above.
pollingDone := make(chan struct{})
go func() {
@@ -247,6 +251,17 @@ func main() {
metrics.Flush()
}
// serverRoutes converts the registry's module routes for internal/server,
// which stays independent of the modules package.
func serverRoutes(reg *modules.Registry) []server.Route {
var routes []server.Route
for _, r := range reg.HTTPRoutes() {
routes = append(routes, server.Route{Pattern: r.Pattern, Handler: r.Handler})
log.Info("http route", "pattern", r.Pattern)
}
return routes
}
// buildProvider picks the storage backend. Selection order:
// 1. Explicit KV_PROVIDER env (memory|mongodb) wins.
// 2. Auto-detect: MONGO_URL set → mongodb; otherwise memory.
+17
View File
@@ -100,6 +100,23 @@ func TestFactoriesRegistersBlacklistCommands(t *testing.T) {
}
}
// noitu is in the catalog and, with GAME_BASE_URL unset, registers its command
// and game but no public HTTP route.
func TestFactoriesRegistersNoituDisabledByDefault(t *testing.T) {
t.Setenv("GAME_BASE_URL", "")
t.Setenv("NOITU_GAME_SECRET", "")
reg, err := modules.Build([]string{"noitu"}, factories(), storage.NewMemoryProvider(), modules.BuildOptions{})
if err != nil {
t.Fatalf("Build noitu: %v", err)
}
if _, ok := reg.AllCommands["noitu"]; !ok {
t.Fatal("missing command noitu")
}
if routes := serverRoutes(reg); len(routes) != 0 {
t.Fatalf("disabled game exposes routes: %+v", routes)
}
}
// An empty MODULES loads every module, so a command name that collides with an
// existing module surfaces here as a test failure rather than as a startup
// crash on deploy.