From df20df658c91030532a497d95696a91b3e7cde5e Mon Sep 17 00:00:00 2001 From: KuroganeToyama Date: Fri, 10 Oct 2025 14:20:09 +0000 Subject: [PATCH 1/5] set up basic file structure --- src/commandDetails/phrase/create.ts | 29 +++++++++++++++++++++ src/commandDetails/phrase/quit.ts | 29 +++++++++++++++++++++ src/commandDetails/phrase/signup.ts | 29 +++++++++++++++++++++ src/commands/phrase/phrase.ts | 40 +++++++++++++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 src/commandDetails/phrase/create.ts create mode 100644 src/commandDetails/phrase/quit.ts create mode 100644 src/commandDetails/phrase/signup.ts create mode 100644 src/commands/phrase/phrase.ts diff --git a/src/commandDetails/phrase/create.ts b/src/commandDetails/phrase/create.ts new file mode 100644 index 00000000..cf9ac3c3 --- /dev/null +++ b/src/commandDetails/phrase/create.ts @@ -0,0 +1,29 @@ +import { container } from '@sapphire/framework'; +import { + CodeyCommandDetails, + SapphireMessageExecuteType, + SapphireMessageResponse, +} from '../../codeyCommand'; + +const phraseCreateExecuteCommand: SapphireMessageExecuteType = async ( + _client, + _messageFromUser, + _args, +): Promise => { + return; +}; + +export const phraseCreateCommandDetails: CodeyCommandDetails = { + name: 'create', + aliases: ['c'], + description: 'Generate phrases of you!', + detailedDescription: `**Examples:** +\`${container.botPrefix}phrase create\` +\`${container.botPrefix}phrase c\``, + + isCommandResponseEphemeral: false, + messageWhenExecutingCommand: 'Creating phrase...', + executeCommand: phraseCreateExecuteCommand, + options: [], + subcommandDetails: {}, +}; diff --git a/src/commandDetails/phrase/quit.ts b/src/commandDetails/phrase/quit.ts new file mode 100644 index 00000000..f30dd43a --- /dev/null +++ b/src/commandDetails/phrase/quit.ts @@ -0,0 +1,29 @@ +import { container } from '@sapphire/framework'; +import { + CodeyCommandDetails, + SapphireMessageExecuteType, + SapphireMessageResponse, +} from '../../codeyCommand'; + +const phraseQuitExecuteCommand: SapphireMessageExecuteType = async ( + _client, + _messageFromUser, + _args, +): Promise => { + return; +}; + +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..5b1a10fc --- /dev/null +++ b/src/commandDetails/phrase/signup.ts @@ -0,0 +1,29 @@ +import { container } from '@sapphire/framework'; +import { + CodeyCommandDetails, + SapphireMessageExecuteType, + SapphireMessageResponse, +} from '../../codeyCommand'; + +const phraseSignupExecuteCommand: SapphireMessageExecuteType = async ( + _client, + _messageFromUser, + _args, +): Promise => { + return; +}; + +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, + }); + } +} From e1080fdec8501c3fa2d09a08fb8da8c08a73b1c3 Mon Sep 17 00:00:00 2001 From: KuroganeToyama Date: Fri, 10 Oct 2025 16:26:25 +0000 Subject: [PATCH 2/5] added basic markov functionalities --- docs/COMMAND-WIKI.md | 30 +++ src/commandDetails/phrase/create.ts | 88 ++++++++- src/commandDetails/phrase/quit.ts | 52 +++++- src/commandDetails/phrase/signup.ts | 86 ++++++++- src/components/db.ts | 39 ++++ src/components/phrase.ts | 278 ++++++++++++++++++++++++++++ 6 files changed, 563 insertions(+), 10 deletions(-) create mode 100644 src/components/phrase.ts 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 index cf9ac3c3..6b2ff9f8 100644 --- a/src/commandDetails/phrase/create.ts +++ b/src/commandDetails/phrase/create.ts @@ -1,16 +1,85 @@ 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, + messageFromUser, + args, ): Promise => { - return; + 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 = { @@ -19,11 +88,20 @@ export const phraseCreateCommandDetails: CodeyCommandDetails = { description: 'Generate phrases of you!', detailedDescription: `**Examples:** \`${container.botPrefix}phrase create\` -\`${container.botPrefix}phrase c\``, +\`${container.botPrefix}phrase c\` +\`${container.botPrefix}phrase create @username\` +\`${container.botPrefix}phrase c @username\``, isCommandResponseEphemeral: false, messageWhenExecutingCommand: 'Creating phrase...', executeCommand: phraseCreateExecuteCommand, - options: [], + 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 index f30dd43a..7203e179 100644 --- a/src/commandDetails/phrase/quit.ts +++ b/src/commandDetails/phrase/quit.ts @@ -3,14 +3,62 @@ 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, + messageFromUser, _args, ): Promise => { - return; + 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 = { diff --git a/src/commandDetails/phrase/signup.ts b/src/commandDetails/phrase/signup.ts index 5b1a10fc..ba69c603 100644 --- a/src/commandDetails/phrase/signup.ts +++ b/src/commandDetails/phrase/signup.ts @@ -1,16 +1,96 @@ 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, + client, + messageFromUser, _args, ): Promise => { - return; + 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 = { diff --git a/src/components/db.ts b/src/components/db.ts index 19dad417..eaf4f67c 100644 --- a/src/components/db.ts +++ b/src/components/db.ts @@ -222,6 +222,43 @@ const initPeopleCompaniesTable = async (db: Database): Promise => { )`); }; +const initPhraseUsersTable = async (db: Database): Promise => { + // 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 => { + // 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 +273,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..74376f1e --- /dev/null +++ b/src/components/phrase.ts @@ -0,0 +1,278 @@ +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, + ); + return rows.map((row) => row.message_content); +}; + +export const collectUserMessages = async ( + client: Client, + userId: string, + guildId: string, + username: string, +): 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; + } + + // 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; + + while (hasMoreMessages) { + 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 + const userMessages = messages.filter( + (msg: Message) => + msg.author.id === userId && + msg.content && + msg.content.trim().length > 0 && + !msg.author.bot && + !msg.content.trim().startsWith('.'), + ); + + // 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) { + 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, + ); + + 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 = 100): string => { + if (messages.length === 0) return "I don't have anything to say!"; + + // Build word transitions map + const transitions: Map = new Map(); + const starters: string[] = []; + + for (const message of messages) { + const words = message + .trim() + .split(/\s+/) + .filter((word) => word.length > 0); + if (words.length === 0) continue; + + // Track sentence starters + starters.push(words[0]); + + // Build transitions + for (let i = 0; i < words.length - 1; i++) { + const currentWord = words[i]; + const nextWord = words[i + 1]; + + if (!transitions.has(currentWord)) { + transitions.set(currentWord, []); + } + transitions.get(currentWord)!.push(nextWord); + } + } + + if (starters.length === 0) return "I don't have anything to say!"; + + // Generate phrase + const result: string[] = []; + let currentWord = starters[Math.floor(Math.random() * starters.length)]; + result.push(currentWord); + + for (let i = 0; i < maxLength && transitions.has(currentWord); i++) { + const possibleNext = transitions.get(currentWord)!; + if (possibleNext.length === 0) break; + + currentWord = possibleNext[Math.floor(Math.random() * possibleNext.length)]; + result.push(currentWord); + + // Stop at sentence endings + if (currentWord.match(/[.!?]$/)) break; + } + + return result.join(' '); +}; From fcb775a1bf433657fae59412da1a0d5e4d852340 Mon Sep 17 00:00:00 2001 From: KuroganeToyama Date: Fri, 10 Oct 2025 16:31:28 +0000 Subject: [PATCH 3/5] added 1 -year message collection cutoff --- src/components/phrase.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/phrase.ts b/src/components/phrase.ts index 74376f1e..1bdd09dc 100644 --- a/src/components/phrase.ts +++ b/src/components/phrase.ts @@ -76,6 +76,10 @@ export const collectUserMessages = async ( return 0; } + // Calculate cutoff date (1 year ago from now) + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + // Get all text channels in the guild const textChannels = guild.channels.cache.filter( (channel) => channel.isTextBased() && channel.type === 0, // GUILD_TEXT @@ -105,16 +109,23 @@ export const collectUserMessages = async ( break; } - // Filter messages from the specific user + // Filter messages from the specific user within the last year 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.content.trim().startsWith('.') && + msg.createdAt >= oneYearAgo, // Only messages from last year ); + // Check if we've gone past the 1-year cutoff + const oldestMessage = messages.last(); + if (oldestMessage && oldestMessage.createdAt < oneYearAgo) { + hasMoreMessages = false; // Stop fetching older messages + } + // Insert messages into database for (const [, message] of userMessages) { try { From 3fc4eea498541b7361228d56bb467fa8f4ba097a Mon Sep 17 00:00:00 2001 From: KuroganeToyama Date: Fri, 10 Oct 2025 16:51:49 +0000 Subject: [PATCH 4/5] improved markov chain --- src/components/phrase.ts | 160 ++++++++++++++++++++++++++++++++------- 1 file changed, 133 insertions(+), 27 deletions(-) diff --git a/src/components/phrase.ts b/src/components/phrase.ts index 1bdd09dc..4c3306f1 100644 --- a/src/components/phrase.ts +++ b/src/components/phrase.ts @@ -57,7 +57,8 @@ export const getUserMessages = async (userId: string, guildId: string): Promise< userId, guildId, ); - return rows.map((row) => row.message_content); + // Sanitize messages when retrieving them (handles legacy data) + return rows.map((row) => sanitizeMessageContent(row.message_content)); }; export const collectUserMessages = async ( @@ -238,52 +239,157 @@ export const signUpUserWithCollection = async ( } }; -export const generateMarkovPhrase = (messages: string[], maxLength = 100): string => { +export const generateMarkovPhrase = (messages: string[], maxLength = 50): string => { if (messages.length === 0) return "I don't have anything to say!"; - // Build word transitions map - const transitions: Map = new Map(); - const starters: string[] = []; + // Build both trigram and bigram transitions for fallback + const trigramTransitions: Map = new Map(); + const bigramTransitions: Map = new Map(); + const trigramStarters: string[] = []; + const bigramStarters: string[] = []; for (const message of messages) { const words = message .trim() .split(/\s+/) .filter((word) => word.length > 0); - if (words.length === 0) continue; - - // Track sentence starters - starters.push(words[0]); + if (words.length < 2) continue; + + // Build trigram data if we have enough words + if (words.length >= 3) { + trigramStarters.push(`${words[0]} ${words[1]} ${words[2]}`); + + for (let i = 0; i < words.length - 3; i++) { + const currentTriplet = `${words[i]} ${words[i + 1]} ${words[i + 2]}`; + const nextWord = words[i + 3]; + + if (!trigramTransitions.has(currentTriplet)) { + trigramTransitions.set(currentTriplet, []); + } + trigramTransitions.get(currentTriplet)!.push(nextWord); + } + } - // Build transitions - for (let i = 0; i < words.length - 1; i++) { - const currentWord = words[i]; - const nextWord = words[i + 1]; + // Always build bigram data as fallback + bigramStarters.push(`${words[0]} ${words[1]}`); + + for (let i = 0; i < words.length - 2; i++) { + const currentPair = `${words[i]} ${words[i + 1]}`; + const nextWord = words[i + 2]; - if (!transitions.has(currentWord)) { - transitions.set(currentWord, []); + if (!bigramTransitions.has(currentPair)) { + bigramTransitions.set(currentPair, []); } - transitions.get(currentWord)!.push(nextWord); + 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 currentWord = starters[Math.floor(Math.random() * starters.length)]; - result.push(currentWord); - - for (let i = 0; i < maxLength && transitions.has(currentWord); i++) { - const possibleNext = transitions.get(currentWord)!; - if (possibleNext.length === 0) break; + 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; + + const nextWord = possibleNext[Math.floor(Math.random() * possibleNext.length)]; + 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; + } + } - currentWord = possibleNext[Math.floor(Math.random() * possibleNext.length)]; - result.push(currentWord); + iterations++; + } - // Stop at sentence endings - if (currentWord.match(/[.!?]$/)) break; + // Ensure we have a reasonable ending + let finalPhrase = result.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 result.join(' '); + return finalPhrase; }; From f08acf33b0de78421f0e7d5dded13a5f7c069859 Mon Sep 17 00:00:00 2001 From: KuroganeToyama Date: Fri, 10 Oct 2025 17:23:19 +0000 Subject: [PATCH 5/5] improved markov chain and added database refreshes --- src/components/cron.ts | 25 ++++ src/components/db.ts | 2 + src/components/phrase.ts | 269 ++++++++++++++++++++++++++++++--------- 3 files changed, 237 insertions(+), 59 deletions(-) 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 eaf4f67c..7a335b14 100644 --- a/src/components/db.ts +++ b/src/components/db.ts @@ -223,6 +223,7 @@ 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 ( @@ -236,6 +237,7 @@ const initPhraseUsersTable = async (db: Database): Promise => { }; 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`); diff --git a/src/components/phrase.ts b/src/components/phrase.ts index 4c3306f1..9b4c4988 100644 --- a/src/components/phrase.ts +++ b/src/components/phrase.ts @@ -66,6 +66,7 @@ export const collectUserMessages = async ( userId: string, guildId: string, username: string, + sinceDate?: Date | null, ): Promise => { const db = await openDB(); let totalCollected = 0; @@ -77,9 +78,14 @@ export const collectUserMessages = async ( return 0; } - // Calculate cutoff date (1 year ago from now) - const oneYearAgo = new Date(); - oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + // 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( @@ -96,8 +102,9 @@ export const collectUserMessages = async ( let lastMessageId: string | undefined; let hasMoreMessages = true; + let foundOldMessage = false; - while (hasMoreMessages) { + while (hasMoreMessages && !foundOldMessage) { const options: { limit: number; before?: string } = { limit: 100 }; if (lastMessageId) { options.before = lastMessageId; @@ -110,7 +117,7 @@ export const collectUserMessages = async ( break; } - // Filter messages from the specific user within the last year + // Filter messages from the specific user within the cutoff date const userMessages = messages.filter( (msg: Message) => msg.author.id === userId && @@ -118,13 +125,13 @@ export const collectUserMessages = async ( msg.content.trim().length > 0 && !msg.author.bot && !msg.content.trim().startsWith('.') && - msg.createdAt >= oneYearAgo, // Only messages from last year + msg.createdAt >= cutoffDate, // Only messages after cutoff ); - // Check if we've gone past the 1-year cutoff + // Check if we've gone past the cutoff date const oldestMessage = messages.last(); - if (oldestMessage && oldestMessage.createdAt < oneYearAgo) { - hasMoreMessages = false; // Stop fetching older messages + if (oldestMessage && oldestMessage.createdAt < cutoffDate) { + foundOldMessage = true; } // Insert messages into database @@ -161,7 +168,7 @@ export const collectUserMessages = async ( // Set up for next iteration const lastMessage = messages.last(); - if (lastMessage && messages.size === 100) { + if (lastMessage && messages.size === 100 && !foundOldMessage) { lastMessageId = lastMessage.id; } else { hasMoreMessages = false; @@ -183,9 +190,12 @@ export const collectUserMessages = async ( guildId, ); - logger.info( - `Collected ${totalCollected} messages for user ${username} (${userId}) in guild ${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); @@ -242,26 +252,35 @@ export const signUpUserWithCollection = async ( 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[] = []; - for (const message of messages) { - const words = message - .trim() - .split(/\s+/) - .filter((word) => word.length > 0); - if (words.length < 2) continue; - - // Build trigram data if we have enough words - if (words.length >= 3) { - trigramStarters.push(`${words[0]} ${words[1]} ${words[2]}`); - - for (let i = 0; i < words.length - 3; i++) { - const currentTriplet = `${words[i]} ${words[i + 1]} ${words[i + 2]}`; - const nextWord = words[i + 3]; + // 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, []); @@ -269,13 +288,20 @@ export const generateMarkovPhrase = (messages: string[], maxLength = 50): string 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); + } - // Always build bigram data as fallback - bigramStarters.push(`${words[0]} ${words[1]}`); - - for (let i = 0; i < words.length - 2; i++) { - const currentPair = `${words[i]} ${words[i + 1]}`; - const nextWord = words[i + 2]; + // Build transitions + if (i < allWords.length - 2) { + const nextWord = allWords[i + 2]; if (!bigramTransitions.has(currentPair)) { bigramTransitions.set(currentPair, []); @@ -303,16 +329,52 @@ export const generateMarkovPhrase = (messages: string[], maxLength = 50): string 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; - const nextWord = possibleNext[Math.floor(Math.random() * possibleNext.length)]; + // 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) @@ -321,27 +383,31 @@ export const generateMarkovPhrase = (messages: string[], maxLength = 50): string // 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)) { + 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); - + 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 @@ -352,37 +418,47 @@ export const generateMarkovPhrase = (messages: string[], maxLength = 50): string 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 = result.join(' '); - + 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)) { + 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)) { + 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')) { + 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 += '!'; @@ -393,3 +469,78 @@ export const generateMarkovPhrase = (messages: string[], maxLength = 50): string 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); + } +};