#!/usr/bin/env node import * as NodeRuntime from "@effect/platform-node/NodeRuntime";import * as NodeServices from "@effect/platform-node/NodeServices";import * as Config from "effect/Config";import * as Data from "effect/Data";import * as DateTime from "effect/DateTime";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as Logger from "effect/Logger";import * as Schema from "effect/Schema";import { Argument, Command, Flag } from "effect/unstable/cli";import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse,} from "effect/unstable/http"; export type DiscordReleaseTarget = "prerelease" | "latest"; interface DiscordReleaseAnnouncementOptions { readonly target: DiscordReleaseTarget; readonly roleId: string; readonly releaseName: string; readonly version: string; readonly tag: string; readonly releaseUrl: URL; readonly timestamp: string;} interface DiscordWebhookPayload { readonly content: string; readonly allowed_mentions: { readonly roles: ReadonlyArray<string>; }; readonly embeds: ReadonlyArray<{ readonly title: string; readonly url: string; readonly description: string; readonly color: number; readonly fields: ReadonlyArray<{ readonly name: string; readonly value: string; readonly inline: boolean; }>; readonly timestamp: string; }>;} const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const;const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/));const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL"); class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{ readonly message: string; readonly cause?: unknown;}> {} const targetLabels = { prerelease: "Prerelease", latest: "Latest",} as const satisfies Record<DiscordReleaseTarget, string>; const targetColors = { prerelease: 0x5865f2, latest: 0x2ecc71,} as const satisfies Record<DiscordReleaseTarget, number>; function describeWebhookUrl(webhookUrl: URL) { return { configured: true, origin: webhookUrl.origin, pathnameSegmentCount: webhookUrl.pathname.split("/").filter(Boolean).length, } as const;} function summarizePayload(payload: DiscordWebhookPayload) { return { contentLength: payload.content.length, embedCount: payload.embeds.length, allowedRoleMentionCount: payload.allowed_mentions.roles.length, hasRoleMentionSyntax: payload.content.includes("<@&"), } as const;} export const buildDiscordReleaseAnnouncement = ( options: DiscordReleaseAnnouncementOptions,): DiscordWebhookPayload => ({ content: `<@&${options.roleId}> ${targetLabels[options.target]} published: ${options.releaseName}`, allowed_mentions: { roles: [options.roleId], }, embeds: [ { title: options.releaseName, url: options.releaseUrl.href, description: options.target === "prerelease" ? "A new T3 Code prerelease is available for nightly testers." : "A new T3 Code latest release is available.", color: targetColors[options.target], fields: [ { name: "Version", value: options.version, inline: true, }, { name: "Tag", value: options.tag, inline: true, }, ], timestamp: options.timestamp, }, ],}); const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* ( webhookUrl: URL, payload: DiscordWebhookPayload,) { const httpClient = (yield* HttpClient.HttpClient).pipe( HttpClient.retryTransient({ retryOn: "errors-and-responses", times: 3, }), ); yield* Effect.logInfo("discord webhook request dispatching").pipe( Effect.annotateLogs({ ...describeWebhookUrl(webhookUrl), ...summarizePayload(payload), }), ); const response = yield* HttpClientRequest.post(webhookUrl).pipe( HttpClientRequest.bodyJson(payload), Effect.flatMap(httpClient.execute), Effect.mapError( (cause) => new DiscordReleaseAnnouncementError({ message: "Failed to post Discord release announcement.", cause, }), ), ); yield* Effect.logInfo("discord webhook response received").pipe( Effect.annotateLogs({ status: response.status, ok: response.status >= 200 && response.status < 300, }), ); yield* HttpClientResponse.filterStatusOk(response).pipe( Effect.mapError( (cause) => new DiscordReleaseAnnouncementError({ message: `Discord webhook returned status ${response.status}.`, cause, }), ), );}); export const notifyDiscordReleaseCommand = Command.make( "notify-discord-release", { target: Argument.choice("target", DISCORD_RELEASE_TARGETS).pipe( Argument.withDescription("Discord announcement target: prerelease or latest."), ), roleId: Flag.string("role-id").pipe( Flag.withSchema(DiscordRoleIdSchema), Flag.withDescription("Discord role ID to mention in the release announcement."), ), releaseName: Flag.string("release-name").pipe( Flag.withSchema(Schema.NonEmptyString), Flag.withDescription("Human-readable release name."), ), releaseVersion: Flag.string("release-version").pipe( Flag.withSchema(Schema.NonEmptyString), Flag.withDescription("Release version."), ), tag: Flag.string("tag").pipe( Flag.withSchema(Schema.NonEmptyString), Flag.withDescription("Git tag for the release."), ), releaseUrl: Flag.string("release-url").pipe( Flag.withSchema(Schema.URLFromString), Flag.withDescription("Public GitHub release URL."), ), }, ({ target, roleId, releaseName, releaseVersion, tag, releaseUrl }) => Effect.gen(function* () { yield* Effect.logInfo("discord release announcement starting").pipe( Effect.annotateLogs({ target, roleIdProvided: roleId.length > 0, roleIdLength: roleId.length, releaseName, version: releaseVersion, tag, releaseUrl, }), ); const webhookUrl = yield* DiscordWebhookUrl; const timestamp = DateTime.formatIso(yield* DateTime.now); const payload = buildDiscordReleaseAnnouncement({ target, roleId, releaseName, version: releaseVersion, tag, releaseUrl, timestamp, }); yield* Effect.logInfo("discord release announcement payload built").pipe( Effect.annotateLogs(summarizePayload(payload)), ); yield* postDiscordWebhook(webhookUrl, payload); yield* Effect.logInfo("discord release announcement completed"); }),).pipe(Command.withDescription("Post a T3 Code release announcement to Discord.")); if (import.meta.main) { Command.run(notifyDiscordReleaseCommand, { version: "0.0.0" }).pipe( Effect.provide( Layer.mergeAll( Logger.layer([Logger.consolePretty()]), NodeServices.layer, FetchHttpClient.layer, ), ), NodeRuntime.runMain, );}
#!/usr/bin/env node
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Config from "effect/Config";
import * as Data from "effect/Data";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Schema from "effect/Schema";
import { Argument, Command, Flag } from "effect/unstable/cli";
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http";
export type DiscordReleaseTarget = "prerelease" | "latest";
interface DiscordReleaseAnnouncementOptions {
readonly target: DiscordReleaseTarget;
readonly roleId: string;
readonly releaseName: string;
readonly version: string;
readonly tag: string;
readonly releaseUrl: URL;
readonly timestamp: string;
}
interface DiscordWebhookPayload {
readonly content: string;
readonly allowed_mentions: {
readonly roles: ReadonlyArray<string>;
};
readonly embeds: ReadonlyArray<{
readonly title: string;
readonly url: string;
readonly description: string;
readonly color: number;
readonly fields: ReadonlyArray<{
readonly name: string;
readonly value: string;
readonly inline: boolean;
}>;
readonly timestamp: string;
}>;
}
const DISCORD_RELEASE_TARGETS = ["prerelease", "latest"] as const;
const DiscordRoleIdSchema = Schema.String.check(Schema.isPattern(/^\d+$/));
const DiscordWebhookUrl = Config.url("DISCORD_WEBHOOK_URL");
class DiscordReleaseAnnouncementError extends Data.TaggedError("DiscordReleaseAnnouncementError")<{
readonly message: string;
readonly cause?: unknown;
}> {}
const targetLabels = {
prerelease: "Prerelease",
latest: "Latest",
} as const satisfies Record<DiscordReleaseTarget, string>;
const targetColors = {
prerelease: 0x5865f2,
latest: 0x2ecc71,
} as const satisfies Record<DiscordReleaseTarget, number>;
function describeWebhookUrl(webhookUrl: URL) {
return {
configured: true,
origin: webhookUrl.origin,
pathnameSegmentCount: webhookUrl.pathname.split("/").filter(Boolean).length,
} as const;
}
function summarizePayload(payload: DiscordWebhookPayload) {
return {
contentLength: payload.content.length,
embedCount: payload.embeds.length,
allowedRoleMentionCount: payload.allowed_mentions.roles.length,
hasRoleMentionSyntax: payload.content.includes("<@&"),
} as const;
}
export const buildDiscordReleaseAnnouncement = (
options: DiscordReleaseAnnouncementOptions,
): DiscordWebhookPayload => ({
content: `<@&${options.roleId}> ${targetLabels[options.target]} published: ${options.releaseName}`,
allowed_mentions: {
roles: [options.roleId],
},
embeds: [
{
title: options.releaseName,
url: options.releaseUrl.href,
description:
options.target === "prerelease"
? "A new T3 Code prerelease is available for nightly testers."
: "A new T3 Code latest release is available.",
color: targetColors[options.target],
fields: [
{
name: "Version",
value: options.version,
inline: true,
},
{
name: "Tag",
value: options.tag,
inline: true,
},
],
timestamp: options.timestamp,
},
],
});
const postDiscordWebhook = Effect.fn("postDiscordWebhook")(function* (
webhookUrl: URL,
payload: DiscordWebhookPayload,
) {
const httpClient = (yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
retryOn: "errors-and-responses",
times: 3,
}),
);
yield* Effect.logInfo("discord webhook request dispatching").pipe(
Effect.annotateLogs({
...describeWebhookUrl(webhookUrl),
...summarizePayload(payload),
}),
);
const response = yield* HttpClientRequest.post(webhookUrl).pipe(
HttpClientRequest.bodyJson(payload),
Effect.flatMap(httpClient.execute),
Effect.mapError(
(cause) =>
new DiscordReleaseAnnouncementError({
message: "Failed to post Discord release announcement.",
cause,
}),
),
);
yield* Effect.logInfo("discord webhook response received").pipe(
Effect.annotateLogs({
status: response.status,
ok: response.status >= 200 && response.status < 300,
}),
);
yield* HttpClientResponse.filterStatusOk(response).pipe(
Effect.mapError(
(cause) =>
new DiscordReleaseAnnouncementError({
message: `Discord webhook returned status ${response.status}.`,
cause,
}),
),
);
});
export const notifyDiscordReleaseCommand = Command.make(
"notify-discord-release",
{
target: Argument.choice("target", DISCORD_RELEASE_TARGETS).pipe(
Argument.withDescription("Discord announcement target: prerelease or latest."),
),
roleId: Flag.string("role-id").pipe(
Flag.withSchema(DiscordRoleIdSchema),
Flag.withDescription("Discord role ID to mention in the release announcement."),
),
releaseName: Flag.string("release-name").pipe(
Flag.withSchema(Schema.NonEmptyString),
Flag.withDescription("Human-readable release name."),
),
releaseVersion: Flag.string("release-version").pipe(
Flag.withSchema(Schema.NonEmptyString),
Flag.withDescription("Release version."),
),
tag: Flag.string("tag").pipe(
Flag.withSchema(Schema.NonEmptyString),
Flag.withDescription("Git tag for the release."),
),
releaseUrl: Flag.string("release-url").pipe(
Flag.withSchema(Schema.URLFromString),
Flag.withDescription("Public GitHub release URL."),
),
},
({ target, roleId, releaseName, releaseVersion, tag, releaseUrl }) =>
Effect.gen(function* () {
yield* Effect.logInfo("discord release announcement starting").pipe(
Effect.annotateLogs({
target,
roleIdProvided: roleId.length > 0,
roleIdLength: roleId.length,
releaseName,
version: releaseVersion,
tag,
releaseUrl,
}),
);
const webhookUrl = yield* DiscordWebhookUrl;
const timestamp = DateTime.formatIso(yield* DateTime.now);
const payload = buildDiscordReleaseAnnouncement({
target,
roleId,
releaseName,
version: releaseVersion,
tag,
releaseUrl,
timestamp,
});
yield* Effect.logInfo("discord release announcement payload built").pipe(
Effect.annotateLogs(summarizePayload(payload)),
);
yield* postDiscordWebhook(webhookUrl, payload);
yield* Effect.logInfo("discord release announcement completed");
}),
).pipe(Command.withDescription("Post a T3 Code release announcement to Discord."));
if (import.meta.main) {
Command.run(notifyDiscordReleaseCommand, { version: "0.0.0" }).pipe(
Effect.provide(
Layer.mergeAll(
Logger.layer([Logger.consolePretty()]),
NodeServices.layer,
FetchHttpClient.layer,
),
),
NodeRuntime.runMain,
);
}