57c778f9b2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.
- Telegram: one bot; drop service_language and the supported_languages
set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
connector proto). The single bot renders chat and out-of-app push in
the recipient's preferred_language; remove the game-language push
routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
{erudit_ru}, CHECK non-empty + subset of the three variants). Gates
the New Game picker, vs-AI and the friend invitation the player
creates, enforced server-side (HTTP 400 otherwise); an invited friend
may still accept any variant. Edited on the Settings screen; variants
are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
backend, ui, UI_DESIGN, PRERELEASE).
The migration squash is deferred to a follow-up PR.
260 lines
8.5 KiB
Go
260 lines
8.5 KiB
Go
package feedback
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/notify"
|
|
)
|
|
|
|
const (
|
|
// maxBodyRunes caps a feedback message (and an operator reply) length.
|
|
maxBodyRunes = 1024
|
|
// replyVisibleFor is how long an operator reply stays shown to the player after
|
|
// it is delivered (read).
|
|
replyVisibleFor = 7 * 24 * time.Hour
|
|
)
|
|
|
|
// Submit / reply validation errors. The transport layer maps them to stable result
|
|
// codes; the UI maps those to the messages shown on the feedback screen.
|
|
var (
|
|
ErrEmptyMessage = errors.New("feedback: empty message")
|
|
ErrMessageTooLong = errors.New("feedback: message too long")
|
|
ErrAttachmentTooLarge = errors.New("feedback: attachment too large")
|
|
ErrAttachmentType = errors.New("feedback: attachment type not allowed")
|
|
ErrGuestForbidden = errors.New("feedback: guests cannot submit feedback")
|
|
ErrBanned = errors.New("feedback: account is banned from feedback")
|
|
ErrPendingReview = errors.New("feedback: previous message still pending review")
|
|
)
|
|
|
|
// validChannels enumerates the submitting platforms a client may report; anything
|
|
// else is normalised to "web" (the channel is informational, never a gate).
|
|
var validChannels = map[string]bool{"telegram": true, "ios": true, "android": true, "web": true}
|
|
|
|
// Service is the feedback domain: the only writer of feedback_messages. It reads
|
|
// accounts for the guest/role gates and publishes the reply notification.
|
|
type Service struct {
|
|
store *Store
|
|
accounts *account.Store
|
|
pub notify.Publisher
|
|
now func() time.Time
|
|
}
|
|
|
|
// NewService constructs a Service. store owns feedback_messages; accounts supplies
|
|
// the guest flag and the feedback-ban role.
|
|
func NewService(store *Store, accounts *account.Store) *Service {
|
|
return &Service{
|
|
store: store,
|
|
accounts: accounts,
|
|
pub: notify.Nop{},
|
|
now: func() time.Time { return time.Now().UTC() },
|
|
}
|
|
}
|
|
|
|
// SetNotifier installs the live-event publisher used to push the "you have a reply"
|
|
// signal to the player. It must be called during startup wiring; the default is
|
|
// notify.Nop (no live events).
|
|
func (svc *Service) SetNotifier(p notify.Publisher) {
|
|
if p != nil {
|
|
svc.pub = p
|
|
}
|
|
}
|
|
|
|
// Submit stores a feedback message from accountID. It rejects guests, feedback-
|
|
// banned accounts and a sender who still has a message pending review, then
|
|
// validates the body (non-empty, within the rune limit) and the optional
|
|
// attachment (size and extension allow-list). senderIP is the gateway-forwarded
|
|
// client IP (validated); channel is the submitting platform.
|
|
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error {
|
|
acc, err := svc.accounts.GetByID(ctx, accountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if acc.IsGuest {
|
|
return ErrGuestForbidden
|
|
}
|
|
banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if banned {
|
|
return ErrBanned
|
|
}
|
|
pending, err := svc.store.HasUnread(ctx, accountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if pending {
|
|
return ErrPendingReview
|
|
}
|
|
body = strings.TrimSpace(body)
|
|
if body == "" {
|
|
return ErrEmptyMessage
|
|
}
|
|
if utf8.RuneCountInString(body) > maxBodyRunes {
|
|
return ErrMessageTooLong
|
|
}
|
|
if len(attachment) > 0 {
|
|
if len(attachment) > maxAttachmentBytes {
|
|
return ErrAttachmentTooLarge
|
|
}
|
|
if !AllowedAttachment(attachmentName) {
|
|
return ErrAttachmentType
|
|
}
|
|
} else {
|
|
attachmentName = "" // a name without bytes carries no attachment
|
|
}
|
|
ch := normalizeChannel(channel)
|
|
// Snapshot the sender's interface language at submit time (acc is already loaded
|
|
// for the guest check) so the operator later sees the state as it was.
|
|
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, parseIP(senderIP))
|
|
return err
|
|
}
|
|
|
|
// State is the player's feedback screen state. Reason is "" (can send), "pending"
|
|
// (a previous message is unreviewed) or "banned". Reply is the operator's answer to
|
|
// show, or nil. Fetching it delivers any pending replies (delivery counts as read),
|
|
// clearing the badge.
|
|
type State struct {
|
|
CanSend bool
|
|
BlockedReason string
|
|
Reply *Reply
|
|
}
|
|
|
|
// Reply is the operator's answer shown back to the player.
|
|
type Reply struct {
|
|
Body string
|
|
RepliedAt time.Time
|
|
}
|
|
|
|
// State computes the feedback screen state for accountID and marks any pending
|
|
// replies delivered.
|
|
func (svc *Service) State(ctx context.Context, accountID uuid.UUID) (State, error) {
|
|
banned, err := svc.accounts.HasRole(ctx, accountID, account.RoleFeedbackBanned)
|
|
if err != nil {
|
|
return State{}, err
|
|
}
|
|
pending, err := svc.store.HasUnread(ctx, accountID)
|
|
if err != nil {
|
|
return State{}, err
|
|
}
|
|
st := State{CanSend: !banned && !pending}
|
|
switch {
|
|
case banned:
|
|
st.BlockedReason = "banned"
|
|
case pending:
|
|
st.BlockedReason = "pending"
|
|
}
|
|
vr, ok, err := svc.store.LatestVisibleReply(ctx, accountID, svc.now().Add(-replyVisibleFor))
|
|
if err != nil {
|
|
return State{}, err
|
|
}
|
|
if ok {
|
|
st.Reply = &Reply{Body: vr.Body, RepliedAt: vr.RepliedAt}
|
|
}
|
|
// Opening the screen delivers every pending reply; mark them read to clear the
|
|
// badge (only the latest is shown, but all are now delivered).
|
|
if err := svc.store.MarkRepliesDelivered(ctx, accountID, svc.now()); err != nil {
|
|
return State{}, err
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
// ReplyUnread reports whether the account has an operator reply not yet delivered —
|
|
// the lobby/Info badge condition. It has no side effect (the lobby may poll it).
|
|
func (svc *Service) ReplyUnread(ctx context.Context, accountID uuid.UUID) (bool, error) {
|
|
return svc.store.HasUnreadReply(ctx, accountID)
|
|
}
|
|
|
|
// AdminList returns the filtered console feedback list, paginated.
|
|
func (svc *Service) AdminList(ctx context.Context, f AdminFilter, limit, offset int) ([]AdminRow, error) {
|
|
return svc.store.AdminList(ctx, f, limit, offset)
|
|
}
|
|
|
|
// AdminCount counts the filtered console feedback list.
|
|
func (svc *Service) AdminCount(ctx context.Context, f AdminFilter) (int, error) {
|
|
return svc.store.AdminCount(ctx, f)
|
|
}
|
|
|
|
// AdminGet loads one message for the console detail view, or ErrNotFound.
|
|
func (svc *Service) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error) {
|
|
return svc.store.AdminGet(ctx, id)
|
|
}
|
|
|
|
// CountUnread counts the active (unread, not archived) feedback queue for the
|
|
// dashboard.
|
|
func (svc *Service) CountUnread(ctx context.Context) (int, error) {
|
|
return svc.store.CountUnread(ctx)
|
|
}
|
|
|
|
// Attachment returns a message's file name and bytes, reporting false when absent.
|
|
func (svc *Service) Attachment(ctx context.Context, id uuid.UUID) (string, []byte, bool, error) {
|
|
return svc.store.Attachment(ctx, id)
|
|
}
|
|
|
|
// MarkRead marks a message dealt-with (the manual "read" action).
|
|
func (svc *Service) MarkRead(ctx context.Context, id uuid.UUID) error {
|
|
return svc.store.MarkRead(ctx, id, svc.now())
|
|
}
|
|
|
|
// Reply sets the operator reply on a message (marking it read), then pushes the
|
|
// "you have a reply" notification to the player.
|
|
func (svc *Service) Reply(ctx context.Context, id uuid.UUID, body string) error {
|
|
body = strings.TrimSpace(body)
|
|
if body == "" {
|
|
return ErrEmptyMessage
|
|
}
|
|
if utf8.RuneCountInString(body) > maxBodyRunes {
|
|
return ErrMessageTooLong
|
|
}
|
|
accountID, err := svc.store.Reply(ctx, id, body, svc.now())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
svc.pub.Publish(notify.Notification(accountID, notify.NotifyAdminReply))
|
|
return nil
|
|
}
|
|
|
|
// Archive files a handled message away (marking it read).
|
|
func (svc *Service) Archive(ctx context.Context, id uuid.UUID) error {
|
|
return svc.store.Archive(ctx, id, svc.now())
|
|
}
|
|
|
|
// Delete physically removes a message and its attachment.
|
|
func (svc *Service) Delete(ctx context.Context, id uuid.UUID) error {
|
|
return svc.store.Delete(ctx, id)
|
|
}
|
|
|
|
// DeleteAllByAccount physically removes every message of an account.
|
|
func (svc *Service) DeleteAllByAccount(ctx context.Context, accountID uuid.UUID) error {
|
|
return svc.store.DeleteAllByAccount(ctx, accountID)
|
|
}
|
|
|
|
// parseIP returns a validated canonical IP string, or nil when raw is empty or not
|
|
// a valid address.
|
|
func parseIP(raw string) *string {
|
|
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
canon := addr.String()
|
|
return &canon
|
|
}
|
|
|
|
// normalizeChannel lower-cases and validates the client-reported channel, falling
|
|
// back to "web" for anything unrecognised.
|
|
func normalizeChannel(c string) string {
|
|
c = strings.ToLower(strings.TrimSpace(c))
|
|
if validChannels[c] {
|
|
return c
|
|
}
|
|
return "web"
|
|
}
|