Promote development → master (initial production release: pre-release line + Stage 18) #104

Merged
developer merged 307 commits from development into master 2026-06-22 05:05:48 +00:00
50 changed files with 2373 additions and 96 deletions
Showing only changes of commit fa8abf22db - Show all commits
+5
View File
@@ -260,11 +260,16 @@ jobs:
GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }}
# The promo button reuses the UI's Mini App link variable.
TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
# The test contour always uses Telegram's test environment — pinned here,
# not an operator variable. The prod workflow leaves it false.
TELEGRAM_TEST_ENV: "true"
+46
View File
@@ -41,6 +41,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** |
| TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs with scheduled rotation, an SSH deploy of both hosts together — is the **deferred final stage** (Stage 18). | owner ad-hoc | **done** (code + test contour; prod wiring → Stage 18) |
| AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **done** (code + test contour; ban enabled in prod → Stage 18) |
| CM | Channel-chat moderation + promo bot: a second standalone bot in the bot container answers `/start` with a localized message + a **URL** button into the **main** bot's Mini App (`?startapp`; a `web_app` button would sign initData with the promo token, which the main validator rejects). The **main** bot gates write access in a channel's linked discussion chat. The chat **allows sending by default** and the bot only restricts (Telegram intersects the chat default with the per-user permission, so a per-user grant cannot exceed a deny-by-default group): it **mutes** a member who is not registered or is admin-suspended or holding a new **`chat_muted`** role, and **un-mutes** an eligible one it had muted, for a member currently in the chat (a `getChatMember` guard, since bots cannot list members). Eligibility = `registered AND NOT suspended AND NOT chat_muted` (the game suspension dominates), resolved once in the backend and reached two ways: the bot's `ResolveChatEligibility` on a `chat_member` event over the existing mTLS bot-link, and a backend `chat_access_changed` event → gateway → `ChatGate` command (emitted on block/unblock, a `chat_muted` change, a first registration, or a temporary-block expiry via a sweeper; idempotent). No schema change — `chat_muted` reuses `account_roles`. | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase)
@@ -579,3 +580,48 @@ Then Stage 18.
(`game_limit_test.go`: count rule + HTTP gate 409 + accept bypass), server unit (error mapping), gateway
transcode round-trip, UI codec + lobbycache unit, e2e (`gamelimit.spec.ts`). Bake-back: `docs/FUNCTIONAL.md`
(+`_ru`), `docs/ARCHITECTURE.md` §8, `docs/UI_DESIGN.md`, `backend/README.md`.
- **CM — Channel-chat moderation + promo bot** (owner ad-hoc, not on the raw TODO list):
- **Locked decisions (interview):** the promo bot is a **goroutine in `cmd/bot`** (its own token, no
bot-link); the moderated chat's default-no-send is configured by a **human** in the group settings (the
bot only grants, never `setChatPermissions`); a non-eligible joiner is **left muted silently**; a
temporary-suspension expiry is handled by a **backend sweeper** that emits the re-evaluate event; and a new
**`chat_muted` role** is a chat-only mute with the **game suspension dominating**
(`eligible = registered AND NOT suspended AND NOT chat_muted`).
- **Bot API reality (verified against the docs):** a cross-bot Mini App launch must be a **URL button** to the
main bot's `t.me/<bot>?startapp` link — a `web_app` button signs initData with the *sending* bot's token,
which the main validator rejects — so the promo button reuses the UI's `VITE_TELEGRAM_LINK`. `chat_member`
updates arrive **only** when the bot is a chat **admin** with the "Ban users" right (the client label for the
Bot API `can_restrict_members`) and `chat_member` is in `allowed_updates`; bots cannot list members but can
`getChatMember` a single user, which is the membership guard on the block/unblock path.
- **Wire:** `pkg/proto/botlink/v1` gains a `ChatGateCommand` in the `Command` oneof and a unary
`ResolveChatEligibility`; the backend gains `notify.KindChatAccessChanged` (no payload, infra-only — never an
out-of-app message) and an internal `POST /api/v1/internal/chat-access` resolver; the gateway resolves the
join (by external_id) and the event (by user_id) through it and pushes the chat-gate command fire-and-forget
(at-most-once, recovered by the next moderation action or a re-join).
- **No schema change → no contour DB wipe:** `chat_muted` is a new `account.KnownRoles` entry (the
`account_roles` table is data-driven). The suspension-expiry sweeper is a new `account.SuspensionSweeper`
(a 1-minute window, idempotent) started in `cmd/backend`, alongside the guest reaper.
- **Deploy:** new `TEST_`/`PROD_` `TELEGRAM_PROMO_BOT_TOKEN` (secret), `TELEGRAM_BOT_USERNAME` and
`TELEGRAM_CHAT_ID` (variables); the promo link reuses the existing `*_VITE_TELEGRAM_LINK` variable as
`TELEGRAM_BOT_LINK`. The bot must be promoted to admin in the real discussion group, and the group default
set to no-send, as part of the Stage 18 prod cutover (the test contour exercises the code path).
- **Bake-back:** `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `platform/telegram/README.md`,
`backend/README.md`, Go Doc comments. Tests: backend resolver truth table + publish on block/unblock/role +
the sweeper window (unit + integration); gateway hub `ResolveChatEligibility` + the chat-gate command; bot
`chat_member` grant + `ApplyChatGate` getChatMember-guard; promo `/start` localization + URL button; config
parsing.
- **Post-contour-test fixes (same PR):** a live test drove three corrections. (1) **Strategy
inversion (the key one)** — the original "group default no-send, bot grants the eligible" cannot
work: Telegram intersects the chat default with each user's permission, so a per-user grant never
exceeds a deny-by-default group (the bot set `can_send=true` yet the user still could not write).
The group now **allows sending by default** and the bot only **restricts** — it mutes an ineligible
member (unregistered / admin-suspended / `chat_muted`) and un-mutes an eligible one it had muted,
acting only when the current state differs (idempotent; the bot's own change is skipped by matching
the actor id to the bot). A present member in a default-allow group can appear as `restricted` with
`is_member`, so the gate reads both. (2) **Join-before-register** — a user who joins before
registering is covered by no `chat_member` event, so `ProvisionTelegram` now reports first contact
and the Telegram auth handler emits `chat_access_changed` on it. (3) **Observability** — a startup
self-check logs whether the bot is an admin-with-restrict in the chat (it caught a misconfigured
`TELEGRAM_CHAT_ID` set to a channel id, not the discussion-group id); the per-event trace is at
Debug, the actual mute/unmute and warnings at Info.
+7
View File
@@ -106,6 +106,13 @@ Telegram `external_id`, language and `notifications_in_app_only` flag) lets the
route out-of-app push to the Telegram bot over the gateway bot-link; the Telegram login
seeds a new account's language and display name from the launch fields, and the
`accounts.notifications_in_app_only` flag (default true).
The gateway-only `POST /api/v1/internal/chat-access` resolves a Telegram identity (the
bot's join-time query) or an account id (a `chat_access_changed` event) to its
**moderated-chat write eligibility**`registered AND NOT suspended AND NOT chat_muted`.
That event is emitted on an admin block/unblock, a `chat_muted` role grant/revoke, or — via
the `account.SuspensionSweeper` started in `cmd/backend` — a temporary block lapsing;
`chat_muted` is an `account.KnownRoles` entry, a chat-only mute distinct from the game
suspension (which dominates it).
`accounts.is_guest` marks an ephemeral guest — a durable row
with no identity, excluded from statistics. The server-rendered
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
+10
View File
@@ -15,6 +15,7 @@ import (
"syscall"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
@@ -160,6 +161,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
zap.Duration("interval", cfg.GuestReapInterval),
zap.Duration("retention", cfg.GuestRetention))
// Re-evaluate moderated-chat write access when a temporary block self-expires:
// no operator action fires then, so the sweeper emits the chat-access-changed
// event for lapsed blocks and the gateway re-pushes the chat-gate command.
chatSweeper := account.NewSuspensionSweeper(accounts, func(id uuid.UUID) {
hub.Publish(notify.ChatAccessChanged(id))
}, logger)
go chatSweeper.Run(ctx)
logger.Info("suspension expiry sweeper started", zap.Duration("interval", chatSweeper.Interval()))
// Lobby & social domains. Their REST and stream surface lives in the gateway,
// so they are handed to the server (like the route groups) for the handlers.
mailer := newMailer(cfg.SMTP, logger)
+28 -8
View File
@@ -151,14 +151,26 @@ func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName stri
return modelToAccount(row), nil
}
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
// identity. On first contact only, it seeds the new account's preferred language
// from the Telegram client languageCode (when it maps to a supported language) and
// its display name sanitized from firstName (falling back to username, then to a
// generated placeholder when neither yields any letters); an already-existing
// account is returned unchanged, so a later profile edit is never overwritten.
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
// ProvisionTelegram provisions (or finds) the account bound to a Telegram identity,
// reporting whether this call created it (first contact). On first contact only, it
// seeds the new account's preferred language from the Telegram client languageCode
// (when it maps to a supported language) and its display name sanitized from firstName
// (falling back to username, then to a generated placeholder when neither yields any
// letters); an already-existing account is returned unchanged, so a later profile edit
// is never overwritten. The created flag lets the auth handler re-evaluate moderated-
// chat write access on first registration — the path of a user who joined the chat
// before registering, whom no chat_member event covers.
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, bool, error) {
// Pre-check whether the identity already exists so the caller can act on first
// contact. A race with a concurrent create only over- or under-reports created for
// that one call, which the idempotent chat-access re-evaluation tolerates.
_, err := s.findByIdentity(ctx, KindTelegram, externalID)
created := errors.Is(err, ErrNotFound)
if err != nil && !created {
return Account{}, false, err
}
acc, err := s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
return acc, created, err
}
// provision finds the account for (kind, externalID) or creates it with seed,
@@ -303,6 +315,14 @@ func (s *Store) CountAccounts(ctx context.Context) (int, error) {
return int(dest.Count), nil
}
// AccountByIdentity returns the account bound to (kind, externalID), or ErrNotFound
// when none exists. Unlike ProvisionByIdentity it never creates one: the chat-access
// resolver uses it to tell a registered Telegram user (eligible to be granted chat
// write access) from an unregistered one (left muted).
func (s *Store) AccountByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
return s.findByIdentity(ctx, kind, externalID)
}
// findByIdentity joins identities to accounts and returns the matching account,
// or ErrNotFound.
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
+9 -1
View File
@@ -24,11 +24,19 @@ const (
// unconditionally, overriding the usual eligibility (a free account with an
// empty hint wallet otherwise sees it). See internal/ads.
RoleNoBanner = "no_banner"
// RoleChatMuted forbids the account from writing in the moderated Telegram
// discussion chat, without otherwise restricting the game (the chat-only
// counterpart to a full account suspension). It is one input to the chat-access
// gate; an active admin suspension mutes the player regardless, so this role only
// matters for an account that is not suspended. Granting or revoking it re-pushes
// the chat-gate command for a member currently in the chat.
RoleChatMuted = "chat_muted"
)
// KnownRoles is the set of roles the console may grant or revoke; an operator
// cannot assign an unrecognised role.
var KnownRoles = []string{RoleFeedbackBanned, RoleNoBanner}
var KnownRoles = []string{RoleFeedbackBanned, RoleNoBanner, RoleChatMuted}
// IsKnownRole reports whether role is a recognised account role.
func IsKnownRole(role string) bool {
+25
View File
@@ -161,6 +161,31 @@ func (s *Store) queryCurrentSuspension(ctx context.Context, accountID uuid.UUID,
return modelToSuspension(row), true, nil
}
// SuspensionsExpiredBetween returns the distinct account ids whose temporary block lapsed in the
// half-open window (since, until]: a non-lifted suspension with a blocked_until in that range. The
// chat-access sweeper uses it to re-evaluate chat write access when a temporary block self-expires,
// since no operator action fires then. An account that still has another active block may be
// included; the eligibility resolver returns the true state, so emitting for it is harmless.
func (s *Store) SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT DISTINCT account_id FROM backend.account_suspensions
WHERE lifted_at IS NULL AND blocked_until > $1 AND blocked_until <= $2`,
since.UTC(), until.UTC())
if err != nil {
return nil, fmt.Errorf("account: suspensions expired between: %w", err)
}
defer rows.Close()
var out []uuid.UUID
for rows.Next() {
var id uuid.UUID
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("account: scan expired suspension: %w", err)
}
out = append(out, id)
}
return out, rows.Err()
}
// invalidateSuspension drops the account's cached block so the next CurrentSuspension re-reads it.
// Called after Suspend and LiftSuspension.
func (s *Store) invalidateSuspension(accountID uuid.UUID) {
@@ -0,0 +1,84 @@
package account
import (
"context"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
// suspensionSweepInterval is how often the sweeper re-checks for temporary blocks
// that lapsed. A minute is well under the coarsest block grain (operators pick day
// presets) while keeping the query trivial.
const suspensionSweepInterval = time.Minute
// suspensionExpiryQuerier is the slice of the account store the sweeper depends on:
// the accounts whose temporary block lapsed in a window. *Store satisfies it; a fake
// drives the sweeper's unit tests.
type suspensionExpiryQuerier interface {
SuspensionsExpiredBetween(ctx context.Context, since, until time.Time) ([]uuid.UUID, error)
}
// SuspensionSweeper re-evaluates chat write access when a temporary block self-
// expires. No operator action fires on expiry — the suspension gate just re-reads
// the wall clock — so without this a temporarily blocked player would stay muted in
// the moderated discussion chat after their block lapsed. Each tick it finds blocks
// that expired since the previous tick and calls onExpire for the affected accounts;
// onExpire is wired to publish the chat-access-changed event, after which the gateway
// re-resolves the true eligibility. A liberal call (an account that still has another
// active block) is therefore harmless. The window is in-memory, so a block that
// expires while the process is down is not re-granted until the next operator action
// or the player rejoins — an accepted best-effort gap.
type SuspensionSweeper struct {
store suspensionExpiryQuerier
onExpire func(accountID uuid.UUID)
log *zap.Logger
// since is the upper bound of the previous swept window; the next sweep covers
// (since, now]. It advances only on a successful query, so a failed tick retries
// the same window rather than dropping expiries.
since time.Time
}
// NewSuspensionSweeper builds the sweeper over the account store, the per-account
// expiry callback (publishing the chat-access-changed event) and a logger. The first
// window opens at construction time, so blocks that lapsed earlier are not re-emitted.
func NewSuspensionSweeper(store *Store, onExpire func(accountID uuid.UUID), log *zap.Logger) *SuspensionSweeper {
if log == nil {
log = zap.NewNop()
}
return &SuspensionSweeper{store: store, onExpire: onExpire, log: log, since: time.Now().UTC()}
}
// Interval reports the sweep cadence, for the startup log line.
func (w *SuspensionSweeper) Interval() time.Duration { return suspensionSweepInterval }
// Run sweeps every Interval until ctx is cancelled.
func (w *SuspensionSweeper) Run(ctx context.Context) {
ticker := time.NewTicker(suspensionSweepInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w.sweep(ctx)
}
}
}
// sweep emits a chat-access-changed signal for every account whose temporary block
// lapsed in (since, now], then advances the window. On a query error it keeps the
// window so the next tick retries it.
func (w *SuspensionSweeper) sweep(ctx context.Context) {
now := time.Now().UTC()
ids, err := w.store.SuspensionsExpiredBetween(ctx, w.since, now)
if err != nil {
w.log.Warn("suspension expiry sweep failed", zap.Error(err))
return
}
w.since = now
for _, id := range ids {
w.onExpire(id)
}
}
@@ -0,0 +1,80 @@
package account
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
// fakeExpiryQuerier records the `since` bound of each call and replays a scripted
// result/error per call, so the sweeper's window and dispatch logic is testable
// without a database.
type fakeExpiryQuerier struct {
results [][]uuid.UUID
errs []error
sinces []time.Time
idx int
}
func (f *fakeExpiryQuerier) SuspensionsExpiredBetween(_ context.Context, since, _ time.Time) ([]uuid.UUID, error) {
f.sinces = append(f.sinces, since)
i := f.idx
f.idx++
if i < len(f.errs) && f.errs[i] != nil {
return nil, f.errs[i]
}
if i < len(f.results) {
return f.results[i], nil
}
return nil, nil
}
func newSweeper(store suspensionExpiryQuerier, onExpire func(uuid.UUID)) *SuspensionSweeper {
return &SuspensionSweeper{
store: store,
onExpire: onExpire,
log: zap.NewNop(),
since: time.Now().Add(-time.Minute).UTC(),
}
}
func TestSuspensionSweeperDispatchesAndAdvances(t *testing.T) {
id1, id2 := uuid.New(), uuid.New()
fake := &fakeExpiryQuerier{results: [][]uuid.UUID{{id1, id2}, nil}}
var got []uuid.UUID
w := newSweeper(fake, func(id uuid.UUID) { got = append(got, id) })
first := w.since
w.sweep(context.Background())
assert.Equal(t, []uuid.UUID{id1, id2}, got, "every expired account is dispatched")
assert.True(t, w.since.After(first), "the window advances on success")
// A second sweep opens the next window at the previous upper bound.
prev := w.since
w.sweep(context.Background())
require.Len(t, fake.sinces, 2)
assert.True(t, fake.sinces[1].After(fake.sinces[0]), "consecutive windows are contiguous and forward")
assert.True(t, fake.sinces[1].Equal(prev), "the next window starts at the previous upper bound")
}
func TestSuspensionSweeperKeepsWindowOnError(t *testing.T) {
fake := &fakeExpiryQuerier{errs: []error{errors.New("db down")}}
w := newSweeper(fake, func(uuid.UUID) { t.Fatal("onExpire must not run when the query fails") })
before := w.since
w.sweep(context.Background())
assert.True(t, w.since.Equal(before), "the window is retained on error so the next tick retries it")
}
func TestNewSuspensionSweeperDefaults(t *testing.T) {
w := NewSuspensionSweeper(nil, func(uuid.UUID) {}, nil)
assert.Equal(t, time.Minute, w.Interval())
assert.NotNil(t, w.log, "a nil logger is tolerated")
assert.WithinDuration(t, time.Now().UTC(), w.since, time.Second, "the first window opens at construction time")
}
+12 -6
View File
@@ -118,10 +118,13 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString()
acc, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван")
acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
if !created {
t.Error("created = false on first contact, want true")
}
if acc.PreferredLanguage != "ru" {
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
}
@@ -133,10 +136,13 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
}
// A later login with different fields returns the same account, unchanged.
again, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other")
again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other")
if err != nil {
t.Fatalf("re-provision telegram: %v", err)
}
if created {
t.Error("created = true on a repeat login, want false")
}
if again.ID != acc.ID {
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
}
@@ -150,7 +156,7 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
// language CHECK.
func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
ctx := context.Background()
acc, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "")
acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -166,7 +172,7 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
func TestHighRateFlagRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -222,7 +228,7 @@ func TestIdentityExternalID(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString()
acc, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User")
acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -247,7 +253,7 @@ func TestIdentityExternalID(t *testing.T) {
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
+1 -1
View File
@@ -222,7 +222,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player")
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player")
if err != nil {
t.Fatalf("provision: %v", err)
}
@@ -0,0 +1,315 @@
//go:build integration
package inttest
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/server"
"scrabble/backend/internal/session"
)
// chatAccessBody mirrors the backend's /internal/chat-access JSON for the test.
type chatAccessBody struct {
ExternalID string `json:"external_id"`
Registered bool `json:"registered"`
Eligible bool `json:"eligible"`
}
// chatAccess issues the gateway-internal chat-access query and asserts a 200.
func chatAccess(t *testing.T, srv *server.Server, body string) chatAccessBody {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/chat-access", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("chat-access %s = %d: %s", body, rec.Code, rec.Body.String())
}
var b chatAccessBody
if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil {
t.Fatalf("decode chat-access: %v", err)
}
return b
}
// TestChatAccessResolver drives the gateway-internal eligibility resolver over HTTP:
// the registered/suspended/chat_muted truth table by Telegram identity and by account
// id, the suspension dominating the chat_muted role, an unknown identity reported
// unregistered, and an account with no Telegram identity carrying an empty external_id.
func TestChatAccessResolver(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts})
ext := "tg-" + uuid.NewString()
acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter")
if err != nil {
t.Fatalf("provision: %v", err)
}
id := acc.ID
byExt := func() chatAccessBody { return chatAccess(t, srv, `{"external_id":"`+ext+`"}`) }
byUser := func() chatAccessBody { return chatAccess(t, srv, `{"user_id":"`+id.String()+`"}`) }
// A registered, unsuspended, unmuted account is eligible by either address, and the
// account-id query resolves back to its Telegram identity.
if b := byExt(); !b.Registered || !b.Eligible || b.ExternalID != ext {
t.Fatalf("fresh by external_id = %+v, want registered+eligible+ext", b)
}
if b := byUser(); !b.Registered || !b.Eligible || b.ExternalID != ext {
t.Fatalf("fresh by user_id = %+v, want registered+eligible+ext", b)
}
// A suspension mutes; a lift restores.
if _, err := accounts.Suspend(ctx, id, nil, "", "", nil); err != nil {
t.Fatalf("suspend: %v", err)
}
if b := byExt(); !b.Registered || b.Eligible {
t.Fatalf("suspended = %+v, want registered but not eligible", b)
}
if err := accounts.LiftSuspension(ctx, id); err != nil {
t.Fatalf("lift: %v", err)
}
if b := byExt(); !b.Eligible {
t.Fatalf("after lift = %+v, want eligible", b)
}
// The chat_muted role mutes independently; a revoke restores.
if err := accounts.GrantRole(ctx, id, account.RoleChatMuted); err != nil {
t.Fatalf("grant chat_muted: %v", err)
}
if b := byExt(); !b.Registered || b.Eligible {
t.Fatalf("chat_muted = %+v, want registered but not eligible", b)
}
// Suspension dominates: while chat_muted is set, lifting a concurrent suspension
// must not re-grant chat (the role still mutes).
if _, err := accounts.Suspend(ctx, id, nil, "", "", nil); err != nil {
t.Fatalf("suspend over mute: %v", err)
}
if b := byExt(); b.Eligible {
t.Fatalf("suspended+muted = %+v, want not eligible", b)
}
if err := accounts.LiftSuspension(ctx, id); err != nil {
t.Fatalf("lift over mute: %v", err)
}
if b := byExt(); b.Eligible {
t.Fatalf("lifted but still muted = %+v, want not eligible", b)
}
if err := accounts.RevokeRole(ctx, id, account.RoleChatMuted); err != nil {
t.Fatalf("revoke chat_muted: %v", err)
}
if b := byExt(); !b.Eligible {
t.Fatalf("after revoke = %+v, want eligible", b)
}
// An unknown Telegram identity is unregistered (and thus left muted).
if b := chatAccess(t, srv, `{"external_id":"tg-missing-`+uuid.NewString()+`"}`); b.Registered || b.Eligible {
t.Fatalf("unknown identity = %+v, want neither registered nor eligible", b)
}
// An account with no Telegram identity (a guest) carries an empty external_id, so
// the gateway has nothing to gate.
guest := provisionGuest(t)
if b := chatAccess(t, srv, `{"user_id":"`+guest.String()+`"}`); b.ExternalID != "" || b.Registered {
t.Fatalf("guest by user_id = %+v, want empty external_id and not registered", b)
}
// A request naming neither address is a bad request.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/chat-access", strings.NewReader(`{}`))
req.Header.Set("Content-Type", "application/json")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("empty query = %d, want 400", rec.Code)
}
}
// captureNotifier records every published intent so a test can assert which live
// events a console action emitted.
type captureNotifier struct {
mu sync.Mutex
intents []notify.Intent
}
func (c *captureNotifier) Publish(in ...notify.Intent) {
c.mu.Lock()
defer c.mu.Unlock()
c.intents = append(c.intents, in...)
}
// count returns how many intents of kind addressed to user were captured.
func (c *captureNotifier) count(user uuid.UUID, kind string) int {
c.mu.Lock()
defer c.mu.Unlock()
n := 0
for _, in := range c.intents {
if in.UserID == user && in.Kind == kind {
n++
}
}
return n
}
// TestChatAccessPublishedOnModeration drives the admin console and asserts each
// moderation action that can change chat eligibility — block, unblock, and the
// chat_muted role grant/revoke — emits the chat_access_changed signal the gateway
// turns into a chat-gate command.
func TestChatAccessPublishedOnModeration(t *testing.T) {
notifier := &captureNotifier{}
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: account.NewStore(testDB),
Games: newGameService(),
Registry: testRegistry,
DictDir: dictDir(),
Notifier: notifier,
})
h := srv.Handler()
id := provisionAccount(t)
base := "http://admin.test/_gm/users/" + id.String()
const origin = "http://admin.test"
steps := []struct {
name, path, body string
want string
}{
{"block", "/block", "duration=permanent", "Blocked"},
{"unblock", "/unblock", "", "Unblocked"},
{"grant chat_muted", "/grant-role", "role=chat_muted", "Role granted"},
{"revoke chat_muted", "/revoke-role", "role=chat_muted", "Role revoked"},
}
for i, s := range steps {
code, body := consoleDo(h, http.MethodPost, base+s.path, s.body, origin)
if code != http.StatusOK || !strings.Contains(body, s.want) {
t.Fatalf("%s = %d, has %q = %v", s.name, code, s.want, strings.Contains(body, s.want))
}
if got := notifier.count(id, notify.KindChatAccessChanged); got != i+1 {
t.Fatalf("after %s: chat_access_changed count = %d, want %d", s.name, got, i+1)
}
}
}
// TestChatAccessPublishedOnFirstRegistration checks that a Telegram first contact
// (the sessions/telegram endpoint creating the account) emits chat_access_changed —
// the re-grant for a user who joined the moderated chat before registering — and that
// a repeat login does not re-emit.
func TestChatAccessPublishedOnFirstRegistration(t *testing.T) {
notifier := &captureNotifier{}
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: notifier,
})
h := srv.Handler()
ext := "tg-" + uuid.NewString()
post := func() {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram",
strings.NewReader(`{"external_id":"`+ext+`","language_code":"en","first_name":"Reg"}`))
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())
}
}
post()
acc, err := account.NewStore(testDB).AccountByIdentity(context.Background(), account.KindTelegram, ext)
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got := notifier.count(acc.ID, notify.KindChatAccessChanged); got != 1 {
t.Fatalf("first registration: chat_access_changed count = %d, want 1", got)
}
// A repeat login (the account already exists) must not re-emit.
post()
if got := notifier.count(acc.ID, notify.KindChatAccessChanged); got != 1 {
t.Fatalf("repeat login: chat_access_changed count = %d, want still 1", got)
}
}
// TestSuspensionsExpiredBetween checks the sweeper's window query: a non-lifted
// temporary block whose expiry falls in the window is returned, while one outside the
// window, a permanent block, and a lifted block are not.
func TestSuspensionsExpiredBetween(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
// A temporary block whose expiry already lapsed at a known instant.
tempID := provisionAccount(t)
expiry := time.Now().Add(-time.Hour).Truncate(time.Second)
if _, err := accounts.Suspend(ctx, tempID, &expiry, "", "", nil); err != nil {
t.Fatalf("suspend temp: %v", err)
}
contains := func(ids []uuid.UUID, want uuid.UUID) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
// A window straddling the expiry returns the account.
got, err := accounts.SuspensionsExpiredBetween(ctx, expiry.Add(-time.Minute), expiry.Add(time.Minute))
if err != nil {
t.Fatalf("expired between: %v", err)
}
if !contains(got, tempID) {
t.Fatalf("window over expiry missing the lapsed block %s", tempID)
}
// A window entirely after the expiry does not.
got, err = accounts.SuspensionsExpiredBetween(ctx, expiry.Add(time.Minute), expiry.Add(2*time.Minute))
if err != nil {
t.Fatalf("expired between (after): %v", err)
}
if contains(got, tempID) {
t.Fatalf("window after expiry should not return %s", tempID)
}
// A permanent block never appears, even in a wide window.
permID := provisionAccount(t)
if _, err := accounts.Suspend(ctx, permID, nil, "", "", nil); err != nil {
t.Fatalf("suspend perm: %v", err)
}
// A lifted block does not appear either. The block must still be in force when lifted
// (LiftSuspension only lifts in-force blocks), so its expiry is in the future and the
// wide window below still covers it — yet lifted_at excludes it.
liftID := provisionAccount(t)
liftExpiry := time.Now().Add(30 * time.Minute).Truncate(time.Second)
if _, err := accounts.Suspend(ctx, liftID, &liftExpiry, "", "", nil); err != nil {
t.Fatalf("suspend lift: %v", err)
}
if err := accounts.LiftSuspension(ctx, liftID); err != nil {
t.Fatalf("lift: %v", err)
}
wide, err := accounts.SuspensionsExpiredBetween(ctx, time.Now().Add(-2*time.Hour), time.Now().Add(time.Hour))
if err != nil {
t.Fatalf("expired between (wide): %v", err)
}
if contains(wide, permID) {
t.Fatalf("permanent block %s must not be reported as expired", permID)
}
if contains(wide, liftID) {
t.Fatalf("lifted block %s must not be reported as expired", liftID)
}
}
@@ -38,7 +38,7 @@ func TestSuspensionGate(t *testing.T) {
Accounts: accounts,
})
acc, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked")
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked")
if err != nil {
t.Fatalf("provision: %v", err)
}
+1 -1
View File
@@ -18,7 +18,7 @@ func TestUserListFilter(t *testing.T) {
st := account.NewStore(testDB)
uniq := uuid.NewString()
human, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman")
human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman")
if err != nil {
t.Fatalf("provision human: %v", err)
}
+10
View File
@@ -216,6 +216,16 @@ func BannerChanged(userID uuid.UUID) Intent {
return Notification(userID, NotifyBanner)
}
// ChatAccessChanged signals that userID's eligibility to write in the moderated
// Telegram discussion chat may have changed (an admin block/unblock, a chat_muted
// grant/revoke, or a temporary block lapsing). It carries no payload: the gateway
// resolves the user's Telegram identity and current eligibility and pushes the
// resulting chat-gate command to the bot. Unlike the lobby notifications it is an
// infra signal — a distinct top-level kind, never an out-of-app rendered message.
func ChatAccessChanged(userID uuid.UUID) Intent {
return Intent{UserID: userID, Kind: KindChatAccessChanged, EventID: eventID()}
}
// eventID returns a best-effort correlation id for one emitted event.
func eventID() string {
if id, err := uuid.NewV7(); err == nil {
+7
View File
@@ -35,6 +35,13 @@ const (
// KindGameOver announces a finished game to each seated player, driving the
// out-of-app "game over" push.
KindGameOver = "game_over"
// KindChatAccessChanged signals that a player's eligibility to write in the
// moderated Telegram discussion chat may have changed (an admin block or unblock,
// a chat_muted grant or revoke, or a temporary block lapsing). It carries no
// payload and is never fanned out to in-app clients: the gateway consumes it to
// resolve the player's Telegram identity and current eligibility and push the
// resulting chat-gate command to the bot.
KindChatAccessChanged = "chat_access_changed"
)
// Notification sub-kinds carried in a KindNotification event payload; the client
+131
View File
@@ -0,0 +1,131 @@
package server
import (
"context"
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/notify"
)
// chatAccessRequest is the gateway's chat write-eligibility query, addressed either
// by Telegram identity (ExternalID — the join path, when the bot sees a user enter
// the chat) or by account id (UserID — the change path, resolving an emitted
// chat-access-changed event). Exactly one field is set.
type chatAccessRequest struct {
ExternalID string `json:"external_id"`
UserID string `json:"user_id"`
}
// chatAccessResponse is the resolved eligibility. ExternalID echoes the account's
// Telegram identity (empty when it has none — the gateway then has nothing to gate);
// Registered reports whether the lookup found an account at all; Eligible is the
// final gate the bot applies (registered and neither admin-suspended nor chat-muted).
type chatAccessResponse struct {
ExternalID string `json:"external_id"`
Registered bool `json:"registered"`
Eligible bool `json:"eligible"`
}
// handleChatAccess resolves whether a Telegram user may write in the moderated
// discussion chat. It is gateway-internal: the gateway's bot-link serves the bot's
// join-time query (by external_id) and resolves an emitted chat-access-changed event
// (by user_id) through it.
func (s *Server) handleChatAccess(c *gin.Context) {
var req chatAccessRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid body")
return
}
switch {
case req.ExternalID != "":
s.respondChatAccessByExternalID(c, req.ExternalID)
case req.UserID != "":
s.respondChatAccessByUserID(c, req.UserID)
default:
abortBadRequest(c, "external_id or user_id required")
}
}
// respondChatAccessByExternalID answers the join-path query: an unknown identity is
// reported unregistered (and left muted); a known one carries its current eligibility.
func (s *Server) respondChatAccessByExternalID(c *gin.Context, externalID string) {
ctx := c.Request.Context()
resp := chatAccessResponse{ExternalID: externalID}
acc, err := s.accounts.AccountByIdentity(ctx, account.KindTelegram, externalID)
if errors.Is(err, account.ErrNotFound) {
c.JSON(http.StatusOK, resp)
return
}
if err != nil {
s.abortErr(c, err)
return
}
resp.Registered = true
eligible, err := s.chatEligible(ctx, acc.ID)
if err != nil {
s.abortErr(c, err)
return
}
resp.Eligible = eligible
c.JSON(http.StatusOK, resp)
}
// respondChatAccessByUserID answers the change-path query: an account with no
// Telegram identity carries an empty external_id (nothing for the gateway to gate);
// otherwise it carries the identity and the current eligibility.
func (s *Server) respondChatAccessByUserID(c *gin.Context, raw string) {
ctx := c.Request.Context()
uid, err := uuid.Parse(raw)
if err != nil {
abortBadRequest(c, "invalid user_id")
return
}
var resp chatAccessResponse
ext, err := s.accounts.IdentityExternalID(ctx, uid, account.KindTelegram)
if errors.Is(err, account.ErrNotFound) {
c.JSON(http.StatusOK, resp)
return
}
if err != nil {
s.abortErr(c, err)
return
}
resp.ExternalID = ext
resp.Registered = true
eligible, err := s.chatEligible(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
resp.Eligible = eligible
c.JSON(http.StatusOK, resp)
}
// chatEligible reports whether the account may write in the moderated discussion
// chat: not currently admin-suspended and not holding the chat_muted role. A
// suspension dominates — it mutes regardless of the role. Registration is established
// by the caller's identity lookup.
func (s *Server) chatEligible(ctx context.Context, accountID uuid.UUID) (bool, error) {
if _, blocked, err := s.accounts.CurrentSuspension(ctx, accountID); err != nil {
return false, err
} else if blocked {
return false, nil
}
muted, err := s.accounts.HasRole(ctx, accountID, account.RoleChatMuted)
if err != nil {
return false, err
}
return !muted, nil
}
// publishChatAccessChange emits the chat-access-changed signal for the account, so
// the gateway re-resolves the player's chat eligibility and pushes the chat-gate
// command to the bot. Best-effort (notify.Nop when no notifier is wired).
func (s *Server) publishChatAccessChange(id uuid.UUID) {
s.notifier.Publish(notify.ChatAccessChanged(id))
}
+7
View File
@@ -37,6 +37,13 @@ func (s *Server) registerRoutes() {
// before delivering an out-of-app notification.
in.POST("/push-target", s.handlePushTarget)
}
if s.accounts != nil {
// Moderated-chat write eligibility for the Telegram bot: resolve a Telegram
// identity (the bot's join-time query) or an account id (a chat-access-changed
// event) to whether the user may write in the discussion chat. It needs only the
// account store, not the session service, so it registers independently.
s.internal.POST("/chat-access", s.handleChatAccess)
}
if s.ratewatch != nil {
// The gateway's periodic rate-limiter rejection summary: feeds the
// admin console's throttled view and the high-rate auto-flag.
@@ -987,6 +987,9 @@ func (s *Server) consoleBlockUser(c *gin.Context) {
s.consoleError(c, err)
return
}
// Re-evaluate the player's moderated-chat write access: a block mutes them in
// the discussion chat if they are currently in it.
s.publishChatAccessChange(id)
s.renderConsoleMessage(c, "Blocked", fmt.Sprintf("account blocked; %d game(s) forfeited", forfeited), back)
}
@@ -1001,6 +1004,9 @@ func (s *Server) consoleUnblockUser(c *gin.Context) {
s.consoleError(c, err)
return
}
// Re-evaluate the player's moderated-chat write access: an unblock restores it
// (unless they are still chat-muted) for a member currently in the chat.
s.publishChatAccessChange(id)
s.renderConsoleMessage(c, "Unblocked", "the block was lifted; lost games are not restored", "/_gm/users/"+id.String())
}
@@ -248,6 +248,9 @@ func (s *Server) consoleGrantRole(c *gin.Context) {
if role == account.RoleNoBanner {
s.publishBannerChange(id)
}
if role == account.RoleChatMuted {
s.publishChatAccessChange(id)
}
s.renderConsoleMessage(c, "Role granted", "granted "+role, back)
}
@@ -270,6 +273,9 @@ func (s *Server) consoleRevokeRole(c *gin.Context) {
if role == account.RoleNoBanner {
s.publishBannerChange(id)
}
if role == account.RoleChatMuted {
s.publishChatAccessChange(id)
}
s.renderConsoleMessage(c, "Role revoked", "revoked "+role, back)
}
+7 -1
View File
@@ -35,11 +35,17 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
abortBadRequest(c, "external_id is required")
return
}
acc, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName)
acc, created, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName)
if err != nil {
s.abortErr(c, err)
return
}
if created {
// First registration: re-evaluate moderated-chat write access, so a user who
// joined the chat before registering is granted on the spot (no chat_member
// event fires on registration).
s.publishChatAccessChange(acc.ID)
}
s.mintSession(c, acc)
}
+4
View File
@@ -46,6 +46,10 @@ GRAFANA_ADMIN_PASSWORD=admin
AWG_CONF= # required; AmneziaWG sidecar config (the bot's Telegram egress)
TELEGRAM_BOT_TOKEN= # required
TELEGRAM_GAME_CHANNEL_ID=
TELEGRAM_CHAT_ID= # moderated discussion chat (channel's linked group); empty disables gating
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_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
TELEGRAM_MINIAPP_URL= # required
TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL=
+11
View File
@@ -268,6 +268,17 @@ services:
# at boot; an empty value leaves the bot down while the rest of the contour comes up.
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
# The moderated discussion chat (a channel's linked group) the bot gates write
# access in. Empty disables gating. The group must ALLOW sending by default — the bot
# only restricts (mutes the ineligible) — and the bot must be an admin there with the
# "Ban users" right; chat_member updates are delivered only to a chat admin.
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
# The optional standalone promo bot (its own token) answering /start with a button
# into the main bot's app. Empty disables it; when set it needs the main bot's
# @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK).
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false}
TELEGRAM_API_BASE_URL: ${TELEGRAM_API_BASE_URL:-}
+27 -2
View File
@@ -824,7 +824,13 @@ the bot renders the message and skips the rest — so in-app-only sub-kinds like
block-state sync to the blocker) never become a platform push. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) render in an **operator-chosen** language in
the console; the backend calls them on the **gateway's bot-link relay**, which forwards them
to the bot and **awaits its delivery ack** (so the console still reports delivered/not).
to the bot and **awaits its delivery ack** (so the console still reports delivered/not). Beyond
messages the same bot-link carries a **chat-gate control path** — a `ChatGate` command sets a user's
write access in the moderated discussion chat and the bot's unary `ResolveChatEligibility` resolves a
joiner's eligibility (neither renders a message; see *Moderated discussion chat* below). An optional
**standalone promo bot** runs in the bot container (`TELEGRAM_PROMO_BOT_TOKEN`): a second bot
answering `/start` with a URL button into the **main** bot's Mini App (`?startapp`, since a `web_app`
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP).
A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md),
@@ -987,9 +993,28 @@ revoked token would fail session resolution at the gateway *before* the gate, se
login instead of the blocked screen). A block instantly **forfeits** every active game the player
is in (the opponent wins, exactly as a resignation — the engine resigns off-turn) and cancels
their open matchmaking games; a temporary block lapses automatically once its expiry passes (no
sweeper the gate recomputes against `now`). No operator identity is recorded (shared
sweeper for the gate — it recomputes against `now`). No operator identity is recorded (shared
Basic-Auth).
**Moderated discussion chat.** A channel's linked discussion group is gated by the Telegram bot
(`TELEGRAM_CHAT_ID`). The group **allows sending by default** and the bot only **restricts**: Telegram
intersects the chat default with each user's permission, so a per-user grant can never exceed a
deny-by-default group — the gate must mute the ineligible, not grant the eligible. A user may write
while they are **registered and neither admin-suspended nor holding the chat-only `chat_muted` role**
(`eligible = registered AND NOT suspended AND NOT chat_muted` — the game suspension dominates); the bot
**mutes** an ineligible member and **un-mutes** an eligible one it had muted, leaving an already-allowed
eligible member untouched (it acts only when the current state differs, so it is idempotent and never
loops on its own change). A single backend resolver behind `POST /api/v1/internal/chat-access` answers
both directions: the bot's `ResolveChatEligibility` on a `chat_member` event (over the mTLS bot-link),
and a `chat_access_changed` event — emitted on a block/unblock, a `chat_muted` grant/revoke, a first
Telegram registration, or a temporary block lapsing (a dedicated `account.SuspensionSweeper`, since no
request fires then) — drives a `ChatGate` command the gateway pushes to the bot. The bot applies it
only to a member currently in the chat (a per-user `getChatMember` probe, since bots cannot list
members); the signal is idempotent and is never an in-app or out-of-app message. `chat_muted` is an
`account_roles` entry (an operator toggle in the console), so it needs no schema change. The bot
must be an administrator in the group with the **restrict-members** right and `chat_member` in its
allowed updates.
**Short numeric codes** (email confirm-codes and friend codes) are stored
only as SHA-256 hashes and are short-lived and single-use. The unauthenticated
email path carries a tight per-IP sub-limit (5 / 10 min); the **friend-code redeem**
+12 -1
View File
@@ -33,7 +33,10 @@ App** launch authenticates from the platform's signed `initData`, themes the UI
the Telegram colours, and — on first contact — seeds the new account's interface
language from the Telegram client. Telegram runs a **single bot**: every player uses
the same bot, and all of its chat and out-of-app notifications are written in the
player's own **interface language** (en/ru). Guests are session-only with restricted features
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
main one — its only job is to answer `/start` with a short message and a button that opens the
**main** bot's app, where the player picks their game variant; it is an onboarding entry point that
touches nothing else. Guests are session-only with restricted features
(auto-match only; no friends, stats or history); an abandoned guest that never
joined a game and has been idle past the retention window is garbage-collected. While the app is open the client
keeps a live stream and receives in-app updates in real time — the opponent's move,
@@ -320,6 +323,14 @@ plus the reason when one was given, and the app stops all background traffic wit
temporary block lifts itself when it expires; the operator can also **unblock** from the user card
at any time (games already lost stay lost).
Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the
bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register
or are unblocked. So an unregistered newcomer who comments is muted (the promo bot points them at the
game to register, after which the bot restores their voice), and a registered, unblocked player simply
writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
without a full account block; an account block mutes them in the chat regardless. Muting and unmuting
take effect for a player already in the chat; one who is not in it is unaffected until they next join.
From the user card the operator can also **top up a player's hint wallet**: an additive grant
(1100 hints per action) that raises the balance shown on the card. Grants are **raise-only**
the console can never lower a wallet (a player only loses hints by spending them in a game), so an
+13 -1
View File
@@ -34,7 +34,10 @@ Mini App** авторизует по подписанным `initData` плат
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
языку Telegram-клиента. Telegram держит **единого бота**: все игроки пользуются одним
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
интерфейса** самого игрока (en/ru). Гость — только сессия, с урезанными функциями (только
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
открывающей приложение **основного** бота, где игрок выбирает нужный вариант игры; это точка входа
для онбординга, не затрагивающая больше ничего. Гость — только сессия, с урезанными функциями (только
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
@@ -329,6 +332,15 @@ high-rate флага. С карточки пользователя операт
истечении срока; оператор также может **разблокировать** с карточки пользователя в любой момент
(уже проигранные партии не возвращаются).
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат,
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
аккаунта всё равно мьютит его в чате. Мьют и размьют срабатывают для игрока, уже находящегося в чате;
того, кого в чате нет, это не затрагивает до его следующего входа.
С карточки пользователя оператор также может **пополнить кошелёк подсказок** игрока: аддитивное
начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления
**только в плюс** — понизить кошелёк из консоли нельзя (игрок теряет подсказки только тратя их в
+35 -1
View File
@@ -147,7 +147,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
// fire-and-forget; the backend admin relay (plaintext, internal) awaits the Ack.
var botHub *botlink.Hub
if cfg.BotLinkEnabled() {
botHub = botlink.NewHub(logger, tel.MeterProvider().Meter("scrabble/gateway/botlink"))
botHub = botlink.NewHub(logger, tel.MeterProvider().Meter("scrabble/gateway/botlink"),
func(ctx context.Context, externalID string) (bool, bool, error) {
r, rerr := backend.ChatEligibility(ctx, externalID)
return r.Registered, r.Eligible, rerr
})
tlsCfg, terr := mtls.ServerConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile)
if terr != nil {
return terr
@@ -361,6 +365,15 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H
}
break
}
// A chat-access-changed event is an infra signal, not an in-app event:
// resolve the recipient's Telegram identity and current eligibility and
// push the chat-gate command to the bot, without fanning it out to clients.
if ev.GetKind() == chatAccessChangedKind {
if bot != nil {
go deliverChatGate(ctx, backend, bot, ev.GetUserId(), logger)
}
continue
}
hub.Publish(push.Event{
UserID: ev.GetUserId(),
Kind: ev.GetKind(),
@@ -398,6 +411,27 @@ func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, bot *bo
bot.Send(botlink.NotifyCommand(target.ExternalID, kind, payload, target.Language))
}
// chatAccessChangedKind is the backend event signalling that a player's moderated-chat
// write eligibility may have changed; the gateway turns it into a bot-link chat-gate
// command rather than an in-app event (it mirrors notify.KindChatAccessChanged).
const chatAccessChangedKind = "chat_access_changed"
// deliverChatGate resolves a chat-access-changed event to the recipient's Telegram
// identity and current eligibility and pushes the chat-gate command to the bot. It is
// best-effort: a recipient with no Telegram identity is skipped, and a resolve failure
// is logged and dropped (the next moderation action, or a re-join, re-applies the gate).
func deliverChatGate(ctx context.Context, backend *backendclient.Client, bot *botlink.Hub, userID string, logger *zap.Logger) {
res, err := backend.ChatAccessByUser(ctx, userID)
if err != nil {
logger.Warn("chat-gate resolve failed", zap.String("user_id", userID), zap.Error(err))
return
}
if res.ExternalID == "" {
return // no Telegram identity, nothing to gate
}
bot.Send(botlink.ChatGateCommand(res.ExternalID, res.Eligible))
}
// sleep waits for d or until ctx is cancelled, reporting whether it waited the
// full duration.
func sleep(ctx context.Context, d time.Duration) bool {
+28
View File
@@ -215,6 +215,34 @@ func (c *Client) PushTarget(ctx context.Context, userID string) (PushTargetResp,
return out, err
}
// ChatAccessResp is a user's moderated-chat write eligibility: ExternalID is their
// Telegram identity (empty when they have none, so the gateway has nothing to gate),
// Registered whether an account was found, and Eligible the final gate the bot applies
// (registered and neither admin-suspended nor chat-muted).
type ChatAccessResp struct {
ExternalID string `json:"external_id"`
Registered bool `json:"registered"`
Eligible bool `json:"eligible"`
}
// ChatEligibility resolves a Telegram identity to its moderated-chat write
// eligibility — the join path, when the bot sees a user enter the chat.
func (c *Client) ChatEligibility(ctx context.Context, externalID string) (ChatAccessResp, error) {
var out ChatAccessResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/chat-access", "", "",
map[string]string{"external_id": externalID}, &out)
return out, err
}
// ChatAccessByUser resolves an account id to its Telegram identity and current
// moderated-chat write eligibility — the change path, for a chat-access-changed event.
func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAccessResp, error) {
var out ChatAccessResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/chat-access", "", "",
map[string]string{"user_id": userID}, &out)
return out, err
}
// GuestAuth provisions a guest account and mints a session.
func (c *Client) GuestAuth(ctx context.Context) (SessionResp, error) {
var out SessionResp
+12
View File
@@ -36,3 +36,15 @@ func SendToGameChannelCommand(text string) *botlinkv1.Command {
}},
}
}
// ChatGateCommand builds a chat-gate command that sets whether the Telegram user
// identified by externalID may write in the moderated discussion chat. The bot
// applies it only to a member currently in the chat (guarded on getChatMember).
func ChatGateCommand(externalID string, allow bool) *botlinkv1.Command {
return &botlinkv1.Command{
Payload: &botlinkv1.Command_ChatGate{ChatGate: &botlinkv1.ChatGateCommand{
ExternalId: externalID,
Allow: allow,
}},
}
}
+33 -6
View File
@@ -31,13 +31,21 @@ var ErrNoBot = errors.New("botlink: no bot connected")
// (at-most-once under backpressure).
const outboundBuffer = 64
// EligibilityResolver answers a Telegram identity's moderated-chat write eligibility
// for the bot's join-time ResolveChatEligibility query: registered reports whether the
// identity maps to an account, eligible is the final gate the bot acts on (registered
// and neither admin-suspended nor chat-muted). The gateway backs it with the backend
// chat-access endpoint.
type EligibilityResolver func(ctx context.Context, externalID string) (registered, eligible bool, err error)
// Hub registers connected bots and routes send commands to them. A single bot is
// expected today; the registry already holds a set so adding more later needs no
// rewrite.
type Hub struct {
botlinkv1.UnimplementedBotLinkServer
log *zap.Logger
log *zap.Logger
eligibility EligibilityResolver
mu sync.Mutex
links map[*link]struct{}
@@ -56,15 +64,18 @@ type link struct {
out chan *botlinkv1.ToBot
}
// NewHub builds a Hub. A nil meter disables metrics; a nil logger is tolerated.
func NewHub(log *zap.Logger, meter metric.Meter) *Hub {
// NewHub builds a Hub. resolve answers the bot's join-time chat-eligibility query
// (nil rejects it as unavailable). A nil meter disables metrics; a nil logger is
// tolerated.
func NewHub(log *zap.Logger, meter metric.Meter, resolve EligibilityResolver) *Hub {
if log == nil {
log = zap.NewNop()
}
h := &Hub{
log: log,
links: make(map[*link]struct{}),
pending: make(map[string]chan *botlinkv1.Ack),
log: log,
eligibility: resolve,
links: make(map[*link]struct{}),
pending: make(map[string]chan *botlinkv1.Ack),
}
if meter != nil {
h.connected, _ = meter.Int64UpDownCounter("botlink_connected_bots",
@@ -120,6 +131,22 @@ func (h *Hub) Link(stream grpc.BidiStreamingServer[botlinkv1.FromBot, botlinkv1.
}
}
// ResolveChatEligibility serves the bot's join-time query: whether the Telegram user
// identified in the request may write in the moderated discussion chat. It delegates
// to the configured resolver (the backend chat-access endpoint), unlike the streamed
// Commands it is a plain request/response over the same mTLS channel.
func (h *Hub) ResolveChatEligibility(ctx context.Context, req *botlinkv1.ChatEligibilityRequest) (*botlinkv1.ChatEligibilityResponse, error) {
if h.eligibility == nil {
return nil, status.Error(codes.Unavailable, "chat eligibility resolver not configured")
}
registered, eligible, err := h.eligibility(ctx, req.GetExternalId())
if err != nil {
h.log.Warn("resolve chat eligibility failed", zap.String("external_id", req.GetExternalId()), zap.Error(err))
return nil, status.Error(codes.Internal, "resolve chat eligibility")
}
return &botlinkv1.ChatEligibilityResponse{Registered: registered, Eligible: eligible}, nil
}
// register adds a connected bot.
func (h *Hub) register(l *link) {
h.mu.Lock()
+67 -3
View File
@@ -8,7 +8,9 @@ import (
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
@@ -23,12 +25,18 @@ type fakeBot struct {
received chan *botlinkv1.Command
}
// startHub registers a Hub on an in-memory gRPC server and returns the hub plus a
// dialer for fake bots.
// startHub registers a Hub (no chat-eligibility resolver) on an in-memory gRPC
// server and returns the hub plus a dialer for fake bots.
func startHub(t *testing.T) (*Hub, func(t *testing.T) botlinkv1.BotLinkClient) {
return startHubWith(t, nil)
}
// startHubWith is startHub with an explicit chat-eligibility resolver, for the
// ResolveChatEligibility tests.
func startHubWith(t *testing.T, resolve EligibilityResolver) (*Hub, func(t *testing.T) botlinkv1.BotLinkClient) {
t.Helper()
lis := bufconn.Listen(1 << 20)
hub := NewHub(nil, nil)
hub := NewHub(nil, nil, resolve)
srv := grpc.NewServer()
botlinkv1.RegisterBotLinkServer(srv, hub)
go func() { _ = srv.Serve(lis) }()
@@ -162,6 +170,62 @@ func TestHubSendAsync(t *testing.T) {
}
}
func TestHubSendChatGate(t *testing.T) {
hub, dial := startHub(t)
ctx := t.Context()
bot := &fakeBot{ack: false}
bot.connect(t, ctx, hub, dial(t))
hub.Send(ChatGateCommand("42", true))
select {
case cmd := <-bot.received:
cg := cmd.GetChatGate()
if cg.GetExternalId() != "42" || !cg.GetAllow() {
t.Errorf("chat_gate = %+v, want external_id=42 allow=true", cg)
}
case <-time.After(time.Second):
t.Fatal("bot received no command")
}
}
func TestHubResolveChatEligibility(t *testing.T) {
var gotExt string
_, dial := startHubWith(t, func(_ context.Context, ext string) (bool, bool, error) {
gotExt = ext
return true, ext == "good", nil
})
client := dial(t)
ctx := t.Context()
resp, err := client.ResolveChatEligibility(ctx, &botlinkv1.ChatEligibilityRequest{ExternalId: "good"})
if err != nil {
t.Fatalf("ResolveChatEligibility: %v", err)
}
if gotExt != "good" {
t.Errorf("resolver external_id = %q, want good", gotExt)
}
if !resp.GetRegistered() || !resp.GetEligible() {
t.Errorf("resp = %+v, want registered+eligible", resp)
}
resp, err = client.ResolveChatEligibility(ctx, &botlinkv1.ChatEligibilityRequest{ExternalId: "muted"})
if err != nil {
t.Fatalf("ResolveChatEligibility(muted): %v", err)
}
if !resp.GetRegistered() || resp.GetEligible() {
t.Errorf("resp = %+v, want registered but not eligible", resp)
}
}
func TestHubResolveChatEligibilityUnconfigured(t *testing.T) {
_, dial := startHub(t) // nil resolver
client := dial(t)
_, err := client.ResolveChatEligibility(t.Context(), &botlinkv1.ChatEligibilityRequest{ExternalId: "x"})
if status.Code(err) != codes.Unavailable {
t.Fatalf("err = %v, want Unavailable", err)
}
}
func TestRelayServerNoBot(t *testing.T) {
hub, _ := startHub(t)
relay := NewRelayServer(hub, 200*time.Millisecond)
+1 -1
View File
@@ -94,7 +94,7 @@ func startMTLSHub(t *testing.T) (hub *Hub, addr, caFile, cliCert, cliKey string)
if err != nil {
t.Fatalf("listen: %v", err)
}
hub = NewHub(nil, nil)
hub = NewHub(nil, nil, nil)
srv := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsCfg)))
botlinkv1.RegisterBotLinkServer(srv, hub)
go func() { _ = srv.Serve(lis) }()
+219 -22
View File
@@ -224,6 +224,7 @@ type Command struct {
// *Command_Notify
// *Command_SendToUser
// *Command_SendToChannel
// *Command_ChatGate
Payload isCommand_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -300,6 +301,15 @@ func (x *Command) GetSendToChannel() *v1.SendToGameChannelRequest {
return nil
}
func (x *Command) GetChatGate() *ChatGateCommand {
if x != nil {
if x, ok := x.Payload.(*Command_ChatGate); ok {
return x.ChatGate
}
}
return nil
}
type isCommand_Payload interface {
isCommand_Payload()
}
@@ -316,12 +326,18 @@ type Command_SendToChannel struct {
SendToChannel *v1.SendToGameChannelRequest `protobuf:"bytes,4,opt,name=send_to_channel,json=sendToChannel,proto3,oneof"`
}
type Command_ChatGate struct {
ChatGate *ChatGateCommand `protobuf:"bytes,5,opt,name=chat_gate,json=chatGate,proto3,oneof"`
}
func (*Command_Notify) isCommand_Payload() {}
func (*Command_SendToUser) isCommand_Payload() {}
func (*Command_SendToChannel) isCommand_Payload() {}
func (*Command_ChatGate) isCommand_Payload() {}
// Ack reports the outcome of the Command with command_id. delivered mirrors the
// connector delivery semantics (false when the kind is not rendered out-of-app, the
// user never started the bot, or no channel is configured); error carries an
@@ -386,6 +402,166 @@ func (x *Ack) GetError() string {
return ""
}
// ChatGateCommand sets a Telegram user's write access in the moderated discussion
// chat. external_id is the user's Telegram identity (as in the backend identities
// table); allow grants the right to write when true and revokes it when false. The
// bot applies it only to a user currently in the chat — it guards on getChatMember,
// so a command for an absent user is a no-op. The gateway emits one whenever the
// user's eligibility may have changed: an admin block or unblock, a chat_muted
// grant or revoke, or a temporary block lapsing.
type ChatGateCommand struct {
state protoimpl.MessageState `protogen:"open.v1"`
ExternalId string `protobuf:"bytes,1,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"`
Allow bool `protobuf:"varint,2,opt,name=allow,proto3" json:"allow,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ChatGateCommand) Reset() {
*x = ChatGateCommand{}
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ChatGateCommand) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ChatGateCommand) ProtoMessage() {}
func (x *ChatGateCommand) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ChatGateCommand.ProtoReflect.Descriptor instead.
func (*ChatGateCommand) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{5}
}
func (x *ChatGateCommand) GetExternalId() string {
if x != nil {
return x.ExternalId
}
return ""
}
func (x *ChatGateCommand) GetAllow() bool {
if x != nil {
return x.Allow
}
return false
}
// ChatEligibilityRequest asks whether the Telegram user identified by external_id
// may write in the moderated discussion chat.
type ChatEligibilityRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ExternalId string `protobuf:"bytes,1,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ChatEligibilityRequest) Reset() {
*x = ChatEligibilityRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ChatEligibilityRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ChatEligibilityRequest) ProtoMessage() {}
func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ChatEligibilityRequest.ProtoReflect.Descriptor instead.
func (*ChatEligibilityRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{6}
}
func (x *ChatEligibilityRequest) GetExternalId() string {
if x != nil {
return x.ExternalId
}
return ""
}
// ChatEligibilityResponse is the eligibility answer. registered reports whether the
// external_id maps to an account at all; eligible is the final gate the bot acts on
// (registered and neither admin-suspended nor chat-muted).
type ChatEligibilityResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Registered bool `protobuf:"varint,1,opt,name=registered,proto3" json:"registered,omitempty"`
Eligible bool `protobuf:"varint,2,opt,name=eligible,proto3" json:"eligible,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ChatEligibilityResponse) Reset() {
*x = ChatEligibilityResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ChatEligibilityResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ChatEligibilityResponse) ProtoMessage() {}
func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ChatEligibilityResponse.ProtoReflect.Descriptor instead.
func (*ChatEligibilityResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{7}
}
func (x *ChatEligibilityResponse) GetRegistered() bool {
if x != nil {
return x.Registered
}
return false
}
func (x *ChatEligibilityResponse) GetEligible() bool {
if x != nil {
return x.Eligible
}
return false
}
var File_botlink_v1_botlink_proto protoreflect.FileDescriptor
const file_botlink_v1_botlink_proto_rawDesc = "" +
@@ -400,22 +576,36 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
"\x05Hello\x12\x1f\n" +
"\vinstance_id\x18\x01 \x01(\tR\n" +
"instanceId\x12!\n" +
"\fowns_updates\x18\x02 \x01(\bR\vownsUpdates\"\x99\x02\n" +
"\fowns_updates\x18\x02 \x01(\bR\vownsUpdates\"\xde\x02\n" +
"\aCommand\x12\x1d\n" +
"\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12=\n" +
"\x06notify\x18\x02 \x01(\v2#.scrabble.telegram.v1.NotifyRequestH\x00R\x06notify\x12K\n" +
"\fsend_to_user\x18\x03 \x01(\v2'.scrabble.telegram.v1.SendToUserRequestH\x00R\n" +
"sendToUser\x12X\n" +
"\x0fsend_to_channel\x18\x04 \x01(\v2..scrabble.telegram.v1.SendToGameChannelRequestH\x00R\rsendToChannelB\t\n" +
"\x0fsend_to_channel\x18\x04 \x01(\v2..scrabble.telegram.v1.SendToGameChannelRequestH\x00R\rsendToChannel\x12C\n" +
"\tchat_gate\x18\x05 \x01(\v2$.scrabble.botlink.v1.ChatGateCommandH\x00R\bchatGateB\t\n" +
"\apayload\"X\n" +
"\x03Ack\x12\x1d\n" +
"\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x1c\n" +
"\tdelivered\x18\x02 \x01(\bR\tdelivered\x12\x14\n" +
"\x05error\x18\x03 \x01(\tR\x05error2O\n" +
"\x05error\x18\x03 \x01(\tR\x05error\"H\n" +
"\x0fChatGateCommand\x12\x1f\n" +
"\vexternal_id\x18\x01 \x01(\tR\n" +
"externalId\x12\x14\n" +
"\x05allow\x18\x02 \x01(\bR\x05allow\"9\n" +
"\x16ChatEligibilityRequest\x12\x1f\n" +
"\vexternal_id\x18\x01 \x01(\tR\n" +
"externalId\"U\n" +
"\x17ChatEligibilityResponse\x12\x1e\n" +
"\n" +
"registered\x18\x01 \x01(\bR\n" +
"registered\x12\x1a\n" +
"\beligible\x18\x02 \x01(\bR\beligible2\xc4\x01\n" +
"\aBotLink\x12D\n" +
"\x04Link\x12\x1c.scrabble.botlink.v1.FromBot\x1a\x1a.scrabble.botlink.v1.ToBot(\x010\x01B)Z'scrabble/pkg/proto/botlink/v1;botlinkv1b\x06proto3"
"\x04Link\x12\x1c.scrabble.botlink.v1.FromBot\x1a\x1a.scrabble.botlink.v1.ToBot(\x010\x01\x12s\n" +
"\x16ResolveChatEligibility\x12+.scrabble.botlink.v1.ChatEligibilityRequest\x1a,.scrabble.botlink.v1.ChatEligibilityResponseB)Z'scrabble/pkg/proto/botlink/v1;botlinkv1b\x06proto3"
var (
file_botlink_v1_botlink_proto_rawDescOnce sync.Once
@@ -429,31 +619,37 @@ func file_botlink_v1_botlink_proto_rawDescGZIP() []byte {
return file_botlink_v1_botlink_proto_rawDescData
}
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
var file_botlink_v1_botlink_proto_goTypes = []any{
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
(*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot
(*Hello)(nil), // 2: scrabble.botlink.v1.Hello
(*Command)(nil), // 3: scrabble.botlink.v1.Command
(*Ack)(nil), // 4: scrabble.botlink.v1.Ack
(*v1.NotifyRequest)(nil), // 5: scrabble.telegram.v1.NotifyRequest
(*v1.SendToUserRequest)(nil), // 6: scrabble.telegram.v1.SendToUserRequest
(*v1.SendToGameChannelRequest)(nil), // 7: scrabble.telegram.v1.SendToGameChannelRequest
(*ChatGateCommand)(nil), // 5: scrabble.botlink.v1.ChatGateCommand
(*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest
(*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse
(*v1.NotifyRequest)(nil), // 8: scrabble.telegram.v1.NotifyRequest
(*v1.SendToUserRequest)(nil), // 9: scrabble.telegram.v1.SendToUserRequest
(*v1.SendToGameChannelRequest)(nil), // 10: scrabble.telegram.v1.SendToGameChannelRequest
}
var file_botlink_v1_botlink_proto_depIdxs = []int32{
2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
5, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
6, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
7, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
0, // 6: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
1, // 7: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
7, // [7:8] is the sub-list for method output_type
6, // [6:7] is the sub-list for method input_type
6, // [6:6] is the sub-list for extension type_name
6, // [6:6] is the sub-list for extension extendee
0, // [0:6] is the sub-list for field type_name
2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
8, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
9, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
10, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
5, // 6: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand
0, // 7: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
6, // 8: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
1, // 9: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
7, // 10: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
9, // [9:11] is the sub-list for method output_type
7, // [7:9] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
}
func init() { file_botlink_v1_botlink_proto_init() }
@@ -469,6 +665,7 @@ func file_botlink_v1_botlink_proto_init() {
(*Command_Notify)(nil),
(*Command_SendToUser)(nil),
(*Command_SendToChannel)(nil),
(*Command_ChatGate)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
@@ -476,7 +673,7 @@ func file_botlink_v1_botlink_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)),
NumEnums: 0,
NumMessages: 5,
NumMessages: 8,
NumExtensions: 0,
NumServices: 1,
},
+34
View File
@@ -20,6 +20,13 @@ service BotLink {
// Link opens the single bot <-> gateway stream. The first client message is
// Hello; thereafter the client sends one Ack per received Command.
rpc Link(stream FromBot) returns (stream ToBot);
// ResolveChatEligibility answers whether the Telegram user identified by
// external_id may write in the moderated discussion chat: registered with an
// account and neither admin-suspended nor chat-muted. The bot calls it over the
// same mTLS channel when a user joins the chat, to decide whether to grant the
// write permission. Delivery of the answer is request/response (not best-effort).
rpc ResolveChatEligibility(ChatEligibilityRequest) returns (ChatEligibilityResponse);
}
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
@@ -53,6 +60,7 @@ message Command {
scrabble.telegram.v1.NotifyRequest notify = 2;
scrabble.telegram.v1.SendToUserRequest send_to_user = 3;
scrabble.telegram.v1.SendToGameChannelRequest send_to_channel = 4;
ChatGateCommand chat_gate = 5;
}
}
@@ -65,3 +73,29 @@ message Ack {
bool delivered = 2;
string error = 3;
}
// ChatGateCommand sets a Telegram user's write access in the moderated discussion
// chat. external_id is the user's Telegram identity (as in the backend identities
// table); allow grants the right to write when true and revokes it when false. The
// bot applies it only to a user currently in the chat — it guards on getChatMember,
// so a command for an absent user is a no-op. The gateway emits one whenever the
// user's eligibility may have changed: an admin block or unblock, a chat_muted
// grant or revoke, or a temporary block lapsing.
message ChatGateCommand {
string external_id = 1;
bool allow = 2;
}
// ChatEligibilityRequest asks whether the Telegram user identified by external_id
// may write in the moderated discussion chat.
message ChatEligibilityRequest {
string external_id = 1;
}
// ChatEligibilityResponse is the eligibility answer. registered reports whether the
// external_id maps to an account at all; eligible is the final gate the bot acts on
// (registered and neither admin-suspended nor chat-muted).
message ChatEligibilityResponse {
bool registered = 1;
bool eligible = 2;
}
+51 -2
View File
@@ -26,7 +26,8 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
BotLink_Link_FullMethodName = "/scrabble.botlink.v1.BotLink/Link"
BotLink_Link_FullMethodName = "/scrabble.botlink.v1.BotLink/Link"
BotLink_ResolveChatEligibility_FullMethodName = "/scrabble.botlink.v1.BotLink/ResolveChatEligibility"
)
// BotLinkClient is the client API for BotLink service.
@@ -41,6 +42,12 @@ type BotLinkClient interface {
// Link opens the single bot <-> gateway stream. The first client message is
// Hello; thereafter the client sends one Ack per received Command.
Link(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[FromBot, ToBot], error)
// ResolveChatEligibility answers whether the Telegram user identified by
// external_id may write in the moderated discussion chat: registered with an
// account and neither admin-suspended nor chat-muted. The bot calls it over the
// same mTLS channel when a user joins the chat, to decide whether to grant the
// write permission. Delivery of the answer is request/response (not best-effort).
ResolveChatEligibility(ctx context.Context, in *ChatEligibilityRequest, opts ...grpc.CallOption) (*ChatEligibilityResponse, error)
}
type botLinkClient struct {
@@ -64,6 +71,16 @@ func (c *botLinkClient) Link(ctx context.Context, opts ...grpc.CallOption) (grpc
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type BotLink_LinkClient = grpc.BidiStreamingClient[FromBot, ToBot]
func (c *botLinkClient) ResolveChatEligibility(ctx context.Context, in *ChatEligibilityRequest, opts ...grpc.CallOption) (*ChatEligibilityResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ChatEligibilityResponse)
err := c.cc.Invoke(ctx, BotLink_ResolveChatEligibility_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// BotLinkServer is the server API for BotLink service.
// All implementations must embed UnimplementedBotLinkServer
// for forward compatibility.
@@ -76,6 +93,12 @@ type BotLinkServer interface {
// Link opens the single bot <-> gateway stream. The first client message is
// Hello; thereafter the client sends one Ack per received Command.
Link(grpc.BidiStreamingServer[FromBot, ToBot]) error
// ResolveChatEligibility answers whether the Telegram user identified by
// external_id may write in the moderated discussion chat: registered with an
// account and neither admin-suspended nor chat-muted. The bot calls it over the
// same mTLS channel when a user joins the chat, to decide whether to grant the
// write permission. Delivery of the answer is request/response (not best-effort).
ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error)
mustEmbedUnimplementedBotLinkServer()
}
@@ -89,6 +112,9 @@ type UnimplementedBotLinkServer struct{}
func (UnimplementedBotLinkServer) Link(grpc.BidiStreamingServer[FromBot, ToBot]) error {
return status.Errorf(codes.Unimplemented, "method Link not implemented")
}
func (UnimplementedBotLinkServer) ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ResolveChatEligibility not implemented")
}
func (UnimplementedBotLinkServer) mustEmbedUnimplementedBotLinkServer() {}
func (UnimplementedBotLinkServer) testEmbeddedByValue() {}
@@ -117,13 +143,36 @@ func _BotLink_Link_Handler(srv interface{}, stream grpc.ServerStream) error {
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type BotLink_LinkServer = grpc.BidiStreamingServer[FromBot, ToBot]
func _BotLink_ResolveChatEligibility_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ChatEligibilityRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BotLinkServer).ResolveChatEligibility(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BotLink_ResolveChatEligibility_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BotLinkServer).ResolveChatEligibility(ctx, req.(*ChatEligibilityRequest))
}
return interceptor(ctx, in, info, handler)
}
// BotLink_ServiceDesc is the grpc.ServiceDesc for BotLink service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var BotLink_ServiceDesc = grpc.ServiceDesc{
ServiceName: "scrabble.botlink.v1.BotLink",
HandlerType: (*BotLinkServer)(nil),
Methods: []grpc.MethodDesc{},
Methods: []grpc.MethodDesc{
{
MethodName: "ResolveChatEligibility",
Handler: _BotLink_ResolveChatEligibility_Handler,
},
},
Streams: []grpc.StreamDesc{
{
StreamName: "Link",
+28 -1
View File
@@ -42,6 +42,27 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
launch button; a deep-link payload routes the launch to a game / invitation / friend
code. This is **self-contained** — the bot never calls back into the game, so `/start`
onboarding works even when the game is down.
- **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion
group, the bot gates who may write there. The group **allows sending by default** (a
human setting) and the bot only **restricts** — Telegram intersects the chat default with
each user's permission, so a per-user grant cannot exceed a deny-by-default group, and the
gate must mute the ineligible rather than grant the eligible. On a `chat_member` event the
bot asks the gateway (`ResolveChatEligibility`) and **mutes** a member who is not registered
or is admin-suspended or `chat_muted`, **un-mutes** an eligible one it had muted, and leaves
an already-allowed eligible member untouched (it acts only when the state differs, so it is
idempotent and skips its own change). When an operator blocks/unblocks an account, toggles
its `chat_muted` role, or a user first registers, the gateway pushes a `ChatGate` command and
the bot applies it — but only to a member currently in the chat (it probes one user with
`getChatMember`, since bots cannot list members). The bot must be an **administrator** there
with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to
`chat_member` updates, which Telegram delivers only to a chat admin.
- **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also
runs a **second, standalone** bot whose only job is to answer `/start` with a localized
message and a button that opens the **main** bot's Mini App. The button is a **URL** to
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
would sign the initData), which the main bot's validator rejects. It is fully
self-contained: no bot-link, no gateway, no game.
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
default 25) to respect the Bot API flood limits.
@@ -57,7 +78,9 @@ parsing is Telegram-specific.
gateway also implements `SendToUser` / `SendToGameChannel` as the backend's admin
relay.
- `pkg/proto/botlink/v1`, service `BotLink` — the reverse bidi stream the **bot** dials
on the gateway (`Hello` / `Command` / `Ack`). Generated Go is committed under `pkg`.
on the gateway (`Hello` / `Command` / `Ack`), now also carrying a `ChatGateCommand` (set
a user's chat write access) and a unary `ResolveChatEligibility` (the bot's join-time
query) over the same mTLS channel. Generated Go is committed under `pkg`.
## Deep-link scheme
@@ -101,6 +124,10 @@ Bot (`cmd/bot`):
| `TELEGRAM_BOTLINK_SERVER_NAME` | — (required) | the gateway certificate's expected SNI / CN |
| `TELEGRAM_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | — (required) | the bot client cert, its key, and the CA that signs the gateway server cert |
| `TELEGRAM_GAME_CHANNEL_ID` | — | the bot's game channel chat id for `SendToGameChannel` |
| `TELEGRAM_CHAT_ID` | — | the moderated discussion chat id (a channel's linked group); empty disables chat gating |
| `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_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_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_INSTANCE_ID` | hostname | bot identity reported to the gateway |
+42 -2
View File
@@ -22,6 +22,7 @@ import (
"scrabble/platform/telegram/internal/bot"
"scrabble/platform/telegram/internal/botlink"
"scrabble/platform/telegram/internal/config"
"scrabble/platform/telegram/internal/promobot"
)
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
@@ -70,6 +71,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
TestEnv: cfg.TestEnv,
MiniAppURL: cfg.MiniAppURL,
SendRatePerSecond: cfg.SendRatePerSecond,
ChatID: cfg.ChatID,
}, logger)
if err != nil {
return err
@@ -80,19 +82,52 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
return err
}
exec := botlink.NewExecutor(b, cfg.GameChannelID, logger)
client := botlink.NewClient(botlink.ClientConfig{
client, err := botlink.NewClient(botlink.ClientConfig{
GatewayAddr: cfg.BotLink.GatewayAddr,
InstanceID: cfg.BotLink.InstanceID,
OwnsUpdates: cfg.OwnsUpdates,
Creds: credentials.NewTLS(tlsCfg),
ReconnectDelay: cfg.BotLink.ReconnectDelay,
}, exec, logger)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
// The chat-join eligibility query rides the same bot-link connection; wire it into
// the bot after the client is built — the late binding that breaks the bot <->
// client construction cycle.
b.SetEligibilityResolver(client.ResolveChatEligibility)
// The optional standalone promo bot: a second bot (its own token) that only answers
// /start with a button opening the main bot's Mini App. It is self-contained — no
// bot-link, no gateway — so onboarding works even when the game is down.
var promo *promobot.Bot
if cfg.PromoBotToken != "" {
// The promo bot is auxiliary and shares this process with the main bot, so its
// construction failure (a bad or unreachable promo token — tgbot.New validates it
// with getMe) is logged and the promo bot is skipped, never fatal: it must not
// take the main game bot down with it.
promo, err = promobot.New(promobot.Config{
Token: cfg.PromoBotToken,
APIBaseURL: cfg.APIBaseURL,
TestEnv: cfg.TestEnv,
BotUsername: cfg.BotUsername,
BotLinkURL: cfg.BotLinkURL,
SendRatePerSecond: cfg.SendRatePerSecond,
}, logger)
if err != nil {
logger.Error("promo bot disabled: construction failed (the main bot is unaffected)", zap.Error(err))
promo = nil
}
}
logger.Info("telegram bot starting",
zap.String("gateway", cfg.BotLink.GatewayAddr),
zap.String("miniapp_url", cfg.MiniAppURL),
zap.Bool("owns_updates", cfg.OwnsUpdates),
zap.Bool("test_env", cfg.TestEnv))
zap.Bool("test_env", cfg.TestEnv),
zap.Bool("chat_gating", cfg.ChatID != 0),
zap.Bool("promo_bot", promo != nil))
var wg sync.WaitGroup
// The long-poll holds the exclusive getUpdates lease (one bot per token); a bot
@@ -105,6 +140,11 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
logger.Error("bot-link client stopped", zap.Error(err))
}
})
// The promo bot runs its own getUpdates long-poll on its own token (no 409 with
// the main bot's lease).
if promo != nil {
wg.Go(func() { promo.Run(ctx) })
}
<-ctx.Done()
wg.Wait()
+249 -2
View File
@@ -8,6 +8,7 @@ package bot
import (
"context"
"net/url"
"strconv"
"strings"
tgbot "github.com/go-telegram/bot"
@@ -30,8 +31,19 @@ type Config struct {
// Telegram Bot API flood limits; 0 disables the limiter. The burst equals the
// per-second rate.
SendRatePerSecond int
// ChatID is the moderated discussion chat the bot gates write access in; 0
// disables chat gating (and the chat_member long-poll subscription). Gating needs
// the bot to be an administrator there with the restrict-members right.
ChatID int64
}
// EligibilityResolver answers whether the Telegram user identified by externalID
// (the decimal user id) may write in the moderated chat: registered and neither
// admin-suspended nor chat-muted. The bot calls it when a user joins the chat. It is
// late-bound (SetEligibilityResolver) because it is backed by the bot-link client,
// which is built after the bot.
type EligibilityResolver func(ctx context.Context, externalID string) (eligible bool, err error)
// Bot wraps a Telegram Bot API client and the Mini App launch URL.
type Bot struct {
api *tgbot.Bot
@@ -40,6 +52,14 @@ type Bot struct {
// limiter throttles outbound sends to stay under the Bot API flood limits; nil
// disables throttling.
limiter *rate.Limiter
// chatID is the moderated discussion chat (0 disables gating).
chatID int64
// botID is the bot's own Telegram user id (resolved at startup); it skips the
// chat_member updates the bot's own restrict actions generate — the grant loop guard.
botID int64
// eligibility resolves a joining user's chat write eligibility; nil leaves a
// joiner muted (fail-closed) until it is wired.
eligibility EligibilityResolver
}
// New builds the bot wrapper, registering the /start handler and a default handler
@@ -49,15 +69,25 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if log == nil {
log = zap.NewNop()
}
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log}
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID}
if cfg.SendRatePerSecond > 0 {
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
}
opts := []tgbot.Option{
tgbot.WithDefaultHandler(t.handleStart),
tgbot.WithDefaultHandler(t.handleUpdate),
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
}
if cfg.ChatID != 0 {
// chat_member updates are off by default; subscribe explicitly (alongside
// messages) so the bot sees joins in the moderated chat. The bot must also be an
// administrator there for Telegram to deliver them.
opts = append(opts, tgbot.WithAllowedUpdates(tgbot.AllowedUpdates{
models.AllowedUpdateMessage,
models.AllowedUpdateMyChatMember,
models.AllowedUpdateChatMember,
}))
}
if cfg.TestEnv {
// Route to the Bot API test environment (.../bot<token>/test/METHOD).
opts = append(opts, tgbot.UseTestEnvironment())
@@ -90,9 +120,39 @@ func (t *Bot) Run(ctx context.Context) {
}); err != nil {
t.log.Warn("set menu button failed", zap.Error(err))
}
if t.chatID != 0 {
t.logChatAdminStatus(ctx)
}
t.api.Start(ctx)
}
// logChatAdminStatus checks, at startup, whether the bot can actually gate the
// moderated chat — it must be an administrator there with the restrict-members
// ("Ban users") right, or Telegram delivers no chat_member updates and restricts
// fail. It logs a prominent warning when the prerequisite is missing (the common
// misconfiguration), so the cause is visible without reproducing a join.
func (t *Bot) logChatAdminStatus(ctx context.Context) {
me, err := t.api.GetMe(ctx)
if err != nil {
t.log.Warn("chat self-check: getMe failed", zap.Error(err))
return
}
t.botID = me.ID
m, err := t.api.GetChatMember(ctx, &tgbot.GetChatMemberParams{ChatID: t.chatID, UserID: me.ID})
if err != nil {
t.log.Warn("chat gating self-check failed: the bot cannot read the chat — is it added and is TELEGRAM_CHAT_ID the discussion group id?",
zap.Int64("chat_id", t.chatID), zap.Error(err))
return
}
canRestrict := m.Type == models.ChatMemberTypeAdministrator && m.Administrator.CanRestrictMembers
if !canRestrict {
t.log.Warn(`chat gating WILL NOT WORK: the bot must be an administrator with the restrict-members ("Ban users") right`,
zap.Int64("chat_id", t.chatID), zap.String("bot_status", string(m.Type)))
return
}
t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID))
}
// Notify sends a notification message with a Mini App launch button that opens the
// app at startParam (empty opens the lobby).
func (t *Bot) Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error {
@@ -176,3 +236,190 @@ func startPayload(text string) string {
}
return strings.TrimSpace(strings.TrimPrefix(text, cmd))
}
// handleUpdate is the default-handler dispatcher: a chat-member change in the
// moderated chat drives the write-access gate; anything else is treated as a message
// and gets the Mini App launch reply.
func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.Update) {
if update.ChatMember != nil {
t.handleChatMember(ctx, update.ChatMember)
return
}
t.handleStart(ctx, api, update)
}
// SetEligibilityResolver wires the chat-eligibility resolver after construction (the
// bot-link client backing it is built after the bot).
func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) {
t.eligibility = resolve
}
// handleChatMember keeps a chat member's write access in sync with their eligibility.
// The chat allows sending by default, so the bot mutes an ineligible member (not
// registered, or admin-suspended, or chat_muted) and restores an eligible one it had
// muted; an eligible member that can already send is left untouched. It acts only when
// the current state differs from the desired one, so it is idempotent and does not
// re-act on its own change; a resolve failure makes no change.
func (t *Bot) handleChatMember(ctx context.Context, cm *models.ChatMemberUpdated) {
user := chatMemberUser(cm.NewChatMember)
var uid int64
if user != nil {
uid = user.ID
}
// Log every chat_member update the bot receives: the one place to see whether
// Telegram delivers joins, for which chat, the transition, who performed it, and the
// new member's send/membership state.
canSend, isMember := restrictedSendState(cm.NewChatMember)
t.log.Debug("chat_member update",
zap.Int64("chat_id", cm.Chat.ID),
zap.Int64("configured_chat_id", t.chatID),
zap.Int64("user_id", uid),
zap.Int64("actor_id", cm.From.ID),
zap.String("old_status", string(cm.OldChatMember.Type)),
zap.String("new_status", string(cm.NewChatMember.Type)),
zap.Bool("new_can_send", canSend),
zap.Bool("new_is_member", isMember))
if t.chatID == 0 || cm.Chat.ID != t.chatID {
return
}
if user == nil || user.IsBot {
return
}
// Loop guard: the bot's own restrict re-fires a chat_member update whose performer is
// the bot; skip those so a grant never re-triggers itself.
if t.botID != 0 && cm.From.ID == t.botID {
return
}
// The chat allows sending by default and the bot only restricts: Telegram intersects
// the chat default with the per-user permission, so a per-user grant cannot exceed a
// deny-by-default — the gate must mute the ineligible, not grant the eligible.
// Determine whether the user is in the chat and can currently send: a plain member
// follows the permissive default; a restricted member can send only with
// CanSendMessages, and only while a member.
var inChat, currentlyCanSend bool
switch cm.NewChatMember.Type {
case models.ChatMemberTypeMember:
inChat, currentlyCanSend = true, true
case models.ChatMemberTypeRestricted:
inChat, currentlyCanSend = isMember, canSend
default:
return // left / kicked / administrator / owner — not a member to gate
}
if !inChat {
return
}
if t.eligibility == nil {
t.log.Warn("chat access: eligibility resolver not wired", zap.Int64("user_id", uid))
return
}
eligible, err := t.eligibility(ctx, strconv.FormatInt(user.ID, 10))
if err != nil {
t.log.Warn("chat access eligibility failed", zap.Int64("user_id", user.ID), zap.Error(err))
return
}
t.log.Debug("chat access evaluated",
zap.Int64("user_id", user.ID), zap.Bool("eligible", eligible), zap.Bool("can_send", currentlyCanSend))
// Desired: an eligible user may send, an ineligible one may not. Act only when the
// current state differs — idempotent, a no-op for the common eligible member, and it
// keeps the bot from re-acting on its own change.
if eligible == currentlyCanSend {
return
}
if err := t.setChatWrite(ctx, user.ID, eligible); err != nil {
t.log.Warn("set chat write failed",
zap.Int64("user_id", user.ID), zap.Bool("can_send", eligible), zap.Error(err))
return
}
t.log.Info("chat access applied", zap.Int64("user_id", user.ID), zap.Bool("can_send", eligible))
}
// ApplyChatGate applies a chat-gate command (an admin block/unblock or chat_muted
// change relayed by the gateway): it sets the user's write access, but only when they
// are currently in the chat. Bots cannot list members, so it probes the single user
// with getChatMember and is a no-op when they are absent (left/kicked) or an
// administrator (who cannot be restricted). It reports whether a restriction was
// applied.
func (t *Bot) ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error) {
if t.chatID == 0 {
return false, nil
}
member, err := t.api.GetChatMember(ctx, &tgbot.GetChatMemberParams{ChatID: t.chatID, UserID: userID})
if err != nil {
return false, err
}
switch member.Type {
case models.ChatMemberTypeMember, models.ChatMemberTypeRestricted:
if err := t.setChatWrite(ctx, userID, allow); err != nil {
return false, err
}
t.log.Info("chat gate applied", zap.Int64("user_id", userID), zap.Bool("allow", allow))
return true, nil
default:
t.log.Debug("chat gate: user not in chat, skipped", zap.Int64("user_id", userID), zap.String("status", string(member.Type)))
return false, nil // absent, or an admin/owner who cannot be restricted
}
}
// setChatWrite restricts the user in the moderated chat to either the full send
// permission set (allow) or none (mute); the non-send permissions stay at their
// default-deny either way.
func (t *Bot) setChatWrite(ctx context.Context, userID int64, allow bool) error {
perms := models.ChatPermissions{}
if allow {
perms = chatWritePerms()
}
_, err := t.api.RestrictChatMember(ctx, &tgbot.RestrictChatMemberParams{
ChatID: t.chatID,
UserID: userID,
Permissions: &perms,
})
return err
}
// chatWritePerms grants a member the ability to send every kind of message; the
// non-send permissions stay denied.
func chatWritePerms() models.ChatPermissions {
return models.ChatPermissions{
CanSendMessages: true,
CanSendAudios: true,
CanSendDocuments: true,
CanSendPhotos: true,
CanSendVideos: true,
CanSendVideoNotes: true,
CanSendVoiceNotes: true,
CanSendPolls: true,
CanSendOtherMessages: true,
CanAddWebPagePreviews: true,
}
}
// restrictedSendState returns a restricted member's text-send permission and whether
// they are currently a member of the chat; (false, false) for any non-restricted
// status (the fields exist only on the restricted variant).
func restrictedSendState(m models.ChatMember) (canSend, isMember bool) {
if m.Type == models.ChatMemberTypeRestricted && m.Restricted != nil {
return m.Restricted.CanSendMessages, m.Restricted.IsMember
}
return false, false
}
// chatMemberUser returns the user a ChatMember refers to across the union variants,
// or nil for an unrecognised type.
func chatMemberUser(m models.ChatMember) *models.User {
switch m.Type {
case models.ChatMemberTypeOwner:
return m.Owner.User
case models.ChatMemberTypeAdministrator:
return &m.Administrator.User
case models.ChatMemberTypeMember:
return m.Member.User
case models.ChatMemberTypeRestricted:
return m.Restricted.User
case models.ChatMemberTypeLeft:
return m.Left.User
case models.ChatMemberTypeBanned:
return m.Banned.User
}
return nil
}
+239
View File
@@ -0,0 +1,239 @@
package bot
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
)
const (
testChatID = 555
botSelfID = 111111 // the bot's own id in tests (for the loop guard)
)
// chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a
// scripted getChatMember status, and records restrictChatMember calls.
type chatAPI struct {
memberStatus string // the status getChatMember reports (default "left")
restricts []restrictCall
}
type restrictCall struct {
userID string
canSend bool // can_send_messages in the applied permissions
}
func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/getMe"):
io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"t","username":"tb"}}`)
case strings.HasSuffix(r.URL.Path, "/getChatMember"):
status := a.memberStatus
if status == "" {
status = "left"
}
io.WriteString(w, `{"ok":true,"result":{"status":"`+status+`","user":{"id":`+r.FormValue("user_id")+`,"is_bot":false,"first_name":"u"}}}`)
case strings.HasSuffix(r.URL.Path, "/restrictChatMember"):
var perms struct {
CanSendMessages bool `json:"can_send_messages"`
}
_ = json.Unmarshal([]byte(r.FormValue("permissions")), &perms)
a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages})
io.WriteString(w, `{"ok":true,"result":true}`)
default:
io.WriteString(w, `{"ok":true,"result":true}`)
}
}
// newChatBot builds a gating bot (ChatID set) over the fake API, without an
// eligibility resolver — each test wires the one it needs.
func newChatBot(t *testing.T, api *chatAPI) *Bot {
t.Helper()
srv := httptest.NewServer(api)
t.Cleanup(srv.Close)
b, err := New(Config{Token: "123:ABC", APIBaseURL: srv.URL, MiniAppURL: "https://example.com/", ChatID: testChatID}, zap.NewNop())
if err != nil {
t.Fatalf("new bot: %v", err)
}
b.botID = botSelfID // normally set at startup; the chat_member updates default actor 0 != this
return b
}
// memberUpdate builds an oldType -> member transition for userID in chatID.
func memberUpdate(chatID, userID int64, oldType models.ChatMemberType) *models.ChatMemberUpdated {
return &models.ChatMemberUpdated{
Chat: models.Chat{ID: chatID},
OldChatMember: models.ChatMember{Type: oldType, Left: &models.ChatMemberLeft{User: &models.User{ID: userID}}},
NewChatMember: models.ChatMember{Type: models.ChatMemberTypeMember, Member: &models.ChatMemberMember{User: &models.User{ID: userID}}},
}
}
// restrictedUpdate builds an oldType -> restricted transition for userID, the new
// member's text-send permission set to canSend and membership to isMember. A muted
// member is restricted with canSend=false; an un-muted one with canSend=true.
func restrictedUpdate(chatID, userID int64, oldType models.ChatMemberType, canSend, isMember bool) *models.ChatMemberUpdated {
return &models.ChatMemberUpdated{
Chat: models.Chat{ID: chatID},
OldChatMember: models.ChatMember{Type: oldType, Left: &models.ChatMemberLeft{User: &models.User{ID: userID}}},
NewChatMember: models.ChatMember{Type: models.ChatMemberTypeRestricted, Restricted: &models.ChatMemberRestricted{User: &models.User{ID: userID}, CanSendMessages: canSend, IsMember: isMember}},
}
}
// leftUpdate builds a transition to left (a leave) for userID.
func leftUpdate(chatID, userID int64) *models.ChatMemberUpdated {
return &models.ChatMemberUpdated{
Chat: models.Chat{ID: chatID},
OldChatMember: models.ChatMember{Type: models.ChatMemberTypeRestricted, Restricted: &models.ChatMemberRestricted{User: &models.User{ID: userID}}},
NewChatMember: models.ChatMember{Type: models.ChatMemberTypeLeft, Left: &models.ChatMemberLeft{User: &models.User{ID: userID}}},
}
}
// eligibleBot builds a gating bot whose resolver returns (eligible, err).
func eligibleBot(t *testing.T, api *chatAPI, eligible bool, err error) *Bot {
t.Helper()
b := newChatBot(t, api)
b.SetEligibilityResolver(func(context.Context, string) (bool, error) { return eligible, err })
return b
}
func TestHandleChatMemberMutesIneligibleMember(t *testing.T) {
// An unregistered/blocked member can send by the permissive default, so the bot mutes.
api := &chatAPI{}
b := eligibleBot(t, api, false, nil)
b.handleChatMember(context.Background(), memberUpdate(testChatID, 777, models.ChatMemberTypeLeft))
if len(api.restricts) != 1 || api.restricts[0].userID != "777" || api.restricts[0].canSend {
t.Fatalf("restricts = %+v, want one mute (can_send=false) for 777", api.restricts)
}
}
func TestHandleChatMemberLeavesEligibleMemberAlone(t *testing.T) {
// An eligible plain member already sends (the permissive default); no action needed.
api := &chatAPI{}
b := eligibleBot(t, api, true, nil)
b.handleChatMember(context.Background(), memberUpdate(testChatID, 777, models.ChatMemberTypeLeft))
if len(api.restricts) != 0 {
t.Fatalf("restricts = %+v, want none for an eligible member (already allowed)", api.restricts)
}
}
func TestHandleChatMemberUnmutesEligibleRestricted(t *testing.T) {
// An eligible member the bot had muted (restricted, can_send=false) is restored.
api := &chatAPI{}
b := eligibleBot(t, api, true, nil)
b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, false, true))
if len(api.restricts) != 1 || !api.restricts[0].canSend {
t.Fatalf("restricts = %+v, want one un-mute (can_send=true)", api.restricts)
}
}
func TestHandleChatMemberLeavesEligibleAllowedRestrictedAlone(t *testing.T) {
// An eligible restricted member who can already send needs no change — the real case
// from the contour (restricted, can_send=true, eligible).
api := &chatAPI{}
b := eligibleBot(t, api, true, nil)
b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, true, true))
if len(api.restricts) != 0 {
t.Fatalf("restricts = %+v, want none for an eligible already-allowed member", api.restricts)
}
}
func TestHandleChatMemberMutesIneligibleRestricted(t *testing.T) {
// An ineligible member who can still send is muted.
api := &chatAPI{}
b := eligibleBot(t, api, false, nil)
b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, true, true))
if len(api.restricts) != 1 || api.restricts[0].canSend {
t.Fatalf("restricts = %+v, want one mute (can_send=false)", api.restricts)
}
}
func TestHandleChatMemberSkipsNonMember(t *testing.T) {
// A restricted record for a user no longer in the chat (is_member=false) is not acted on.
api := &chatAPI{}
b := eligibleBot(t, api, false, nil)
b.handleChatMember(context.Background(), restrictedUpdate(testChatID, 777, models.ChatMemberTypeRestricted, true, false))
if len(api.restricts) != 0 {
t.Fatalf("restricts = %+v, want none for a non-member", api.restricts)
}
}
func TestHandleChatMemberSkipsBotsOwnAction(t *testing.T) {
// The bot's own restrict re-fires a chat_member update performed by the bot; skip it
// so an action never loops (the resolver here would otherwise mute).
api := &chatAPI{}
b := eligibleBot(t, api, false, nil)
upd := memberUpdate(testChatID, 777, models.ChatMemberTypeLeft)
upd.From = models.User{ID: botSelfID}
b.handleChatMember(context.Background(), upd)
if len(api.restricts) != 0 {
t.Fatalf("restricts = %+v, want none for the bot's own action (no loop)", api.restricts)
}
}
func TestHandleChatMemberNoChangeOnResolveError(t *testing.T) {
api := &chatAPI{}
b := eligibleBot(t, api, false, context.DeadlineExceeded)
b.handleChatMember(context.Background(), memberUpdate(testChatID, 777, models.ChatMemberTypeLeft))
if len(api.restricts) != 0 {
t.Fatalf("a resolve error still changed access: %+v (want no change)", api.restricts)
}
}
func TestHandleChatMemberIgnoresOtherChatAndLeaves(t *testing.T) {
api := &chatAPI{}
b := eligibleBot(t, api, false, nil) // ineligible — would mute if it acted
ctx := context.Background()
b.handleChatMember(ctx, memberUpdate(999, 777, models.ChatMemberTypeLeft)) // foreign chat
b.handleChatMember(ctx, leftUpdate(testChatID, 777)) // a leave
if len(api.restricts) != 0 {
t.Fatalf("restricts = %+v, want none for a foreign chat / a leave", api.restricts)
}
}
func TestApplyChatGatePresentMember(t *testing.T) {
for _, tc := range []struct {
name string
allow bool
}{{"grant", true}, {"mute", false}} {
t.Run(tc.name, func(t *testing.T) {
api := &chatAPI{memberStatus: "member"}
b := newChatBot(t, api)
applied, err := b.ApplyChatGate(context.Background(), 777, tc.allow)
if err != nil {
t.Fatalf("apply: %v", err)
}
if !applied {
t.Fatal("applied = false, want true for a present member")
}
if len(api.restricts) != 1 || api.restricts[0].canSend != tc.allow {
t.Fatalf("restricts = %+v, want one with can_send=%v", api.restricts, tc.allow)
}
})
}
}
func TestApplyChatGateSkipsAbsentAndAdmin(t *testing.T) {
for _, status := range []string{"left", "kicked", "administrator", "creator"} {
t.Run(status, func(t *testing.T) {
api := &chatAPI{memberStatus: status}
b := newChatBot(t, api)
applied, err := b.ApplyChatGate(context.Background(), 777, false)
if err != nil {
t.Fatalf("apply: %v", err)
}
if applied {
t.Errorf("applied = true for status %q, want a no-op", status)
}
if len(api.restricts) != 0 {
t.Errorf("restricts = %+v for status %q, want none", api.restricts, status)
}
})
}
}
+34 -19
View File
@@ -37,27 +37,25 @@ type ClientConfig struct {
}
// Client maintains the long-lived bot-link to the gateway, executing the commands
// it receives and re-dialing after any break.
// it receives and re-dialing after any break. The same mTLS connection also serves
// the unary chat-eligibility query the bot makes on a chat join.
type Client struct {
cfg ClientConfig
exec *Executor
log *zap.Logger
cfg ClientConfig
exec *Executor
log *zap.Logger
conn *grpc.ClientConn
client botlinkv1.BotLinkClient
}
// NewClient builds the bot-link client over the executor.
func NewClient(cfg ClientConfig, exec *Executor, log *zap.Logger) *Client {
// NewClient builds the bot-link client over the executor, dialing the gateway. The
// gRPC connection is lazy, so the dial does not block on the gateway being up; the
// caller must Close it. The bot-link command stream is opened by Run.
func NewClient(cfg ClientConfig, exec *Executor, log *zap.Logger) (*Client, error) {
if log == nil {
log = zap.NewNop()
}
return &Client{cfg: cfg, exec: exec, log: log}
}
// Run dials the gateway and keeps the bot-link open, re-dialing after each break,
// until ctx is cancelled. The gRPC connection auto-reconnects the transport; this
// loop re-opens the Link stream on top of it.
func (c *Client) Run(ctx context.Context) error {
conn, err := grpc.NewClient(c.cfg.GatewayAddr,
grpc.WithTransportCredentials(c.cfg.Creds),
conn, err := grpc.NewClient(cfg.GatewayAddr,
grpc.WithTransportCredentials(cfg.Creds),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: clientKeepaliveTime,
@@ -66,13 +64,30 @@ func (c *Client) Run(ctx context.Context) error {
}),
)
if err != nil {
return err
return nil, err
}
defer func() { _ = conn.Close() }()
client := botlinkv1.NewBotLinkClient(conn)
return &Client{cfg: cfg, exec: exec, log: log, conn: conn, client: botlinkv1.NewBotLinkClient(conn)}, nil
}
// Close releases the bot-link connection.
func (c *Client) Close() error { return c.conn.Close() }
// ResolveChatEligibility asks the gateway whether the Telegram user identified by
// externalID may write in the moderated chat. The bot calls it on a chat join, over
// the same mTLS connection as the command stream.
func (c *Client) ResolveChatEligibility(ctx context.Context, externalID string) (bool, error) {
resp, err := c.client.ResolveChatEligibility(ctx, &botlinkv1.ChatEligibilityRequest{ExternalId: externalID})
if err != nil {
return false, err
}
return resp.GetEligible(), nil
}
// Run keeps the bot-link command stream open, re-opening it after each break, until
// ctx is cancelled. The gRPC connection auto-reconnects the transport underneath.
func (c *Client) Run(ctx context.Context) error {
for ctx.Err() == nil {
if err := c.serve(ctx, client); err != nil && ctx.Err() == nil {
if err := c.serve(ctx, c.client); err != nil && ctx.Err() == nil {
c.log.Warn("bot-link stream ended", zap.Error(err))
}
if !sleep(ctx, c.cfg.ReconnectDelay) {
@@ -63,12 +63,16 @@ func TestClientServesCommands(t *testing.T) {
t.Cleanup(srv.Stop)
sender := &fakeSender{}
client := NewClient(ClientConfig{
client, err := NewClient(ClientConfig{
GatewayAddr: lis.Addr().String(),
InstanceID: "test",
Creds: insecure.NewCredentials(),
ReconnectDelay: 50 * time.Millisecond,
}, NewExecutor(sender, 0, nil), nil)
if err != nil {
t.Fatalf("new client: %v", err)
}
t.Cleanup(func() { _ = client.Close() })
go func() { _ = client.Run(t.Context()) }()
@@ -23,6 +23,10 @@ type Sender interface {
Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error
// SendText sends a plain text message to chatID.
SendText(ctx context.Context, chatID int64, text string) error
// ApplyChatGate sets the Telegram user's write access in the moderated discussion
// chat, but only when they are currently in it; it reports whether a restriction
// was applied.
ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error)
}
// Executor turns a bot-link Command into a Bot API send. The delivered flag mirrors
@@ -53,11 +57,30 @@ func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, er
return e.sendToUser(ctx, p.SendToUser)
case *botlinkv1.Command_SendToChannel:
return e.sendToChannel(ctx, p.SendToChannel)
case *botlinkv1.Command_ChatGate:
return e.chatGate(ctx, p.ChatGate)
default:
return false, fmt.Errorf("botlink: empty command")
}
}
// chatGate applies a chat-gate command: it parses the target Telegram user id and
// sets their write access in the moderated chat (a no-op when they are not in it). A
// Bot API failure is logged and reported as not-delivered, not a hard error.
func (e *Executor) chatGate(ctx context.Context, req *botlinkv1.ChatGateCommand) (bool, error) {
userID, err := parseChatID(req.GetExternalId())
if err != nil {
return false, err
}
applied, err := e.sender.ApplyChatGate(ctx, userID, req.GetAllow())
if err != nil {
e.log.Warn("chat gate apply failed",
zap.String("external_id", req.GetExternalId()), zap.Bool("allow", req.GetAllow()), zap.Error(err))
return false, nil
}
return applied, nil
}
// notify renders an out-of-app push and sends it with a Mini App launch button.
func (e *Executor) notify(ctx context.Context, req *telegramv1.NotifyRequest) (bool, error) {
msg, ok := render.Render(req.GetKind(), req.GetPayload(), req.GetLanguage())
@@ -13,9 +13,11 @@ import (
// fakeSender records the delivery calls the executor makes.
type fakeSender struct {
notify []notifyCall
text []textCall
err error
notify []notifyCall
text []textCall
gate []gateCall
applied bool // ApplyChatGate's reported result
err error
}
type notifyCall struct {
@@ -26,6 +28,10 @@ type textCall struct {
chatID int64
text string
}
type gateCall struct {
userID int64
allow bool
}
func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error {
f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam})
@@ -37,6 +43,11 @@ func (f *fakeSender) SendText(_ context.Context, chatID int64, text string) erro
return f.err
}
func (f *fakeSender) ApplyChatGate(_ context.Context, userID int64, allow bool) (bool, error) {
f.gate = append(f.gate, gateCall{userID, allow})
return f.applied, f.err
}
func yourTurnPayload(gameID string) []byte {
b := flatbuffers.NewBuilder(0)
gid := b.CreateString(gameID)
@@ -106,6 +117,46 @@ func TestExecutorSendToUser(t *testing.T) {
}
}
func chatGateCmd(externalID string, allow bool) *botlinkv1.Command {
return &botlinkv1.Command{Payload: &botlinkv1.Command_ChatGate{ChatGate: &botlinkv1.ChatGateCommand{
ExternalId: externalID, Allow: allow,
}}}
}
func TestExecutorChatGateApplied(t *testing.T) {
sender := &fakeSender{applied: true}
exec := NewExecutor(sender, 0, nil)
delivered, err := exec.Handle(context.Background(), chatGateCmd("777", true))
if err != nil {
t.Fatalf("handle: %v", err)
}
if !delivered || len(sender.gate) != 1 || sender.gate[0].userID != 777 || !sender.gate[0].allow {
t.Errorf("chat gate = %v / calls %+v", delivered, sender.gate)
}
}
func TestExecutorChatGateNotInChat(t *testing.T) {
sender := &fakeSender{applied: false} // user not in the chat
exec := NewExecutor(sender, 0, nil)
delivered, err := exec.Handle(context.Background(), chatGateCmd("888", false))
if err != nil {
t.Fatalf("handle: %v", err)
}
if delivered {
t.Error("expected delivered=false when the user is not in the chat")
}
if len(sender.gate) != 1 {
t.Errorf("gate calls = %d, want 1", len(sender.gate))
}
}
func TestExecutorChatGateInvalidExternalID(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil)
if _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil {
t.Error("expected an error for a non-numeric external_id")
}
}
func TestExecutorSendToChannel(t *testing.T) {
channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}}
@@ -37,6 +37,24 @@ type BotConfig struct {
// GameChannelID is the chat id of the bot's game channel for the admin channel
// post (TELEGRAM_GAME_CHANNEL_ID, optional; 0 disables channel posts).
GameChannelID int64
// ChatID is the chat id of the moderated discussion supergroup (a channel's linked
// chat) whose write access the bot gates by registration and moderation
// (TELEGRAM_CHAT_ID, optional; 0 disables chat gating). The bot must be an admin
// there with the "Ban users" right, and "chat_member" in its allowed updates.
ChatID int64
// PromoBotToken is the API token of the optional standalone promo bot run in this
// container — a second bot whose only job is to answer /start with a button that
// opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables
// the promo bot).
PromoBotToken string
// BotUsername is the main bot's @username without the leading @, used in the promo
// bot's message text (TELEGRAM_BOT_USERNAME; required when the promo bot runs).
BotUsername string
// BotLinkURL is the main bot's Mini App direct link — the same value the UI builds
// share links from (VITE_TELEGRAM_LINK), e.g. https://t.me/<bot>/<app>. The promo
// 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.
BotLinkURL string
// MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is
// the base of every launch button (TELEGRAM_MINIAPP_URL, required).
MiniAppURL string
@@ -114,6 +132,9 @@ func LoadBot() (BotConfig, error) {
TestEnv: os.Getenv("TELEGRAM_TEST_ENV") == "true",
OwnsUpdates: os.Getenv("TELEGRAM_OWNS_UPDATES") != "false",
SendRatePerSecond: defaultSendRatePerSecond,
PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"),
BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"),
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
BotLink: BotLinkClientConfig{
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
@@ -128,6 +149,9 @@ func LoadBot() (BotConfig, error) {
if cfg.GameChannelID, err = envInt64("TELEGRAM_GAME_CHANNEL_ID", 0); err != nil {
return BotConfig{}, err
}
if cfg.ChatID, err = envInt64("TELEGRAM_CHAT_ID", 0); err != nil {
return BotConfig{}, err
}
if cfg.SendRatePerSecond, err = envInt("TELEGRAM_SEND_RATE_PER_SECOND", defaultSendRatePerSecond); err != nil {
return BotConfig{}, err
}
@@ -155,6 +179,9 @@ func LoadBot() (BotConfig, error) {
if cfg.BotLink.CertFile == "" || cfg.BotLink.KeyFile == "" || cfg.BotLink.CAFile == "" {
return BotConfig{}, fmt.Errorf("config: TELEGRAM_BOTLINK_TLS_CERT, _KEY and _CA are required")
}
if cfg.PromoBotToken != "" && (cfg.BotUsername == "" || cfg.BotLinkURL == "") {
return BotConfig{}, fmt.Errorf("config: TELEGRAM_BOT_USERNAME and TELEGRAM_BOT_LINK are required when TELEGRAM_PROMO_BOT_TOKEN is set")
}
return cfg, nil
}
@@ -103,6 +103,54 @@ func TestLoadBotRequired(t *testing.T) {
}
}
// TestLoadBotChatAndPromo verifies the moderated-chat id and the promo-bot
// configuration parse, the @-prefix is stripped from the username, and a promo token
// without a username/link is rejected.
func TestLoadBotChatAndPromo(t *testing.T) {
t.Run("parsed", func(t *testing.T) {
setBotRequired(t)
t.Setenv("TELEGRAM_CHAT_ID", "-100222")
t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token")
t.Setenv("TELEGRAM_BOT_USERNAME", "@ScrabbleBot")
t.Setenv("TELEGRAM_BOT_LINK", "https://t.me/ScrabbleBot/app")
c, err := LoadBot()
if err != nil {
t.Fatalf("LoadBot: %v", err)
}
if c.ChatID != -100222 {
t.Errorf("ChatID = %d, want -100222", c.ChatID)
}
if c.PromoBotToken != "promo-token" {
t.Errorf("PromoBotToken = %q", c.PromoBotToken)
}
if c.BotUsername != "ScrabbleBot" {
t.Errorf("BotUsername = %q, want the leading @ stripped", c.BotUsername)
}
if c.BotLinkURL != "https://t.me/ScrabbleBot/app" {
t.Errorf("BotLinkURL = %q", c.BotLinkURL)
}
})
t.Run("promo token requires username and link", func(t *testing.T) {
setBotRequired(t)
t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token")
if _, err := LoadBot(); err == nil {
t.Fatal("LoadBot: expected an error for a promo token without username/link")
}
})
t.Run("disabled by default", func(t *testing.T) {
setBotRequired(t)
c, err := LoadBot()
if err != nil {
t.Fatalf("LoadBot: %v", err)
}
if c.PromoBotToken != "" || c.ChatID != 0 {
t.Errorf("defaults: promo=%q chat=%d, want empty/0", c.PromoBotToken, c.ChatID)
}
})
}
// TestLoadRejectsUnsupportedExporter verifies an exporter outside the supported set
// fails validation (the validator path).
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
@@ -0,0 +1,164 @@
// Package promobot is the standalone promo bot: a second Telegram bot in the bot
// container whose only job is to answer /start with a localized message and a button
// that opens the MAIN bot's Mini App. It is self-contained — it never calls the
// gateway or the game — so onboarding works even when the game is down. The button is
// a URL to the main bot's direct Mini App link: a web_app button would launch the Mini
// App under the promo bot's identity (its token would sign the initData), which the
// main bot's validator would reject, so the cross-bot launch must be a t.me link. It
// reuses the same link the UI builds invitation links from (VITE_TELEGRAM_LINK).
package promobot
import (
"context"
"net/url"
"strings"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
"golang.org/x/time/rate"
)
// Config configures the promo bot.
type Config struct {
// Token is the promo bot's Bot API token.
Token string
// APIBaseURL overrides the Bot API host ("" uses https://api.telegram.org).
APIBaseURL string
// TestEnv routes requests to the Bot API test environment.
TestEnv bool
// BotUsername is the main bot's @username without the leading @, named in the
// message text.
BotUsername string
// BotLinkURL is the main bot's Mini App direct link; the button appends
// ?startapp=<payload> to it.
BotLinkURL string
// SendRatePerSecond caps outbound sends to respect the Bot API flood limits; 0
// disables the limiter. The burst equals the per-second rate.
SendRatePerSecond int
}
// Bot is the promo bot wrapper around a Telegram Bot API client.
type Bot struct {
api *tgbot.Bot
username string
linkURL string
log *zap.Logger
limiter *rate.Limiter
}
// New builds the promo bot, registering a /start (and default) handler that replies
// with the launch button. It does not start polling; call Run for that.
func New(cfg Config, log *zap.Logger) (*Bot, error) {
if log == nil {
log = zap.NewNop()
}
t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, log: log}
if cfg.SendRatePerSecond > 0 {
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
}
opts := []tgbot.Option{
tgbot.WithDefaultHandler(t.handleStart),
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
}
if cfg.TestEnv {
opts = append(opts, tgbot.UseTestEnvironment())
}
if cfg.APIBaseURL != "" {
opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL))
}
api, err := tgbot.New(cfg.Token, opts...)
if err != nil {
return nil, err
}
t.api = api
return t, nil
}
// Run sets the bot command, then blocks on the long-poll update loop until ctx is
// cancelled.
func (t *Bot) Run(ctx context.Context) {
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
Commands: []models.BotCommand{{Command: "start", Description: "Open Scrabble"}},
}); err != nil {
t.log.Warn("promo: set commands failed", zap.Error(err))
}
t.api.Start(ctx)
}
// 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.
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
if update.Message == nil {
return
}
if err := t.throttle(ctx); err != nil {
return
}
lang := ""
if update.Message.From != nil {
lang = update.Message.From.LanguageCode
}
text, button := promoText(lang, t.username)
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: update.Message.Chat.ID,
Text: text,
ReplyMarkup: t.launchMarkup(button, startPayload(update.Message.Text)),
}); err != nil {
t.log.Warn("promo: reply to start failed", zap.Error(err))
}
}
// launchMarkup builds the single URL button that opens the main bot's Mini App at the
// optional startapp payload.
func (t *Bot) launchMarkup(buttonText, startParam string) *models.InlineKeyboardMarkup {
return &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{{
{Text: buttonText, URL: t.launchURL(startParam)},
}},
}
}
// launchURL appends the startapp payload to the main bot's Mini App link; an empty
// payload returns the base link unchanged.
func (t *Bot) launchURL(startParam string) string {
if startParam == "" {
return t.linkURL
}
u, err := url.Parse(t.linkURL)
if err != nil {
return t.linkURL
}
q := u.Query()
q.Set("startapp", startParam)
u.RawQuery = q.Encode()
return u.String()
}
// throttle blocks until the rate limiter admits one send, or ctx is cancelled. It is
// a no-op when no limiter is configured.
func (t *Bot) throttle(ctx context.Context) error {
if t.limiter == nil {
return nil
}
return t.limiter.Wait(ctx)
}
// startPayload extracts the deep-link payload from a "/start <payload>" command; any
// other text yields an empty payload (open the lobby).
func startPayload(text string) string {
const cmd = "/start"
if !strings.HasPrefix(text, cmd) {
return ""
}
return strings.TrimSpace(strings.TrimPrefix(text, cmd))
}
// promoText returns the localized message body and button label, naming the main bot
// (Russian for a "ru" language code, English otherwise).
func promoText(lang, username string) (text, button string) {
if strings.HasPrefix(strings.ToLower(lang), "ru") {
return "Откройте @" + username + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
}
return "Open @" + username + " and choose your game variant in the profile settings.", "🤩 I want to play!"
}
@@ -0,0 +1,105 @@
package promobot
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
)
func TestPromoTextLocalization(t *testing.T) {
en, enBtn := promoText("en", "ScrabbleBot")
if !strings.Contains(en, "@ScrabbleBot") || !strings.Contains(en, "profile settings") {
t.Errorf("en text = %q", en)
}
if enBtn != "🤩 I want to play!" {
t.Errorf("en button = %q", enBtn)
}
ru, ruBtn := promoText("ru-RU", "ScrabbleBot")
if !strings.Contains(ru, "@ScrabbleBot") || !strings.Contains(ru, "Откройте") {
t.Errorf("ru text = %q", ru)
}
if ruBtn != "🤩 Хочу играть!" {
t.Errorf("ru button = %q", ruBtn)
}
// An unknown language falls back to English.
if got, _ := promoText("de", "B"); !strings.Contains(got, "Open @B") {
t.Errorf("fallback text = %q, want English", got)
}
}
func TestLaunchURLAppendsStartapp(t *testing.T) {
b := &Bot{linkURL: "https://t.me/bot/app"}
if got := b.launchURL(""); got != "https://t.me/bot/app" {
t.Errorf("empty payload = %q, want the base link unchanged", got)
}
if got := b.launchURL("g123"); got != "https://t.me/bot/app?startapp=g123" {
t.Errorf("launchURL = %q, want startapp=g123 appended", got)
}
}
func TestLaunchMarkupIsURLButton(t *testing.T) {
b := &Bot{linkURL: "https://t.me/bot/app"}
btn := b.launchMarkup("Play", "f99").InlineKeyboard[0][0]
if btn.WebApp != nil {
t.Error("the promo button must not be a web_app button (it would sign initData with the promo token, which the main bot rejects)")
}
if !strings.Contains(btn.URL, "startapp=f99") {
t.Errorf("button URL = %q, want startapp=f99", btn.URL)
}
}
// fakeAPI answers getMe (so New succeeds offline) and records the last sendMessage.
type fakeAPI struct {
chatID, text, replyMarkup string
}
func (f *fakeAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/getMe"):
io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"t","username":"promo"}}`)
case strings.HasSuffix(r.URL.Path, "/sendMessage"):
f.chatID = r.FormValue("chat_id")
f.text = r.FormValue("text")
f.replyMarkup = r.FormValue("reply_markup")
io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`)
default:
io.WriteString(w, `{"ok":true,"result":true}`)
}
}
func TestHandleStartReplies(t *testing.T) {
api := &fakeAPI{}
srv := httptest.NewServer(api)
t.Cleanup(srv.Close)
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "ScrabbleBot", BotLinkURL: "https://t.me/bot/app"}, zap.NewNop())
if err != nil {
t.Fatalf("new: %v", err)
}
b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{
Chat: models.Chat{ID: 42},
From: &models.User{LanguageCode: "ru"},
Text: "/start f99",
}})
if api.chatID != "42" {
t.Errorf("chat_id = %q, want 42", api.chatID)
}
if !strings.Contains(api.text, "@ScrabbleBot") {
t.Errorf("text = %q, want the @mention", api.text)
}
if strings.Contains(api.replyMarkup, "web_app") {
t.Errorf("reply_markup = %q has a web_app button; want a url button", api.replyMarkup)
}
if !strings.Contains(api.replyMarkup, "startapp=f99") {
t.Errorf("reply_markup = %q, want startapp=f99 (the /start payload forwarded)", api.replyMarkup)
}
}
+8 -8
View File
@@ -19,7 +19,7 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async
// Still waiting for an opponent: the opponent card shows the placeholder, and resign (in the
// history panel) is disabled.
await expect(page.getByText(/Searching for opponent/)).toBeVisible();
await expect(page.getByText(/Waiting for opponent/)).toBeVisible();
await page.locator('.scoreboard').click(); // open the history panel
await expect(page.getByRole('button', { name: 'Drop game' })).toBeDisabled();
@@ -28,7 +28,7 @@ test('quick game: enter immediately, wait for an opponent, then it joins', async
// The opponent card shows its name, the placeholder is gone, and resign is enabled again.
await expect(page.getByText('Robo')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Drop game' })).toBeEnabled();
});
@@ -48,7 +48,7 @@ test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', a
// The player lands in an active game at once (no "searching" wait); the opponent shows as 🤖.
await expect(page.locator('[data-cell]').first()).toBeVisible();
await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0);
// An AI game has no chat at all: the comms hub drops the Chat tab and lands on the Dictionary
// alone, which stays usable.
@@ -96,7 +96,7 @@ async function enterOpenGame(page: import('@playwright/test').Page): Promise<voi
await page.getByRole('button', { name: 'Random player' }).click();
await page.locator('.variant').first().click();
await page.getByRole('button', { name: /Start game/i }).click();
await expect(page.getByText(/Searching for opponent/)).toBeVisible();
await expect(page.getByText(/Waiting for opponent/)).toBeVisible();
}
test('quick game: a poll recovers a join missed while the live stream is down', async ({ page }) => {
@@ -106,7 +106,7 @@ test('quick game: a poll recovers a join missed while the live stream is down',
await page.evaluate(() => (window as unknown as { __stream: { drop(): void } }).__stream.drop());
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
await expect(page.getByText('Robo')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0);
});
test('quick game: a stream reconnect recovers a join missed while it was down', async ({ page }) => {
@@ -117,7 +117,7 @@ test('quick game: a stream reconnect recovers a join missed while it was down',
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
await page.evaluate(() => (window as unknown as { __stream: { restore(): void } }).__stream.restore());
await expect(page.getByText('Robo')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0);
});
test('quick game: a foreground resync recovers a join shed while the stream stayed alive', async ({ page }) => {
@@ -125,11 +125,11 @@ test('quick game: a foreground resync recovers a join shed while the stream stay
// The opponent joins but the event is shed with the stream still alive (no reconnect, and the
// poll only runs while the stream is down): nothing has recovered yet.
await page.evaluate(() => (window as unknown as { __mock: { joinOpponentSilently(): void } }).__mock.joinOpponentSilently());
await expect(page.getByText(/Searching for opponent/)).toBeVisible();
await expect(page.getByText(/Waiting for opponent/)).toBeVisible();
// Returning to the foreground resyncs the open game (pageshow drives goForeground).
await page.evaluate(() => window.dispatchEvent(new Event('pageshow')));
await expect(page.getByText('Robo')).toBeVisible();
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
await expect(page.getByText(/Waiting for opponent/)).toHaveCount(0);
});
// Regression: an opponent joining must not freeze the screen. The in-game live-event effect tracked
+1 -1
View File
@@ -63,7 +63,7 @@ export const en = {
'game.yourTurn': 'Your turn',
'game.yourTurnBy': '{name}: Your turn!',
'game.opponentsTurn': "Opponent's turn",
'game.searchingForOpponent': 'Searching for opponent…',
'game.searchingForOpponent': 'Waiting for opponent…',
'game.waiting': "Waiting for {name}",
'game.makeMove': 'Make move',
'game.reset': 'Reset',
+1 -1
View File
@@ -64,7 +64,7 @@ export const ru: Record<MessageKey, string> = {
'game.yourTurn': 'Ваш ход',
'game.yourTurnBy': '{name}: Ваш ход!',
'game.opponentsTurn': 'Ход соперника',
'game.searchingForOpponent': 'Поиск соперника...',
'game.searchingForOpponent': 'Ждём соперника',
'game.waiting': 'Ожидаем {name}',
'game.makeMove': 'Сделать ход',
'game.reset': 'Сброс',