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:
@@ -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