github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/chat/bots/commands.go (about)

     1  package bots
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"sort"
     9  	"sync"
    10  	"time"
    11  
    12  	"github.com/keybase/client/go/chat/globals"
    13  	"github.com/keybase/client/go/chat/storage"
    14  	"github.com/keybase/client/go/chat/types"
    15  	"github.com/keybase/client/go/chat/utils"
    16  	"github.com/keybase/client/go/encrypteddb"
    17  	"github.com/keybase/client/go/libkb"
    18  	"github.com/keybase/client/go/protocol/chat1"
    19  	"github.com/keybase/client/go/protocol/gregor1"
    20  	"github.com/keybase/client/go/protocol/keybase1"
    21  	"golang.org/x/sync/errgroup"
    22  )
    23  
    24  const storageVersion = 1
    25  
    26  type uiResult struct {
    27  	err      error
    28  	settings chat1.UIBotCommandsUpdateSettings
    29  }
    30  
    31  type commandUpdaterJob struct {
    32  	convID      chat1.ConversationID
    33  	info        *chat1.BotInfo
    34  	completeChs []chan error
    35  	uiCh        chan uiResult
    36  }
    37  
    38  type userCommandAdvertisement struct {
    39  	Alias    *string                     `json:"alias,omitempty"`
    40  	Commands []chat1.UserBotCommandInput `json:"commands"`
    41  }
    42  
    43  type storageCommandAdvertisement struct {
    44  	Advertisement     userCommandAdvertisement
    45  	UntrustedTeamRole keybase1.TeamRole
    46  	UID               gregor1.UID
    47  	Username          string
    48  	Typ               chat1.BotCommandsAdvertisementTyp
    49  }
    50  
    51  type commandsStorage struct {
    52  	Advertisements []storageCommandAdvertisement `codec:"A"`
    53  	Version        int                           `codec:"V"`
    54  }
    55  
    56  var commandsPublicTopicName = "___keybase_botcommands_public"
    57  
    58  type nameInfoSourceFn func(ctx context.Context, g *globals.Context, membersType chat1.ConversationMembersType) types.NameInfoSource
    59  
    60  type CachingBotCommandManager struct {
    61  	globals.Contextified
    62  	utils.DebugLabeler
    63  	sync.Mutex
    64  
    65  	uid     gregor1.UID
    66  	started bool
    67  	eg      errgroup.Group
    68  	stopCh  chan struct{}
    69  
    70  	ri              func() chat1.RemoteInterface
    71  	nameInfoSource  nameInfoSourceFn
    72  	edb             *encrypteddb.EncryptedDB
    73  	commandUpdateCh chan *commandUpdaterJob
    74  	queuedUpdatedMu sync.Mutex
    75  	queuedUpdates   map[chat1.ConvIDStr]*commandUpdaterJob
    76  }
    77  
    78  func NewCachingBotCommandManager(g *globals.Context, ri func() chat1.RemoteInterface,
    79  	nameInfoSource nameInfoSourceFn) *CachingBotCommandManager {
    80  	keyFn := func(ctx context.Context) ([32]byte, error) {
    81  		return storage.GetSecretBoxKey(ctx, g.ExternalG())
    82  	}
    83  	dbFn := func(g *libkb.GlobalContext) *libkb.JSONLocalDb {
    84  		return g.LocalChatDb
    85  	}
    86  	return &CachingBotCommandManager{
    87  		Contextified:    globals.NewContextified(g),
    88  		DebugLabeler:    utils.NewDebugLabeler(g.ExternalG(), "CachingBotCommandManager", false),
    89  		ri:              ri,
    90  		edb:             encrypteddb.New(g.ExternalG(), dbFn, keyFn),
    91  		commandUpdateCh: make(chan *commandUpdaterJob, 100),
    92  		queuedUpdates:   make(map[chat1.ConvIDStr]*commandUpdaterJob),
    93  		nameInfoSource:  nameInfoSource,
    94  	}
    95  }
    96  
    97  func (b *CachingBotCommandManager) Start(ctx context.Context, uid gregor1.UID) {
    98  	defer b.Trace(ctx, nil, "Start")()
    99  	b.Lock()
   100  	defer b.Unlock()
   101  	if b.started {
   102  		return
   103  	}
   104  	b.stopCh = make(chan struct{})
   105  	b.started = true
   106  	b.uid = uid
   107  	b.eg.Go(func() error { return b.commandUpdateLoop(b.stopCh) })
   108  }
   109  
   110  func (b *CachingBotCommandManager) Stop(ctx context.Context) chan struct{} {
   111  	defer b.Trace(ctx, nil, "Stop")()
   112  	b.Lock()
   113  	defer b.Unlock()
   114  	ch := make(chan struct{})
   115  	if b.started {
   116  		close(b.stopCh)
   117  		b.started = false
   118  		go func() {
   119  			err := b.eg.Wait()
   120  			if err != nil {
   121  				b.Debug(ctx, "CachingBotCommandManager: error waiting: %+v", err)
   122  			}
   123  			close(ch)
   124  		}()
   125  	} else {
   126  		close(ch)
   127  	}
   128  	return ch
   129  }
   130  
   131  func (b *CachingBotCommandManager) getMyUsername(ctx context.Context) (string, error) {
   132  	nn, err := b.G().GetUPAKLoader().LookupUsername(ctx, keybase1.UID(b.uid.String()))
   133  	if err != nil {
   134  		return "", err
   135  	}
   136  	return nn.String(), nil
   137  }
   138  
   139  func (b *CachingBotCommandManager) createConv(ctx context.Context, typ chat1.BotCommandsAdvertisementTyp,
   140  	teamName *string, convID *chat1.ConversationID) (res chat1.ConversationLocal, err error) {
   141  	username, err := b.getMyUsername(ctx)
   142  	if err != nil {
   143  		return res, err
   144  	}
   145  	switch typ {
   146  	case chat1.BotCommandsAdvertisementTyp_PUBLIC:
   147  		if teamName != nil {
   148  			return res, errors.New("team name cannot be specified for public advertisements")
   149  		} else if convID != nil {
   150  			return res, errors.New("convID cannot be specified for public advertisements")
   151  		}
   152  
   153  		res, _, err = b.G().ChatHelper.NewConversation(ctx, b.uid, username, &commandsPublicTopicName,
   154  			chat1.TopicType_DEV, chat1.ConversationMembersType_IMPTEAMNATIVE, keybase1.TLFVisibility_PUBLIC)
   155  		return res, err
   156  	case chat1.BotCommandsAdvertisementTyp_TLFID_MEMBERS, chat1.BotCommandsAdvertisementTyp_TLFID_CONVS:
   157  		if teamName == nil {
   158  			return res, errors.New("missing team name")
   159  		} else if convID != nil {
   160  			return res, errors.New("convID cannot be specified for team advertisments use type 'conv'")
   161  		}
   162  
   163  		topicName := fmt.Sprintf("___keybase_botcommands_team_%s_%v", username, typ)
   164  		res, _, err = b.G().ChatHelper.NewConversationSkipFindExisting(ctx, b.uid, *teamName, &topicName,
   165  			chat1.TopicType_DEV, chat1.ConversationMembersType_TEAM, keybase1.TLFVisibility_PRIVATE)
   166  		return res, err
   167  	case chat1.BotCommandsAdvertisementTyp_CONV:
   168  		if teamName != nil {
   169  			return res, errors.New("unexpected team name")
   170  		} else if convID == nil {
   171  			return res, errors.New("missing convID")
   172  		}
   173  
   174  		topicName := fmt.Sprintf("___keybase_botcommands_conv_%s_%v", username, typ)
   175  		convs, err := b.G().ChatHelper.FindConversationsByID(ctx, []chat1.ConversationID{*convID})
   176  		if err != nil {
   177  			return res, err
   178  		} else if len(convs) != 1 {
   179  			return res, errors.New("Unable able to find conversation for advertisement")
   180  		}
   181  		conv := convs[0]
   182  		res, _, err = b.G().ChatHelper.NewConversationSkipFindExisting(ctx, b.uid, conv.Info.TlfName, &topicName,
   183  			chat1.TopicType_DEV, conv.Info.MembersType, keybase1.TLFVisibility_PRIVATE)
   184  		return res, err
   185  	default:
   186  		return res, fmt.Errorf("unknown bot advertisement typ %q", typ)
   187  	}
   188  }
   189  
   190  func (b *CachingBotCommandManager) PublicCommandsConv(ctx context.Context, username string) (*chat1.ConversationID, error) {
   191  	convs, err := b.G().ChatHelper.FindConversations(ctx, username, &commandsPublicTopicName,
   192  		chat1.TopicType_DEV, chat1.ConversationMembersType_IMPTEAMNATIVE, keybase1.TLFVisibility_PUBLIC)
   193  	if err != nil {
   194  		return nil, err
   195  	}
   196  	if len(convs) != 1 {
   197  		b.Debug(ctx, "PublicCommandsConv: no command conv found")
   198  		return nil, nil
   199  	}
   200  	convID := convs[0].GetConvID()
   201  	return &convID, nil
   202  }
   203  
   204  func (b *CachingBotCommandManager) Advertise(ctx context.Context, alias *string,
   205  	ads []chat1.AdvertiseCommandsParam) (err error) {
   206  	defer b.Trace(ctx, &err, "Advertise")()
   207  	remotes := make([]chat1.RemoteBotCommandsAdvertisement, 0, len(ads))
   208  	for _, ad := range ads {
   209  		// create conversations with the commands
   210  		conv, err := b.createConv(ctx, ad.Typ, ad.TeamName, ad.ConvID)
   211  		if err != nil {
   212  			return err
   213  		}
   214  		// marshal contents
   215  		payload := userCommandAdvertisement{
   216  			Alias:    alias,
   217  			Commands: ad.Commands,
   218  		}
   219  		dat, err := json.Marshal(payload)
   220  		if err != nil {
   221  			return err
   222  		}
   223  		var vis keybase1.TLFVisibility
   224  		var tlfID *chat1.TLFID
   225  		var adConvID *chat1.ConversationID
   226  		switch ad.Typ {
   227  		case chat1.BotCommandsAdvertisementTyp_PUBLIC:
   228  			vis = keybase1.TLFVisibility_PUBLIC
   229  		case chat1.BotCommandsAdvertisementTyp_CONV:
   230  			vis = keybase1.TLFVisibility_PRIVATE
   231  			adConvID = ad.ConvID
   232  		default:
   233  			tlfID = &conv.Info.Triple.Tlfid
   234  			vis = keybase1.TLFVisibility_PRIVATE
   235  		}
   236  		remote, err := ad.ToRemote(conv.GetConvID(), tlfID, adConvID)
   237  		if err != nil {
   238  			return err
   239  		}
   240  		// write out commands to conv
   241  		if err := b.G().ChatHelper.SendMsgByID(ctx, conv.GetConvID(), conv.Info.TlfName,
   242  			chat1.NewMessageBodyWithText(chat1.MessageText{
   243  				Body: string(dat),
   244  			}), chat1.MessageType_TEXT, vis); err != nil {
   245  			return err
   246  		}
   247  		remotes = append(remotes, remote)
   248  	}
   249  	if _, err := b.ri().AdvertiseBotCommands(ctx, remotes); err != nil {
   250  		return err
   251  	}
   252  	return nil
   253  }
   254  
   255  func (b *CachingBotCommandManager) Clear(ctx context.Context, filter *chat1.ClearBotCommandsFilter) (err error) {
   256  	defer b.Trace(ctx, &err, "Clear")()
   257  	var remote *chat1.RemoteClearBotCommandsFilter
   258  	if filter != nil {
   259  		remote = new(chat1.RemoteClearBotCommandsFilter)
   260  
   261  		var tlfID *chat1.TLFID
   262  		var convID *chat1.ConversationID
   263  		switch filter.Typ {
   264  		case chat1.BotCommandsAdvertisementTyp_PUBLIC:
   265  		case chat1.BotCommandsAdvertisementTyp_TLFID_CONVS, chat1.BotCommandsAdvertisementTyp_TLFID_MEMBERS:
   266  			conv, err := b.createConv(ctx, filter.Typ, filter.TeamName, filter.ConvID)
   267  			if err != nil {
   268  				return err
   269  			}
   270  			tlfID = &conv.Info.Triple.Tlfid
   271  		case chat1.BotCommandsAdvertisementTyp_CONV:
   272  			convID = filter.ConvID
   273  		}
   274  
   275  		*remote, err = filter.ToRemote(tlfID, convID)
   276  		if err != nil {
   277  			return err
   278  		}
   279  	}
   280  	if _, err := b.ri().ClearBotCommands(ctx, remote); err != nil {
   281  		return err
   282  	}
   283  	return nil
   284  }
   285  
   286  func (b *CachingBotCommandManager) dbInfoKey(convID chat1.ConversationID) libkb.DbKey {
   287  	return libkb.DbKey{
   288  		Key: fmt.Sprintf("ik:%s:%s", b.uid, convID),
   289  		Typ: libkb.DBChatBotCommands,
   290  	}
   291  }
   292  
   293  func (b *CachingBotCommandManager) dbCommandsKey(convID chat1.ConversationID) libkb.DbKey {
   294  	return libkb.DbKey{
   295  		Key: fmt.Sprintf("ck:%s:%s", b.uid, convID),
   296  		Typ: libkb.DBChatBotCommands,
   297  	}
   298  }
   299  
   300  func (b *CachingBotCommandManager) ListCommands(ctx context.Context, convID chat1.ConversationID) (res []chat1.UserBotCommandOutput, alias map[string]string, err error) {
   301  	defer b.Trace(ctx, &err, "ListCommands")()
   302  	alias = make(map[string]string)
   303  	dbKey := b.dbCommandsKey(convID)
   304  	var s commandsStorage
   305  	found, err := b.edb.Get(ctx, dbKey, &s)
   306  	if err != nil {
   307  		b.Debug(ctx, "ListCommands: failed to read cache: %s", err)
   308  		if err := b.edb.Delete(ctx, dbKey); err != nil {
   309  			b.Debug(ctx, "edb.Delete: %v", err)
   310  		}
   311  		found = false
   312  	}
   313  	if !found {
   314  		return res, alias, nil
   315  	}
   316  	if s.Version != storageVersion {
   317  		b.Debug(ctx, "ListCommands: deleting old version %d vs %d", s.Version, storageVersion)
   318  		if err := b.edb.Delete(ctx, dbKey); err != nil {
   319  			b.Debug(ctx, "edb.Delete: %v", err)
   320  		}
   321  		return res, alias, nil
   322  	}
   323  
   324  	cmdOutputs := make(map[string]chat1.UserBotCommandOutput)
   325  	cmdDedup := make(map[string]chat1.BotCommandsAdvertisementTyp)
   326  	for _, ad := range s.Advertisements {
   327  		ad.Username = libkb.NewNormalizedUsername(ad.Username).String()
   328  		if ad.Advertisement.Alias != nil {
   329  			alias[ad.Username] = *ad.Advertisement.Alias
   330  		}
   331  		for _, cmd := range ad.Advertisement.Commands {
   332  			key := cmd.Name + ad.Username
   333  			if typ, ok := cmdDedup[key]; !ok || ad.Typ > typ {
   334  				cmdOutputs[key] = cmd.ToOutput(ad.Username)
   335  				cmdDedup[key] = ad.Typ
   336  			}
   337  		}
   338  	}
   339  	res = make([]chat1.UserBotCommandOutput, 0, len(cmdOutputs))
   340  	for _, cmd := range cmdOutputs {
   341  		res = append(res, cmd)
   342  	}
   343  
   344  	sort.Slice(res, func(i, j int) bool {
   345  		l := res[i]
   346  		r := res[j]
   347  		if l.Username < r.Username {
   348  			return true
   349  		} else if l.Username > r.Username {
   350  			return false
   351  		} else {
   352  			return l.Name < r.Name
   353  		}
   354  	})
   355  	return res, alias, nil
   356  }
   357  
   358  func (b *CachingBotCommandManager) UpdateCommands(ctx context.Context, convID chat1.ConversationID,
   359  	info *chat1.BotInfo) (completeCh chan error, err error) {
   360  	defer b.Trace(ctx, &err, "UpdateCommands")()
   361  	completeCh = make(chan error, 1)
   362  	uiCh := make(chan uiResult, 1)
   363  	return completeCh, b.queueCommandUpdate(ctx, &commandUpdaterJob{
   364  		convID:      convID,
   365  		info:        info,
   366  		completeChs: []chan error{completeCh},
   367  		uiCh:        uiCh,
   368  	})
   369  }
   370  
   371  func (b *CachingBotCommandManager) getChatUI(ctx context.Context) libkb.ChatUI {
   372  	ui, err := b.G().UIRouter.GetChatUI()
   373  	if err != nil || ui == nil {
   374  		b.Debug(ctx, "getChatUI: no chat UI found: err: %s", err)
   375  		return utils.NullChatUI{}
   376  	}
   377  	return ui
   378  }
   379  
   380  func (b *CachingBotCommandManager) runCommandUpdateUI(ctx context.Context, job *commandUpdaterJob) {
   381  	err := b.getChatUI(ctx).ChatBotCommandsUpdateStatus(ctx, job.convID,
   382  		chat1.NewUIBotCommandsUpdateStatusWithBlank())
   383  	if err != nil {
   384  		b.Debug(ctx, "getChatUI: error getting update status: %+v", err)
   385  	}
   386  	for {
   387  		select {
   388  		case res := <-job.uiCh:
   389  			var updateStatus chat1.UIBotCommandsUpdateStatus
   390  			if res.err != nil {
   391  				updateStatus = chat1.NewUIBotCommandsUpdateStatusWithFailed()
   392  			} else {
   393  				updateStatus = chat1.NewUIBotCommandsUpdateStatusWithUptodate(res.settings)
   394  			}
   395  			if err = b.getChatUI(ctx).ChatBotCommandsUpdateStatus(ctx, job.convID, updateStatus); err != nil {
   396  				b.Debug(ctx, "getChatUI: error getting update status: %+v", err)
   397  			}
   398  			return
   399  		case <-time.After(800 * time.Millisecond):
   400  			err := b.getChatUI(ctx).ChatBotCommandsUpdateStatus(ctx, job.convID,
   401  				chat1.NewUIBotCommandsUpdateStatusWithUpdating())
   402  			if err != nil {
   403  				b.Debug(ctx, "getChatUI: error getting update status: %+v", err)
   404  			}
   405  		}
   406  	}
   407  }
   408  
   409  func (b *CachingBotCommandManager) queueCommandUpdate(ctx context.Context, job *commandUpdaterJob) error {
   410  	b.queuedUpdatedMu.Lock()
   411  	defer b.queuedUpdatedMu.Unlock()
   412  	if curJob, ok := b.queuedUpdates[job.convID.ConvIDStr()]; ok {
   413  		b.Debug(ctx, "queueCommandUpdate: skipping already queued: %s", job.convID)
   414  		curJob.completeChs = append(curJob.completeChs, job.completeChs...)
   415  		return nil
   416  	}
   417  	select {
   418  	case b.commandUpdateCh <- job:
   419  		go b.runCommandUpdateUI(globals.BackgroundChatCtx(ctx, b.G()), job)
   420  		b.queuedUpdates[job.convID.ConvIDStr()] = job
   421  	default:
   422  		return errors.New("queue full")
   423  	}
   424  	return nil
   425  }
   426  
   427  func (b *CachingBotCommandManager) getBotInfo(ctx context.Context, job *commandUpdaterJob) (botInfo chat1.BotInfo, doUpdate bool, err error) {
   428  	defer b.Trace(ctx, &err, fmt.Sprintf("getBotInfo: %v", job.convID))()
   429  	if job.info != nil {
   430  		return *job.info, true, nil
   431  	}
   432  	convID := job.convID
   433  	found, err := b.edb.Get(ctx, b.dbInfoKey(convID), &botInfo)
   434  	if err != nil {
   435  		b.Debug(ctx, "getBotInfo: failed to read cache: %s", err)
   436  		found = false
   437  	}
   438  	var infoHash chat1.BotInfoHash
   439  	if found {
   440  		infoHash = botInfo.Hash()
   441  	}
   442  	res, err := b.ri().GetBotInfo(ctx, chat1.GetBotInfoArg{
   443  		ConvID:   convID,
   444  		InfoHash: infoHash,
   445  		// Send up the latest client version we known about. The server
   446  		// will apply the client version when hashing so we can cache even if
   447  		// new clients are using a different hash function.
   448  		ClientHashVers: chat1.ClientBotInfoHashVers,
   449  	})
   450  	if err != nil {
   451  		return botInfo, false, err
   452  	}
   453  	rtyp, err := res.Response.Typ()
   454  	if err != nil {
   455  		return botInfo, false, err
   456  	}
   457  	switch rtyp {
   458  	case chat1.BotInfoResponseTyp_UPTODATE:
   459  		return botInfo, false, nil
   460  	case chat1.BotInfoResponseTyp_INFO:
   461  		if err := b.edb.Put(ctx, b.dbInfoKey(convID), res.Response.Info()); err != nil {
   462  			return botInfo, false, err
   463  		}
   464  		return res.Response.Info(), true, nil
   465  	}
   466  	return botInfo, false, errors.New("unknown response type")
   467  }
   468  
   469  func (b *CachingBotCommandManager) getConvAdvertisement(ctx context.Context, convID chat1.ConversationID,
   470  	botUID gregor1.UID, untrustedTeamRole keybase1.TeamRole, typ chat1.BotCommandsAdvertisementTyp) (res *storageCommandAdvertisement) {
   471  	b.Debug(ctx, "getConvAdvertisement: reading commands from: %s for uid: %s", convID, botUID)
   472  	tv, err := b.G().ConvSource.Pull(ctx, convID, b.uid, chat1.GetThreadReason_BOTCOMMANDS, nil,
   473  		&chat1.GetThreadQuery{
   474  			MessageTypes: []chat1.MessageType{chat1.MessageType_TEXT},
   475  		}, &chat1.Pagination{Num: 1})
   476  	if err != nil {
   477  		b.Debug(ctx, "getConvAdvertisement: failed to read thread: %s", err)
   478  		return nil
   479  	}
   480  	if len(tv.Messages) == 0 {
   481  		b.Debug(ctx, "getConvAdvertisement: no messages")
   482  		return nil
   483  	}
   484  	msg := tv.Messages[0]
   485  	if !msg.IsValid() {
   486  		b.Debug(ctx, "getConvAdvertisement: latest message is not valid")
   487  		return nil
   488  	}
   489  	body := msg.Valid().MessageBody
   490  	if !body.IsType(chat1.MessageType_TEXT) {
   491  		b.Debug(ctx, "getConvAdvertisement: latest message is not text")
   492  		return nil
   493  	}
   494  	// make sure the sender is who the server said it is
   495  	if !msg.Valid().ClientHeader.Sender.Eq(botUID) {
   496  		b.Debug(ctx, "getConvAdvertisement: wrong sender: %s != %s", botUID, msg.Valid().ClientHeader.Sender)
   497  		return nil
   498  	}
   499  	res = new(storageCommandAdvertisement)
   500  	if err = json.Unmarshal([]byte(body.Text().Body), &res.Advertisement); err != nil {
   501  		b.Debug(ctx, "getConvAdvertisement: failed to JSON decode: %s", err)
   502  		return nil
   503  	}
   504  	res.Username = msg.Valid().SenderUsername
   505  	res.UID = botUID
   506  	res.UntrustedTeamRole = untrustedTeamRole
   507  	res.Typ = typ
   508  
   509  	return res
   510  }
   511  
   512  func (b *CachingBotCommandManager) commandUpdate(ctx context.Context, job *commandUpdaterJob) (err error) {
   513  	var botSettings chat1.UIBotCommandsUpdateSettings
   514  	ctx = globals.ChatCtx(ctx, b.G(), keybase1.TLFIdentifyBehavior_CHAT_GUI, nil, nil)
   515  	defer b.Trace(ctx, &err, "commandUpdate")()
   516  	defer func() {
   517  		b.queuedUpdatedMu.Lock()
   518  		delete(b.queuedUpdates, job.convID.ConvIDStr())
   519  		b.queuedUpdatedMu.Unlock()
   520  		job.uiCh <- uiResult{
   521  			err:      err,
   522  			settings: botSettings,
   523  		}
   524  		for _, completeCh := range job.completeChs {
   525  			completeCh <- err
   526  		}
   527  	}()
   528  	var eg errgroup.Group
   529  	eg.Go(func() error {
   530  		botInfo, doUpdate, err := b.getBotInfo(ctx, job)
   531  		if err != nil {
   532  			return err
   533  		}
   534  		if !doUpdate {
   535  			b.Debug(ctx, "commandUpdate: bot info uptodate, not updating")
   536  			return nil
   537  		}
   538  		s := commandsStorage{
   539  			Version: storageVersion,
   540  		}
   541  		for _, cconv := range botInfo.CommandConvs {
   542  			ad := b.getConvAdvertisement(ctx, cconv.ConvID, cconv.Uid, cconv.UntrustedTeamRole, cconv.Typ)
   543  			if ad != nil {
   544  				s.Advertisements = append(s.Advertisements, *ad)
   545  			}
   546  		}
   547  		if err := b.edb.Put(ctx, b.dbCommandsKey(job.convID), s); err != nil {
   548  			return err
   549  		}
   550  		// alert that the conv is now updated
   551  		b.G().InboxSource.NotifyUpdate(ctx, b.uid, job.convID)
   552  		return nil
   553  	})
   554  	eg.Go(func() error {
   555  		conv, err := utils.GetVerifiedConv(ctx, b.G(), b.uid, job.convID, types.InboxSourceDataSourceAll)
   556  		if err != nil {
   557  			return err
   558  		}
   559  		ni := b.nameInfoSource(ctx, b.G(), conv.GetMembersType())
   560  		rawSettings, err := ni.TeamBotSettings(ctx, conv.Info.TlfName, conv.Info.Triple.Tlfid,
   561  			conv.GetMembersType(), conv.IsPublic())
   562  		if err != nil {
   563  			return err
   564  		}
   565  		botSettings.Settings = make(map[string]keybase1.TeamBotSettings)
   566  		for uv, settings := range rawSettings {
   567  			username, err := b.G().GetUPAKLoader().LookupUsername(ctx, uv.Uid)
   568  			if err != nil {
   569  				return err
   570  			}
   571  			botSettings.Settings[username.String()] = settings
   572  		}
   573  		return nil
   574  	})
   575  	return eg.Wait()
   576  }
   577  
   578  func (b *CachingBotCommandManager) commandUpdateLoop(stopCh chan struct{}) error {
   579  	ctx := context.Background()
   580  	for {
   581  		select {
   582  		case job := <-b.commandUpdateCh:
   583  			if err := b.commandUpdate(ctx, job); err != nil {
   584  				b.Debug(ctx, "commandUpdateLoop: failed to update: %s", err)
   585  			}
   586  		case <-stopCh:
   587  			return nil
   588  		}
   589  	}
   590  }