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 }