Skip to content
This repository was archived by the owner on Apr 15, 2026. It is now read-only.

Commit f53e34c

Browse files
committed
Add Discord components v2 support for news in a beta state on a dedicated Discord server.
1 parent c2ec200 commit f53e34c

14 files changed

Lines changed: 332 additions & 6 deletions

.env.skel

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ GAME_WORLDS="adamantoise,cactuar,faerie,gilgamesh,jenova,midgardsormr,sargatanas
5555
# Custom Emoji Markdowns
5656
EMOJI_DOGGO_SMILE="<:DoggoSmile:1439265481596076195>"
5757
EMOJI_LOADING="<a:Loading:1437414476138811422>"
58+
EMOJI_LODESTONE="<:Lodestone:1451149413228613773>"
5859
EMOJI_NEWS_TOPIC="<:Topic:1437414497383088178>"
5960
EMOJI_NEWS_NOTICE="<:Notice:1437414478529560637>"
6061
EMOJI_NEWS_MAINTENANCE="<:Maintenance:1437414477229195284>"
@@ -78,3 +79,11 @@ EMOJI_SERVER_PREFERRED_PLUS="<:SrvPrefPlus:1446646265801740448>"
7879
EMOJI_SERVER_PREFERRED="<:SrvPref:1446645177912524820>"
7980
EMOJI_SERVER_STANDARD="<:SrvStd:1446645181087744040>"
8081
EMOJI_SERVER_CONGESTED="<:SrvCong:1446645176079613982>"
82+
83+
# Beta Feature Discord Components V2
84+
BETA_GUILD_ID_NEWS=
85+
BETA_CHANNEL_ID_TOPICS=
86+
BETA_CHANNEL_ID_NOTICES=
87+
BETA_CHANNEL_ID_MAINTENANCES=
88+
BETA_CHANNEL_ID_UPDATES=
89+
BETA_CHANNEL_ID_STATUSES=

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ client.on("interactionCreate", async (interaction) => {
283283
if (!command) {
284284
log.error(`Command not found: ${interaction.commandName}`);
285285
const embed = DiscordEmbedService.getErrorEmbed(
286-
`Command '${interaction.commandName}' not found. Try redeploying commands.`,
286+
`Command '${interaction.commandName}' not found. Please try again later.`,
287287
);
288288
await interaction.reply({
289289
embeds: [embed],
@@ -367,7 +367,7 @@ client.on("interactionCreate", async (interaction) => {
367367
if (!command) {
368368
log.error(`Command not found: ${interaction.commandName}`);
369369
const embed = DiscordEmbedService.getErrorEmbed(
370-
`Command '${interaction.commandName}' not found. Try redeploying commands.`,
370+
`Command '${interaction.commandName}' not found. Please try again later.`,
371371
);
372372
await interaction.reply({
373373
embeds: [embed],
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export interface TextDisplayComponent {
2+
type: "textDisplay";
3+
content: string;
4+
}
5+
6+
export interface MediaGalleryComponent {
7+
type: "mediaGallery";
8+
urls: string[];
9+
}
10+
11+
export interface SeparatorComponent {
12+
type: "separator";
13+
}
14+
15+
export type DiscordComponentV2 = TextDisplayComponent | MediaGalleryComponent | SeparatorComponent;
16+
17+
export interface DiscordComponentsV2 {
18+
components: DiscordComponentV2[];
19+
}

src/naagostone/type/Maintenance.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { DiscordComponentsV2 } from "./DiscordComponentsV2.ts";
2+
13
export interface MaintenanceDescription {
24
html: string;
35
markdown: string;
6+
discord_components_v2?: DiscordComponentsV2;
47
}
58

69
export interface Maintenance {

src/naagostone/type/Notice.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { DiscordComponentsV2 } from "./DiscordComponentsV2.ts";
2+
13
export interface NoticeDescription {
24
html: string;
35
markdown: string;
6+
discord_components_v2?: DiscordComponentsV2;
47
}
58

69
export interface Notice {

src/naagostone/type/Status.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { DiscordComponentsV2 } from "./DiscordComponentsV2.ts";
2+
13
export interface StatusDescription {
24
html: string;
35
markdown: string;
6+
discord_components_v2?: DiscordComponentsV2;
47
}
58

69
export interface Status {

src/naagostone/type/Topic.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { DiscordComponentsV2 } from "./DiscordComponentsV2.ts";
2+
13
export interface TopicDescription {
24
html: string;
35
markdown: string;
6+
discord_components_v2?: DiscordComponentsV2;
47
}
58

69
export interface Topic {

src/naagostone/type/Updates.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { DiscordComponentsV2 } from "./DiscordComponentsV2.ts";
2+
13
export interface UpdateDescription {
24
html: string;
35
markdown: string;
6+
discord_components_v2?: DiscordComponentsV2;
47
}
58

69
export interface Update {
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { ContainerBuilder, MediaGalleryBuilder, MessageFlags, TextChannel, time, TimestampStyles } from "discord.js";
2+
import { DiscordComponentsV2 } from "../naagostone/type/DiscordComponentsV2.ts";
3+
import { GlobalClient } from "../index.ts";
4+
import * as log from "@std/log";
5+
6+
const MAX_TOTAL_CHARACTERS = 4000;
7+
const MAX_TOTAL_COMPONENTS = 40;
8+
9+
type NewsType = "topics" | "notices" | "maintenances" | "updates" | "statuses";
10+
11+
interface NewsData {
12+
title: string;
13+
link: string;
14+
date: number;
15+
banner?: string;
16+
tag?: string | null;
17+
description: {
18+
html: string;
19+
markdown: string;
20+
discord_components_v2?: DiscordComponentsV2;
21+
};
22+
}
23+
24+
function hexToNumber(hex: string): number {
25+
return parseInt(hex.replace("#", ""), 16);
26+
}
27+
28+
function getNewsTypeLabel(type: NewsType, tag?: string | null): string {
29+
if (tag) return tag;
30+
switch (type) {
31+
case "topics":
32+
return "Topic";
33+
case "notices":
34+
return "Notice";
35+
case "maintenances":
36+
return "Maintenance";
37+
case "updates":
38+
return "Update";
39+
case "statuses":
40+
return "Status";
41+
}
42+
}
43+
44+
function getColorEnvKey(type: NewsType): string {
45+
switch (type) {
46+
case "topics":
47+
return "COLOR_TOPICS";
48+
case "notices":
49+
return "COLOR_NOTICES";
50+
case "maintenances":
51+
return "COLOR_MAINTENANCES";
52+
case "updates":
53+
return "COLOR_UPDATES";
54+
case "statuses":
55+
return "COLOR_STATUS";
56+
}
57+
}
58+
59+
function getBetaChannelEnvKey(type: NewsType): string {
60+
switch (type) {
61+
case "topics":
62+
return "BETA_CHANNEL_ID_TOPICS";
63+
case "notices":
64+
return "BETA_CHANNEL_ID_NOTICES";
65+
case "maintenances":
66+
return "BETA_CHANNEL_ID_MAINTENANCES";
67+
case "updates":
68+
return "BETA_CHANNEL_ID_UPDATES";
69+
case "statuses":
70+
return "BETA_CHANNEL_ID_STATUSES";
71+
}
72+
}
73+
74+
function getNewsEmojiEnvKey(type: NewsType): string {
75+
switch (type) {
76+
case "topics":
77+
return "EMOJI_NEWS_TOPIC";
78+
case "notices":
79+
return "EMOJI_NEWS_NOTICE";
80+
case "maintenances":
81+
return "EMOJI_NEWS_MAINTENANCE";
82+
case "updates":
83+
return "EMOJI_NEWS_UPDATE";
84+
case "statuses":
85+
return "EMOJI_NEWS_STATUS";
86+
}
87+
}
88+
89+
export class BetaComponentsV2Service {
90+
public static async sendToBetaChannel(newsType: NewsType, data: NewsData): Promise<void> {
91+
const client = GlobalClient.client;
92+
if (!client) return;
93+
94+
const betaGuildId = Deno.env.get("BETA_GUILD_ID_NEWS");
95+
const betaChannelId = Deno.env.get(getBetaChannelEnvKey(newsType));
96+
97+
if (!betaGuildId || !betaChannelId) return;
98+
99+
if (!data.description.discord_components_v2) return;
100+
101+
try {
102+
const guild = await client.guilds.fetch(betaGuildId);
103+
if (!guild) return;
104+
const channel = await guild.channels.fetch(betaChannelId);
105+
if (!channel) return;
106+
107+
const container = this.buildContainer(newsType, data);
108+
if (!container) return;
109+
110+
await (channel as TextChannel).send({
111+
components: [container],
112+
flags: MessageFlags.IsComponentsV2,
113+
});
114+
} catch (error: unknown) {
115+
if (error instanceof Error) {
116+
log.error(`[BETA V2] Sending ${newsType} to beta channel was NOT successful: ${error.stack}`);
117+
}
118+
}
119+
}
120+
121+
private static buildContainer(newsType: NewsType, data: NewsData): ContainerBuilder | null {
122+
const colorHex = Deno.env.get(getColorEnvKey(newsType));
123+
if (!colorHex) return null;
124+
125+
const componentsV2 = data.description.discord_components_v2;
126+
if (!componentsV2) return null;
127+
128+
const container = new ContainerBuilder().setAccentColor(hexToNumber(colorHex));
129+
130+
let totalCharacters = 0;
131+
let totalComponents = 0;
132+
133+
const newsEmoji = Deno.env.get(getNewsEmojiEnvKey(newsType)) ?? "";
134+
const lodestoneEmoji = Deno.env.get("EMOJI_LODESTONE") ?? "";
135+
const typeLabel = `${newsEmoji} **${getNewsTypeLabel(newsType, data.tag)}**`;
136+
const titleMarkdown = `## [${data.title}](${data.link})`;
137+
const footerText = `${lodestoneEmoji} Lodestone · ${
138+
time(Math.floor(data.date / 1000), TimestampStyles.LongDateTime)
139+
}`;
140+
141+
totalCharacters += typeLabel.length + titleMarkdown.length + footerText.length;
142+
totalComponents += 4;
143+
144+
if (data.banner) {
145+
totalComponents += 1;
146+
}
147+
148+
for (const component of componentsV2.components) {
149+
if (component.type === "textDisplay") {
150+
totalCharacters += component.content.length;
151+
totalComponents += 1;
152+
} else if (component.type === "mediaGallery") {
153+
totalComponents += 1 + component.urls.length;
154+
} else if (component.type === "separator") {
155+
totalComponents += 1;
156+
}
157+
}
158+
159+
if (totalCharacters > MAX_TOTAL_CHARACTERS || totalComponents > MAX_TOTAL_COMPONENTS) {
160+
log.warn(
161+
`[BETA V2] ${newsType} message exceeds limits (chars: ${totalCharacters}/${MAX_TOTAL_CHARACTERS}, components: ${totalComponents}/${MAX_TOTAL_COMPONENTS}). Truncating...`,
162+
);
163+
}
164+
165+
let currentCharacters = 0;
166+
let currentComponents = 0;
167+
168+
container.addTextDisplayComponents((textDisplay) => textDisplay.setContent(typeLabel));
169+
currentCharacters += typeLabel.length;
170+
currentComponents += 1;
171+
172+
container.addTextDisplayComponents((textDisplay) => textDisplay.setContent(titleMarkdown));
173+
currentCharacters += titleMarkdown.length;
174+
currentComponents += 1;
175+
176+
if (data.banner) {
177+
const bannerGallery = new MediaGalleryBuilder().addItems((item) =>
178+
item.setDescription("Banner image").setURL(data.banner!)
179+
);
180+
container.addMediaGalleryComponents(bannerGallery);
181+
currentComponents += 2;
182+
}
183+
184+
for (const component of componentsV2.components) {
185+
if (currentComponents >= MAX_TOTAL_COMPONENTS - 2) break;
186+
187+
if (component.type === "textDisplay") {
188+
const remainingChars = MAX_TOTAL_CHARACTERS - currentCharacters - footerText.length;
189+
if (remainingChars <= 0) break;
190+
191+
let content = component.content;
192+
if (content.length > remainingChars) {
193+
content = content.substring(0, remainingChars - 3) + "...";
194+
}
195+
196+
container.addTextDisplayComponents((textDisplay) => textDisplay.setContent(content));
197+
currentCharacters += content.length;
198+
currentComponents += 1;
199+
} else if (component.type === "mediaGallery") {
200+
const remainingComponentSlots = MAX_TOTAL_COMPONENTS - currentComponents - 2;
201+
if (remainingComponentSlots <= 1) break;
202+
203+
const maxItems = Math.min(component.urls.length, remainingComponentSlots - 1);
204+
const gallery = new MediaGalleryBuilder();
205+
206+
for (let i = 0; i < maxItems; i++) {
207+
gallery.addItems((item) => item.setDescription("Image").setURL(component.urls[i]));
208+
}
209+
210+
container.addMediaGalleryComponents(gallery);
211+
currentComponents += 1 + maxItems;
212+
} else if (component.type === "separator") {
213+
container.addSeparatorComponents((separator) => separator);
214+
currentComponents += 1;
215+
}
216+
}
217+
218+
container.addSeparatorComponents((separator) => separator);
219+
currentComponents += 1;
220+
221+
container.addTextDisplayComponents((textDisplay) => textDisplay.setContent(footerText));
222+
currentCharacters += footerText.length;
223+
currentComponents += 1;
224+
225+
return container;
226+
}
227+
}

src/service/MaintenanceSenderService.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { GlobalClient } from "../index.ts";
88
import { Setup } from "../database/schema/setups.ts";
99
import { SetupsRepository } from "../database/repository/SetupsRepository.ts";
1010
import { DiscordEmbedService } from "./DiscordEmbedService.ts";
11+
import { BetaComponentsV2Service } from "./BetaComponentsV2Service.ts";
1112
import * as log from "@std/log";
1213

1314
const saveLodestoneNews = Deno.env.get("SAVE_LODESTONE_NEWS") === "true";
@@ -69,5 +70,13 @@ export class MaintenanceSenderService {
6970
continue;
7071
}
7172
}
73+
74+
await BetaComponentsV2Service.sendToBetaChannel("maintenances", {
75+
title: maintenance.title,
76+
link: maintenance.link,
77+
date: maintenance.date,
78+
tag: maintenance.tag,
79+
description: maintenance.description,
80+
});
7281
}
7382
}

0 commit comments

Comments
 (0)