Files
scrabble-game/gateway/internal/transcode/transcode_social.go
T
Ilia Denisov 52f898ca6f
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 20s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Stage 11: account linking & merge (email + Telegram Login Widget)
Link an email (confirm-code) or Telegram (web Login Widget) to the current
account; if the identity already has its own account, merge the two into the
one in use (the current account is primary, except a guest initiator whose
durable counterpart wins). The merge runs in one transaction
(internal/accountmerge): stats + hint wallet summed, paid_account ORed,
identities/games/chat/complaints transferred, friends/blocks de-duplicated,
the secondary kept as a merged_into tombstone so a shared finished game's
no-cascade FKs hold; a shared active game blocks the merge.

- migration 00009: accounts.paid_account, merged_into, merged_at (+ jetgen)
- internal/link orchestrator; session.RevokeAllForAccount on merge
- connector ValidateLoginWidget RPC + loginwidget HMAC validator
- edge ops link.email.request/confirm/merge, link.telegram.confirm/merge;
  supersedes the Stage 8 email.bind.* surface (request never reveals 'taken'
  before the code is verified, so a probe cannot enumerate addresses)
- UI Profile link section + irreversible-merge dialog; Telegram web sign-in
- focused regression tests (merge core, guest inversion, active-game refusal,
  finished-shared-game kept), gateway transcode + connector + UI codec/e2e
- docs: PLAN, ARCHITECTURE 3/4/9, FUNCTIONAL(+ru), module READMEs
2026-06-04 11:15:14 +02:00

279 lines
9.5 KiB
Go

package transcode
import (
"context"
"scrabble/gateway/internal/backendclient"
fb "scrabble/pkg/fbs/scrabblefb"
)
// Stage 8 message types: friends (incl. the one-time code path), per-user blocks,
// friend-game invitations, profile editing + email binding, statistics and GCG
// export. All are authenticated. Registered by registerStage8 from NewRegistry.
const (
MsgFriendsList = "friends.list"
MsgFriendsIncoming = "friends.incoming"
MsgFriendRequest = "friends.request"
MsgFriendRespond = "friends.respond"
MsgFriendCancel = "friends.cancel"
MsgFriendUnfriend = "friends.unfriend"
MsgFriendCodeIssue = "friends.code.issue"
MsgFriendCodeRedeem = "friends.code.redeem"
MsgBlocksList = "blocks.list"
MsgBlockAdd = "blocks.add"
MsgBlockRemove = "blocks.remove"
MsgInvitationsList = "invitation.list"
MsgInvitationCreate = "invitation.create"
MsgInvitationAccept = "invitation.accept"
MsgInvitationDecline = "invitation.decline"
MsgInvitationCancel = "invitation.cancel"
MsgProfileUpdate = "profile.update"
MsgStatsGet = "stats.get"
MsgGameGCG = "game.gcg"
)
// registerStage8 adds the Stage 8 social, account and history operations to the
// registry (all authenticated; the email-bind ops carry the costly-email flag).
func registerStage8(r *Registry, backend *backendclient.Client) {
r.ops[MsgFriendsList] = Op{Handler: friendsListHandler(backend), Auth: true}
r.ops[MsgFriendsIncoming] = Op{Handler: friendsIncomingHandler(backend), Auth: true}
r.ops[MsgFriendRequest] = Op{Handler: friendRequestHandler(backend), Auth: true}
r.ops[MsgFriendRespond] = Op{Handler: friendRespondHandler(backend), Auth: true}
r.ops[MsgFriendCancel] = Op{Handler: friendCancelHandler(backend), Auth: true}
r.ops[MsgFriendUnfriend] = Op{Handler: friendUnfriendHandler(backend), Auth: true}
r.ops[MsgFriendCodeIssue] = Op{Handler: friendCodeIssueHandler(backend), Auth: true}
r.ops[MsgFriendCodeRedeem] = Op{Handler: friendCodeRedeemHandler(backend), Auth: true}
r.ops[MsgBlocksList] = Op{Handler: blocksListHandler(backend), Auth: true}
r.ops[MsgBlockAdd] = Op{Handler: blockAddHandler(backend), Auth: true}
r.ops[MsgBlockRemove] = Op{Handler: blockRemoveHandler(backend), Auth: true}
r.ops[MsgInvitationsList] = Op{Handler: invitationsListHandler(backend), Auth: true}
r.ops[MsgInvitationCreate] = Op{Handler: invitationCreateHandler(backend), Auth: true}
r.ops[MsgInvitationAccept] = Op{Handler: invitationRespondHandler(backend, true), Auth: true}
r.ops[MsgInvitationDecline] = Op{Handler: invitationRespondHandler(backend, false), Auth: true}
r.ops[MsgInvitationCancel] = Op{Handler: invitationCancelHandler(backend), Auth: true}
r.ops[MsgProfileUpdate] = Op{Handler: profileUpdateHandler(backend), Auth: true}
r.ops[MsgStatsGet] = Op{Handler: statsHandler(backend), Auth: true}
r.ops[MsgGameGCG] = Op{Handler: gcgHandler(backend), Auth: true}
}
// --- friends ---
func friendsListHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.ListFriends(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeFriendList(res), nil
}
}
func friendsIncomingHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.ListIncoming(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeIncomingList(res), nil
}
}
func friendRequestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTargetRequest(req.Payload, 0)
if err := backend.SendFriendRequest(ctx, req.UserID, string(in.AccountId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func friendRespondHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsFriendRespondRequest(req.Payload, 0)
if err := backend.RespondFriendRequest(ctx, req.UserID, string(in.RequesterId()), in.Accept()); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func friendCancelHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTargetRequest(req.Payload, 0)
if err := backend.CancelFriendRequest(ctx, req.UserID, string(in.AccountId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func friendUnfriendHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTargetRequest(req.Payload, 0)
if err := backend.Unfriend(ctx, req.UserID, string(in.AccountId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func friendCodeIssueHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.IssueFriendCode(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeFriendCode(res), nil
}
}
func friendCodeRedeemHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsRedeemCodeRequest(req.Payload, 0)
res, err := backend.RedeemFriendCode(ctx, req.UserID, string(in.Code()))
if err != nil {
return nil, err
}
return encodeRedeemResult(res), nil
}
}
// --- blocks ---
func blocksListHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.ListBlocks(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeBlockList(res), nil
}
}
func blockAddHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTargetRequest(req.Payload, 0)
if err := backend.Block(ctx, req.UserID, string(in.AccountId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
func blockRemoveHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTargetRequest(req.Payload, 0)
if err := backend.Unblock(ctx, req.UserID, string(in.AccountId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
// --- invitations ---
func invitationsListHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.ListInvitations(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeInvitationList(res), nil
}
}
func invitationCreateHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsCreateInvitationRequest(req.Payload, 0)
params := backendclient.InvitationParams{
InviteeIDs: decodeInviteeIDs(in),
Variant: string(in.Variant()),
TurnTimeoutSecs: int(in.TurnTimeoutSecs()),
HintsAllowed: in.HintsAllowed(),
HintsPerPlayer: int(in.HintsPerPlayer()),
DropoutTiles: string(in.DropoutTiles()),
}
res, err := backend.CreateInvitation(ctx, req.UserID, params)
if err != nil {
return nil, err
}
return encodeInvitation(res), nil
}
}
func invitationRespondHandler(backend *backendclient.Client, accept bool) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsInvitationActionRequest(req.Payload, 0)
res, err := backend.RespondInvitation(ctx, req.UserID, string(in.InvitationId()), accept)
if err != nil {
return nil, err
}
return encodeInvitation(res), nil
}
}
func invitationCancelHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsInvitationActionRequest(req.Payload, 0)
if err := backend.CancelInvitation(ctx, req.UserID, string(in.InvitationId())); err != nil {
return nil, err
}
return encodeAck(true), nil
}
}
// --- profile, email, stats, gcg ---
func profileUpdateHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsUpdateProfileRequest(req.Payload, 0)
p := backendclient.ProfileResp{
DisplayName: string(in.DisplayName()),
PreferredLanguage: string(in.PreferredLanguage()),
TimeZone: string(in.TimeZone()),
AwayStart: string(in.AwayStart()),
AwayEnd: string(in.AwayEnd()),
BlockChat: in.BlockChat(),
BlockFriendRequests: in.BlockFriendRequests(),
NotificationsInAppOnly: in.NotificationsInAppOnly(),
}
out, err := backend.UpdateProfile(ctx, req.UserID, p)
if err != nil {
return nil, err
}
return encodeProfile(out), nil
}
}
func statsHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
res, err := backend.Stats(ctx, req.UserID)
if err != nil {
return nil, err
}
return encodeStats(res), nil
}
}
func gcgHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsGameActionRequest(req.Payload, 0)
res, err := backend.ExportGCG(ctx, req.UserID, string(in.GameId()))
if err != nil {
return nil, err
}
return encodeGcg(res), nil
}
}
// decodeInviteeIDs reads the invitee id vector from a CreateInvitationRequest.
func decodeInviteeIDs(in *fb.CreateInvitationRequest) []string {
n := in.InviteeIdsLength()
out := make([]string, 0, n)
for i := range n {
out = append(out, string(in.InviteeIds(i)))
}
return out
}