Posting on Bluesky

Share your advanced PureBasic knowledge/code with the community.
Seymour Clufley
Addict
Addict
Posts: 1267
Joined: Wed Feb 28, 2007 9:13 am
Location: London

Posting on Bluesky

Post by Seymour Clufley »

This is a small library for using the API for the Bluesky social media platform. I've adapted it from this PHP library and written a few convenience procedures. I only need it for very basic stuff so I have only implemented making posts, with or without images. The text can include HTML hyperlinks and simple URLs. Both will be converted into clickable links. Bluesky can accept images of type JPEG, WEBP, 24-bit PNG, and GIF. However, only the first frame of an animated gif will be shown.

In order to use this, you will need to create an app password on Bluesky (as described here) then use that password as shown in the demo code below.

Bluesky.pbi:

Code: Select all

Global c13.s = Chr(13)
Global c32.s=Chr(32)
Global c34.s = Chr(34)
Global c39.s=Chr(39)
#d1 = "|"

Macro R(t)
  MessageRequester("Report",t,0)
EndMacro

Macro StartsWith(main,sub)
	(sub<>"" And main<>"" And Left(main,Len(sub))=sub)
EndMacro

Macro EnsureThisEnd(t,endd)
	
	If endd<>""
		If Right(t,Len(endd)) <> endd
			t+endd
		EndIf
	EndIf
	
EndMacro




Procedure.s StringMapToURLParameters(Map arg.s())
  t.s = ""
  a=0
  ForEach arg()
    a+1
    If a>1
      t + "&"
    EndIf
    t + MapKey(arg())+"="+arg()
  Next
  ProcedureReturn t
EndProcedure


Procedure.s StringMapToJSONObject(Map arg.s())
  t.s = "{ "
  ForEach arg()
    t + c34+MapKey(arg())+c34+": "
    If StartsWith(arg(),"[") Or StartsWith(arg(),"{")
      t + arg()
    Else
      t + c34+arg()+c34
    EndIf
    t+", "
  Next
  t = Left(t,Len(t)-2) + " }"
  ProcedureReturn t
EndProcedure



Structure BlueskyAPI
  accountDid.s
  apiKey.s
  refreshToken.s
  apiUri.s
EndStructure

Structure BlueskyCredentials
  handle.s
  password.s
EndStructure


Macro BS_PostURL(bshdl,bspid)
  "https://bsky.app/profile/"+bshdl+"/post/"+bspid
EndMacro



; Make a request to the Bluesky API
Procedure.i BS_Request(*cnc.BlueskyAPI,reqType.i, request.s, Map arg.s(),postFields.s="",content_type.s="")
  
  u.s = *cnc\apiUri + request
  
  If reqType=#PB_HTTP_Get And MapSize(arg())
    u + "?" + StringMapToURLParameters(arg())
  Else
    If reqType=#PB_HTTP_Post And content_type=""
      content_type = "application/json"
    EndIf
  EndIf
  
  NewMap header.s()
  If *cnc\apiKey
    header("Authorization") = "Bearer "+*cnc\apiKey
  EndIf
  
  If content_type<>""
    header("Content-Type") = content_type
    
    If content_type="application/json" And MapSize(arg())
      postFields = StringMapToJSONObject(arg())
      ClearMap(arg())
    EndIf
  EndIf
  
  
  req.i = HTTPRequest(reqType,u,postFields,0,header())
  If req
    Debug "StatusCode: " + HTTPInfo(req,#PB_HTTP_StatusCode)
    Debug "Response: " + HTTPInfo(req,#PB_HTTP_Response)
    em.s=HTTPInfo(req,#PB_HTTP_ErrorMessage) : If em : Debug "Error: "+em : EndIf
    
    *ret = HTTPMemory(req)
    j.i = CatchJSON(#PB_Any,*ret,MemorySize(*ret))
    FinishHTTP(req)
    FreeMemory(*ret)
    
    ProcedureReturn j
  EndIf
  
EndProcedure



; Start a new user session using handle and app password
Procedure.i BS_StartNewSession(*cnc.BlueskyAPI,handle.s, password.s)
  
  NewMap arg.s()
  arg("identifier") =  handle
  arg("password") = password
  
  j.i = BS_Request(*cnc,#PB_HTTP_Post, "com.atproto.server.createSession", arg())
  
  ;If j\error
    ;throw new RuntimeException(j\message)
  ;EndIf
  
  ProcedureReturn j
EndProcedure



; Refresh a user session using an API key
Procedure.i BS_RefreshSession(*cnc.BlueskyAPI,api_key.s)
  
  *cnc\apiKey = api_key
  NewMap arg.s()
  j.i = BS_Request(*cnc,#PB_HTTP_Post, "com.atproto.server.refreshSession",arg())
  ;unset(*cnc\apiKey)
  
  ;If j\error
    ;throw new RuntimeException($data->message)
  ;EndIf
  
  ProcedureReturn j
EndProcedure



; Authorize a user
; If handle and password are provided, a new session will be created. If a refresh token is provided, the session will be refreshed.
Procedure.b BS_Auth(*cnc.BlueskyAPI,handleOrToken.s, password.s="")
  
  If password<>""
    ;R("START NEW SESSION")
    j = BS_StartNewSession(*cnc,handleOrToken,password)
  Else
    ;R("REFRESH SESSION")
    j = BS_RefreshSession(*cnc,handleOrToken)
  EndIf
  
  ObjectValue = JSONValue(j)
  If ExamineJSONMembers(ObjectValue)
    While NextJSONMember(ObjectValue)
      Select JSONMemberKey(ObjectValue)
        Case "did"
          *cnc\accountDid = GetJSONString(JSONMemberValue(ObjectValue))
        Case "accessJwt"
          *cnc\apiKey = GetJSONString(JSONMemberValue(ObjectValue))
        Case "refreshJwt"
          *cnc\refreshToken = GetJSONString(JSONMemberValue(ObjectValue))
      EndSelect
    Wend
  EndIf
  FreeJSON(j)
  
EndProcedure



Procedure BS_Initialize(*cnc.BlueskyAPI,handle.s,password.s,api_uri.s="https://bsky.social/xrpc/")
  *cnc.BlueskyAPI\apiUri = api_uri
  
  BS_Auth(*cnc,handle,password)
EndProcedure




;-------------------------------------------------

#BS_ImageSizeLimit = 1000000

Structure BS_BlobRef
  link.s
EndStructure
Structure BS_Blob
  type.s
  ref.BS_BlobRef
  mimeType.s
  size.i
EndStructure

Procedure.s BS_UploadBlob(*cnc.BlueskyAPI,*imgMem,mimeType.s)
  
  u.s = *cnc\apiUri + "com.atproto.repo.uploadBlob"
  
  MemLen = MemorySize(*imgMem)
  
  NewMap header.s()
  header("Content-Type") = mimeType
  If *cnc\apiKey
    header("Authorization") = "Bearer "+*cnc\apiKey
  EndIf
  
  header("Content-Length") = Str(MemLen)
  
  req = HTTPRequestMemory(#PB_HTTP_Post, u, *imgMem, MemLen, 0, header())
  If req
    Debug "StatusCode = "+ HTTPInfo(req, #PB_HTTP_StatusCode)
    resp.s = HTTPInfo(req, #PB_HTTP_Response)
    resp = ReplaceString(resp,"$type","type")
    resp = ReplaceString(resp,"$link","link")
    j.i = ParseJSON(#PB_Any,resp)
    FinishHTTP(req)
  EndIf
  
  
  If j
    ObjectValue = JSONValue(j)
    If ExamineJSONMembers(ObjectValue)
      While NextJSONMember(ObjectValue)
        Select JSONMemberKey(ObjectValue)
          Case "blob"
            value = JSONMemberValue(ObjectValue)
            If ExamineJSONMembers(value)
              While NextJSONMember(value)
                If JSONMemberKey(value) = "ref"
                  value2 = JSONMemberValue(value)
                  ExtractJSONStructure(value,@struc.BS_Blob,BS_Blob)
                  j2 = CreateJSON(#PB_Any)
                  InsertJSONStructure(JSONValue(j2),@struc,BS_Blob)
                  blob_ref.s = ComposeJSON(j2)
                  blob_ref = ReplaceString(blob_ref,"type","$type")
                  blob_ref = ReplaceString(blob_ref,"link","$link")
                  FreeJSON(j2)
                  Break 2
                EndIf
              Wend
            EndIf
        EndSelect
      Wend
    Else
      R("CAN'T EXAMINE MAIN")
    EndIf
    FreeJSON(j)
  EndIf
  
  
  ProcedureReturn blob_ref
  
EndProcedure


CompilerIf Defined(MimeType,#PB_Procedure)=#False
Procedure.s MimeType(ext.s)
  ext = RemoveString(LCase(ext),".")
  Select ext
    Case "txt"
      ProcedureReturn "text/plain"
    Case "json"
      ProcedureReturn "application/json"
    Case "xml"
      ProcedureReturn "application/xml"
    Case "mp3", "ogg", "m4a"
      ProcedureReturn "audio/"+ext
    Case "jpg", "jpeg"
      ProcedureReturn "image/jpeg"
    Case "png", "gif", "webp"
      ProcedureReturn "image/"+ext
    Case "mp4", "webm"
      ProcedureReturn "video/"+ext
    Case "svg"
      ProcedureReturn "image/svg+xml"
    Case "srt"
      ProcedureReturn "application/x-subrip"
  EndSelect
  Debug "Don't know mime type for file extension:"+c13+ext
EndProcedure
CompilerEndIf


Procedure.s BS_UploadImageFile(*cnc.BlueskyAPI,fn.s)
  If fn="" Or FileSize(fn)=-1
    ProcedureReturn ""
  EndIf
  
  f = ReadFile(#PB_Any,fn)
  size = Lof(f)
  
  If size>#BS_ImageSizeLimit
    CloseFile(f)
    Debug "media too large"
    ProcedureReturn ""
  EndIf
  
  *imgMem = AllocateMemory(size)
  ReadData(f,*imgMem,size)
  CloseFile(f)
  
  mime.s = MimeType(GetExtensionPart(fn))
  
  blob_ref.s = BS_UploadBlob(*cnc,*imgMem,mime)
  FreeMemory(*imgMem)
  ProcedureReturn blob_ref
EndProcedure

Procedure.s BS_UploadPBImageAsJPEG(*cnc.BlueskyAPI,img.i,quality.i=7)
  
  If Not IsImage(img)
    ProcedureReturn ""
  EndIf
  
  UseJPEGImageEncoder()
  *imgMem = EncodeImage(img,#PB_ImagePlugin_JPEG,quality)
  mime.s = MimeType("jpg")
  
  size = MemorySize(*imgMem)
  If size>#BS_ImageSizeLimit
    FreeMemory(*imgMem)
    Debug "image too large"
    ProcedureReturn ""
  EndIf
  
  blob_ref.s = BS_UploadBlob(*cnc,*imgMem,mime)
  FreeMemory(*imgMem)
  ProcedureReturn blob_ref
EndProcedure

Procedure.s BS_UploadPBImageAsPNG(*cnc.BlueskyAPI,img.i,depth=24)
  
  If Not IsImage(img)
    ProcedureReturn ""
  EndIf
  
  ; BS doesn't support 32-bit
  If depth=32
    depth=24
  EndIf
  
  UsePNGImageEncoder()
  *imgMem = EncodeImage(img,#PB_ImagePlugin_PNG,0,depth)
  mime.s = MimeType("png")
  
  size = MemorySize(*imgMem)
  If size>#BS_ImageSizeLimit
    FreeMemory(*imgMem)
    Debug "image too large"
    ProcedureReturn ""
  EndIf
  
  blob_ref.s = BS_UploadBlob(*cnc,*imgMem,mime)
  FreeMemory(*imgMem)
  ProcedureReturn blob_ref
EndProcedure



Structure BS_FacetIndex
  byteStart.i
  byteEnd.i
EndStructure
Structure BS_FacetFeatures
  type.s
  uri.s
  did.s
EndStructure
Structure BS_Facet
  index.BS_FacetIndex
  Array features.BS_FacetFeatures(0)
EndStructure

Procedure.s ConformAllHyperlinks(h.s)
  Static r.i
  If Not r
    r = CreateRegularExpression(#PB_Any,"href='(.+?)'")
  EndIf
  
  If ExamineRegularExpression(r,h)
    While NextRegularExpressionMatch(r)
      detect1 = RegularExpressionMatchPosition(r)
      detect2 = RegularExpressionMatchLength(r)
      tag.s = Mid(h,detect1,detect2)
      tag = ReplaceString(tag,c39,c34)
      h = Left(h,detect1-1)+tag+Mid(h,detect1+detect2,Len(h))
    Wend
  EndIf
  
  ProcedureReturn h
  
EndProcedure

Procedure.s MakeURLsIntoHyperlinks(h.s)
  h = ConformAllHyperlinks(h)
  
  Dim cand_detect2.i(5)
  
  Repeat
    detect1 = FindString(h,"http",start)
    If Not detect1 : Break : EndIf
    If Mid(h,detect1-9,8)="<a href="
      start = FindString(h,"</a>",detect1)+4
      Continue
    EndIf
    ;cand_detect2(0) = FindString(h,".",detect1+1)
    cand_detect2(0) = FindString(h,",",detect1+1)
    cand_detect2(1) = FindString(h,c13,detect1+1)
    cand_detect2(2) = FindString(h,c32,detect1+1)
    cand_detect2(3) = FindString(h,c34,detect1+1)
    cand_detect2(4) = FindString(h,c39,detect1+1)
    cand_detect2(5) = Len(h)+1
    SortArray(cand_detect2(),#PB_Sort_Ascending)
    detect2 = -1
    For a = 1 To ArraySize(cand_detect2())
      If cand_detect2(a)>0
        detect2 = cand_detect2(a)
        Break
      EndIf
    Next a
    If detect2=-1 : Break : EndIf
    u.s = Mid(h,detect1,detect2-detect1)
    nh.s = Left(h,detect1-1)+"<a href="+c34+u+c34+">"+u+"</a>"
    start = Len(nh)+1
    nh + Mid(h,detect2,Len(h))
    h = nh
    
  ForEver
  
  ProcedureReturn h
  
EndProcedure

Procedure.s ParseHyperlinks(h.s,List facet.BS_Facet())
  h = MakeURLsIntoHyperlinks(h)
  
  pt.s = ""
  anchors = CountString(h,"<a ")
  For a = 1 To anchors+1
    this_one.s = StringField(h,a,"<a ")
    If FindString(this_one,"</a>")=0
      pt + this_one
      Continue
    EndIf
    u.s = StringField(this_one,2,c34)
    alias.s = StringField(this_one,2,">")
    alias = StringField(alias,1,"<")
    excess.s = StringField(this_one,2,"</a>")
    
    AddElement(facet())
    facet()\index\byteStart = Len(pt)
    facet()\index\byteEnd = Len(pt)+Len(alias)
    facet()\features(0)\type = "app.bsky.richtext.facet#link"
    facet()\features(0)\uri = u
    pt+alias+excess
  Next a
  
  ProcedureReturn pt
EndProcedure


Procedure.s BS_Post(*cnc.BlueskyAPI,txt.s,img_blobref_arr.s="")
  
  NewList facet.BS_Facet()
  plainTxt.s = ParseHyperlinks(txt,facet())
  
  NewMap record_arg.s()
  record_arg("text") = plainTxt
  record_arg("langs") = "["+c34+"en"+c34+"]"
  record_arg("createdAt") = FormatDate("%yyyy-%mm-%ddT%hh:%mm:%ss.000000Z",Date())
  record_arg("$type") = "app.bsky.feed.post"
  
  fj = CreateJSON(#PB_Any)
  InsertJSONList(JSONValue(fj),facet())
  record_arg("facets") = ComposeJSON(fj)
  FreeJSON(fj)
  record_arg("facets") = ReplaceString(record_arg("facets"),c34+"type"+c34+":",c34+"$type"+c34+":")
  
  If img_blobref_arr<>""
    EnsureThisEnd(img_blobref_arr,#d1)
    blobs = CountString(img_blobref_arr,#d1)
    t.s = ""
    For b = 1 To blobs
      blob_ref.s = StringField(img_blobref_arr,b,#d1)
      t + "{ ¬alt¬:¬¬, ¬image¬:"+blob_ref+"}"
      If b<blobs : t+", " : EndIf
    Next b
    record_arg("embed") = "{ ¬$type¬: ¬app.bsky.embed.images¬, ¬images¬: [" +t+ "] }"
    record_arg("embed") = ReplaceString(record_arg("embed"),"¬",c34)
  EndIf
  
  
  NewMap arg.s()
  arg("collection") = "app.bsky.feed.post"
  arg("repo") = *cnc\accountDid
  arg("record") = StringMapToJSONObject(record_arg())
  
  j.i = BS_Request(*cnc,#PB_HTTP_Post, "com.atproto.repo.createRecord", arg())
  
  If j
    ObjectValue = JSONValue(j)
    If ExamineJSONMembers(ObjectValue)
      While NextJSONMember(ObjectValue)
        If JSONMemberKey(ObjectValue) = "uri"
          ur.s = GetJSONString(JSONMemberValue(ObjectValue))
          id.s = StringField(ur,5,"/")
          Break
        EndIf
      Wend
    EndIf
    FreeJSON(j)
  EndIf
  
  ProcedureReturn id
EndProcedure
Usage demo:

Code: Select all

XIncludeFile "Bluesky.pbi"

#MyBSHandle = "myblueskyhandle.bsky.social"
#MyBSAppPassword = "enbr-39rh-mlc4-kzva"
BS_Initialize(@bsky.BlueskyAPI,#MyBSHandle,#MyBSAppPassword)
Dim blob_ref.s(2)
For b = 1 To 2
  iw=600
  ih=400
  img = CreateImage(#PB_Any,iw,ih,32)
  StartDrawing(ImageOutput(img))
  DrawingMode(#PB_2DDrawing_AlphaBlend)
  clr = RGBA(Random(255),Random(255),Random(255),255)
  For a = 1 To 10
    LineXY(Random(iw),Random(ih),Random(iw),Random(ih),clr)
  Next a
  StopDrawing()
  
  Select b
    Case 1
      ; upload this image directly
      blob_ref(b) = BS_UploadPBImageAsJPEG(@bsky,img)
    Case 2
      ; save this image to a file and upload the file
      jpgfn.s = ; enter a viable JPEG filename here
      UseJPEGImageEncoder()
      SaveImage(img,jpgfn,#PB_ImagePlugin_JPEG)
      blob_ref(b) = BS_UploadImageFile(@bsky,jpgfn)
  EndSelect
  FreeImage(img)
Next b
txt.s = "Two images created in PB:"
mid.s = BS_Post(@bsky,txt,blob_ref(1)+#d1+blob_ref(2))
u.s = BS_PostURL(#MyBSHandle,mid)
RunProgram(u)
I might add more functions in future, if Bluesky implement videos and proper gif handling.
JACK WEBB: "Coding in C is like sculpting a statue using only sandpaper. You can do it, but the result wouldn't be any better. So why bother? Just use the right tools and get the job done."
User avatar
❤x1
User
User
Posts: 53
Joined: Thu Jan 10, 2019 5:56 pm

Re: Posting on Bluesky

Post by ❤x1 »

Hey!
Thanks for doing the hard work and figuring out how to interface with Bluesky's API. I took your work and packaged it in a simple, cleaner and easier to use module :

Code: Select all

; ============================================================================
; BlueskyAPI Module for PureBasic
; By LastLife based on https://www.purebasic.fr/english/viewtopic.php?p=626397
; ============================================================================

DeclareModule BlueskyAPI
	;- =======================================================================
	;- Constants
	;- =======================================================================
	
	#DEFAULT_API_URI   = "https://bsky.social/xrpc/"
	#IMAGE_SIZE_LIMIT  = 1000000  ; 1MB max for images
	
	;- =======================================================================
	;- Data Structures
	;- =======================================================================
	
	Structure Session
		DID.s           ; Decentralized Identifier
		AccessToken.s	; JWT access token
		RefreshToken.s	; JWT refresh token
		ApiUri.s		; API endpoint URI
	EndStructure
	
	Structure BlobReference
		Link.s
	EndStructure
	
	Structure Blob
		Type.s
		Ref.BlobReference
		MimeType.s
		Size.i
	EndStructure
	
	Structure FacetIndex
		ByteStart.i
		ByteEnd.i
	EndStructure
	
	Structure FacetFeature
		Type.s
		Uri.s
		DID.s
	EndStructure
	
	Structure Facet
		Index.FacetIndex
		Array Features.FacetFeature(0)
	EndStructure
	
	;- =======================================================================
	;- Public Interface
	;- =======================================================================
	
	; Session Management
	Declare.b Initialize(*Session.Session, Handle.s, Password.s, ApiUri.s = #DEFAULT_API_URI)
	Declare.b RefreshSession(*Session.Session)
	
	; Posting
	Declare.s CreatePost(*Session.Session, Text.s, Language.s = "en", ImageBlobRefs.s = "")
	
	; Media Upload
	Declare.s UploadBlob(*Session.Session, *Memory, MemorySize.i, MimeType.s)
	Declare.s UploadImageFile(*Session.Session, FilePath.s)
	Declare.s UploadImageAsJPEG(*Session.Session, Image.i, Quality.i = 7)
	Declare.s UploadImageAsPNG(*Session.Session, Image.i)
	
	; Utility
	Declare.s GetPostURL(Handle.s, PostId.s)
	
EndDeclareModule

Module BlueskyAPI
	
	;- =======================================================================
	;- Private Constants
	;- =======================================================================
	
	#CHAR_DQUOTE     = Chr(34)
	#BLOB_DELIMITER  = "|"
	
	;- =======================================================================
	;- URL/JSON Conversion Helpers
	;- =======================================================================
	
	Procedure.s ConvertMapToURLParams(Map Params.s())
		Protected result.s = ""
		Protected isFirst.b = #True
		
		ForEach Params()
			If Not isFirst
				result + "&"
			EndIf
			isFirst = #False
			result + MapKey(Params()) + "=" + Params()
		Next
		
		ProcedureReturn result
	EndProcedure
	
	Procedure.s EscapeJSONString(Text.s)
		Protected result.s = Text
		
		; Backslash must be escaped first!
		result = ReplaceString(result, "\", "\\")
		; Escape double quotes
		result = ReplaceString(result, #CHAR_DQUOTE, "\" + #CHAR_DQUOTE)
		; Escape control characters
		result = ReplaceString(result, #CR$, "\r")
		result = ReplaceString(result, #LF$, "\n")
		result = ReplaceString(result, #TAB$, "\t")
		
		ProcedureReturn result
	EndProcedure
	
	Procedure.s ConvertMapToJSON(Map Params.s())
		Protected json.s = "{ "
		
		ForEach Params()
			Protected key.s = MapKey(Params())
			Protected value.s = Params()
			Protected isRawJSON.b = Bool(Left(value, 1) = "[" Or Left(value, 1) = "{")
			
			json + #CHAR_DQUOTE + key + #CHAR_DQUOTE + ": "
			
			If isRawJSON
				json + value
			Else
				json + #CHAR_DQUOTE + EscapeJSONString(value) + #CHAR_DQUOTE
			EndIf
			
			json + ", "
		Next
		
		json = Left(json, Len(json) - 2) + " }"
		ProcedureReturn json
	EndProcedure
	
	;- =======================================================================
	;- MIME Type Resolution
	;- =======================================================================
	
	Procedure.s GetMimeTypeForExtension(Extension.s)
		Protected ext.s = LCase(RemoveString(Extension, "."))
		
		Select ext
				; Text formats
			Case "txt"  : ProcedureReturn "text/plain"
			Case "json" : ProcedureReturn "application/json"
			Case "xml"  : ProcedureReturn "application/xml"
				
				; Audio formats
			Case "mp3", "ogg", "m4a"
				ProcedureReturn "audio/" + ext
				
				; Image formats
			Case "jpg", "jpeg"
				ProcedureReturn "image/jpeg"
			Case "png", "gif", "webp"
				ProcedureReturn "image/" + ext
			Case "svg"
				ProcedureReturn "image/svg+xml"
				
				; Video formats
			Case "mp4", "webm"
				ProcedureReturn "video/" + ext
		EndSelect
	EndProcedure
	
	;- =======================================================================
	;- HTTP Request Handler
	;- =======================================================================
	
	Procedure.i ExecuteAPIRequest(*Session.Session, RequestType.i, Endpoint.s, Map Args.s(), PostBody.s = "", ContentType.s = "")
		
		Protected url.s = *Session\ApiUri + Endpoint
		Protected NewMap headers.s()
		
		; Configure request based on type
		If RequestType = #PB_HTTP_Get And MapSize(Args())
			url + "?" + ConvertMapToURLParams(Args())
		ElseIf RequestType = #PB_HTTP_Post And ContentType = ""
			ContentType = "application/json"
		EndIf
		
		; Set authorization header if authenticated
		If *Session\AccessToken
			headers("Authorization") = "Bearer " + *Session\AccessToken
		EndIf
		
		; Handle POST body conversion
		If ContentType
			headers("Content-Type") = ContentType
			
			If ContentType = "application/json" And MapSize(Args())
				PostBody = ConvertMapToJSON(Args())
				ClearMap(Args())
			EndIf
		EndIf
		
		; Execute request
		Protected request.i = HTTPRequest(RequestType, url, PostBody, 0, headers())
		
		If request = 0
			ProcedureReturn 0
		EndIf
				
		; Parse JSON response
		Protected *responseData = HTTPMemory(request)
		Protected jsonResult.i = CatchJSON(#PB_Any, *responseData, MemorySize(*responseData))
		
		FinishHTTP(request)
		FreeMemory(*responseData)
		
		ProcedureReturn jsonResult
	EndProcedure
	
	;- =======================================================================
	;- Session Management (Internal)
	;- =======================================================================
	
	Procedure.i RequestNewSession(*Session.Session, Handle.s, Password.s)
		Protected NewMap args.s()
		args("identifier") = Handle
		args("password") = Password
		
		ProcedureReturn ExecuteAPIRequest(*Session, #PB_HTTP_Post, "com.atproto.server.createSession", args())
	EndProcedure
	
	Procedure.i RequestSessionRefresh(*Session.Session, RefreshToken.s)
		Protected originalToken.s = *Session\AccessToken
		*Session\AccessToken = RefreshToken
		
		Protected NewMap args.s()
		Protected json.i = ExecuteAPIRequest(*Session, #PB_HTTP_Post, "com.atproto.server.refreshSession", args())
		
		If json = 0
			*Session\AccessToken = originalToken
		EndIf
		
		ProcedureReturn json
	EndProcedure
	
	Procedure.b ParseSessionFromJSON(*Session.Session, JSON.i)
		If JSON = 0
			ProcedureReturn #False
		EndIf
		
		Protected rootValue = JSONValue(JSON)
		
		If ExamineJSONMembers(rootValue)
			While NextJSONMember(rootValue)
				Protected key.s = JSONMemberKey(rootValue)
				Protected value = JSONMemberValue(rootValue)
				
				Select key
					Case "did"
						*Session\DID = GetJSONString(value)
					Case "accessJwt"
						*Session\AccessToken = GetJSONString(value)
					Case "refreshJwt"
						*Session\RefreshToken = GetJSONString(value)
				EndSelect
			Wend
		EndIf
		
		FreeJSON(JSON)
		ProcedureReturn Bool(*Session\AccessToken <> "")
	EndProcedure
	
	;- =======================================================================
	;- Hyperlink Processing
	;- =======================================================================
	
	; Converts single-quoted href attributes to double-quoted for consistency
	Procedure.s NormalizeHrefQuotes(HTML.s)
		Static regexHandle.i = 0
		
		If regexHandle = 0
			regexHandle = CreateRegularExpression(#PB_Any, "href='(.+?)'")
		EndIf
		
		If ExamineRegularExpression(regexHandle, HTML)
			While NextRegularExpressionMatch(regexHandle)
				Protected pos.i = RegularExpressionMatchPosition(regexHandle)
				Protected len.i = RegularExpressionMatchLength(regexHandle)
				Protected tag.s = Mid(HTML, pos, len)
				
				tag = ReplaceString(tag, "'", #CHAR_DQUOTE)
				HTML = Left(HTML, pos - 1) + tag + Mid(HTML, pos + len)
			Wend
		EndIf
		
		ProcedureReturn HTML
	EndProcedure
	
	; Finds the end position of a URL starting at the given position
	Procedure.i FindURLEndPosition(Text.s, StartPos.i)
		Protected Dim terminators.i(5)
		
		terminators(0) = FindString(Text, ",", StartPos + 1)
		terminators(1) = FindString(Text, #CR$, StartPos + 1)
		terminators(2) = FindString(Text, " ", StartPos + 1)
		terminators(3) = FindString(Text, #CHAR_DQUOTE, StartPos + 1)
		terminators(4) = FindString(Text, "'", StartPos + 1)
		terminators(5) = Len(Text) + 1
		
		SortArray(terminators(), #PB_Sort_Ascending)
		
		Protected i.i
		For i = 0 To ArraySize(terminators())
			If terminators(i) > 0
				ProcedureReturn terminators(i)
			EndIf
		Next
		
		ProcedureReturn -1
	EndProcedure
	
	; Wraps plain URLs in anchor tags
	Procedure.s ConvertPlainURLsToAnchors(Text.s)
		Protected html.s = NormalizeHrefQuotes(Text)
		Protected searchStart.i = 0
		
		Repeat
			Protected urlPos.i = FindString(html, "http", searchStart)
			
			If urlPos = 0
				Break
			EndIf
			
			; Skip URLs already inside anchor tags
			If Mid(html, urlPos - 9, 8) = "<a href="
				searchStart = FindString(html, "</a>", urlPos) + 4
				Continue
			EndIf
			
			Protected urlEnd.i = FindURLEndPosition(html, urlPos)
			If urlEnd = -1
				Break
			EndIf
			
			Protected url.s = Mid(html, urlPos, urlEnd - urlPos)
			Protected anchorTag.s = ~"<a href=\"" + url + ~"\">" + url + "</a>"
			Protected newHTML.s = Left(html, urlPos - 1) + anchorTag
			
			searchStart = Len(newHTML) + 1
			newHTML + Mid(html, urlEnd)
			html = newHTML
		ForEver
		
		ProcedureReturn html
	EndProcedure
	
	; Extracts hyperlinks from HTML and converts them to Bluesky facets
	Procedure.s ExtractFacetsFromHTML(HTML.s, List Facets.Facet())
		Protected processedHTML.s = ConvertPlainURLsToAnchors(HTML)
		Protected plainText.s = ""
		Protected anchorCount.i = CountString(processedHTML, "<a ")
		
		Protected i.i
		For i = 1 To anchorCount + 1
			Protected segment.s = StringField(processedHTML, i, "<a ")
			
			; Handle text before/after anchors
			If FindString(segment, "</a>") = 0
				plainText + segment
				Continue
			EndIf
			
			; Extract URL and display text from anchor
			Protected url.s = StringField(segment, 2, #CHAR_DQUOTE)
			Protected displayText.s = StringField(segment, 2, ">")
			displayText = StringField(displayText, 1, "<")
			Protected remainder.s = StringField(segment, 2, "</a>")
			
			; Create facet for this link
			AddElement(Facets())
			Facets()\Index\ByteStart = StringByteLength(plainText, #PB_UTF8)
			Facets()\Index\ByteEnd = StringByteLength(plainText, #PB_UTF8) + StringByteLength(displayText, #PB_UTF8)
			Facets()\Features(0)\Type = "app.bsky.richtext.facet#link"
			Facets()\Features(0)\Uri = url
			
			plainText + displayText + remainder
		Next
		
		ProcedureReturn plainText
	EndProcedure
	
	;- =======================================================================
	;- Blob Reference Helpers
	;- =======================================================================
	
	Procedure.s EnsureTrailingDelimiter(Text.s)
		If Right(Text, Len(#BLOB_DELIMITER)) <> #BLOB_DELIMITER
			ProcedureReturn Text + #BLOB_DELIMITER
		EndIf
		ProcedureReturn Text
	EndProcedure
	
	; Extracts the blob object JSON from an upload response
	Procedure.s ExtractBlobRefFromResponse(Response.s)
		Protected blobStart.i = FindString(Response, ~"\"blob\"")
		
		If blobStart = 0
			ProcedureReturn ""
		EndIf
		
		; Find opening brace after "blob":
		blobStart = FindString(Response, "{", blobStart + 6)
		If blobStart = 0
			ProcedureReturn ""
		EndIf
		
		; Match braces to find complete blob object
		Protected braceDepth.i = 1
		Protected pos.i = blobStart + 1
		
		While braceDepth > 0 And pos <= Len(Response)
			Protected char.s = Mid(Response, pos, 1)
			
			If char = "{"
				braceDepth + 1
			ElseIf char = "}"
				braceDepth - 1
			EndIf
			
			pos + 1
		Wend
		
		ProcedureReturn Mid(Response, blobStart, pos - blobStart)
	EndProcedure
	
	;- =======================================================================
	;- JSON Building Helpers
	;- =======================================================================
	
	Procedure.s BuildFacetsJSON(List Facets.Facet())
		If ListSize(Facets()) = 0
			ProcedureReturn ""
		EndIf
		
		Protected json.s = "["
		Protected isFirst.b = #True
		
		; The issue is most likely the chair to keyboard interface, but I couldn't get InsertJSONList to work with, so let's build a JSON manually!
		ForEach Facets()
			If Not isFirst
				json + ", "
			EndIf
			isFirst = #False
			
			json + ~"{\"index\": {\"byteStart\": " + Str(Facets()\Index\ByteStart) + ", "
			json + ~"\"byteEnd\": " + Str(Facets()\Index\ByteEnd) + "}, "
			json + ~"\"features\": [{\"$type\": \"" + Facets()\Features(0)\Type + ~"\", "
			json + ~"\"uri\": \"" + Facets()\Features(0)\Uri + ~"\"}]}"
		Next
		
		json + "]"
		ProcedureReturn json
	EndProcedure
	
	Procedure.s BuildImageEmbedJSON(ImageBlobRefs.s)
		Protected refs.s = EnsureTrailingDelimiter(ImageBlobRefs)
		Protected blobCount.i = CountString(refs, #BLOB_DELIMITER)
		Protected imagesArray.s = ""
		
		Protected i.i
		For i = 1 To blobCount
			Protected blobRef.s = StringField(refs, i, #BLOB_DELIMITER)
			
			imagesArray + ~"{ \"alt\":\"\", \"image\":" + blobRef + "}"
			
			If i < blobCount
				imagesArray + ", "
			EndIf
		Next
		
		Protected embedJSON.s = ~"{ \"$type\": \"app.bsky.embed.images\", \"images\": [" + imagesArray + "] }"
		
		ProcedureReturn embedJSON
	EndProcedure
	
	;- =======================================================================
	;- Public Procedures: Session Management
	;- =======================================================================
	
	Procedure.b Initialize(*Session.Session, Handle.s, Password.s, ApiUri.s = #DEFAULT_API_URI)
		*Session\ApiUri = ApiUri
		
		Protected json.i = RequestNewSession(*Session, Handle, Password)
		ProcedureReturn ParseSessionFromJSON(*Session, json)
	EndProcedure
	
	Procedure.b RefreshSession(*Session.Session)
		If *Session\RefreshToken = ""
			ProcedureReturn #False
		EndIf
		
		Protected json.i = RequestSessionRefresh(*Session, *Session\RefreshToken)
		ProcedureReturn ParseSessionFromJSON(*Session, json)
	EndProcedure
	
	;- =======================================================================
	;- Public Procedures: Media Upload
	;- =======================================================================
	
	Procedure.s UploadBlob(*Session.Session, *Memory, MemorySize.i, MimeType.s)
		Protected url.s = *Session\ApiUri + "com.atproto.repo.uploadBlob"
		Protected NewMap headers.s()
		
		headers("Content-Type") = MimeType
		headers("Content-Length") = Str(MemorySize)
		
		If *Session\AccessToken
			headers("Authorization") = "Bearer " + *Session\AccessToken
		EndIf
		
		Protected request.i = HTTPRequestMemory(#PB_HTTP_Post, url, *Memory, MemorySize, 0, headers())
		
		If request = 0
			ProcedureReturn ""
		EndIf
		
		Protected response.s = HTTPInfo(request, #PB_HTTP_Response)
		FinishHTTP(request)
		
		ProcedureReturn ExtractBlobRefFromResponse(response)
	EndProcedure
	
	Procedure.s UploadImageFile(*Session.Session, FilePath.s)
		; Validate file exists
		If FilePath = "" Or FileSize(FilePath) = -1
			ProcedureReturn ""
		EndIf
		
		; Open file
		Protected file.i = ReadFile(#PB_Any, FilePath)
		If file = 0
			ProcedureReturn ""
		EndIf
		
		; Check size limit
		Protected size.i = Lof(file)
		If size > #IMAGE_SIZE_LIMIT
			CloseFile(file)
			ProcedureReturn ""
		EndIf
		
		; Read file into memory
		Protected *memory = AllocateMemory(size)
		ReadData(file, *memory, size)
		CloseFile(file)
		
		; Upload and cleanup
		Protected mimeType.s = GetMimeTypeForExtension(GetExtensionPart(FilePath))
		Protected blobRef.s = UploadBlob(*Session, *memory, size, mimeType)
		
		FreeMemory(*memory)
		ProcedureReturn blobRef
	EndProcedure
	
		Procedure.s UploadImageAsJPEG(*Session.Session, Image.i, Quality.i = 7)
		If IsImage(Image) = 0
			ProcedureReturn ""
		EndIf
		
		Protected *memory = EncodeImage(Image, #PB_ImagePlugin_JPEG, Quality)
		If *memory = 0
			ProcedureReturn ""
		EndIf
		
		Protected size.i = MemorySize(*memory)
		If size > #IMAGE_SIZE_LIMIT
			FreeMemory(*memory)
			ProcedureReturn ""
		EndIf
		
		Protected blobRef.s = UploadBlob(*Session, *memory, size, "image/jpeg")
		FreeMemory(*memory)
		
		ProcedureReturn blobRef
	EndProcedure
	
	Procedure.s UploadImageAsPNG(*Session.Session, Image.i)
		If IsImage(Image) = 0
			ProcedureReturn ""
		EndIf
		
		Protected *memory = EncodeImage(Image, #PB_ImagePlugin_PNG, 0, 24)
		If *memory = 0
			ProcedureReturn ""
		EndIf
		
		Protected size.i = MemorySize(*memory)
		If size > #IMAGE_SIZE_LIMIT
			FreeMemory(*memory)
			ProcedureReturn ""
		EndIf
		
		Protected blobRef.s = UploadBlob(*Session, *memory, size, "image/png")
		FreeMemory(*memory)
		
		ProcedureReturn blobRef
	EndProcedure
	
	;- =======================================================================
	;- Public Procedures: Posting
	;- =======================================================================
	
	Procedure.s CreatePost(*Session.Session, Text.s, Language.s = "en", ImageBlobRefs.s = "")
		; Parse text for hyperlinks and create facets
		Protected NewList facets.Facet()
		Protected plainText.s = ExtractFacetsFromHTML(Text, facets())
		
		; Build record object
		Protected NewMap recordArgs.s()
		recordArgs("text") = plainText
		recordArgs("langs") = ~"[\"" + Language + ~"\"]"
		recordArgs("createdAt") = FormatDate("%yyyy-%mm-%ddT%hh:%ii:%ss.000000Z", Date())
		recordArgs("$type") = "app.bsky.feed.post"
		
		; Add facets if present
		Protected facetsJSON.s = BuildFacetsJSON(facets())
		If facetsJSON <> ""
			recordArgs("facets") = facetsJSON
		EndIf
		
		; Add image embeds if provided
		If ImageBlobRefs <> ""
			recordArgs("embed") = BuildImageEmbedJSON(ImageBlobRefs)
		EndIf
		
		; Build request arguments
		Protected NewMap args.s()
		args("collection") = "app.bsky.feed.post"
		args("repo") = *Session\DID
		args("record") = ConvertMapToJSON(recordArgs())
		
		; Execute request
		Protected json.i = ExecuteAPIRequest(*Session, #PB_HTTP_Post, 
		                                     "com.atproto.repo.createRecord", args())
		
		; Extract post ID from response
		Protected postId.s = ""
		
		If json
			Protected rootValue = JSONValue(json)
			
			If ExamineJSONMembers(rootValue)
				While NextJSONMember(rootValue)
					If JSONMemberKey(rootValue) = "uri"
						Protected uri.s = GetJSONString(JSONMemberValue(rootValue))
						postId = StringField(uri, 5, "/")
						Break
					EndIf
				Wend
			EndIf
			
			FreeJSON(json)
		EndIf
		
		ProcedureReturn postId
	EndProcedure
	
	;- =======================================================================
	;- Public Procedures: Utility
	;- =======================================================================
	
	Procedure.s GetPostURL(Handle.s, PostId.s)
		ProcedureReturn "https://bsky.app/profile/" + Handle + "/post/" + PostId
	EndProcedure
	
EndModule

CompilerIf #PB_Compiler_IsMainFile
	
	#MyHandle = "handle.bsky.social"
	#MyAppPassword = "xxxx-xxxx-xxxx-xxxx"
	
	Define Session.BlueskyAPI::Session
	
	If BlueskyAPI::Initialize(@Session, #MyHandle, #MyAppPassword)
		Debug "Successfully authenticated as: " + Session\Did
		Define PostId.s
		
		; --------------------------------------------------------------------
		; Example 1: Create a simple text post
		; --------------------------------------------------------------------
		PostId.s = BlueskyAPI::CreatePost(@Session, "Hello from PureBasic!")
		If PostId
			Debug "Posted: " + BlueskyAPI::GetPostURL(#MyHandle, PostId)
		EndIf
		
		Delay(1000)
		
		; --------------------------------------------------------------------
		; Example 2: Create a post with hyperlinks
		; (URLs are automatically converted to rich text facets)
		; --------------------------------------------------------------------
		PostId = BlueskyAPI::CreatePost(@Session, "Check out https://lastlife.net/")
		If PostId
			Debug "Posted with link: " + BlueskyAPI::GetPostURL(#MyHandle, PostId)
		EndIf
		
		Delay(1000)
		
		; --------------------------------------------------------------------
		; Example 3: Create and upload images, then post with images, and show
		;			 the post in your browser
		; --------------------------------------------------------------------
		;Create two test images
		Define Dim BlobRefs.s(2)
		Define i.i
		
		For i = 1 To 2
			Define ImageWidth.i = 600
			Define ImageHeight.i = 400
			Define Img.i = CreateImage(#PB_Any, ImageWidth, ImageHeight, 32)
			
			If Img
				StartDrawing(ImageOutput(Img))
				DrawingMode(#PB_2DDrawing_AlphaBlend)
				
				;Draw random colored lines
				Define Color.i = RGBA(Random(255), Random(255), Random(255), 255)
				Define j.i
				For j = 1 To 10
					LineXY(Random(ImageWidth), Random(ImageHeight), Random(ImageWidth), Random(ImageHeight), Color)
				Next
				
				StopDrawing()
				
				;Upload As JPEG
				BlobRefs(i) = BlueskyAPI::UploadImageAsJPEG(@Session, Img, 7)
				FreeImage(Img)
				
				If BlobRefs(i)
					Debug "Image " + Str(i) + " uploaded successfully"
				Else
					Debug "Failed to upload image " + Str(i)
				EndIf
			EndIf
		Next
		
		; Create post with both images
		If BlobRefs(1) And BlobRefs(2)
			Define ImageRefs.s = BlobRefs(1) + "|" + BlobRefs(2)
			PostId = BlueskyAPI::CreatePost(@Session, "Two images created in PureBasic:", "en", ImageRefs)
			
			If PostId
				Define PostURL.s = BlueskyAPI::GetPostURL(#MyHandle, PostId)
				Debug "Posted with images: " + PostURL
				
				; Open the post in the default browser
				RunProgram(PostURL)
			EndIf
		EndIf
		
		Delay(1000)
		
		; --------------------------------------------------------------------
		; Example 4: Refresh the session token
		; --------------------------------------------------------------------
		If BlueskyAPI::RefreshSession(@Session)
			Debug "Session refreshed successfully"
		Else
			Debug "Failed to refresh session"
		EndIf
		
	Else
		Debug "Authentication failed!"
	EndIf
CompilerEndIf
My blog : https://lastlife.net/
My open source PB stuff : Inputify, UITK, SelfHost.
Post Reply