Skip to content

Commit

Permalink
fix(announce): Fixes links, mentions and hashtags for Bluesky. Fixes #…
Browse files Browse the repository at this point in the history
  • Loading branch information
TomCools authored and aalmiray committed May 1, 2024
1 parent 1c8d2ae commit 0a2d0d7
Show file tree
Hide file tree
Showing 14 changed files with 560 additions and 39 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@
*/
package org.jreleaser.sdk.bluesky;

import org.commonmark.parser.Parser;
import org.commonmark.renderer.text.TextContentRenderer;
import org.jreleaser.bundle.RB;
import org.jreleaser.model.internal.JReleaserContext;
import org.jreleaser.model.spi.announce.AnnounceException;
Expand All @@ -34,8 +32,6 @@
import static org.jreleaser.model.Constants.KEY_TAG_NAME;
import static org.jreleaser.mustache.MustacheUtils.applyTemplates;
import static org.jreleaser.mustache.Templates.resolveTemplate;
import static org.jreleaser.util.MarkdownUtils.createMarkdownParser;
import static org.jreleaser.util.MarkdownUtils.createTextContentRenderer;
import static org.jreleaser.util.StringUtils.isNotBlank;

/**
Expand Down Expand Up @@ -91,12 +87,8 @@ public void announce() throws AnnounceException {
statuses.add(bluesky.getStatus());
}

Parser markdownParser = createMarkdownParser();
TextContentRenderer markdownRenderer = createTextContentRenderer();

for (int i = 0; i < statuses.size(); i++) {
String status = getResolvedMessage(context, statuses.get(i));
status = markdownRenderer.render(markdownParser.parse(status));
context.getLogger().info(RB.$("bluesky.skeet"), status);
context.getLogger().debug(RB.$("bluesky.skeet.size"), status.length());
statuses.set(i, status);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* Copyright 2020-2024 The JReleaser authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jreleaser.sdk.bluesky;

import org.jreleaser.sdk.bluesky.api.BlueskyAPI;
import org.jreleaser.sdk.bluesky.api.CreateRecordResponse;
import org.jreleaser.sdk.bluesky.api.CreateTextRecordRequest;
import org.jreleaser.sdk.bluesky.api.Facet;
import org.jreleaser.sdk.bluesky.api.Index;
import org.jreleaser.sdk.bluesky.api.ResolveHandleResponse;
import org.jreleaser.sdk.bluesky.api.features.Feature;
import org.jreleaser.sdk.bluesky.api.features.LinkFeature;
import org.jreleaser.sdk.bluesky.api.features.MentionFeature;
import org.jreleaser.sdk.bluesky.api.features.TagFeature;
import org.jreleaser.sdk.commons.RestAPIException;

import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import static org.jreleaser.util.StringUtils.requireNonBlank;

/**
* @author Tom Cools
* @since 1.12.0
*/
class BlueskyRecordFactory {

private static final Pattern URL_PATTERN = Pattern.compile(
"(?:^|[\\W])((ht|f)tp(s?):\\/\\/|www\\.)"
+ "(([\\w\\-]+\\.){1,}?([\\w\\-.~]+\\/?)*"
+ "[\\p{Alnum}.,%_=?&#\\-+()\\[\\]\\*$~@!:/{};']*)",
Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL);
private static final Pattern TAG_PATTERN = Pattern.compile("(#[a-zA-z]{2,})");
private static final Pattern MENTION_PATTERN = Pattern.compile("(^|\\s|\\()(@)([a-zA-Z0-9.-]+)(\\b)");

private BlueskyAPI api;

BlueskyRecordFactory(BlueskyAPI api) {
this.api = api;
}

public CreateTextRecordRequest textRecord(String repo, String text) {
CreateTextRecordRequest request = new CreateTextRecordRequest();
request.setRepo(requireNonBlank(repo, "'repo' must not be blank").trim());

CreateTextRecordRequest.TextRecord textRecord = new CreateTextRecordRequest.TextRecord();
textRecord.setText(requireNonBlank(text, "'text' must not be blank").trim());
textRecord.setCreatedAt(Instant.now().toString());
request.setRecord(textRecord);

textRecord.setFacets(determineFacets(text));

return request;
}

public CreateTextRecordRequest textRecord(String repo, String text, CreateRecordResponse root, CreateRecordResponse parent) {
CreateTextRecordRequest request = textRecord(repo, text);

CreateTextRecordRequest.ReplyReference reply = new CreateTextRecordRequest.ReplyReference();
reply.setRoot(root);
reply.setParent(parent);

request.getRecord().setReply(reply);

return request;
}

/*
* Retrieves facets from the given text.
*
* Source of information: https://docs.bsky.app/docs/advanced-guides/post-richtext
*/
public List<Facet> determineFacets(String text) {
List<Facet> facets = new ArrayList<>();

facets.addAll(parseFeatures(text, URL_PATTERN, link -> {
LinkFeature linkFeature = new LinkFeature();
linkFeature.setUri(link);
return Optional.of(linkFeature);
}));

facets.addAll(parseFeatures(text, TAG_PATTERN, tag -> {
TagFeature tagFeature = new TagFeature();
tagFeature.setTag(tag.replace("#", "").trim());
return Optional.of(tagFeature);
}));

facets.addAll(parseFeatures(text, MENTION_PATTERN, mention -> {
String handle = mention.replace("@", "").trim();
MentionFeature mentionFeature = new MentionFeature();
try {
// MentionFeature requires the DID, this should be resolved against the API.
ResolveHandleResponse handleResponse = api.resolveHandle(handle);
mentionFeature.setDid(handleResponse.getDid());
} catch (RestAPIException e) {
// Given the DID could not be resolved, for whatever reason, just post without the mention.
return Optional.empty();
}
return Optional.of(mentionFeature);
}));
return facets;
}

private static List<Facet> parseFeatures(String text, Pattern pattern, Function<String, Optional<Feature>> matchedContentToFeature) {
BlueskyStringEncodingWrapper wrapper = new BlueskyStringEncodingWrapper(text);
Matcher matcher = pattern.matcher(wrapper);
List<Facet> urlFacets = new ArrayList<>();
while (matcher.find()) {
int matchStart = matcher.start(1);
int matchEnd = matcher.end();
Facet facet = new Facet();
Index index = new Index();
index.setByteStart(matchStart);
index.setByteEnd(matchEnd);
facet.setIndex(index);
String matchedSubstring = wrapper.subSequence(matchStart, matchEnd).toString();

matchedContentToFeature.apply(matchedSubstring).ifPresent(feature -> {
facet.setFeatures(Collections.singletonList(feature));
urlFacets.add(facet);
});
}
return urlFacets;
}

/**
* Helper class responsible for encoding a String to a bytebuffer of the correct encoding for Bluesky.
* Bluesky does everything internally as UTF-8, but Java by default uses Strings in UTF-16.
* Without this class, indexes calculated with Pattern/Matcher use UTF-16, which leads to incorrect indexes.
*
* See: https://docs.bsky.app/docs/advanced-guides/post-richtext#text-encoding-and-indexing
*/
public static class BlueskyStringEncodingWrapper implements CharSequence {

private final ByteBuffer buffer;
private final Charset charset;

public BlueskyStringEncodingWrapper(String sourceString) {
this.charset = StandardCharsets.UTF_8;
this.buffer = ByteBuffer.wrap(sourceString.getBytes(charset));
}

private BlueskyStringEncodingWrapper(ByteBuffer sourceBuffer) {
this.charset = StandardCharsets.UTF_8;
this.buffer = sourceBuffer;
}

@Override
public CharSequence subSequence(int start, int end) {
ByteBuffer buffer = this.buffer.duplicate();
buffer.position(buffer.position() + start);
buffer.limit(buffer.position() + (end - start));
return new BlueskyStringEncodingWrapper(buffer);
}

@Override
public int length() {
return buffer.limit();
}

@Override
public char charAt(int index) {
return (char) buffer.get(index);
}

@Override
public String toString() {
return charset.decode(buffer.duplicate()).toString();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public class BlueskySdk {
private final boolean dryrun;
private final String handle;
private final String password;
private final BlueskyRecordFactory factory;

private BlueskySdk(JReleaserContext context,
boolean dryrun,
Expand All @@ -76,6 +77,8 @@ private BlueskySdk(JReleaserContext context,
.decoder(new JacksonDecoder(objectMapper))
.target(BlueskyAPI.class, host);

factory = new BlueskyRecordFactory(api);

context.getLogger().debug(RB.$("workflow.dryrun"), dryrun);
}

Expand All @@ -85,13 +88,13 @@ public void skeet(List<String> statuses) throws BlueskyException {
String identifier = session.getDid();

// To skeet a thread, the first and previous statuses must be added to a new status.
CreateTextRecordRequest firstStatusRequest = CreateTextRecordRequest.of(identifier, statuses.get(0));
CreateTextRecordRequest firstStatusRequest = factory.textRecord(identifier, statuses.get(0));
CreateRecordResponse firstStatus = api.createRecord(firstStatusRequest, session.getAccessJwt());
CreateRecordResponse previousStatus = firstStatus;

for (int i = 1; i < statuses.size(); i++) {
String status = statuses.get(i);
CreateTextRecordRequest nextStatusRequest = CreateTextRecordRequest.of(identifier, status, firstStatus, previousStatus);
CreateTextRecordRequest nextStatusRequest = factory.textRecord(identifier, status, firstStatus, previousStatus);
previousStatus = api.createRecord(nextStatusRequest, session.getAccessJwt());
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,8 @@ public interface BlueskyAPI {
@RequestLine("POST /xrpc/com.atproto.repo.createRecord")
@Headers({"Content-Type: application/json", "Authorization: Bearer {accessToken}"})
CreateRecordResponse createRecord(CreateTextRecordRequest request, @Param("accessToken") String accessToken);

@RequestLine("GET /xrpc/com.atproto.identity.resolveHandle?handle={handle}")
@Headers("Accept: application/json")
ResolveHandleResponse resolveHandle(@Param("handle") String handle);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;

import java.time.Instant;
import java.util.List;

import static org.jreleaser.util.StringUtils.requireNonBlank;

/**
* @author Simon Verhoeven
Expand All @@ -31,36 +30,10 @@
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class CreateTextRecordRequest {
private static final String BLUESKY_POST_COLLECTION = "app.bsky.feed.post";

public static CreateTextRecordRequest of(String repo, String text) {
CreateTextRecordRequest request = new CreateTextRecordRequest();
request.repo = requireNonBlank(repo, "'repo' must not be blank").trim();
request.collection = BLUESKY_POST_COLLECTION;

TextRecord textRecord = new TextRecord();
textRecord.text = requireNonBlank(text, "'text' must not be blank").trim();
textRecord.createdAt = Instant.now().toString();
request.record = textRecord;

return request;
}

public static CreateTextRecordRequest of(String repo, String text, CreateRecordResponse root, CreateRecordResponse parent) {
CreateTextRecordRequest request = CreateTextRecordRequest.of(repo, text);

ReplyReference reply = new ReplyReference();
reply.root = root;
reply.parent = parent;

request.record.setReply(reply);

return request;
}

private String repo;

private String collection;
private String collection = "app.bsky.feed.post";

/**
* The record to create.
Expand Down Expand Up @@ -102,6 +75,8 @@ public static class TextRecord {
@JsonProperty("$type")
private String type;

private List<Facet> facets;

public String getText() {
return text;
}
Expand Down Expand Up @@ -133,6 +108,14 @@ public String getType() {
public void setType(String type) {
this.type = type;
}

public List<Facet> getFacets() {
return facets;
}

public void setFacets(List<Facet> facets) {
this.facets = facets;
}
}

@JsonIgnoreProperties(ignoreUnknown = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* SPDX-License-Identifier: Apache-2.0
*
* Copyright 2020-2024 The JReleaser authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.jreleaser.sdk.bluesky.api;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import org.jreleaser.sdk.bluesky.api.features.Feature;

import java.util.List;

/**
* @author Tom Cools
* @since 1.12.0
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class Facet {
private Index index;

private List<Feature> features;

public Index getIndex() {
return index;
}

public void setIndex(Index index) {
this.index = index;
}

public List<Feature> getFeatures() {
return features;
}

public void setFeatures(List<Feature> features) {
this.features = features;
}
}

0 comments on commit 0a2d0d7

Please sign in to comment.