diff --git a/.env.example b/.env.example index a5d0f74..be18af9 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,26 @@ -# Local Environment Variables (Secrets) -# Copy this file to .env and fill in your actual values -# .env is gitignored and should NEVER be committed +# Discord Bot Credentials (REQUIRED - Secret values, do not commit!) +DISCORD_TOKEN=your_discord_bot_token_here +CLIENT_ID=your_discord_client_id_here -# Discord Bot Token & Application ID (REQUIRED) -# Get this from: https://discord.com/developers/applications -DISCORD_TOKEN=your-bot-token-here -CLIENT_ID=your-bot-application-id +# Server Configuration (REQUIRED) +SERVER_ID=your_server_id_here -# Override any public config values for local testing +# Channel IDs (REQUIRED) +GUIDES_CHANNEL_ID=your_guides_channel_id_here +ADVENT_OF_CODE_CHANNEL_ID=your_advent_of_code_forum_channel_id_here +REPEL_LOG_CHANNEL_ID=your_repel_log_channel_id_here -# Discord Server ID (your dev server) -SERVER_ID=your-server-id +# Role IDs (REQUIRED) +MODERATORS_ROLE_IDS=role_id_1,role_id_2,role_id_3 +REPEL_ROLE_ID=your_repel_role_id_here -# Channel IDs (from your dev server) -GUIDES_CHANNEL_ID=your-guide-channel-id -REPEL_LOG_CHANNEL_ID=your-repel-log-channel-id +# Optional Role IDs +# ROLE_A_ID=optional_role_a_id +# ROLE_B_ID=optional_role_b_id +# ROLE_C_ID=optional_role_c_id -# Role IDs (from your dev server) -REPEL_ROLE_ID=your-repel-role-id -MODERATORS_ROLE_IDS=your-moderator-role-id - -# Other -GUIDES_TRACKER_PATH=guides-tracker.json \ No newline at end of file +# Data Persistence (OPTIONAL) +# Local development defaults to current directory +# Docker deployments should use /app/data for persistence +GUIDES_TRACKER_PATH=guides-tracker.json +ADVENT_OF_CODE_TRACKER_PATH=advent-of-code-tracker.json diff --git a/.env.production b/.env.production index fb3ab4c..6631190 100644 --- a/.env.production +++ b/.env.production @@ -8,6 +8,7 @@ SERVER_ID=434487340535382016 # Channel IDs (from your dev server) GUIDES_CHANNEL_ID=1429492053825290371 REPEL_LOG_CHANNEL_ID=1403558160144531589 +ADVENT_OF_CODE_CHANNEL_ID=1047623689488830495 # Role IDs (from your dev server) REPEL_ROLE_ID=1002411741776461844 @@ -15,5 +16,6 @@ MODERATORS_ROLE_IDS=849481536654803004 # Other GUIDES_TRACKER_PATH=/app/data/guides-tracker.json +ADVENT_OF_CODE_TRACKER_PATH=/app/data/advent-of-code-tracker.json # Note: DISCORD_TOKEN & CLIENT_ID should be in .env.local (not committed) diff --git a/.env.test b/.env.test index a5d0f74..d099dce 100644 --- a/.env.test +++ b/.env.test @@ -14,6 +14,7 @@ SERVER_ID=your-server-id # Channel IDs (from your dev server) GUIDES_CHANNEL_ID=your-guide-channel-id +ADVENT_OF_CODE_CHANNEL_ID=your_advent_of_code_forum_channel_id_here REPEL_LOG_CHANNEL_ID=your-repel-log-channel-id # Role IDs (from your dev server) @@ -21,4 +22,5 @@ REPEL_ROLE_ID=your-repel-role-id MODERATORS_ROLE_IDS=your-moderator-role-id # Other -GUIDES_TRACKER_PATH=guides-tracker.json \ No newline at end of file +GUIDES_TRACKER_PATH=guides-tracker.json +ADVENT_OF_CODE_TRACKER_PATH=test-advent-tracker.json \ No newline at end of file diff --git a/.gitignore b/.gitignore index 34cf7ea..2094f23 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,9 @@ yarn-error.log* !.env.example !.env.test -# guides tracker +# tracker guides-tracker.json +advent-of-code-tracker.json # Docker docker-compose.yml \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index bd5b017..30bcce6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: volumes: # Mount environment config file - ./.env.production:/app/.env.production:ro - # Persist guides tracker data + # Persist tracker data - guides-data:/app/data profiles: - prod diff --git a/package.json b/package.json index 5ea1b62..182916f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "check": "biome check .", "check:fix": "biome check --write .", "typecheck": "tsc --noEmit", - "test": "pnpm run build:dev && node --test dist/**/*.test.js", + "test": "tsx --test '**/*.test.ts'", "test:ci": "NODE_ENV=test node --test dist/**/*.test.js", "prepare": "husky", "pre-commit": "lint-staged", @@ -34,12 +34,14 @@ "packageManager": "pnpm@10.17.1", "dependencies": { "discord.js": "^14.22.1", + "node-cron": "^4.2.1", "typescript": "^5.9.3", "web-features": "^3.7.0" }, "devDependencies": { "@biomejs/biome": "2.2.4", "@types/node": "^24.5.2", + "@types/node-cron": "^3.0.11", "husky": "^9.1.7", "lint-staged": "^16.2.1", "tsup": "^8.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 637a258..b34e314 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: discord.js: specifier: ^14.22.1 version: 14.23.2 + node-cron: + specifier: ^4.2.1 + version: 4.2.1 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -24,6 +27,9 @@ importers: '@types/node': specifier: ^24.5.2 version: 24.8.1 + '@types/node-cron': + specifier: ^3.0.11 + version: 3.0.11 husky: specifier: ^9.1.7 version: 9.1.7 @@ -422,6 +428,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node-cron@3.0.11': + resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==} + '@types/node@24.8.1': resolution: {integrity: sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==} @@ -698,6 +707,10 @@ packages: resolution: {integrity: sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw==} engines: {node: '>=20.17'} + node-cron@4.2.1: + resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==} + engines: {node: '>=6.0.0'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1223,6 +1236,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/node-cron@3.0.11': {} + '@types/node@24.8.1': dependencies: undici-types: 7.14.0 @@ -1505,6 +1520,8 @@ snapshots: nano-spawn@2.0.0: {} + node-cron@4.2.1: {} + object-assign@4.1.1: {} onetime@7.0.0: diff --git a/src/env.ts b/src/env.ts index e97f373..ace8485 100644 --- a/src/env.ts +++ b/src/env.ts @@ -23,6 +23,7 @@ export const config = { serverId: requireEnv('SERVER_ID'), fetchAndSyncMessages: true, guidesTrackerPath: optionalEnv('GUIDES_TRACKER_PATH'), + adventOfCodeTrackerPath: requireEnv('ADVENT_OF_CODE_TRACKER_PATH'), roleIds: { moderators: requireEnv('MODERATORS_ROLE_IDS') ? requireEnv('MODERATORS_ROLE_IDS').split(',') @@ -35,6 +36,7 @@ export const config = { channelIds: { repelLogs: requireEnv('REPEL_LOG_CHANNEL_ID'), guides: requireEnv('GUIDES_CHANNEL_ID'), + adventOfCode: requireEnv('ADVENT_OF_CODE_CHANNEL_ID'), }, }; diff --git a/src/events/ready.ts b/src/events/ready.ts index a9f45f8..0afa6c7 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,5 +1,6 @@ import { Events } from 'discord.js'; import { config } from '../env.js'; +import { initializeAdventScheduler } from '../util/advent-scheduler.js'; import { fetchAndCachePublicChannelsMessages } from '../util/cache.js'; import { createEvent } from '../util/events.js'; import { syncGuidesToChannel } from '../util/post-guides.js'; @@ -44,5 +45,12 @@ export const readyEvent = createEvent( } } } + + // Initialize Advent of Code scheduler + try { + initializeAdventScheduler(client, config.channelIds.adventOfCode); + } catch (error) { + console.error('❌ Failed to initialize Advent of Code scheduler:', error); + } } ); diff --git a/src/util/advent-scheduler.test.ts b/src/util/advent-scheduler.test.ts new file mode 100644 index 0000000..2b6001d --- /dev/null +++ b/src/util/advent-scheduler.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import test from 'node:test'; +import { config } from '../env.js'; + +// Import after setting env var +const { loadTracker, saveTracker } = await import('./advent-scheduler.js'); + +async function cleanupTestTracker() { + try { + await fs.unlink(config.adventOfCodeTrackerPath); + } catch (_error) { + // File might not exist, that's fine + } +} + +test('advent scheduler: tracker file operations', async (t) => { + await t.test('should create empty tracker if file does not exist', async () => { + await cleanupTestTracker(); + const tracker = await loadTracker(); + assert.deepEqual(tracker, {}); + }); + + await t.test('should save and load tracker data correctly', async () => { + const testData = { + '2025': [1, 2, 3], + '2026': [1], + }; + await saveTracker(testData); + const loaded = await loadTracker(); + assert.deepEqual(loaded, testData); + }); + + await t.test('should track multiple days per year', async () => { + const tracker = { + '2025': [1, 5, 10, 15, 20, 25], + }; + await saveTracker(tracker); + const loaded = await loadTracker(); + assert.equal(loaded['2025'].length, 6); + assert.ok(loaded['2025'].includes(1)); + assert.ok(loaded['2025'].includes(25)); + }); + + // Cleanup + await cleanupTestTracker(); +}); diff --git a/src/util/advent-scheduler.ts b/src/util/advent-scheduler.ts new file mode 100644 index 0000000..48949ad --- /dev/null +++ b/src/util/advent-scheduler.ts @@ -0,0 +1,133 @@ +import { ChannelType, type Client } from 'discord.js'; +import * as cron from 'node-cron'; +import { promises as fs } from 'node:fs'; +import { config } from '../env.js'; + +const TRACKER_FILE = config.adventOfCodeTrackerPath; + +type TrackerData = { + [year: string]: number[]; +}; + +export async function loadTracker(): Promise { + try { + const data = await fs.readFile(TRACKER_FILE, 'utf-8'); + return JSON.parse(data); + } catch { + // If file doesn't exist or can't be read, return empty object + return {}; + } +} + +export async function saveTracker(data: TrackerData): Promise { + await fs.writeFile(TRACKER_FILE, JSON.stringify(data, null, 2), 'utf-8'); +} + +async function isDayPosted(year: number, day: number): Promise { + const tracker = await loadTracker(); + const yearData = tracker[year.toString()]; + return yearData ? yearData.includes(day) : false; +} + +async function markDayAsPosted(year: number, day: number): Promise { + const tracker = await loadTracker(); + const yearKey = year.toString(); + + if (!tracker[yearKey]) { + tracker[yearKey] = []; + } + + if (!tracker[yearKey].includes(day)) { + tracker[yearKey].push(day); + tracker[yearKey].sort((a, b) => a - b); + await saveTracker(tracker); + } +} + +async function createAdventPost( + client: Client, + channelId: string, + year: number, + day: number +): Promise { + try { + const channel = await client.channels.fetch(channelId); + + if (!channel) { + console.error(`❌ Advent of Code channel not found: ${channelId}`); + return false; + } + + if (channel.type !== ChannelType.GuildForum) { + console.error(`❌ Advent of Code channel is not a forum channel. Type: ${channel.type}`); + return false; + } + + const forumChannel = channel; + const title = `Day ${day}, ${year}`; + const content = `https://adventofcode.com/${year}/day/${day}`; + + await forumChannel.threads.create({ + name: title, + message: { + content: content, + }, + }); + + console.log(`✅ Created Advent of Code post: ${title}`); + return true; + } catch (error) { + console.error(`❌ Failed to create Advent of Code post for day ${day}:`, error); + return false; + } +} + +async function checkAndCreateTodaysPost(client: Client, channelId: string): Promise { + const now = new Date(); + const month = now.getUTCMonth(); // 0-indexed, so December is 11 + const day = now.getUTCDate(); + const year = now.getUTCFullYear(); + + if (month !== 11) { + return; + } + + if (day < 1 || day > 25) { + return; + } + + const alreadyPosted = await isDayPosted(year, day); + if (alreadyPosted) { + console.log(`ℹ️ Advent of Code post for ${year} day ${day} already exists`); + return; + } + + const success = await createAdventPost(client, channelId, year, day); + + if (success) { + await markDayAsPosted(year, day); + } +} + +/** + * Initialize the Advent of Code scheduler + * Runs every day at midnight UTC-5 and checks if we should create a post + */ +export function initializeAdventScheduler(client: Client, channelId: string): void { + console.log('🎄 Initializing Advent of Code scheduler...'); + + checkAndCreateTodaysPost(client, channelId).catch((error) => { + console.error('❌ Error checking for Advent of Code post on startup:', error); + }); + + // Schedule to run every day at midnight UTC-5 + // https://github.com/node-cron/node-cron?tab=readme-ov-file#cron-syntax + cron.schedule('0 5 * * *', () => { + console.log('⏰ Running scheduled Advent of Code check...'); + checkAndCreateTodaysPost(client, channelId).catch((error) => { + console.error('❌ Error in scheduled Advent of Code check:', error); + }); + }); + + console.log('✅ Advent of Code scheduler initialized (runs daily at midnight UTC)'); +}