AnnounceSupport.java

package network.ike.plugin;

import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;

/**
 * Posting an announcement to a Zulip stream, split so that everything
 * except the network call is pure and testable
 * (IKE-Network/ike-issues#1016).
 *
 * <p>Exists because the release and checkpoint chains each carried their
 * own copy of this as shell inside a CI build step โ€” unreproducible off
 * the server, and prone to a class of bug a goal cannot have (an
 * unresolved {@code %parameter%} reference silently makes a TeamCity
 * configuration incompatible with every agent).
 */
public final class AnnounceSupport {

    private AnnounceSupport() {}

    /**
     * A resolved announcement: where it goes and what it says.
     *
     * @param site    the Zulip site base URL, without a trailing slash
     * @param stream  the destination stream
     * @param topic   the destination topic within the stream
     * @param content the message body, Zulip-flavoured markdown
     */
    public record Announcement(String site, String stream, String topic,
                               String content) {}

    /**
     * Credentials for the posting bot.
     *
     * @param email the bot's email address
     * @param token the bot's API token
     */
    public record BotIdentity(String email, String token) {}

    /**
     * Validate an announcement, naming precisely what is missing rather
     * than letting the API answer with a generic rejection.
     *
     * @param a the announcement to check
     * @return the first problem found, or {@code null} when valid
     */
    public static String validate(Announcement a) {
        if (a == null) return "no announcement";
        if (isBlank(a.site())) return "zulip site URL is not set";
        if (isBlank(a.stream())) return "stream is not set";
        if (isBlank(a.topic())) return "topic is not set";
        if (isBlank(a.content())) return "message content is empty";
        if (a.content().length() > 10000) {
            return "message is " + a.content().length() + " characters; "
                    + "Zulip rejects anything over 10000";
        }
        return null;
    }

    /**
     * The form body Zulip's {@code /api/v1/messages} endpoint expects.
     * Built here rather than at the call site so a test can read it.
     *
     * @param a the announcement to encode
     * @return the URL-encoded form body
     */
    public static String formBody(Announcement a) {
        Map<String, String> fields = new LinkedHashMap<>();
        fields.put("type", "stream");
        fields.put("to", a.stream());
        fields.put("topic", a.topic());
        fields.put("content", a.content());
        StringBuilder body = new StringBuilder();
        for (Map.Entry<String, String> field : fields.entrySet()) {
            if (!body.isEmpty()) body.append('&');
            body.append(field.getKey()).append('=')
                    .append(URLEncoder.encode(field.getValue(),
                            StandardCharsets.UTF_8));
        }
        return body.toString();
    }

    /**
     * A one-line, credential-free description of where a message would
     * go โ€” what the draft prints, and what the publish echoes.
     *
     * @param a the announcement
     * @return the destination, e.g. {@code chat.example/#Stream > topic}
     */
    public static String destination(Announcement a) {
        return a.site().replaceFirst("^https?://", "")
                + " ยท #" + a.stream() + " > " + a.topic();
    }

    /**
     * Post the announcement. Zulip authenticates with HTTP basic auth
     * over the bot's email and token.
     *
     * @param a   the announcement to post
     * @param bot the posting identity
     * @return the response body, which carries the message id on success
     * @throws Exception when the request fails or Zulip rejects it
     */
    public static String post(Announcement a, BotIdentity bot)
            throws Exception {
        String basic = Base64.getEncoder().encodeToString(
                (bot.email() + ":" + bot.token())
                        .getBytes(StandardCharsets.UTF_8));
        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(a.site() + "/api/v1/messages"))
                .header("Authorization", "Basic " + basic)
                .header("Content-Type", "application/x-www-form-urlencoded")
                .timeout(Duration.ofSeconds(30))
                .POST(HttpRequest.BodyPublishers.ofString(formBody(a),
                        StandardCharsets.UTF_8))
                .build();
        HttpResponse<String> response;
        try (HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(15)).build()) {
            response = client.send(request,
                    HttpResponse.BodyHandlers.ofString());
        }
        if (response.statusCode() != 200) {
            throw new IllegalStateException("Zulip rejected the message ("
                    + response.statusCode() + "): " + response.body());
        }
        return response.body();
    }

    private static boolean isBlank(String s) {
        return s == null || s.isBlank();
    }
}