diff --git a/.github/scripts/notify-new-strings.mjs b/.github/scripts/notify-new-strings.mjs new file mode 100644 index 0000000..5e235ab --- /dev/null +++ b/.github/scripts/notify-new-strings.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +/** + * Posts a Discord message listing translation keys that were added to en-GB.json, + * then creates a thread on that message for translator questions. + * + * Env: + * OLD_FILE path to the previous version of the locale file (missing => treated as {}) + * NEW_FILE path to the current version + * DISCORD_BOT_TOKEN bot token (needs View Channel / Send Messages / Create Public Threads) + * DISCORD_CHANNEL_ID channel to post in + * DISCORD_ROLE_IDS role(s) to ping, comma-separated + * EMOJI_PREFIX e.g. "<:notice1:123456789012345678><:notice2:123456789012345678>" + * DRY_RUN "true" to print the message instead of sending it + */ + +const API = "https://discord.com/api/v10"; +const MAX_LEN = 2000; +const MAX_VALUE_CHARS = 250; + +const { + OLD_FILE, + NEW_FILE, + DISCORD_BOT_TOKEN, + DISCORD_CHANNEL_ID, + DISCORD_ROLE_IDS, + DISCORD_ROLE_ID, + EMOJI_PREFIX = "", + DRY_RUN, +} = process.env; + +const dryRun = DRY_RUN === "true"; + +// Roles are only pinged if they're listed in allowed_mentions.roles, even when +// the <@&id> mention is in the content. +const roleIds = (DISCORD_ROLE_IDS || DISCORD_ROLE_ID || "") + .split(",") + .map((id) => id.trim()) + .filter(Boolean); + +if (roleIds.length === 0) throw new Error("DISCORD_ROLE_IDS is not set"); + +// --- load + flatten ------------------------------------------------------- + +import { readFile } from "node:fs/promises"; + +async function load(path) { + if (!path) return {}; + try { + return JSON.parse(await readFile(path, "utf8")); + } catch (err) { + if (err.code === "ENOENT") return {}; // file didn't exist in the base commit + throw err; + } +} + +// The file mixes flat dot-notation keys with nested objects (and has one "" +// key at the root), so both shapes collapse to the same dotted path here. +function flatten(obj, prefix = "", out = {}) { + for (const [key, value] of Object.entries(obj ?? {})) { + const path = prefix && key ? `${prefix}.${key}` : prefix || key; + if (value && typeof value === "object" && !Array.isArray(value)) { + flatten(value, path, out); + } else { + out[path] = value; + } + } + return out; +} + +// --- formatting ----------------------------------------------------------- + +function code(raw) { + let value = String(raw ?? "") + .replace(/\r?\n/g, "\\n") + .trim(); + if (value.length > MAX_VALUE_CHARS) value = `${value.slice(0, MAX_VALUE_CHARS - 1)}…`; + if (value === "") return "`(empty)`"; + // Use ``double`` fences when the string itself contains a backtick. + const fence = value.includes("`") ? "``" : "`"; + const pad = value.startsWith("`") || value.endsWith("`") ? " " : ""; + return `${fence}${pad}${value}${pad}${fence}`; +} + +function chunk(lines, firstBudget, restBudget) { + const chunks = []; + let current = []; + let budget = firstBudget; + let length = 0; + for (const line of lines) { + const cost = line.length + 1; + if (current.length && length + cost > budget) { + chunks.push(current); + current = []; + length = 0; + budget = restBudget; + } + current.push(line); + length += cost; + } + if (current.length) chunks.push(current); + return chunks; +} + +// --- discord -------------------------------------------------------------- + +async function discord(path, body, method = "POST") { + for (let attempt = 0; attempt < 5; attempt++) { + const res = await fetch(`${API}${path}`, { + method, + headers: { + Authorization: `Bot ${DISCORD_BOT_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (res.status === 429) { + const { retry_after: retryAfter = 1 } = await res.json().catch(() => ({})); + await new Promise((r) => setTimeout(r, (retryAfter + 0.5) * 1000)); + continue; + } + if (!res.ok) { + throw new Error(`Discord ${method} ${path} -> ${res.status}: ${await res.text()}`); + } + return res.json(); + } + throw new Error(`Discord ${method} ${path}: rate limited after 5 attempts`); +} + +// --- main ----------------------------------------------------------------- + +const before = flatten(await load(OLD_FILE)); +const after = flatten(await load(NEW_FILE)); + +const added = Object.keys(after).filter((key) => !(key in before)); + +if (added.length === 0) { + console.log("No new translation strings — nothing to post."); + process.exit(0); +} + +console.log(`Found ${added.length} new string(s).`); + +const header = [ + `## ${EMOJI_PREFIX} ${roleIds.map((id) => `<@&${id}>`).join(" ")}`, + "", + `The following translation string${added.length === 1 ? " has" : "s have"} been added:`, + "", +].join("\n"); + +const footer = "\n\nAny queries/questions, please respond in the thread attached"; + +const lines = added.map((key) => `- \`${key}\`: ${code(after[key])}`); + +const batches = chunk( + lines, + MAX_LEN - header.length - footer.length, + MAX_LEN - 100, // continuation posts go in the thread +); + +const first = header + batches[0].join("\n") + footer; +const overflow = batches.slice(1).map((batch) => batch.join("\n")); + +if (dryRun) { + console.log("----- message -----"); + console.log(first); + overflow.forEach((text, i) => console.log(`----- thread post ${i + 1} -----\n${text}`)); + process.exit(0); +} + +const message = await discord(`/channels/${DISCORD_CHANNEL_ID}/messages`, { + content: first, + allowed_mentions: { parse: [], roles: roleIds }, +}); + +const stamp = new Date().toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", +}); + +const thread = await discord( + `/channels/${DISCORD_CHANNEL_ID}/messages/${message.id}/threads`, + { + name: `New strings — ${stamp}`.slice(0, 100), + auto_archive_duration: 10080, // 7 days + }, +); + +for (const text of overflow) { + await discord(`/channels/${thread.id}/messages`, { + content: text, + allowed_mentions: { parse: [] }, + }); +} + +console.log(`Posted message ${message.id} and opened thread ${thread.id}.`); diff --git a/.github/workflows/notify.yml b/.github/workflows/notify.yml new file mode 100644 index 0000000..479f9b2 --- /dev/null +++ b/.github/workflows/notify.yml @@ -0,0 +1,60 @@ +name: Notify Translators + +on: + push: + branches: [main] + paths: + - 'en-GB.json' + workflow_dispatch: + inputs: + base: + description: 'Commit/ref to compare against (defaults to the previous commit)' + required: false + dry_run: + description: 'Print the message instead of posting it' + type: boolean + default: true + +concurrency: + group: notify-translators + cancel-in-progress: false + +jobs: + notify: + runs-on: ubuntu-latest + env: + LOCALE_FILE: en-GB.json + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Extract previous version of the locale file + id: base + run: | + BASE="${{ inputs.base || github.event.before }}" + # Branch creation / force push / unknown ref -> fall back to the parent commit + if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then + BASE="$(git rev-parse HEAD^ 2>/dev/null || echo '')" + fi + echo "Comparing against: ${BASE:-}" + if [ -n "$BASE" ] && git show "$BASE:$LOCALE_FILE" > /tmp/old.json 2>/dev/null; then + echo "Extracted previous version." + else + echo '{}' > /tmp/old.json + fi + + - name: Post new strings to Discord + env: + OLD_FILE: /tmp/old.json + NEW_FILE: ${{ env.LOCALE_FILE }} + DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + DISCORD_CHANNEL_ID: ${{ vars.TRANSLATIONS_CHANNEL_ID }} + DISCORD_ROLE_IDS: '1382765707774918686' + EMOJI_PREFIX: ${{ vars.TRANSLATIONS_EMOJI_PREFIX }} + DRY_RUN: ${{ inputs.dry_run || 'false' }} + run: node .github/scripts/notify-new-strings.mjs