; ============================================================================
; 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)
; 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) = "", 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 = ~"" + url + ""
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, "") = 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, "")
; 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
;- =======================================================================
;- 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
; IDE Options = PureBasic 6.30 beta 5 (Linux - x64)
; CursorPosition = 64
; FirstLine = 21
; Folding = DAAA+
; EnableXP
; DPIAware