a little carrier pigeon that ferries figma events to discord
1/**
2 * Build and send Discord channel webhook messages.
3 *
4 * Discord embed limits we respect:
5 * - field.value <= 1024 chars
6 * - up to 25 fields
7 * - total embed <= 6000 chars
8 * See: https://discord.com/developers/docs/resources/webhook
9 */
10
11import type { BatchState, LibraryItem } from "./types.js";
12import { figmaFileUrl } from "./figma.js";
13
14const MAX_ITEMS_PER_FIELD = 15;
15const FIELD_VALUE_MAX = 1024;
16
17export interface DiscordEmbed {
18 title: string;
19 url: string;
20 description?: string;
21 timestamp?: string;
22 color?: number;
23 fields?: Array<{ name: string; value: string; inline?: boolean }>;
24 footer?: { text: string };
25}
26
27/**
28 * Format a list of library items into a Discord field value. Truncates to
29 * MAX_ITEMS_PER_FIELD entries and/or the field-value character limit, adding
30 * a "+ X more" tail when necessary.
31 */
32function formatItemList(items: LibraryItem[]): string {
33 if (items.length === 0) return "";
34
35 const shown = items.slice(0, MAX_ITEMS_PER_FIELD);
36 const remaining = items.length - shown.length;
37
38 const lines = shown.map((it) => `\u2022 ${it.name}`);
39 if (remaining > 0) lines.push(`\u2026 + ${remaining} more`);
40
41 let value = lines.join("\n");
42 if (value.length > FIELD_VALUE_MAX) {
43 // Hard cap: trim and append an ellipsis.
44 value = value.slice(0, FIELD_VALUE_MAX - 1) + "\u2026";
45 }
46 return value;
47}
48
49export function buildEmbed(batch: BatchState): DiscordEmbed {
50 const components = Object.values(batch.items.components);
51 const styles = Object.values(batch.items.styles);
52 const variables = Object.values(batch.items.variables);
53
54 const fields: DiscordEmbed["fields"] = [];
55 if (components.length > 0) {
56 fields.push({
57 name: `Components (${components.length})`,
58 value: formatItemList(components),
59 });
60 }
61 if (styles.length > 0) {
62 fields.push({
63 name: `Styles (${styles.length})`,
64 value: formatItemList(styles),
65 });
66 }
67 if (variables.length > 0) {
68 fields.push({
69 name: `Variables (${variables.length})`,
70 value: formatItemList(variables),
71 });
72 }
73
74 const embed: DiscordEmbed = {
75 title: `\u{1F4E6} ${batch.fileName} \u2014 library published`,
76 url: figmaFileUrl(batch.fileKey, batch.fileName),
77 timestamp: new Date(batch.firstSeenAt).toISOString(),
78 color: 0x0acf83, // Figma green-ish
79 };
80
81 if (batch.fileDescription && batch.fileDescription.trim().length > 0) {
82 embed.description = batch.fileDescription.slice(0, 4096);
83 }
84 if (fields.length > 0) embed.fields = fields;
85
86 return embed;
87}
88
89/**
90 * POST an embed to a Discord channel webhook URL. Returns the HTTP response
91 * so the caller can decide whether to retry.
92 */
93export async function sendToDiscord(
94 webhookUrl: string,
95 embed: DiscordEmbed,
96): Promise<Response> {
97 return fetch(webhookUrl, {
98 method: "POST",
99 headers: { "Content-Type": "application/json" },
100 body: JSON.stringify({ embeds: [embed] }),
101 });
102}