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)) }