Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fe1bdba6b | |||
| c1d1c1624b | |||
| 9207664fbd | |||
| a4581663f4 | |||
| 03dfc29a54 | |||
| c02262fcf7 | |||
| f8fab4a4c2 |
@@ -101,6 +101,66 @@ func validateVariantPreferences(prefs []string) ([]string, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// variantSeedPrefix marks a Telegram start-param payload that seeds a brand-new
|
||||||
|
// account's variant preferences (e.g. "verudit_ru-scrabble_en"): the prefix, then the
|
||||||
|
// canonical variant labels joined by "-". It is deliberately distinct from the routing
|
||||||
|
// deep links (g/i/f; see platform/telegram .../deeplink) so the client's start-param
|
||||||
|
// router falls through to the lobby for it.
|
||||||
|
const variantSeedPrefix = "v"
|
||||||
|
|
||||||
|
// SeedVariantsFromStartParam decodes a promo deep-link start-param into the variant
|
||||||
|
// preference set to seed onto a brand-new account: the variantSeedPrefix followed by
|
||||||
|
// the canonical variant labels joined by "-" (e.g. "verudit_ru-scrabble_en"). It
|
||||||
|
// returns nil for any payload that is not a variant-seed link or that fails validation
|
||||||
|
// against the known variants, so a malformed, empty or unrelated start-param simply
|
||||||
|
// leaves the account on its default preferences rather than failing the login.
|
||||||
|
func SeedVariantsFromStartParam(startParam string) []string {
|
||||||
|
if !strings.HasPrefix(startParam, variantSeedPrefix) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body := strings.TrimPrefix(startParam, variantSeedPrefix)
|
||||||
|
if body == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
prefs, err := validateVariantPreferences(strings.Split(body, "-"))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return prefs
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVariantPreferences overwrites only the variant-preference set of the account,
|
||||||
|
// cleaning it to a deduplicated, canonically ordered subset of the known variants
|
||||||
|
// (rejecting an empty or unknown set with ErrInvalidProfile) and bumping updated_at; it
|
||||||
|
// reports ErrNotFound when no account matches id. It is the narrow counterpart to
|
||||||
|
// UpdateProfile used to seed a promo-onboarded account's variants at first contact
|
||||||
|
// without disturbing its other profile fields.
|
||||||
|
func (s *Store) SetVariantPreferences(ctx context.Context, id uuid.UUID, prefs []string) (Account, error) {
|
||||||
|
clean, err := validateVariantPreferences(prefs)
|
||||||
|
if err != nil {
|
||||||
|
return Account{}, err
|
||||||
|
}
|
||||||
|
stmt := table.Accounts.UPDATE(
|
||||||
|
table.Accounts.VariantPreferences, table.Accounts.UpdatedAt,
|
||||||
|
).SET(
|
||||||
|
// clean is validated against the closed knownVariants set; bind as a text[]
|
||||||
|
// parameter (lib/pq encodes the array, the cast pins the column type), mirroring
|
||||||
|
// UpdateProfile.
|
||||||
|
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(clean)}),
|
||||||
|
postgres.TimestampzT(time.Now().UTC()),
|
||||||
|
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||||
|
RETURNING(table.Accounts.AllColumns)
|
||||||
|
|
||||||
|
var row model.Accounts
|
||||||
|
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||||
|
if errors.Is(err, qrm.ErrNoRows) {
|
||||||
|
return Account{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Account{}, fmt.Errorf("account: set variant preferences %s: %w", id, err)
|
||||||
|
}
|
||||||
|
return modelToAccount(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateProfile validates and overwrites the editable fields of the account, then
|
// UpdateProfile validates and overwrites the editable fields of the account, then
|
||||||
// returns the stored row. It reports ErrInvalidProfile for a bad language,
|
// returns the stored row. It reports ErrInvalidProfile for a bad language,
|
||||||
// timezone or display name and ErrNotFound when no account matches id.
|
// timezone or display name and ErrNotFound when no account matches id.
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSeedVariantsFromStartParam covers decoding a promo deep-link start-param into the
|
||||||
|
// variant-preference set to seed: a valid "v"-prefixed, "-"-joined label list is cleaned
|
||||||
|
// to the canonical order and deduplicated, while anything that is not a variant-seed link
|
||||||
|
// or that names an unknown variant yields nil (leaving the account on its defaults).
|
||||||
|
func TestSeedVariantsFromStartParam(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
param string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"english promo", "verudit_ru-scrabble_en", []string{"erudit_ru", "scrabble_en"}},
|
||||||
|
{"single variant", "vscrabble_en", []string{"scrabble_en"}},
|
||||||
|
{"canonical order regardless of payload order", "vscrabble_en-erudit_ru", []string{"erudit_ru", "scrabble_en"}},
|
||||||
|
{"deduplicated", "verudit_ru-erudit_ru", []string{"erudit_ru"}},
|
||||||
|
{"empty", "", nil},
|
||||||
|
{"prefix only", "v", nil},
|
||||||
|
{"routing game link is not a seed", "g0190abcd", nil},
|
||||||
|
{"friend code link is not a seed", "f123456", nil},
|
||||||
|
{"unknown variant rejected", "vscrabble_de", nil},
|
||||||
|
{"one unknown label rejects the whole set", "verudit_ru-scrabble_de", nil},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := SeedVariantsFromStartParam(tc.param)
|
||||||
|
if !slices.Equal(got, tc.want) {
|
||||||
|
t.Errorf("SeedVariantsFromStartParam(%q) = %v, want %v", tc.param, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/account"
|
||||||
|
"scrabble/backend/internal/server"
|
||||||
|
"scrabble/backend/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint
|
||||||
|
// to confirm a promo deep-link start-param seeds a brand-new account's variant
|
||||||
|
// preferences (English Scrabble alongside the default Erudit), that a new account with no
|
||||||
|
// such payload keeps the Erudit-only default, and that an existing account is never
|
||||||
|
// re-seeded on a later login (the new-user-only contract).
|
||||||
|
func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
|
||||||
|
srv := server.New(":0", server.Deps{
|
||||||
|
Logger: zaptest.NewLogger(t),
|
||||||
|
DB: testDB,
|
||||||
|
Accounts: account.NewStore(testDB),
|
||||||
|
Sessions: session.NewService(session.NewStore(testDB), session.NewCache()),
|
||||||
|
Notifier: &captureNotifier{},
|
||||||
|
})
|
||||||
|
h := srv.Handler()
|
||||||
|
|
||||||
|
post := func(ext, startParam string) {
|
||||||
|
body := `{"external_id":"` + ext + `","language_code":"en","first_name":"Promo"`
|
||||||
|
if startParam != "" {
|
||||||
|
body += `,"start_param":"` + startParam + `"`
|
||||||
|
}
|
||||||
|
body += `}`
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("telegram auth = %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
store := account.NewStore(testDB)
|
||||||
|
reload := func(ext string) []string {
|
||||||
|
acc, err := store.AccountByIdentity(context.Background(), account.KindTelegram, ext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup %s: %v", ext, err)
|
||||||
|
}
|
||||||
|
return acc.VariantPreferences
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brand-new account reached through a promo deep-link is seeded with English
|
||||||
|
// Scrabble alongside the default Erudit.
|
||||||
|
promoExt := "tg-" + uuid.NewString()
|
||||||
|
post(promoExt, "verudit_ru-scrabble_en")
|
||||||
|
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("promo new account variants = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brand-new account with no promo payload keeps the Erudit-only default.
|
||||||
|
plainExt := "tg-" + uuid.NewString()
|
||||||
|
post(plainExt, "")
|
||||||
|
if got, want := reload(plainExt), []string{"erudit_ru"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("plain new account variants = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A later login of the promo account, even via a different payload, must not re-seed:
|
||||||
|
// the seed is first-contact only.
|
||||||
|
post(promoExt, "vscrabble_ru")
|
||||||
|
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||||
|
t.Errorf("existing account re-seeded = %v, want unchanged %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
)
|
)
|
||||||
@@ -19,13 +20,16 @@ import (
|
|||||||
// telegramAuthRequest carries the identity the connector extracted from a
|
// telegramAuthRequest carries the identity the connector extracted from a
|
||||||
// validated initData payload. Username, FirstName and LanguageCode seed a
|
// validated initData payload. Username, FirstName and LanguageCode seed a
|
||||||
// brand-new account's display name and language; BrowserTZ (the client's detected
|
// brand-new account's display name and language; BrowserTZ (the client's detected
|
||||||
// "±HH:MM" UTC offset) seeds its time zone (first contact only).
|
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch
|
||||||
|
// deep-link payload, which may seed the new account's variant preferences (first
|
||||||
|
// contact only).
|
||||||
type telegramAuthRequest struct {
|
type telegramAuthRequest struct {
|
||||||
ExternalID string `json:"external_id"`
|
ExternalID string `json:"external_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
LanguageCode string `json:"language_code"`
|
LanguageCode string `json:"language_code"`
|
||||||
BrowserTZ string `json:"browser_tz"`
|
BrowserTZ string `json:"browser_tz"`
|
||||||
|
StartParam string `json:"start_param"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
||||||
@@ -47,6 +51,16 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
|
|||||||
// joined the chat before registering is granted on the spot (no chat_member
|
// joined the chat before registering is granted on the spot (no chat_member
|
||||||
// event fires on registration).
|
// event fires on registration).
|
||||||
s.publishChatAccessChange(acc.ID)
|
s.publishChatAccessChange(acc.ID)
|
||||||
|
// A promo deep-link may seed this brand-new account's variant preferences (e.g.
|
||||||
|
// English Scrabble alongside the default Erudit). Best-effort: an absent or
|
||||||
|
// malformed payload leaves the account on its defaults, and a write failure must
|
||||||
|
// not block the session mint.
|
||||||
|
if seed := account.SeedVariantsFromStartParam(req.StartParam); len(seed) > 0 {
|
||||||
|
if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, seed); err != nil {
|
||||||
|
s.log.Warn("telegram: seed variant preferences failed",
|
||||||
|
zap.String("account", acc.ID.String()), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.mintSession(c, acc)
|
s.mintSession(c, acc)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ TELEGRAM_SUPPORT_CHAT_ID= # private forum supergroup for the suppor
|
|||||||
TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; empty disables it
|
TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; empty disables it
|
||||||
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
|
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
|
||||||
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
|
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
|
||||||
|
TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload
|
||||||
TELEGRAM_MINIAPP_URL= # required
|
TELEGRAM_MINIAPP_URL= # required
|
||||||
TELEGRAM_TEST_ENV=false
|
TELEGRAM_TEST_ENV=false
|
||||||
TELEGRAM_API_BASE_URL=
|
TELEGRAM_API_BASE_URL=
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ services:
|
|||||||
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
|
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
|
||||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
|
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
|
||||||
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
|
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
|
||||||
|
TELEGRAM_PROMO_START_PARAM: ${TELEGRAM_PROMO_START_PARAM:-}
|
||||||
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
|
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
|
||||||
# Real Bot API in prod (the test contour pins TELEGRAM_TEST_ENV=true instead).
|
# Real Bot API in prod (the test contour pins TELEGRAM_TEST_ENV=true instead).
|
||||||
TELEGRAM_TEST_ENV: "false"
|
TELEGRAM_TEST_ENV: "false"
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
> recipient's interface language (`preferred_language`), with no per-bot routing. New
|
> recipient's interface language (`preferred_language`), with no per-bot routing. New
|
||||||
> Game variant gating moved off the login language onto a per-user profile setting
|
> Game variant gating moved off the login language onto a per-user profile setting
|
||||||
> `variant_preferences` (default Erudit only, server-enforced on the caller's create
|
> `variant_preferences` (default Erudit only, server-enforced on the caller's create
|
||||||
> paths; an invited friend may still accept any variant). The per-bot env vars and
|
> paths; an invited friend may still accept any variant, and a Telegram **promo deep-link** seeds extra variants — e.g. English Scrabble — onto a brand-new account via the validated `start_param`). The per-bot env vars and
|
||||||
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped
|
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped
|
||||||
> `service_language`/`supported_languages` and the push `language` routing field, and
|
> `service_language`/`supported_languages` and the push `language` routing field, and
|
||||||
> gained `variant_preferences` on Profile/UpdateProfile.
|
> gained `variant_preferences` on Profile/UpdateProfile.
|
||||||
|
|||||||
+3
-2
@@ -253,8 +253,9 @@ merge".
|
|||||||
|
|
||||||
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
||||||
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
||||||
yourself to be matched into; a **new account starts with Erudite only**, and you must keep **at
|
yourself to be matched into; a **new account starts with Erudite only** — unless it was created
|
||||||
least one** selected. This list is exactly what **New Game** offers when you start a game
|
through the **promo bot's deep link**, which also enables **English Scrabble** — and you must keep
|
||||||
|
**at least one** selected. This list is exactly what **New Game** offers when you start a game
|
||||||
(auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is
|
(auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is
|
||||||
not offered, and the server refuses it. It does not restrict games you are **invited** to: an
|
not offered, and the server refuses it. It does not restrict games you are **invited** to: an
|
||||||
invited friend may accept an invitation in **any** variant, and you can always open and play
|
invited friend may accept an invitation in **any** variant, and you can always open and play
|
||||||
|
|||||||
@@ -259,8 +259,9 @@ UTC; при создании аккаунта она подставляется
|
|||||||
|
|
||||||
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
||||||
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
||||||
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом**, и нужно
|
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом** — если только
|
||||||
оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
|
он не создан по **диплинку промо-бота**, который дополнительно включает **английский Scrabble**, —
|
||||||
|
и нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
|
||||||
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
|
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
|
||||||
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
|
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
|
||||||
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
|
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
|
||||||
|
|||||||
@@ -184,10 +184,10 @@ type ChatResp struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
|
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
|
||||||
// brand-new account's display name and language from the validated launch fields and
|
// brand-new account's display name and language from the validated launch fields, its
|
||||||
// its time zone from browserTz (the client's detected "±HH:MM" UTC offset; first
|
// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the
|
||||||
// contact only).
|
// validated launch deep-link startParam, its variant preferences (first contact only).
|
||||||
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz string) (SessionResp, error) {
|
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam string) (SessionResp, error) {
|
||||||
var out SessionResp
|
var out SessionResp
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
@@ -196,6 +196,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
|||||||
"username": username,
|
"username": username,
|
||||||
"first_name": firstName,
|
"first_name": firstName,
|
||||||
"browser_tz": browserTz,
|
"browser_tz": browserTz,
|
||||||
|
"start_param": startParam,
|
||||||
}, &out)
|
}, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"scrabble/gateway/internal/backendclient"
|
"scrabble/gateway/internal/backendclient"
|
||||||
"scrabble/gateway/internal/connector"
|
"scrabble/gateway/internal/connector"
|
||||||
@@ -154,11 +155,19 @@ func DomainCode(err error) (string, bool) {
|
|||||||
func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler {
|
func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler {
|
||||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0)
|
in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0)
|
||||||
user, err := tg.ValidateInitData(ctx, string(in.InitData()))
|
initData := string(in.InitData())
|
||||||
|
user, err := tg.ValidateInitData(ctx, initData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()))
|
// start_param rides inside the signed initData validated just above, so the launch
|
||||||
|
// deep-link payload can be trusted here without a separate wire field; the backend
|
||||||
|
// uses it to seed a brand-new account's variant preferences.
|
||||||
|
startParam := ""
|
||||||
|
if q, perr := url.ParseQuery(initData); perr == nil {
|
||||||
|
startParam = q.Get("start_param")
|
||||||
|
}
|
||||||
|
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
|
|||||||
t.Fatal("auth.telegram not registered")
|
t.Fatal("auth.telegram not registered")
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("init")})
|
payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("start_param=verudit_ru-scrabble_en&query_id=abc")})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("handler: %v", err)
|
t.Fatalf("handler: %v", err)
|
||||||
}
|
}
|
||||||
@@ -66,6 +66,11 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
|
|||||||
if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" {
|
if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" {
|
||||||
t.Errorf("forwarded body = %+v, want external_id=42 language_code=ru first_name=Иван", gotBody)
|
t.Errorf("forwarded body = %+v, want external_id=42 language_code=ru first_name=Иван", gotBody)
|
||||||
}
|
}
|
||||||
|
// start_param is parsed out of the validated initData and forwarded so the backend can
|
||||||
|
// seed the new account's variant preferences from a promo deep link.
|
||||||
|
if gotBody["start_param"] != "verudit_ru-scrabble_en" {
|
||||||
|
t.Errorf("forwarded start_param = %q, want verudit_ru-scrabble_en", gotBody["start_param"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTelegramAuthInvalidInitData(t *testing.T) {
|
func TestTelegramAuthInvalidInitData(t *testing.T) {
|
||||||
|
|||||||
@@ -83,7 +83,12 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
|
|||||||
the main bot's direct link (`TELEGRAM_BOT_LINK`, the same link the UI uses) with
|
the main bot's direct link (`TELEGRAM_BOT_LINK`, the same link the UI uses) with
|
||||||
`?startapp` — a `web_app` button would launch under the promo bot's identity (its token
|
`?startapp` — a `web_app` button would launch under the promo bot's identity (its token
|
||||||
would sign the initData), which the main bot's validator rejects. It is fully
|
would sign the initData), which the main bot's validator rejects. It is fully
|
||||||
self-contained: no bot-link, no gateway, no game.
|
self-contained: no bot-link, no gateway, no game. Its `?startapp` payload
|
||||||
|
(`TELEGRAM_PROMO_START_PARAM`, default `verudit_ru-scrabble_en`) is a variant-seed deep
|
||||||
|
link the backend decodes to seed a brand-new user's variant preferences (English Scrabble
|
||||||
|
alongside the default Erudit). The message body also renders the bot's `@username` as that
|
||||||
|
same deep link (an HTML `text_link`), so tapping the mention — not just the button — opens
|
||||||
|
the seeded Mini App rather than the bot profile.
|
||||||
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
|
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
|
||||||
default 25) to respect the Bot API flood limits.
|
default 25) to respect the Bot API flood limits.
|
||||||
|
|
||||||
@@ -151,6 +156,7 @@ Bot (`cmd/bot`):
|
|||||||
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it |
|
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it |
|
||||||
| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs |
|
| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs |
|
||||||
| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs |
|
| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs |
|
||||||
|
| `TELEGRAM_PROMO_START_PARAM` | `verudit_ru-scrabble_en` | the promo button's `startapp` payload — a variant-seed deep link the backend decodes to seed a brand-new user's variant preferences; empty forwards the user's own `/start` payload |
|
||||||
| `TELEGRAM_OWNS_UPDATES` | `true` | run the exclusive `getUpdates` long-poll (one bot per token) |
|
| `TELEGRAM_OWNS_UPDATES` | `true` | run the exclusive `getUpdates` long-poll (one bot per token) |
|
||||||
| `TELEGRAM_SEND_RATE_PER_SECOND` | `25` | outbound Bot API send cap (0 disables) |
|
| `TELEGRAM_SEND_RATE_PER_SECOND` | `25` | outbound Bot API send cap (0 disables) |
|
||||||
| `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway |
|
| `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway |
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
|||||||
TestEnv: cfg.TestEnv,
|
TestEnv: cfg.TestEnv,
|
||||||
BotUsername: cfg.BotUsername,
|
BotUsername: cfg.BotUsername,
|
||||||
BotLinkURL: cfg.BotLinkURL,
|
BotLinkURL: cfg.BotLinkURL,
|
||||||
|
StartParam: cfg.PromoStartParam,
|
||||||
SendRatePerSecond: cfg.SendRatePerSecond,
|
SendRatePerSecond: cfg.SendRatePerSecond,
|
||||||
}, logger)
|
}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ type BotConfig struct {
|
|||||||
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
|
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
|
||||||
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
|
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
|
||||||
BotLinkURL string
|
BotLinkURL string
|
||||||
|
// PromoStartParam is the promo button's launch payload, appended as
|
||||||
|
// ?startapp=<PromoStartParam> (TELEGRAM_PROMO_START_PARAM, default
|
||||||
|
// "verudit_ru-scrabble_en"). It is a variant-seed deep link the backend decodes to
|
||||||
|
// seed a brand-new user's variant preferences; its labels must match the backend's
|
||||||
|
// known variants. Empty falls back to forwarding the user's own /start payload.
|
||||||
|
PromoStartParam string
|
||||||
// MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is
|
// MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is
|
||||||
// the base of every launch button (TELEGRAM_MINIAPP_URL, required).
|
// the base of every launch button (TELEGRAM_MINIAPP_URL, required).
|
||||||
MiniAppURL string
|
MiniAppURL string
|
||||||
@@ -113,6 +119,10 @@ const (
|
|||||||
defaultValidatorGRPCAddr = ":9091"
|
defaultValidatorGRPCAddr = ":9091"
|
||||||
defaultBotReconnectDelay = 2 * time.Second
|
defaultBotReconnectDelay = 2 * time.Second
|
||||||
defaultSendRatePerSecond = 25
|
defaultSendRatePerSecond = 25
|
||||||
|
// defaultPromoStartParam is the promo button's default launch payload: a variant-seed
|
||||||
|
// deep link the backend decodes to add English Scrabble to a brand-new user's variant
|
||||||
|
// preferences alongside the default Erudit. Override with TELEGRAM_PROMO_START_PARAM.
|
||||||
|
defaultPromoStartParam = "verudit_ru-scrabble_en"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadValidator reads the validator configuration from the environment.
|
// LoadValidator reads the validator configuration from the environment.
|
||||||
@@ -145,6 +155,7 @@ func LoadBot() (BotConfig, error) {
|
|||||||
PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"),
|
PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"),
|
||||||
BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"),
|
BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"),
|
||||||
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
|
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
|
||||||
|
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
|
||||||
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
|
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
|
||||||
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
|
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
|
||||||
BotLink: BotLinkClientConfig{
|
BotLink: BotLinkClientConfig{
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
package promobot
|
package promobot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"html"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -33,6 +35,11 @@ type Config struct {
|
|||||||
// BotLinkURL is the main bot's Mini App direct link; the button appends
|
// BotLinkURL is the main bot's Mini App direct link; the button appends
|
||||||
// ?startapp=<payload> to it.
|
// ?startapp=<payload> to it.
|
||||||
BotLinkURL string
|
BotLinkURL string
|
||||||
|
// StartParam is the campaign payload appended as ?startapp=<StartParam> to the launch
|
||||||
|
// button — e.g. a variant-seed deep link ("verudit_ru-scrabble_en") the backend
|
||||||
|
// decodes to seed a brand-new user's variant preferences. Empty falls back to
|
||||||
|
// forwarding the user's own /start payload.
|
||||||
|
StartParam string
|
||||||
// SendRatePerSecond caps outbound sends to respect the Bot API flood limits; 0
|
// SendRatePerSecond caps outbound sends to respect the Bot API flood limits; 0
|
||||||
// disables the limiter. The burst equals the per-second rate.
|
// disables the limiter. The burst equals the per-second rate.
|
||||||
SendRatePerSecond int
|
SendRatePerSecond int
|
||||||
@@ -43,6 +50,7 @@ type Bot struct {
|
|||||||
api *tgbot.Bot
|
api *tgbot.Bot
|
||||||
username string
|
username string
|
||||||
linkURL string
|
linkURL string
|
||||||
|
startParam string
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
limiter *rate.Limiter
|
limiter *rate.Limiter
|
||||||
}
|
}
|
||||||
@@ -53,7 +61,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
|||||||
if log == nil {
|
if log == nil {
|
||||||
log = zap.NewNop()
|
log = zap.NewNop()
|
||||||
}
|
}
|
||||||
t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, log: log}
|
t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, startParam: cfg.StartParam, log: log}
|
||||||
if cfg.SendRatePerSecond > 0 {
|
if cfg.SendRatePerSecond > 0 {
|
||||||
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
|
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
|
||||||
}
|
}
|
||||||
@@ -87,7 +95,8 @@ func (t *Bot) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleStart replies to any message (typically /start) with the localized promo text
|
// handleStart replies to any message (typically /start) with the localized promo text
|
||||||
// and a button that opens the main bot's Mini App, forwarding any /start payload.
|
// and a button that opens the main bot's Mini App at the configured campaign payload
|
||||||
|
// (falling back to forwarding any /start payload the user arrived with).
|
||||||
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
|
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
|
||||||
if update.Message == nil {
|
if update.Message == nil {
|
||||||
return
|
return
|
||||||
@@ -104,11 +113,16 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up
|
|||||||
if update.Message.From != nil {
|
if update.Message.From != nil {
|
||||||
lang = update.Message.From.LanguageCode
|
lang = update.Message.From.LanguageCode
|
||||||
}
|
}
|
||||||
text, button := promoText(lang, t.username)
|
// The configured campaign payload (a variant-seed deep link) takes precedence; absent
|
||||||
|
// one, fall back to forwarding any /start payload the user arrived with. The same
|
||||||
|
// payload backs both the inline button and the @username link in the body.
|
||||||
|
param := cmp.Or(t.startParam, startPayload(update.Message.Text))
|
||||||
|
text, button := promoText(lang, t.username, t.launchURL(param))
|
||||||
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
|
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||||
ChatID: update.Message.Chat.ID,
|
ChatID: update.Message.Chat.ID,
|
||||||
Text: text,
|
Text: text,
|
||||||
ReplyMarkup: t.launchMarkup(button, startPayload(update.Message.Text)),
|
ParseMode: models.ParseModeHTML,
|
||||||
|
ReplyMarkup: t.launchMarkup(button, param),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.log.Warn("promo: reply to start failed", zap.Error(err))
|
t.log.Warn("promo: reply to start failed", zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -159,11 +173,15 @@ func startPayload(text string) string {
|
|||||||
return strings.TrimSpace(strings.TrimPrefix(text, cmd))
|
return strings.TrimSpace(strings.TrimPrefix(text, cmd))
|
||||||
}
|
}
|
||||||
|
|
||||||
// promoText returns the localized message body and button label, naming the main bot
|
// promoText returns the localized message body and button label. The body names the main
|
||||||
// (Russian for a "ru" language code, English otherwise).
|
// bot as a clickable @username whose link is the Mini App deep link (launchURL, the same
|
||||||
func promoText(lang, username string) (text, button string) {
|
// target as the button), so tapping the mention opens the seeded Mini App rather than the
|
||||||
|
// bot profile. The body is sent with ParseMode HTML; only the link carries markup, so the
|
||||||
|
// static sentences need no escaping. Russian for a "ru" language code, English otherwise.
|
||||||
|
func promoText(lang, username, launchURL string) (text, button string) {
|
||||||
|
mention := `<a href="` + html.EscapeString(launchURL) + `">@` + html.EscapeString(username) + `</a>`
|
||||||
if strings.HasPrefix(strings.ToLower(lang), "ru") {
|
if strings.HasPrefix(strings.ToLower(lang), "ru") {
|
||||||
return "Откройте @" + username + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
|
return "Откройте " + mention + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
|
||||||
}
|
}
|
||||||
return "Open @" + username + " and choose your game variant in the profile settings.", "🤩 I want to play!"
|
return "Open " + mention + " and choose your game variant in the profile settings.", "🤩 I want to play!"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,25 +13,30 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestPromoTextLocalization(t *testing.T) {
|
func TestPromoTextLocalization(t *testing.T) {
|
||||||
en, enBtn := promoText("en", "ScrabbleBot")
|
const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en"
|
||||||
if !strings.Contains(en, "@ScrabbleBot") || !strings.Contains(en, "profile settings") {
|
// The @username is rendered as a clickable deep link (the same target as the button),
|
||||||
|
// not a plain mention, so tapping it opens the seeded Mini App.
|
||||||
|
wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
|
||||||
|
|
||||||
|
en, enBtn := promoText("en", "ScrabbleBot", url)
|
||||||
|
if !strings.Contains(en, wantLink) || !strings.Contains(en, "profile settings") {
|
||||||
t.Errorf("en text = %q", en)
|
t.Errorf("en text = %q", en)
|
||||||
}
|
}
|
||||||
if enBtn != "🤩 I want to play!" {
|
if enBtn != "🤩 I want to play!" {
|
||||||
t.Errorf("en button = %q", enBtn)
|
t.Errorf("en button = %q", enBtn)
|
||||||
}
|
}
|
||||||
|
|
||||||
ru, ruBtn := promoText("ru-RU", "ScrabbleBot")
|
ru, ruBtn := promoText("ru-RU", "ScrabbleBot", url)
|
||||||
if !strings.Contains(ru, "@ScrabbleBot") || !strings.Contains(ru, "Откройте") {
|
if !strings.Contains(ru, wantLink) || !strings.Contains(ru, "Откройте") {
|
||||||
t.Errorf("ru text = %q", ru)
|
t.Errorf("ru text = %q", ru)
|
||||||
}
|
}
|
||||||
if ruBtn != "🤩 Хочу играть!" {
|
if ruBtn != "🤩 Хочу играть!" {
|
||||||
t.Errorf("ru button = %q", ruBtn)
|
t.Errorf("ru button = %q", ruBtn)
|
||||||
}
|
}
|
||||||
|
|
||||||
// An unknown language falls back to English.
|
// An unknown language falls back to English, still with the linked mention.
|
||||||
if got, _ := promoText("de", "B"); !strings.Contains(got, "Open @B") {
|
if got, _ := promoText("de", "B", url); !strings.Contains(got, "Open ") || !strings.Contains(got, `">@B</a>`) {
|
||||||
t.Errorf("fallback text = %q, want English", got)
|
t.Errorf("fallback text = %q, want English with a linked mention", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +98,8 @@ func TestHandleStartReplies(t *testing.T) {
|
|||||||
if api.chatID != "42" {
|
if api.chatID != "42" {
|
||||||
t.Errorf("chat_id = %q, want 42", api.chatID)
|
t.Errorf("chat_id = %q, want 42", api.chatID)
|
||||||
}
|
}
|
||||||
if !strings.Contains(api.text, "@ScrabbleBot") {
|
if !strings.Contains(api.text, `<a href="https://t.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
|
||||||
t.Errorf("text = %q, want the @mention", api.text)
|
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
|
||||||
}
|
}
|
||||||
if strings.Contains(api.replyMarkup, "web_app") {
|
if strings.Contains(api.replyMarkup, "web_app") {
|
||||||
t.Errorf("reply_markup = %q has a web_app button; want a url button", api.replyMarkup)
|
t.Errorf("reply_markup = %q has a web_app button; want a url button", api.replyMarkup)
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--gap);
|
gap: var(--gap);
|
||||||
padding: 10px var(--pad);
|
padding: 5px var(--pad);
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
@@ -139,15 +139,15 @@
|
|||||||
back chevron is hidden), so min-height (the nav-band height) doesn't bind. Without the
|
back chevron is hidden), so min-height (the nav-band height) doesn't bind. Without the
|
||||||
(removed) hamburger that content shrank and the bar sat flush under Telegram's native nav
|
(removed) hamburger that content shrank and the bar sat flush under Telegram's native nav
|
||||||
band. So **padding-top** is the lever: it drops the title clear of the band — the notch
|
band. So **padding-top** is the lever: it drops the title clear of the band — the notch
|
||||||
plus a **10px** gap (was 6). A fixed px (not rem/em) gap so the clearance from Telegram's
|
plus an **8px** gap (halved from 16). A fixed px (not rem/em) gap so the clearance from
|
||||||
native controls stays constant if the user scales up the font (the title then grows
|
Telegram's native controls stays constant if the user scales up the font (the title then
|
||||||
downward and the bar with it). (Owner-tunable: the 10px.) */
|
grows downward and the bar with it). (Owner-tunable: the 8px.) */
|
||||||
min-height: var(--tg-content-top);
|
min-height: var(--tg-content-top);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: calc(var(--tg-safe-top) + 16px);
|
padding-top: calc(var(--tg-safe-top) + 8px);
|
||||||
padding-bottom: 6px;
|
padding-bottom: 3px;
|
||||||
}
|
}
|
||||||
:global(html.tg-fullscreen) .spacer {
|
:global(html.tg-fullscreen) .spacer {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export const en = {
|
|||||||
'friends.redeem': 'Add',
|
'friends.redeem': 'Add',
|
||||||
'friends.copy': 'Copy',
|
'friends.copy': 'Copy',
|
||||||
'friends.codeCopied': 'Code copied.',
|
'friends.codeCopied': 'Code copied.',
|
||||||
'friends.shareTelegram': 'Share via Telegram',
|
'friends.shareTelegram': 'Share',
|
||||||
'friends.inviteText': "Let's play Scrabble!",
|
'friends.inviteText': "Let's play Scrabble!",
|
||||||
'friends.linkCopied': 'Link copied.',
|
'friends.linkCopied': 'Link copied.',
|
||||||
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
|
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'friends.redeem': 'Добавить',
|
'friends.redeem': 'Добавить',
|
||||||
'friends.copy': 'Копировать',
|
'friends.copy': 'Копировать',
|
||||||
'friends.codeCopied': 'Код скопирован.',
|
'friends.codeCopied': 'Код скопирован.',
|
||||||
'friends.shareTelegram': 'Поделиться через Telegram',
|
'friends.shareTelegram': 'Поделиться',
|
||||||
'friends.inviteText': 'Давай играть в Эрудит!',
|
'friends.inviteText': 'Давай играть в Эрудит!',
|
||||||
'friends.linkCopied': 'Ссылка скопирована.',
|
'friends.linkCopied': 'Ссылка скопирована.',
|
||||||
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
|
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
|
||||||
|
|||||||
@@ -182,7 +182,7 @@
|
|||||||
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
|
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
|
||||||
</span>
|
</span>
|
||||||
{#if tg}
|
{#if tg}
|
||||||
<button type="button" class="link tgshare" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
|
<button type="button" class="link" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
Reference in New Issue
Block a user