/
githubmirror
/
client
Обзор
Документация
Войти
/
githubmirror
/
client
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
go/chat/flipmanager.go
1 693 строки
55 KB
chrisnojima
fix(flip): keep half-asleep devices out of coin flips (#29362)
02 июл 2026, 23:27
Не верифицирован
02 июл 2026, 23:27
67e2957
Код
Авторство
О чём код?
package chat import ( "bytes" "context" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "image" "image/color" "image/png" "math" "math/big" "sort" "strconv" "strings" "sync" "time" lru "github.com/hashicorp/golang-lru" "github.com/keybase/client/go/chat/flip" "github.com/keybase/client/go/chat/globals" "github.com/keybase/client/go/chat/storage" "github.com/keybase/client/go/chat/types" "github.com/keybase/client/go/chat/utils" "github.com/keybase/client/go/libkb" "github.com/keybase/client/go/protocol/chat1" "github.com/keybase/client/go/protocol/gregor1" "github.com/keybase/client/go/protocol/keybase1" "github.com/keybase/clockwork" ) type sentMessageResult struct { MsgID chat1.MessageID Err error } type sentMessageListener struct { globals.Contextified libkb.NoopNotifyListener utils.DebugLabeler outboxID chat1.OutboxID listenCh chan sentMessageResult } type startFlipSendStatus struct { status types.FlipSendStatus flipConvID chat1.ConversationID } func newSentMessageListener(g *globals.Context, outboxID chat1.OutboxID) *sentMessageListener { return &sentMessageListener{ Contextified: globals.NewContextified(g), DebugLabeler: utils.NewDebugLabeler(g.ExternalG(), "sentMessageListener", false), outboxID: outboxID, listenCh: make(chan sentMessageResult, 10), } } func (n *sentMessageListener) NewChatActivity(uid keybase1.UID, activity chat1.ChatActivity, source chat1.ChatActivitySource, ) { if source != chat1.ChatActivitySource_LOCAL { return } st, err := activity.ActivityType() if err != nil { n.Debug(context.Background(), "NewChatActivity: failed to get type: %s", err) return } switch st { case chat1.ChatActivityType_INCOMING_MESSAGE: msg := activity.IncomingMessage().Message if msg.IsOutbox() { return } if n.outboxID.Eq(msg.GetOutboxID()) { n.listenCh <- sentMessageResult{ MsgID: msg.GetMessageID(), } } case chat1.ChatActivityType_FAILED_MESSAGE: for _, obr := range activity.FailedMessage().OutboxRecords { if obr.OutboxID.Eq(&n.outboxID) { n.listenCh <- sentMessageResult{ Err: errors.New("failed to send message"), } break } } } } type flipTextMetadata struct { LowerBound string ShuffleItems []string DeckShuffle bool HandCardCount uint HandTargets []string ConvMemberShuffle bool } type hostMessageInfo struct { flipTextMetadata ConvID chat1.ConversationID MsgID chat1.MessageID } type loadGameJob struct { uid gregor1.UID hostConvID chat1.ConversationID hostMsgID chat1.MessageID gameID chat1.FlipGameID flipConvID chat1.ConversationID resCh chan chat1.UICoinFlipStatus errCh chan error } type convParticipationsRateLimit struct { count int reset time.Time } type FlipManager struct { globals.Contextified utils.DebugLabeler dealer *flip.Dealer visualizer *FlipVisualizer clock clockwork.Clock ri func() chat1.RemoteInterface started bool shutdownMu sync.Mutex shutdownCh chan struct{} dealerShutdownCh chan struct{} dealerCancel context.CancelFunc forceCh chan struct{} loadGameCh chan loadGameJob maybeInjectCh chan func() deck string cardMap map[string]int cardReverseMap map[int]string gamesMu sync.Mutex games *lru.Cache dirtyGames map[chat1.FlipGameIDStr]chat1.FlipGameID flipConvs *lru.Cache gameMsgIDs *lru.Cache gameOutboxIDMu sync.Mutex gameOutboxIDs *lru.Cache partMu sync.Mutex maxConvParticipations int maxConvParticipationsReset time.Duration convParticipations map[chat1.ConvIDStr]convParticipationsRateLimit // testing only testingServerClock clockwork.Clock } func NewFlipManager(g *globals.Context, ri func() chat1.RemoteInterface) *FlipManager { games, _ := lru.New(200) flipConvs, _ := lru.New(200) gameMsgIDs, _ := lru.New(200) gameOutboxIDs, _ := lru.New(200) m := &FlipManager{ Contextified: globals.NewContextified(g), DebugLabeler: utils.NewDebugLabeler(g.ExternalG(), "FlipManager", false), ri: ri, clock: clockwork.NewRealClock(), games: games, dirtyGames: make(map[chat1.FlipGameIDStr]chat1.FlipGameID), forceCh: make(chan struct{}, 10), loadGameCh: make(chan loadGameJob, 200), convParticipations: make(map[chat1.ConvIDStr]convParticipationsRateLimit), maxConvParticipations: 1000, maxConvParticipationsReset: 5 * time.Minute, visualizer: NewFlipVisualizer(128, 80), cardMap: make(map[string]int), cardReverseMap: make(map[int]string), flipConvs: flipConvs, gameMsgIDs: gameMsgIDs, gameOutboxIDs: gameOutboxIDs, maybeInjectCh: make(chan func(), 2000), } dealer := flip.NewDealer(m) m.dealer = dealer m.deck = "2♠️,3♠️,4♠️,5♠️,6♠️,7♠️,8♠️,9♠️,10♠️,J♠️,Q♠️,K♠️,A♠️,2♣️,3♣️,4♣️,5♣️,6♣️,7♣️,8♣️,9♣️,10♣️,J♣️,Q♣️,K♣️,A♣️,2♦️,3♦️,4♦️,5♦️,6♦️,7♦️,8♦️,9♦️,10♦️,J♦️,Q♦️,K♦️,A♦️,2♥️,3♥️,4♥️,5♥️,6♥️,7♥️,8♥️,9♥️,10♥️,J♥️,Q♥️,K♥️,A♥️" for index, card := range strings.Split(m.deck, ",") { m.cardMap[card] = index m.cardReverseMap[index] = card } return m } func (m *FlipManager) Start(ctx context.Context, uid gregor1.UID) { defer m.Trace(ctx, nil, "Start")() m.shutdownMu.Lock() if m.started { m.shutdownMu.Unlock() return } m.started = true var dealerCtx context.Context shutdownCh := make(chan struct{}) dealerShutdownCh := make(chan struct{}) m.shutdownCh = shutdownCh m.dealerShutdownCh = dealerShutdownCh dealerCtx, m.dealerCancel = context.WithCancel(context.Background()) m.shutdownMu.Unlock() go func(shutdownCh chan struct{}) { _ = m.dealer.Run(dealerCtx) close(shutdownCh) }(dealerShutdownCh) go m.updateLoop(shutdownCh) go m.notificationLoop(shutdownCh) go m.loadGameLoop(shutdownCh) go m.maybeInjectLoop(shutdownCh) if !m.G().IsMobileAppType() { // so ShouldCommit can tell a dark wake or a missed suspend event from // a machine that is really awake m.G().DesktopAppState.StartWakeWatcher() } } func (m *FlipManager) Stop(ctx context.Context) (ch chan struct{}) { defer m.Trace(ctx, nil, "Stop")() m.dealer.Stop() m.shutdownMu.Lock() defer m.shutdownMu.Unlock() m.started = false if m.shutdownCh != nil { m.dealerCancel() close(m.shutdownCh) m.shutdownCh = nil } if m.dealerShutdownCh != nil { ch = m.dealerShutdownCh m.dealerShutdownCh = nil } else { ch = make(chan struct{}) close(ch) } return ch } func (m *FlipManager) makeBkgContext() context.Context { ctx := context.Background() return globals.ChatCtx(ctx, m.G(), keybase1.TLFIdentifyBehavior_CHAT_SKIP, nil, nil) } func (m *FlipManager) isHostMessageInfoMsgID(msgID chat1.MessageID) bool { // The first message in a flip thread is metadata about the flip, which is // message ID 2 since conversations have an initial message from creation. return chat1.MessageID(2) == msgID } func (m *FlipManager) startMsgID() chat1.MessageID { return chat1.MessageID(3) } func (m *FlipManager) isStartMsgID(msgID chat1.MessageID) bool { // The first message after the host message is the flip start message, // which will have message ID 3 return m.startMsgID() == msgID } func (m *FlipManager) getVisualizer() *FlipVisualizer { return m.visualizer } func (m *FlipManager) notifyDirtyGames() { m.gamesMu.Lock() if len(m.dirtyGames) == 0 { m.gamesMu.Unlock() return } dirtyGames := m.dirtyGames m.dirtyGames = make(map[chat1.FlipGameIDStr]chat1.FlipGameID) m.gamesMu.Unlock() ctx := m.makeBkgContext() ui, err := m.G().UIRouter.GetChatUI() if err != nil || ui == nil { m.Debug(ctx, "notifyDirtyGames: no chat UI available for notification") return } var updates []chat1.UICoinFlipStatus m.Debug(ctx, "notifyDirtyGames: notifying about %d games", len(dirtyGames)) for _, dg := range dirtyGames { if game, ok := m.games.Get(dg.FlipGameIDStr()); ok { status := game.(chat1.UICoinFlipStatus) m.getVisualizer().Visualize(&status) presentStatus := status.DeepCopy() m.sortParticipants(&presentStatus) updates = append(updates, presentStatus) } } ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := ui.ChatCoinFlipStatus(ctx, updates); err != nil { m.Debug(ctx, "notifyDirtyGames: failed to notify status: %s", err) } else { m.Debug(ctx, "notifyDirtyGames: UI notified") } } func (m *FlipManager) notificationLoop(shutdownCh chan struct{}) { duration := 50 * time.Millisecond next := m.clock.Now().Add(duration) m.Debug(context.Background(), "notificationLoop: starting") for { select { case <-m.clock.AfterTime(next): m.notifyDirtyGames() next = m.clock.Now().Add(duration) case <-m.forceCh: m.notifyDirtyGames() next = m.clock.Now().Add(duration) case <-shutdownCh: m.Debug(context.Background(), "notificationLoop: exiting") return } } } func (m *FlipManager) sortParticipants(status *chat1.UICoinFlipStatus) { sort.Slice(status.Participants, func(i, j int) bool { return status.Participants[i].Username < status.Participants[j].Username }) } func (m *FlipManager) addParticipant(ctx context.Context, status *chat1.UICoinFlipStatus, update flip.CommitmentUpdate, ) { username, deviceName, _, err := m.G().GetUPAKLoader().LookupUsernameAndDevice(ctx, keybase1.UID(update.User.U.String()), keybase1.DeviceID(update.User.D.String())) if err != nil { m.Debug(ctx, "addParticipant: failed to get username/device (using IDs): %s", err) username = libkb.NewNormalizedUsername(update.User.U.String()) deviceName = update.User.D.String() } status.Participants = append(status.Participants, chat1.UICoinFlipParticipant{ Uid: update.User.U.String(), DeviceID: update.User.D.String(), Username: username.String(), DeviceName: deviceName, Commitment: update.Commitment.String(), }) endingS := "" if len(status.Participants) > 1 { endingS = "s" } status.ProgressText = fmt.Sprintf("Gathered %d commitment%s", len(status.Participants), endingS) } func (m *FlipManager) finalizeParticipants(ctx context.Context, status *chat1.UICoinFlipStatus, cc flip.CommitmentComplete, ) { completeMap := make(map[string]bool) mapKey := func(u, d string) string { return u + "," + d } for _, p := range cc.Players { completeMap[mapKey(p.Ud.U.String(), p.Ud.D.String())] = true } var filteredParts []chat1.UICoinFlipParticipant for _, p := range status.Participants { if completeMap[mapKey(p.Uid, p.DeviceID)] { filteredParts = append(filteredParts, p) } } filteredMap := make(map[string]bool) for _, p := range filteredParts { filteredMap[mapKey(p.Uid, p.DeviceID)] = true } status.Participants = filteredParts for _, p := range cc.Players { if !filteredMap[mapKey(p.Ud.U.String(), p.Ud.D.String())] { m.addParticipant(ctx, status, flip.CommitmentUpdate{ User: p.Ud, Commitment: p.C, }) } } } func (m *FlipManager) addReveal(ctx context.Context, status *chat1.UICoinFlipStatus, update flip.RevealUpdate, ) { numReveals := 0 for index, p := range status.Participants { if p.Reveal != nil { numReveals++ } if p.Uid == update.User.U.String() && p.DeviceID == update.User.D.String() { reveal := update.Reveal.String() status.Participants[index].Reveal = &reveal numReveals++ } } status.ProgressText = fmt.Sprintf("%d participants have revealed secrets", numReveals) } func (m *FlipManager) cardIndex(card string) (int, error) { if index, ok := m.cardMap[card]; ok { return index, nil } return 0, fmt.Errorf("unknown card: %s", card) } func (m *FlipManager) addCardHandResult(ctx context.Context, status *chat1.UICoinFlipStatus, result flip.Result, hmi hostMessageInfo, ) { deckIndex := 0 numCards := len(result.Shuffle) handSize := int(hmi.HandCardCount) //nolint:gosec // G115: Hand card count is a small game parameter, safe to convert var uiHandResult []chat1.UICoinFlipHand for _, target := range hmi.HandTargets { if numCards-handSize < deckIndex { uiHandResult = append(uiHandResult, chat1.UICoinFlipHand{ Target: target, }) continue } uiHand := chat1.UICoinFlipHand{ Target: target, } for di := deckIndex; di < deckIndex+handSize; di++ { card := hmi.ShuffleItems[result.Shuffle[di]] cardIndex, err := m.cardIndex(card) if err != nil { m.Debug(ctx, "addCardHandResult: failed to get card: %s", err) m.setGenericError(status, "Failed to describe card hand result") return } uiHand.Hand = append(uiHand.Hand, cardIndex) } uiHandResult = append(uiHandResult, uiHand) deckIndex += handSize } resultInfo := chat1.NewUICoinFlipResultWithHands(uiHandResult) status.ResultInfo = &resultInfo } func (m *FlipManager) setGenericError(status *chat1.UICoinFlipStatus, errMsg string) { status.Phase = chat1.UICoinFlipPhase_ERROR status.ProgressText = errMsg errorInfo := chat1.NewUICoinFlipErrorWithGeneric(status.ProgressText) status.ErrorInfo = &errorInfo } func (m *FlipManager) resultToText(result chat1.UICoinFlipResult) string { typ, err := result.Typ() if err != nil { return "" } switch typ { case chat1.UICoinFlipResultTyp_COIN: if result.Coin() { return "HEADS" } return "TAILS" case chat1.UICoinFlipResultTyp_NUMBER: return result.Number() case chat1.UICoinFlipResultTyp_DECK: var cards []string for _, cardIndex := range result.Deck() { cards = append(cards, m.cardReverseMap[cardIndex]) } return strings.TrimRight(strings.Join(cards, ", "), " ") case chat1.UICoinFlipResultTyp_SHUFFLE: return strings.TrimRight(strings.Join(result.Shuffle(), ", "), " ") case chat1.UICoinFlipResultTyp_HANDS: var rows []string for index, hand := range result.Hands() { if len(hand.Hand) == 0 { rows = append(rows, fmt.Sprintf("%d. %s: 🤨", index+1, hand.Target)) } else { var cards []string for _, cardIndex := range hand.Hand { cards = append(cards, m.cardReverseMap[cardIndex]) } rows = append(rows, fmt.Sprintf("%d. %s: %s", index+1, hand.Target, strings.TrimRight(strings.Join(cards, ", "), " "))) } } return strings.Join(rows, "\n") } return "" } func (m *FlipManager) addResult(ctx context.Context, status *chat1.UICoinFlipStatus, result flip.Result, convID chat1.ConversationID, ) { defer func() { if status.ResultInfo != nil { status.ResultText = m.resultToText(*status.ResultInfo) } if len(status.ResultText) > 0 { status.ProgressText += " (complete)" } }() hmi, err := m.getHostMessageInfo(ctx, convID) switch { case err != nil: m.Debug(ctx, "addResult: failed to describe result: %s", err) m.setGenericError(status, "Failed to describe result") case result.Big != nil: lb := new(big.Int) res := new(big.Int) lb.SetString(hmi.LowerBound, 0) res.Add(lb, result.Big) resultInfo := chat1.NewUICoinFlipResultWithNumber(res.String()) status.ResultInfo = &resultInfo case result.Bool != nil: resultInfo := chat1.NewUICoinFlipResultWithCoin(*result.Bool) status.ResultInfo = &resultInfo case result.Int != nil: resultInfo := chat1.NewUICoinFlipResultWithNumber(fmt.Sprintf("%d", *result.Int)) status.ResultInfo = &resultInfo case len(result.Shuffle) > 0: if hmi.HandCardCount > 0 { m.addCardHandResult(ctx, status, result, hmi) return } if len(hmi.ShuffleItems) != len(result.Shuffle) { m.setGenericError(status, "Failed to describe shuffle result") return } items := make([]string, len(hmi.ShuffleItems)) for index, r := range result.Shuffle { items[index] = utils.EscapeForDecorate(ctx, hmi.ShuffleItems[r]) } var resultInfo chat1.UICoinFlipResult if hmi.DeckShuffle { var cardIndexes []int for _, card := range items { cardIndex, err := m.cardIndex(card) if err != nil { m.Debug(ctx, "addResult: failed to get card: %s", err) m.setGenericError(status, "Failed to describe deck result") return } cardIndexes = append(cardIndexes, cardIndex) } resultInfo = chat1.NewUICoinFlipResultWithDeck(cardIndexes) } else { resultInfo = chat1.NewUICoinFlipResultWithShuffle(items) } status.ResultInfo = &resultInfo } } func (m *FlipManager) queueDirtyGameID(ctx context.Context, gameID chat1.FlipGameID, force bool) { m.gamesMu.Lock() m.dirtyGames[gameID.FlipGameIDStr()] = gameID m.gamesMu.Unlock() if force { select { case m.forceCh <- struct{}{}: default: m.Debug(ctx, "queueDirtyGameID: failed to write onto forceCh!") } } } func (m *FlipManager) getErrorParticipant(ctx context.Context, a flip.UserDevice) chat1.UICoinFlipErrorParticipant { username, deviceName, _, err := m.G().GetUPAKLoader().LookupUsernameAndDevice(ctx, keybase1.UID(a.U.String()), keybase1.DeviceID(a.D.String())) if err != nil { m.Debug(ctx, "getErrorParticipant: failed to get names: %s", err) return chat1.UICoinFlipErrorParticipant{ User: a.U.String(), Device: a.D.String(), } } return chat1.UICoinFlipErrorParticipant{ User: username.String(), Device: deviceName, } } func (m *FlipManager) formatError(ctx context.Context, rawErr error) chat1.UICoinFlipError { switch terr := rawErr.(type) { case flip.AbsenteesError: // lookup all the absentees var absentees []chat1.UICoinFlipErrorParticipant for _, a := range terr.Absentees { absentees = append(absentees, m.getErrorParticipant(ctx, a)) } return chat1.NewUICoinFlipErrorWithAbsentee(chat1.UICoinFlipAbsenteeError{ Absentees: absentees, }) case flip.TimeoutError: return chat1.NewUICoinFlipErrorWithTimeout() case flip.GameAbortedError: return chat1.NewUICoinFlipErrorWithAborted() case flip.DuplicateRegistrationError: return chat1.NewUICoinFlipErrorWithDupreg(m.getErrorParticipant(ctx, terr.U)) case flip.DuplicateCommitmentCompleteError: return chat1.NewUICoinFlipErrorWithDupcommitcomplete(m.getErrorParticipant(ctx, terr.U)) case flip.DuplicateRevealError: return chat1.NewUICoinFlipErrorWithDupreveal(m.getErrorParticipant(ctx, terr.U)) case flip.CommitmentMismatchError: return chat1.NewUICoinFlipErrorWithCommitmismatch(m.getErrorParticipant(ctx, terr.U)) } return chat1.NewUICoinFlipErrorWithGeneric(rawErr.Error()) } func (m *FlipManager) handleSummaryUpdate(ctx context.Context, gameID chat1.FlipGameID, update *flip.GameSummary, statusConvID chat1.ConversationID, resultConvID chat1.ConversationID, force bool, ) (status chat1.UICoinFlipStatus) { defer m.queueDirtyGameID(ctx, gameID, force) if update.Err != nil { var parts []chat1.UICoinFlipParticipant oldGame, ok := m.games.Get(gameID.FlipGameIDStr()) if ok { parts = oldGame.(chat1.UICoinFlipStatus).Participants } formatted := m.formatError(ctx, update.Err) status = chat1.UICoinFlipStatus{ GameID: gameID.FlipGameIDStr(), ConvID: statusConvID.ConvIDStr(), Phase: chat1.UICoinFlipPhase_ERROR, ProgressText: fmt.Sprintf("Something went wrong: %s", update.Err), Participants: parts, ErrorInfo: &formatted, } m.games.Add(gameID.FlipGameIDStr(), status) return status } status = chat1.UICoinFlipStatus{ GameID: gameID.FlipGameIDStr(), ConvID: statusConvID.ConvIDStr(), Phase: chat1.UICoinFlipPhase_COMPLETE, } m.addResult(ctx, &status, update.Result, resultConvID) for _, p := range update.Players { m.addParticipant(ctx, &status, flip.CommitmentUpdate{ User: p.Device, Commitment: p.Commitment, }) if p.Reveal != nil { m.addReveal(ctx, &status, flip.RevealUpdate{ User: p.Device, Reveal: *p.Reveal, }) } } status.ProgressText = "Complete" m.games.Add(gameID.FlipGameIDStr(), status) return status } func (m *FlipManager) handleUpdate(ctx context.Context, update flip.GameStateUpdateMessage, force bool) (err error) { gameID := update.Metadata.GameID defer m.Trace(ctx, &err, "handleUpdate: gameID: %s", gameID)() defer func() { if err == nil { m.queueDirtyGameID(ctx, gameID, force) } }() var status chat1.UICoinFlipStatus rawGame, ok := m.games.Get(gameID.FlipGameIDStr()) if ok { status = rawGame.(chat1.UICoinFlipStatus) } else { status = chat1.UICoinFlipStatus{ GameID: gameID.FlipGameIDStr(), } } if len(status.ConvID) == 0 { hmi, hmiErr := m.getHostMessageInfo(ctx, update.Metadata.ConversationID) if hmiErr != nil { m.Debug(ctx, "handleUpdate: failed to get host message info: %s", hmiErr) } else { status.ConvID = hmi.ConvID.ConvIDStr() } } switch { case update.Err != nil: m.Debug(ctx, "handleUpdate: error received") status.Phase = chat1.UICoinFlipPhase_ERROR status.ProgressText = fmt.Sprintf("Something went wrong: %s", update.Err) formatted := m.formatError(ctx, update.Err) status.ErrorInfo = &formatted case update.Commitment != nil: m.Debug(ctx, "handleUpdate: commit received") // Only care about these while we are in the commitment phase if status.Phase == chat1.UICoinFlipPhase_COMMITMENT { status.ErrorInfo = nil status.Phase = chat1.UICoinFlipPhase_COMMITMENT m.addParticipant(ctx, &status, *update.Commitment) } case update.CommitmentComplete != nil: m.Debug(ctx, "handleUpdate: complete received") status.ErrorInfo = nil status.Phase = chat1.UICoinFlipPhase_REVEALS m.finalizeParticipants(ctx, &status, *update.CommitmentComplete) case update.Reveal != nil: m.Debug(ctx, "handleUpdate: reveal received") m.addReveal(ctx, &status, *update.Reveal) case update.Result != nil: m.Debug(ctx, "handleUpdate: result received") status.Phase = chat1.UICoinFlipPhase_COMPLETE status.ErrorInfo = nil m.addResult(ctx, &status, *update.Result, update.Metadata.ConversationID) default: return errors.New("unknown update kind") } m.games.Add(gameID.FlipGameIDStr(), status) return nil } func (m *FlipManager) updateLoop(shutdownCh chan struct{}) { m.Debug(context.Background(), "updateLoop: starting") for { select { case msg := <-m.dealer.UpdateCh(): err := m.handleUpdate(m.makeBkgContext(), msg, false) if err != nil { m.Debug(context.TODO(), "updateLoop: error handling update: %+v", err) } case <-shutdownCh: m.Debug(context.Background(), "updateLoop: exiting") return } } } const gameIDTopicNamePrefix = "__keybase_coinflip_game_" func (m *FlipManager) gameTopicNameFromGameID(gameID chat1.FlipGameID) string { return fmt.Sprintf("%s%s", gameIDTopicNamePrefix, gameID) } var errFailedToParse = errors.New("failed to parse") func (m *FlipManager) parseMultiDie(arg string, nPlayersApprox int) (start flip.Start, err error) { lb := new(big.Int) val, ok := lb.SetString(arg, 0) if !ok { return start, errFailedToParse } // needs to be a positive number > 0 if val.Sign() <= 0 { return start, errFailedToParse } return flip.NewStartWithBigInt(m.clock.Now(), val, nPlayersApprox), nil } const shuffleSeparaters = ",," func (m *FlipManager) parseShuffle(arg string, nPlayersApprox int) (start flip.Start, metadata flipTextMetadata, err error) { if strings.ContainsAny(arg, shuffleSeparaters) { var shuffleItems []string for _, tok := range strings.FieldsFunc(arg, func(c rune) bool { return strings.ContainsRune(shuffleSeparaters, c) }) { shuffleItems = append(shuffleItems, strings.Trim(tok, " ")) } return flip.NewStartWithShuffle(m.clock.Now(), int64(len(shuffleItems)), nPlayersApprox), flipTextMetadata{ ShuffleItems: shuffleItems, }, nil } return start, metadata, errFailedToParse } func (m *FlipManager) parseRange(arg string, nPlayersApprox int) (start flip.Start, metadata flipTextMetadata, err error) { if !strings.Contains(arg, "..") || strings.Contains(arg, ",") { return start, metadata, errFailedToParse } toks := strings.Split(arg, "..") if len(toks) != 2 { return start, metadata, errFailedToParse } lb, ok := new(big.Int).SetString(toks[0], 0) if !ok { return start, metadata, errFailedToParse } ub, ok := new(big.Int).SetString(toks[1], 0) if !ok { return start, metadata, errFailedToParse } one := new(big.Int).SetInt64(1) diff := new(big.Int) diff.Sub(ub, lb) diff = diff.Add(diff, one) if diff.Sign() <= 0 { return start, metadata, errFailedToParse } return flip.NewStartWithBigInt(m.clock.Now(), diff, nPlayersApprox), flipTextMetadata{ LowerBound: lb.String(), }, nil } func (m *FlipManager) parseSpecials(arg string, usernames []string, nPlayersApprox int, ) (start flip.Start, metadata flipTextMetadata, err error) { switch { case strings.HasPrefix(arg, "cards"): deckShuffle, deckShuffleMetadata, _ := m.parseShuffle(m.deck, nPlayersApprox) deckShuffleMetadata.DeckShuffle = true if arg == "cards" { return deckShuffle, deckShuffleMetadata, nil } toks := strings.Split(arg, " ") if len(toks) < 3 { return deckShuffle, deckShuffleMetadata, nil } handCount, err := strconv.ParseUint(toks[1], 0, 0) if err != nil { return deckShuffle, deckShuffleMetadata, nil } var targets []string handParts := strings.Split(strings.Join(toks[2:], " "), ",") if len(handParts) == 1 && (handParts[0] == "@here" || handParts[0] == "@channel") { targets = usernames } else { for _, pt := range handParts { t := strings.Trim(pt, " ") if len(t) > 0 { targets = append(targets, t) } } } return deckShuffle, flipTextMetadata{ ShuffleItems: deckShuffleMetadata.ShuffleItems, HandCardCount: uint(handCount), HandTargets: targets, }, nil case arg == "@here" || arg == "@channel": if len(usernames) == 0 { return flip.NewStartWithShuffle(m.clock.Now(), 1, nPlayersApprox), flipTextMetadata{ ShuffleItems: []string{"@here"}, ConvMemberShuffle: true, }, nil } return flip.NewStartWithShuffle(m.clock.Now(), int64(len(usernames)), nPlayersApprox), flipTextMetadata{ ShuffleItems: usernames, ConvMemberShuffle: true, }, nil } return start, metadata, errFailedToParse } func (m *FlipManager) startFromText(text string, convMembers []string) (start flip.Start, metadata flipTextMetadata) { var err error nPlayersApprox := len(convMembers) toks := strings.Split(strings.TrimRight(text, " "), " ") if len(toks) == 1 { return flip.NewStartWithBool(m.clock.Now(), nPlayersApprox), flipTextMetadata{} } // Combine into one argument if there is more than one arg := strings.Join(toks[1:], " ") // Check for special flips if start, metadata, err = m.parseSpecials(arg, convMembers, nPlayersApprox); err == nil { return start, metadata } // Check for /flip 20 if start, err = m.parseMultiDie(arg, nPlayersApprox); err == nil { return start, flipTextMetadata{ LowerBound: "1", } } // Check for /flip mikem,karenm,lisam if start, metadata, err = m.parseShuffle(arg, nPlayersApprox); err == nil { return start, metadata } // Check for /flip 2..8 if start, metadata, err = m.parseRange(arg, nPlayersApprox); err == nil { return start, metadata } // Just shuffle the one unknown thing return flip.NewStartWithShuffle(m.clock.Now(), 1, nPlayersApprox), flipTextMetadata{ ShuffleItems: []string{arg}, } } func (m *FlipManager) getHostMessageInfo(ctx context.Context, convID chat1.ConversationID) (res hostMessageInfo, err error) { m.Debug(ctx, "getHostMessageInfo: getting host message info for: %s", convID) uid, err := utils.AssertLoggedInUID(ctx, m.G()) if err != nil { return res, err } reason := chat1.GetThreadReason_COINFLIP msg, err := m.G().ChatHelper.GetMessage(ctx, uid, convID, 2, false, &reason) if err != nil { return res, err } if !msg.IsValid() { return res, errors.New("host message invalid") } if !msg.Valid().MessageBody.IsType(chat1.MessageType_FLIP) { return res, fmt.Errorf("invalid host message type: %v", msg.GetMessageType()) } body := msg.Valid().MessageBody.Flip().Text if err := json.Unmarshal([]byte(body), &res); err != nil { return res, err } return res, nil } func (m *FlipManager) DescribeFlipText(ctx context.Context, text string) string { defer m.Trace(ctx, nil, "DescribeFlipText")() start, metadata := m.startFromText(text, nil) typ, err := start.Params.T() if err != nil { m.Debug(ctx, "DescribeFlipText: failed get start typ: %s", err) return "" } switch typ { case flip.FlipType_BIG: if metadata.LowerBound == "1" { return fmt.Sprintf("*%s-sided die roll*", new(big.Int).SetBytes(start.Params.Big())) } lb, _ := new(big.Int).SetString(metadata.LowerBound, 0) ub := new(big.Int).Sub(new(big.Int).SetBytes(start.Params.Big()), new(big.Int).SetInt64(1)) return fmt.Sprintf("*Number in range %s..%s*", metadata.LowerBound, new(big.Int).Add(lb, ub)) case flip.FlipType_BOOL: return "*HEADS* or *TAILS*" case flip.FlipType_SHUFFLE: if metadata.DeckShuffle { return "*Shuffling a deck of cards*" } else if metadata.ConvMemberShuffle { return "*Shuffling all members of the conversation*" } else if metadata.HandCardCount > 0 { return fmt.Sprintf("*Dealing hands of %d cards*", metadata.HandCardCount) } return fmt.Sprintf("*Shuffling %s*", strings.TrimRight(strings.Join(metadata.ShuffleItems, ", "), " ")) } return "" } func (m *FlipManager) setStartFlipSendStatus(ctx context.Context, outboxID chat1.OutboxID, status types.FlipSendStatus, flipConvID *chat1.ConversationID, ) { payload := startFlipSendStatus{ status: status, } if flipConvID != nil { payload.flipConvID = *flipConvID } m.flipConvs.Add(outboxID.String(), payload) m.G().MessageDeliverer.ForceDeliverLoop(ctx) } // StartFlip implements the types.CoinFlipManager interface func (m *FlipManager) StartFlip(ctx context.Context, uid gregor1.UID, hostConvID chat1.ConversationID, tlfName, text string, inOutboxID *chat1.OutboxID, ) (err error) { defer m.Trace(ctx, &err, "StartFlip: convID: %s", hostConvID)() gameID := flip.GenerateGameID() m.Debug(ctx, "StartFlip: using gameID: %s", gameID) // Get host conv using local storage, just bail out if we don't have it hostConv, err := utils.GetVerifiedConv(ctx, m.G(), uid, hostConvID, types.InboxSourceDataSourceLocalOnly) if err != nil { return err } // First generate the message representing the flip into the host conversation. We also wait for it // to actually get sent before doing anything flip related. var outboxID chat1.OutboxID if inOutboxID != nil { outboxID = *inOutboxID } else { if outboxID, err = storage.NewOutboxID(); err != nil { return err } } // Generate dev channel for game message var conv chat1.ConversationLocal var participants []string m.setStartFlipSendStatus(ctx, outboxID, types.FlipSendStatusInProgress, nil) convCreatedCh := make(chan error) go func() { var err error topicName := m.gameTopicNameFromGameID(gameID) membersType := hostConv.GetMembersType() switch membersType { case chat1.ConversationMembersType_IMPTEAMUPGRADE: // just override this to use native membersType = chat1.ConversationMembersType_IMPTEAMNATIVE fallthrough case chat1.ConversationMembersType_IMPTEAMNATIVE: tlfName = utils.AddUserToTLFName(m.G(), tlfName, keybase1.TLFVisibility_PRIVATE, membersType) default: } // Get conv participants if participants, err = utils.GetConvParticipantUsernames(ctx, m.G(), uid, hostConvID); err != nil { convCreatedCh <- err return } // Preserve the ephemeral lifetime from the conv/message to the game // conversation. elf, err := utils.EphemeralLifetimeFromConv(ctx, m.G(), hostConv) if err != nil { m.Debug(ctx, "StartFlip: failed to get ephemeral lifetime from conv: %s", err) convCreatedCh <- err return } var retentionPolicy *chat1.RetentionPolicy if elf != nil { retentionPolicy = new(chat1.RetentionPolicy) *retentionPolicy = chat1.NewRetentionPolicyWithEphemeral(chat1.RpEphemeral{Age: *elf}) } conv, _, err = NewConversationWithMemberSourceConv(ctx, m.G(), uid, tlfName, &topicName, chat1.TopicType_DEV, membersType, keybase1.TLFVisibility_PRIVATE, nil, m.ri, NewConvFindExistingSkip, retentionPolicy, &hostConvID) convCreatedCh <- err }() listener := newSentMessageListener(m.G(), outboxID) nid := m.G().NotifyRouter.AddListener(listener) if err := m.sendNonblock(ctx, uid, hostConvID, text, tlfName, outboxID, gameID, chat1.TopicType_CHAT); err != nil { m.Debug(ctx, "StartFlip: failed to send flip message: %s", err) m.setStartFlipSendStatus(ctx, outboxID, types.FlipSendStatusError, nil) m.G().NotifyRouter.RemoveListener(nid) return err } if err := <-convCreatedCh; err != nil { m.setStartFlipSendStatus(ctx, outboxID, types.FlipSendStatusError, nil) m.G().NotifyRouter.RemoveListener(nid) return err } flipConvID := conv.GetConvID() m.Debug(ctx, "StartFlip: flip conv created: %s", flipConvID) m.setStartFlipSendStatus(ctx, outboxID, types.FlipSendStatusSent, &flipConvID) sendRes := <-listener.listenCh m.G().NotifyRouter.RemoveListener(nid) if sendRes.Err != nil { return sendRes.Err } // Record metadata of the host message into the game thread as the first message m.Debug(ctx, "StartFlip: generating parameters for %d players", len(participants)) start, metadata := m.startFromText(text, participants) infoBody, err := json.Marshal(hostMessageInfo{ flipTextMetadata: metadata, ConvID: hostConvID, MsgID: sendRes.MsgID, }) if err != nil { return err } if err := m.G().ChatHelper.SendMsgByID(ctx, flipConvID, tlfName, chat1.NewMessageBodyWithFlip(chat1.MessageFlip{ Text: string(infoBody), GameID: gameID, }), chat1.MessageType_FLIP, keybase1.TLFVisibility_PRIVATE); err != nil { return err } // Start the game return m.dealer.StartFlipWithGameID(ctx, start, flipConvID, gameID) } func (m *FlipManager) shouldIgnoreInject(ctx context.Context, hostConvID, flipConvID chat1.ConversationID, gameID chat1.FlipGameID, ) bool { if m.dealer.IsGameActive(ctx, flipConvID, gameID) { return false } // Ignore any flip messages for non-active games when not in the foreground appBkg := m.G().IsMobileAppType() && m.G().MobileAppState.State() != keybase1.MobileAppState_FOREGROUND partViolation := m.isConvParticipationViolation(ctx, hostConvID) return appBkg || partViolation } func (m *FlipManager) isConvParticipationViolation(ctx context.Context, convID chat1.ConversationID) bool { m.partMu.Lock() defer m.partMu.Unlock() if rec, ok := m.convParticipations[convID.ConvIDStr()]; ok { m.Debug(ctx, "isConvParticipationViolation: rec: count: %d remain: %v", rec.count, m.clock.Now().Sub(rec.reset)) if rec.reset.Before(m.clock.Now()) { return false } if rec.count >= m.maxConvParticipations { m.Debug(ctx, "isConvParticipationViolation: violation: convID: %s remaining: %v", convID, m.clock.Now().Sub(rec.reset)) return true } return false } return false } func (m *FlipManager) recordConvParticipation(ctx context.Context, convID chat1.ConversationID) { m.partMu.Lock() defer m.partMu.Unlock() addNew := func() { m.convParticipations[convID.ConvIDStr()] = convParticipationsRateLimit{ count: 1, reset: m.clock.Now().Add(m.maxConvParticipationsReset), } } if rec, ok := m.convParticipations[convID.ConvIDStr()]; ok { if rec.reset.Before(m.clock.Now()) { addNew() } else { rec.count++ m.convParticipations[convID.ConvIDStr()] = rec } } else { addNew() } } func (m *FlipManager) injectIncomingChat(ctx context.Context, uid gregor1.UID, convID, hostConvID chat1.ConversationID, gameID chat1.FlipGameID, msg chat1.MessageUnboxed, ) error { if !msg.IsValid() { m.Debug(ctx, "injectIncomingChat: skipping invalid message: %d", msg.GetMessageID()) return errors.New("invalid message") } if msg.Valid().ClientHeader.OutboxID != nil && m.isSentOutboxID(ctx, gameID, *msg.Valid().ClientHeader.OutboxID) { m.Debug(ctx, "injectIncomingChat: skipping sent outboxID message: %d outboxID: %s ", msg.GetMessageID(), msg.Valid().ClientHeader.OutboxID) return nil } body := msg.Valid().MessageBody if !body.IsType(chat1.MessageType_FLIP) { return errors.New("non-flip message") } sender := flip.UserDevice{ U: msg.Valid().ClientHeader.Sender, D: msg.Valid().ClientHeader.SenderDevice, } m.recordConvParticipation(ctx, hostConvID) // record the inject for rate limiting purposes m.gameMsgIDs.Add(gameID.FlipGameIDStr(), msg.GetMessageID()) m.Debug(ctx, "injectIncomingChat: injecting: gameID: %s msgID: %d", gameID, msg.GetMessageID()) return m.dealer.InjectIncomingChat(ctx, sender, convID, gameID, flip.MakeGameMessageEncoded(body.Flip().Text), m.isStartMsgID(msg.GetMessageID())) } func (m *FlipManager) updateActiveGame(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID, hostConvID chat1.ConversationID, nextMsg chat1.MessageUnboxed, gameID chat1.FlipGameID, ) (err error) { defer func() { if err == nil { if err = m.injectIncomingChat(ctx, uid, convID, hostConvID, gameID, nextMsg); err != nil { m.Debug(ctx, "updateActiveGame: failed to inject next message: %s", err) } } }() m.Debug(ctx, "updateActiveGame: uid: %s convID: %s gameID: %s nextMsgID: %d", uid, convID, gameID, nextMsg.GetMessageID()) // Get current msg ID of the game if we know about it var msgIDStart chat1.MessageID if storedMsgIDIface, ok := m.gameMsgIDs.Get(gameID.FlipGameIDStr()); ok { storedMsgID := storedMsgIDIface.(chat1.MessageID) if nextMsg.GetMessageID() == storedMsgID+1 { m.Debug(ctx, "updateActiveGame: truly incremental update, injecting...") return nil } else if nextMsg.GetMessageID() <= storedMsgID { m.Debug(ctx, "updateActiveGame: update from the past, ignoring: stored: %d", storedMsgID) return errors.New("update from the past") } m.Debug(ctx, "updateActiveGame: gapped update: storedMsgID: %d", storedMsgID) msgIDStart = storedMsgID } else { if m.isStartMsgID(nextMsg.GetMessageID()) { // if this is a start msg, then just send it in m.Debug(ctx, "updateActiveGame: starting new game: convID: %s gameID: %s", convID, gameID) return nil } m.Debug(ctx, "updateActiveGame: unknown game, setting start to 0") } // Otherwise, grab the thread and inject everything that has happened so far tv, err := m.G().ConvSource.PullFull(ctx, convID, uid, chat1.GetThreadReason_COINFLIP, nil, nil) if err != nil { return err } m.Debug(ctx, "updateActiveGame: got %d messages, injecting...", len(tv.Messages)) for i := len(tv.Messages) - 3; i >= 0; i-- { msg := tv.Messages[i] if msg.GetMessageID() <= msgIDStart { m.Debug(ctx, "updateActiveGame: skipping known msgID: %d", msg.GetMessageID()) continue } if msg.GetMessageID() >= nextMsg.GetMessageID() { m.Debug(ctx, "updateActiveGame: reached current msgID, finishing...") return nil } if err := m.injectIncomingChat(ctx, uid, convID, hostConvID, gameID, msg); err != nil { m.Debug(ctx, "updateActiveGame: failed to inject: %s", err) } } return nil } func (m *FlipManager) maybeInjectLoop(shutdownCh chan struct{}) { m.Debug(context.Background(), "maybeInjectLoop: starting") for { select { case closure := <-m.maybeInjectCh: closure() case <-shutdownCh: m.Debug(context.Background(), "maybeInjectLoop: exiting loop") return } } } // MaybeInjectFlipMessage implements the types.CoinFlipManager interface func (m *FlipManager) MaybeInjectFlipMessage(ctx context.Context, boxedMsg chat1.MessageBoxed, inboxVers chat1.InboxVers, uid gregor1.UID, convID chat1.ConversationID, topicType chat1.TopicType, ) bool { // earliest of outs if this isn't a dev convo, an error, or the outbox ID message if topicType != chat1.TopicType_DEV || boxedMsg.GetMessageType() != chat1.MessageType_FLIP || m.isHostMessageInfoMsgID(boxedMsg.GetMessageID()) { return false } defer m.Trace(ctx, nil, "MaybeInjectFlipMessage: uid: %s convID: %s", uid, convID)() // Update inbox for this guy if err := m.G().InboxSource.UpdateInboxVersion(ctx, uid, inboxVers); err != nil { m.Debug(ctx, "MaybeInjectFlipMessage: failed to update inbox version: %s", err) // charge forward here, we will figure it out } if err := storage.New(m.G(), nil).SetMaxMsgID(ctx, convID, uid, boxedMsg.GetMessageID()); err != nil { m.Debug(ctx, "MaybeInjectFlipMessage: failed to write max msgid: %s", err) // charge forward from this error } // Unbox the message conv, err := utils.GetUnverifiedConv(ctx, m.G(), uid, convID, types.InboxSourceDataSourceAll) if err != nil { m.Debug(ctx, "MaybeInjectFlipMessage: failed to get conversation for unbox: %s", err) return true } msg, err := NewBoxer(m.G()).UnboxMessage(ctx, boxedMsg, conv.Conv, nil) if err != nil { m.Debug(ctx, "MaybeInjectFlipMessage: failed to unbox: %s", err) return true } if !msg.IsValid() { m.Debug(ctx, "MaybeInjectFlipMessage: failed to unbox msg") return true } body := msg.Valid().MessageBody if !body.IsType(chat1.MessageType_FLIP) { m.Debug(ctx, "MaybeInjectFlipMessage: bogus flip message with a non-flip body") return true } // Ignore anything from the current device ctx = globals.BackgroundChatCtx(ctx, m.G()) select { case m.maybeInjectCh <- func() { defer m.Trace(ctx, nil, "MaybeInjectFlipMessage(goroutine): uid: %s convID: %s id: %d", uid, convID, msg.GetMessageID())() if m.Me().Eq(flip.UserDevice{ U: msg.Valid().ClientHeader.Sender, D: msg.Valid().ClientHeader.SenderDevice, }) { m.gameMsgIDs.Add(body.Flip().GameID.FlipGameIDStr(), msg.GetMessageID()) return } // Check to see if we are going to participate from this inject hmi, err := m.getHostMessageInfo(ctx, convID) if err != nil { m.Debug(ctx, "MaybeInjectFlipMessage: failed to get host message info: %s", err) return } if m.shouldIgnoreInject(ctx, hmi.ConvID, convID, body.Flip().GameID) { m.Debug(ctx, "MaybeInjectFlipMessage: ignored flip message") return } // Check to see if the game is unknown, and if so, then rebuild and see what we can do if err := m.updateActiveGame(ctx, uid, convID, hmi.ConvID, msg, body.Flip().GameID); err != nil { m.Debug(ctx, "MaybeInjectFlipMessage: failed to rebuild non-active game: %s", err) } }: default: m.Debug(ctx, "MaybeInjectFlipMessage: failed to dispatch job, queue full!") } return true } func (m *FlipManager) HasActiveGames(ctx context.Context) bool { return m.dealer.HasActiveGames(ctx) } func (m *FlipManager) loadGame(ctx context.Context, job loadGameJob) (err error) { defer m.Trace(ctx, &err, "loadGame: hostConvID: %s flipConvID: %s gameID: %s hostMsgID: %d", job.hostConvID, job.flipConvID, job.gameID, job.hostMsgID)() defer func() { if err != nil { job.errCh <- err } }() // Check to make sure the flip conversation aligns with the host message flipConvID := job.flipConvID hmi, err := m.getHostMessageInfo(ctx, flipConvID) if err != nil { m.Debug(ctx, "loadGame: failed to get host message info: %s", err) return err } if !hmi.ConvID.Eq(job.hostConvID) || hmi.MsgID != job.hostMsgID { m.Debug(ctx, "loadGame: host message info mismatch: job.hostConvID: %s hmi.ConvID: %s job.hostMsgID: %d hmi.msgID: %d", job.hostConvID, hmi.ConvID, job.hostMsgID, hmi.MsgID) return errors.New("flip conversation does not match host message info") } tv, err := m.G().ConvSource.PullFull(ctx, flipConvID, job.uid, chat1.GetThreadReason_COINFLIP, nil, nil) if err != nil { m.Debug(ctx, "loadGame: failed to pull thread: %s", err) return err } if len(tv.Messages) < 3 { m.Debug(ctx, "loadGame: not enough messages to replay") return errors.New("not enough messages") } var history flip.GameHistory for index := len(tv.Messages) - 3; index >= 0; index-- { msg := tv.Messages[index] if !msg.IsValid() { m.Debug(ctx, "loadGame: skipping invalid message: id: %d", msg.GetMessageID()) continue } body := msg.Valid().MessageBody if !body.IsType(chat1.MessageType_FLIP) { continue } history = append(history, flip.GameMessageReplayed{ GameMessageWrappedEncoded: flip.GameMessageWrappedEncoded{ Sender: flip.UserDevice{ U: msg.Valid().ClientHeader.Sender, D: msg.Valid().ClientHeader.SenderDevice, }, GameID: job.gameID, Body: flip.MakeGameMessageEncoded(body.Flip().Text), FirstInConversation: m.isStartMsgID(msg.GetMessageID()), }, Time: msg.Valid().ServerHeader.Ctime.Time(), }) } m.Debug(ctx, "loadGame: playing back %d messages from history", len(history)) summary, err := flip.Replay(ctx, m, history) if err != nil { m.Debug(ctx, "loadGame: failed to replay history: %s", err) // Make sure we aren't current playing this game, and bail out if we are if m.dealer.IsGameActive(ctx, flipConvID, job.gameID) { m.Debug(ctx, "loadGame: game is currently active, bailing out") return errors.New("game is active") } // Spawn off this error notification in a goroutine and only deliver it if the game is not active // after the timer summary = &flip.GameSummary{ Err: err, } go func(ctx context.Context, summary *flip.GameSummary) { m.clock.Sleep(5 * time.Second) rawGame, ok := m.games.Get(job.gameID.FlipGameIDStr()) if ok { status := rawGame.(chat1.UICoinFlipStatus) switch status.Phase { case chat1.UICoinFlipPhase_ERROR: // we'll send our error if there is an error on the screen default: // any other phase we will send nothing m.Debug(ctx, "loadGame: after pausing, we have a status in phase: %v", status.Phase) return } } m.Debug(ctx, "loadGame: game had no action after pausing, sending error") job.resCh <- m.handleSummaryUpdate(ctx, job.gameID, summary, hmi.ConvID, flipConvID, true) }(globals.BackgroundChatCtx(ctx, m.G()), summary) } else { job.resCh <- m.handleSummaryUpdate(ctx, job.gameID, summary, hmi.ConvID, flipConvID, true) } return nil } func (m *FlipManager) loadGameLoop(shutdownCh chan struct{}) { for { select { case job := <-m.loadGameCh: ctx := m.makeBkgContext() if err := m.loadGame(ctx, job); err != nil { m.Debug(ctx, "loadGameLoop: failed to load game: %s", err) } case <-shutdownCh: return } } } // LoadFlip implements the types.CoinFlipManager interface func (m *FlipManager) LoadFlip(ctx context.Context, uid gregor1.UID, hostConvID chat1.ConversationID, hostMsgID chat1.MessageID, flipConvID chat1.ConversationID, gameID chat1.FlipGameID, ) (res chan chat1.UICoinFlipStatus, err chan error) { defer m.Trace(ctx, nil, "LoadFlip")() stored, ok := m.games.Get(gameID.FlipGameIDStr()) if ok { switch stored.(chat1.UICoinFlipStatus).Phase { case chat1.UICoinFlipPhase_ERROR: // do nothing here, just replay if we are storing an error default: m.queueDirtyGameID(ctx, gameID, true) res = make(chan chat1.UICoinFlipStatus, 1) status := stored.(chat1.UICoinFlipStatus) status.ConvID = hostConvID.ConvIDStr() res <- status err = make(chan error, 1) return res, err } } // If we miss the in-memory game storage, attempt to replay the game job := loadGameJob{ uid: uid, hostConvID: hostConvID, hostMsgID: hostMsgID, flipConvID: flipConvID, gameID: gameID, resCh: make(chan chat1.UICoinFlipStatus, 1), errCh: make(chan error, 1), } select { case m.loadGameCh <- job: default: m.Debug(ctx, "LoadFlip: queue full: gameID: %s hostConvID %s flipConvID: %s", gameID, hostConvID, flipConvID) job.errCh <- errors.New("queue full") } return job.resCh, job.errCh } func (m *FlipManager) IsFlipConversationCreated(ctx context.Context, outboxID chat1.OutboxID) (convID chat1.ConversationID, status types.FlipSendStatus) { defer m.Trace(ctx, nil, "IsFlipConversationCreated")() if rec, ok := m.flipConvs.Get(outboxID.String()); ok { status := rec.(startFlipSendStatus) switch status.status { case types.FlipSendStatusSent: convID = status.flipConvID default: // Nothing to do for other status types. } return convID, status.status } return convID, types.FlipSendStatusError } // CLogf implements the flip.DealersHelper interface func (m *FlipManager) CLogf(ctx context.Context, fmt string, args ...any) { m.Debug(ctx, fmt, args...) } // Clock implements the flip.DealersHelper interface func (m *FlipManager) Clock() clockwork.Clock { return m.clock } // ServerTime implements the flip.DealersHelper interface func (m *FlipManager) ServerTime(ctx context.Context) (res time.Time, err error) { ctx = globals.ChatCtx(ctx, m.G(), keybase1.TLFIdentifyBehavior_CHAT_SKIP, nil, nil) defer m.Trace(ctx, &err, "ServerTime")() if m.testingServerClock != nil { return m.testingServerClock.Now(), nil } sres, err := m.ri().ServerNow(ctx) if err != nil { return res, err } return sres.Now.Time(), nil } func (m *FlipManager) sendNonblock(ctx context.Context, initiatorUID gregor1.UID, convID chat1.ConversationID, text, tlfName string, outboxID chat1.OutboxID, gameID chat1.FlipGameID, topicType chat1.TopicType, ) error { sender := NewNonblockingSender(m.G(), NewBlockingSender(m.G(), NewBoxer(m.G()), m.ri)) _, _, err := sender.Send(ctx, convID, chat1.MessagePlaintext{ MessageBody: chat1.NewMessageBodyWithFlip(chat1.MessageFlip{ Text: text, GameID: gameID, }), ClientHeader: chat1.MessageClientHeader{ TlfName: tlfName, MessageType: chat1.MessageType_FLIP, Conv: chat1.ConversationIDTriple{ TopicType: topicType, }, // Prefill this value in case a restricted bot is running the flip // so bot keys are used instead of regular team keys. BotUID: &initiatorUID, }, }, 0, &outboxID, nil, nil) return err } func (m *FlipManager) isSentOutboxID(ctx context.Context, gameID chat1.FlipGameID, outboxID chat1.OutboxID) bool { m.gameOutboxIDMu.Lock() defer m.gameOutboxIDMu.Unlock() if omIface, ok := m.gameOutboxIDs.Get(gameID.FlipGameIDStr()); ok { om := omIface.(map[string]bool) return om[outboxID.String()] } return false } func (m *FlipManager) registerSentOutboxID(ctx context.Context, gameID chat1.FlipGameID, outboxID chat1.OutboxID, ) { m.gameOutboxIDMu.Lock() defer m.gameOutboxIDMu.Unlock() var om map[string]bool if omIface, ok := m.gameOutboxIDs.Get(gameID.FlipGameIDStr()); ok { om = omIface.(map[string]bool) } else { om = make(map[string]bool) } om[outboxID.String()] = true m.gameOutboxIDs.Add(gameID.FlipGameIDStr(), om) } // SendChat implements the flip.DealersHelper interface func (m *FlipManager) SendChat(ctx context.Context, initatorUID gregor1.UID, convID chat1.ConversationID, gameID chat1.FlipGameID, msg flip.GameMessageEncoded, ) (err error) { ctx = globals.ChatCtx(ctx, m.G(), keybase1.TLFIdentifyBehavior_CHAT_SKIP, nil, nil) defer m.Trace(ctx, &err, "SendChat: convID: %s", convID)() uid, err := utils.AssertLoggedInUID(ctx, m.G()) if err != nil { return err } conv, err := utils.GetVerifiedConv(ctx, m.G(), uid, convID, types.InboxSourceDataSourceAll) if err != nil { return err } outboxID, err := storage.NewOutboxID() if err != nil { return err } m.registerSentOutboxID(ctx, gameID, outboxID) return m.sendNonblock(ctx, initatorUID, convID, msg.String(), conv.Info.TlfName, outboxID, gameID, chat1.TopicType_DEV) } // Me implements the flip.DealersHelper interface func (m *FlipManager) Me() flip.UserDevice { ad := m.G().ActiveDevice did := ad.DeviceID() hdid := make([]byte, libkb.DeviceIDLen) if err := did.ToBytes(hdid); err != nil { return flip.UserDevice{} } return flip.UserDevice{ U: gregor1.UID(ad.UID().ToBytes()), D: gregor1.DeviceID(hdid), } } // minFlipConnAge is how long the gregor connection must have been up before // this device will join someone else's flip. const minFlipConnAge = 30 * time.Second func (m *FlipManager) ShouldCommit(ctx context.Context) bool { if m.G().IsMobileAppType() { return true } if !m.G().DesktopAppState.AwakeAndUnlocked(m.G().MetaContext(ctx)) { m.Debug(ctx, "ShouldCommit -> false (not awake and unlocked)") return false } // A gregor connection this fresh usually means the machine just woke up // or reconnected (dark wakes included), and may vanish again before it // can reveal; sit this game out. Zero means no connection-age data // available, which is not grounds for a veto. if cs := m.G().ConnectivityMonitor.ConnectedSince(ctx); !cs.IsZero() { if age := m.clock.Now().Sub(cs); age < minFlipConnAge { m.Debug(ctx, "ShouldCommit -> false (gregor connection too young: %v)", age) return false } } return true } // clearGameCache should only be used by tests func (m *FlipManager) clearGameCache() { m.games.Purge() } type FlipVisualizer struct { width, height int commitmentColors [256]color.RGBA secretColors [256]color.RGBA commitmentMatchColors [256]color.RGBA } func NewFlipVisualizer(width, height int) *FlipVisualizer { v := &FlipVisualizer{ height: height, // 40 width: width, // 64 } for i := range 256 { v.commitmentColors[i] = color.RGBA{ R: uint8(i), //nolint:gosec // G115: Loop bounded by 256, safe for uint8 G: uint8((128 + i*5) % 128), //nolint:gosec // G115: Modulo by 128 ensures result fits in uint8 B: 255, A: 128, } v.secretColors[i] = color.RGBA{ R: 255, G: uint8(64 + i/2), //nolint:gosec // G115: 64 + (0-255)/2 = 64-191, fits in uint8 B: 0, A: 255, } v.commitmentMatchColors[i] = color.RGBA{ R: uint8(i * 3 / 4), //nolint:gosec // G115: (0-255)*3/4 = 0-191, fits in uint8 G: uint8((192 + i*4) % 64), //nolint:gosec // G115: Modulo by 64 ensures result fits in uint8 B: 255, A: 255, } } return v } func (v *FlipVisualizer) fillCell(img *image.NRGBA, x, y, cellHeight, cellWidth int, b byte, palette [256]color.RGBA, ) { for i := x; i < x+cellWidth; i++ { for j := y; j < y+cellHeight; j++ { img.Set(i, j, palette[b]) } } } func (v *FlipVisualizer) fillRow(img *image.NRGBA, startY, cellHeight, cellWidth int, source string, palette [256]color.RGBA, ) { b, _ := hex.DecodeString(source) x := 0 for i := range b { v.fillCell(img, x, startY, cellHeight, cellWidth, b[i], palette) x += cellWidth } } func (v *FlipVisualizer) Visualize(status *chat1.UICoinFlipStatus) { cellWidth := int(math.Round(float64(v.width) / 32.0)) v.width = 32 * cellWidth commitmentImg := image.NewNRGBA(image.Rect(0, 0, v.width, v.height)) secretImg := image.NewNRGBA(image.Rect(0, 0, v.width, v.height)) numParts := len(status.Participants) if numParts > 0 { startY := 0 // just add these next 2 things heightAccum := float64(0) // how far into the image we should be rawRowHeight := float64(v.height) / float64(numParts) for _, p := range status.Participants { heightAccum += rawRowHeight rowHeight := int(math.Round(heightAccum - float64(startY))) if rowHeight > 0 { if p.Reveal != nil { v.fillRow(commitmentImg, startY, rowHeight, cellWidth, p.Commitment, v.commitmentMatchColors) v.fillRow(secretImg, startY, rowHeight, cellWidth, *p.Reveal, v.secretColors) } else { v.fillRow(commitmentImg, startY, rowHeight, cellWidth, p.Commitment, v.commitmentColors) } startY += rowHeight } } } var commitmentBuf, secretBuf bytes.Buffer _ = png.Encode(&commitmentBuf, commitmentImg) _ = png.Encode(&secretBuf, secretImg) status.CommitmentVisualization = base64.StdEncoding.EncodeToString(commitmentBuf.Bytes()) status.RevealVisualization = base64.StdEncoding.EncodeToString(secretBuf.Bytes()) }