github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/chat/emojisource.go (about) 1 package chat 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "image/gif" 8 "io" 9 "net/http" 10 "os" 11 "sort" 12 "strings" 13 "sync" 14 15 "camlistore.org/pkg/images" 16 "github.com/dustin/go-humanize" 17 "github.com/keybase/client/go/chat/attachments" 18 "github.com/keybase/client/go/chat/globals" 19 "github.com/keybase/client/go/chat/storage" 20 "github.com/keybase/client/go/chat/types" 21 "github.com/keybase/client/go/chat/utils" 22 "github.com/keybase/client/go/encrypteddb" 23 "github.com/keybase/client/go/libkb" 24 "github.com/keybase/client/go/protocol/chat1" 25 "github.com/keybase/client/go/protocol/gregor1" 26 "github.com/keybase/client/go/protocol/keybase1" 27 ) 28 29 const ( 30 minShortNameLength = 2 31 maxShortNameLength = 48 32 minEmojiSize = 512 // min size for reading mime type 33 maxEmojiSize = 256 * 1000 // 256kb 34 animationKey = "emojianimations" 35 ) 36 37 type EmojiValidationError struct { 38 Underlying error 39 CLIDisplay string 40 UIDisplay string 41 } 42 43 func (e *EmojiValidationError) Error() string { 44 if e == nil || e.Underlying == nil { 45 return "" 46 } 47 return e.Underlying.Error() 48 } 49 50 func (e *EmojiValidationError) Export() *chat1.EmojiError { 51 if e == nil { 52 return nil 53 } 54 return &chat1.EmojiError{ 55 Clidisplay: e.CLIDisplay, 56 Uidisplay: e.UIDisplay, 57 } 58 } 59 60 func NewEmojiValidationError(err error, cliDisplay, uiDisplay string) *EmojiValidationError { 61 return &EmojiValidationError{ 62 Underlying: err, 63 CLIDisplay: cliDisplay, 64 UIDisplay: uiDisplay, 65 } 66 } 67 68 func NewEmojiValidationErrorSimple(err error, display string) *EmojiValidationError { 69 return &EmojiValidationError{ 70 Underlying: err, 71 CLIDisplay: display, 72 UIDisplay: display, 73 } 74 } 75 76 func NewEmojiValidationErrorJustError(err error) *EmojiValidationError { 77 return &EmojiValidationError{ 78 Underlying: err, 79 CLIDisplay: err.Error(), 80 UIDisplay: err.Error(), 81 } 82 } 83 84 type DevConvEmojiSource struct { 85 globals.Contextified 86 utils.DebugLabeler 87 88 aliasLookupLock sync.Mutex 89 aliasLookup map[string]chat1.Emoji 90 ri func() chat1.RemoteInterface 91 encryptedDB *encrypteddb.EncryptedDB 92 93 // testing 94 tempDir string 95 testingCreatedSyncConv chan struct{} 96 testingRefreshedSyncConv chan struct{} 97 } 98 99 var _ types.EmojiSource = (*DevConvEmojiSource)(nil) 100 101 func NewDevConvEmojiSource(g *globals.Context, ri func() chat1.RemoteInterface) *DevConvEmojiSource { 102 keyFn := func(ctx context.Context) ([32]byte, error) { 103 return storage.GetSecretBoxKey(ctx, g.ExternalG()) 104 } 105 dbFn := func(g *libkb.GlobalContext) *libkb.JSONLocalDb { 106 return g.LocalChatDb 107 } 108 return &DevConvEmojiSource{ 109 Contextified: globals.NewContextified(g), 110 DebugLabeler: utils.NewDebugLabeler(g.ExternalG(), "DevConvEmojiSource", false), 111 ri: ri, 112 encryptedDB: encrypteddb.New(g.ExternalG(), dbFn, keyFn), 113 } 114 } 115 116 func (s *DevConvEmojiSource) makeStorage(topicType chat1.TopicType) types.ConvConversationBackedStorage { 117 return NewConvDevConversationBackedStorage(s.G(), topicType, false, s.ri) 118 } 119 120 func (s *DevConvEmojiSource) topicName(suffix *string) string { 121 ret := "emojis" 122 if suffix != nil { 123 ret += *suffix 124 } 125 return ret 126 } 127 128 func (s *DevConvEmojiSource) dbKey(uid gregor1.UID) libkb.DbKey { 129 return libkb.DbKey{ 130 Typ: libkb.DBChatUserEmojis, 131 Key: uid.String(), 132 } 133 } 134 135 func (s *DevConvEmojiSource) getAliasLookup(ctx context.Context, uid gregor1.UID) (res map[string]chat1.Emoji, err error) { 136 s.aliasLookupLock.Lock() 137 defer s.aliasLookupLock.Unlock() 138 if s.aliasLookup != nil { 139 res = make(map[string]chat1.Emoji, len(s.aliasLookup)) 140 for alias, emoji := range s.aliasLookup { 141 res[alias] = emoji 142 } 143 return res, nil 144 } 145 res = make(map[string]chat1.Emoji) 146 s.Debug(ctx, "getAliasLookup: missed alias lookup, reading from disk") 147 found, err := s.encryptedDB.Get(ctx, s.dbKey(uid), &res) 148 if err != nil { 149 return res, err 150 } 151 if !found { 152 return make(map[string]chat1.Emoji), nil 153 } 154 return res, nil 155 } 156 157 func (s *DevConvEmojiSource) putAliasLookup(ctx context.Context, uid gregor1.UID, 158 aliasLookup map[string]chat1.Emoji, opts chat1.EmojiFetchOpts) error { 159 s.aliasLookupLock.Lock() 160 defer s.aliasLookupLock.Unlock() 161 // set this if it is blank, or a full fetch 162 if !opts.OnlyInTeam || s.aliasLookup == nil { 163 s.aliasLookup = aliasLookup 164 } 165 // only commit to disk if this is a full lookup 166 if !opts.OnlyInTeam { 167 return s.encryptedDB.Put(ctx, s.dbKey(uid), s.aliasLookup) 168 } 169 return nil 170 } 171 172 func (s *DevConvEmojiSource) addAdvanced(ctx context.Context, uid gregor1.UID, 173 storageConv *chat1.ConversationLocal, convID chat1.ConversationID, 174 alias, filename string, allowOverwrite bool, storage types.ConvConversationBackedStorage) (res chat1.EmojiRemoteSource, err error) { 175 var stored chat1.EmojiStorage 176 alias = strings.ReplaceAll(alias, ":", "") // drop any colons from alias 177 if storageConv != nil { 178 _, err = storage.GetFromKnownConv(ctx, uid, *storageConv, &stored) 179 } else { 180 topicName := s.topicName(nil) 181 _, storageConv, err = storage.Get(ctx, uid, convID, topicName, &stored, true) 182 } 183 if err != nil { 184 return res, err 185 } 186 if stored.Mapping == nil { 187 stored.Mapping = make(map[string]chat1.EmojiRemoteSource) 188 } 189 if _, ok := stored.Mapping[alias]; ok && !allowOverwrite { 190 return res, NewEmojiValidationError(errors.New("alias already exists"), 191 "alias already exists, must specify --allow-overwrite to edit", "alias already exists") 192 } 193 194 sender := NewBlockingSender(s.G(), NewBoxer(s.G()), s.ri) 195 _, msgID, err := attachments.NewSender(s.G()).PostFileAttachment(ctx, sender, uid, 196 storageConv.GetConvID(), storageConv.Info.TlfName, keybase1.TLFVisibility_PRIVATE, nil, filename, 197 "", nil, 0, nil, nil) 198 if err != nil { 199 return res, err 200 } 201 if msgID == nil { 202 return res, errors.New("no messageID from attachment") 203 } 204 res = chat1.NewEmojiRemoteSourceWithMessage(chat1.EmojiMessage{ 205 ConvID: storageConv.GetConvID(), 206 MsgID: *msgID, 207 }) 208 stored.Mapping[alias] = res 209 return res, storage.PutToKnownConv(ctx, uid, *storageConv, stored) 210 } 211 212 func (s *DevConvEmojiSource) IsStockEmoji(alias string) bool { 213 parts := strings.Split(alias, ":") 214 if len(parts) > 3 { // if we have a skin tone here, drop it 215 alias = fmt.Sprintf(":%s:", parts[1]) 216 } else if len(parts) == 1 { 217 alias = fmt.Sprintf(":%s:", parts[0]) 218 } 219 alias2 := strings.ReplaceAll(alias, "-", "_") 220 return storage.EmojiExists(alias) || storage.EmojiExists(alias2) 221 } 222 223 func (s *DevConvEmojiSource) normalizeShortName(shortName string) string { 224 return strings.ReplaceAll(shortName, ":", "") // drop any colons from alias 225 } 226 227 func (s *DevConvEmojiSource) validateShortName(shortName string) error { 228 if len(shortName) > maxShortNameLength { 229 err := errors.New("alias is too long") 230 return NewEmojiValidationError(err, fmt.Sprintf("alias is too long, must be less than %d", 231 maxShortNameLength), err.Error()) 232 } 233 if len(shortName) < minShortNameLength { 234 err := errors.New("alias is too short") 235 return NewEmojiValidationError(err, 236 fmt.Sprintf("alias is too short, must be greater than %d", minShortNameLength), 237 err.Error()) 238 } 239 if strings.Contains(shortName, "#") { 240 return NewEmojiValidationErrorJustError(errors.New("invalid character in alias")) 241 } 242 return nil 243 } 244 245 func (s *DevConvEmojiSource) validateCustomEmoji(ctx context.Context, shortName, filename string) (string, error) { 246 err := s.validateShortName(shortName) 247 if err != nil { 248 return "", err 249 } 250 shortName = s.normalizeShortName(shortName) 251 252 err = s.validateFile(ctx, filename) 253 if err != nil { 254 return "", err 255 } 256 return shortName, nil 257 } 258 259 // validateFile validates the following: 260 // file size 261 // format 262 func (s *DevConvEmojiSource) validateFile(ctx context.Context, filename string) error { 263 finfo, err := attachments.StatOSOrKbfsFile(ctx, s.G().GlobalContext, filename) 264 if err != nil { 265 return NewEmojiValidationErrorSimple(err, "unable to open file") 266 } 267 if finfo.IsDir() { 268 return NewEmojiValidationErrorJustError(errors.New("unable to use a directory")) 269 } else if finfo.Size() > maxEmojiSize { 270 err := errors.New("file too large") 271 return NewEmojiValidationError(err, 272 fmt.Sprintf("emoji filesize too large, must be less than %s", humanize.Bytes(maxEmojiSize)), 273 err.Error()) 274 } else if finfo.Size() < minEmojiSize { 275 err := errors.New("file too small") 276 return NewEmojiValidationError(err, 277 fmt.Sprintf("emoji filesize too small, must be greater than %s", humanize.Bytes(minEmojiSize)), 278 err.Error()) 279 } 280 281 src, err := attachments.NewReadCloseResetter(ctx, s.G().GlobalContext, filename) 282 if err != nil { 283 return NewEmojiValidationErrorSimple(err, "failed to process file") 284 } 285 defer func() { src.Close() }() 286 if _, _, err = images.Decode(src, nil); err != nil { 287 s.Debug(ctx, "validateFile: failed to decode image: %s", err) 288 if err := src.Reset(); err != nil { 289 return NewEmojiValidationErrorSimple(err, "failed to process file") 290 } 291 g, err := gif.DecodeAll(src) 292 if err != nil { 293 s.Debug(ctx, "validateFile: failed to decode gif: %s", err) 294 return NewEmojiValidationErrorSimple(err, "invalid image file") 295 } 296 if len(g.Image) == 0 { 297 return NewEmojiValidationErrorJustError(errors.New("no image frames in GIF")) 298 } 299 } 300 return nil 301 } 302 303 func (s *DevConvEmojiSource) fromURL(ctx context.Context, url string) (string, error) { 304 resp, err := http.Get(url) 305 if err != nil { 306 return "", err 307 } 308 defer resp.Body.Close() 309 file, _, err := s.G().AttachmentUploader.GetUploadTempSink(ctx, "tmp-emoji") 310 if err != nil { 311 return "", err 312 } 313 _, err = io.Copy(file, resp.Body) 314 return file.Name(), err 315 } 316 317 func (s *DevConvEmojiSource) Add(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID, 318 alias, filename string, allowOverwrite bool) (res chat1.EmojiRemoteSource, err error) { 319 defer s.Trace(ctx, &err, "Add")() 320 if strings.HasPrefix(filename, "http://") || strings.HasPrefix(filename, "https://") { 321 filename, err = s.fromURL(ctx, filename) 322 if err != nil { 323 return res, err 324 } 325 defer func() { _ = os.Remove(filename) }() 326 } 327 if alias, err = s.validateCustomEmoji(ctx, alias, filename); err != nil { 328 return res, err 329 } 330 storage := s.makeStorage(chat1.TopicType_EMOJI) 331 return s.addAdvanced(ctx, uid, nil, convID, alias, filename, allowOverwrite, storage) 332 } 333 334 func (s *DevConvEmojiSource) AddAlias(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID, 335 newAlias, existingAlias string) (res chat1.EmojiRemoteSource, err error) { 336 defer s.Trace(ctx, &err, "AddAlias")() 337 if err = s.validateShortName(newAlias); err != nil { 338 return res, err 339 } 340 var stored chat1.EmojiStorage 341 storage := s.makeStorage(chat1.TopicType_EMOJI) 342 topicName := s.topicName(nil) 343 if _, _, err := storage.Get(ctx, uid, convID, topicName, &stored, false); err != nil { 344 return res, err 345 } 346 getExistingMsgSrc := func() (res chat1.EmojiRemoteSource, found bool) { 347 if stored.Mapping == nil { 348 return res, false 349 } 350 existingSource, ok := stored.Mapping[strings.Trim(existingAlias, ":")] 351 if !ok { 352 return res, false 353 } 354 if !existingSource.IsMessage() { 355 return res, false 356 } 357 return existingSource, true 358 } 359 msgSrc, ok := getExistingMsgSrc() 360 if ok { 361 res = chat1.NewEmojiRemoteSourceWithMessage(chat1.EmojiMessage{ 362 ConvID: msgSrc.Message().ConvID, 363 MsgID: msgSrc.Message().MsgID, 364 IsAlias: true, 365 }) 366 newAlias = s.normalizeShortName(newAlias) 367 } else if s.IsStockEmoji(existingAlias) { 368 username, err := s.G().GetUPAKLoader().LookupUsername(ctx, keybase1.UID(uid.String())) 369 if err != nil { 370 return res, err 371 } 372 res = chat1.NewEmojiRemoteSourceWithStockalias(chat1.EmojiStockAlias{ 373 Text: existingAlias, 374 Username: username.String(), 375 Time: gregor1.ToTime(s.G().GetClock().Now()), 376 }) 377 } else { 378 return res, fmt.Errorf("alias is not a valid existing custom emoji or stock emoji") 379 } 380 if stored.Mapping == nil { 381 stored.Mapping = make(map[string]chat1.EmojiRemoteSource) 382 } 383 stored.Mapping[newAlias] = res 384 return res, storage.Put(ctx, uid, convID, topicName, stored) 385 } 386 387 func (s *DevConvEmojiSource) removeRemoteSource(ctx context.Context, uid gregor1.UID, 388 conv chat1.ConversationLocal, source chat1.EmojiRemoteSource) error { 389 typ, err := source.Typ() 390 if err != nil { 391 return err 392 } 393 switch typ { 394 case chat1.EmojiRemoteSourceTyp_MESSAGE: 395 if source.Message().IsAlias { 396 s.Debug(ctx, "removeRemoteSource: skipping asset remove on alias") 397 return nil 398 } 399 return s.G().ChatHelper.DeleteMsg(ctx, source.Message().ConvID, conv.Info.TlfName, 400 source.Message().MsgID) 401 case chat1.EmojiRemoteSourceTyp_STOCKALIAS: 402 // do nothing 403 default: 404 return fmt.Errorf("unable to delete remote source of typ: %v", typ) 405 } 406 return nil 407 } 408 409 func (s *DevConvEmojiSource) Remove(ctx context.Context, uid gregor1.UID, convID chat1.ConversationID, 410 alias string) (err error) { 411 defer s.Trace(ctx, &err, "Remove")() 412 var stored chat1.EmojiStorage 413 storage := s.makeStorage(chat1.TopicType_EMOJI) 414 topicName := s.topicName(nil) 415 _, storageConv, err := storage.Get(ctx, uid, convID, topicName, &stored, true) 416 if err != nil { 417 return err 418 } 419 if storageConv == nil { 420 s.Debug(ctx, "Remove: no storage conv returned, bailing") 421 return nil 422 } 423 if stored.Mapping == nil { 424 s.Debug(ctx, "Remove: no mapping, bailing") 425 return nil 426 } 427 // get attachment message and delete it 428 source, ok := stored.Mapping[alias] 429 if !ok { 430 s.Debug(ctx, "Remove: no alias in mapping, bailing") 431 return nil 432 } 433 if err := s.removeRemoteSource(ctx, uid, *storageConv, source); err != nil { 434 s.Debug(ctx, "Remove: failed to remove remote source: %s", err) 435 return err 436 } 437 delete(stored.Mapping, alias) 438 // take out any aliases 439 if source.IsMessage() && !source.Message().IsAlias { 440 for existingAlias, existingSource := range stored.Mapping { 441 if existingSource.IsMessage() && existingSource.Message().IsAlias && 442 existingSource.Message().MsgID == source.Message().MsgID { 443 delete(stored.Mapping, existingAlias) 444 } 445 } 446 } 447 return storage.Put(ctx, uid, convID, topicName, stored) 448 } 449 450 func (s *DevConvEmojiSource) animationsDisabled(ctx context.Context, uid gregor1.UID) bool { 451 st, err := s.G().GregorState.State(ctx) 452 if err != nil { 453 s.Debug(ctx, "animationsDisabled: failed to get state: %s", err) 454 return false 455 } 456 cat, err := gregor1.ObjFactory{}.MakeCategory(animationKey) 457 if err != nil { 458 s.Debug(ctx, "animationsDisabled: failed to make category: %s", err) 459 return false 460 } 461 items, err := st.ItemsInCategory(cat) 462 if err != nil { 463 s.Debug(ctx, "animationsDisabled: failed to get items: %s", err) 464 return false 465 } 466 return len(items) > 0 467 } 468 469 func (s *DevConvEmojiSource) ToggleAnimations(ctx context.Context, uid gregor1.UID, enabled bool) (err error) { 470 defer s.Trace(ctx, &err, "ToggleAnimations: enabled: %v", enabled)() 471 cat, err := gregor1.ObjFactory{}.MakeCategory(animationKey) 472 if err != nil { 473 s.Debug(ctx, "animationsDisabled: failed to make category: %s", err) 474 return err 475 } 476 if enabled { 477 return s.G().GregorState.DismissCategory(ctx, cat.(gregor1.Category)) 478 } 479 _, err = s.G().GregorState.InjectItem(ctx, animationKey, []byte{1}, gregor1.TimeOrOffset{}) 480 return err 481 } 482 483 func (s *DevConvEmojiSource) RemoteToLocalSource(ctx context.Context, uid gregor1.UID, 484 remote chat1.EmojiRemoteSource) (source chat1.EmojiLoadSource, noAnimSource chat1.EmojiLoadSource, err error) { 485 typ, err := remote.Typ() 486 if err != nil { 487 return source, noAnimSource, err 488 } 489 noAnim := s.animationsDisabled(ctx, uid) 490 switch typ { 491 case chat1.EmojiRemoteSourceTyp_MESSAGE: 492 msg := remote.Message() 493 sourceURL := s.G().AttachmentURLSrv.GetURL(ctx, msg.ConvID, msg.MsgID, false, noAnim, true) 494 noAnimSourceURL := s.G().AttachmentURLSrv.GetURL(ctx, msg.ConvID, msg.MsgID, false, true, true) 495 return chat1.NewEmojiLoadSourceWithHttpsrv(sourceURL), 496 chat1.NewEmojiLoadSourceWithHttpsrv(noAnimSourceURL), nil 497 case chat1.EmojiRemoteSourceTyp_STOCKALIAS: 498 ret := chat1.NewEmojiLoadSourceWithStr(remote.Stockalias().Text) 499 return ret, ret, nil 500 default: 501 return source, noAnimSource, errors.New("unknown remote source for local source") 502 } 503 } 504 505 func (s *DevConvEmojiSource) creationInfo(ctx context.Context, uid gregor1.UID, 506 remote chat1.EmojiRemoteSource) (res chat1.EmojiCreationInfo, err error) { 507 typ, err := remote.Typ() 508 if err != nil { 509 return res, err 510 } 511 reason := chat1.GetThreadReason_EMOJISOURCE 512 switch typ { 513 case chat1.EmojiRemoteSourceTyp_MESSAGE: 514 msg := remote.Message() 515 sourceMsg, err := s.G().ConvSource.GetMessage(ctx, msg.ConvID, uid, msg.MsgID, &reason, nil, false) 516 if err != nil { 517 return res, err 518 } 519 if !sourceMsg.IsValid() { 520 return res, errors.New("invalid message for creation info") 521 } 522 return chat1.EmojiCreationInfo{ 523 Username: sourceMsg.Valid().SenderUsername, 524 Time: sourceMsg.Valid().ServerHeader.Ctime, 525 }, nil 526 case chat1.EmojiRemoteSourceTyp_STOCKALIAS: 527 return chat1.EmojiCreationInfo{ 528 Username: remote.Stockalias().Username, 529 Time: remote.Stockalias().Time, 530 }, nil 531 default: 532 return res, errors.New("unknown remote source for creation info") 533 } 534 } 535 536 func (s *DevConvEmojiSource) getNoSet(ctx context.Context, uid gregor1.UID, convID *chat1.ConversationID, 537 opts chat1.EmojiFetchOpts) (res chat1.UserEmojis, aliasLookup map[string]chat1.Emoji, err error) { 538 aliasLookup = make(map[string]chat1.Emoji) 539 topicType := chat1.TopicType_EMOJI 540 storage := s.makeStorage(topicType) 541 var sourceTLFID *chat1.TLFID 542 if convID != nil { 543 conv, err := utils.GetUnverifiedConv(ctx, s.G(), uid, *convID, types.InboxSourceDataSourceAll) 544 if err != nil { 545 return res, aliasLookup, err 546 } 547 sourceTLFID = new(chat1.TLFID) 548 *sourceTLFID = conv.Conv.Metadata.IdTriple.Tlfid 549 } 550 readTopicName := s.topicName(nil) 551 ibox, _, err := s.G().InboxSource.Read(ctx, uid, types.ConversationLocalizerBlocking, 552 types.InboxSourceDataSourceAll, nil, &chat1.GetInboxLocalQuery{ 553 TopicType: &topicType, 554 TopicName: &readTopicName, 555 }) 556 if err != nil { 557 return res, aliasLookup, err 558 } 559 convs := ibox.Convs 560 seenAliases := make(map[string]int) 561 addEmojis := func(convs []chat1.ConversationLocal, isCrossTeam bool) { 562 if opts.OnlyInTeam && isCrossTeam { 563 return 564 } 565 for _, conv := range convs { 566 var stored chat1.EmojiStorage 567 found, err := storage.GetFromKnownConv(ctx, uid, conv, &stored) 568 if err != nil { 569 s.Debug(ctx, "Get: failed to read from known conv: %s", err) 570 continue 571 } 572 if !found { 573 s.Debug(ctx, "Get: no stored info for: %s", conv.GetConvID()) 574 continue 575 } 576 group := chat1.EmojiGroup{ 577 Name: conv.Info.TlfName, 578 } 579 for alias, storedEmoji := range stored.Mapping { 580 if !opts.GetAliases && storedEmoji.IsAlias() { 581 continue 582 } 583 var creationInfo *chat1.EmojiCreationInfo 584 source, noAnimSource, err := s.RemoteToLocalSource(ctx, uid, storedEmoji) 585 if err != nil { 586 s.Debug(ctx, "Get: skipping emoji on remote-to-local error: %s", err) 587 continue 588 } 589 if opts.GetCreationInfo { 590 ci, err := s.creationInfo(ctx, uid, storedEmoji) 591 if err != nil { 592 s.Debug(ctx, "Get: failed to get creation info: %s", err) 593 } else { 594 creationInfo = new(chat1.EmojiCreationInfo) 595 *creationInfo = ci 596 } 597 } 598 teamname := conv.Info.TlfName 599 emoji := chat1.Emoji{ 600 Alias: alias, 601 Source: source, 602 NoAnimSource: noAnimSource, 603 RemoteSource: storedEmoji, 604 IsCrossTeam: isCrossTeam, 605 CreationInfo: creationInfo, 606 IsAlias: storedEmoji.IsAlias(), 607 Teamname: &teamname, 608 } 609 if seen, ok := seenAliases[alias]; ok { 610 seenAliases[alias]++ 611 emoji.Alias += fmt.Sprintf("#%d", seen) 612 } else { 613 seenAliases[alias] = 2 614 if s.IsStockEmoji(alias) { 615 emoji.Alias += fmt.Sprintf("#%d", seenAliases[alias]) 616 seenAliases[alias]++ 617 } 618 } 619 aliasLookup[emoji.Alias] = emoji 620 group.Emojis = append(group.Emojis, emoji) 621 } 622 res.Emojis = append(res.Emojis, group) 623 } 624 } 625 var tlfConvs, otherConvs []chat1.ConversationLocal 626 for _, conv := range convs { 627 if sourceTLFID != nil && conv.Info.Triple.Tlfid.Eq(*sourceTLFID) { 628 tlfConvs = append(tlfConvs, conv) 629 } else { 630 otherConvs = append(otherConvs, conv) 631 } 632 } 633 addEmojis(tlfConvs, false) 634 addEmojis(otherConvs, sourceTLFID != nil) 635 return res, aliasLookup, nil 636 } 637 638 func (s *DevConvEmojiSource) Get(ctx context.Context, uid gregor1.UID, convID *chat1.ConversationID, 639 opts chat1.EmojiFetchOpts) (res chat1.UserEmojis, err error) { 640 defer s.Trace(ctx, &err, "Get %v", opts)() 641 var aliasLookup map[string]chat1.Emoji 642 if res, aliasLookup, err = s.getNoSet(ctx, uid, convID, opts); err != nil { 643 return res, err 644 } 645 if err := s.putAliasLookup(ctx, uid, aliasLookup, opts); err != nil { 646 s.Debug(ctx, "Get: failed to put alias lookup: %s", err) 647 } 648 for _, group := range res.Emojis { 649 sort.Slice(group.Emojis, func(i, j int) bool { 650 return group.Emojis[i].Alias < group.Emojis[j].Alias 651 }) 652 } 653 return res, nil 654 } 655 656 type emojiMatch struct { 657 name string 658 position []int 659 } 660 661 func (s *DevConvEmojiSource) parse(ctx context.Context, body string) (res []emojiMatch) { 662 body = utils.ReplaceQuotedSubstrings(body, false) 663 hits := globals.EmojiPattern.FindAllStringSubmatchIndex(body, -1) 664 for _, hit := range hits { 665 if len(hit) < 4 { 666 s.Debug(ctx, "parse: malformed hit: %d", len(hit)) 667 continue 668 } 669 res = append(res, emojiMatch{ 670 name: body[hit[2]:hit[3]], 671 position: []int{hit[0], hit[1]}, 672 }) 673 } 674 return res 675 } 676 677 func (s *DevConvEmojiSource) stripAlias(alias string) string { 678 return strings.Split(alias, "#")[0] 679 } 680 681 func (s *DevConvEmojiSource) versionMatch(ctx context.Context, uid gregor1.UID, l chat1.EmojiRemoteSource, 682 r chat1.EmojiRemoteSource) bool { 683 if !l.IsMessage() || !r.IsMessage() { 684 return false 685 } 686 reason := chat1.GetThreadReason_EMOJISOURCE 687 lmsg, err := s.G().ConvSource.GetMessage(ctx, l.Message().ConvID, uid, l.Message().MsgID, &reason, 688 nil, false) 689 if err != nil { 690 s.Debug(ctx, "versionMatch: failed to get lmsg: %s", err) 691 return false 692 } 693 rmsg, err := s.G().ConvSource.GetMessage(ctx, r.Message().ConvID, uid, r.Message().MsgID, &reason, 694 nil, false) 695 if err != nil { 696 s.Debug(ctx, "versionMatch: failed to get rmsg: %s", err) 697 return false 698 } 699 if !lmsg.IsValid() || !rmsg.IsValid() { 700 s.Debug(ctx, "versionMatch: one message not valid: lmsg: %s rmsg: %s", lmsg.DebugString(), 701 rmsg.DebugString()) 702 return false 703 } 704 if !lmsg.Valid().MessageBody.IsType(chat1.MessageType_ATTACHMENT) || 705 !rmsg.Valid().MessageBody.IsType(chat1.MessageType_ATTACHMENT) { 706 s.Debug(ctx, "versionMatch: one message not attachment: lmsg: %s rmsg: %s", lmsg.DebugString(), 707 rmsg.DebugString()) 708 return false 709 } 710 lhash := lmsg.Valid().MessageBody.Attachment().Object.PtHash 711 rhash := rmsg.Valid().MessageBody.Attachment().Object.PtHash 712 return lhash != nil && rhash != nil && lhash.Eq(rhash) 713 } 714 715 func (s *DevConvEmojiSource) getCrossTeamConv(ctx context.Context, uid gregor1.UID, 716 convID chat1.ConversationID, sourceConvID chat1.ConversationID) (res chat1.ConversationLocal, err error) { 717 baseConv, err := utils.GetVerifiedConv(ctx, s.G(), uid, convID, types.InboxSourceDataSourceAll) 718 if err != nil { 719 s.Debug(ctx, "getCrossTeamConv: failed to get base conv: %s", err) 720 return res, err 721 } 722 sourceConv, err := utils.GetVerifiedConv(ctx, s.G(), uid, sourceConvID, types.InboxSourceDataSourceAll) 723 if err != nil { 724 s.Debug(ctx, "getCrossTeamConv: failed to get source conv: %s", err) 725 return res, err 726 } 727 var created bool 728 topicID := chat1.TopicID(sourceConv.Info.Triple.Tlfid.Bytes()) 729 s.Debug(ctx, "getCrossTeamConv: attempting conv create: sourceConvID: %s topicID: %s", 730 sourceConv.GetConvID(), topicID) 731 topicName := topicID.String() 732 if res, created, err = NewConversation(ctx, s.G(), uid, baseConv.Info.TlfName, &topicName, 733 chat1.TopicType_EMOJICROSS, baseConv.GetMembersType(), baseConv.Info.Visibility, 734 &topicID, s.ri, NewConvFindExistingNormal); err != nil { 735 if convExistsErr, ok := err.(libkb.ChatConvExistsError); ok { 736 s.Debug(ctx, "getCrossTeamConv: conv exists error received, attempting to join: %s", err) 737 if err := JoinConversation(ctx, s.G(), s.DebugLabeler, s.ri, uid, convExistsErr.ConvID); err != nil { 738 s.Debug(ctx, "getCrossTeamConv: failed to join: %s", err) 739 return res, err 740 } 741 if res, err = utils.GetVerifiedConv(ctx, s.G(), uid, convExistsErr.ConvID, 742 types.InboxSourceDataSourceAll); err != nil { 743 s.Debug(ctx, "getCrossTeamConv: failed to get conv after successful join: %s", err) 744 } 745 created = false 746 } else { 747 return res, err 748 } 749 } 750 if created { 751 s.Debug(ctx, "getCrossTeamConv: created a new sync conv: %s (topicID: %s)", res.GetConvID(), topicID) 752 if s.testingCreatedSyncConv != nil { 753 s.testingCreatedSyncConv <- struct{}{} 754 } 755 } else { 756 s.Debug(ctx, "getCrossTeamConv: using exising sync conv: %s (topicID: %s)", res.GetConvID(), topicID) 757 } 758 return res, nil 759 } 760 761 func (s *DevConvEmojiSource) getCacheDir() string { 762 if len(s.tempDir) > 0 { 763 return s.tempDir 764 } 765 return s.G().GetCacheDir() 766 } 767 768 func (s *DevConvEmojiSource) syncCrossTeam(ctx context.Context, uid gregor1.UID, emoji chat1.HarvestedEmoji, 769 convID chat1.ConversationID) (res chat1.HarvestedEmoji, err error) { 770 typ, err := emoji.Source.Typ() 771 if err != nil { 772 return res, err 773 } 774 switch typ { 775 case chat1.EmojiRemoteSourceTyp_MESSAGE: 776 case chat1.EmojiRemoteSourceTyp_STOCKALIAS: 777 emoji.IsCrossTeam = true 778 return emoji, nil 779 default: 780 return res, errors.New("invalid remote source to sync") 781 } 782 var stored chat1.EmojiStorage 783 storage := s.makeStorage(chat1.TopicType_EMOJICROSS) 784 sourceConvID := emoji.Source.Message().ConvID 785 syncConv, err := s.getCrossTeamConv(ctx, uid, convID, sourceConvID) 786 if err != nil { 787 s.Debug(ctx, "syncCrossTeam: failed to get cross team conv: %s", err) 788 return res, err 789 } 790 if _, err := storage.GetFromKnownConv(ctx, uid, syncConv, &stored); err != nil { 791 s.Debug(ctx, "syncCrossTeam: failed to get from known conv: %s", err) 792 return res, err 793 } 794 if stored.Mapping == nil { 795 stored.Mapping = make(map[string]chat1.EmojiRemoteSource) 796 } 797 798 // check for a match 799 stripped := s.stripAlias(emoji.Alias) 800 if existing, ok := stored.Mapping[stripped]; ok { 801 s.Debug(ctx, "syncCrossTeam: hit mapping") 802 if s.versionMatch(ctx, uid, existing, emoji.Source) { 803 s.Debug(ctx, "syncCrossTeam: hit version, returning") 804 return chat1.HarvestedEmoji{ 805 Alias: emoji.Alias, 806 Source: existing, 807 IsCrossTeam: true, 808 }, nil 809 } 810 s.Debug(ctx, "syncCrossTeam: missed on version") 811 } else { 812 s.Debug(ctx, "syncCrossTeam: missed mapping") 813 } 814 if s.testingRefreshedSyncConv != nil { 815 s.testingRefreshedSyncConv <- struct{}{} 816 } 817 // download from the original source 818 sink, err := os.CreateTemp(s.getCacheDir(), "emoji") 819 if err != nil { 820 return res, err 821 } 822 defer os.Remove(sink.Name()) 823 if err := attachments.Download(ctx, s.G(), uid, sourceConvID, 824 emoji.Source.Message().MsgID, sink, false, nil, s.ri); err != nil { 825 s.Debug(ctx, "syncCrossTeam: failed to download: %s", err) 826 return res, err 827 } 828 829 // add the source to the target storage area 830 newSource, err := s.addAdvanced(ctx, uid, &syncConv, convID, stripped, sink.Name(), true, storage) 831 if err != nil { 832 return res, err 833 } 834 return chat1.HarvestedEmoji{ 835 Alias: emoji.Alias, 836 Source: newSource, 837 IsCrossTeam: true, 838 }, nil 839 } 840 841 func (s *DevConvEmojiSource) Harvest(ctx context.Context, body string, uid gregor1.UID, 842 convID chat1.ConversationID, mode types.EmojiHarvestMode) (res []chat1.HarvestedEmoji, err error) { 843 if globals.IsEmojiHarvesterCtx(ctx) { 844 s.Debug(ctx, "Harvest: in an existing harvest context, bailing") 845 return nil, nil 846 } 847 matches := s.parse(ctx, body) 848 if len(matches) == 0 { 849 return nil, nil 850 } 851 ctx = globals.CtxMakeEmojiHarvester(ctx) 852 defer s.Trace(ctx, &err, "Harvest: mode: %v", mode)() 853 s.Debug(ctx, "Harvest: %d matches found", len(matches)) 854 aliasMap, err := s.getAliasLookup(ctx, uid) 855 if err != nil { 856 s.Debug(ctx, "Harvest: failed to get alias lookup: %s", err) 857 return res, err 858 } 859 shouldSync := false 860 switch mode { 861 case types.EmojiHarvestModeNormal: 862 shouldSync = true 863 if len(aliasMap) == 0 { 864 s.Debug(ctx, "Harvest: no alias map, fetching fresh") 865 _, aliasMap, err = s.getNoSet(ctx, uid, &convID, chat1.EmojiFetchOpts{ 866 GetCreationInfo: false, 867 GetAliases: true, 868 OnlyInTeam: false, 869 }) 870 if err != nil { 871 s.Debug(ctx, "Harvest: failed to get emojis: %s", err) 872 return res, err 873 } 874 } 875 case types.EmojiHarvestModeFast: 876 // skip this, just use alias map in fast mode 877 } 878 if len(aliasMap) == 0 { 879 return nil, nil 880 } 881 s.Debug(ctx, "Harvest: num emojis: alias: %d", len(aliasMap)) 882 for _, match := range matches { 883 // try group map first 884 if emoji, ok := aliasMap[match.name]; ok { 885 var resEmoji chat1.HarvestedEmoji 886 if emoji.IsCrossTeam && shouldSync { 887 if resEmoji, err = s.syncCrossTeam(ctx, uid, chat1.HarvestedEmoji{ 888 Alias: match.name, 889 Source: emoji.RemoteSource, 890 }, convID); err != nil { 891 s.Debug(ctx, "Harvest: failed to sync cross team: %s", err) 892 return res, err 893 } 894 } else { 895 resEmoji = chat1.HarvestedEmoji{ 896 Alias: match.name, 897 Source: emoji.RemoteSource, 898 IsCrossTeam: emoji.IsCrossTeam, 899 } 900 } 901 res = append(res, resEmoji) 902 } 903 } 904 return res, nil 905 } 906 907 func (s *DevConvEmojiSource) Decorate(ctx context.Context, body string, uid gregor1.UID, 908 messageType chat1.MessageType, emojis []chat1.HarvestedEmoji) string { 909 if len(emojis) == 0 { 910 return body 911 } 912 matches := s.parse(ctx, body) 913 if len(matches) == 0 { 914 return body 915 } 916 bigEmoji := false 917 if messageType == chat1.MessageType_TEXT && len(matches) == 1 { 918 singleEmoji := matches[0] 919 // check if the emoji is the entire message (ignoring whitespace) 920 if singleEmoji.position[0] == 0 && singleEmoji.position[1] == len(strings.TrimSpace(body)) { 921 bigEmoji = true 922 } 923 } 924 defer s.Trace(ctx, nil, "Decorate")() 925 emojiMap := make(map[string]chat1.EmojiRemoteSource, len(emojis)) 926 for _, emoji := range emojis { 927 // If we have conflicts on alias, use the first one. This helps make dealing with reactions 928 // better, since we really want the first reaction on an alias to always be displayed. 929 if _, ok := emojiMap[emoji.Alias]; !ok { 930 emojiMap[emoji.Alias] = emoji.Source 931 } 932 } 933 offset := 0 934 added := 0 935 isReacji := messageType == chat1.MessageType_REACTION 936 for _, match := range matches { 937 if remoteSource, ok := emojiMap[match.name]; ok { 938 source, noAnimSource, err := s.RemoteToLocalSource(ctx, uid, remoteSource) 939 if err != nil { 940 s.Debug(ctx, "Decorate: failed to get local source: %s", err) 941 continue 942 } 943 typ, err := source.Typ() 944 if err != nil { 945 s.Debug(ctx, "Decorate: failed to get load source type: %s", err) 946 continue 947 } 948 if typ == chat1.EmojiLoadSourceTyp_STR { 949 // Instead of decorating aliases, just replace them with the alias string 950 strDecoration := source.Str() 951 length := match.position[1] - match.position[0] 952 added := len(strDecoration) - length 953 decorationOffset := match.position[0] + offset 954 body = fmt.Sprintf("%s%s%s", body[:decorationOffset], strDecoration, 955 body[decorationOffset+length:]) 956 offset += added 957 continue 958 } 959 960 body, added = utils.DecorateBody(ctx, body, match.position[0]+offset, 961 match.position[1]-match.position[0], 962 chat1.NewUITextDecorationWithEmoji(chat1.Emoji{ 963 IsBig: bigEmoji, 964 IsReacji: isReacji, 965 Alias: match.name, 966 Source: source, 967 NoAnimSource: noAnimSource, 968 IsAlias: remoteSource.IsAlias(), 969 })) 970 offset += added 971 } 972 } 973 return body 974 } 975 976 func (s *DevConvEmojiSource) IsValidSize(size int64) bool { 977 return size <= maxEmojiSize && size >= minEmojiSize 978 }