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