Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
New platform/telegram connector (own container, bot token only there): - go-telegram/bot long-poll loop: /start deep-links + Mini App launch button. - gRPC API pkg/proto/telegram/v1 (Telegram service): ValidateInitData, Notify (renders a localized message + deep-link button), SendToUser/SendToGameChannel (admin, wired in Stage 10). Generic methods are platform-agnostic (external_id). - Bot API base override for Telegram's test environment; Dockerfile + compose (VPN sidecar, no public ingress); README. Gateway: - initData validation relocated from the gateway into the connector; the gateway calls ValidateInitData over gRPC (GATEWAY_CONNECTOR_ADDR), drops the bot token, and deletes internal/auth. - Out-of-app push: runPushPump routes events whose recipient has no live in-app stream to connector.Notify, gated by /internal/push-target + the in-app-only flag (race-free de-dup); HasSubscribers added to the push hub. Backend: - Migration 00007 accounts.notifications_in_app_only (default true) + jetgen. - ProvisionTelegram seeds a new account's language/display name from the launch fields; IdentityExternalID reverse lookup; /internal/push-target handler. UI: - Telegram Mini App launch: detect initData, apply themeParams, authTelegram, route the deep-link start_param (g/i/f); /telegram/ guard redirects outside Telegram. Vite relative base + telegram-web-app.js. In-app-only profile toggle; share-to-Telegram link for a friend code. Vitest + Playwright coverage. Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only (Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN, ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# Telegram connector image.
|
||||
#
|
||||
# The connector imports only the shared scrabble/pkg module, so the build drops the
|
||||
# other workspace modules (backend, gateway) and the scrabble-solver replace from a
|
||||
# copy of go.work: it needs neither their sources nor the solver sibling checkout.
|
||||
# Build from the repository ROOT so go.work, pkg/ and platform/telegram/ are all in
|
||||
# the context (see deploy/docker-compose.yml, which sets context: ../../..).
|
||||
FROM golang:1.26.3-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.work go.work.sum ./
|
||||
COPY pkg ./pkg
|
||||
COPY platform/telegram ./platform/telegram
|
||||
|
||||
# Reduce the workspace to what the connector needs: only pkg + platform/telegram.
|
||||
RUN go work edit -dropuse=./backend -dropuse=./gateway -dropreplace=scrabble-solver
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/telegram ./platform/telegram/cmd/telegram
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
COPY --from=build /out/telegram /usr/local/bin/telegram
|
||||
ENTRYPOINT ["/usr/local/bin/telegram"]
|
||||
@@ -0,0 +1,86 @@
|
||||
# scrabble/platform/telegram — Telegram connector
|
||||
|
||||
The Telegram platform side-service. It is the **only** component that holds the bot
|
||||
token: it runs the 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.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- **Mini App auth.** `ValidateInitData` verifies Telegram Web App `initData` (HMAC
|
||||
under the bot token) and returns the user identity. The gateway calls it during
|
||||
the `auth.telegram` edge operation, then provisions the session through the
|
||||
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. 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** (wired in Stage 10). `SendToUser` and `SendToGameChannel` send
|
||||
arbitrary text to one user or the configured game channel.
|
||||
|
||||
The generic methods (`Notify`, `SendToUser`, `SendToGameChannel`) address a
|
||||
recipient by the identity `external_id` (as in the backend `identities` table), so a
|
||||
future VK / MAX connector can implement the same service; only `ValidateInitData` is
|
||||
Telegram-specific.
|
||||
|
||||
## gRPC API
|
||||
|
||||
`pkg/proto/telegram/v1`, service `Telegram`: `ValidateInitData`, `Notify`,
|
||||
`SendToUser`, `SendToGameChannel`. Generated Go is committed under `pkg`.
|
||||
|
||||
## Deep-link scheme
|
||||
|
||||
Shared verbatim with the UI (`ui/src/lib/deeplink.ts`). A Mini App start parameter
|
||||
is a one-character kind prefix plus a value:
|
||||
|
||||
| Parameter | Destination |
|
||||
| --- | --- |
|
||||
| `g<game uuid>` | open that game |
|
||||
| `i<invitation uuid>` | open that invitation |
|
||||
| `f<6-digit code>` | redeem that friend code |
|
||||
| empty / unknown | the lobby |
|
||||
|
||||
The bot turns a `/start <payload>` or a notification target into a launch-button URL
|
||||
`<MiniAppURL>?startapp=<payload>`.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `TELEGRAM_BOT_TOKEN` | — (required) | Bot API token + the initData HMAC secret |
|
||||
| `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`) |
|
||||
| `TELEGRAM_GAME_CHANNEL_ID` | — | game channel chat id for `SendToGameChannel` |
|
||||
| `TELEGRAM_LOG_LEVEL` | `info` | zap log level |
|
||||
|
||||
The **test environment** is selected by `TELEGRAM_TEST_ENV=true`, which suffixes the
|
||||
Bot API path with `/test` (the connector appends it to the token, since the client
|
||||
builds `<host>/bot<token>/<method>`).
|
||||
|
||||
## Build, test, run
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
The connector runs in its **own container** with the bot token held only there and
|
||||
all egress through a VPN sidecar (`deploy/docker-compose.yml`, mirroring
|
||||
`../../15-puzzle`). It needs no public ingress — it long-polls Telegram and answers
|
||||
internal gRPC at `telegram:9091` on the shared `edge` network. The host reverse proxy
|
||||
routes public traffic to the **gateway** port only, which serves the Mini App under
|
||||
`/telegram/`. The full multi-service deploy lands with Stage 12.
|
||||
|
||||
A real end-to-end Telegram smoke needs a BotFather bot, its token, a public HTTPS
|
||||
Mini App origin, and the connector container; the unit tests cover the wire format,
|
||||
templates, deep-links and the gRPC handlers without a live bot.
|
||||
@@ -0,0 +1,94 @@
|
||||
// Command telegram is the Telegram platform side-service (the "connector"). It is
|
||||
// the only component holding the bot token: it runs the Bot API long-poll loop
|
||||
// (Mini App launch + /start deep-links) and serves the connector gRPC API
|
||||
// (pkg/proto/telegram/v1) that the gateway and backend call over the trusted
|
||||
// internal network. See platform/telegram/README.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
telegramv1 "scrabble/pkg/proto/telegram/v1"
|
||||
"scrabble/platform/telegram/internal/bot"
|
||||
"scrabble/platform/telegram/internal/config"
|
||||
"scrabble/platform/telegram/internal/connector"
|
||||
"scrabble/platform/telegram/internal/initdata"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("telegram: load config: %v", err)
|
||||
}
|
||||
logger, err := newLogger(cfg.LogLevel)
|
||||
if err != nil {
|
||||
log.Fatalf("telegram: build logger: %v", err)
|
||||
}
|
||||
defer func() { _ = logger.Sync() }()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := run(ctx, cfg, logger); err != nil {
|
||||
logger.Fatal("telegram: terminated", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// run wires the bot and the gRPC server and serves both until the context is
|
||||
// cancelled.
|
||||
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
b, err := bot.New(bot.Config{
|
||||
Token: cfg.BotToken,
|
||||
APIBaseURL: cfg.APIBaseURL,
|
||||
TestEnv: cfg.TestEnv,
|
||||
MiniAppURL: cfg.MiniAppURL,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := connector.NewServer(initdata.NewHMACValidator(cfg.BotToken), b, cfg.GameChannelID, logger)
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
telegramv1.RegisterTelegramServer(grpcServer, srv)
|
||||
|
||||
lis, err := net.Listen("tcp", cfg.GRPCAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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()
|
||||
}()
|
||||
|
||||
logger.Info("telegram connector starting",
|
||||
zap.String("grpc_addr", cfg.GRPCAddr),
|
||||
zap.String("miniapp_url", cfg.MiniAppURL),
|
||||
zap.Bool("test_env", cfg.TestEnv))
|
||||
if err := grpcServer.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// newLogger builds a production JSON logger at the given level.
|
||||
func newLogger(level string) (*zap.Logger, error) {
|
||||
var lvl zap.AtomicLevel
|
||||
if err := lvl.UnmarshalText([]byte(level)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := zap.NewProductionConfig()
|
||||
cfg.Level = lvl
|
||||
return cfg.Build()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# Deploy descriptor for the Telegram connector (the platform side-service).
|
||||
#
|
||||
# Networking mirrors the sibling ../15-puzzle/deploy/docker-compose.yml:
|
||||
# - The `vpn` sidecar (developer/amneziawg-sidecar) holds the tunnel and provides
|
||||
# the netns shared by `app` (network_mode: "service:vpn"). All of the
|
||||
# connector's egress to api.telegram.org therefore leaves through the tunnel.
|
||||
# - `vpn` is the one attached to the external `edge` network, with the alias
|
||||
# `telegram`, so the other services reach the connector's gRPC port at
|
||||
# `telegram:9091` inside the shared netns. The connector needs NO public
|
||||
# ingress — it long-polls Telegram and only answers internal gRPC.
|
||||
#
|
||||
# The connector joins the same `edge` network as `backend` and `gateway` (the full
|
||||
# service set rolled out together on a dev-environment deploy). The gateway calls it
|
||||
# with GATEWAY_CONNECTOR_ADDR=telegram:9091; the backend admin surface (Stage 10)
|
||||
# will use the same address. The single public ingress for the host reverse proxy
|
||||
# (caddy) is the gateway's HTTP port, which also serves the Mini App under /telegram/
|
||||
# (ARCHITECTURE.md §13). The full multi-service compose lands with Stage 12; this is
|
||||
# the connector-scoped descriptor.
|
||||
name: scrabble-telegram
|
||||
|
||||
services:
|
||||
vpn:
|
||||
container_name: scrabble-telegram-vpn
|
||||
image: docker.iliadenisov.ru/developer/amneziawg-sidecar:latest
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
environment:
|
||||
AWG_CONF: ${AWG_CONF:?set AWG_CONF}
|
||||
networks:
|
||||
edge:
|
||||
aliases:
|
||||
- telegram
|
||||
|
||||
app:
|
||||
container_name: scrabble-telegram
|
||||
image: scrabble-telegram:latest
|
||||
build:
|
||||
# Build from the repository root so go.work, pkg/ and platform/telegram/ are
|
||||
# all in the Docker context (see platform/telegram/Dockerfile).
|
||||
context: ../../..
|
||||
dockerfile: platform/telegram/Dockerfile
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- vpn
|
||||
network_mode: "service:vpn"
|
||||
environment:
|
||||
# The bot token lives ONLY in this container (ARCHITECTURE.md §12).
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}
|
||||
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
|
||||
TELEGRAM_GRPC_ADDR: ${TELEGRAM_GRPC_ADDR:-:9091}
|
||||
# Set to true when deploying into Telegram's test environment.
|
||||
TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false}
|
||||
TELEGRAM_API_BASE_URL: ${TELEGRAM_API_BASE_URL:-}
|
||||
TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
|
||||
|
||||
networks:
|
||||
edge:
|
||||
external: true
|
||||
@@ -0,0 +1,12 @@
|
||||
module scrabble/platform/telegram
|
||||
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/go-telegram/bot v1.21.0
|
||||
github.com/google/flatbuffers v23.5.26+incompatible
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
scrabble/pkg v0.0.0
|
||||
)
|
||||
@@ -0,0 +1,155 @@
|
||||
// Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it
|
||||
// runs the long-poll update loop — replying to /start (with an optional deep-link
|
||||
// payload) and any other message with a Mini App launch button — and sends the
|
||||
// notification and admin messages the connector requests. The bot token lives only
|
||||
// in this process.
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Config configures the bot wrapper.
|
||||
type Config struct {
|
||||
// Token is the Bot API token.
|
||||
Token string
|
||||
// APIBaseURL overrides the Bot API host ("" uses https://api.telegram.org).
|
||||
APIBaseURL string
|
||||
// TestEnv routes requests to the Bot API test environment.
|
||||
TestEnv bool
|
||||
// MiniAppURL is the base URL of the Mini App launch button.
|
||||
MiniAppURL string
|
||||
}
|
||||
|
||||
// Bot wraps a Telegram Bot API client and the Mini App launch URL.
|
||||
type Bot struct {
|
||||
api *tgbot.Bot
|
||||
miniAppURL string
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// New builds the bot wrapper, registering the /start handler and a default handler
|
||||
// that both reply with a Mini App launch button. It does not start polling; call
|
||||
// Run for that.
|
||||
func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log}
|
||||
|
||||
token := cfg.Token
|
||||
if cfg.TestEnv {
|
||||
// The Bot API test environment lives under /bot<token>/test/METHOD; the
|
||||
// client builds <host>/bot<token>/<method>, so suffixing the token with
|
||||
// "/test" injects the test segment without a custom host.
|
||||
token += "/test"
|
||||
}
|
||||
opts := []tgbot.Option{
|
||||
tgbot.WithDefaultHandler(t.handleStart),
|
||||
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
|
||||
}
|
||||
if cfg.APIBaseURL != "" {
|
||||
opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL))
|
||||
}
|
||||
api, err := tgbot.New(token, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.api = api
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Run sets the bot commands and the Mini App menu button, then blocks on the
|
||||
// long-poll update loop until ctx is cancelled.
|
||||
func (t *Bot) Run(ctx context.Context) {
|
||||
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
|
||||
Commands: []models.BotCommand{{Command: "start", Description: "Open Scrabble"}},
|
||||
}); err != nil {
|
||||
t.log.Warn("set commands failed", zap.Error(err))
|
||||
}
|
||||
if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{
|
||||
MenuButton: models.MenuButtonWebApp{
|
||||
Type: models.MenuButtonTypeWebApp,
|
||||
Text: "Play",
|
||||
WebApp: models.WebAppInfo{URL: t.miniAppURL},
|
||||
},
|
||||
}); err != nil {
|
||||
t.log.Warn("set menu button failed", zap.Error(err))
|
||||
}
|
||||
t.api.Start(ctx)
|
||||
}
|
||||
|
||||
// Notify sends a notification message with a Mini App launch button that opens the
|
||||
// app at startParam (empty opens the lobby).
|
||||
func (t *Bot) Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error {
|
||||
_, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||
ChatID: chatID,
|
||||
Text: text,
|
||||
ReplyMarkup: t.launchMarkup(buttonText, startParam),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// SendText sends a plain text message with no markup (admin use).
|
||||
func (t *Bot) SendText(ctx context.Context, chatID int64, text string) error {
|
||||
_, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text})
|
||||
return err
|
||||
}
|
||||
|
||||
// handleStart replies to /start (with an optional deep-link payload) and to any
|
||||
// other message with a Mini App launch button.
|
||||
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
|
||||
if update.Message == nil {
|
||||
return
|
||||
}
|
||||
startParam := startPayload(update.Message.Text)
|
||||
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||
ChatID: update.Message.Chat.ID,
|
||||
Text: "Tap to open Scrabble.",
|
||||
ReplyMarkup: t.launchMarkup("Open Scrabble", startParam),
|
||||
}); err != nil {
|
||||
t.log.Warn("reply to start failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// launchMarkup builds the single-button inline keyboard that opens the Mini App at
|
||||
// startParam.
|
||||
func (t *Bot) launchMarkup(buttonText, startParam string) *models.InlineKeyboardMarkup {
|
||||
return &models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{{
|
||||
{Text: buttonText, WebApp: &models.WebAppInfo{URL: t.launchURL(startParam)}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// launchURL appends the deep-link start parameter to the Mini App URL as a startapp
|
||||
// query parameter; an empty parameter returns the base URL unchanged.
|
||||
func (t *Bot) launchURL(startParam string) string {
|
||||
if startParam == "" {
|
||||
return t.miniAppURL
|
||||
}
|
||||
u, err := url.Parse(t.miniAppURL)
|
||||
if err != nil {
|
||||
return t.miniAppURL
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("startapp", startParam)
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// startPayload extracts the deep-link payload from a "/start <payload>" command;
|
||||
// any other text yields an empty payload (open the lobby).
|
||||
func startPayload(text string) string {
|
||||
const cmd = "/start"
|
||||
if !strings.HasPrefix(text, cmd) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(text, cmd))
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// fakeBotAPI answers getMe (so bot.New succeeds offline) and records the last
|
||||
// sendMessage form fields.
|
||||
type fakeBotAPI struct {
|
||||
chatID string
|
||||
text string
|
||||
replyMarkup string
|
||||
}
|
||||
|
||||
func (f *fakeBotAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, "/getMe"):
|
||||
io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"test","username":"testbot"}}`)
|
||||
case strings.HasSuffix(r.URL.Path, "/sendMessage"):
|
||||
f.chatID = r.FormValue("chat_id")
|
||||
f.text = r.FormValue("text")
|
||||
f.replyMarkup = r.FormValue("reply_markup")
|
||||
io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`)
|
||||
default:
|
||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestBot(t *testing.T, api http.Handler) *Bot {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(api)
|
||||
t.Cleanup(srv.Close)
|
||||
b, err := New(Config{Token: "123:ABC", APIBaseURL: srv.URL, MiniAppURL: "https://example.com/telegram/"}, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("new bot: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestNotifyBuildsLaunchButton(t *testing.T) {
|
||||
api := &fakeBotAPI{}
|
||||
b := newTestBot(t, api)
|
||||
if err := b.Notify(context.Background(), 12345, "It's your turn.", "Open game", "g7c9e"); err != nil {
|
||||
t.Fatalf("notify: %v", err)
|
||||
}
|
||||
if api.chatID != "12345" {
|
||||
t.Errorf("chat_id = %q, want 12345", api.chatID)
|
||||
}
|
||||
if api.text != "It's your turn." {
|
||||
t.Errorf("text = %q", api.text)
|
||||
}
|
||||
if !strings.Contains(api.replyMarkup, "web_app") || !strings.Contains(api.replyMarkup, "startapp=g7c9e") {
|
||||
t.Errorf("reply_markup = %q, want a web_app button with startapp=g7c9e", api.replyMarkup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTextHasNoMarkup(t *testing.T) {
|
||||
api := &fakeBotAPI{}
|
||||
b := newTestBot(t, api)
|
||||
if err := b.SendText(context.Background(), 999, "plain"); err != nil {
|
||||
t.Fatalf("send text: %v", err)
|
||||
}
|
||||
if api.chatID != "999" || api.text != "plain" {
|
||||
t.Errorf("chat_id=%q text=%q, want 999/plain", api.chatID, api.text)
|
||||
}
|
||||
if api.replyMarkup != "" {
|
||||
t.Errorf("reply_markup = %q, want empty", api.replyMarkup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPayload(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/start g123": "g123",
|
||||
"/start": "",
|
||||
"/start f99 ": "f99",
|
||||
"hello": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := startPayload(in); got != want {
|
||||
t.Errorf("startPayload(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchURL(t *testing.T) {
|
||||
b := &Bot{miniAppURL: "https://example.com/telegram/"}
|
||||
if got := b.launchURL(""); got != "https://example.com/telegram/" {
|
||||
t.Errorf("empty start param = %q, want the base URL", got)
|
||||
}
|
||||
if got := b.launchURL("g123"); !strings.Contains(got, "startapp=g123") {
|
||||
t.Errorf("launchURL = %q, want startapp=g123", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package config loads the Telegram connector's environment configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config is the Telegram connector's runtime configuration, read from the
|
||||
// environment. The bot token lives only in this process (ARCHITECTURE.md §12).
|
||||
type Config struct {
|
||||
// BotToken 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 validation.
|
||||
BotToken string
|
||||
// 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).
|
||||
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.
|
||||
APIBaseURL string
|
||||
// TestEnv routes the Bot API client to Telegram's test environment
|
||||
// (.../bot<token>/test/METHOD) (TELEGRAM_TEST_ENV=true, default false).
|
||||
TestEnv bool
|
||||
// GameChannelID is the chat id of the bot's game channel for SendToGameChannel
|
||||
// (TELEGRAM_GAME_CHANNEL_ID, optional; 0 disables channel posts).
|
||||
GameChannelID int64
|
||||
// LogLevel is the zap log level (TELEGRAM_LOG_LEVEL, default info).
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
// Load reads the connector configuration from the environment, applying defaults
|
||||
// and validating the required fields.
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
BotToken: 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"),
|
||||
}
|
||||
if cfg.BotToken == "" {
|
||||
return Config{}, fmt.Errorf("config: TELEGRAM_BOT_TOKEN is required")
|
||||
}
|
||||
if cfg.MiniAppURL == "" {
|
||||
return Config{}, fmt.Errorf("config: TELEGRAM_MINIAPP_URL is required")
|
||||
}
|
||||
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)
|
||||
}
|
||||
cfg.GameChannelID = id
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// envOr returns the environment value for key, or def when it is unset or empty.
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Package connector implements the Telegram gRPC service (pkg/proto/telegram/v1):
|
||||
// the gateway calls ValidateInitData (Mini App auth) and Notify (out-of-app push);
|
||||
// the admin surface (Stage 10) will call SendToUser and SendToGameChannel. The
|
||||
// generic methods address a recipient by the identity external_id, so a future
|
||||
// platform connector can implement the same service.
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
telegramv1 "scrabble/pkg/proto/telegram/v1"
|
||||
"scrabble/platform/telegram/internal/initdata"
|
||||
"scrabble/platform/telegram/internal/render"
|
||||
)
|
||||
|
||||
// Sender delivers Telegram messages to a chat. *bot.Bot implements it.
|
||||
type Sender interface {
|
||||
// Notify sends a notification with a Mini App launch button to chatID.
|
||||
Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error
|
||||
// SendText sends a plain text message to chatID.
|
||||
SendText(ctx context.Context, chatID int64, text string) error
|
||||
}
|
||||
|
||||
// Server implements telegramv1.TelegramServer.
|
||||
type Server struct {
|
||||
telegramv1.UnimplementedTelegramServer
|
||||
validator initdata.Validator
|
||||
sender Sender
|
||||
channelID int64
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
// NewServer builds the gRPC service from a validator (for ValidateInitData), a
|
||||
// sender (the bot), and the configured game channel id (0 disables channel posts).
|
||||
func NewServer(validator initdata.Validator, sender Sender, channelID int64, log *zap.Logger) *Server {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
return &Server{validator: validator, sender: sender, channelID: channelID, log: log}
|
||||
}
|
||||
|
||||
// ValidateInitData verifies Mini App launch data and returns the user identity.
|
||||
func (s *Server) ValidateInitData(ctx context.Context, req *telegramv1.ValidateInitDataRequest) (*telegramv1.ValidateInitDataResponse, error) {
|
||||
u, err := s.validator.Validate(req.GetInitData())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
return &telegramv1.ValidateInitDataResponse{
|
||||
ExternalId: u.ExternalID,
|
||||
Username: u.Username,
|
||||
FirstName: u.FirstName,
|
||||
LanguageCode: u.LanguageCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Notify renders and delivers an out-of-app notification. It reports
|
||||
// delivered=false (without an error) for a kind that is not pushed out-of-app or a
|
||||
// delivery the bot could not complete (e.g. the user never started the bot), so the
|
||||
// gateway treats a fallback miss as best-effort.
|
||||
func (s *Server) Notify(ctx context.Context, req *telegramv1.NotifyRequest) (*telegramv1.NotifyResponse, error) {
|
||||
msg, ok := render.Render(req.GetKind(), req.GetPayload(), req.GetLanguage())
|
||||
if !ok {
|
||||
return &telegramv1.NotifyResponse{Delivered: false}, nil
|
||||
}
|
||||
chat, err := parseChatID(req.GetExternalId())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
if err := s.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.
|
||||
func (s *Server) SendToUser(ctx context.Context, req *telegramv1.SendToUserRequest) (*telegramv1.SendResponse, error) {
|
||||
chat, err := parseChatID(req.GetExternalId())
|
||||
if err != nil {
|
||||
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||
}
|
||||
if err := s.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 admin message to the configured game channel.
|
||||
func (s *Server) SendToGameChannel(ctx context.Context, req *telegramv1.SendToGameChannelRequest) (*telegramv1.SendResponse, error) {
|
||||
if s.channelID == 0 {
|
||||
return nil, status.Error(codes.FailedPrecondition, "game channel is not configured")
|
||||
}
|
||||
if err := s.sender.SendText(ctx, s.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
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid external_id %q", externalID)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package connector
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"scrabble/pkg/fbs/scrabblefb"
|
||||
telegramv1 "scrabble/pkg/proto/telegram/v1"
|
||||
"scrabble/platform/telegram/internal/initdata"
|
||||
)
|
||||
|
||||
// stubValidator returns a fixed user / error from Validate.
|
||||
type stubValidator struct {
|
||||
user initdata.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubValidator) Validate(string) (initdata.User, error) { return s.user, s.err }
|
||||
|
||||
// fakeSender records the delivery calls the server makes.
|
||||
type fakeSender struct {
|
||||
notify []notifyCall
|
||||
text []textCall
|
||||
err error
|
||||
}
|
||||
|
||||
type notifyCall struct {
|
||||
chatID int64
|
||||
text, buttonText, startParam string
|
||||
}
|
||||
type textCall struct {
|
||||
chatID int64
|
||||
text string
|
||||
}
|
||||
|
||||
func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error {
|
||||
f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam})
|
||||
return f.err
|
||||
}
|
||||
|
||||
func (f *fakeSender) SendText(_ context.Context, chatID int64, text string) error {
|
||||
f.text = append(f.text, textCall{chatID, text})
|
||||
return f.err
|
||||
}
|
||||
|
||||
func yourTurnPayload(gameID string) []byte {
|
||||
b := flatbuffers.NewBuilder(0)
|
||||
gid := b.CreateString(gameID)
|
||||
scrabblefb.YourTurnEventStart(b)
|
||||
scrabblefb.YourTurnEventAddGameId(b, gid)
|
||||
b.Finish(scrabblefb.YourTurnEventEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
func TestValidateInitData(t *testing.T) {
|
||||
want := initdata.User{ExternalID: "42", Username: "neo", FirstName: "Thomas", LanguageCode: "ru"}
|
||||
srv := NewServer(stubValidator{user: want}, &fakeSender{}, 0, nil)
|
||||
resp, err := srv.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{InitData: "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
if resp.GetExternalId() != "42" || resp.GetUsername() != "neo" || resp.GetFirstName() != "Thomas" || resp.GetLanguageCode() != "ru" {
|
||||
t.Errorf("resp = %+v, want %+v", resp, want)
|
||||
}
|
||||
|
||||
bad := NewServer(stubValidator{err: initdata.ErrInvalidInitData}, &fakeSender{}, 0, nil)
|
||||
if _, err := bad.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{}); status.Code(err) != codes.InvalidArgument {
|
||||
t.Errorf("err code = %v, want InvalidArgument", status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyDelivers(t *testing.T) {
|
||||
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
sender := &fakeSender{}
|
||||
srv := NewServer(stubValidator{}, sender, 0, nil)
|
||||
resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
|
||||
ExternalId: "12345", Kind: "your_turn", Payload: yourTurnPayload(gameID), Language: "en",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("notify: %v", err)
|
||||
}
|
||||
if !resp.GetDelivered() {
|
||||
t.Fatal("expected delivered=true")
|
||||
}
|
||||
if len(sender.notify) != 1 {
|
||||
t.Fatalf("notify calls = %d, want 1", len(sender.notify))
|
||||
}
|
||||
if got := sender.notify[0]; got.chatID != 12345 || got.startParam != "g"+gameID {
|
||||
t.Errorf("notify call = %+v, want chatID 12345 startParam g%s", got, gameID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifySkipsUnrenderedKind(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
srv := NewServer(stubValidator{}, sender, 0, nil)
|
||||
resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
|
||||
ExternalId: "12345", Kind: "opponent_moved", Language: "en",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("notify: %v", err)
|
||||
}
|
||||
if resp.GetDelivered() {
|
||||
t.Error("expected delivered=false for an unrendered kind")
|
||||
}
|
||||
if len(sender.notify) != 0 {
|
||||
t.Errorf("sender called %d times, want 0", len(sender.notify))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyInvalidExternalID(t *testing.T) {
|
||||
srv := NewServer(stubValidator{}, &fakeSender{}, 0, nil)
|
||||
_, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
|
||||
ExternalId: "not-a-number", Kind: "your_turn", Payload: yourTurnPayload("g"), Language: "en",
|
||||
})
|
||||
if status.Code(err) != codes.InvalidArgument {
|
||||
t.Errorf("err code = %v, want InvalidArgument", status.Code(err))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToUser(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
srv := NewServer(stubValidator{}, sender, 0, nil)
|
||||
resp, err := srv.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"})
|
||||
if err != nil {
|
||||
t.Fatalf("send to user: %v", err)
|
||||
}
|
||||
if !resp.GetDelivered() || len(sender.text) != 1 || sender.text[0].chatID != 999 || sender.text[0].text != "hi" {
|
||||
t.Errorf("send to user = %v / calls %+v", resp.GetDelivered(), sender.text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendToGameChannel(t *testing.T) {
|
||||
t.Run("unconfigured", func(t *testing.T) {
|
||||
srv := NewServer(stubValidator{}, &fakeSender{}, 0, 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", func(t *testing.T) {
|
||||
sender := &fakeSender{}
|
||||
srv := NewServer(stubValidator{}, sender, 555, 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(sender.text) != 1 || sender.text[0].chatID != 555 {
|
||||
t.Errorf("send to channel calls = %+v", sender.text)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package deeplink builds and parses the Telegram Mini App "start parameters" that
|
||||
// route a launch to a specific destination. The scheme is shared verbatim with the
|
||||
// UI (ui/src/lib/deeplink.ts): a one-character kind prefix followed by a value —
|
||||
//
|
||||
// g<game uuid> open that game
|
||||
// i<invitation uuid> open that invitation
|
||||
// f<6-digit code> redeem that friend code
|
||||
//
|
||||
// An empty or unrecognised parameter opens the lobby. UUIDs keep their dashes,
|
||||
// which are allowed in a Telegram startapp parameter ([A-Za-z0-9_-]).
|
||||
package deeplink
|
||||
|
||||
import "strings"
|
||||
|
||||
// Kind prefixes for the start-parameter scheme.
|
||||
const (
|
||||
prefixGame = "g"
|
||||
prefixInvitation = "i"
|
||||
prefixFriendCode = "f"
|
||||
)
|
||||
|
||||
// Kind classifies a start parameter.
|
||||
type Kind int
|
||||
|
||||
// The start-parameter kinds.
|
||||
const (
|
||||
KindLobby Kind = iota
|
||||
KindGame
|
||||
KindInvitation
|
||||
KindFriendCode
|
||||
)
|
||||
|
||||
// Game returns the start parameter that opens the game with the given id.
|
||||
func Game(id string) string { return prefixGame + id }
|
||||
|
||||
// Invitation returns the start parameter that opens the invitation with the id.
|
||||
func Invitation(id string) string { return prefixInvitation + id }
|
||||
|
||||
// FriendCode returns the start parameter that redeems the given friend code.
|
||||
func FriendCode(code string) string { return prefixFriendCode + code }
|
||||
|
||||
// Parse classifies a start parameter and returns its value (the part after the
|
||||
// kind prefix). An empty or unrecognised parameter is KindLobby with an empty
|
||||
// value.
|
||||
func Parse(p string) (Kind, string) {
|
||||
switch {
|
||||
case strings.HasPrefix(p, prefixGame):
|
||||
return KindGame, p[len(prefixGame):]
|
||||
case strings.HasPrefix(p, prefixInvitation):
|
||||
return KindInvitation, p[len(prefixInvitation):]
|
||||
case strings.HasPrefix(p, prefixFriendCode):
|
||||
return KindFriendCode, p[len(prefixFriendCode):]
|
||||
default:
|
||||
return KindLobby, ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package deeplink
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildAndParse(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
param string
|
||||
wantKind Kind
|
||||
wantValue string
|
||||
}{
|
||||
{"game", Game("7c9e6679-7425-40de-944b-e07fc1f90ae7"), KindGame, "7c9e6679-7425-40de-944b-e07fc1f90ae7"},
|
||||
{"invitation", Invitation("11111111-2222-3333-4444-555555555555"), KindInvitation, "11111111-2222-3333-4444-555555555555"},
|
||||
{"friend code", FriendCode("123456"), KindFriendCode, "123456"},
|
||||
{"empty is lobby", "", KindLobby, ""},
|
||||
{"unknown is lobby", "x-nope", KindLobby, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotKind, gotValue := Parse(tc.param)
|
||||
if gotKind != tc.wantKind {
|
||||
t.Errorf("kind = %d, want %d", gotKind, tc.wantKind)
|
||||
}
|
||||
if gotValue != tc.wantValue {
|
||||
t.Errorf("value = %q, want %q", gotValue, tc.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPrefixes(t *testing.T) {
|
||||
if Game("x") != "gx" {
|
||||
t.Errorf("Game = %q, want gx", Game("x"))
|
||||
}
|
||||
if Invitation("x") != "ix" {
|
||||
t.Errorf("Invitation = %q, want ix", Invitation("x"))
|
||||
}
|
||||
if FriendCode("123456") != "f123456" {
|
||||
t.Errorf("FriendCode = %q, want f123456", FriendCode("123456"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Package initdata validates Telegram Mini App launch data (initData). It lives in
|
||||
// the connector because the HMAC secret is the bot token, which is held only here
|
||||
// (ARCHITECTURE.md §12); the gateway calls the connector's ValidateInitData RPC
|
||||
// instead of validating the launch data itself.
|
||||
package initdata
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
|
||||
// the hash, is malformed, or is older than the freshness window.
|
||||
var ErrInvalidInitData = errors.New("initdata: invalid telegram init data")
|
||||
|
||||
// defaultMaxAge bounds how old a validated initData payload may be.
|
||||
const defaultMaxAge = 24 * time.Hour
|
||||
|
||||
// User is the identity extracted from a validated initData payload. ExternalID is
|
||||
// the Telegram user id used as the identities external_id; LanguageCode seeds a
|
||||
// new account's preferred language (Stage 9).
|
||||
type User struct {
|
||||
ExternalID string
|
||||
Username string
|
||||
FirstName string
|
||||
LanguageCode string
|
||||
}
|
||||
|
||||
// Validator validates Telegram Web App launch data and returns the authenticated
|
||||
// user. It is an interface so the connector can be tested with a fixture.
|
||||
type Validator interface {
|
||||
Validate(initData string) (User, error)
|
||||
}
|
||||
|
||||
// HMACValidator validates initData against a bot token per Telegram's documented
|
||||
// algorithm: the data-check string is HMAC-SHA256'd under a secret derived from
|
||||
// the bot token, and the result is compared with the supplied hash.
|
||||
type HMACValidator struct {
|
||||
botToken string
|
||||
maxAge time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewHMACValidator constructs a validator for botToken.
|
||||
func NewHMACValidator(botToken string) *HMACValidator {
|
||||
return &HMACValidator{botToken: botToken, maxAge: defaultMaxAge, now: time.Now}
|
||||
}
|
||||
|
||||
// Validate parses and verifies initData, returning the authenticated user.
|
||||
func (v *HMACValidator) Validate(initData string) (User, error) {
|
||||
values, err := url.ParseQuery(initData)
|
||||
if err != nil {
|
||||
return User{}, ErrInvalidInitData
|
||||
}
|
||||
hash := values.Get("hash")
|
||||
if hash == "" {
|
||||
return User{}, ErrInvalidInitData
|
||||
}
|
||||
values.Del("hash")
|
||||
|
||||
if !v.checkSignature(values, hash) {
|
||||
return User{}, ErrInvalidInitData
|
||||
}
|
||||
if err := v.checkFreshness(values.Get("auth_date")); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return parseUser(values.Get("user"))
|
||||
}
|
||||
|
||||
// checkSignature recomputes the HMAC over the sorted data-check string and
|
||||
// compares it with hash in constant time.
|
||||
func (v *HMACValidator) checkSignature(values url.Values, hash string) bool {
|
||||
keys := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
lines = append(lines, k+"="+values.Get(k))
|
||||
}
|
||||
dataCheck := strings.Join(lines, "\n")
|
||||
|
||||
secret := hmacSHA256([]byte("WebAppData"), []byte(v.botToken))
|
||||
want := hmacSHA256(secret, []byte(dataCheck))
|
||||
got, err := hex.DecodeString(hash)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return hmac.Equal(want, got)
|
||||
}
|
||||
|
||||
// checkFreshness rejects an auth_date older than the validator's window.
|
||||
func (v *HMACValidator) checkFreshness(authDate string) error {
|
||||
if authDate == "" {
|
||||
return ErrInvalidInitData
|
||||
}
|
||||
secs, err := strconv.ParseInt(authDate, 10, 64)
|
||||
if err != nil {
|
||||
return ErrInvalidInitData
|
||||
}
|
||||
if v.now().Sub(time.Unix(secs, 0)) > v.maxAge {
|
||||
return ErrInvalidInitData
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseUser extracts the user id, names and language from the user JSON field.
|
||||
func parseUser(userJSON string) (User, error) {
|
||||
if userJSON == "" {
|
||||
return User{}, ErrInvalidInitData
|
||||
}
|
||||
var u struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
LanguageCode string `json:"language_code"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
|
||||
return User{}, ErrInvalidInitData
|
||||
}
|
||||
return User{
|
||||
ExternalID: strconv.FormatInt(u.ID, 10),
|
||||
Username: u.Username,
|
||||
FirstName: u.FirstName,
|
||||
LanguageCode: u.LanguageCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// hmacSHA256 returns HMAC-SHA256(message) under key.
|
||||
func hmacSHA256(key, message []byte) []byte {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write(message)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package initdata
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testToken = "123456:TESTTOKEN"
|
||||
|
||||
// signInitData builds a validly signed initData query string for the given token
|
||||
// and decoded fields, mirroring Telegram's data-check algorithm.
|
||||
func signInitData(token string, fields map[string]string) string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
lines = append(lines, k+"="+fields[k])
|
||||
}
|
||||
secret := hmacSHA256([]byte("WebAppData"), []byte(token))
|
||||
mac := hmacSHA256(secret, []byte(strings.Join(lines, "\n")))
|
||||
|
||||
v := url.Values{}
|
||||
for k, val := range fields {
|
||||
v.Set(k, val)
|
||||
}
|
||||
v.Set("hash", hex.EncodeToString(mac))
|
||||
return v.Encode()
|
||||
}
|
||||
|
||||
func freshFields() map[string]string {
|
||||
return map[string]string{
|
||||
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||
"user": `{"id":42,"username":"neo","first_name":"Thomas","language_code":"ru"}`,
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateOK(t *testing.T) {
|
||||
initData := signInitData(testToken, freshFields())
|
||||
u, err := NewHMACValidator(testToken).Validate(initData)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
if u.ExternalID != "42" || u.Username != "neo" || u.FirstName != "Thomas" || u.LanguageCode != "ru" {
|
||||
t.Errorf("user = %+v, want {42 neo Thomas ru}", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejects(t *testing.T) {
|
||||
valid := signInitData(testToken, freshFields())
|
||||
|
||||
t.Run("tampered hash", func(t *testing.T) {
|
||||
tampered := strings.Replace(valid, "hash=", "hash=00", 1)
|
||||
if _, err := NewHMACValidator(testToken).Validate(tampered); !errors.Is(err, ErrInvalidInitData) {
|
||||
t.Errorf("err = %v, want ErrInvalidInitData", err)
|
||||
}
|
||||
})
|
||||
t.Run("wrong token", func(t *testing.T) {
|
||||
if _, err := NewHMACValidator("other:TOKEN").Validate(valid); !errors.Is(err, ErrInvalidInitData) {
|
||||
t.Errorf("err = %v, want ErrInvalidInitData", err)
|
||||
}
|
||||
})
|
||||
t.Run("missing hash", func(t *testing.T) {
|
||||
if _, err := NewHMACValidator(testToken).Validate("user=%7B%7D&auth_date=1"); !errors.Is(err, ErrInvalidInitData) {
|
||||
t.Errorf("err = %v, want ErrInvalidInitData", err)
|
||||
}
|
||||
})
|
||||
t.Run("stale auth_date", func(t *testing.T) {
|
||||
stale := signInitData(testToken, map[string]string{
|
||||
"auth_date": strconv.FormatInt(time.Now().Add(-48*time.Hour).Unix(), 10),
|
||||
"user": `{"id":42}`,
|
||||
})
|
||||
if _, err := NewHMACValidator(testToken).Validate(stale); !errors.Is(err, ErrInvalidInitData) {
|
||||
t.Errorf("err = %v, want ErrInvalidInitData", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Package render turns a backend push event into a localized Telegram message with
|
||||
// a Mini App deep-link. Only the out-of-app push set is rendered (your_turn, nudge,
|
||||
// match_found, and the invitation / friend_request notify sub-kinds); every other
|
||||
// kind returns ok=false so the connector skips it (the in-app stream still carries
|
||||
// it).
|
||||
package render
|
||||
|
||||
import (
|
||||
"scrabble/pkg/fbs/scrabblefb"
|
||||
"scrabble/platform/telegram/internal/deeplink"
|
||||
)
|
||||
|
||||
// Message is a rendered notification: the body text, the launch-button label and
|
||||
// the deep-link start parameter (empty opens the lobby).
|
||||
type Message struct {
|
||||
Text string
|
||||
ButtonText string
|
||||
StartParam string
|
||||
}
|
||||
|
||||
// Render builds the localized message for a backend push event of the given kind
|
||||
// and FlatBuffers payload, in language lang ("ru" selects Russian; anything else
|
||||
// is English). It returns ok=false for a kind that is not delivered out-of-app.
|
||||
func Render(kind string, payload []byte, lang string) (Message, bool) {
|
||||
p := english
|
||||
if lang == "ru" {
|
||||
p = russian
|
||||
}
|
||||
switch kind {
|
||||
case "your_turn":
|
||||
ev := scrabblefb.GetRootAsYourTurnEvent(payload, 0)
|
||||
return Message{Text: p.yourTurn, ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true
|
||||
case "nudge":
|
||||
ev := scrabblefb.GetRootAsNudgeEvent(payload, 0)
|
||||
return Message{Text: p.nudge, ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true
|
||||
case "match_found":
|
||||
ev := scrabblefb.GetRootAsMatchFoundEvent(payload, 0)
|
||||
return Message{Text: p.matchFound, ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true
|
||||
case "notify":
|
||||
ev := scrabblefb.GetRootAsNotificationEvent(payload, 0)
|
||||
switch string(ev.Kind()) {
|
||||
case "invitation":
|
||||
return Message{Text: p.invitation, ButtonText: p.open}, true
|
||||
case "friend_request":
|
||||
return Message{Text: p.friendRequest, ButtonText: p.open}, true
|
||||
}
|
||||
}
|
||||
return Message{}, false
|
||||
}
|
||||
|
||||
// phrases is one language's message catalog.
|
||||
type phrases struct {
|
||||
yourTurn string
|
||||
nudge string
|
||||
matchFound string
|
||||
invitation string
|
||||
friendRequest string
|
||||
openGame string
|
||||
open string
|
||||
}
|
||||
|
||||
var english = phrases{
|
||||
yourTurn: "It's your turn.",
|
||||
nudge: "You were nudged — it's your turn.",
|
||||
matchFound: "Your game is ready.",
|
||||
invitation: "You have a new game invitation.",
|
||||
friendRequest: "You have a new friend request.",
|
||||
openGame: "Open game",
|
||||
open: "Open",
|
||||
}
|
||||
|
||||
var russian = phrases{
|
||||
yourTurn: "Ваш ход.",
|
||||
nudge: "Вас поторопили — ваш ход.",
|
||||
matchFound: "Игра найдена.",
|
||||
invitation: "Вас пригласили в игру.",
|
||||
friendRequest: "Вам пришла заявка в друзья.",
|
||||
openGame: "Открыть игру",
|
||||
open: "Открыть",
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
|
||||
"scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
|
||||
func yourTurnPayload(id string) []byte {
|
||||
b := flatbuffers.NewBuilder(0)
|
||||
gid := b.CreateString(id)
|
||||
scrabblefb.YourTurnEventStart(b)
|
||||
scrabblefb.YourTurnEventAddGameId(b, gid)
|
||||
b.Finish(scrabblefb.YourTurnEventEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
func nudgePayload(id string) []byte {
|
||||
b := flatbuffers.NewBuilder(0)
|
||||
gid := b.CreateString(id)
|
||||
scrabblefb.NudgeEventStart(b)
|
||||
scrabblefb.NudgeEventAddGameId(b, gid)
|
||||
b.Finish(scrabblefb.NudgeEventEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
func matchFoundPayload(id string) []byte {
|
||||
b := flatbuffers.NewBuilder(0)
|
||||
gid := b.CreateString(id)
|
||||
scrabblefb.MatchFoundEventStart(b)
|
||||
scrabblefb.MatchFoundEventAddGameId(b, gid)
|
||||
b.Finish(scrabblefb.MatchFoundEventEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
func notifyPayload(kind string) []byte {
|
||||
b := flatbuffers.NewBuilder(0)
|
||||
k := b.CreateString(kind)
|
||||
scrabblefb.NotificationEventStart(b)
|
||||
scrabblefb.NotificationEventAddKind(b, k)
|
||||
b.Finish(scrabblefb.NotificationEventEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
func TestRenderGameEvents(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, kind string
|
||||
payload []byte
|
||||
}{
|
||||
{"your_turn", "your_turn", yourTurnPayload(gameID)},
|
||||
{"nudge", "nudge", nudgePayload(gameID)},
|
||||
{"match_found", "match_found", matchFoundPayload(gameID)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+" en", func(t *testing.T) {
|
||||
m, ok := Render(tc.kind, tc.payload, "en")
|
||||
if !ok {
|
||||
t.Fatal("expected ok")
|
||||
}
|
||||
if m.StartParam != "g"+gameID {
|
||||
t.Errorf("StartParam = %q, want g%s", m.StartParam, gameID)
|
||||
}
|
||||
if m.ButtonText != "Open game" {
|
||||
t.Errorf("ButtonText = %q, want Open game", m.ButtonText)
|
||||
}
|
||||
if m.Text == "" {
|
||||
t.Error("Text is empty")
|
||||
}
|
||||
})
|
||||
t.Run(tc.name+" ru", func(t *testing.T) {
|
||||
m, ok := Render(tc.kind, tc.payload, "ru")
|
||||
if !ok {
|
||||
t.Fatal("expected ok")
|
||||
}
|
||||
if m.ButtonText != "Открыть игру" {
|
||||
t.Errorf("ButtonText = %q, want Открыть игру", m.ButtonText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderNotify(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
subKind string
|
||||
wantOK bool
|
||||
}{
|
||||
"invitation": {"invitation", true},
|
||||
"friend_request": {"friend_request", true},
|
||||
"friend_added": {"friend_added", false},
|
||||
"game_started": {"game_started", false},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
m, ok := Render("notify", notifyPayload(tc.subKind), "en")
|
||||
if ok != tc.wantOK {
|
||||
t.Fatalf("ok = %v, want %v", ok, tc.wantOK)
|
||||
}
|
||||
if ok && m.StartParam != "" {
|
||||
t.Errorf("StartParam = %q, want empty (lobby)", m.StartParam)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSkipsUnpushedKinds(t *testing.T) {
|
||||
for _, kind := range []string{"opponent_moved", "chat_message", "unknown"} {
|
||||
if _, ok := Render(kind, nil, "en"); ok {
|
||||
t.Errorf("kind %q: ok = true, want false", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user