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
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:
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user