diff --git a/docs/COMMAND-WIKI.md b/docs/COMMAND-WIKI.md index c2cc7eca..28609a0c 100644 --- a/docs/COMMAND-WIKI.md +++ b/docs/COMMAND-WIKI.md @@ -272,6 +272,36 @@ - **Options:** None - **Subcommands:** None +# PHRASE +## phrase +- **Aliases:** None +- **Description:** Handle phrase functions. +- **Examples:**
`.phrase signup`
`.phrase s
`.phrase create`
`.phrase c
`.phrase create @Codey`
`.phrase c @Codey
`.phrase quit
`.phrase q` +- **Options:** None +- **Subcommands:** `signup`, `create`, `quit` + +## phrase create +- **Aliases:** `c` +- **Description:** Generate phrases of you! +- **Examples:**
`.phrase create`
`.phrase c`
`.phrase create @username`
`.phrase c @username` +- **Options:** + - ``user``: User to generate a phrase for (defaults to yourself) +- **Subcommands:** None + +## phrase quit +- **Aliases:** `q` +- **Description:** Opt out of phrase generation +- **Examples:**
`.phrase quit`
`.phrase q` +- **Options:** None +- **Subcommands:** None + +## phrase signup +- **Aliases:** `s` +- **Description:** Sign up to generate phrases of you! +- **Examples:**
`.phrase signup`
`.phrase s` +- **Options:** None +- **Subcommands:** None + # PROFILE ## profile - **Aliases:** `userprofile`, `aboutme` diff --git a/src/commandDetails/phrase/create.ts b/src/commandDetails/phrase/create.ts new file mode 100644 index 00000000..6b2ff9f8 --- /dev/null +++ b/src/commandDetails/phrase/create.ts @@ -0,0 +1,107 @@ +import { container } from '@sapphire/framework'; +import { + CodeyCommandDetails, + CodeyCommandOptionType, + getUserFromMessage, + SapphireMessageExecuteType, + SapphireMessageResponse, +} from '../../codeyCommand'; +import { EmbedBuilder, User } from 'discord.js'; +import { + checkIfUserSignedUp, + getUserMessages, + generateMarkovPhrase, +} from '../../components/phrase'; +import { logger } from '../../logger/default'; + +const phraseCreateExecuteCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + args, +): Promise => { + const caller = getUserFromMessage(messageFromUser); + + // Type-check the user argument properly + let targetUser: User; + if (args.user && args.user instanceof User) { + targetUser = args.user; + } else { + targetUser = caller; + } + + const guildId = messageFromUser.guild?.id; + + if (!guildId) { + const embed = new EmbedBuilder() + .setColor('Red') + .setTitle('Error ❌') + .setDescription('This command can only be used in a server.'); + return { embeds: [embed] }; + } + + try { + // Check if target user has opted in + const isTargetSignedUp = await checkIfUserSignedUp(targetUser.id, guildId); + if (!isTargetSignedUp) { + const embed = new EmbedBuilder() + .setColor('Orange') + .setTitle('User Not Signed Up') + .setDescription(`${targetUser.username} hasn't opted in to phrase generation yet.`); + return { embeds: [embed] }; + } + + // Get messages for the target user + const messages = await getUserMessages(targetUser.id, guildId); + if (messages.length === 0) { + const embed = new EmbedBuilder() + .setColor('Orange') + .setTitle('No Messages Found') + .setDescription( + `No messages found for ${targetUser.username}. Try again after the next daily sync.`, + ); + return { embeds: [embed] }; + } + + // Generate phrase using Markov chain + const generatedPhrase = generateMarkovPhrase(messages); + + const embed = new EmbedBuilder() + .setColor('Green') + .setTitle(`${targetUser.username} says...`) + .setDescription(`"${generatedPhrase}"`) + .setFooter({ text: `Generated from ${messages.length} messages` }); + + return { embeds: [embed] }; + } catch (error) { + logger.error('Error generating phrase:', error); + const embed = new EmbedBuilder() + .setColor('Red') + .setTitle('Error ❌') + .setDescription('An error occurred while generating the phrase. Please try again later.'); + return { embeds: [embed] }; + } +}; + +export const phraseCreateCommandDetails: CodeyCommandDetails = { + name: 'create', + aliases: ['c'], + description: 'Generate phrases of you!', + detailedDescription: `**Examples:** +\`${container.botPrefix}phrase create\` +\`${container.botPrefix}phrase c\` +\`${container.botPrefix}phrase create @username\` +\`${container.botPrefix}phrase c @username\``, + + isCommandResponseEphemeral: false, + messageWhenExecutingCommand: 'Creating phrase...', + executeCommand: phraseCreateExecuteCommand, + options: [ + { + name: 'user', + description: 'User to generate a phrase for (defaults to yourself)', + type: CodeyCommandOptionType.USER, + required: false, + }, + ], + subcommandDetails: {}, +}; diff --git a/src/commandDetails/phrase/quit.ts b/src/commandDetails/phrase/quit.ts new file mode 100644 index 00000000..7203e179 --- /dev/null +++ b/src/commandDetails/phrase/quit.ts @@ -0,0 +1,77 @@ +import { container } from '@sapphire/framework'; +import { + CodeyCommandDetails, + SapphireMessageExecuteType, + SapphireMessageResponse, + getUserFromMessage, +} from '../../codeyCommand'; +import { EmbedBuilder } from 'discord.js'; +import { logger } from '../../logger/default'; +import { checkIfUserSignedUp, removeUser } from '../../components/phrase'; + +const phraseQuitExecuteCommand: SapphireMessageExecuteType = async ( + _client, + messageFromUser, + _args, +): Promise => { + const title = 'Phrase Quit Information'; + const user = getUserFromMessage(messageFromUser); + const userId = user.id; + const guildId = messageFromUser.guild?.id; + + // Check if guild ID is available + if (!guildId) { + const embed = new EmbedBuilder() + .setColor('Red') + .setTitle(title) + .setDescription('This command can only be used in a server.'); + return { embeds: [embed] }; + } + + try { + // Check if user is signed up + const isSignedUp = await checkIfUserSignedUp(userId, guildId); + if (!isSignedUp) { + const embed = new EmbedBuilder() + .setColor('Orange') + .setTitle(title) + .setDescription("You're not currently signed up for phrase generation."); + return { embeds: [embed] }; + } + + // Remove the user + await removeUser(userId, guildId); + + const embed = new EmbedBuilder() + .setColor('Green') + .setTitle(title) + .setDescription( + 'You have been removed from phrase generation. All your message data has been removed from database.', + ); + return { embeds: [embed] }; + } catch (error) { + logger.error('Error in phrase quit:', error); + const embed = new EmbedBuilder() + .setColor('Red') + .setTitle(title) + .setDescription( + 'An error occurred while removing you from database. Please try again later.', + ); + return { embeds: [embed] }; + } +}; + +export const phraseQuitCommandDetails: CodeyCommandDetails = { + name: 'quit', + aliases: ['q'], + description: 'Opt out of phrase generation', + detailedDescription: `**Examples:** +\`${container.botPrefix}phrase quit\` +\`${container.botPrefix}phrase q\``, + + isCommandResponseEphemeral: false, + messageWhenExecutingCommand: 'Removing user from database...', + executeCommand: phraseQuitExecuteCommand, + options: [], + subcommandDetails: {}, +}; diff --git a/src/commandDetails/phrase/signup.ts b/src/commandDetails/phrase/signup.ts new file mode 100644 index 00000000..ba69c603 --- /dev/null +++ b/src/commandDetails/phrase/signup.ts @@ -0,0 +1,109 @@ +import { container } from '@sapphire/framework'; +import { + CodeyCommandDetails, + getUserFromMessage, + SapphireMessageExecuteType, + SapphireMessageResponse, +} from '../../codeyCommand'; +import { checkIfUserSignedUp, signUpUserWithCollection } from '../../components/phrase'; +import { logger } from '../../logger/default'; +import { EmbedBuilder, Message } from 'discord.js'; + +const phraseSignupExecuteCommand: SapphireMessageExecuteType = async ( + client, + messageFromUser, + _args, +): Promise => { + const title = 'Phrase Signup Information'; + const user = getUserFromMessage(messageFromUser); + const userId = user.id; + const username = user.username; + const guildId = messageFromUser.guild?.id; + + // Check if guild ID is available + if (!guildId) { + const embed = new EmbedBuilder() + .setColor('Red') + .setTitle(title) + .setDescription('This command can only be used in a server (guild).'); + return { embeds: [embed] }; + } + + try { + // Check if user is already signed up + const isAlreadySignedUp = await checkIfUserSignedUp(userId, guildId); + if (isAlreadySignedUp) { + const embed = new EmbedBuilder() + .setColor('Orange') + .setTitle(title) + .setDescription("You're already signed up for phrase generation!"); + return { embeds: [embed] }; + } + + // For long-running operations, send initial response first + const initialResponse = await messageFromUser.reply({ + embeds: [ + new EmbedBuilder() + .setColor('Blue') + .setTitle(title) + .setDescription( + 'Signing you up and collecting your messages... This may take a few minutes.', + ), + ], + ephemeral: true, + fetchReply: true, + }); + + // Sign up the user and collect messages + const result = await signUpUserWithCollection(client, userId, guildId, username); + + let finalEmbed: EmbedBuilder; + if (result.success) { + finalEmbed = new EmbedBuilder().setColor('Green').setTitle(title); + + if (result.error) { + finalEmbed.setDescription(`You're signed up! ${result.error}`); + } else { + finalEmbed.setDescription( + `You've successfully signed up for phrase generation! Found ${ + result.messagesCollected || 0 + } messages to use for phrase generation.`, + ); + } + } else { + finalEmbed = new EmbedBuilder() + .setColor('Red') + .setTitle(title) + .setDescription(result.error || 'Failed to sign up. Please try again later.'); + } + + // Update the response based on command type + if (messageFromUser instanceof Message) { + await initialResponse.edit({ embeds: [finalEmbed] }); + } else { + await messageFromUser.editReply({ embeds: [finalEmbed] }); + } + } catch (error) { + logger.error('Error in phrase signup:', error); + const embed = new EmbedBuilder() + .setColor('Red') + .setTitle(title) + .setDescription('An error occurred while signing you up. Please try again later.'); + return { embeds: [embed] }; + } +}; + +export const phraseSignupCommandDetails: CodeyCommandDetails = { + name: 'signup', + aliases: ['s'], + description: 'Sign up to generate phrases of you!', + detailedDescription: `**Examples:** +\`${container.botPrefix}phrase signup\` +\`${container.botPrefix}phrase s\``, + + isCommandResponseEphemeral: false, + messageWhenExecutingCommand: 'Signing user up...', + executeCommand: phraseSignupExecuteCommand, + options: [], + subcommandDetails: {}, +}; diff --git a/src/commands/phrase/phrase.ts b/src/commands/phrase/phrase.ts new file mode 100644 index 00000000..1d5513ba --- /dev/null +++ b/src/commands/phrase/phrase.ts @@ -0,0 +1,40 @@ +import { Command, container } from '@sapphire/framework'; +import { CodeyCommand, CodeyCommandDetails } from '../../codeyCommand'; +import { phraseSignupCommandDetails } from '../../commandDetails/phrase/signup'; +import { phraseCreateCommandDetails } from '../../commandDetails/phrase/create'; +import { phraseQuitCommandDetails } from '../../commandDetails/phrase/quit'; + +const phraseCommandDetails: CodeyCommandDetails = { + name: 'phrase', + aliases: [], + description: 'Handle phrase functions.', + detailedDescription: `**Examples:** +\`${container.botPrefix}phrase signup\` +\`${container.botPrefix}phrase s\ +\`${container.botPrefix}phrase create\` +\`${container.botPrefix}phrase c\ +\`${container.botPrefix}phrase create @Codey\` +\`${container.botPrefix}phrase c @Codey\ +\`${container.botPrefix}phrase quit\ +\`${container.botPrefix}phrase q\``, + options: [], + subcommandDetails: { + signup: phraseSignupCommandDetails, + create: phraseCreateCommandDetails, + quit: phraseQuitCommandDetails, + }, + defaultSubcommandDetails: phraseCreateCommandDetails, +}; + +export class PhraseCommand extends CodeyCommand { + details = phraseCommandDetails; + + public constructor(context: Command.Context, options: Command.Options) { + super(context, { + ...options, + aliases: phraseCommandDetails.aliases, + description: phraseCommandDetails.description, + detailedDescription: phraseCommandDetails.detailedDescription, + }); + } +} diff --git a/src/components/cron.ts b/src/components/cron.ts index 7c64c4bc..7dfe9a3e 100644 --- a/src/components/cron.ts +++ b/src/components/cron.ts @@ -5,6 +5,7 @@ import _ from 'lodash'; import fetch from 'node-fetch'; import { alertMatches } from '../components/coffeeChat'; import { alertUsers } from './officeOpenDM'; +import { performDailyMessageSync, performMonthlyCleanup } from './phrase'; import { vars } from '../config'; import { DEFAULT_EMBED_COLOUR } from '../utils/embeds'; import { getMatch, writeHistoricMatches } from '../components/coffeeChat'; @@ -49,6 +50,8 @@ export const initCrons = async (client: Client): Promise => { createCoffeeChatCron(client).start(); createOfficeStatusCron(client).start(); assignCodeyRoleForLeaderboard(client).start(); + createPhraseMessageSyncCron(client).start(); + createPhraseMonthlyCleanupCron().start(); }; interface officeStatus { @@ -218,3 +221,25 @@ export const assignCodeyRoleForLeaderboard = (client: Client): CronJob => }); }); }); + +// Daily phrase message sync - runs at 12:00 AM every day +export const createPhraseMessageSyncCron = (client: Client): CronJob => + new CronJob('0 0 0 * * *', async function () { + logger.info('Starting daily phrase message sync'); + try { + await performDailyMessageSync(client); + } catch (error) { + logger.error('Error in daily phrase message sync:', error); + } + }); + +// Monthly phrase cleanup - runs at 12:00 AM on the 1st day of every month +export const createPhraseMonthlyCleanupCron = (): CronJob => + new CronJob('0 0 0 1 * *', async function () { + logger.info('Starting monthly phrase message cleanup'); + try { + await performMonthlyCleanup(); + } catch (error) { + logger.error('Error in monthly phrase cleanup:', error); + } + }); diff --git a/src/components/db.ts b/src/components/db.ts index 19dad417..7a335b14 100644 --- a/src/components/db.ts +++ b/src/components/db.ts @@ -222,6 +222,45 @@ const initPeopleCompaniesTable = async (db: Database): Promise => { )`); }; +const initPhraseUsersTable = async (db: Database): Promise => { + // Leaving here for future debugging + // await db.run(`DROP TABLE IF EXISTS phrase_users`); + await db.run(` + CREATE TABLE IF NOT EXISTS phrase_users ( + user_id VARCHAR(255) NOT NULL, + guild_id VARCHAR(255) NOT NULL, + opted_in_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + username TEXT NOT NULL, + last_message_sync TIMESTAMP DEFAULT NULL, + PRIMARY KEY (user_id, guild_id) + )`); +}; + +const initPhraseMessagesTable = async (db: Database): Promise => { + // Leaving here for future debugging + // await db.run(`DROP TABLE IF EXISTS phrase_messages`); + // await db.run(`DROP INDEX IF EXISTS ix_phrase_messages_user_id`); + // await db.run(`DROP INDEX IF EXISTS ix_phrase_messages_message_timestamp`); + await db.run(` + CREATE TABLE IF NOT EXISTS phrase_messages ( + id INTEGER PRIMARY KEY NOT NULL, + user_id VARCHAR(255) NOT NULL, + guild_id VARCHAR(255) NOT NULL, + message_content TEXT NOT NULL, + channel_id VARCHAR(255) NOT NULL, + message_id VARCHAR(255) UNIQUE NOT NULL, + message_timestamp TIMESTAMP NOT NULL, + collected_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id, guild_id) REFERENCES phrase_users(user_id, guild_id) ON DELETE CASCADE + )`); + await db.run( + `CREATE INDEX IF NOT EXISTS ix_phrase_messages_user_guild ON phrase_messages (user_id, guild_id)`, + ); + await db.run( + `CREATE INDEX IF NOT EXISTS ix_phrase_messages_message_timestamp ON phrase_messages (message_timestamp)`, + ); +}; + const initTables = async (db: Database): Promise => { //initialize all relevant tables await initCoffeeChatTables(db); @@ -236,6 +275,8 @@ const initTables = async (db: Database): Promise => { await initResumePreview(db); await initCompaniesTable(db); await initPeopleCompaniesTable(db); + await initPhraseUsersTable(db); + await initPhraseMessagesTable(db); }; export const openDB = async (): Promise => { diff --git a/src/components/phrase.ts b/src/components/phrase.ts new file mode 100644 index 00000000..9b4c4988 --- /dev/null +++ b/src/components/phrase.ts @@ -0,0 +1,546 @@ +import { openDB } from './db'; +import { Client, TextChannel, Collection, Message } from 'discord.js'; +import { logger } from '../logger/default'; + +// Sanitize message content to remove mentions and other problematic content +const sanitizeMessageContent = (content: string): string => { + return ( + content + // Remove user mentions: <@123456789> or <@!123456789> + .replace(/<@!?\d+>/g, '@user') + // Remove role mentions: <@&123456789> + .replace(/<@&\d+>/g, '@role') + // Remove channel mentions: <#123456789> + .replace(/<#\d+>/g, '#channel') + // Remove custom emojis: <:name:123456789> or + .replace(//g, ':emoji:') + // Remove URLs to prevent link spam + .replace(/https?:\/\/[^\s]+/g, '[link]') + .trim() + ); +}; + +export const checkIfUserSignedUp = async (userId: string, guildId: string): Promise => { + const db = await openDB(); + const result = await db.get( + 'SELECT user_id FROM phrase_users WHERE user_id = ? AND guild_id = ?', + userId, + guildId, + ); + return result !== undefined; +}; + +export const signUpUser = async ( + userId: string, + guildId: string, + username: string, +): Promise => { + const db = await openDB(); + await db.run( + 'INSERT INTO phrase_users (user_id, guild_id, username, opted_in_at, last_message_sync) VALUES (?, ?, ?, datetime("now"), NULL)', + userId, + guildId, + username, + ); +}; + +export const removeUser = async (userId: string, guildId: string): Promise => { + const db = await openDB(); + await db.run('DELETE FROM phrase_users WHERE user_id = ? AND guild_id = ?', userId, guildId); + // Note: phrase_messages will be automatically deleted due to ON DELETE CASCADE +}; + +export const getUserMessages = async (userId: string, guildId: string): Promise => { + const db = await openDB(); + const rows = await db.all( + 'SELECT message_content FROM phrase_messages WHERE user_id = ? AND guild_id = ? ORDER BY message_timestamp', + userId, + guildId, + ); + // Sanitize messages when retrieving them (handles legacy data) + return rows.map((row) => sanitizeMessageContent(row.message_content)); +}; + +export const collectUserMessages = async ( + client: Client, + userId: string, + guildId: string, + username: string, + sinceDate?: Date | null, +): Promise => { + const db = await openDB(); + let totalCollected = 0; + + try { + const guild = client.guilds.cache.get(guildId); + if (!guild) { + logger.error(`Guild ${guildId} not found for message collection`); + return 0; + } + + // Determine cutoff date: use provided date, or default to 1 year ago + const cutoffDate = + sinceDate || + (() => { + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + return oneYearAgo; + })(); + + // Get all text channels in the guild + const textChannels = guild.channels.cache.filter( + (channel) => channel.isTextBased() && channel.type === 0, // GUILD_TEXT + ) as Collection; + + for (const [, channel] of textChannels) { + try { + // Check if bot has permission to read message history + const permissions = channel.permissionsFor(client.user!); + if (!permissions?.has(['ViewChannel', 'ReadMessageHistory'])) { + continue; // Skip channels we can't read + } + + let lastMessageId: string | undefined; + let hasMoreMessages = true; + let foundOldMessage = false; + + while (hasMoreMessages && !foundOldMessage) { + const options: { limit: number; before?: string } = { limit: 100 }; + if (lastMessageId) { + options.before = lastMessageId; + } + + const messages: Collection = await channel.messages.fetch(options); + + if (messages.size === 0) { + hasMoreMessages = false; + break; + } + + // Filter messages from the specific user within the cutoff date + const userMessages = messages.filter( + (msg: Message) => + msg.author.id === userId && + msg.content && + msg.content.trim().length > 0 && + !msg.author.bot && + !msg.content.trim().startsWith('.') && + msg.createdAt >= cutoffDate, // Only messages after cutoff + ); + + // Check if we've gone past the cutoff date + const oldestMessage = messages.last(); + if (oldestMessage && oldestMessage.createdAt < cutoffDate) { + foundOldMessage = true; + } + + // Insert messages into database + for (const [, message] of userMessages) { + try { + // Sanitize message content before storing + const sanitizedContent = sanitizeMessageContent(message.content); + + // Skip messages that become too short after sanitization + if (sanitizedContent.length < 3) continue; + + await db.run( + `INSERT OR IGNORE INTO phrase_messages + (user_id, guild_id, message_content, channel_id, message_id, message_timestamp, collected_at) + VALUES (?, ?, ?, ?, ?, ?, datetime("now"))`, + userId, + guildId, + sanitizedContent, + channel.id, + message.id, + new Date(message.createdTimestamp).toISOString(), + ); + totalCollected++; + } catch (insertError: unknown) { + // Skip duplicate messages (INSERT OR IGNORE) + if ( + insertError instanceof Error && + !insertError.message.includes('UNIQUE constraint failed') + ) { + logger.error('Error inserting message:', insertError); + } + } + } + + // Set up for next iteration + const lastMessage = messages.last(); + if (lastMessage && messages.size === 100 && !foundOldMessage) { + lastMessageId = lastMessage.id; + } else { + hasMoreMessages = false; + } + + // Add delay to respect rate limits + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } catch (channelError) { + logger.error(`Error collecting from channel ${channel.name}:`, channelError); + // Continue with other channels + } + } + + // Update last_message_sync timestamp + await db.run( + 'UPDATE phrase_users SET last_message_sync = datetime("now") WHERE user_id = ? AND guild_id = ?', + userId, + guildId, + ); + + if (totalCollected > 0) { + logger.info( + `Collected ${totalCollected} messages for user ${username} (${userId}) in guild ${guildId}`, + ); + } + + return totalCollected; + } catch (error) { + logger.error('Error in collectUserMessages:', error); + throw error; + } +}; + +export const signUpUserWithCollection = async ( + client: Client, + userId: string, + guildId: string, + username: string, +): Promise<{ success: boolean; messagesCollected?: number; error?: string }> => { + try { + // First sign up the user + await signUpUser(userId, guildId, username); + + // Then collect their messages with timeout + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Message collection timeout')), 300000); // 5 minutes + }); + + const collectionPromise = collectUserMessages(client, userId, guildId, username); + + const messagesCollected = await Promise.race([collectionPromise, timeoutPromise]); + + return { success: true, messagesCollected }; + } catch (error) { + logger.error('Error in signUpUserWithCollection:', error); + + // If signup succeeded but collection failed, user is still signed up + const isSignedUp = await checkIfUserSignedUp(userId, guildId); + if (isSignedUp) { + if (error instanceof Error && error.message === 'Message collection timeout') { + return { + success: true, + messagesCollected: 0, + error: + 'Message collection timed out, but you are signed up. Messages will be collected in the next daily sync.', + }; + } + return { + success: true, + messagesCollected: 0, + error: + 'Message collection failed, but you are signed up. Messages will be collected in the next daily sync.', + }; + } + + return { success: false, error: 'Failed to sign up user.' }; + } +}; + +export const generateMarkovPhrase = (messages: string[], maxLength = 50): string => { + if (messages.length === 0) return "I don't have anything to say!"; + + // Combine all messages into one large corpus with sentence boundary markers + // This allows the chain to cross between different original messages + const combinedText = messages.join(' '); + const allWords = combinedText + .trim() + .split(/\s+/) + .filter((word) => word.length > 0); + + if (allWords.length < 3) return "I don't have enough words to work with!"; + + // Build both trigram and bigram transitions for fallback + const trigramTransitions: Map = new Map(); + const bigramTransitions: Map = new Map(); + const trigramStarters: string[] = []; + const bigramStarters: string[] = []; + + // Build trigram data + if (allWords.length >= 3) { + for (let i = 0; i < allWords.length - 2; i++) { + const currentTriplet = `${allWords[i]} ${allWords[i + 1]} ${allWords[i + 2]}`; + + // Skip triplets that contain sentence boundaries for starters + if (!currentTriplet.includes('')) { + trigramStarters.push(currentTriplet); + } + + // Build transitions (including across sentence boundaries, but filter them out later) + if (i < allWords.length - 3) { + const nextWord = allWords[i + 3]; + + if (!trigramTransitions.has(currentTriplet)) { + trigramTransitions.set(currentTriplet, []); + } + trigramTransitions.get(currentTriplet)!.push(nextWord); + } + } + } + + // Build bigram data as fallback + for (let i = 0; i < allWords.length - 1; i++) { + const currentPair = `${allWords[i]} ${allWords[i + 1]}`; + + // Skip pairs that contain sentence boundaries for starters + if (!currentPair.includes('')) { + bigramStarters.push(currentPair); + } + + // Build transitions + if (i < allWords.length - 2) { + const nextWord = allWords[i + 2]; + + if (!bigramTransitions.has(currentPair)) { + bigramTransitions.set(currentPair, []); + } + bigramTransitions.get(currentPair)!.push(nextWord); + } + } + + // Prefer trigrams but fallback to bigrams if needed + const usesTrigrams = trigramStarters.length > 0; + const transitions = usesTrigrams ? trigramTransitions : bigramTransitions; + const starters = usesTrigrams ? trigramStarters : bigramStarters; + const contextSize = usesTrigrams ? 3 : 2; + + if (starters.length === 0) return "I don't have anything to say!"; + + // Generate phrase + const result: string[] = []; + let currentContext = starters[Math.floor(Math.random() * starters.length)]; + const startWords = currentContext.split(' '); + result.push(...startWords); + + let iterations = 0; + const maxIterations = Math.min(maxLength - contextSize, 40); + + while (iterations < maxIterations) { + let possibleNext = transitions.get(currentContext); + + // Fallback to bigrams if trigram fails + if (usesTrigrams && (!possibleNext || possibleNext.length === 0)) { + const bigramContext = result.slice(-2).join(' '); + possibleNext = bigramTransitions.get(bigramContext); + } + + if (!possibleNext || possibleNext.length === 0) break; + + // Filter out sentence boundary markers and select next word + const validNext = possibleNext.filter((word) => word !== ''); + + // If we hit a sentence boundary, we have a chance to either: + // 1. End the current phrase (30% chance) + // 2. Jump to a new random context (20% chance) + // 3. Continue with current context (50% chance) + if (validNext.length === 0 || possibleNext.includes('')) { + const rand = Math.random(); + if (rand < 0.3 && result.length >= 8) { + // End phrase naturally + break; + } else if (rand < 0.5 && result.length >= 5) { + // Jump to new context to mix things up + const newContext = starters[Math.floor(Math.random() * starters.length)]; + const newWords = newContext.split(' ').slice(-contextSize); + + // Only add words that aren't already at the end to avoid repetition + const lastWords = result.slice(-contextSize); + if (newWords.join(' ') !== lastWords.join(' ')) { + result.push(...newWords); + currentContext = newWords.join(' '); + } + iterations++; + continue; + } + // Otherwise try to continue with available valid words + if (validNext.length === 0) break; + } + + const nextWord = + validNext.length > 0 + ? validNext[Math.floor(Math.random() * validNext.length)] + : possibleNext[Math.floor(Math.random() * possibleNext.length)]; + + if (nextWord === '') break; + + result.push(nextWord); + + // Update current context (slide the window) + const lastWords = result.slice(-contextSize); + currentContext = lastWords.join(' '); + + // Stop at natural sentence endings (but ensure minimum length) + if (nextWord.match(/[.!?]$/) && result.length >= 8) break; + + // Better connector handling - only stop if we're at a natural pause + if (result.length > 12) { + // Check if this is a sentence starter that would indicate a new thought + if ( + nextWord.match( + /^(But|However|Therefore|Meanwhile|Additionally|Furthermore|Moreover|Nevertheless|Nonetheless)$/i, + ) + ) { + // Look ahead to see if we can complete the current thought + const nextContext = usesTrigrams + ? `${result.slice(-2).join(' ')} ${nextWord}` + : `${result.slice(-1)[0]} ${nextWord}`; + + const lookahead = usesTrigrams + ? trigramTransitions.get(nextContext) + : bigramTransitions.get(nextContext); + + // If there's no good continuation after the connector, stop before it + if (!lookahead || lookahead.length === 0) { + result.pop(); // Remove the connector + break; + } + } + + // Stop at coordinating conjunctions only if phrase is getting very long + if (result.length > 20 && nextWord.match(/^(And|Or|So|Then|Now|Well)$/i)) { + result.pop(); // Remove the connector + break; + } + } + + iterations++; + } + + // Clean up the result array to remove any sentence boundary markers that might have slipped through + const cleanedResult = result.filter((word) => word !== ''); + + // Ensure we have a reasonable ending + let finalPhrase = cleanedResult.join(' '); + + // Remove trailing connectors that make the phrase feel incomplete + const words = finalPhrase.split(' '); + while ( + words.length > 3 && + words[words.length - 1].match(/^(and|or|but|so|then|now|well|also|too|though|yet|however)$/i) + ) { + words.pop(); + finalPhrase = words.join(' '); + } + + // If phrase ends abruptly, try to find a better ending point + if (!finalPhrase.match(/[.!?]$/) && words.length > 3) { + // Look for the last reasonable stopping point (punctuation or common endings) + for (let i = words.length - 1; i >= Math.max(3, words.length - 5); i--) { + const word = words[i]; + if ( + word.match(/[.!?]$/) || + word.match(/^(too|though|yet|now|then|here|there|well|right|okay|ok)$/i) + ) { + finalPhrase = words.slice(0, i + 1).join(' '); + break; + } + } + } + + // Add punctuation if still missing + if (!finalPhrase.match(/[.!?]$/)) { + // Add appropriate punctuation based on content + if ( + finalPhrase.toLowerCase().includes('what') || + finalPhrase.toLowerCase().includes('how') || + finalPhrase.toLowerCase().includes('why') || + finalPhrase.toLowerCase().includes('when') || + finalPhrase.toLowerCase().includes('where') + ) { + finalPhrase += '?'; + } else if (finalPhrase.match(/wow|great|awesome|amazing|cool|nice/i)) { + finalPhrase += '!'; + } else { + finalPhrase += '.'; + } + } + + return finalPhrase; +}; + +// Daily sync function to update all users' message data +export const performDailyMessageSync = async (client: Client): Promise => { + const db = await openDB(); + + try { + logger.info('Starting daily message sync for all phrase users'); + + // Get all opted-in users + const users = await db.all( + 'SELECT user_id, guild_id, username, last_message_sync FROM phrase_users', + ); + + let totalUsersProcessed = 0; + let totalMessagesCollected = 0; + + for (const user of users) { + try { + const lastSync = user.last_message_sync ? new Date(user.last_message_sync) : null; + const guild = client.guilds.cache.get(user.guild_id); + + if (!guild) { + logger.warn(`Guild ${user.guild_id} not found for user ${user.username}`); + continue; + } + + // Collect new messages since last sync using the unified function + const messagesCollected = await collectUserMessages( + client, + user.user_id, + user.guild_id, + user.username, + lastSync, + ); + + totalMessagesCollected += messagesCollected; + totalUsersProcessed++; + + // Small delay to respect rate limits + await new Promise((resolve) => setTimeout(resolve, 2000)); + } catch (error) { + logger.error(`Error syncing messages for user ${user.username}:`, error); + } + } + + logger.info( + `Daily sync complete: processed ${totalUsersProcessed} users, collected ${totalMessagesCollected} messages`, + ); + } catch (error) { + logger.error('Error in daily message sync:', error); + } +}; + +// Monthly cleanup function to remove messages older than 1 year +export const performMonthlyCleanup = async (): Promise => { + const db = await openDB(); + + try { + logger.info('Starting monthly message cleanup'); + + // Calculate cutoff date (1 year ago) + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + + // Delete old messages + const result = await db.run( + 'DELETE FROM phrase_messages WHERE message_timestamp < ?', + oneYearAgo.toISOString(), + ); + + logger.info(`Monthly cleanup complete: removed ${result.changes} old messages`); + } catch (error) { + logger.error('Error in monthly cleanup:', error); + } +};