Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
Tests · Go / test (push) Successful in 8s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Failing after 5m9s
Tests · Go / test (pull_request) Successful in 7s
Tests · Integration / integration (pull_request) Successful in 13s
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 5290157999
76 changed files with 3547 additions and 343 deletions
+103 -21
View File
@@ -10,7 +10,9 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
@@ -56,9 +58,13 @@ type Account struct {
BlockFriendRequests bool
// IsGuest marks an ephemeral guest account: a durable row with no identity,
// excluded from statistics, friends and history.
IsGuest bool
CreatedAt time.Time
UpdatedAt time.Time
IsGuest bool
// NotificationsInAppOnly confines notifications to the in-app live stream when
// true (the default): the platform side-service skips out-of-app push for the
// account (Stage 9).
NotificationsInAppOnly bool
CreatedAt time.Time
UpdatedAt time.Time
}
// Store is the Postgres-backed query surface for accounts and identities.
@@ -77,6 +83,22 @@ func NewStore(db *sql.DB) *Store {
// resolved by re-reading the winner's account. A platform identity is recorded
// as confirmed; an email identity starts unconfirmed.
func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
return s.provision(ctx, kind, externalID, provisionSeed{})
}
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
// identity. On first contact only, it seeds the new account's preferred language
// from the Telegram client languageCode (when it maps to a supported language) and
// its display name from firstName (falling back to username); an already-existing
// account is returned unchanged, so a later profile edit is never overwritten.
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
}
// provision finds the account for (kind, externalID) or creates it with seed,
// collapsing a concurrent-create race on the identity unique constraint into a
// re-read of the winner's account.
func (s *Store) provision(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) {
acc, err := s.findByIdentity(ctx, kind, externalID)
if err == nil {
return acc, nil
@@ -85,7 +107,7 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
return Account{}, err
}
acc, err = s.create(ctx, kind, externalID)
acc, err = s.create(ctx, kind, externalID, seed)
if err != nil {
if isUniqueViolation(err) {
// A concurrent caller created the identity first; return theirs.
@@ -96,6 +118,35 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
return acc, nil
}
// provisionSeed carries the optional create-time profile seed for a brand-new
// account (Telegram first contact). Empty fields fall back to the accounts table
// defaults, so an unknown language keeps the 'en' default and an empty name keeps
// the ” default.
type provisionSeed struct {
preferredLanguage string
displayName string
}
// telegramSeed derives the create-time seed from Telegram launch fields: a
// supported preferred language from languageCode (an ISO-639 code, possibly
// region-tagged like "ru-RU"), and a display name from firstName or, failing that,
// username (capped to maxDisplayName runes).
func telegramSeed(languageCode, username, firstName string) provisionSeed {
var seed provisionSeed
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
seed.preferredLanguage = lang
}
name := strings.TrimSpace(firstName)
if name == "" {
name = strings.TrimSpace(username)
}
if utf8.RuneCountInString(name) > maxDisplayName {
name = string([]rune(name)[:maxDisplayName])
}
seed.displayName = name
return seed
}
// GetByID loads the account identified by id, or ErrNotFound when it is absent.
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
stmt := postgres.SELECT(table.Accounts.AllColumns).
@@ -113,6 +164,29 @@ func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
return modelToAccount(row), nil
}
// IdentityExternalID returns the external_id of the account's identity of the
// given kind, or ErrNotFound when the account has no such identity. The Telegram
// side-service uses it (through the gateway push-target lookup) to address an
// out-of-app notification to a recipient's Telegram chat.
func (s *Store) IdentityExternalID(ctx context.Context, accountID uuid.UUID, kind string) (string, error) {
stmt := postgres.SELECT(table.Identities.ExternalID).
FROM(table.Identities).
WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(kind))),
).
LIMIT(1)
var row model.Identities
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return "", ErrNotFound
}
return "", fmt.Errorf("account: identity external id (%s, %s): %w", accountID, kind, err)
}
return row.ExternalID, nil
}
// findByIdentity joins identities to accounts and returns the matching account,
// or ErrNotFound.
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
@@ -137,9 +211,9 @@ func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Ac
return modelToAccount(row), nil
}
// create inserts a new account and its first identity inside one transaction
// and returns the persisted account row.
func (s *Store) create(ctx context.Context, kind, externalID string) (Account, error) {
// create inserts a new account (seeded from seed) and its first identity inside
// one transaction and returns the persisted account row.
func (s *Store) create(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) {
accountID, err := uuid.NewV7()
if err != nil {
return Account{}, fmt.Errorf("account: new account id: %w", err)
@@ -151,9 +225,16 @@ func (s *Store) create(ctx context.Context, kind, externalID string) (Account, e
var created Account
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
// Seed the new row's display name and language (Telegram first contact); an
// empty seed reproduces the table defaults ('' and 'en') the other callers
// relied on, so their behaviour is unchanged.
lang := seed.preferredLanguage
if lang == "" {
lang = "en"
}
insertAccount := table.Accounts.
INSERT(table.Accounts.AccountID).
VALUES(accountID).
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage).
VALUES(accountID, seed.displayName, lang).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
@@ -230,18 +311,19 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
// modelToAccount projects a generated model row into the public Account struct.
func modelToAccount(row model.Accounts) Account {
return Account{
ID: row.AccountID,
DisplayName: row.DisplayName,
PreferredLanguage: row.PreferredLanguage,
TimeZone: row.TimeZone,
AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd,
HintBalance: int(row.HintBalance),
BlockChat: row.BlockChat,
BlockFriendRequests: row.BlockFriendRequests,
IsGuest: row.IsGuest,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
ID: row.AccountID,
DisplayName: row.DisplayName,
PreferredLanguage: row.PreferredLanguage,
TimeZone: row.TimeZone,
AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd,
HintBalance: int(row.HintBalance),
BlockChat: row.BlockChat,
BlockFriendRequests: row.BlockFriendRequests,
IsGuest: row.IsGuest,
NotificationsInAppOnly: row.NotificationsInAppOnly,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}