a moon tracking bot for Bluesky

feat: refactor moon phase bot with improved architecture and error handling

- Add new utility functions for array operations
- Implement proper class-based services with better error handling
- Add debug mode for testing message generation
- Improve scheduler with time utilities and graceful shutdown
- Refactor message generation with configurable constants
- Add type safety with new interfaces

ewancroft.uk 2f3c1a70 4a03b27e

verified
+34
src/core/debugMode.ts
··· 1 + import { MOON_PHASES, MONTH_NAMES } from './moonPhaseConstants'; 2 + import { getPlayfulMoonMessage } from './moonPhaseMessages'; 3 + 4 + export class DebugMode { 5 + public async runDebugLoop(): Promise<void> { 6 + console.log("=== DEBUG MODE: Generating sample messages ===\n"); 7 + 8 + for (const month of MONTH_NAMES) { 9 + const monthIndex = MONTH_NAMES.indexOf(month); 10 + console.log(`\n--- ${month.toUpperCase()} ---`); 11 + 12 + for (const phase of MOON_PHASES) { 13 + const illumination = Math.random() * 100; 14 + const { message, hashtag } = getPlayfulMoonMessage(phase, illumination, monthIndex); 15 + 16 + const mockPost = { 17 + text: message, 18 + facets: [{ 19 + features: [{ 20 + $type: "app.bsky.richtext.facet#tag", 21 + tag: hashtag.replace('#', '') 22 + }] 23 + }] 24 + }; 25 + 26 + console.log(`Phase: ${phase}`); 27 + console.log(`Message: ${message}`); 28 + console.log(`Length: ${message.length} characters`); 29 + console.log(`Mock Post:`, JSON.stringify(mockPost, null, 2)); 30 + console.log('---'); 31 + } 32 + } 33 + } 34 + }
+94 -74
src/core/moonPhaseConstants.ts
··· 1 - /** 2 - * Constants related to moon phase message generation. 3 - */ 1 + export const MOON_PHASES = [ 2 + "New Moon", "Waxing Crescent", "First Quarter", "Waxing Gibbous", 3 + "Full Moon", "Waning Gibbous", "Last Quarter", "Waning Crescent" 4 + ] as const; 4 5 5 - export const monthNames = [ 6 + export const MONTH_NAMES = [ 6 7 "January", "February", "March", "April", "May", "June", 7 8 "July", "August", "September", "October", "November", "December" 8 - ]; 9 + ] as const; 9 10 10 - export const lycanthropicPhrases = [ 11 + export const LYCANTHROPIC_PHRASES = [ 11 12 "Awooo!", 12 13 "Call of the wild is strong tonight.", 13 14 "Feel the lunar pull?", 14 15 "Unleash your inner beast!", 15 16 "Howl at the moon!", 16 17 "Night is young, moon is bright.", 17 - "Beware the moon, lads.", // hehehe, aawil reference lol 18 + "Beware the moon, lads.", 18 19 "Proper lunar spectacle.", 19 20 "Moon's got a hold on me.", 20 21 "Lunar madness is upon us.", ··· 23 24 "Embrace the lunar energy.", 24 25 "Lost in the moon's embrace.", 25 26 "The night belongs to the moon." 26 - ]; 27 + ] as const; 27 28 28 - export const britishReferences = [ 29 + export const BRITISH_REFERENCES = [ 29 30 "Fancy a cuppa under its glow?", 30 31 "Right then, moonlit stroll.", 31 32 "Bloody hell, what a moon!", ··· 40 41 "A right good show from the heavens.", 41 42 "Stiff upper lip, even under a full moon.", 42 43 "Mind the gap, and the moon's glow." 43 - ]; 44 + ] as const; 44 45 45 - export const prideReferences = [ 46 + export const PRIDE_REFERENCES = [ 46 47 "Love wins, even under the moon!", 47 48 "Shine bright, shine proud!", 48 49 "Moonbeams & rainbows for all!", ··· 53 54 "Our love shines as bright as the moon.", 54 55 "United under the lunar rainbow.", 55 56 "Freedom to love, illuminated by the moon." 56 - ]; 57 + ] as const; 57 58 58 - export const monthFlairs: { [key: string]: string[] } = { 59 - "January": [ 60 - `Frosty start to lunar year!`, 61 - `New year, new moon, same great British charm!`, 62 - `Chilly nights, bright moon.`, 63 - `The Wolf Moon howls!` 59 + export const MONTH_FLAIRS: Record<string, readonly string[]> = { 60 + January: [ 61 + "Frosty start to lunar year!", 62 + "New year, new moon, same great British charm!", 63 + "Chilly nights, bright moon.", 64 + "The Wolf Moon howls!" 64 65 ], 65 - "February": [ 66 - `Love in the air, so is the moon!`, 67 - `A romantic moon for the shortest month.`, 68 - `Cupid's arrow, guided by moonlight.`, 69 - `The Snow Moon blankets the sky!` 66 + February: [ 67 + "Love in the air, so is the moon!", 68 + "A romantic moon for the shortest month.", 69 + "Cupid's arrow, guided by moonlight.", 70 + "The Snow Moon blankets the sky!" 70 71 ], 71 - "March": [ 72 - `Spring is here, moon is blooming!`, 73 - `As March roars in, the moon glows on.`, 74 - `New beginnings under the vernal moon.`, 75 - `The Worm Moon signals new life!` 72 + March: [ 73 + "Spring is here, moon is blooming!", 74 + "As March roars in, the moon glows on.", 75 + "New beginnings under the vernal moon.", 76 + "The Worm Moon signals new life!" 76 77 ], 77 - "April": [ 78 - `April showers bring lunar powers!`, 79 - `Don't let the rain obscure this lovely moon!`, 80 - `Spring has truly sprung, and so has the moon!`, 81 - `The Pink Moon is a sight to behold!` 78 + April: [ 79 + "April showers bring lunar powers!", 80 + "Don't let the rain obscure this lovely moon!", 81 + "Spring has truly sprung, and so has the moon!", 82 + "The Pink Moon is a sight to behold!" 82 83 ], 83 - "May": [ 84 - `May the moon be with you!`, 85 - `A blooming good moon for May!`, 86 - `Longer days, beautiful moonlit nights.`, 87 - `The Flower Moon is in full bloom!` 84 + May: [ 85 + "May the moon be with you!", 86 + "A blooming good moon for May!", 87 + "Longer days, beautiful moonlit nights.", 88 + "The Flower Moon is in full bloom!" 88 89 ], 89 - "June": [ 90 - `Summer nights & moonlit delights!`, 91 - `June's moon, perfect for long evenings.`, 92 - `The summer solstice moon is here!`, 93 - `The Strawberry Moon ripens!` 90 + June: [ 91 + "Summer nights & moonlit delights!", 92 + "June's moon, perfect for long evenings.", 93 + "The summer solstice moon is here!", 94 + "The Strawberry Moon ripens!" 94 95 ], 95 - "July": [ 96 - `Hot summer, cool moon!`, 97 - `July's moon, a real scorcher!`, 98 - `Midsummer moon, shining bright.`, 99 - `The Buck Moon is majestic!` 96 + July: [ 97 + "Hot summer, cool moon!", 98 + "July's moon, a real scorcher!", 99 + "Midsummer moon, shining bright.", 100 + "The Buck Moon is majestic!" 100 101 ], 101 - "August": [ 102 - `Starry August night, moon our guide!`, 103 - `August's moon, a late summer treat.`, 104 - `Harvest moon on the horizon!`, 105 - `The Sturgeon Moon swims into view!` 102 + August: [ 103 + "Starry August night, moon our guide!", 104 + "August's moon, a late summer treat.", 105 + "Harvest moon on the horizon!", 106 + "The Sturgeon Moon swims into view!" 106 107 ], 107 - "September": [ 108 - `Autumn leaves & moonlit dreams!`, 109 - `September's moon, a touch of autumn.`, 110 - `Crisp air, clear moon.`, 111 - `The Harvest Moon brings abundance!` 108 + September: [ 109 + "Autumn leaves & moonlit dreams!", 110 + "September's moon, a touch of autumn.", 111 + "Crisp air, clear moon.", 112 + "The Harvest Moon brings abundance!" 112 113 ], 113 - "October": [ 114 - `Spooky season moon vibes!`, 115 - `October's moon, perfect for ghostly tales.`, 116 - `A hauntingly beautiful moon!`, 117 - `The Hunter's Moon guides the way!` 114 + October: [ 115 + "Spooky season moon vibes!", 116 + "October's moon, perfect for ghostly tales.", 117 + "A hauntingly beautiful moon!", 118 + "The Hunter's Moon guides the way!" 118 119 ], 119 - "November": [ 120 - `Giving thanks for this beautiful moon!`, 121 - `November's moon, a prelude to winter.`, 122 - `Chilly nights, warm moonlit thoughts.`, 123 - `The Beaver Moon builds its presence!` 120 + November: [ 121 + "Giving thanks for this beautiful moon!", 122 + "November's moon, a prelude to winter.", 123 + "Chilly nights, warm moonlit thoughts.", 124 + "The Beaver Moon builds its presence!" 124 125 ], 125 - "December": [ 126 - `Winter wonderland, moon shining bright!`, 127 - `December's moon, festive and bright.`, 128 - `A truly magical moon for the holidays!`, 129 - `The Cold Moon brings winter's embrace!` 126 + December: [ 127 + "Winter wonderland, moon shining bright!", 128 + "December's moon, festive and bright.", 129 + "A truly magical moon for the holidays!", 130 + "The Cold Moon brings winter's embrace!" 130 131 ] 131 - }; 132 + } as const; 133 + 134 + export const PHASE_CONFIG = { 135 + "New Moon": { emoji: "🌑", hashtag: "#NewMoon" }, 136 + "Waxing Crescent": { emoji: "🌒", hashtag: "#WaxingCrescent" }, 137 + "First Quarter": { emoji: "🌓", hashtag: "#FirstQuarter" }, 138 + "Waxing Gibbous": { emoji: "🌔", hashtag: "#WaxingGibbous" }, 139 + "Full Moon": { emoji: "🌕", hashtag: "#FullMoon" }, 140 + "Waning Gibbous": { emoji: "🌖", hashtag: "#WaningGibbous" }, 141 + "Last Quarter": { emoji: "🌗", hashtag: "#LastQuarter" }, 142 + "Waning Crescent": { emoji: "🌘", hashtag: "#WaningCrescent" } 143 + } as const; 144 + 145 + export const MESSAGE_CONFIG = { 146 + MAX_LENGTH: 300, 147 + TRUNCATE_SUFFIX: "...", 148 + MONTH_FLAIR_CHANCE: 0.5, 149 + BRITISH_REFERENCE_CHANCE: 0.5, 150 + PRIDE_REFERENCE_CHANCE_JUNE: 0.7 151 + } as const;
+82 -91
src/core/moonPhaseMessages.ts
··· 1 - import { monthNames, lycanthropicPhrases, britishReferences, prideReferences, monthFlairs } from './moonPhaseConstants'; 1 + import { 2 + MONTH_NAMES, 3 + LYCANTHROPIC_PHRASES, 4 + BRITISH_REFERENCES, 5 + PRIDE_REFERENCES, 6 + MONTH_FLAIRS, 7 + PHASE_CONFIG, 8 + MESSAGE_CONFIG 9 + } from './moonPhaseConstants'; 10 + import { getRandomElement, shuffleArray } from '../utils/arrayUtils'; 11 + import type { MoonMessage } from '../types/moonPhase'; 2 12 3 - /** 4 - * Generates a playful message about the moon phase and illumination, considering the month. 5 - * @param phase The name of the moon phase (e.g., "New Moon", "Full Moon"). 6 - * @param illumination The illumination percentage of the moon (0-100). 7 - * @param monthIndex The 0-indexed month (0 for January, 11 for December). 8 - * @returns A playful string describing the moon, limited to 300 characters. 9 - */ 10 - export function getPlayfulMoonMessage( 11 - phase: string, 12 - illumination: number, 13 - monthIndex: number 14 - ): { message: string; hashtag: string } { 15 - const illuminationFixed = illumination.toFixed(1); 16 - const currentMonth = monthNames[monthIndex]; 13 + export class MoonMessageGenerator { 14 + private getBaseMessage(phase: string, illumination: number): string { 15 + const illuminationFixed = illumination.toFixed(1); 16 + const config = PHASE_CONFIG[phase as keyof typeof PHASE_CONFIG]; 17 + 18 + if (!config) { 19 + throw new Error(`Unknown moon phase: ${phase}`); 20 + } 17 21 18 - const getRandomElement = (arr: string[]) => arr[Math.floor(Math.random() * arr.length)]; 22 + const messages = { 23 + "New Moon": `It's a New Moon, barely a whisper! Illumination: ${illuminationFixed}%.`, 24 + "Waxing Crescent": `Look up! Waxing Crescent, brighter at ${illuminationFixed}%.`, 25 + "First Quarter": `Halfway to full! First Quarter moon ${illuminationFixed}% lit.`, 26 + "Waxing Gibbous": `Waxing Gibbous almost full, glowing at ${illuminationFixed}%!`, 27 + "Full Moon": `By Jove, a magnificent Full Moon! ${illuminationFixed}% light.`, 28 + "Waning Gibbous": `Waning Gibbous gracefully fading, ${illuminationFixed}% illuminated.`, 29 + "Last Quarter": `Last Quarter moon, ${illuminationFixed}% visible!`, 30 + "Waning Crescent": `Waning Crescent, tiny sliver, ${illuminationFixed}% lit.` 31 + }; 19 32 20 - let baseMessage = `The moon is a ${phase} today, shining at ${illuminationFixed}%!`; 21 - let emoji = ''; 22 - let hashtag = '#MoonPhase'; 23 - 24 - // Determine emoji and specific hashtag based on phase 25 - switch (phase) { 26 - case "New Moon": 27 - emoji = '🌑'; 28 - hashtag = '#NewMoon'; 29 - baseMessage = `It's a New Moon, barely a whisper! Illumination: ${illuminationFixed}%. ${getRandomElement(lycanthropicPhrases)}`; 30 - break; 31 - case "Waxing Crescent": 32 - emoji = '🌒'; 33 - hashtag = '#WaxingCrescent'; 34 - baseMessage = `Look up! Waxing Crescent, brighter at ${illuminationFixed}%. ${getRandomElement(lycanthropicPhrases)}`; 35 - break; 36 - case "First Quarter": 37 - emoji = '🌓'; 38 - hashtag = '#FirstQuarter'; 39 - baseMessage = `Halfway to full! First Quarter moon ${illuminationFixed}% lit. ${getRandomElement(lycanthropicPhrases)}`; 40 - break; 41 - case "Waxing Gibbous": 42 - emoji = '🌔'; 43 - hashtag = '#WaxingGibbous'; 44 - baseMessage = `Waxing Gibbous almost full, glowing at ${illuminationFixed}%! ${getRandomElement(lycanthropicPhrases)}`; 45 - break; 46 - case "Full Moon": 47 - emoji = '🌕'; 48 - hashtag = '#FullMoon'; 49 - baseMessage = `By Jove, a magnificent Full Moon! ${illuminationFixed}% light. ${getRandomElement(lycanthropicPhrases)}`; 50 - break; 51 - case "Waning Gibbous": 52 - emoji = '🌖'; 53 - hashtag = '#WaningGibbous'; 54 - baseMessage = `Waning Gibbous gracefully fading, ${illuminationFixed}% illuminated. ${getRandomElement(lycanthropicPhrases)}`; 55 - break; 56 - case "Last Quarter": 57 - emoji = '🌗'; 58 - hashtag = '#LastQuarter'; 59 - baseMessage = `Last Quarter moon, ${illuminationFixed}% visible! ${getRandomElement(lycanthropicPhrases)}`; 60 - break; 61 - case "Waning Crescent": 62 - emoji = '🌘'; 63 - hashtag = '#WaningCrescent'; 64 - baseMessage = `Waning Crescent, tiny sliver, ${illuminationFixed}% lit. ${getRandomElement(lycanthropicPhrases)}`; 65 - break; 33 + const baseMessage = messages[phase as keyof typeof messages]; 34 + const lycanthropicPhrase = getRandomElement(LYCANTHROPIC_PHRASES); 35 + 36 + return `${config.emoji} ${baseMessage} ${lycanthropicPhrase}`; 66 37 } 67 38 68 - // Prepend emoji to the base message 69 - baseMessage = `${emoji} ${baseMessage}`; 39 + private getAdditionalMessages(monthIndex: number): string[] { 40 + const currentMonth = MONTH_NAMES[monthIndex]; 41 + const additionalMessages: string[] = []; 70 42 71 - // Ensure baseMessage ends with a space for proper concatenation 72 - baseMessage += ' '; 43 + // Add month-specific flair 44 + if (Math.random() < MESSAGE_CONFIG.MONTH_FLAIR_CHANCE && MONTH_FLAIRS[currentMonth]) { 45 + additionalMessages.push(getRandomElement(MONTH_FLAIRS[currentMonth])); 46 + } 73 47 74 - let additionalMessageParts: string[] = []; 48 + // Add British reference 49 + if (Math.random() < MESSAGE_CONFIG.BRITISH_REFERENCE_CHANCE) { 50 + additionalMessages.push(getRandomElement(BRITISH_REFERENCES)); 51 + } 75 52 76 - // Add month-specific flair with a 50% chance 77 - if (Math.random() < 0.5 && monthFlairs[currentMonth]) { // 50% chance to add month flair 78 - additionalMessageParts.push(getRandomElement(monthFlairs[currentMonth])); 79 - } 53 + // Add Pride reference for June 54 + if (currentMonth === "June" && Math.random() < MESSAGE_CONFIG.PRIDE_REFERENCE_CHANCE_JUNE) { 55 + additionalMessages.push(getRandomElement(PRIDE_REFERENCES)); 56 + } 80 57 81 - // Add a British reference with a 50% chance 82 - if (Math.random() < 0.5) { // 50% chance to add British reference 83 - additionalMessageParts.push(getRandomElement(britishReferences)); 58 + return shuffleArray(additionalMessages); 84 59 } 85 60 86 - // Add a Pride reference for June with a 70% chance 87 - if (currentMonth === "June" && Math.random() < 0.7) { // 70% chance to add a Pride reference in June 88 - additionalMessageParts.push(getRandomElement(prideReferences)); 61 + private truncateMessage(message: string): string { 62 + if (message.length <= MESSAGE_CONFIG.MAX_LENGTH) { 63 + return message; 64 + } 65 + 66 + const truncateLength = MESSAGE_CONFIG.MAX_LENGTH - MESSAGE_CONFIG.TRUNCATE_SUFFIX.length; 67 + return message.substring(0, truncateLength) + MESSAGE_CONFIG.TRUNCATE_SUFFIX; 89 68 } 90 69 91 - // Join additional parts with a space, if any exist 92 - // Randomise the order of additional message parts 93 - for (let i = additionalMessageParts.length - 1; i > 0; i--) { 94 - const j = Math.floor(Math.random() * (i + 1)); 95 - [additionalMessageParts[i], additionalMessageParts[j]] = [additionalMessageParts[j], additionalMessageParts[i]]; 96 - } 70 + public generateMessage(phase: string, illumination: number, monthIndex: number): MoonMessage { 71 + if (monthIndex < 0 || monthIndex > 11) { 72 + throw new Error(`Invalid month index: ${monthIndex}. Must be between 0 and 11.`); 73 + } 97 74 98 - let finalMessage = `${baseMessage}${additionalMessageParts.join(' ')}`; 75 + const config = PHASE_CONFIG[phase as keyof typeof PHASE_CONFIG]; 76 + if (!config) { 77 + throw new Error(`Unknown moon phase: ${phase}`); 78 + } 99 79 100 - // Append hashtag to the end of the message 101 - finalMessage = `${finalMessage} ${hashtag}`; 80 + const baseMessage = this.getBaseMessage(phase, illumination); 81 + const additionalMessages = this.getAdditionalMessages(monthIndex); 82 + 83 + const fullMessage = [baseMessage, ...additionalMessages, config.hashtag].join(' '); 84 + const truncatedMessage = this.truncateMessage(fullMessage); 102 85 103 - // Ensure message is within 300 characters 104 - if (finalMessage.length > 300) { 105 - finalMessage = finalMessage.substring(0, 297) + "..."; 86 + return { 87 + message: truncatedMessage, 88 + hashtag: config.hashtag 89 + }; 106 90 } 91 + } 107 92 108 - return { message: finalMessage, hashtag: hashtag }; 109 - } 93 + export function getPlayfulMoonMessage( 94 + phase: string, 95 + illumination: number, 96 + monthIndex: number 97 + ): MoonMessage { 98 + const generator = new MoonMessageGenerator(); 99 + return generator.generateMessage(phase, illumination, monthIndex); 100 + }
+49
src/core/scheduler.ts
··· 1 + import { getDelayUntilNextMidnightUTC, formatTimeRemaining, isTimeForPost, hasPassedMidnight } from './timeUtils'; 2 + import { postMoonPhaseToBluesky } from '../services/blueskyService'; 3 + 4 + export class PostScheduler { 5 + private isRunning = false; 6 + 7 + public async start(): Promise<void> { 8 + if (this.isRunning) { 9 + console.warn("Scheduler is already running"); 10 + return; 11 + } 12 + 13 + this.isRunning = true; 14 + console.log("Starting moon phase post scheduler"); 15 + 16 + while (this.isRunning) { 17 + try { 18 + if (isTimeForPost()) { 19 + console.log("It's 00:00 UTC. Posting now..."); 20 + await postMoonPhaseToBluesky(); 21 + } else if (hasPassedMidnight()) { 22 + console.log("It's already past 00:00 UTC. Posting now for today..."); 23 + await postMoonPhaseToBluesky(); 24 + } else { 25 + console.log("Waiting for 00:00 UTC to post..."); 26 + } 27 + 28 + const delay = getDelayUntilNextMidnightUTC(); 29 + const timeRemaining = formatTimeRemaining(delay); 30 + console.log(`Next post scheduled in ${timeRemaining} (at 00:00 UTC).`); 31 + 32 + await this.sleep(delay); 33 + } catch (error) { 34 + console.error("Error in scheduler loop:", error); 35 + // Wait 5 minutes before retrying 36 + await this.sleep(5 * 60 * 1000); 37 + } 38 + } 39 + } 40 + 41 + public stop(): void { 42 + this.isRunning = false; 43 + console.log("Scheduler stopped"); 44 + } 45 + 46 + private sleep(ms: number): Promise<void> { 47 + return new Promise(resolve => setTimeout(resolve, ms)); 48 + } 49 + }
+18 -5
src/core/timeUtils.ts
··· 1 - /** 2 - * Calculates the delay until the next 00:00 UTC. 3 - * @returns The delay in milliseconds. 4 - */ 5 1 export function getDelayUntilNextMidnightUTC(): number { 6 2 const now = new Date(); 7 3 const nextMidnightUTC = new Date( 8 4 Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1, 0, 0, 0, 0) 9 5 ); 10 6 return nextMidnightUTC.getTime() - now.getTime(); 11 - } 7 + } 8 + 9 + export function formatTimeRemaining(milliseconds: number): string { 10 + const hours = Math.floor(milliseconds / (1000 * 60 * 60)); 11 + const minutes = Math.floor((milliseconds % (1000 * 60 * 60)) / (1000 * 60)); 12 + const seconds = Math.floor((milliseconds % (1000 * 60)) / 1000); 13 + return `${hours} hours, ${minutes} minutes, and ${seconds} seconds`; 14 + } 15 + 16 + export function isTimeForPost(): boolean { 17 + const now = new Date(); 18 + return now.getUTCHours() === 0 && now.getUTCMinutes() === 0; 19 + } 20 + 21 + export function hasPassedMidnight(): boolean { 22 + const now = new Date(); 23 + return now.getUTCHours() > 0; 24 + }
+54 -63
src/index.ts
··· 1 - import * as process from "process"; 2 - import { postMoonPhaseToBluesky } from "./services/blueskyService"; 3 - import { getDelayUntilNextMidnightUTC } from "./core/timeUtils"; 4 - import { getPlayfulMoonMessage } from "./core/moonPhaseMessages"; 5 1 import * as dotenv from "dotenv"; 2 + import { PostScheduler } from "./core/scheduler"; 3 + import { DebugMode } from "./core/debugMode"; 4 + import { postMoonPhaseToBluesky } from "./services/blueskyService"; 6 5 7 - // Load environment variables from the config.env file 6 + // Load environment variables 8 7 dotenv.config({ path: "./src/config.env" }); 9 8 10 - const DEBUG_MODE = process.env.DEBUG_MODE === "true"; 9 + class MoonPhaseBot { 10 + private readonly isDebugMode: boolean; 11 + private readonly hasCredentials: boolean; 11 12 12 - async function debugLoop() { 13 - const phases = [ 14 - "New Moon", "Waxing Crescent", "First Quarter", "Waxing Gibbous", 15 - "Full Moon", "Waning Gibbous", "Last Quarter", "Waning Crescent" 16 - ]; 17 - const months = [ 18 - "January", "February", "March", "April", "May", "June", 19 - "July", "August", "September", "October", "November", "December" 20 - ]; 13 + constructor() { 14 + this.isDebugMode = process.env.DEBUG_MODE === "true"; 15 + this.hasCredentials = !!(process.env.BLUESKY_USERNAME && process.env.BLUESKY_PASSWORD); 16 + } 17 + 18 + public async run(): Promise<void> { 19 + console.log("🌙 Moon Phase Bot Starting..."); 20 + console.log(`Debug Mode: ${this.isDebugMode ? 'ON' : 'OFF'}`); 21 + console.log(`Credentials Available: ${this.hasCredentials ? 'YES' : 'NO'}`); 21 22 22 - for (const month of months) { 23 - const monthIndex = months.indexOf(month); 24 - for (const phase of phases) { 25 - const illumination = Math.random() * 100; // Random illumination for debug 26 - const { message, hashtag } = getPlayfulMoonMessage(phase, illumination, monthIndex); 27 - const mockPost = { 28 - text: message, 29 - facets: [{ 30 - features: [{ 31 - $type: "app.bsky.richtext.facet#tag", 32 - tag: hashtag 33 - }] 34 - }] 35 - }; 36 - console.log(`[DEBUG] Mock Bluesky Post (Phase: ${phase}, Month: ${month}):`); 37 - console.log(JSON.stringify(mockPost, null, 2)); 38 - console.log(); 23 + try { 24 + if (this.isDebugMode) { 25 + await this.runDebugMode(); 26 + } else { 27 + await this.runProductionMode(); 28 + } 29 + } catch (error) { 30 + console.error("Fatal error:", error); 31 + process.exit(1); 39 32 } 40 33 } 41 - } 42 34 43 - async function runLoop() { 44 - if (DEBUG_MODE) { 45 - console.log("Starting in DEBUG MODE."); 46 - if (process.env.BLUESKY_USERNAME && process.env.BLUESKY_PASSWORD) { 47 - console.log("Bluesky credentials found. Attempting to post immediately..."); 35 + private async runDebugMode(): Promise<void> { 36 + if (this.hasCredentials) { 37 + console.log("Debug mode with credentials - posting immediately..."); 48 38 await postMoonPhaseToBluesky(); 49 39 } else { 50 - console.log("No Bluesky credentials found. Messages will be logged to console."); 51 - debugLoop(); 40 + console.log("Debug mode without credentials - generating sample messages..."); 41 + const debugMode = new DebugMode(); 42 + await debugMode.runDebugLoop(); 52 43 } 53 - return; // Exit runLoop as debugLoop or immediate post handles logging 54 44 } 55 45 56 - while (true) { 57 - const now = new Date(); 58 - const currentUTCHours = now.getUTCHours(); 59 - const currentUTCMinutes = now.getUTCMinutes(); 60 - 61 - if (currentUTCHours === 0 && currentUTCMinutes === 0) { 62 - console.log("It's 00:00 UTC. Posting now..."); 63 - await postMoonPhaseToBluesky(); 64 - } else if (currentUTCHours > 0) { 65 - console.log("It's already past 00:00 UTC. Posting now for today..."); 66 - await postMoonPhaseToBluesky(); 67 - } else { 68 - console.log("Waiting for 00:00 UTC to post..."); 46 + private async runProductionMode(): Promise<void> { 47 + if (!this.hasCredentials) { 48 + throw new Error("Production mode requires BLUESKY_USERNAME and BLUESKY_PASSWORD environment variables"); 69 49 } 70 50 71 - const delay = getDelayUntilNextMidnightUTC(); 72 - const hours = Math.floor(delay / (1000 * 60 * 60)); 73 - const minutes = Math.floor((delay % (1000 * 60 * 60)) / (1000 * 60)); 74 - const seconds = Math.floor((delay % (1000 * 60)) / 1000); 51 + const scheduler = new PostScheduler(); 52 + 53 + // Handle graceful shutdown 54 + process.on('SIGINT', () => { 55 + console.log("\nReceived SIGINT, shutting down gracefully..."); 56 + scheduler.stop(); 57 + process.exit(0); 58 + }); 75 59 76 - console.log( 77 - `Next post scheduled in ${hours} hours, ${minutes} minutes, and ${seconds} seconds (at 00:00 UTC).` 78 - ); 60 + process.on('SIGTERM', () => { 61 + console.log("\nReceived SIGTERM, shutting down gracefully..."); 62 + scheduler.stop(); 63 + process.exit(0); 64 + }); 79 65 80 - await new Promise((resolve) => setTimeout(resolve, delay)); 66 + await scheduler.start(); 81 67 } 82 68 } 83 69 84 - runLoop().catch((error) => console.error("Error in run loop:", error)); 70 + // Entry point 71 + const bot = new MoonPhaseBot(); 72 + bot.run().catch(error => { 73 + console.error("Unhandled error:", error); 74 + process.exit(1); 75 + });
+129 -107
src/services/blueskyService.ts
··· 1 - import * as process from "process"; 1 + import { BskyAgent, RichText, AppBskyRichtextFacet } from "@atproto/api"; 2 2 import { getMoonPhase } from "./moonPhaseService"; 3 3 import { getPlayfulMoonMessage } from "../core/moonPhaseMessages"; 4 - import { BskyAgent, RichText, AppBskyRichtextFacet } from "@atproto/api"; 4 + import type { BlueskyPost } from '../types/moonPhase'; 5 5 6 - /** 7 - * Manually creates a hashtag facet for the given text and hashtag 8 - */ 9 - function createHashtagFacet(text: string, hashtag: string): AppBskyRichtextFacet.Main | null { 10 - const hashtagWithHash = hashtag.startsWith('#') ? hashtag : `#${hashtag}`; 11 - const hashtagIndex = text.lastIndexOf(hashtagWithHash); 12 - 13 - if (hashtagIndex === -1) { 14 - return null; 6 + export class BlueskyService { 7 + private agent: BskyAgent; 8 + private readonly pdsUrl: string; 9 + 10 + constructor(pdsUrl: string = "https://bsky.social") { 11 + this.pdsUrl = pdsUrl; 12 + this.agent = new BskyAgent({ service: this.pdsUrl }); 15 13 } 16 14 17 - // Use TextEncoder to get proper UTF-8 byte offsets 18 - const encoder = new TextEncoder(); 19 - const beforeHashtag = text.substring(0, hashtagIndex); 20 - const hashtagText = hashtagWithHash; 21 - 22 - const byteStart = encoder.encode(beforeHashtag).length; 23 - const byteEnd = byteStart + encoder.encode(hashtagText).length; 15 + private validateCredentials(): { username: string; password: string } { 16 + const username = process.env.BLUESKY_USERNAME; 17 + const password = process.env.BLUESKY_PASSWORD; 24 18 25 - return { 26 - index: { 27 - byteStart, 28 - byteEnd, 29 - }, 30 - features: [{ 31 - $type: 'app.bsky.richtext.facet#tag', 32 - tag: hashtag.replace(/^#/, ''), // Remove # from the tag value 33 - }], 34 - }; 35 - } 19 + if (!username || !password) { 20 + throw new Error("Missing required environment variables: BLUESKY_USERNAME and BLUESKY_PASSWORD"); 21 + } 36 22 37 - export async function postMoonPhaseToBluesky() { 38 - console.log("Attempting to post moon phase to Bluesky."); 39 - 40 - // Check for empty environment variables and abort if needed 41 - if (!process.env.BLUESKY_USERNAME || !process.env.BLUESKY_PASSWORD) { 42 - console.error( 43 - "Missing required environment variables: BLUESKY_USERNAME and BLUESKY_PASSWORD.\nAborting script." 44 - ); 45 - process.exit(1); 23 + return { username, password }; 46 24 } 47 25 48 - const pdsUrl = process.env.BLUESKY_PDS_URL || "https://bsky.social"; 49 - const agent = new BskyAgent({ service: pdsUrl }); 26 + private createHashtagFacet(text: string, hashtag: string): AppBskyRichtextFacet.Main | null { 27 + const hashtagWithHash = hashtag.startsWith('#') ? hashtag : `#${hashtag}`; 28 + const hashtagIndex = text.lastIndexOf(hashtagWithHash); 29 + 30 + if (hashtagIndex === -1) { 31 + console.warn(`Hashtag ${hashtagWithHash} not found in text`); 32 + return null; 33 + } 50 34 51 - try { 52 - // Login to Bluesky 53 - await agent.login({ 54 - identifier: process.env.BLUESKY_USERNAME!, 55 - password: process.env.BLUESKY_PASSWORD!, 56 - }); 57 - console.log("Logged in to Bluesky."); 35 + const encoder = new TextEncoder(); 36 + const beforeHashtag = text.substring(0, hashtagIndex); 37 + const hashtagText = hashtagWithHash; 38 + 39 + const byteStart = encoder.encode(beforeHashtag).length; 40 + const byteEnd = byteStart + encoder.encode(hashtagText).length; 58 41 59 - // Get moon phase data 60 - const moonPhaseData = await getMoonPhase(); 42 + return { 43 + index: { byteStart, byteEnd }, 44 + features: [{ 45 + $type: 'app.bsky.richtext.facet#tag', 46 + tag: hashtag.replace(/^#/, ''), 47 + }], 48 + }; 49 + } 50 + 51 + private async processRichText(postText: string, hashtag: string): Promise<RichText> { 52 + const rt = new RichText({ text: postText }); 53 + 54 + // Detect facets automatically 55 + await rt.detectFacets(this.agent); 61 56 62 - if (moonPhaseData) { 63 - const { message: postText, hashtag } = getPlayfulMoonMessage( 64 - moonPhaseData.Phase, 65 - moonPhaseData.Illumination * 100, 66 - new Date().getMonth() 57 + // Manually ensure hashtag facet is correct 58 + const hashtagFacet = this.createHashtagFacet(postText, hashtag); 59 + 60 + if (hashtagFacet) { 61 + const existingHashtagFacet = rt.facets?.find(facet => 62 + facet.features.some(feature => 63 + feature.$type === 'app.bsky.richtext.facet#tag' && 64 + feature.tag === hashtag.replace(/^#/, '') 65 + ) 67 66 ); 68 67 69 - // Create RichText object 70 - const rt = new RichText({ 71 - text: postText, 72 - }); 73 - 74 - // First, detect facets automatically (for links, mentions, etc.) 75 - await rt.detectFacets(agent); 68 + if (!existingHashtagFacet) { 69 + rt.facets = [...(rt.facets || []), hashtagFacet]; 70 + console.log("Manually added hashtag facet"); 71 + } else { 72 + console.log("Hashtag facet already detected automatically"); 73 + } 74 + } 75 + 76 + // Sort facets by byteStart 77 + if (rt.facets && rt.facets.length > 1) { 78 + rt.facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 79 + } 76 80 77 - // Then, manually ensure hashtag facet is correct 78 - const hashtagFacet = createHashtagFacet(postText, hashtag); 81 + return rt; 82 + } 83 + 84 + private createPostRecord(rt: RichText): BlueskyPost { 85 + return { 86 + text: rt.text, 87 + facets: rt.facets, 88 + langs: ["en"], 89 + createdAt: new Date().toISOString(), 90 + }; 91 + } 92 + 93 + private logDebugInfo(postText: string, rt: RichText): void { 94 + if (process.env.DEBUG_MODE === "true") { 95 + console.log("Final facets sent with post:"); 96 + console.log(JSON.stringify(rt.facets, null, 2)); 79 97 80 - if (hashtagFacet) { 81 - // Check if hashtag facet already exists from automatic detection 82 - const existingHashtagFacet = rt.facets?.find(facet => 83 - facet.features.some(feature => 84 - feature.$type === 'app.bsky.richtext.facet#tag' && 85 - feature.tag === hashtag.replace(/^#/, '') 86 - ) 87 - ); 98 + const encoder = new TextEncoder(); 99 + const utf8Bytes = encoder.encode(postText); 100 + console.log(`Post text UTF-8 length: ${utf8Bytes.length} bytes`); 101 + console.log(`Post text character length: ${postText.length} characters`); 102 + } 103 + } 88 104 89 - if (!existingHashtagFacet) { 90 - // Add our manually created hashtag facet 91 - rt.facets = [...(rt.facets || []), hashtagFacet]; 92 - console.log("Manually added hashtag facet."); 93 - } else { 94 - console.log("Hashtag facet already detected automatically."); 95 - } 96 - } 105 + public async login(): Promise<void> { 106 + const { username, password } = this.validateCredentials(); 107 + 108 + try { 109 + await this.agent.login({ identifier: username, password }); 110 + console.log("Successfully logged in to Bluesky"); 111 + } catch (error) { 112 + console.error("Failed to login to Bluesky:", error); 113 + throw new Error("Bluesky authentication failed"); 114 + } 115 + } 97 116 98 - // Sort facets by byteStart to ensure proper ordering 99 - if (rt.facets && rt.facets.length > 1) { 100 - rt.facets.sort((a, b) => a.index.byteStart - b.index.byteStart); 117 + public async postMoonPhase(): Promise<void> { 118 + console.log("Attempting to post moon phase to Bluesky"); 119 + 120 + try { 121 + await this.login(); 122 + 123 + const moonPhaseData = await getMoonPhase(); 124 + if (!moonPhaseData) { 125 + throw new Error("Could not retrieve moon phase data"); 101 126 } 102 127 103 - // Post the moon phase information to Bluesky 104 - const postRecord = { 105 - text: rt.text, 106 - facets: rt.facets, 107 - langs: ["en"], 108 - createdAt: new Date().toISOString(), 109 - }; 128 + const { message: postText, hashtag } = getPlayfulMoonMessage( 129 + moonPhaseData.Phase, 130 + moonPhaseData.Illumination * 100, 131 + new Date().getMonth() 132 + ); 133 + 134 + const rt = await this.processRichText(postText, hashtag); 135 + const postRecord = this.createPostRecord(rt); 110 136 111 - await agent.post(postRecord); 112 - console.log("Just posted:", postText); 137 + await this.agent.post(postRecord); 138 + console.log("Successfully posted:", postText); 113 139 114 - // Debug: Log the facets that were sent 115 - if (process.env.DEBUG_MODE === "true") { 116 - console.log("Final facets sent with post:"); 117 - console.log(JSON.stringify(rt.facets, null, 2)); 118 - 119 - // Verify UTF-8 encoding 120 - const encoder = new TextEncoder(); 121 - const utf8Bytes = encoder.encode(postText); 122 - console.log(`Post text UTF-8 length: ${utf8Bytes.length} bytes`); 123 - console.log(`Post text character length: ${postText.length} characters`); 124 - } 125 - } else { 126 - console.log("Could not retrieve moon phase data to post."); 140 + this.logDebugInfo(postText, rt); 141 + 142 + } catch (error) { 143 + console.error("Error during Bluesky posting process:", error); 144 + throw error; 127 145 } 128 - } catch (error) { 129 - console.error("Error during Bluesky posting process:", error); 130 146 } 131 - } 147 + } 148 + 149 + export async function postMoonPhaseToBluesky(): Promise<void> { 150 + const pdsUrl = process.env.BLUESKY_PDS_URL || "https://bsky.social"; 151 + const service = new BlueskyService(pdsUrl); 152 + await service.postMoonPhase(); 153 + }
+71 -20
src/services/moonPhaseService.ts
··· 1 1 import axios from "axios"; 2 + import type { MoonPhaseData } from '../types/moonPhase'; 2 3 3 - /** 4 - * Fetches the current moon phase data from the farmsense.net API. 5 - * @returns A Promise that resolves to the moon phase data object, or null if an error occurs. 6 - */ 7 - export async function getMoonPhase(): Promise<any | null> { 8 - try { 9 - // Get the current Unix timestamp for 00:00 UTC 4 + export class MoonPhaseService { 5 + private readonly apiUrl = "https://api.farmsense.net/v1/moonphases/"; 6 + private readonly timeoutMs = 10000; // 10 seconds 7 + 8 + private getApiUrl(timestamp: number): string { 9 + return `${this.apiUrl}?d=${timestamp}`; 10 + } 11 + 12 + private getCurrentMidnightTimestamp(): number { 10 13 const now = new Date(); 11 14 now.setUTCHours(0, 0, 0, 0); 12 - const unixTimestamp = Math.floor(now.getTime() / 1000); 15 + return Math.floor(now.getTime() / 1000); 16 + } 17 + 18 + private validateMoonPhaseData(data: any): data is MoonPhaseData { 19 + return ( 20 + data && 21 + typeof data.Phase === 'string' && 22 + typeof data.Illumination === 'number' && 23 + data.Phase.length > 0 && 24 + data.Illumination >= 0 && 25 + data.Illumination <= 1 26 + ); 27 + } 28 + 29 + public async getMoonPhase(): Promise<MoonPhaseData | null> { 30 + try { 31 + const timestamp = this.getCurrentMidnightTimestamp(); 32 + const url = this.getApiUrl(timestamp); 33 + 34 + console.log(`Fetching moon phase data from: ${url}`); 35 + 36 + const response = await axios.get(url, { 37 + timeout: this.timeoutMs, 38 + headers: { 39 + 'User-Agent': 'Moon Phase Bot/1.0' 40 + } 41 + }); 42 + 43 + if (!Array.isArray(response.data)) { 44 + console.error("API response is not an array:", response.data); 45 + return null; 46 + } 13 47 14 - const apiUrl = `https://api.farmsense.net/v1/moonphases/?d=${unixTimestamp}`; 15 - const response = await axios.get(apiUrl); 48 + if (response.data.length === 0) { 49 + console.error("API returned empty array"); 50 + return null; 51 + } 52 + 53 + const moonData = response.data[0]; 54 + 55 + if (!this.validateMoonPhaseData(moonData)) { 56 + console.error("Invalid moon phase data structure:", moonData); 57 + return null; 58 + } 16 59 17 - // Explicitly type response.data as an array of any 18 - const moonData: any[] = Array.isArray(response.data) ? response.data : []; 60 + console.log(`Successfully fetched moon phase: ${moonData.Phase} (${(moonData.Illumination * 100).toFixed(1)}%)`); 61 + return moonData; 19 62 20 - if (moonData && moonData.length > 0) { 21 - return response.data[0]; 22 - } else { 23 - console.error("No moon phase data received."); 63 + } catch (error) { 64 + if (error) { 65 + console.error(`API request failed: ${error.message}`); 66 + if (error.response) { 67 + console.error(`Response status: ${error.response.status}`); 68 + console.error(`Response data:`, error.response.data); 69 + } 70 + } else { 71 + console.error("Unexpected error fetching moon phase data:", error); 72 + } 24 73 return null; 25 74 } 26 - } catch (error) { 27 - console.error("Error fetching moon phase data:", error); 28 - return null; 29 75 } 30 - } 76 + } 77 + 78 + export async function getMoonPhase(): Promise<MoonPhaseData | null> { 79 + const service = new MoonPhaseService(); 80 + return service.getMoonPhase(); 81 + }
+18
src/types/moonPhase.ts
··· 1 + export interface MoonPhaseData { 2 + Phase: string; 3 + Illumination: number; 4 + Error?: string; 5 + } 6 + 7 + export interface MoonMessage { 8 + message: string; 9 + hashtag: string; 10 + } 11 + 12 + export interface BlueskyPost { 13 + [key: string]: any; // Add string index signature 14 + text: string; 15 + facets?: any[]; 16 + langs: string[]; 17 + createdAt: string; 18 + }
+15
src/utils/arrayUtils.ts
··· 1 + export function getRandomElement<T>(arr: readonly T[]): T { 2 + if (arr.length === 0) { 3 + throw new Error("Cannot get random element from empty array"); 4 + } 5 + return arr[Math.floor(Math.random() * arr.length)]; 6 + } 7 + 8 + export function shuffleArray<T>(arr: T[]): T[] { 9 + const shuffled = [...arr]; 10 + for (let i = shuffled.length - 1; i > 0; i--) { 11 + const j = Math.floor(Math.random() * (i + 1)); 12 + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; 13 + } 14 + return shuffled; 15 + }