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);
+ }
+};