Compare commits

..

11 Commits

Author SHA1 Message Date
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer c1d1c1624b Merge pull request 'feat(telegram): promo deep-link seeds EN variant (+ UI: share label, header padding)' (#134) from feature/promo-deeplink-and-ui-nits into development
CI / unit (push) Successful in 10s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / changes (push) Successful in 2s
CI / integration (push) Successful in 16s
CI / deploy (push) Successful in 1m18s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-23 20:50:14 +00:00
Ilia Denisov 9207664fbd docs(telegram): note the promo body @username is a deep link
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
2026-06-23 22:45:54 +02:00
Ilia Denisov a4581663f4 feat(telegram): link the promo body @username to the Mini App deep link
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
The promo message body now renders "@<bot>" as an HTML text_link to the same ?startapp deep link the button uses (ParseMode HTML), so tapping the mention opens the seeded Mini App instead of the bot profile. Same payload (campaign start-param, else the forwarded /start payload) backs both the button and the mention.
2026-06-23 22:40:52 +02:00
Ilia Denisov 03dfc29a54 feat(telegram): promo deep-link seeds English Scrabble for new users
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 50s
The promo bot button carries a configurable variant-seed start-param (default verudit_ru-scrabble_en). The gateway parses start_param from the validated initData and forwards it; the backend, on first contact only, seeds the new account variant_preferences from it (English Scrabble alongside the default Erudit).

No schema change (the scrabble_en CHECK is already in the baseline) and the gateway<->backend REST field is additive, so the rolling deploy is safe in either order. TELEGRAM_PROMO_START_PARAM configures the payload (empty forwards the user own /start payload). Covered by account unit tests, a gateway transcode test, and an integration test asserting new-only seeding.
2026-06-23 21:51:52 +02:00
Ilia Denisov c02262fcf7 style(ui): halve custom header top/bottom padding
The custom title bar was too tall. Halve the standard bar padding (10->5px) and, in the Telegram path, the notch gap (16->8px) and bottom (6->3px); the notch safe-area inset is unchanged. Title and back chevron stay vertically centred.
2026-06-23 21:51:52 +02:00
Ilia Denisov f8fab4a4c2 fix(ui): rename Friends "Share via Telegram" to "Share"
The share button uses the OS Web Share sheet outside Telegram (the TG share picker only inside it), so "via Telegram" was misleading. Rename to a neutral "Share"/"Поделиться" and drop the now-unused tgshare class.
2026-06-23 21:51:52 +02:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 10264e10c8 Merge pull request 'fix(telegram): support relay — text_mention card + reopen deleted topic' (#132) from feature/telegram-support-card-mention into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 17s
CI / ui (push) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m17s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-23 16:51:04 +00:00
Ilia Denisov fc1715128e fix(telegram): reopen a deleted support topic instead of losing it to General
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Telegram silently routes a copyMessage aimed at a deleted forum topic
into the chat's General topic with NO error, so the bot's error-based
recreate never fired — the user's next message landed in General with no
card. Confirmed against the prod bot logs: a relay after a topic deletion
logged neither "relay to topic failed" nor "topic gone, reopening".

Probe topic liveness before reusing it: re-applying the info card's reply
markup is a no-op that errors only when the card (hence the topic) is
gone, so the bot detects the deletion and reopens the topic + card rather
than relying on the (absent) copy error. The post-copy recreate stays as
a race backstop.
2026-06-23 18:45:39 +02:00
Ilia Denisov bb18dc362b fix(telegram): support card — text_mention name, no command/dead link
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
The topic info card built the profile link as `tg://user?id=`, which
clients render as dead text for a user they don't already know (the
test-vs-prod difference an operator saw), and it put the raw display
name through HTML — so a name starting with "/" was auto-detected as a
tappable bot command that fired when tapped.

Render the card with message entities instead: the display name is
covered by a `text_mention` entity (the reliable way to mention a
username-less user the bot has already seen — it messaged the bot),
which links the profile AND, because the name sits inside an entity,
suppresses the "/command" and "@mention" auto-detection on it. Drop the
HTML parse mode and the tg:// link. Entity offsets are UTF-16.
2026-06-23 18:33:14 +02:00
23 changed files with 463 additions and 94 deletions
+60
View File
@@ -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)
}
}
+15 -1
View File
@@ -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)
} }
+1
View File
@@ -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=
+1
View File
@@ -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"
+5 -3
View File
@@ -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.
@@ -1204,8 +1204,10 @@ migration.
**Telegram support relay.** Separate from the in-app Feedback above, the bot offers a direct **Telegram support relay.** Separate from the in-app Feedback above, the bot offers a direct
support channel for users who message it on Telegram. Any message other than `/start` is relayed support channel for users who message it on Telegram. Any message other than `/start` is relayed
into a private **forum supergroup** (`TELEGRAM_SUPPORT_CHAT_ID`): a user's first message opens a into a private **forum supergroup** (`TELEGRAM_SUPPORT_CHAT_ID`): a user's first message opens a
dedicated **forum topic** whose first message is an info card (name, @username, language, premium, dedicated **forum topic** whose first message is an info card (the name is a tappable profile
id, profile deep-link) carrying a Block/Unblock toggle and a Clear button; every message is then mention via a `text_mention` entity — which also keeps a name beginning with `/` from being read as
a command — plus @username, language, premium, id) carrying a Block/Unblock toggle and a Clear
button; every message is then
copied into that topic (`copyMessage`, so any content — text, media, voice, files — carries over). copied into that topic (`copyMessage`, so any content — text, media, voice, files — carries over).
Any **administrator** of the support chat who writes in a user's topic has their message copied Any **administrator** of the support chat who writes in a user's topic has their message copied
back to that user; non-admins and the bot's own posts are ignored (the loop guard). Block drops the back to that user; non-admins and the bot's own posts are ignored (the loop guard). Block drops the
+3 -2
View File
@@ -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
+3 -2
View File
@@ -259,8 +259,9 @@ UTC; при создании аккаунта она подставляется
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты **Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом**, и нужно которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом** — если только
оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты он не создан по **диплинку промо-бота**, который дополнительно включает **английский Scrabble**, —
и нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте, **приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
+5 -4
View File
@@ -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
} }
+11 -2
View File
@@ -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) {
+10 -3
View File
@@ -65,8 +65,9 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
`chat_member` updates, which Telegram delivers only to a chat admin. `chat_member` updates, which Telegram delivers only to a chat admin.
- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum - **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum
supergroup**, the bot runs a direct support channel for users who message it. A user's first supergroup**, the bot runs a direct support channel for users who message it. A user's first
non-`/start` message opens a dedicated **forum topic** whose first message is an info card (name, non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the
@username, language, premium, id, `tg://user` profile link) carrying a **Block/Unblock** toggle name is a tappable profile mention via a `text_mention` entity — which also stops a name starting
with `/` from rendering as a command — plus @username, language, premium, id) with a **Block/Unblock** toggle
and a **Clear** button; each message is then copied into that topic (`copyMessage`, so any content and a **Clear** button; each message is then copied into that topic (`copyMessage`, so any content
— text, media, voice, files — carries over). Any **administrator** of the support chat who writes — text, media, voice, files — carries over). Any **administrator** of the support chat who writes
in a user's topic has it copied back to that user; the bot's own posts and non-admins are ignored in a user's topic has it copied back to that user; the bot's own posts and non-admins are ignored
@@ -82,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.
@@ -150,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 |
+1
View File
@@ -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 {
+74 -28
View File
@@ -3,15 +3,17 @@ package bot
import ( import (
"context" "context"
"fmt" "fmt"
"html"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf16"
tgbot "github.com/go-telegram/bot" tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
"go.uber.org/zap" "go.uber.org/zap"
"scrabble/platform/telegram/internal/support"
) )
// Support relay: the bot forwards a user's direct messages into a per-user forum // Support relay: the bot forwards a user's direct messages into a per-user forum
@@ -130,26 +132,20 @@ func (t *Bot) handleSupportUserMessage(ctx context.Context, m *models.Message) {
unlock := t.supportLocks.lock(uid) unlock := t.supportLocks.lock(uid)
defer unlock() defer unlock()
rec, ok := t.support.Get(uid) rec, _ := t.support.Get(uid)
if ok && rec.Blocked { if rec.Blocked {
return return
} }
topicID := 0 topicID, err := t.ensureTopic(ctx, m.From, rec)
if ok { if err != nil {
topicID = rec.TopicID t.log.Warn("support: ensure topic failed", zap.Int64("user_id", uid), zap.Error(err))
}
if topicID == 0 {
var err error
if topicID, err = t.openSupportTopic(ctx, m.From); err != nil {
t.log.Warn("support: open topic failed", zap.Int64("user_id", uid), zap.Error(err))
return return
} }
}
newID, err := t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID) newID, err := t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
if err != nil && isTopicMissingErr(err) { if err != nil && isTopicMissingErr(err) {
// The operators deleted the whole topic; reopen it and retry once. // Backstop: the topic vanished between the liveness probe and the copy.
t.log.Info("support: topic gone, reopening", zap.Int64("user_id", uid), zap.Int("topic_id", topicID)) t.log.Info("support: topic gone on copy, reopening", zap.Int64("user_id", uid), zap.Int("topic_id", topicID))
if topicID, err = t.openSupportTopic(ctx, m.From); err == nil { if topicID, err = t.openSupportTopic(ctx, m.From); err == nil {
newID, err = t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID) newID, err = t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
} }
@@ -258,6 +254,31 @@ func (t *Bot) clearSupportTopic(ctx context.Context, cq *models.CallbackQuery, u
t.answerSupportCallback(ctx, cq.ID, "Очищено") t.answerSupportCallback(ctx, cq.ID, "Очищено")
} }
// ensureTopic returns a live forum-topic id for the user, opening a fresh topic (and
// info card) when none exists or the previous one was deleted. Telegram silently
// routes a copy aimed at a deleted topic into the chat's General topic (no error), so
// the bot cannot rely on copyMessage failing; instead it probes the info card —
// re-applying the card's reply markup is a no-op that errors only when the card (hence
// the topic) is gone — and reopens on that signal. The probe also keeps the card's
// block button in sync with the stored state.
func (t *Bot) ensureTopic(ctx context.Context, u *models.User, rec support.User) (int, error) {
if rec.TopicID == 0 || rec.HeaderMsgID == 0 {
return t.openSupportTopic(ctx, u)
}
_, err := t.api.EditMessageReplyMarkup(ctx, &tgbot.EditMessageReplyMarkupParams{
ChatID: t.supportChatID,
MessageID: rec.HeaderMsgID,
ReplyMarkup: supportCardMarkup(u.ID, rec.Blocked),
})
if isCardMissingErr(err) {
t.log.Info("support: topic gone, reopening", zap.Int64("user_id", u.ID), zap.Int("topic_id", rec.TopicID))
return t.openSupportTopic(ctx, u)
}
// Any other outcome (success, or a benign "message is not modified") means the
// topic is alive; reuse it.
return rec.TopicID, nil
}
// openSupportTopic creates a forum topic for the user and posts its info card with // openSupportTopic creates a forum topic for the user and posts its info card with
// the block/clear buttons, persisting both. It returns the new topic id. A failure to // the block/clear buttons, persisting both. It returns the new topic id. A failure to
// persist is logged but not fatal: the in-memory mapping still lets the relay proceed. // persist is logged but not fatal: the in-memory mapping still lets the relay proceed.
@@ -270,11 +291,12 @@ func (t *Bot) openSupportTopic(ctx context.Context, u *models.User) (int, error)
return 0, fmt.Errorf("create forum topic: %w", err) return 0, fmt.Errorf("create forum topic: %w", err)
} }
headerID := 0 headerID := 0
cardText, cardEntities := supportCard(u)
header, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ header, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: t.supportChatID, ChatID: t.supportChatID,
MessageThreadID: topic.MessageThreadID, MessageThreadID: topic.MessageThreadID,
Text: supportCardText(u), Text: cardText,
ParseMode: models.ParseModeHTML, Entities: cardEntities,
ReplyMarkup: supportCardMarkup(u.ID, false), ReplyMarkup: supportCardMarkup(u.ID, false),
}) })
if err != nil { if err != nil {
@@ -396,16 +418,22 @@ func supportTopicName(u *models.User) string {
return name return name
} }
// supportCardText renders the info card describing the user. User-controlled fields // supportCard renders the topic info card describing the user and the message
// are HTML-escaped because the card uses HTML parse mode for the profile link. // entities for it. The display name is covered by a text_mention entity: it links to
func supportCardText(u *models.User) string { // the user's profile (the reliable way to mention a user who has no public username —
name := html.EscapeString(strings.TrimSpace(u.FirstName + " " + u.LastName)) // the bot has seen them, since they messaged it) and, because the name sits inside an
// entity, Telegram does not auto-detect a leading "/" as a bot command or a stray
// "@handle" inside the name as a mention. The remaining lines are plain text; a public
// @username, when present, is left for Telegram to auto-link. Entity offsets are in
// UTF-16 code units, as the Bot API requires; the name is at offset 0.
func supportCard(u *models.User) (string, []models.MessageEntity) {
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
if name == "" { if name == "" {
name = "—" name = "user " + strconv.FormatInt(u.ID, 10)
} }
username := "—" username := "—"
if u.Username != "" { if u.Username != "" {
username = "@" + html.EscapeString(u.Username) username = "@" + u.Username
} }
lang := u.LanguageCode lang := u.LanguageCode
if lang == "" { if lang == "" {
@@ -416,12 +444,30 @@ func supportCardText(u *models.User) string {
premium = "да" premium = "да"
} }
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "<b>%s</b>\n", name) b.WriteString(name)
fmt.Fprintf(&b, "Username: %s\n", username) fmt.Fprintf(&b, "\nUsername: %s", username)
fmt.Fprintf(&b, "ID: <code>%d</code>\n", u.ID) fmt.Fprintf(&b, "\nID: %d", u.ID)
fmt.Fprintf(&b, "Язык: %s · Premium: %s\n", html.EscapeString(lang), premium) fmt.Fprintf(&b, "\nЯзык: %s · Premium: %s", lang, premium)
fmt.Fprintf(&b, `<a href="tg://user?id=%d">Открыть профиль</a>`, u.ID) entities := []models.MessageEntity{{
return b.String() Type: models.MessageEntityTypeTextMention,
Offset: 0,
Length: len(utf16.Encode([]rune(name))),
User: &models.User{ID: u.ID},
}}
return b.String(), entities
}
// isCardMissingErr reports whether err means the info-card message no longer exists
// (the operators deleted the topic), as opposed to a benign "message is not modified"
// no-op when the card is still there.
func isCardMissingErr(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
return strings.Contains(s, "message to edit not found") ||
strings.Contains(s, "message can't be edited") ||
strings.Contains(s, "message_id_invalid")
} }
// isTopicMissingErr reports whether err is Telegram's deleted/absent forum-topic // isTopicMissingErr reports whether err is Telegram's deleted/absent forum-topic
+80 -11
View File
@@ -38,6 +38,9 @@ type supportAPI struct {
deleted [][]int deleted [][]int
editedMsgIDs []string editedMsgIDs []string
answers []string answers []string
// editErr, when set, makes editMessageReplyMarkup return that Telegram error
// description (simulating a deleted info card / topic).
editErr string
} }
func newSupportAPI(adminIDs ...int64) *supportAPI { func newSupportAPI(adminIDs ...int64) *supportAPI {
@@ -77,6 +80,10 @@ func (s *supportAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.deleted = append(s.deleted, ids) s.deleted = append(s.deleted, ids)
io.WriteString(w, `{"ok":true,"result":true}`) io.WriteString(w, `{"ok":true,"result":true}`)
case strings.HasSuffix(path, "/editMessageReplyMarkup"): case strings.HasSuffix(path, "/editMessageReplyMarkup"):
if s.editErr != "" {
fmt.Fprintf(w, `{"ok":false,"error_code":400,"description":%q}`, s.editErr)
return
}
s.editedMsgIDs = append(s.editedMsgIDs, r.FormValue("message_id")) s.editedMsgIDs = append(s.editedMsgIDs, r.FormValue("message_id"))
io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`) io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`)
case strings.HasSuffix(path, "/answerCallbackQuery"): case strings.HasSuffix(path, "/answerCallbackQuery"):
@@ -181,6 +188,36 @@ func TestSupportSubsequentReusesTopic(t *testing.T) {
} }
} }
// TestSupportTopicRecreatedWhenDeleted covers the case the operator hit in prod:
// after they delete a user's whole topic, Telegram routes a copy aimed at it into
// General with no error, so the bot must proactively detect the dead topic (the card
// probe fails) and reopen it instead of silently losing the message to General.
func TestSupportTopicRecreatedWhenDeleted(t *testing.T) {
api := newSupportAPI()
api.editErr = "message to edit not found" // the operators deleted the topic + its card
b, st := newSupportBot(t, api)
// Seed a topic that has since been deleted in Telegram.
if err := st.SetTopic(7, 999, 4999, "Ann", "", "annlee"); err != nil {
t.Fatalf("seed topic: %v", err)
}
b.handleSupportUserMessage(context.Background(), userMsg(7, "still there?"))
if api.topicsOpened != 1 {
t.Fatalf("topics opened = %d, want 1 (reopened after deletion)", api.topicsOpened)
}
rec, _ := st.Get(7)
if rec.TopicID != 1000 || rec.HeaderMsgID != 5000 {
t.Errorf("store = topic %d / header %d, want the reopened 1000 / 5000", rec.TopicID, rec.HeaderMsgID)
}
if len(api.copies) != 1 || api.copies[0].threadID != "1000" {
t.Errorf("relay copies = %+v, want one into the reopened topic 1000", api.copies)
}
if _, ok := st.ByTopic(999); ok {
t.Error("stale topic 999 still indexed after reopen")
}
}
func TestSupportBlockedUserDropped(t *testing.T) { func TestSupportBlockedUserDropped(t *testing.T) {
api := newSupportAPI() api := newSupportAPI()
b, st := newSupportBot(t, api) b, st := newSupportBot(t, api)
@@ -360,21 +397,53 @@ func TestParseSupportCallback(t *testing.T) {
} }
} }
func TestSupportCardTextEscapes(t *testing.T) { func TestSupportCard(t *testing.T) {
u := &models.User{ID: 7, FirstName: "<b>Ann</b>", Username: "ann", LanguageCode: "ru", IsPremium: true} t.Run("name is a plain-text mention, not a command", func(t *testing.T) {
card := supportCardText(u) u := &models.User{ID: 7, FirstName: "/start", Username: "ann", LanguageCode: "ru", IsPremium: true}
if strings.Contains(card, "<b>Ann</b>") { text, ents := supportCard(u)
t.Error("user-supplied first name was not HTML-escaped in the card") // The name is rendered verbatim (no HTML, no escaping) at the start of the card.
if !strings.HasPrefix(text, "/start\n") {
t.Errorf("card = %q, want it to start with the raw name", text)
} }
if !strings.Contains(card, "&lt;b&gt;Ann&lt;/b&gt;") { // A text_mention entity covers exactly the name with the user id — this both
t.Errorf("card = %q, want the escaped name", card) // suppresses the "/start" command auto-detection and links the profile.
if len(ents) != 1 {
t.Fatalf("entities = %d, want 1", len(ents))
} }
if !strings.Contains(card, "tg://user?id=7") { e := ents[0]
t.Error("card missing the profile deep link") if e.Type != models.MessageEntityTypeTextMention || e.Offset != 0 || e.Length != len("/start") {
t.Errorf("entity = %+v, want a text_mention over the name", e)
} }
if !strings.Contains(card, "Premium: да") { if e.User == nil || e.User.ID != 7 {
t.Error("card missing the premium flag") t.Errorf("entity user = %+v, want id 7", e.User)
} }
if strings.Contains(text, "tg://") || strings.Contains(text, "<") {
t.Errorf("card = %q, want no tg:// link and no HTML", text)
}
if !strings.Contains(text, "Premium: да") || !strings.Contains(text, "@ann") {
t.Errorf("card = %q, want premium + @username", text)
}
})
t.Run("entity length is UTF-16, not runes", func(t *testing.T) {
// "😀A" is 2 runes but 3 UTF-16 code units (the emoji is a surrogate pair).
u := &models.User{ID: 9, FirstName: "😀A"}
_, ents := supportCard(u)
if len(ents) != 1 || ents[0].Length != 3 {
t.Fatalf("entity = %+v, want length 3 UTF-16 units", ents)
}
})
t.Run("nameless user falls back to an id label", func(t *testing.T) {
u := &models.User{ID: 42}
text, ents := supportCard(u)
if !strings.HasPrefix(text, "user 42") {
t.Errorf("card = %q, want the id fallback name", text)
}
if ents[0].Length != len("user 42") {
t.Errorf("entity length = %d, want it over the fallback name", ents[0].Length)
}
})
} }
func TestSupportTopicName(t *testing.T) { func TestSupportTopicName(t *testing.T) {
@@ -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)
+6 -6
View File
@@ -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;
+1 -1
View File
@@ -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 ☺️",
+1 -1
View File
@@ -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': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
+1 -1
View File
@@ -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}