e71e40eef5
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.
85 lines
3.2 KiB
Go
85 lines
3.2 KiB
Go
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)
|
|
}
|
|
}
|