Class: Tempest::AvatarStore

Inherits:
Object
  • Object
show all
Defined in:
lib/tempest/avatar_store.rb

Overview

Resolves Bluesky DIDs to a local PNG file path for the actor’s avatar. Mirrors the shape of HandleResolver: an injected client speaks XRPC for ‘app.bsky.actor.getProfile`, and the result is cached in-process so the PDS isn’t hit on every event.

Disk layout: avatars live under ‘cache_dir/` as “<sanitized-did>__<avatar-cid>.png”. The avatar CID is read from the tail of the avatar URL so that re-uploaded avatars (which receive a new CID) invalidate the cache without server-side coordination.

Defined Under Namespace

Classes: DefaultProfileClient

Constant Summary collapse

NOT_FOUND =

Sentinel for “we tried, there is no avatar” — distinct from “we haven’t looked yet” (nil). Mirrors the pattern in HandleResolver.

Object.new.freeze
EXT_BY_MIME =
{
  "image/jpeg" => "jpg",
  "image/jpg" => "jpg",
  "image/png" => "png",
  "image/webp" => "webp",
  "image/gif" => "gif",
  "image/avif" => "avif",
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(client:, cache_dir:, fetcher: nil, converter: nil, async: true, executor: nil) ⇒ AvatarStore

Returns a new instance of AvatarStore.



50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/tempest/avatar_store.rb', line 50

def initialize(client:, cache_dir:, fetcher: nil, converter: nil, async: true, executor: nil)
  @client = client
  @cache_dir = cache_dir
  @fetcher = fetcher || self.class.default_fetcher
  @converter = converter || self.class.default_converter
  @async = async
  @executor = executor || method(:default_executor)
  @cache = {}
  @pending = {}
  @mutex = Mutex.new
  FileUtils.mkdir_p(@cache_dir)
end

Class Method Details

.default_converterObject

Production format normalizer: shells out to ImageMagick to crop-fit the avatar into a 128x128 PNG. The crop pads non-square inputs so the Kitty graphics protocol can render at a consistent 1-row, 2-col aspect.



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/tempest/avatar_store.rb', line 82

def self.default_converter
  @default_converter ||= lambda do |bytes, content_type:|
    ext = ext_for(content_type, bytes)
    Dir.mktmpdir do |dir|
      src = File.join(dir, "src.#{ext}")
      dst = File.join(dir, "out.png")
      File.binwrite(src, bytes)
      _out, status = Open3.capture2e(
        "magick", src,
        "-resize", "128x128^",
        "-gravity", "center",
        "-extent", "128x128",
        dst,
      )
      raise "magick convert failed" unless status.success?
      File.binread(dst)
    end
  end
end

.default_fetcherObject

Production HTTP fetcher used when no fetcher is injected. Returns the raw bytes and Content-Type header so the converter can pick the right input format.



66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/tempest/avatar_store.rb', line 66

def self.default_fetcher
  @default_fetcher ||= lambda do |url|
    uri = URI(url)
    res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
      http.get(uri.request_uri)
    end
    unless res.is_a?(Net::HTTPSuccess)
      raise Tempest::APIError.new(res.code.to_i, { "error" => res.message })
    end
    [res.body, res["content-type"].to_s]
  end
end

.ext_for(content_type, bytes) ⇒ Object



111
112
113
114
115
116
117
118
119
120
# File 'lib/tempest/avatar_store.rb', line 111

def self.ext_for(content_type, bytes)
  mime = content_type.to_s.split(";").first.to_s.strip.downcase
  return EXT_BY_MIME[mime] if EXT_BY_MIME.key?(mime)
  head = bytes.byteslice(0, 16).to_s
  return "jpg"  if head.start_with?("\xFF\xD8\xFF".b)
  return "png"  if head.start_with?("\x89PNG\r\n\x1A\n".b)
  return "gif"  if head.start_with?("GIF87a", "GIF89a")
  return "webp" if head[0, 4] == "RIFF" && head[8, 4] == "WEBP"
  "bin"
end

Instance Method Details

#path_for(did) ⇒ Object



122
123
124
125
126
127
128
129
130
131
132
# File 'lib/tempest/avatar_store.rb', line 122

def path_for(did)
  cached = @mutex.synchronize { @cache[did] }
  return cached_value(cached) unless cached.nil?

  if @async
    enqueue_resolve(did)
    nil
  else
    resolve_and_cache(did)
  end
end

#seed(did, path) ⇒ Object



134
135
136
# File 'lib/tempest/avatar_store.rb', line 134

def seed(did, path)
  @mutex.synchronize { @cache[did] = path }
end