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,
}
}
+12 -9
View File
@@ -39,13 +39,14 @@ var ErrInvalidProfile = errors.New("account: invalid profile")
// and AwayEnd carry only the hour and minute of the daily away window, in the
// account's TimeZone.
type ProfileUpdate struct {
DisplayName string
PreferredLanguage string // "en" or "ru"
TimeZone string // an IANA location name
AwayStart time.Time
AwayEnd time.Time
BlockChat bool
BlockFriendRequests bool
DisplayName string
PreferredLanguage string // "en" or "ru"
TimeZone string // an IANA location name
AwayStart time.Time
AwayEnd time.Time
BlockChat bool
BlockFriendRequests bool
NotificationsInAppOnly bool
}
// UpdateProfile validates and overwrites the editable fields of the account, then
@@ -71,11 +72,13 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate
stmt := table.Accounts.UPDATE(
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
table.Accounts.AwayStart, table.Accounts.AwayEnd,
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt,
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests,
table.Accounts.NotificationsInAppOnly, table.Accounts.UpdatedAt,
).SET(
postgres.String(name), postgres.String(lang), postgres.String(tz),
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests), postgres.TimestampzT(time.Now().UTC()),
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests),
postgres.Bool(p.NotificationsInAppOnly), postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.AllColumns)
@@ -0,0 +1,48 @@
package account
import (
"strings"
"testing"
"unicode/utf8"
)
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
// create-time account seed: supported-language detection (bare and region-tagged),
// the first-name / username display-name precedence, and trimming.
func TestTelegramSeed(t *testing.T) {
cases := map[string]struct {
languageCode, username, firstName string
wantLang, wantName string
}{
"ru bare": {"ru", "user", "Иван", "ru", "Иван"},
"en region-tagged": {"en-US", "user", "John", "en", "John"},
"ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"},
"unknown language": {"fr", "frodo", "Frodo", "", "Frodo"},
"empty language": {"", "neo", "Neo", "", "Neo"},
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
"username fallback": {"en", "handle", "", "en", "handle"},
"both empty": {"en", "", "", "en", ""},
"trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := telegramSeed(tc.languageCode, tc.username, tc.firstName)
if got.preferredLanguage != tc.wantLang {
t.Errorf("preferredLanguage = %q, want %q", got.preferredLanguage, tc.wantLang)
}
if got.displayName != tc.wantName {
t.Errorf("displayName = %q, want %q", got.displayName, tc.wantName)
}
})
}
}
// TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to
// maxDisplayName runes (counted in runes, not bytes).
func TestTelegramSeedTruncatesLongName(t *testing.T) {
long := strings.Repeat("я", maxDisplayName+5)
got := telegramSeed("ru", "", long)
if n := utf8.RuneCountInString(got.displayName); n != maxDisplayName {
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName)
}
}