package chat import ( "encoding/hex" "errors" "fmt" "time" "github.com/keybase/client/go/chat/attachments" "github.com/keybase/client/go/chat/bots" "github.com/keybase/client/go/chat/globals" "github.com/keybase/client/go/chat/msgchecker" "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/engine" "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/client/go/teams" "github.com/keybase/clockwork" context "golang.org/x/net/context" ) type BlockingSender struct { globals.Contextified utils.DebugLabeler boxer *Boxer store attachments.Store getRi func() chat1.RemoteInterface prevPtrPagination *chat1.Pagination clock clockwork.Clock } var _ types.Sender = (*BlockingSender)(nil) func NewBlockingSender(g *globals.Context, boxer *Boxer, getRi func() chat1.RemoteInterface) *BlockingSender { return &BlockingSender{ Contextified: globals.NewContextified(g), DebugLabeler: utils.NewDebugLabeler(g.ExternalG(), "BlockingSender", false), getRi: getRi, boxer: boxer, store: attachments.NewS3Store(g, g.GetRuntimeDir()), clock: clockwork.NewRealClock(), prevPtrPagination: &chat1.Pagination{Num: 50}, } } func (s *BlockingSender) setPrevPagination(p *chat1.Pagination) { s.prevPtrPagination = p } func (s *BlockingSender) SetClock(clock clockwork.Clock) { s.clock = clock } func (s *BlockingSender) addSenderToMessage(msg chat1.MessagePlaintext) (chat1.MessagePlaintext, gregor1.UID, error) { uid := s.G().Env.GetUID() if uid.IsNil() { return chat1.MessagePlaintext{}, nil, libkb.LoginRequiredError{} } did := s.G().Env.GetDeviceID() if did.IsNil() { return chat1.MessagePlaintext{}, nil, libkb.DeviceRequiredError{} } huid := uid.ToBytes() if huid == nil { return chat1.MessagePlaintext{}, nil, errors.New("invalid UID") } hdid := make([]byte, libkb.DeviceIDLen) if err := did.ToBytes(hdid); err != nil { return chat1.MessagePlaintext{}, nil, err } header := msg.ClientHeader header.Sender = gregor1.UID(huid) header.SenderDevice = gregor1.DeviceID(hdid) updated := chat1.MessagePlaintext{ ClientHeader: header, MessageBody: msg.MessageBody, SupersedesOutboxID: msg.SupersedesOutboxID, } return updated, gregor1.UID(huid), nil } func (s *BlockingSender) addPrevPointersAndCheckConvID(ctx context.Context, msg chat1.MessagePlaintext, conv chat1.ConversationLocal) (resMsg chat1.MessagePlaintext, err error) { // Make sure the caller hasn't already assembled this list. For now, this // should never happen, and we'll return an error just in case we make a // mistake in the future. But if there's some use case in the future where // a caller wants to specify custom prevs, we can relax this. if len(msg.ClientHeader.Prev) != 0 { return resMsg, fmt.Errorf("addPrevPointersToMessage expects an empty prev list") } var thread chat1.ThreadView var prevs []chat1.MessagePreviousPointer pagination := &chat1.Pagination{ Num: s.prevPtrPagination.Num, } // If we fail to find anything to prev against after maxAttempts, we allow // the message to be send with an empty prev list. maxAttempts := 5 attempt := 0 reachedLast := false for { thread, err = s.G().ConvSource.Pull(ctx, conv.GetConvID(), msg.ClientHeader.Sender, chat1.GetThreadReason_PREPARE, nil, &chat1.GetThreadQuery{ DisableResolveSupersedes: true, }, pagination) if err != nil { return resMsg, err } else if thread.Pagination == nil { break } pagination.Next = thread.Pagination.Next if len(thread.Messages) == 0 { s.Debug(ctx, "no local messages found for prev pointers") } newPrevsForRegular, newPrevsForExploding, err := CheckPrevPointersAndGetUnpreved(&thread) if err != nil { return resMsg, err } var hasPrev bool if msg.IsEphemeral() { prevs = newPrevsForExploding hasPrev = len(newPrevsForExploding) > 0 } else { prevs = newPrevsForRegular // If we have only sent ephemeralMessages and are now sending a regular // message, we may have an empty list for newPrevsForRegular. In this // case we allow the `Prev` to be empty, so we don't want to abort in // the check on numPrev below. hasPrev = len(newPrevsForRegular) > 0 || len(newPrevsForExploding) > 0 } if hasPrev { break } else if thread.Pagination.Last && !reachedLast { s.Debug(ctx, "Could not find previous messages for prev pointers (of %v). Nuking local storage and retrying.", len(thread.Messages)) if err := s.G().ConvSource.Clear(ctx, conv.GetConvID(), msg.ClientHeader.Sender, &types.ClearOpts{ SendLocalAdminNotification: true, Reason: "missing prev pointer", }); err != nil { s.Debug(ctx, "Unable to clear conversation: %v, %v", conv.GetConvID(), err) break } attempt = 0 pagination.Next = nil // Make sure we only reset `attempt` once reachedLast = true continue } else if attempt >= maxAttempts || reachedLast { s.Debug(ctx, "Could not find previous messages for prev pointers (of %v), after %v attempts. Giving up.", len(thread.Messages), attempt) break } else { s.Debug(ctx, "Could not find previous messages for prev pointers (of %v), attempt: %v of %v, retrying", len(thread.Messages), attempt, maxAttempts) } attempt++ } for _, msg2 := range thread.Messages { if msg2.IsValid() { if err = s.checkConvID(ctx, conv, msg, msg2); err != nil { s.Debug(ctx, "Unable to checkConvID: %s", msg2.DebugString()) return resMsg, err } break } } // Make an attempt to avoid changing anything in the input message. There // are a lot of shared pointers though, so this is header := msg.ClientHeader header.Prev = prevs updated := chat1.MessagePlaintext{ ClientHeader: header, MessageBody: msg.MessageBody, } return updated, nil } // Check that the {ConvID,ConvTriple,TlfName} of msgToSend matches both the ConvID and an existing message from the questionable ConvID. // `convID` is the convID that `msgToSend` will be posted to. // `msgReference` is a validated message from `convID`. // The misstep that this method checks for is thus: The frontend may post a message while viewing an "untrusted inbox view". // That message (msgToSend) will have the header.{TlfName,TlfPublic} set to the user's intention. // But the header.Conv.{Tlfid,TopicType,TopicID} and the convID to post to may be erroneously set to a different conversation's values. // This method checks that all of those fields match. Using `msgReference` as the validated link from {TlfName,TlfPublic} <-> ConvTriple. func (s *BlockingSender) checkConvID(ctx context.Context, conv chat1.ConversationLocal, msgToSend chat1.MessagePlaintext, msgReference chat1.MessageUnboxed) error { headerQ := msgToSend.ClientHeader headerRef := msgReference.Valid().ClientHeader fmtConv := func(conv chat1.ConversationIDTriple) string { return hex.EncodeToString(conv.Hash()) } if !headerQ.Conv.Derivable(conv.GetConvID()) { s.Debug(ctx, "checkConvID: ConvID %s 0 { // TODO HOTPOT-330 Add support for "hidden" messages for multiple bots msg.ClientHeader.BotUID = &botUIDs[0] } s.Debug(ctx, "applyTeamBotSettings: matched %d bots, applied %v", len(botUIDs), msg.ClientHeader.BotUID) encInfo, err := s.boxer.GetEncryptionInfo(ctx, &msg, membersType, skp) if err != nil { s.Debug(ctx, "Prepare: error getting encryption info: %s", err) return res, err } boxed, err := s.boxer.BoxMessage(ctx, msg, membersType, skp, &encInfo) if err != nil { s.Debug(ctx, "Prepare: error boxing message: %s", err) return res, err } return types.SenderPrepareResult{ Boxed: boxed, EncryptionInfo: encInfo, PendingAssetDeletes: pendingAssetDeletes, DeleteFlipConv: deleteFlipConvID, AtMentions: atMentions, ChannelMention: chanMention, TopicNameState: topicNameState, TopicNameStateConvs: topicNameStateConvs, }, nil } func (s *BlockingSender) applyTeamBotSettings(ctx context.Context, uid gregor1.UID, msg *chat1.MessagePlaintext, convID *chat1.ConversationID, membersType chat1.ConversationMembersType, atMentions []gregor1.UID, opts chat1.SenderPrepareOptions) ([]gregor1.UID, error) { // no bots in KBFS convs if membersType == chat1.ConversationMembersType_KBFS { return nil, nil } // Skip checks if botUID already set if msg.ClientHeader.BotUID != nil { s.Debug(ctx, "applyTeamBotSettings: found existing botUID %v", msg.ClientHeader.BotUID) // verify this value is actually a restricted bot of the team. teamBotSettings, err := CreateNameInfoSource(ctx, s.G(), membersType).TeamBotSettings(ctx, msg.ClientHeader.TlfName, msg.ClientHeader.Conv.Tlfid, membersType, msg.ClientHeader.TlfPublic) if err != nil { return nil, err } for uv := range teamBotSettings { botUID := gregor1.UID(uv.Uid.ToBytes()) if botUID.Eq(*msg.ClientHeader.BotUID) { s.Debug(ctx, "applyTeamBotSettings: existing botUID matches, short circuiting.") return nil, nil } } s.Debug(ctx, "applyTeamBotSettings: existing botUID %v does not match any bot, clearing") // Caller was mistaken, this uid is not actually a bot so we unset the // value. msg.ClientHeader.BotUID = nil } // Check if we are superseding a bot message. If so, just take what the // superseded has. Don't automatically key for replies, run the normal checks. if msg.ClientHeader.Supersedes > 0 && opts.ReplyTo == nil && convID != nil { target, err := s.getMessage(ctx, uid, *convID, msg.ClientHeader.Supersedes, false /*resolveSupersedes */) if err != nil { return nil, err } botUID := target.ClientHeader.BotUID if botUID == nil { s.Debug(ctx, "applyTeamBotSettings: skipping, supersedes has nil botUID from msgID %d", msg.ClientHeader.Supersedes) return nil, nil } s.Debug(ctx, "applyTeamBotSettings: supersedes botUID %v from msgID %d", botUID, msg.ClientHeader.Supersedes) return []gregor1.UID{*botUID}, nil } // Fetch the bot settings, if any teamBotSettings, err := CreateNameInfoSource(ctx, s.G(), membersType).TeamBotSettings(ctx, msg.ClientHeader.TlfName, msg.ClientHeader.Conv.Tlfid, membersType, msg.ClientHeader.TlfPublic) if err != nil { return nil, err } mentionMap := make(map[string]struct{}) for _, uid := range atMentions { mentionMap[uid.String()] = struct{}{} } var botUIDs []gregor1.UID for uv, botSettings := range teamBotSettings { botUID := gregor1.UID(uv.Uid.ToBytes()) // If the bot is the sender encrypt only for them. if msg.ClientHeader.Sender.Eq(botUID) { if convID == nil || botSettings.ConvIDAllowed(convID.String()) { return []gregor1.UID{botUID}, nil } // Bot channel restrictions only apply to CHAT types. conv, err := utils.GetVerifiedConv(ctx, s.G(), uid, *convID, types.InboxSourceDataSourceAll) if err == nil && conv.GetTopicType() != chat1.TopicType_CHAT { return []gregor1.UID{botUID}, nil } return nil, NewRestrictedBotChannelError() } isMatch, err := bots.ApplyTeamBotSettings(ctx, s.G(), botUID, botSettings, *msg, convID, mentionMap, s.DebugLabeler) if err != nil { return nil, err } s.Debug(ctx, "applyTeamBotSettings: applied settings for %+v for botuid: %v, senderUID: %v, convID: %v isMatch: %v", botSettings, uv.Uid, msg.ClientHeader.Sender, convID, isMatch) if isMatch { botUIDs = append(botUIDs, botUID) } } return botUIDs, nil } func (s *BlockingSender) getSigningKeyPair(ctx context.Context) (kp libkb.NaclSigningKeyPair, err error) { // get device signing key for this user signingKey, err := engine.GetMySecretKey(ctx, s.G().ExternalG(), libkb.DeviceSigningKeyType, "sign chat message") if err != nil { return libkb.NaclSigningKeyPair{}, err } kp, ok := signingKey.(libkb.NaclSigningKeyPair) if !ok || kp.Private == nil { return libkb.NaclSigningKeyPair{}, libkb.KeyCannotSignError{} } return kp, nil } // deleteAssets deletes assets from s3. // Logs but does not return errors. Assets may be left undeleted. func (s *BlockingSender) deleteAssets(ctx context.Context, convID chat1.ConversationID, assets []chat1.Asset) error { // get s3 params from server params, err := s.getRi().GetS3Params(ctx, convID) if err != nil { s.G().Log.Warning("error getting s3 params: %s", err) return nil } if err := s.store.DeleteAssets(ctx, params, s, assets); err != nil { s.G().Log.Warning("error deleting assets: %s", err) // there's no way to get asset information after this point. // any assets not deleted will be stranded on s3. return nil } s.G().Log.Debug("deleted %d assets", len(assets)) return nil } // Sign implements github.com/keybase/go/chat/s3.Signer interface. func (s *BlockingSender) Sign(payload []byte) ([]byte, error) { arg := chat1.S3SignArg{ Payload: payload, Version: 1, } return s.getRi().S3Sign(context.Background(), arg) } func (s *BlockingSender) presentUIItem(ctx context.Context, uid gregor1.UID, conv *chat1.ConversationLocal) (res *chat1.InboxUIItem) { if conv != nil { pc := utils.PresentConversationLocal(ctx, s.G(), uid, *conv, utils.PresentParticipantsModeSkip) res = &pc } return res } func (s *BlockingSender) Send(ctx context.Context, convID chat1.ConversationID, msg chat1.MessagePlaintext, clientPrev chat1.MessageID, outboxID *chat1.OutboxID, sendOpts *chat1.SenderSendOptions, prepareOpts *chat1.SenderPrepareOptions) (obid chat1.OutboxID, boxed *chat1.MessageBoxed, err error) { defer s.Trace(ctx, &err, fmt.Sprintf("Send(%s)", convID))() defer utils.SuspendComponent(ctx, s.G(), s.G().InboxSource)() // Get conversation metadata first. If we can't find it, we will just attempt to join // the conversation in case that is an option. If it succeeds, then we just keep going, // otherwise we give up and return an error. var conv chat1.ConversationLocal sender := gregor1.UID(s.G().Env.GetUID().ToBytes()) conv, err = utils.GetVerifiedConv(ctx, s.G(), sender, convID, types.InboxSourceDataSourceAll) if err != nil { s.Debug(ctx, "Send: error getting conversation metadata: %v", err) return nil, nil, err } s.Debug(ctx, "Send: uid: %s in conversation %s (tlfName: %s) with status: %v", sender, conv.GetConvID(), conv.Info.TlfName, conv.ReaderInfo.Status) // If we are in preview mode, then just join the conversation right now. switch conv.ReaderInfo.Status { case chat1.ConversationMemberStatus_PREVIEW, chat1.ConversationMemberStatus_NEVER_JOINED: switch msg.ClientHeader.MessageType { case chat1.MessageType_JOIN, chat1.MessageType_LEAVE, chat1.MessageType_HEADLINE, chat1.MessageType_METADATA, chat1.MessageType_SYSTEM: // don't need to join to send a system message. // pass so we don't loop between Send and Join/Leave or join when // updating the metadata/headline. default: s.Debug(ctx, "Send: user is in mode: %v, joining conversation", conv.ReaderInfo.Status) if err = JoinConversation(ctx, s.G(), s.DebugLabeler, s.getRi, sender, convID); err != nil { return nil, nil, err } } default: // do nothing } var prepareRes types.SenderPrepareResult var plres chat1.PostRemoteRes // If we get a ChatStalePreviousStateError we blow away in the box cache // once to allow the retry to get fresh data. clearedCache := false // Try this up to 5 times in case we are trying to set the topic name, and the topic name // state is moving around underneath us. for i := 0; i < 5; i++ { // Add a bunch of stuff to the message (like prev pointers, sender info, ...) if prepareRes, err = s.Prepare(ctx, msg, conv.GetMembersType(), &conv, prepareOpts); err != nil { s.Debug(ctx, "Send: error in Prepare: %s", err) return nil, nil, err } boxed = &prepareRes.Boxed // Log some useful information about the message we are sending obidstr := "(none)" if boxed.ClientHeader.OutboxID != nil { obidstr = boxed.ClientHeader.OutboxID.String() } s.Debug(ctx, "Send: sending message: convID: %s outboxID: %s", convID, obidstr) // Keep trying if we get an error on topicNameState for a fixed number of times rarg := chat1.PostRemoteArg{ ConversationID: convID, MessageBoxed: *boxed, AtMentions: prepareRes.AtMentions, ChannelMention: prepareRes.ChannelMention, TopicNameState: prepareRes.TopicNameState, JoinMentionsAs: sendOpts.GetJoinMentionsAs(), } plres, err = s.getRi().PostRemote(ctx, rarg) if err != nil { switch e := err.(type) { case libkb.ChatStalePreviousStateError: // If we hit the stale previous state error, that means we should try again, since our view is // out of date. s.Debug(ctx, "Send: failed because of stale previous state, trying the whole thing again") if !clearedCache { s.Debug(ctx, "Send: clearing inbox cache to retry stale previous state") if err := s.G().InboxSource.Clear(ctx, sender, &types.ClearOpts{ SendLocalAdminNotification: true, Reason: "stale previous topic state", }); err != nil { s.Debug(ctx, "Send: error clearing: %+v", err) } s.Debug(ctx, "Send: clearing conversation cache to retry stale previous state: %d convs", len(prepareRes.TopicNameStateConvs)) for _, convID := range prepareRes.TopicNameStateConvs { if err := s.G().ConvSource.Clear(ctx, convID, sender, nil); err != nil { s.Debug(ctx, "Send: error clearing: %v %+v", convID, err) } } clearedCache = true } continue case libkb.ChatEphemeralRetentionPolicyViolatedError: s.Debug(ctx, "Send: failed because of invalid ephemeral policy, trying the whole thing again") var cerr error conv, cerr = utils.GetVerifiedConv(ctx, s.G(), sender, convID, types.InboxSourceDataSourceRemoteOnly) if cerr != nil { return nil, nil, cerr } continue case libkb.EphemeralPairwiseMACsMissingUIDsError: s.Debug(ctx, "Send: failed because of missing KIDs for pairwise MACs, reloading UPAKs for %v and retrying.", e.UIDs) err := utils.ForceReloadUPAKsForUIDs(ctx, s.G(), e.UIDs) if err != nil { s.Debug(ctx, "Send: error forcing reloads: %+v", err) } continue default: s.Debug(ctx, "Send: failed to PostRemote, bailing: %s", err) return nil, nil, err } } boxed.ServerHeader = &plres.MsgHeader // Delete assets associated with a delete operation. // Logs instead of returning an error. Assets can be left undeleted. if len(prepareRes.PendingAssetDeletes) > 0 { err = s.deleteAssets(ctx, convID, prepareRes.PendingAssetDeletes) if err != nil { s.Debug(ctx, "Send: failure in deleteAssets: %s", err) } } if prepareRes.DeleteFlipConv != nil { _, err = s.getRi().DeleteConversation(ctx, *prepareRes.DeleteFlipConv) if err != nil { s.Debug(ctx, "Send: failure in DeleteConversation: %s", err) } } break } if err != nil { return nil, nil, err } // If this message was sent from the Outbox, then we can remove it now if boxed.ClientHeader.OutboxID != nil { if _, err = storage.NewOutbox(s.G(), sender).RemoveMessage(ctx, *boxed.ClientHeader.OutboxID); err != nil { s.Debug(ctx, "unable to remove outbox message: %v", err) } } // Write new message out to cache and other followup var cerr error var convLocal *chat1.ConversationLocal s.Debug(ctx, "sending local updates to chat sources") // unbox using encryption info we already have unboxedMsg, err := s.boxer.UnboxMessage(ctx, *boxed, conv, &prepareRes.EncryptionInfo) if err != nil { s.Debug(ctx, "Send: failed to unbox sent message: %s", err) } else { if cerr = s.G().ConvSource.PushUnboxed(ctx, conv, boxed.ClientHeader.Sender, []chat1.MessageUnboxed{unboxedMsg}); cerr != nil { s.Debug(ctx, "Send: failed to push new message into convsource: %s", err) } } if convLocal, err = s.G().InboxSource.NewMessage(ctx, boxed.ClientHeader.Sender, 0, convID, *boxed, nil); err != nil { s.Debug(ctx, "Send: failed to update inbox: %s", err) } // Send up to frontend if cerr == nil && boxed.GetMessageType() != chat1.MessageType_LEAVE { unboxedMsg, err = NewReplyFiller(s.G()).FillSingle(ctx, boxed.ClientHeader.Sender, convID, unboxedMsg) if err != nil { s.Debug(ctx, "Send: failed to fill reply: %s", err) } activity := chat1.NewChatActivityWithIncomingMessage(chat1.IncomingMessage{ Message: utils.PresentMessageUnboxed(ctx, s.G(), unboxedMsg, boxed.ClientHeader.Sender, convID), ConvID: convID, DisplayDesktopNotification: false, Conv: s.presentUIItem(ctx, boxed.ClientHeader.Sender, convLocal), }) s.G().ActivityNotifier.Activity(ctx, boxed.ClientHeader.Sender, conv.GetTopicType(), &activity, chat1.ChatActivitySource_LOCAL) } if conv.GetTopicType() == chat1.TopicType_CHAT { // Unfurl go s.G().Unfurler.UnfurlAndSend(globals.BackgroundChatCtx(ctx, s.G()), boxed.ClientHeader.Sender, convID, unboxedMsg) // Start tracking any live location sends if unboxedMsg.IsValid() && unboxedMsg.GetMessageType() == chat1.MessageType_TEXT && unboxedMsg.Valid().MessageBody.Text().LiveLocation != nil { if unboxedMsg.Valid().MessageBody.Text().LiveLocation.EndTime.IsZero() { s.G().LiveLocationTracker.GetCurrentPosition(ctx, conv.GetConvID(), unboxedMsg.GetMessageID()) } else { s.G().LiveLocationTracker.StartTracking(ctx, conv.GetConvID(), unboxedMsg.GetMessageID(), gregor1.FromTime(unboxedMsg.Valid().MessageBody.Text().LiveLocation.EndTime)) } } if conv.GetMembersType() == chat1.ConversationMembersType_TEAM { teamID, err := keybase1.TeamIDFromString(conv.Info.Triple.Tlfid.String()) if err != nil { s.Debug(ctx, "Send: failed to get team ID: %v", err) } else { go s.G().JourneyCardManager.SentMessage(globals.BackgroundChatCtx(ctx, s.G()), sender, teamID, convID) } } } return nil, boxed, nil } type NonblockingSender struct { globals.Contextified utils.DebugLabeler sender types.Sender } var _ types.Sender = (*NonblockingSender)(nil) func NewNonblockingSender(g *globals.Context, sender types.Sender) *NonblockingSender { s := &NonblockingSender{ Contextified: globals.NewContextified(g), DebugLabeler: utils.NewDebugLabeler(g.ExternalG(), "NonblockingSender", false), sender: sender, } return s } func (s *NonblockingSender) Prepare(ctx context.Context, msg chat1.MessagePlaintext, membersType chat1.ConversationMembersType, conv *chat1.ConversationLocal, opts *chat1.SenderPrepareOptions) (types.SenderPrepareResult, error) { return s.sender.Prepare(ctx, msg, membersType, conv, opts) } func (s *NonblockingSender) Send(ctx context.Context, convID chat1.ConversationID, msg chat1.MessagePlaintext, clientPrev chat1.MessageID, outboxID *chat1.OutboxID, sendOpts *chat1.SenderSendOptions, prepareOpts *chat1.SenderPrepareOptions) (chat1.OutboxID, *chat1.MessageBoxed, error) { uid, err := utils.AssertLoggedInUID(ctx, s.G()) if err != nil { return nil, nil, err } // The strategy here is to select the larger prev between what the UI provides, and what we have // stored locally. If we just use the UI version, then we can race for creating ordinals in // Outbox.PushMessage. However, in rare cases we might not have something locally, in that case just // fallback to the UI provided number. var storedPrev chat1.MessageID conv, err := utils.GetUnverifiedConv(ctx, s.G(), uid, convID, types.InboxSourceDataSourceLocalOnly) if err != nil { s.Debug(ctx, "Send: failed to get local inbox info: %s", err) } else { storedPrev = conv.Conv.GetMaxMessageID() } if storedPrev > clientPrev { clientPrev = storedPrev } if clientPrev == 0 { clientPrev = 1 } s.Debug(ctx, "Send: using prevMsgID: %d", clientPrev) msg.ClientHeader.OutboxInfo = &chat1.OutboxInfo{ Prev: clientPrev, ComposeTime: gregor1.ToTime(time.Now()), } identifyBehavior, _, _ := globals.CtxIdentifyMode(ctx) obr, err := s.G().MessageDeliverer.Queue(ctx, convID, msg, outboxID, sendOpts, prepareOpts, identifyBehavior) if err != nil { return obr.OutboxID, nil, err } return obr.OutboxID, nil, nil } func (s *NonblockingSender) SendUnfurlNonblock(ctx context.Context, convID chat1.ConversationID, msg chat1.MessagePlaintext, clientPrev chat1.MessageID, outboxID chat1.OutboxID) (chat1.OutboxID, error) { res, _, err := s.Send(ctx, convID, msg, clientPrev, &outboxID, nil, nil) return res, err }