Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
Tests · Go / test (push) Successful in 7s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 12s
Tests · UI / test (pull_request) Failing after 5m9s

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:
Ilia Denisov
2026-06-04 01:42:54 +02:00
parent 1012fb47a0
commit 3590df28db
80 changed files with 3590 additions and 368 deletions
+82
View File
@@ -0,0 +1,82 @@
// Package connector is the gateway's gRPC client for the Telegram connector
// side-service: it validates Mini App initData and delivers out-of-app push. The
// connector lives on the trusted internal network, so the connection uses insecure
// (plaintext) transport credentials (ARCHITECTURE.md §12).
package connector
import (
"context"
"errors"
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
telegramv1 "scrabble/pkg/proto/telegram/v1"
)
// ErrInvalidInitData is returned by ValidateInitData when the connector rejects the
// launch data (a gRPC InvalidArgument), letting the transcode layer surface a stable
// result code.
var ErrInvalidInitData = errors.New("connector: invalid telegram init data")
// User is a validated Mini App identity.
type User struct {
ExternalID string
Username string
FirstName string
LanguageCode string
}
// Client wraps the connector's Telegram gRPC service.
type Client struct {
conn *grpc.ClientConn
c telegramv1.TelegramClient
}
// New dials the connector gRPC endpoint.
func New(addr string) (*Client, error) {
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, fmt.Errorf("connector: dial %s: %w", addr, err)
}
return &Client{conn: conn, c: telegramv1.NewTelegramClient(conn)}, nil
}
// Close releases the gRPC connection.
func (c *Client) Close() error { return c.conn.Close() }
// ValidateInitData verifies Mini App launch data and returns the user identity,
// mapping a connector InvalidArgument to ErrInvalidInitData.
func (c *Client) ValidateInitData(ctx context.Context, initData string) (User, error) {
resp, err := c.c.ValidateInitData(ctx, &telegramv1.ValidateInitDataRequest{InitData: initData})
if err != nil {
if status.Code(err) == codes.InvalidArgument {
return User{}, ErrInvalidInitData
}
return User{}, err
}
return User{
ExternalID: resp.GetExternalId(),
Username: resp.GetUsername(),
FirstName: resp.GetFirstName(),
LanguageCode: resp.GetLanguageCode(),
}, nil
}
// Notify delivers an out-of-app notification for a push event; delivered reports
// whether a message was actually sent.
func (c *Client) Notify(ctx context.Context, externalID, kind string, payload []byte, language string) (bool, error) {
resp, err := c.c.Notify(ctx, &telegramv1.NotifyRequest{
ExternalId: externalID,
Kind: kind,
Payload: payload,
Language: language,
})
if err != nil {
return false, err
}
return resp.GetDelivered(), nil
}
+23
View File
@@ -0,0 +1,23 @@
package connector
// outOfAppKinds is the set of backend push kinds delivered out-of-app; the rest
// stay in-app only (opponent_moved and chat_message are too noisy for a platform
// notification).
var outOfAppKinds = map[string]bool{
"your_turn": true,
"nudge": true,
"match_found": true,
"notify": true,
}
// OutOfAppKind reports whether a push kind is eligible for out-of-app delivery.
func OutOfAppKind(kind string) bool { return outOfAppKinds[kind] }
// DeliverToTarget reports whether a resolved push target should receive an
// out-of-app message: it has a Telegram identity (externalID != "") and has not
// confined notifications to the app (inAppOnly == false). Combined with the
// caller's "recipient is offline" check, this is the dedup rule that keeps the
// platform push free of duplicates with the in-app stream.
func DeliverToTarget(externalID string, inAppOnly bool) bool {
return externalID != "" && !inAppOnly
}
@@ -0,0 +1,38 @@
package connector
import "testing"
func TestOutOfAppKind(t *testing.T) {
out := []string{"your_turn", "nudge", "match_found", "notify"}
for _, k := range out {
if !OutOfAppKind(k) {
t.Errorf("OutOfAppKind(%q) = false, want true", k)
}
}
for _, k := range []string{"opponent_moved", "chat_message", "", "unknown"} {
if OutOfAppKind(k) {
t.Errorf("OutOfAppKind(%q) = true, want false", k)
}
}
}
func TestDeliverToTarget(t *testing.T) {
cases := []struct {
name string
externalID string
inAppOnly bool
want bool
}{
{"telegram + opted in", "12345", false, true},
{"in-app only suppresses", "12345", true, false},
{"no telegram identity", "", false, false},
{"no identity and in-app only", "", true, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := DeliverToTarget(tc.externalID, tc.inAppOnly); got != tc.want {
t.Errorf("DeliverToTarget(%q, %v) = %v, want %v", tc.externalID, tc.inAppOnly, got, tc.want)
}
})
}
}