Commands/Send-AtProto.ps1

function Send-AtProto
{
    <#
    .SYNOPSIS
        Sends to the At Protocol
    .DESCRIPTION
        Sends a BlueSky post using the At Protocol
    .EXAMPLE
        # Send a Hello World
        Send-AtProto "Hello World (from https://github.com/StartAutomating/PSA )"
    .EXAMPLE
        # Don't send Hello World, ask -WhatIf I did, and get back the object you would post.
        Send-AtProto "Hello World (from https://github.com/StartAutomating/PSA )" -WhatIf
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [Alias(
        'Send-AtProtocol', # Longform
        'Send-Bsky',       # Alternate lexicon
        'Send-BlueSky',    # Alternate longform
        'Send-Skeet',      # PowerShell Colloquial
        'Skeet'            # Colloqiual
    )]
    param(
    # The text of a post
    [Parameter(ValueFromPipelineByPropertyName)]
    [Alias('Post','Skeet','Title')]
    [string]
    $Text,

    # One or more images to attach to a post.
    [Parameter(ValueFromPipelineByPropertyName)]
    [string[]]
    $Image,

    # One or more alternate image texts, for accessibility.
    [Parameter(ValueFromPipelineByPropertyName)]
    [string[]]
    $AlternateImageText = @(),

    # A post that will be quoted by this post.
    [Parameter(ValueFromPipelineByPropertyName)]
    [PSObject]
    $QuotePost,

    # A post that this post will reply to.
    [Parameter(ValueFromPipelineByPropertyName)]
    [PSObject]
    $Reply,

    # A web card, containing rich links
    [Parameter(ValueFromPipelineByPropertyName)]
    [PSObject]
    $WebCard,

    # Patterns within a message to replace with links.
    [Parameter(ValueFromPipelineByPropertyName)]
    [PSObject]
    $LinkPattern
    )

    begin {
        if (-not $script:KnownAtActors) {
            $script:KnownAtActors = [Ordered]@{}
        }

        filter MatchToFacet {
            param($type)
            [Ordered]@{
                index = [ordered]@{
                    byteStart = $_.Index
                    byteEnd = $_.Index + $_.Length
                }
                features = @([Ordered]@{
                    '$type' = $type
                })
            }          
        }

        <#
        Thankful author's note:
        
        This function owes a lot to this [AtProtocol Blog Post](https://atproto.com/blog/create-post).
        
        #>


        
    }

    process {
        # First up, if there's nothing to say, we're done.
        if (-not $Text) { return }


        # Next, we want to prepare facets.

        # Facets are BlueSkys/ At Protocols way of including extra information within a message.
        $facets = @()

        # One facet is mentions
        foreach ($mention in [Regex]::Matches($text, "\@(?<Mention>[\p{L}\d\.]+)")) {
            # For each potential mention
            $actorMentioned = $mention.Groups["Mention"].Value
            if (-not $script:KnownAtActors[$actorMentioned]) {
                $actorMentioned = $mention.Groups["Mention"].Value
                $foundProfile =  # see if we can find a profile of an actor
                    if ($actorMentioned -notlike '*.*') { 
                        Get-BskyActorProfile -Actor "$actorMentioned.bsky.social"
                    } else {
                        Get-BskyActorProfile -Actor $actorMentioned
                    }
                
                if ($foundProfile) { # (cache it if we find one)
                    $script:KnownAtActors[$actorMentioned] = $foundProfile
                }
            }
            
            # Assuming we've resolved the mention
            if ($script:KnownAtActors[$actorMentioned]) {
                # we can simply turn the match into a facet
                $facet = $mention | MatchToFacet -type "app.bsky.richtext.facet#mention"
                # and set the did (a unique identifier in At)
                $facet.features[0].did = $script:KnownAtActors[$actorMentioned].did
                $facets += $facet
            }            
        }

        # Links also become a facet
        foreach ($urlLink in [regex]::Matches($text, "https?:\/\/(www\.)?[-a-zA-Z0-9@:%._/\+~#=]{1,256}")) {
            $facet = $urlLink | MatchToFacet -type "app.bsky.richtext.facet#link"
            $facet.features[0].uri = $urlLink.Value
            $facets += $facet
        }

        # If there was a link pattern provided, and it was dictionary,
        if ($LinkPattern -is [Collections.IDictionary]) {
            $LinkPattern = [PSCustomObject]$LinkPattern # make it a property bag.
        }

        # Walk thru each potential property to link
        foreach ($linkPatternInfo in $LinkPattern.psobject.properties) {
            foreach ($match in [Regex]::Matches($text, $linkPatternInfo.Name)) {
                # replacing any matches
                $facet = $match | MatchToFacet -type "app.bsky.richtext.facet#link"
                # with a facet linking to this text.
                $facet.features[0].uri = $linkPatternInfo.Value
                $facets += $facet
            }                    
        }

        # (quick note here, this is something that the protocol is capable of but the web client isn't yet)

        # Now we create the post object
        $postObject = [Ordered]@{
            '$type' = 'app.bsky.feed.post'
            text=$Text
            createdAt=[DateTime]::Now.ToString('o')
            langs = @(
                "$([Globalization.CultureInfo]::CurrentCulture.TwoLetterISOLanguageName)" -replace "^iv$", "en"
            )
        }        

        if ($facets) { # and add any facets we have.
            $postObject.facets = $facets
        }
        
        # Next up, handling replies:
        if ($reply) {            
            # The first part is easy, the parent of a reply is basically what was in -Reply
            $postObject.reply = [Ordered]@{}
            $postObject.reply.root = [Ordered]@{}
            $postobject.reply.parent = [Ordered]@{
                uri = $reply.Uri
                cid = $reply.cid
            }

            # Replies in AtProtocol have to have the root as well.
            # Since this is true, if we're replying to a reply, it already has a root
            if ($Reply.record.reply.root) {                
                $reRoot = $Reply.record.reply.root
                # and we can just use that to reply to the whole thread.
                $postObject.reply.root.uri = $reRoot.Uri
                $postObject.reply.root.cid = $reRoot.cid
            } else {
                # otherwise, we're the first reply, and this what we're replying to becomes the root as well.
                $postObject.reply.root.uri = $reply.Uri
                $postObject.reply.root.cid = $reply.cid
            }
        }

        # Next up are the three different things we can embed into a post.

        # First, images:
        if ($Image) {
            $messageImages = @()

            $index = -1

            # Multiple images can be attached
            foreach ($img in $image) {
                $index++
                                
                $imgUri = $img -as [uri]
                $imgBytes = [byte[]]$null
                # but we need to upload them, so, if we're linking to a web image, download it first.
                if ($imgUri.Scheme -in 'http', 'https') {
                    $webResponse = Invoke-WebRequest -Uri $imgUri
                    $imgBytes = $webResponse.Content -as [byte[]]
                }
                elseif ($img -is [byte[]]) {
                    $imgBytes =  $img
                }
                else {
                    foreach ($imgFile in $ExecutionContext.SessionState.Path.GetResolvedPSPathFromPSPath($img)) {
                        $imgBytes = [io.file]::ReadAllBytes("$imgFile")
                        break
                    }
                }

                if (-not $imgBytes.Length) { continue }
                # Get the potential alternate text for this image.
                $altText = $AlternateImageText[$index]

                # And add the image and it's uploaded blob to the list.
                $messageImages += [Ordered]@{
                    alt = "$altText"
                    image = Set-AtprotoRepoBlob -Data $imgBytes -ContentType "image/jpeg"
                }
            }

            # Then we embed each of the attached images into the post.
            $postObject.embed = [Ordered]@{
                '$type' = 'app.bsky.embed.images'
                images = $messageImages
            }
        } 
        elseif ($QuotePost) {
            # For quote posting, we're embedding a record to the post.
            $postObject.embed = [Ordered]@{
                '$type' = 'app.bsky.embed.record'
                record = [Ordered]@{
                    uri = $QuotePost.uri
                    cid = $QuotePost.cid
                }                
            }
        }
        elseif ($WebCard.Url -or $WebCard.Uri) {
            # For web cards, we want to try to provide a .title, .url., .description, and .image
            $postObject.embed = [Ordered]@{
                '$type' = 'app.bsky.embed.external'
                external = [Ordered]@{}
            }

            # A webcard could be as little as a uri/url
            $WebCardUri = 
                if ($WebCard.uri) { 
                    $WebCard.uri
                }
                else { $WebCard.Url }


            
            # If the webcard does not have a title, description, or image
            
            if (-not ($WebCard.title) -or (-not $WebCard.description) -or (-not $webcard.Image)) {
                # Get it's content
                $gotWebCardContent = Invoke-RestMethod -Uri $WebCardUri
                # and find all of the meta tags
                foreach ($metaMatch in  [Regex]::Matches($gotWebCardContent, "<meta.+?/>", 'IgnoreCase')) {
                    $metaXml = ($metaMatch.Value -as [xml])
                    # and pick out the opengraph title
                    if ($metaXml.meta.property -eq 'og:title' -and -not $WebCard.title) {
                        $WebCard.title = $metaXml.meta.content
                    }
                    # description
                    if ($metaXml.meta.property -eq 'og:description' -and -not $WebCard.description) {
                        $WebCard.description = $metaXml.meta.content
                    }
                    # and image.
                    if ($metaXml.meta.property -eq 'og:image' -and -not $WebCard.image) {
                        $WebCard.image = $metaXml.meta.content
                    }
                }
            }

            # Then embed it into the post
            $postObject.embed.external.uri = $WebCardUri
            $postObject.embed.external.title = "$($WebCard.title)"
            $postObject.embed.external.description = "$($WebCard.description)"

            if ($WebCard.Image) {
                $webCardImageUrl = $WebCard.Image                
                $webCardImageResponse = Invoke-WebRequest -Uri $webCardImageUrl 
                if ($webCardImageResponse -and $webCardImageResponse.Content -is [byte[]]) {
                    $postObject.embed.external.thumb =
                        Set-AtprotoRepoBlob -Data $webCardImageResponse.Content -ContentType "image/jpeg"                    
                }
            }
        }

        # PowerShell Bonus points: Passing -WhatIf
        if ($WhatIfPreference) {
            [PSCustomObject]$postObject # will output what we would post, without posting.
        } elseif (
            # Also, -Confirm will check that you want to post something first.
            $PSCmdlet.ShouldProcess("Post $($postObject.Text)")
        ) { 
            # Get the most recent session
            $atSession = $script:AtServerSessions[-1]

            # and, if we have a did for the current user
            if ($atSession.did) {
                # create a post under their account.
                Add-AtProtoRepoRecord -Record ([PSCustomObject]$postObject) -Repo $atSession.did -Collection 'app.bsky.feed.post'
            }            
        }
    }
}