// plugins/story.js - Collaborative storytelling plugin const fs = require('fs'); const path = require('path'); module.exports = { init(bot) { console.log('šŸ“– Story plugin initialized'); this.bot = bot; // Story data structure this.stories = new Map(); // channel -> { title, sentences: [{author, text, timestamp}], active, lastContribution, maxLength } // Story settings this.settings = { maxSentenceLength: 200, maxStoryLength: 20, // Maximum sentences per story cooldownTime: 60000, // 1 minute between contributions from same user maxStoriesPerChannel: 3, // Maximum saved stories per channel inactivityTimeout: 300000 // 5 minutes of inactivity ends story }; // User cooldowns this.userCooldowns = new Map(); // "channel:nick" -> timestamp // File to save stories this.storiesFile = path.join(__dirname, 'stories.json'); // Load existing stories this.loadStories(); // Story starters for inspiration this.storyStarters = [ "In a world where gravity worked backwards, Sarah discovered that her morning coffee was the least of her problems.", "The last library on Earth had just received its most unusual visitor.", "Detective Martinez never expected to find a confession letter written in crayon at a crime scene.", "The GPS kept saying 'turn left at the rainbow,' but there was no rainbow in sight.", "When the doorbell rang at 3 AM, nobody expected to find a penguin holding a pizza delivery bag.", "The antique music box in grandmother's attic began playing a song that hadn't been written yet.", "Every time it rained, the mailbox would fill with letters addressed to people who didn't exist.", "The new teacher seemed normal until students noticed she never cast a shadow.", "The fortune cookie contained a message that simply read: 'The dragons know where you hid the keys.'", "Professor Chen's time machine worked perfectly, except it only traveled to last Tuesday.", "The vending machine in the subway station started dispensing items that wouldn't be invented for another century.", "When the power went out citywide, only one house remained mysteriously illuminated.", "The street musician's violin could make people temporarily forget their own names.", "Every night at exactly 11:47 PM, the statue in the town square would sneeze.", "The new apartment came with everything included, except the previous tenant refused to leave the mirror." ]; }, cleanup(bot) { console.log('šŸ“– Story plugin cleaned up'); this.saveStories(); }, // Load stories from file loadStories() { try { if (fs.existsSync(this.storiesFile)) { const data = fs.readFileSync(this.storiesFile, 'utf8'); const storiesObject = JSON.parse(data); // Convert back to Map and reconstruct story objects this.stories = new Map(); for (const [channel, storyData] of Object.entries(storiesObject)) { this.stories.set(channel, { title: storyData.title || 'Untitled Story', sentences: storyData.sentences || [], active: false, // Always start inactive when loading lastContribution: storyData.lastContribution || Date.now(), maxLength: storyData.maxLength || this.settings.maxStoryLength }); } console.log(`šŸ“š Loaded ${this.stories.size} saved stories`); } else { console.log('šŸ“š No existing stories file found, starting fresh'); } } catch (error) { console.error('āŒ Error loading stories:', error); this.stories = new Map(); } }, // Save stories to file saveStories() { try { // Only save completed stories (not active ones) const completedStories = {}; for (const [channel, story] of this.stories.entries()) { if (!story.active && story.sentences.length > 0) { completedStories[channel] = { title: story.title, sentences: story.sentences, lastContribution: story.lastContribution, maxLength: story.maxLength }; } } const data = JSON.stringify(completedStories, null, 2); fs.writeFileSync(this.storiesFile, data, 'utf8'); console.log(`šŸ’¾ Saved ${Object.keys(completedStories).length} completed stories`); } catch (error) { console.error('āŒ Error saving stories:', error); } }, // Check if user is on cooldown isOnCooldown(channel, nick) { const key = `${channel}:${nick}`; const lastContribution = this.userCooldowns.get(key); if (!lastContribution) return false; const timeSince = Date.now() - lastContribution; return timeSince < this.settings.cooldownTime; }, // Set user cooldown setCooldown(channel, nick) { const key = `${channel}:${nick}`; this.userCooldowns.set(key, Date.now()); }, // Get remaining cooldown time getCooldownRemaining(channel, nick) { const key = `${channel}:${nick}`; const lastContribution = this.userCooldowns.get(key); if (!lastContribution) return 0; const timeSince = Date.now() - lastContribution; const remaining = this.settings.cooldownTime - timeSince; return Math.max(0, Math.ceil(remaining / 1000)); }, // Get current story for channel getCurrentStory(channel) { return this.stories.get(channel); }, // Start a new story startStory(channel, title = null, customStarter = null) { const storyTitle = title || `Story ${Date.now()}`; let firstSentence; if (customStarter) { firstSentence = customStarter; } else { firstSentence = this.storyStarters[Math.floor(Math.random() * this.storyStarters.length)]; } const story = { title: storyTitle, sentences: [{ author: 'StoryBot', text: firstSentence, timestamp: Date.now() }], active: true, lastContribution: Date.now(), maxLength: this.settings.maxStoryLength }; this.stories.set(channel, story); return story; }, // Add sentence to current story addSentence(channel, author, text) { const story = this.getCurrentStory(channel); if (!story || !story.active) return false; // Check if story is at max length if (story.sentences.length >= story.maxLength) { this.endStory(channel); return false; } // Clean up the sentence const cleanText = text.trim(); if (cleanText.length === 0) return false; if (cleanText.length > this.settings.maxSentenceLength) return false; // Add the sentence story.sentences.push({ author: author, text: cleanText, timestamp: Date.now() }); story.lastContribution = Date.now(); // Check if story should end (reached max length) if (story.sentences.length >= story.maxLength) { this.endStory(channel); } return true; }, // End the current story endStory(channel) { const story = this.getCurrentStory(channel); if (!story) return false; story.active = false; this.saveStories(); return true; }, // Get story as formatted text formatStory(story, includeAuthors = false, maxSentences = null) { if (!story || story.sentences.length === 0) return "No story found."; const sentences = maxSentences ? story.sentences.slice(0, maxSentences) : story.sentences; let formattedStory = `šŸ“– "${story.title}"\n\n`; sentences.forEach((sentence, index) => { if (includeAuthors && sentence.author !== 'StoryBot') { formattedStory += `${sentence.text} [${sentence.author}] `; } else { formattedStory += `${sentence.text} `; } }); if (story.active) { formattedStory += `\n\nšŸ“ Story in progress (${sentences.length}/${story.maxLength} sentences)`; } else { formattedStory += `\n\nāœ… Story completed with ${sentences.length} sentences`; } return formattedStory.trim(); }, // Check for inactive stories and end them checkInactiveStories() { for (const [channel, story] of this.stories.entries()) { if (story.active) { const timeSinceLastContribution = Date.now() - story.lastContribution; if (timeSinceLastContribution > this.settings.inactivityTimeout) { this.endStory(channel); this.bot.say(channel, 'šŸ“– Story ended due to inactivity. Use !story to start a new one!'); } } } }, commands: [ { name: 'story', description: 'Start a new collaborative story or add to the current one', execute: function(context, bot) { const plugin = module.exports; const target = context.replyTo; const from = context.nick; const args = context.args; const channel = context.channel || target; // Check for inactive stories plugin.checkInactiveStories(); const currentStory = plugin.getCurrentStory(channel); // If no arguments, show current story status if (args.length === 0) { if (currentStory && currentStory.active) { const preview = plugin.formatStory(currentStory, false, 3); bot.say(target, preview); bot.say(target, `šŸ’¬ Add to the story with: !story `); } else { bot.say(target, 'šŸ“– No active story. Start one with: !story '); bot.say(target, 'šŸ’” Or use: !storynew for a random starter'); } return; } const sentence = args.join(' '); // If there's an active story, try to add to it if (currentStory && currentStory.active) { // Check cooldown if (plugin.isOnCooldown(channel, from)) { const remaining = plugin.getCooldownRemaining(channel, from); bot.say(target, `${from}: Please wait ${remaining} seconds before contributing again.`); return; } // Check sentence length if (sentence.length > plugin.settings.maxSentenceLength) { bot.say(target, `${from}: Sentence too long! Max ${plugin.settings.maxSentenceLength} characters.`); return; } // Add the sentence if (plugin.addSentence(channel, from, sentence)) { plugin.setCooldown(channel, from); // Show last few sentences for context const recentSentences = currentStory.sentences.slice(-2); let context = recentSentences.map(s => s.text).join(' '); bot.say(target, `šŸ“ ${from} added: "${sentence}"`); // Check if story is complete if (!currentStory.active) { bot.say(target, `šŸŽ‰ Story "${currentStory.title}" is complete! Use !storyread to see the full story.`); } else { bot.say(target, `šŸ“– Story continues... (${currentStory.sentences.length}/${currentStory.maxLength})`); } } else { bot.say(target, `${from}: Couldn't add sentence. Story may be complete or sentence invalid.`); } } else { // Start new story with user's sentence const story = plugin.startStory(channel, `Story by ${from}`, sentence); plugin.setCooldown(channel, from); bot.say(target, `šŸ“– ${from} started a new story: "${sentence}"`); bot.say(target, `šŸ’¬ Others can continue with: !story `); } } }, { name: 'storynew', description: 'Start a new story with a random starter', execute: function(context, bot) { const plugin = module.exports; const target = context.replyTo; const from = context.nick; const channel = context.channel || target; // Check for inactive stories plugin.checkInactiveStories(); const currentStory = plugin.getCurrentStory(channel); if (currentStory && currentStory.active) { bot.say(target, `${from}: There's already an active story! Use !storyend to finish it first.`); return; } const story = plugin.startStory(channel); bot.say(target, `šŸ“– New story started: "${story.sentences[0].text}"`); bot.say(target, `šŸ’¬ Continue the story with: !story `); } }, { name: 'storyread', description: 'Read the current or last completed story', execute: function(context, bot) { const plugin = module.exports; const target = context.replyTo; const channel = context.channel || target; const story = plugin.getCurrentStory(channel); if (!story) { bot.say(target, 'No story found for this channel. Start one with !story or !storynew'); return; } const formattedStory = plugin.formatStory(story, true); // Split long stories into multiple messages const maxLength = 400; if (formattedStory.length <= maxLength) { bot.say(target, formattedStory); } else { const lines = formattedStory.split('\n'); let currentMessage = ''; for (const line of lines) { if (currentMessage.length + line.length + 1 <= maxLength) { currentMessage += (currentMessage ? '\n' : '') + line; } else { if (currentMessage) { bot.say(target, currentMessage); currentMessage = line; } else { // Single line too long, split it bot.say(target, line.substring(0, maxLength)); currentMessage = line.substring(maxLength); } } } if (currentMessage) { bot.say(target, currentMessage); } } } }, { name: 'storyend', description: 'End the current story', execute: function(context, bot) { const plugin = module.exports; const target = context.replyTo; const from = context.nick; const channel = context.channel || target; const story = plugin.getCurrentStory(channel); if (!story || !story.active) { bot.say(target, `${from}: No active story to end.`); return; } plugin.endStory(channel); bot.say(target, `šŸ“– ${from} ended the story "${story.title}" with ${story.sentences.length} sentences.`); bot.say(target, `šŸ“š Use !storyread to read the completed story, or !storynew to start fresh.`); } }, { name: 'storystatus', description: 'Show current story status and stats', execute: function(context, bot) { const plugin = module.exports; const target = context.replyTo; const from = context.nick; const channel = context.channel || target; plugin.checkInactiveStories(); const story = plugin.getCurrentStory(channel); if (!story) { bot.say(target, 'No story in this channel. Use !story or !storynew to start one!'); return; } let statusMsg = `šŸ“Š Story "${story.title}": ${story.sentences.length}/${story.maxLength} sentences`; if (story.active) { statusMsg += ' (Active)'; // Show contributors const contributors = [...new Set(story.sentences.filter(s => s.author !== 'StoryBot').map(s => s.author))]; if (contributors.length > 0) { statusMsg += ` | Contributors: ${contributors.join(', ')}`; } // Show cooldown info if (plugin.isOnCooldown(channel, from)) { const remaining = plugin.getCooldownRemaining(channel, from); statusMsg += ` | Your cooldown: ${remaining}s`; } } else { statusMsg += ' (Completed)'; } bot.say(target, statusMsg); } }, { name: 'storytitle', description: 'Set a title for the current story', execute: function(context, bot) { const plugin = module.exports; const target = context.replyTo; const from = context.nick; const args = context.args; const channel = context.channel || target; if (args.length === 0) { bot.say(target, `${from}: Usage: !storytitle `); return; } const story = plugin.getCurrentStory(channel); if (!story) { bot.say(target, `${from}: No story to rename. Start one first!`); return; } const newTitle = args.join(' '); if (newTitle.length > 50) { bot.say(target, `${from}: Title too long! Max 50 characters.`); return; } const oldTitle = story.title; story.title = newTitle; bot.say(target, `šŸ“– ${from} renamed the story from "${oldTitle}" to "${newTitle}"`); } } ] };