feat(telegram): promo bot + channel-chat moderation gate
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s

Add a second standalone promo bot to the bot container (answers /start with a
localized message + a URL button into the main bot's Mini App) and gate write
access in a channel's linked discussion chat: grant on join when the Telegram
user is registered and neither admin-suspended nor holding a new chat_muted
role, and revoke/grant on the matching moderation change for a member currently
in the chat.

Eligibility (registered AND NOT suspended AND NOT chat_muted; the game
suspension dominates) is resolved once in the backend and reached two ways: the
bot's join-time unary ResolveChatEligibility over the existing mTLS bot-link,
and a backend chat_access_changed event -> gateway -> ChatGate command
(idempotent; a temporary-block-expiry sweeper may over-emit). The bot guards the
block/unblock path with getChatMember, since bots cannot list members.

A web_app button cannot open another bot's Mini App (it signs initData with the
sending bot's token), so the promo button is a t.me ?startapp URL reusing the
UI's VITE_TELEGRAM_LINK. The bot must be a chat admin with the restrict-members
right and chat_member in its allowed updates.

No schema change: chat_muted reuses the data-driven account_roles table.
This commit is contained in:
Ilia Denisov
2026-06-21 14:46:51 +02:00
parent 41d21f3f6f
commit e71e40eef5
42 changed files with 2082 additions and 68 deletions
+8
View File
@@ -303,6 +303,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")
}
@@ -0,0 +1,272 @@
//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"
)
// 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)
}
}
}
// 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)
}
}
+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)
}