feat(telegram,game): single bot + per-user variant preferences
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s

Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.

- Telegram: one bot; drop service_language and the supported_languages
  set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
  connector proto). The single bot renders chat and out-of-app push in
  the recipient's preferred_language; remove the game-language push
  routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
  {erudit_ru}, CHECK non-empty + subset of the three variants). Gates
  the New Game picker, vs-AI and the friend invitation the player
  creates, enforced server-side (HTTP 400 otherwise); an invited friend
  may still accept any variant. Edited on the Settings screen; variants
  are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
  and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
  VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
  to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
  backend, ui, UI_DESIGN, PRERELEASE).

The migration squash is deferred to a follow-up PR.
This commit is contained in:
Ilia Denisov
2026-06-20 14:08:27 +02:00
parent 1933849dba
commit 57c778f9b2
99 changed files with 1006 additions and 1256 deletions
+21 -25
View File
@@ -1,22 +1,20 @@
# scrabble/platform/telegram — Telegram connector
The Telegram platform side-service. It is the **only** component that holds the bot
tokens: it runs a Bot API long-poll loop **per service language** (Mini App launch +
deep-links) and serves the connector gRPC API that the gateway and backend call over
the trusted internal network. See
[`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12.
token: it runs a Bot API long-poll loop (Mini App launch + deep-links) and serves
the connector gRPC API that the gateway and backend call over the trusted internal
network. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12.
## Service languages (dual bots)
## Single bot
The connector hosts **one bot per service language** (`en`, `ru`) — each its own
token + game channel, configured by the `*_EN` / `*_RU` env vars; at least one is
required. The **same Telegram user id spans both bots**. `ValidateInitData` tries
each bot's token in turn (none validates ⇒ invalid) and reports which bot validated:
its **`service_language`** (persisted by the backend to route the user's later push)
and its **`supported_languages`** set (which the UI gates the New Game variant choice
by — `en` → English, `ru` → Russian + Эрудит). The user-facing `Notify` routes by the
recipient's persisted service language; the admin `SendToUser` / `SendToGameChannel`
route by an **operator-chosen** `language` (unrelated to login).
The connector hosts **one unified bot** — one token plus one optional game channel,
configured by `TELEGRAM_BOT_TOKEN` and `TELEGRAM_GAME_CHANNEL_ID`. `ValidateInitData`
validates `initData` against that single token (it does not validate ⇒ invalid) and
returns only the Telegram user identity — there is no per-bot service language and no
supported-languages set. Every message the bot sends is rendered in the **recipient's
interface language**: the user-facing `Notify` renders in the request's `language` (the
recipient's interface language); the admin `SendToUser` / `SendToGameChannel` render in
an **operator-chosen** `language`. No call routes between bots — there is only one.
## Responsibilities
@@ -26,17 +24,17 @@ route by an **operator-chosen** `language` (unrelated to login).
backend internal API — so the bot token never leaves this process.
- **Out-of-app push.** `Notify` renders a backend push event (your_turn, nudge,
match_found, and the invitation / friend_request notify sub-kinds) into a
localized message with a Mini App launch button and sends it **through the bot for
the request's `language`** (the recipient's service language). The gateway calls it
**only** for a recipient with no live in-app stream and the
localized message with a Mini App launch button and sends it through the **single
bot**, rendered in the request's `language` (the recipient's interface language).
The gateway calls it **only** for a recipient with no live in-app stream and the
`notifications_in_app_only` flag off, so the platform push never duplicates in-app
delivery.
- **Bot chat.** `/start <payload>` (and the chat menu button) reply with a Mini App
launch button; a deep-link payload routes the launch to a game / invitation /
friend code.
- **Admin messaging.** `SendToUser` and `SendToGameChannel` send
arbitrary text to one user or a game channel through the bot the request selects by
`language` (an operator choice in the admin console).
arbitrary text to one user or a game channel through the single bot, rendered in the
`language` the operator chooses in the admin console.
The generic methods (`Notify`, `SendToUser`, `SendToGameChannel`) address a
recipient by the identity `external_id` (as in the backend `identities` table), so a
@@ -71,11 +69,9 @@ The bot turns a `/start <payload>` or a notification target into a launch-button
| Env var | Default | Meaning |
| --- | --- | --- |
| `TELEGRAM_BOT_TOKEN_EN` | — | English bot's API token + initData HMAC secret |
| `TELEGRAM_BOT_TOKEN_RU` | — | Russian bot's API token + initData HMAC secret (≥ 1 of EN/RU required) |
| `TELEGRAM_GAME_CHANNEL_ID_EN` | — | English bot's game channel chat id for `SendToGameChannel` |
| `TELEGRAM_GAME_CHANNEL_ID_RU` | — | Russian bot's game channel chat id |
| `TELEGRAM_MINIAPP_URL` | — (required) | Mini App HTTPS origin, shared by all bots (BotFather-registered) |
| `TELEGRAM_BOT_TOKEN` | — (required) | The bot's API token + initData HMAC secret |
| `TELEGRAM_GAME_CHANNEL_ID` | — | The bot's game channel chat id for `SendToGameChannel` |
| `TELEGRAM_MINIAPP_URL` | — (required) | Mini App HTTPS origin (BotFather-registered) |
| `TELEGRAM_GRPC_ADDR` | `:9091` | connector gRPC listen address |
| `TELEGRAM_API_BASE_URL` | `https://api.telegram.org` | Bot API host override (mock / self-hosted) |
| `TELEGRAM_TEST_ENV` | `false` | route to the Bot API **test environment** (`/bot<token>/test/METHOD`) |
@@ -93,7 +89,7 @@ builds `<host>/bot<token>/<method>`).
```sh
go build ./platform/telegram/...
go test ./platform/telegram/... # unit tests use an httptest fake Bot API
go run ./platform/telegram/cmd/telegram # needs a real TELEGRAM_BOT_TOKEN_EN or _RU
go run ./platform/telegram/cmd/telegram # needs a real TELEGRAM_BOT_TOKEN
```
## Deploy
+18 -35
View File
@@ -67,36 +67,22 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Warn("telemetry: start runtime metrics", zap.Error(err))
}
// One bot per configured service language; ValidateInitData tries each token and
// the push/admin methods route by language.
var bots []*bot.Bot
var runtimes []connector.BotRuntime
var langs []string
for _, lang := range config.Languages {
bc, ok := cfg.Bots[lang]
if !ok {
continue
}
b, err := bot.New(bot.Config{
Token: bc.Token,
APIBaseURL: cfg.APIBaseURL,
TestEnv: cfg.TestEnv,
MiniAppURL: cfg.MiniAppURL,
}, logger)
if err != nil {
return err
}
bots = append(bots, b)
runtimes = append(runtimes, connector.BotRuntime{
Language: lang,
Sender: b,
ChannelID: bc.GameChannelID,
InitValidator: initdata.NewHMACValidator(bc.Token),
WidgetValidator: loginwidget.NewHMACValidator(bc.Token),
})
langs = append(langs, lang)
// The single bot validates launch data and delivers push/admin messages.
b, err := bot.New(bot.Config{
Token: cfg.Token,
APIBaseURL: cfg.APIBaseURL,
TestEnv: cfg.TestEnv,
MiniAppURL: cfg.MiniAppURL,
}, logger)
if err != nil {
return err
}
srv := connector.NewServer(runtimes, logger)
srv := connector.NewServer(connector.BotRuntime{
Sender: b,
ChannelID: cfg.GameChannelID,
InitValidator: initdata.NewHMACValidator(cfg.Token),
WidgetValidator: loginwidget.NewHMACValidator(cfg.Token),
}, logger)
grpcServer := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()))
telegramv1.RegisterTelegramServer(grpcServer, srv)
@@ -106,11 +92,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
return err
}
// The long-poll loops and the gRPC server run together; cancelling the context
// stops every bot loop and gracefully drains the gRPC server.
for _, b := range bots {
go b.Run(ctx)
}
// The long-poll loop and the gRPC server run together; cancelling the context
// stops the bot loop and gracefully drains the gRPC server.
go b.Run(ctx)
go func() {
<-ctx.Done()
grpcServer.GracefulStop()
@@ -119,7 +103,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("telegram connector starting",
zap.String("grpc_addr", cfg.GRPCAddr),
zap.String("miniapp_url", cfg.MiniAppURL),
zap.Strings("languages", langs),
zap.Bool("test_env", cfg.TestEnv))
if err := grpcServer.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
return err
+18 -41
View File
@@ -10,40 +10,26 @@ import (
pkgtel "scrabble/pkg/telemetry"
)
// Languages is the set of service languages a bot may be tagged with. Each is a
// separate bot (own token + game channel) serving that audience; the same Telegram
// user id spans them all (ARCHITECTURE.md §12).
var Languages = []string{"en", "ru"}
// BotConfig is one language-tagged bot's settings.
type BotConfig struct {
// Token is the Telegram Bot API token (TELEGRAM_BOT_TOKEN_<LANG>). It both
// authenticates the Bot API client and is the HMAC secret for Mini App initData
// validation against this bot.
Token string
// GameChannelID is the chat id of this bot's game channel for SendToGameChannel
// (TELEGRAM_GAME_CHANNEL_ID_<LANG>, optional; 0 disables channel posts).
GameChannelID int64
}
// Config is the Telegram connector's runtime configuration, read from the
// environment. The bot tokens live only in this process (ARCHITECTURE.md §12).
// environment. The bot token lives only in this process (ARCHITECTURE.md §12).
type Config struct {
// Bots maps a service language (one of Languages) to that language's bot
// settings. A language is present when its TELEGRAM_BOT_TOKEN_<LANG> is set; at
// least one bot is required.
Bots map[string]BotConfig
// Token is the Telegram Bot API token (TELEGRAM_BOT_TOKEN, required). It both
// authenticates the Bot API client and is the HMAC secret for Mini App initData
// and Login Widget validation.
Token string
// GameChannelID is the chat id of the bot's game channel for SendToGameChannel
// (TELEGRAM_GAME_CHANNEL_ID, optional; 0 disables channel posts).
GameChannelID int64
// GRPCAddr is the listen address of the connector gRPC server that gateway and
// backend call (TELEGRAM_GRPC_ADDR, default :9091).
GRPCAddr string
// MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it
// is the base of every launch button, to which a deep-link adds a startapp
// query parameter (TELEGRAM_MINIAPP_URL, required). It is shared by all bots
// (one gateway origin); initData is signed per bot token.
// query parameter (TELEGRAM_MINIAPP_URL, required).
MiniAppURL string
// APIBaseURL overrides the Bot API host (TELEGRAM_API_BASE_URL, optional;
// default https://api.telegram.org) — used for a local mock or a self-hosted
// Bot API server. Shared by all bots.
// Bot API server.
APIBaseURL string
// TestEnv routes the Bot API client to Telegram's test environment
// (.../bot<token>/test/METHOD) (TELEGRAM_TEST_ENV=true, default false).
@@ -58,36 +44,27 @@ type Config struct {
// and validating the required fields.
func Load() (Config, error) {
cfg := Config{
Bots: map[string]BotConfig{},
Token: os.Getenv("TELEGRAM_BOT_TOKEN"),
GRPCAddr: envOr("TELEGRAM_GRPC_ADDR", ":9091"),
MiniAppURL: os.Getenv("TELEGRAM_MINIAPP_URL"),
APIBaseURL: os.Getenv("TELEGRAM_API_BASE_URL"),
TestEnv: os.Getenv("TELEGRAM_TEST_ENV") == "true",
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
}
for _, lang := range Languages {
suffix := strings.ToUpper(lang)
token := os.Getenv("TELEGRAM_BOT_TOKEN_" + suffix)
if token == "" {
continue
if v := strings.TrimSpace(os.Getenv("TELEGRAM_GAME_CHANNEL_ID")); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return Config{}, fmt.Errorf("config: TELEGRAM_GAME_CHANNEL_ID %q: %w", v, err)
}
bot := BotConfig{Token: token}
if v := strings.TrimSpace(os.Getenv("TELEGRAM_GAME_CHANNEL_ID_" + suffix)); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return Config{}, fmt.Errorf("config: TELEGRAM_GAME_CHANNEL_ID_%s %q: %w", suffix, v, err)
}
bot.GameChannelID = id
}
cfg.Bots[lang] = bot
cfg.GameChannelID = id
}
tel := pkgtel.DefaultConfig("scrabble-telegram")
tel.ServiceName = envOr("TELEGRAM_SERVICE_NAME", tel.ServiceName)
tel.TracesExporter = envOr("TELEGRAM_OTEL_TRACES_EXPORTER", tel.TracesExporter)
tel.MetricsExporter = envOr("TELEGRAM_OTEL_METRICS_EXPORTER", tel.MetricsExporter)
cfg.Telemetry = tel
if len(cfg.Bots) == 0 {
return Config{}, fmt.Errorf("config: at least one TELEGRAM_BOT_TOKEN_<LANG> (LANG in %v) is required", Languages)
if cfg.Token == "" {
return Config{}, fmt.Errorf("config: TELEGRAM_BOT_TOKEN is required")
}
if cfg.MiniAppURL == "" {
return Config{}, fmt.Errorf("config: TELEGRAM_MINIAPP_URL is required")
@@ -6,33 +6,38 @@ import (
pkgtel "scrabble/pkg/telemetry"
)
// setRequired sets the required connector variables (one bot + the Mini App URL)
// so Load reaches the telemetry checks.
// setRequired sets the required connector variables (the bot token + the Mini App
// URL) so Load reaches the telemetry checks.
func setRequired(t *testing.T) {
t.Helper()
t.Setenv("TELEGRAM_BOT_TOKEN_EN", "test-token")
t.Setenv("TELEGRAM_BOT_TOKEN", "test-token")
t.Setenv("TELEGRAM_MINIAPP_URL", "https://example.org/app")
}
// TestLoadBots verifies the per-language bot parsing: a present token enables a
// language, its channel id is optional, and the result is keyed by language.
func TestLoadBots(t *testing.T) {
// TestLoadBot verifies the bot parsing: the token is read and the game channel id is
// parsed when present.
func TestLoadBot(t *testing.T) {
t.Setenv("TELEGRAM_MINIAPP_URL", "https://example.org/app")
t.Setenv("TELEGRAM_BOT_TOKEN_EN", "en-token")
t.Setenv("TELEGRAM_GAME_CHANNEL_ID_EN", "-100111")
t.Setenv("TELEGRAM_BOT_TOKEN_RU", "ru-token")
t.Setenv("TELEGRAM_BOT_TOKEN", "bot-token")
t.Setenv("TELEGRAM_GAME_CHANNEL_ID", "-100111")
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(c.Bots) != 2 {
t.Fatalf("Bots = %d, want 2", len(c.Bots))
if c.Token != "bot-token" || c.GameChannelID != -100111 {
t.Errorf("config = token %q / channel %d, want bot-token / -100111", c.Token, c.GameChannelID)
}
if c.Bots["en"].Token != "en-token" || c.Bots["en"].GameChannelID != -100111 {
t.Errorf("en bot = %+v", c.Bots["en"])
}
// TestLoadOptionalChannel verifies the game channel id defaults to 0 when unset.
func TestLoadOptionalChannel(t *testing.T) {
setRequired(t)
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Bots["ru"].Token != "ru-token" || c.Bots["ru"].GameChannelID != 0 {
t.Errorf("ru bot = %+v, want token ru-token / channel 0", c.Bots["ru"])
if c.GameChannelID != 0 {
t.Errorf("GameChannelID = %d, want 0", c.GameChannelID)
}
}
+49 -103
View File
@@ -4,15 +4,14 @@
// methods address a recipient by the identity external_id, so a future platform
// connector can implement the same service.
//
// The connector hosts one bot per configured service language (en/ru); the same
// Telegram user id spans them all. ValidateInitData tries each bot's token in turn
// and reports which bot validated (its service language); the push and admin
// methods route to the bot the request selects by language.
// The connector hosts a single bot. ValidateInitData/ValidateLoginWidget verify
// launch data against its token; Notify renders the message in the recipient's
// interface language (the single bot needs no routing); the admin methods deliver
// through that bot.
package connector
import (
"context"
"errors"
"fmt"
"strconv"
@@ -34,103 +33,70 @@ type Sender interface {
SendText(ctx context.Context, chatID int64, text string) error
}
// BotRuntime is one configured language-tagged bot: its sender, game channel id,
// and the two HMAC validators bound to its token.
// BotRuntime is the configured bot: its sender, game channel id, and the two HMAC
// validators bound to its token.
type BotRuntime struct {
// Language is the bot's service language (en/ru).
Language string
// Sender delivers messages through this bot.
// Sender delivers messages through the bot.
Sender Sender
// ChannelID is this bot's game channel (0 disables channel posts).
// ChannelID is the bot's game channel (0 disables channel posts).
ChannelID int64
// InitValidator verifies Mini App initData signed by this bot's token.
// InitValidator verifies Mini App initData signed by the bot's token.
InitValidator initdata.Validator
// WidgetValidator verifies Login Widget data signed by this bot's token.
// WidgetValidator verifies Login Widget data signed by the bot's token.
WidgetValidator loginwidget.Validator
}
// Server implements telegramv1.TelegramServer over one or more language-tagged bots.
// Server implements telegramv1.TelegramServer over the single configured bot.
type Server struct {
telegramv1.UnimplementedTelegramServer
bots map[string]BotRuntime // keyed by service language
order []string // stable iteration order for validation
log *zap.Logger
bot BotRuntime
log *zap.Logger
}
// NewServer builds the gRPC service from the configured bots (at least one).
func NewServer(bots []BotRuntime, log *zap.Logger) *Server {
// NewServer builds the gRPC service from the configured bot.
func NewServer(bot BotRuntime, log *zap.Logger) *Server {
if log == nil {
log = zap.NewNop()
}
m := make(map[string]BotRuntime, len(bots))
order := make([]string, 0, len(bots))
for _, b := range bots {
m[b.Language] = b
order = append(order, b.Language)
}
return &Server{bots: m, order: order, log: log}
return &Server{bot: bot, log: log}
}
// ValidateInitData verifies Mini App launch data against each bot's token in turn
// and returns the user identity plus the validating bot's service language (which
// routes the user's later push) and its set of offered game languages.
// ValidateInitData verifies Mini App launch data against the bot's token and returns
// the user identity.
func (s *Server) ValidateInitData(ctx context.Context, req *telegramv1.ValidateInitDataRequest) (*telegramv1.ValidateInitDataResponse, error) {
var lastErr error
for _, lang := range s.order {
u, err := s.bots[lang].InitValidator.Validate(req.GetInitData())
if err != nil {
lastErr = err
continue
}
return &telegramv1.ValidateInitDataResponse{
ExternalId: u.ExternalID,
Username: u.Username,
FirstName: u.FirstName,
LanguageCode: u.LanguageCode,
ServiceLanguage: lang,
SupportedLanguages: []string{lang},
}, nil
u, err := s.bot.InitValidator.Validate(req.GetInitData())
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if lastErr == nil {
lastErr = errors.New("no bot configured")
}
return nil, status.Error(codes.InvalidArgument, lastErr.Error())
return &telegramv1.ValidateInitDataResponse{
ExternalId: u.ExternalID,
Username: u.Username,
FirstName: u.FirstName,
LanguageCode: u.LanguageCode,
}, nil
}
// ValidateLoginWidget verifies Login Widget authorization data against each bot's
// token in turn and returns the user identity, for attaching a Telegram identity to
// an existing account.
// ValidateLoginWidget verifies Login Widget authorization data against the bot's
// token and returns the user identity, for attaching a Telegram identity to an
// existing account.
func (s *Server) ValidateLoginWidget(ctx context.Context, req *telegramv1.ValidateLoginWidgetRequest) (*telegramv1.ValidateLoginWidgetResponse, error) {
var lastErr error
for _, lang := range s.order {
u, err := s.bots[lang].WidgetValidator.Validate(req.GetData())
if err != nil {
lastErr = err
continue
}
return &telegramv1.ValidateLoginWidgetResponse{
ExternalId: u.ExternalID,
Username: u.Username,
FirstName: u.FirstName,
}, nil
u, err := s.bot.WidgetValidator.Validate(req.GetData())
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if lastErr == nil {
lastErr = errors.New("no bot configured")
}
return nil, status.Error(codes.InvalidArgument, lastErr.Error())
return &telegramv1.ValidateLoginWidgetResponse{
ExternalId: u.ExternalID,
Username: u.Username,
FirstName: u.FirstName,
}, nil
}
// Notify renders and delivers an out-of-app notification through the bot selected by
// the recipient's service language. It reports delivered=false (without an error)
// when no bot serves that language, the kind is not pushed out-of-app, or the bot
// could not deliver (e.g. the user never started it), so the gateway treats a
// Notify renders and delivers an out-of-app notification through the bot. The message
// is rendered in the recipient's interface language (req language). It reports
// delivered=false (without an error) when the kind is not pushed out-of-app or the
// bot could not deliver (e.g. the user never started it), so the gateway treats a
// fallback miss as best-effort.
func (s *Server) Notify(ctx context.Context, req *telegramv1.NotifyRequest) (*telegramv1.NotifyResponse, error) {
bot, ok := s.bots[req.GetLanguage()]
if !ok {
s.log.Warn("notify: no bot for language", zap.String("language", req.GetLanguage()), zap.String("kind", req.GetKind()))
return &telegramv1.NotifyResponse{Delivered: false}, nil
}
msg, ok := render.Render(req.GetKind(), req.GetPayload(), req.GetLanguage())
if !ok {
return &telegramv1.NotifyResponse{Delivered: false}, nil
@@ -139,58 +105,38 @@ func (s *Server) Notify(ctx context.Context, req *telegramv1.NotifyRequest) (*te
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if err := bot.Sender.Notify(ctx, chat, msg.Text, msg.ButtonText, msg.StartParam); err != nil {
if err := s.bot.Sender.Notify(ctx, chat, msg.Text, msg.ButtonText, msg.StartParam); err != nil {
s.log.Warn("notify delivery failed", zap.String("kind", req.GetKind()), zap.Error(err))
return &telegramv1.NotifyResponse{Delivered: false}, nil
}
return &telegramv1.NotifyResponse{Delivered: true}, nil
}
// SendToUser sends an arbitrary admin message to one user through the bot selected
// by language (an operator choice in the admin console).
// SendToUser sends an arbitrary admin message to one user through the bot.
func (s *Server) SendToUser(ctx context.Context, req *telegramv1.SendToUserRequest) (*telegramv1.SendResponse, error) {
bot, err := s.botFor(req.GetLanguage())
if err != nil {
return nil, err
}
chat, err := parseChatID(req.GetExternalId())
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if err := bot.Sender.SendText(ctx, chat, req.GetText()); err != nil {
if err := s.bot.Sender.SendText(ctx, chat, req.GetText()); err != nil {
s.log.Warn("send to user failed", zap.Error(err))
return &telegramv1.SendResponse{Delivered: false}, nil
}
return &telegramv1.SendResponse{Delivered: true}, nil
}
// SendToGameChannel posts an arbitrary admin message to the game channel of the bot
// selected by language (an operator choice in the admin console).
// SendToGameChannel posts an arbitrary admin message to the bot's game channel.
func (s *Server) SendToGameChannel(ctx context.Context, req *telegramv1.SendToGameChannelRequest) (*telegramv1.SendResponse, error) {
bot, err := s.botFor(req.GetLanguage())
if err != nil {
return nil, err
if s.bot.ChannelID == 0 {
return nil, status.Error(codes.FailedPrecondition, "game channel is not configured")
}
if bot.ChannelID == 0 {
return nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("game channel is not configured for language %q", req.GetLanguage()))
}
if err := bot.Sender.SendText(ctx, bot.ChannelID, req.GetText()); err != nil {
if err := s.bot.Sender.SendText(ctx, s.bot.ChannelID, req.GetText()); err != nil {
s.log.Warn("send to channel failed", zap.Error(err))
return &telegramv1.SendResponse{Delivered: false}, nil
}
return &telegramv1.SendResponse{Delivered: true}, nil
}
// botFor returns the bot tagged with language, or a FailedPrecondition error when no
// bot serves it (admin broadcasts choose the language explicitly).
func (s *Server) botFor(language string) (BotRuntime, error) {
bot, ok := s.bots[language]
if !ok {
return BotRuntime{}, status.Error(codes.FailedPrecondition, fmt.Sprintf("no bot configured for language %q", language))
}
return bot, nil
}
// parseChatID converts a Telegram identity external_id into a numeric chat id.
func parseChatID(externalID string) (int64, error) {
id, err := strconv.ParseInt(externalID, 10, 64)
@@ -56,9 +56,9 @@ func (f *fakeSender) SendText(_ context.Context, chatID int64, text string) erro
return f.err
}
// botRT assembles one language-tagged bot runtime for a test.
func botRT(lang string, sender Sender, channelID int64, iv initdata.Validator, wv loginwidget.Validator) BotRuntime {
return BotRuntime{Language: lang, Sender: sender, ChannelID: channelID, InitValidator: iv, WidgetValidator: wv}
// botRT assembles the bot runtime for a test.
func botRT(sender Sender, channelID int64, iv initdata.Validator, wv loginwidget.Validator) BotRuntime {
return BotRuntime{Sender: sender, ChannelID: channelID, InitValidator: iv, WidgetValidator: wv}
}
func yourTurnPayload(gameID string) []byte {
@@ -72,7 +72,7 @@ func yourTurnPayload(gameID string) []byte {
func TestValidateInitData(t *testing.T) {
want := initdata.User{ExternalID: "42", Username: "neo", FirstName: "Thomas", LanguageCode: "en-GB"}
srv := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{user: want}, stubWidgetValidator{})}, nil)
srv := NewServer(botRT(&fakeSender{}, 0, stubValidator{user: want}, stubWidgetValidator{}), nil)
resp, err := srv.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{InitData: "x"})
if err != nil {
t.Fatalf("validate: %v", err)
@@ -80,36 +80,16 @@ func TestValidateInitData(t *testing.T) {
if resp.GetExternalId() != "42" || resp.GetUsername() != "neo" || resp.GetFirstName() != "Thomas" || resp.GetLanguageCode() != "en-GB" {
t.Errorf("resp = %+v, want %+v", resp, want)
}
if resp.GetServiceLanguage() != "en" || len(resp.GetSupportedLanguages()) != 1 || resp.GetSupportedLanguages()[0] != "en" {
t.Errorf("service_language=%q supported=%v, want en / [en]", resp.GetServiceLanguage(), resp.GetSupportedLanguages())
}
bad := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{err: initdata.ErrInvalidInitData}, stubWidgetValidator{})}, nil)
bad := NewServer(botRT(&fakeSender{}, 0, stubValidator{err: initdata.ErrInvalidInitData}, stubWidgetValidator{}), nil)
if _, err := bad.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{}); status.Code(err) != codes.InvalidArgument {
t.Errorf("err code = %v, want InvalidArgument", status.Code(err))
}
}
// TestValidateInitDataPicksValidatingBot proves the second bot's token validates and
// its service language is reported when the first bot rejects the data.
func TestValidateInitDataPicksValidatingBot(t *testing.T) {
want := initdata.User{ExternalID: "7", Username: "ru_user", FirstName: "Иван", LanguageCode: "ru"}
srv := NewServer([]BotRuntime{
botRT("en", &fakeSender{}, 0, stubValidator{err: initdata.ErrInvalidInitData}, stubWidgetValidator{}),
botRT("ru", &fakeSender{}, 0, stubValidator{user: want}, stubWidgetValidator{}),
}, nil)
resp, err := srv.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{InitData: "x"})
if err != nil {
t.Fatalf("validate: %v", err)
}
if resp.GetExternalId() != "7" || resp.GetServiceLanguage() != "ru" || resp.GetSupportedLanguages()[0] != "ru" {
t.Errorf("resp = %+v, want external 7 / service ru", resp)
}
}
func TestValidateLoginWidget(t *testing.T) {
want := loginwidget.User{ExternalID: "42", Username: "neo", FirstName: "Thomas"}
srv := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{}, stubWidgetValidator{user: want})}, nil)
srv := NewServer(botRT(&fakeSender{}, 0, stubValidator{}, stubWidgetValidator{user: want}), nil)
resp, err := srv.ValidateLoginWidget(context.Background(), &telegramv1.ValidateLoginWidgetRequest{Data: "x"})
if err != nil {
t.Fatalf("validate: %v", err)
@@ -118,7 +98,7 @@ func TestValidateLoginWidget(t *testing.T) {
t.Errorf("resp = %+v, want %+v", resp, want)
}
bad := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{}, stubWidgetValidator{err: loginwidget.ErrInvalidLoginWidget})}, nil)
bad := NewServer(botRT(&fakeSender{}, 0, stubValidator{}, stubWidgetValidator{err: loginwidget.ErrInvalidLoginWidget}), nil)
if _, err := bad.ValidateLoginWidget(context.Background(), &telegramv1.ValidateLoginWidgetRequest{}); status.Code(err) != codes.InvalidArgument {
t.Errorf("err code = %v, want InvalidArgument", status.Code(err))
}
@@ -127,7 +107,7 @@ func TestValidateLoginWidget(t *testing.T) {
func TestNotifyDelivers(t *testing.T) {
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
sender := &fakeSender{}
srv := NewServer([]BotRuntime{botRT("en", sender, 0, stubValidator{}, stubWidgetValidator{})}, nil)
srv := NewServer(botRT(sender, 0, stubValidator{}, stubWidgetValidator{}), nil)
resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
ExternalId: "12345", Kind: "your_turn", Payload: yourTurnPayload(gameID), Language: "en",
})
@@ -145,44 +125,9 @@ func TestNotifyDelivers(t *testing.T) {
}
}
// TestNotifyRoutesByLanguage proves the push goes through the bot for the recipient's
// service language, not the other bot.
func TestNotifyRoutesByLanguage(t *testing.T) {
en, ru := &fakeSender{}, &fakeSender{}
srv := NewServer([]BotRuntime{
botRT("en", en, 0, stubValidator{}, stubWidgetValidator{}),
botRT("ru", ru, 0, stubValidator{}, stubWidgetValidator{}),
}, nil)
resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
ExternalId: "12345", Kind: "your_turn", Payload: yourTurnPayload("7c9e6679-7425-40de-944b-e07fc1f90ae7"), Language: "ru",
})
if err != nil || !resp.GetDelivered() {
t.Fatalf("notify: %v delivered=%v", err, resp.GetDelivered())
}
if len(ru.notify) != 1 || len(en.notify) != 0 {
t.Errorf("routing wrong: ru=%d en=%d, want 1/0", len(ru.notify), len(en.notify))
}
}
// TestNotifyUnknownLanguage proves a push for a language with no bot is a best-effort
// miss (delivered=false, no delivery attempt), not an error.
func TestNotifyUnknownLanguage(t *testing.T) {
en := &fakeSender{}
srv := NewServer([]BotRuntime{botRT("en", en, 0, stubValidator{}, stubWidgetValidator{})}, nil)
resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
ExternalId: "12345", Kind: "your_turn", Payload: yourTurnPayload("g"), Language: "ru",
})
if err != nil {
t.Fatalf("notify: %v", err)
}
if resp.GetDelivered() || len(en.notify) != 0 {
t.Errorf("delivered=%v en calls=%d, want false / 0", resp.GetDelivered(), len(en.notify))
}
}
func TestNotifySkipsUnrenderedKind(t *testing.T) {
sender := &fakeSender{}
srv := NewServer([]BotRuntime{botRT("en", sender, 0, stubValidator{}, stubWidgetValidator{})}, nil)
srv := NewServer(botRT(sender, 0, stubValidator{}, stubWidgetValidator{}), nil)
resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
ExternalId: "12345", Kind: "opponent_moved", Language: "en",
})
@@ -198,7 +143,7 @@ func TestNotifySkipsUnrenderedKind(t *testing.T) {
}
func TestNotifyInvalidExternalID(t *testing.T) {
srv := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{}, stubWidgetValidator{})}, nil)
srv := NewServer(botRT(&fakeSender{}, 0, stubValidator{}, stubWidgetValidator{}), nil)
_, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
ExternalId: "not-a-number", Kind: "your_turn", Payload: yourTurnPayload("g"), Language: "en",
})
@@ -209,8 +154,8 @@ func TestNotifyInvalidExternalID(t *testing.T) {
func TestSendToUser(t *testing.T) {
sender := &fakeSender{}
srv := NewServer([]BotRuntime{botRT("en", sender, 0, stubValidator{}, stubWidgetValidator{})}, nil)
resp, err := srv.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi", Language: "en"})
srv := NewServer(botRT(sender, 0, stubValidator{}, stubWidgetValidator{}), nil)
resp, err := srv.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"})
if err != nil {
t.Fatalf("send to user: %v", err)
}
@@ -219,36 +164,23 @@ func TestSendToUser(t *testing.T) {
}
}
// TestSendToUserUnknownLanguage proves the admin broadcast errors when no bot serves
// the operator-chosen language.
func TestSendToUserUnknownLanguage(t *testing.T) {
srv := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{}, stubWidgetValidator{})}, nil)
_, err := srv.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi", Language: "ru"})
if status.Code(err) != codes.FailedPrecondition {
t.Errorf("err code = %v, want FailedPrecondition", status.Code(err))
}
}
func TestSendToGameChannel(t *testing.T) {
t.Run("unconfigured", func(t *testing.T) {
srv := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{}, stubWidgetValidator{})}, nil)
_, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "x", Language: "en"})
srv := NewServer(botRT(&fakeSender{}, 0, stubValidator{}, stubWidgetValidator{}), nil)
_, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "x"})
if status.Code(err) != codes.FailedPrecondition {
t.Errorf("err code = %v, want FailedPrecondition", status.Code(err))
}
})
t.Run("configured routes by language", func(t *testing.T) {
en, ru := &fakeSender{}, &fakeSender{}
srv := NewServer([]BotRuntime{
botRT("en", en, 111, stubValidator{}, stubWidgetValidator{}),
botRT("ru", ru, 555, stubValidator{}, stubWidgetValidator{}),
}, nil)
resp, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "news", Language: "ru"})
t.Run("configured", func(t *testing.T) {
sender := &fakeSender{}
srv := NewServer(botRT(sender, 555, stubValidator{}, stubWidgetValidator{}), nil)
resp, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "news"})
if err != nil {
t.Fatalf("send to channel: %v", err)
}
if !resp.GetDelivered() || len(ru.text) != 1 || ru.text[0].chatID != 555 || len(en.text) != 0 {
t.Errorf("send to channel: ru=%+v en=%+v", ru.text, en.text)
if !resp.GetDelivered() || len(sender.text) != 1 || sender.text[0].chatID != 555 {
t.Errorf("send to channel: %+v", sender.text)
}
})
}