require 'uri' require 'time' require 'json' require 'strscan' class Bsky class Error < StandardError; end class CreateSessionError < Error; end class NoSession < Error; end class ApiError < Error; end class BlobSizeExceeded < Error; end BASE_URL = 'https://bsky.social/xrpc/com.atproto.' STORAGE_KEY = 'bsky_session' # @param identifier [String] # @param password [String] def self.create_session(identifier, password) params = {identifier:, password:} resp = Http.post(BASE_URL + 'server.createSession', params.to_json, headers: {"Content-Type"=>"application/json"}) raise CreateSessionError, resp.text.await unless resp.ok session = resp.json.await.to_hash LocalStorage.set(STORAGE_KEY, session.to_json) session end def self.destroy_session LocalStorage.remove(STORAGE_KEY) end attr_reader :session # @raise [NoSession] def initialize session = LocalStorage.get(STORAGE_KEY) raise NoSession unless session @session = JSON.parse(session) headers = {"Content-Type"=>"application/json", "Authorization"=>"Bearer #{@session['accessJwt']}"} resp = Http.get(BASE_URL + 'server.getSession', headers:) return if resp.ok res = resp.json.await.to_hash if res['error'] == 'ExpiredToken' refresh_session else LocalStorage.remove(STORAGE_KEY) raise NoSession end end # @raise [NoSession] def refresh_session resp = Http.post(BASE_URL + 'server.refreshSession', "", headers: {"Content-Type"=>"application/json", "Authorization"=>"Bearer #{@session['refreshJwt']}"}) unless resp.ok LocalStorage.remove(STORAGE_KEY) raise NoSession end json = resp.json.await.to_json LocalStorage.set(STORAGE_KEY, json) @session = JSON.parse(json) end # @return [Hash] def api(method, *args, headers: {"Content-Type"=>"application/json"}) count = 0 while count < 2 count += 1 headers = headers.merge("Authorization"=>"Bearer #{@session['accessJwt']}") resp = Http.__send__(method, *args, headers:) res = resp.json.await.to_hash return res if resp.ok case res['error'] when 'ExpiredToken' refresh_session next when 'InvalidToken' LocalStorage.remove(STORAGE_KEY) raise NoSession else raise ApiError, res.to_json end end raise ApiError, json.to_json end # @param text [String] # @param images [Hash] # @return [Hash] def post(text, images: {}, card: nil) text, facets = markdown(text) embed_images = images.each_value.map {|file| {image: upload_file(file), alt: ''} } record = { '$type': 'app.bsky.feed.post', createdAt: Time.now.iso8601, text:, facets:, } unless embed_images.empty? record[:embed] = {'$type': 'app.bsky.embed.images', images: embed_images} end if card embed = { '$type': 'app.bsky.embed.external', external: card.slice(:uri, :title, :description), } embed[:external]['thumb'] = upload_file(card[:thumbnail]) if card[:thumbnail] record[:embed] = embed end body = { repo: @session['did'], collection: 'app.bsky.feed.post', record:, } api(:post, BASE_URL + 'repo.createRecord', body.to_json) end # @param file [JSrb] # @return [Hash] def upload_file(file) upload_blob(file, file.type) end # @param blob [JSrb] # @param type [String] Content-Type # @retrurn [Hash] def upload_blob(blob, type) raise BlobSizeExceeded if blob.size > 1000000 res = api(:post, BASE_URL + 'repo.uploadBlob', blob.js_object, headers: {"Content-Type"=>type}) return res['blob'] end # @param src [String] # @return [Array>] def markdown(src) ss = StringScanner.new(src.dup) facets = [] result = '' until ss.eos? if ss.scan(/[^<\[]+/) result.concat ss[0] elsif ss.scan(/<(https?:\/\/[^>]+)>/) uri = ss[1] s = result.bytesize e = s + uri.bytesize facets.push(index: {byteStart: s, byteEnd: e}, features: [{uri:, '$type': 'app.bsky.richtext.facet#link'}]) result.concat uri elsif ss.scan(/\[([^\]]+)\]\((https?:\/\/[^\)]+)\)/m) text, uri = ss[1], ss[2] s = result.bytesize e = s + text.bytesize facets.push(index: {byteStart: s, byteEnd: e}, features: [{uri:, '$type': 'app.bsky.richtext.facet#link'}]) result.concat text else result.concat ss.scan(/./) end end return result, facets end end