rl-warmup-plugin/includes/class-rl-mailwarmer-conversation-helper.php
ruben de40085318 Implement AI-powered conversation generation and improved campaign timeline management
- Add OpenAI integration for realistic email conversation generation
- Enhance campaign timeline algorithm with natural distribution patterns
- Improve message handling with Symfony Mailer components
- Add conversation blueprint system for structured email threads
- Implement visual timeline with heatmap for campaign tracking

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-03-07 01:49:49 -06:00

1305 lines
58 KiB
PHP

<?php
class RL_MailWarmer_Conversation_Handler {
/**
* Process conversations starting within the next 24 hours.
*
* This function finds conversations with a status of "new" that are scheduled to start within the next 24 hours,
* generates them, and updates their status to "generated." Limits to 50 conversations per run.
*/
public static function process_upcoming_conversations() {
// log_to_file("process_upcoming_conversations - Running!");
global $wpdb;
$conversation_table = $wpdb->prefix . 'rl_mailwarmer_conversations';
// $current_time = current_time('mysql');
$start_time = date('Y-m-d H:i:s', strtotime('-96 hours'));
$end_time = date('Y-m-d H:i:s', strtotime('+12 hours'));
// $end_time = date('Y-m-d H:i:s', strtotime('now'));
// Fetch up to 50 conversations starting within the next 24 hours
$conversations = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM $conversation_table WHERE status = %s AND first_message_timestamp BETWEEN %s AND %s ORDER BY first_message_timestamp ASC LIMIT 50",
'new',
$start_time,
$end_time
),
ARRAY_A
);
$conversations_count = count($conversations);
if ($conversations_count > 0) {
log_to_file("process_upcoming_conversations - Processing {$conversations_count} conversations" );
action_log("process_upcoming_conversations - Processing {$conversations_count} conversations" );
foreach ($conversations as $conversation) {
log_to_file("process_upcoming_conversations - Conversation: ", $conversation['id']);
try {
// Generate conversation content using AI
// $ai_response = self::fetch_conversation_from_ai($conversation);
$ai_response = isset($conversation['ai_response']) ? json_decode($conversation['ai_response'], true) : false;
$prompt = $conversation['prompt'];
$model = $conversation['model'];
// log_to_file("process_upcoming_conversations - AI prompt: ", $prompt);
// $ai_response = RL_MailWarmer_Campaign_Helper::clean_ai_response(RL_MailWarmer_Campaign_Helper::generate_ai_conversation($prompt));
if ($ai_response) {
log_to_file("process_upcoming_conversations - Using cached AI Response");
} else {
log_to_file("process_upcoming_conversations - No cached AI Response found. Fetching.");
$raw_ai_response = self::generate_ai_conversation($prompt, $model);
// log_to_file("process_upcoming_conversations - AI Reponse: {$raw_ai_response}");
// $ai_response_type = gettype($raw_ai_response);
// log_to_file("process_upcoming_conversations - AI Reponse is a : ", $ai_response_type);
$ai_response = self::normalize_conversation_format($raw_ai_response);
// Save the generated content to the conversation
$update_data = [
'ai_response' => json_encode($ai_response),
];
$update_db_with_AI_response = RL_MailWarmer_DB_Helper::update_conversation(intval($conversation['id']), $update_data);
// log_to_file("process_upcoming_conversations - update_db_with_AI_response: ", $update_db_with_AI_response);
}
// $ai_response = get_post_meta($conversation['campaign_id'], 'last_ai_response', true);
log_to_file("process_upcoming_conversations - AI Response: ", $ai_response);
if (is_wp_error($ai_response)) {
log_to_file("process_upcoming_conversations - AI Response is an error!");
return $ai_response; // Handle AI generation failure gracefully
}
log_to_file("process_upcoming_conversations - Before merge_timeline_with_ai_response");
$updated_conversation_steps = self::merge_timeline_with_ai_response($conversation['conversation_steps'], $ai_response);
log_to_file("process_upcoming_conversations - Merged steps: ", $updated_conversation_steps);
// // Update conversation status to "generated"
$conversation_table = $wpdb->prefix . 'rl_mailwarmer_conversations';
$status = 'generated';
// log_to_file("process_upcoming_conversations - Updating DB for conversation {$conversation['id']} to status: {$status}. Response: {$ai_response} New steps: ", $updated_conversation_steps);
$result = $wpdb->update(
$conversation_table,
// [],
['status' => $status, 'conversation_steps' => json_encode($updated_conversation_steps), 'ai_response' => json_encode($ai_response)],
['id' => $conversation['id']],
['%s'],
['%s'],
['%s'],
['%d']
);
// log_to_file("process_upcoming_conversations - Update DB Result: ", $result);
$update_messages_db = self::update_messages_with_merged_output($conversation['id'], json_encode($updated_conversation_steps));
log_to_file("process_upcoming_conversations - Result of update_messages_with_merged_output(): ", $update_messages_db);
// Update the conversation with generated steps
log_to_file("process_upcoming_conversations - Updating conversation_steps");
if (self::update_conversation_steps($conversation['id'], $updated_conversation_steps)) {
log_to_file("process_upcoming_conversations - Updated conversation_steps!");
}
} catch (Exception $e) {
error_log("Failed to generate conversation ID {$conversation['id']}: " . $e->getMessage());
}
}
action_log("process_upcoming_conversations - Finished processing {$conversations_count} conversations");
return $conversations_count;
} else {
return 0;
}
}
private static function update_messages_with_merged_output($conversation_id, $merged_output) {
// log_to_file("update_messages_with_merged_output - merged_output: ", $merged_output);
global $wpdb;
$message_table = $wpdb->prefix . 'rl_mailwarmer_messages';
$merged_output = json_decode($merged_output);
$merged_output_length = count($merged_output);
// log_to_file("update_messages_with_merged_output - merged_output length: ", $merged_output_length);
// Fetch all messages for the given conversation ID
$messages = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM $message_table WHERE conversation_id = %d ORDER BY `scheduled_for_timestamp` ASC",
$conversation_id
),
ARRAY_A
);
$messages_length = count($messages);
// log_to_file("update_messages_with_merged_output - Found {$messages_length} messages: ", $messages);
if (empty($messages)) {
throw new Exception("No messages found for conversation ID $conversation_id.");
}
// Iterate through messages and merged output
foreach ($messages as $index => $message) {
if (!isset($merged_output[$index])) {
// log_to_file("update_messages_with_merged_output - No matching message & output found. Skipping! {$index} ");
continue; // Skip if there's no corresponding merged output
} else {
// log_to_file("update_messages_with_merged_output - Processing message {$index} ");
}
// $merged = json_decode($merged_output[$index], true);
$merged = $merged_output[$index];
// log_to_file("update_messages_with_merged_output - Message Data:", $merged);
// $merged_length = count($merged);
// log_to_file("update_messages_with_merged_output - Merged {$merged_length}:", $merged);
// log_to_file("update_messages_with_merged_output - merged[status]:", $merged->status);
// Prepare updated data
// $updated_data = [];
$updated_data = [
'status' => $merged->status,
'scheduled_for_timestamp' => $merged->scheduled_for,
'from_email' => $merged->from,
'to_email' => json_encode($merged->to),
'cc' => json_encode($merged->cc),
'subject' => $merged->subject,
'body' => $merged->body,
];
// log_to_file("update_messages_with_merged_output - Updated Data: ", $updated_data);
// Update the database
$db_update_message_result = $wpdb->update(
$message_table,
$updated_data,
['id' => intval($message['id'])], // Ensure the correct message ID is passed here
['%s', '%s', '%s', '%s', '%s', '%s', '%s'], // Format specifiers
['%d']
);
if ($db_update_message_result) {
log_to_file("update_messages_with_merged_output - DB Update result: ", $db_update_message_result);
} else {
$last_query = $wpdb->last_query;
log_to_file("update_messages_with_merged_output - Problem updating DB: ", $last_query);
return false;
}
}
return true;
}
public static function merge_timeline_with_ai_response($conversation_string, $ai_response) {
$conversation = json_decode($conversation_string, true);
$conversation_type = gettype($conversation);
$ai_response_type = gettype($ai_response);
// log_to_file("merge_timeline_with_ai_response - Conversation type: ", $conversation_type);
// log_to_file("merge_timeline_with_ai_response - AI Response type: ", $ai_response_type);
// log_to_file("merge_timeline_with_ai_response - Conversation: ", $conversation);
// log_to_file("merge_timeline_with_ai_response - AI Response: ", $ai_response);
// Ensure both arrays have the same number of elements
// log_to_file("merge_timeline_with_ai_response - Counting arrays");
$merged = [];
$count_timeline = count($conversation);
$count_ai_response = count($ai_response);
// log_to_file("merge_timeline_with_ai_response - Timeline Count: {$count_timeline} AI Response Count: {$count_ai_response}");
if ($count_ai_response >= $count_timeline) {
// Trim the AI responses to match the timeline count
// log_to_file("merge_timeline_with_ai_response - Trimming AI messages to merge with conversation");
$ai_response = array_slice($ai_response, 0, $count_timeline);
} elseif ($count_ai_response < $count_timeline) {
// log_to_file("merge_timeline_with_ai_response - AI response is too short to merge with conversation");
throw new Exception('The number of AI responses is less than the timeline steps.');
}
// log_to_file("merge_timeline_with_ai_response - Attempting merge");
// Merge corresponding elements
foreach ($conversation as $index => $timeline_step) {
$ai_message = $ai_response[$index];
$timeline_step_type = gettype($timeline_step);
$ai_message_type = gettype($ai_message);
// log_to_file("merge_timeline_with_ai_response - Conversation step type: ", $timeline_step_type);
// log_to_file("merge_timeline_with_ai_response - AI Message type: ", $ai_message_type);
$merge_result = array_merge($timeline_step, $ai_message);
// log_to_file("merge_timeline_with_ai_response - Merged: ", $merge_result);
$merged[] = $merge_result;
}
// log_to_file("merge_timeline_with_ai_response - Merged Steps: ", $merged);
// Return the merged result as JSON
return $merged;
}
/**
* Update the conversation steps for a given conversation ID.
*
* @param int $conversation_id The ID of the conversation to update.
* @param string $ai_response The AI-generated response in JSON format.
* @return bool|WP_Error True on success, WP_Error on failure.
*/
public static function update_conversation_steps($conversation_id, $ai_response) {
global $wpdb;
// log_to_file("update_conversation_steps - Running! $conversation_id ", $ai_response);
// Validate input
if (empty($conversation_id) || !is_int($conversation_id)) {
return new WP_Error('invalid_conversation_id', __('Invalid conversation ID.', 'rl-mailwarmer'));
}
if (empty($ai_response) || !is_string($ai_response)) {
return new WP_Error('invalid_ai_response', __('Invalid AI response.', 'rl-mailwarmer'));
}
// Define the table name
$table_name = $wpdb->prefix . 'rl_mailwarmer_conversation';
// Prepare the data
$data = [
'conversation_steps' => $ai_response, // Save the JSON response as a string
];
$where = [
'id' => $conversation_id,
];
// Update the database
$result = $wpdb->update($table_name, $data, $where, ['%s'], ['%d']);
if ($result === false) {
return new WP_Error('db_update_failed', __('Failed to update the conversation steps.', 'rl-mailwarmer'));
}
// log_to_file("update_conversation_steps - Updated conversation steps for #$conversation_id");
return true;
}
/**
* Generate a conversation blueprint for a campaign.
*
* @param int $campaign_id The ID of the campaign.
* @param int $number_responses The number of responses/messages to schedule. Defaults to 0.
* @return int The ID of the created conversation in the database.
* @throws Exception If required data is missing or saving fails.
*/
public static function generate_conversation_blueprint($campaign_id, $num_particpants = 1, $number_responses = 1, $start_date = false, $end_date = false, $filled_dates = []) {
global $wpdb;
// log_to_file("generate_conversation_blueprint - {$campaign_id} {$number_responses} {$start_date} {$end_date}", $filled_dates);
$campaign_limited = get_post_meta($campaign_id, 'campaign_limited', true);
// Fetch the start date for the campaign if one isn't passed
if (!$start_date) {
$start_date = get_post_meta($campaign_id, 'start_date', true);
if (!$start_date) {
throw new Exception(__('generate_conversation_blueprint - Campaign start date is missing.', 'rl-mailwarmer'));
}
}
// log_to_file("generate_conversation_blueprint - Generating conversation with starting date: $start_date & end date: $end_date");
// Step 1: Generate placeholders for the conversation steps
$array_args = ['step' => '', 'status' => 'scheduled'];
$conversation_steps_placeholder = array_fill(0, $number_responses, $array_args);
// log_to_file("generate_conversation_blueprint - Conversation Steps Placeholder", $conversation_steps_placeholder);
$conversation_steps = self::add_timestamps_to_conversation($conversation_steps_placeholder, $start_date, $end_date, $filled_dates);
// log_to_file("generate_conversation_blueprint - Conversation Steps", $conversation_steps);
// AI Prompt Defaults
$args = [
'initiated_by' => null,
'received_by' => null,
'subject' => null,
'length' => null,
'num_participants' => $num_particpants,
'num_responses' => $number_responses,
'reply_pool' => [],
'cc_pool' => [],
];
// $args = wp_parse_args($args, $defaults);
// $args['num_responses'] = $number_responses;
$campaign_tracking_id = get_post_meta($campaign_id, 'campaign_tracking_id', true);
// Fetch campaign target profession
// log_to_file("generate_conversation_blueprint - Target Profession");
$target_profession = get_field('target_profession', $campaign_id);
$from_emails = get_post_meta($campaign_id, 'email_accounts', true);
log_to_file("generate_conversation_blueprint - From Emails", $from_emails);
if (!$from_emails) {
throw new Exception(__('generate_conversation_blueprint - from_email is missing.', 'rl-mailwarmer'));
}
$from_emails_key = array_rand($from_emails, 1);
$from_address = get_the_title($from_emails[$from_emails_key]);
// log_to_file("generate_conversation_blueprint - From Email ID", $from_emails[$from_emails_key]);
// log_to_file("generate_conversation_blueprint - From Email Address", $from_address);
// $from_pool = [];
// foreach ($from_emails as $email_id) {
// }
// $email = get_post($from_email);
// log_to_file("generate_conversation_blueprint - From pool: ", $from_pool);
// Fetch scrubber pool if 'received_by' is not passed
if (!isset($args['received_by'])) {
$scrubber_pool_full = self::get_email_accounts_for_pool($campaign_id, 'scrubber_test');
// Pick $num_particpants random addresses
$num_scrubbers = min($num_particpants, count($scrubber_pool_full));
$scrubber_pool = array_rand(array_flip($scrubber_pool_full), $num_scrubbers);
// Ensure $random_keys is an array
if ($num_scrubbers === 1) {
$args['received_by'] = [$scrubber_pool];
} else {
$args['received_by'] = $scrubber_pool;
}
log_to_file("generate_conversation_blueprint - Scrubber Pool: ", $args['received_by']);
}
// Fetch the CC Pool
// log_to_file("generate_conversation_blueprint - CC Pool");
// $cc_pool = self::get_email_accounts_for_pool($campaign_id, 'cc');
// // Get up to 4 random items
// $num_to_select = min(4, count($cc_pool)); // Ensure we don't request more items than are available
// $random_keys = array_rand($cc_pool, $num_to_select);
// If only one item is selected, ensure it's returned as an array
// $args['cc_pool'] = is_array($random_keys) ? array_intersect_key($cc_pool, array_flip($random_keys)) : [$cc_pool[$random_keys]];
// log_to_file("generate_conversation_blueprint - Reply Pool");
// Fetch the Reply pool if there will be any replies needed
if ( intval($args['num_responses']) > 0) {
$reply_pool = self::get_email_accounts_for_pool($campaign_id, 'reply');
$args['reply_pool'] = array_values(array_intersect($args['received_by'], $reply_pool));
log_to_file("generate_conversation_blueprint - Reply Pool: ", $args['reply_pool']);
}
// log_to_file("generate_conversation_blueprint - Conversation Topics");
// Fetch topics for the conversation if 'subject' is not passed
if (!$args['subject']) {
$default_topics = get_option('options_default_topic_pool') ? rl_get_textarea_meta_as_array("option", "default_topic_pool") : [];
$campaign_topics = get_post_meta($campaign_id, 'campaign_conversation_topics', true) ? rl_get_textarea_meta_as_array($campaign_id, 'campaign_conversation_topics') : [];
// $topics_2 = get_post_meta($campaign_id, 'campaign_conversation_topics', true);
// log_to_file("generate_conversation_blueprint - Default topics:", $default_topics);
// log_to_file("generate_conversation_blueprint - Campaign topics:", $campaign_topics);
// log_to_file("generate_conversation_blueprint - Campaign topics2 : $topics_2");
$all_topics = array_merge($default_topics, $campaign_topics);
// $all_topics_count = count($all_topics);
// log_to_file("generate_conversation - All topics $all_topics_count: ", $all_topics);
if (!empty($all_topics)) {
$args['subject'] = $all_topics[array_rand($all_topics)];
} else {
$args['subject'] = __('General Inquiry', 'rl-mailwarmer');
}
}
log_to_file("generate_conversation_blueprint - Conversation args", $args);
// Generate the prompt
if ($campaign_limited || (intval($args['num_responses']) === 1) ) {
$chat_model = 'gpt-4o-mini';
// $chat_model = 'o3-mini';
$prompt = sprintf(
"Generate a single JSON email from {$from_address} to one or more addresses in 'to_pool' with no replies. Fields to return: from, to, cc, subject, body, valediction. The valediction should be separate from the body. Return only JSON, no notes\n%s",
json_encode([
'profession' => $target_profession,
'from_address' => $from_address,
'to_pool' => $args['received_by'],
'subject' => $args['subject'],
'num_of_replies' => 1,
'available_to_cc' => $args['cc_pool'],
])
);
} else {
$chat_model = 'gpt-4o';
// $chat_model = 'o3-mini';
$num_replies = intval($args['num_responses']) + 1;
$prompt = sprintf(
"Generate a JSON email conversation with distinct participant personalities; up to 5%% errors; initiating email can be sent to multiple people; replies and follow-ups only from the sender or addresses in both 'can_reply' AND 'to_pool'. Fields to return: from, to, cc, subject, body, valediction. The valediction should be separate from the body. Return only JSON, no notes\n%s",
json_encode([
'profession' => $target_profession,
'from_address' => $from_address,
'to_pool' => $args['received_by'],
'subject' => $args['subject'],
'num_of_replies' => $num_replies,
'can_reply' => $args['reply_pool'],
'available_to_cc' => $args['cc_pool'],
])
);
}
// log_to_file("From prompt: ", $prompt);
// Step 2: Save the conversation to the database
$conversation_data = [
'campaign_ID' => $campaign_id,
'email_account_id' => $from_emails[$from_emails_key],
'created_at' => current_time('mysql'),
'status' => 'new',
'first_message_timestamp' => $conversation_steps[0]['scheduled_for'],
'prompt' => $prompt,
'model' => $chat_model,
'conversation_steps' => json_encode($conversation_steps),
'campaign_tracking_id' => $campaign_tracking_id,
];
// log_to_file("generate_conversation_blueprint - Conversation Data: ", $conversation_data);
$conversation_id = RL_MailWarmer_DB_Helper::insert_conversation($conversation_data);
// $conversation_id = 69;
// $conversation_table = $wpdb->prefix . 'rl_mailwarmer_conversation';
// $wpdb->insert($conversation_table, $conversation_data);
// $conversation_id = $wpdb->insert_id;
if (!$conversation_id) {
throw new Exception(__('generate_conversation_blueprint - Failed to save the conversation blueprint.', 'rl-mailwarmer'));
}
// Step 3: Save the individual message placeholders to the messages table
// $message_table = $wpdb->prefix . 'rl_mailwarmer_messages';
$previous_message_id = null; // Initialize the previous message ID
foreach ($conversation_steps as $index => $step) {
$is_first_message = $index === 0 ? 1 : 0;
$message_data = [
'campaign_ID' => $campaign_id,
'campaign_tracking_id' => $campaign_tracking_id . '-' .$conversation_id,
'email_account_id' => $from_emails[$from_emails_key],
'conversation_ID' => $conversation_id,
'scheduled_for_timestamp' => $step['scheduled_for'],
'status' => 'new',
'first_message' => $is_first_message,
'previous_message_id' => $previous_message_id, // Include previous message ID here
];
log_to_file("generate_conversation_blueprint - Message Data: ", $message_data);
$message_id = RL_MailWarmer_DB_Helper::insert_message($message_data);
if (!$message_id) {
throw new Exception(__('generate_conversation_blueprint - Failed to save the message blueprint.', 'rl-mailwarmer'));
}
// Save the message_id back to the current step
$conversation_steps[$index]['message_id'] = $message_id;
// Update the previous_message_id for the next iteration
$previous_message_id = $message_id;
}
// Update the conversation with the message IDS
$update_data['conversation_steps'] = json_encode($conversation_steps);
// log_to_file("generate_conversation_blueprint - Update Data: ", $update_data);
RL_MailWarmer_DB_Helper::update_conversation($conversation_id, $update_data);
return $conversation_steps;
}
/**
* Generate a single conversation for a campaign - On the admin page sidebar
*
* @param int $campaign_id The ID of the campaign.
* @param array $args Optional arguments to customize the conversation.
* @return int|WP_Error The ID of the created conversation or WP_Error on failure.
*/
public static function generate_conversation($campaign_id, $args = []) {
if (empty($campaign_id)) {
return new WP_Error('invalid_campaign', __('Campaign ID is required.', 'rl-mailwarmer'));
}
// Fetch campaign details
$campaign = get_post($campaign_id);
if (!$campaign || $campaign->post_type !== 'campaign') {
return new WP_Error('invalid_campaign', __('Invalid campaign.', 'rl-mailwarmer'));
}
// Defaults
$defaults = [
'initiated_by' => null,
'received_by' => null,
'subject' => null,
'length' => null,
'num_participants' => null,
'num_responses' => null,
'reply_pool' => [],
'cc_pool' => [],
];
$args = wp_parse_args($args, $defaults);
// Fetch email accounts for this campaign's domain if 'initiated_by' is not passed
if (!$args['initiated_by']) {
$campaign_domain = get_field('domain', $campaign_id);
$email_accounts = get_posts([
'post_type' => 'email-account',
'meta_query' => [
[
'key' => 'domain',
'value' => $campaign_domain->ID,
'compare' => '=',
],
],
'posts_per_page' => -1,
'fields' => 'ids',
]);
if (empty($email_accounts)) {
return new WP_Error('no_initiator', __('No email accounts available for the campaign domain.', 'rl-mailwarmer'));
}
$args['initiated_by'] = $email_accounts[array_rand($email_accounts)];
}
if ( (int) $args['length'] > 2 ) {
$chat_model = 'gpt-4o';
} else {
$chat_model = 'gpt-4o-mini';
}
// fetch the email address
// $from_id = $args['initiated_by'];
// $from_email = get_the_title($from_id);
// $args['initiated_by'] = $from_email;
// Set length defaults for participants and responses
if ($args['length']) {
$length_defaults = [
'extra-long' => ['num_participants' => mt_rand(2, 8), 'num_responses' => mt_rand(5, 8)],
'long' => ['num_participants' => mt_rand(3, 6), 'num_responses' => mt_rand(4, 6)],
'medium' => ['num_participants' => mt_rand(3, 5), 'num_responses' => mt_rand(3, 5)],
'short' => ['num_participants' => mt_rand(2, 4), 'num_responses' => mt_rand(1, 3)],
'single' => ['num_participants' => mt_rand(2, 6), 'num_responses' => 0],
];
if (isset($length_defaults[$args['length']])) {
$defaults = $length_defaults[$args['length']];
// log_to_file("Length defaults: ", $defaults['num_participants']);
if (isset($args['num_participants'])) {
unset($defaults['num_participants']);
}
if (isset($args['num_responses'])) {
unset($defaults['num_responses']);
}
$args = array_merge($args, $length_defaults[$args['length']]);
}
} else {
if (!$args['num_participants']) {
$args['num_participants'] = 2;
}
if (!$args['num_responses']) {
$args['num_responses'] = 0;
}
}
// Fetch topics for the conversation if 'subject' is not passed
if (!$args['subject']) {
$default_topics = get_option('options_default_topic_pool') ? rl_get_textarea_meta_as_array("option", "default_topic_pool") : [];
$campaign_topics = get_post_meta($campaign_id, 'campaign_conversation_topics', true) ? rl_get_textarea_meta_as_array($campaign_id, 'campaign_conversation_topics') : [];
$all_topics = array_merge($default_topics, $campaign_topics);
// $all_topics_count = count($all_topics);
// log_to_file("generate_conversation - All topics $all_topics_count: ", $all_topics);
if (!empty($all_topics)) {
$args['subject'] = $all_topics[array_rand($all_topics)];
} else {
$args['subject'] = __('General Inquiry', 'rl-mailwarmer');
}
}
// Fetch campaign duration
$start_date = get_field('start_date', $campaign_id);
$warmup_period = get_field('warmup_period', $campaign_id);
$end_date = date('Ymd', strtotime("+{$warmup_period} weeks", strtotime($start_date)));
// Fetch scrubber pool if 'received_by' is not passed
if (!$args['received_by']) {
$args['received_by'] = self::get_email_accounts_for_pool($campaign_id, 'scrubber_test');
// $args['received_by'] = get_field('scrubber_pool', 'option') ? rl_get_textarea_meta_as_array('option', 'options_scrubber_pool') : '';
}
// Fetch the CC Pool
$args['cc_pool'] = self::get_email_accounts_for_pool($campaign_id, 'cc');
// Fetch the Reply pool if there will be any replies needed
if ( intval($args['num_responses']) > 0) {
$args['reply_pool'] = self::get_email_accounts_for_pool($campaign_id, 'reply');
}
// Fetch campaign target profession
$target_profession = get_field('target_profession', $campaign_id);
// Generate the prompt
$prompt = sprintf(
"Generate a JSON email conversation with distinct participant personalities, up to 5%% errors, and replies only from the sender or addresses in both 'can_reply' and 'to_pool'. Include: from, to, cc, subject, body\n%s",
json_encode([
'profession' => $target_profession,
'from' => $args['initiated_by'],
'to_pool' => $args['received_by'],
'subject' => $args['subject'],
'num_of_replies' => $args['num_responses'],
'num_of_participants' => $args['num_participants'],
'can_reply' => $args['reply_pool'],
'available_to_cc' => $args['cc_pool'],
], JSON_PRETTY_PRINT)
);
log_to_file("generate_conversation - Prompt: $prompt");
// Save the conversation to the database
$conversation_steps = []; // Placeholder until AI generates steps
$conversation_id = RL_MailWarmer_DB_Helper::insert_conversation($campaign_id, $conversation_steps, $prompt, $chat_model);
if (!$conversation_id) {
return new WP_Error('db_error', __('Failed to create conversation.', 'rl-mailwarmer'));
}
// Generate AI content for the conversation
$ai_response[] = self::clean_ai_response(self::generate_ai_conversation($prompt,$chat_model));
// $ai_response = self::clean_ai_response(get_post_meta($campaign_id, 'last_ai_response', true));
if (is_wp_error($ai_response[0])) {
return $ai_response[0]; // Handle AI generation failure gracefully
}
// Update the conversation with generated steps - ??????????????????
// self::update_conversation_steps($conversation_id, $ai_response);
log_to_file("generate_conversation - AI response: ", $ai_response);
// update_post_meta($campaign_id, 'last_ai_response', wp_slash($ai_response));
// Parse the AI response and save messages
// $parsed_steps = self::parse_ai_response($ai_response);
$array_args = ['step' => ''];
$parsed_steps = array_fill(0, $args['num_responses'], $array_args );
// Save each timestamp into the parsed steps
$parsed_steps = self::add_timestamps_to_conversation($parsed_steps, $start_date);
log_to_file("generate_conversation - parsed_steps: ", $parsed_steps);
foreach ($parsed_steps as $index => $step) {
if (!empty($step['scheduled_for'])) {
$is_first_message = ($index === 0);
if (is_array($step['to'])) {
$step['to'] = implode(', ', $step['to']);
}
if (is_array($step['cc'])) {
$step['cc'] = implode(', ', $step['cc']);
}
// log_to_file("generate_conversation - message body: ", $step['body']);
// RL_MailWarmer_DB_Helper::insert_message(
// $campaign_id,
// $conversation_id,
// $step['scheduled_for'],
// $step['from'],
// $step['to'],
// $step['cc'],
// $step['subject'],
// $step['body'],
// $is_first_message
// );
}
}
return $conversation_id;
}
/**
* Fetch all published email accounts matching the campaign domain with "include_in_cc_pool" set to true.
*
* @param int $campaign_id The ID of the campaign.
* @param string $pool The name of the pool. Valid options are: cc, reply
* @return array The email addresses to include in the CC pool.
*/
private static function get_email_accounts_for_pool($campaign_id, $pool) {
// Get the domain associated with the campaign
$domain_id = get_post_meta($campaign_id, 'domain', true);
if (empty($domain_id)) {
return []; // Return an empty array if no domain is found
}
// Query email accounts
$args = [
'post_type' => 'email-account',
'post_status' => 'publish',
'meta_query' => [
'relation' => 'AND',
[
'key' => 'include_in_' . $pool . '_pool',
'value' => true,
'compare' => '=',
],
// [
// 'key' => 'domain',
// 'value' => $domain_id,
// 'compare' => '=',
// ],
],
'fields' => 'ids', // Retrieve only IDs for efficiency
'posts_per_page' => -1, // Fetch all matching posts
];
$query = new WP_Query($args);
$email_pool = [];
if ($query->have_posts()) {
foreach ($query->posts as $post_id) {
$email_address = get_the_title($post_id);
if (!empty($email_address)) {
$email_pool[] = $email_address;
}
}
}
return $email_pool;
}
/**
* Add timestamps to parsed conversation steps.
*
* @param array $parsed_steps The parsed steps of the conversation.
* @param string $start_date The starting date for the timestamps.
* @return array The updated conversation steps with timestamps and step indices.
*/
private static function add_timestamps_to_conversation($parsed_steps, $start_date, $end_date = NULL, $filled_dates = []) {
// Ensure parsed_steps is an array
if (!is_array($parsed_steps) || empty($parsed_steps)) {
throw new Exception(__('Parsed steps must be a non-empty array.', 'rl-mailwarmer'));
}
// Initialize the updated steps array
$updated_steps = [];
// Iterate through each step and assign timestamps
foreach ($parsed_steps as $index => $step) {
// If this is not the first step, ensure the start_date is at least 5 minutes after the previous step
if ($index > 0) {
$previous_timestamp = strtotime($updated_steps[$index - 1]['scheduled_for']);
$start_date = date('Y-m-d H:i:s', max($timestamp, $previous_timestamp + 300)); // 300 seconds = 5 minutes
}
// Use the helper function to generate a random timestamp
$timestamp = self::generate_random_date_time($start_date, $end_date, $filled_dates );
// log_to_file("add_timestamps_to_conversation - Returned random timestamp: $timestamp");
// Update the step with the scheduled timestamp and step index
$step['scheduled_for'] = date('Y-m-d H:i:s', $timestamp);
$step['step'] = $index + 1;
// Update the starting date for the next iteration
$start_date = date('Y-m-d H:i:s', $timestamp);
// Append the updated step
$updated_steps[] = $step;
}
// log_to_file("add_timestamps_to_conversation - Updated steps: ", $updated_steps);
return $updated_steps;
}
/**
* Generate a semi-randomized timestamp with a random date and time.
*
* @param string $start_date The start date in 'Y-m-d' format. Defaults to today.
* @param string $end_date The end date in 'Y-m-d' format. Defaults to today + 7 days.
* @param string $start_time The start time in 'H:i' format (e.g., '08:00'). Defaults to '08:00'.
* @param string $end_time The end time in 'H:i' format (e.g., '18:00'). Defaults to '18:00'.
* @return int The random timestamp.
*/
private static function generate_random_date_time($start_date = null, $end_date = null, $filled_dates = [], $start_time = '07:20', $end_time = '21:00' ) {
// Default dates
$start_date = $start_date ?: date('Y-m-d H:i');
$end_date = $end_date ?: date('Y-m-d H:i', strtotime($start_date . ' +8 days'));
log_to_file("generate_random_date_time - Generating random date time between $start_date & $end_date");
$start_date_timestamp = strtotime($start_date);
$end_date_timestamp = strtotime($end_date);
$retry = false;
if ($end_date_timestamp < $start_date_timestamp) {
throw new Exception('generate_random_date_time - End date must be greater than or equal to start date.');
} else if ( $end_date_timestamp === $start_date_timestamp ) {
// Single-part conversations have the same start & end date
$random_date = date('Y-m-d', $start_date_timestamp);
// log_to_file("generate_random_date_time - Using the same start & end date");
} else {
// Generate a random date within the timeframe
log_to_file("generate_random_date_time - Finding a date that's not in \$filled_dates");
do {
if ($retry) {
// log_to_file("generate_random_date_time - Regenerating random date because it was found in filled_dates.");
}
// Generate a random date between the start and end dates
$random_date_timestamp = mt_rand($start_date_timestamp, $end_date_timestamp);
$random_date = date('Y-m-d', $random_date_timestamp);
$retry = true;
log_to_file("generate_random_date_time - Random date $random_date");
log_to_file("generate_random_date_time - Filled Dates: ", $filled_dates);
} while (in_array($random_date, $filled_dates));
log_to_file("generate_random_date_time - Random date found!");
}
// Generate a random time within the specified range for the selected date
log_to_file("generate_random_date_time - calling generate_random_time( $random_date, $start_time, $end_time)");
$random_time_timestamp = self::generate_random_time($random_date, $start_time, $end_time);
// log_to_file("generate_random_date_time - Returned random timestamp: $random_time_timestamp");
return $random_time_timestamp;
}
/**
* Generate a random timestamp for a given date between specific hours.
*
* @param string $date The date in 'Y-m-d' format.
* @param string $start_time The start time in 'H:i' format.
* @param string $end_time The end time in 'H:i' format.
* @return int The random timestamp.
*/
private static function generate_random_time($date, $start_time, $end_time) {
// Convert start and end times to timestamps
$start_timestamp = strtotime("$date $start_time");
$end_timestamp = strtotime("$date $end_time");
// log_to_file("generate_random_time - Start: $start_timestamp End: $end_timestamp");
if ($end_timestamp < $start_timestamp) {
throw new Exception('generate_random_time - End time must be greater or equal to start time.');
}
// Generate a random timestamp within the time range
$timestamp = mt_rand($start_timestamp, $end_timestamp);
// log_to_file("generate_random_time - Random Timestamp: $timestamp");
return $timestamp;
}
/**
* Generate AI content for the conversation using OpenAI's ChatGPT API.
*
* @param string $prompt The prompt to send to ChatGPT.
* @return string|WP_Error The AI response or WP_Error on failure.
*/
public static function generate_ai_conversation($prompt, $model) {
// $api_key = getenv('OPENAI_API_KEY');
$api_key = get_field('chatgpt_api_key', 'option');
if (empty($api_key)) {
return new WP_Error('missing_api_key', __('OpenAI API key is not configured.', 'rl-mailwarmer'));
}
try {
$client = \OpenAI::client($api_key);
$response = $client->chat()->create([
'model' => $model,
'messages' => [
['role' => 'system', 'content' => 'You are a helpful assistant specialized in generating email conversations.'],
['role' => 'user', 'content' => $prompt],
],
'temperature' => 0.7,
'max_tokens' => 3000,
]);
return self::clean_ai_response($response['choices'][0]['message']['content']);
} catch (\Exception $e) {
return new WP_Error('api_error', $e->getMessage());
}
}
/**
* Clean the AI response by removing Markdown-style code block delimiters.
*
* @param string $response The raw AI response.
* @return string The cleaned JSON string.
*/
private static function clean_ai_response($response) {
// Use regex to remove the ```json and ``` delimiters
return preg_replace('/^```json\s*|```$/m', '', trim($response));
}
private static function normalize_conversation_format($ai_response_string) {
log_to_file("normalize_conversation_format - Running");
// log_to_file("normalize_conversation_format - Response: {}");
// Decode JSON string to PHP array/object
$decoded = json_decode($ai_response_string, true);
// If decoding failed, return empty array
if (!$decoded) {
log_to_file("normalize_conversation_format - Failed to json_decode ai_response_string");
return [];
}
// Check if the response is a flat object (single email)
if (isset($decoded['from'])) {
// It's a single email, wrap it in an array
log_to_file("normalize_conversation_format - Single response. Converting to multi-dimensional array");
return [$decoded];
}
// Already a multi-dimensional array, return as is
log_to_file("normalize_conversation_format - Returning as is");
return $decoded;
}
}
add_action('admin_menu', function () {
add_menu_page(
__('Conversations', 'rl-mailwarmer'), // Page title
__('Conversations', 'rl-mailwarmer'), // Menu title
'manage_options', // Capability
'rl-mailwarmer-conversations', // Menu slug
'rl_mailwarmer_render_conversations_page', // Callback function
'dashicons-email', // Icon
8 // Position
);
});
/**
* Render the Conversations admin page with checkboxes and a Delete button.
*/
function rl_mailwarmer_render_conversations_page() {
if (!current_user_can('manage_options')) {
return;
}
global $wpdb;
$table_name = $wpdb->prefix . 'rl_mailwarmer_conversations'; // Conversation table
$conversations = $wpdb->get_results("SELECT * FROM $table_name ORDER BY created_at DESC");
?>
<div class="wrap">
<h1><?php esc_html_e('Conversations', 'rl-mailwarmer'); ?></h1>
<?php if (empty($conversations)) : ?>
<p><?php esc_html_e('No conversations found.', 'rl-mailwarmer'); ?></p>
<?php else : ?>
<form id="conversation-management-form" method="post">
<table class="widefat fixed striped">
<thead>
<tr>
<th style="width: 50px;">
<input type="checkbox" id="check-all-conversations">
</th>
<th><?php esc_html_e('ID', 'rl-mailwarmer'); ?></th>
<th><?php esc_html_e('Campaign ID', 'rl-mailwarmer'); ?></th>
<th><?php esc_html_e('Created At', 'rl-mailwarmer'); ?></th>
<th><?php esc_html_e('Steps', 'rl-mailwarmer'); ?></th>
<th><?php esc_html_e('Prompt', 'rl-mailwarmer'); ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($conversations as $conversation) : ?>
<tr>
<td>
<input type="checkbox" name="selected_conversations[]" value="<?php echo esc_attr($conversation->id); ?>">
</td>
<td><?php echo esc_html($conversation->id); ?></td>
<td><?php echo esc_html($conversation->campaign_id); ?></td>
<td><?php echo esc_html($conversation->created_at); ?></td>
<td><?php echo esc_html($conversation->conversation_steps); ?></td>
<td>
<details>
<summary><?php esc_html_e('View Prompt', 'rl-mailwarmer'); ?></summary>
<pre><?php echo esc_html($conversation->prompt); ?></pre>
</details>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p>
<button type="button" id="delete-selected-conversations" class="button button-primary">
<?php esc_html_e('Delete Items', 'rl-mailwarmer'); ?>
</button>
</p>
</form>
<?php endif; ?>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Select all conversations
const checkAll = document.getElementById('check-all-conversations');
const checkboxes = document.querySelectorAll('input[name="selected_conversations[]"]');
checkAll.addEventListener('change', function () {
checkboxes.forEach(checkbox => checkbox.checked = checkAll.checked);
});
// Handle delete button click
document.getElementById('delete-selected-conversations').addEventListener('click', function () {
const selectedIds = Array.from(checkboxes)
.filter(checkbox => checkbox.checked)
.map(checkbox => checkbox.value);
if (selectedIds.length === 0) {
alert('<?php esc_html_e('Please select at least one conversation to delete.', 'rl-mailwarmer'); ?>');
return;
}
if (confirm('<?php esc_html_e('Are you sure you want to delete the selected conversations?', 'rl-mailwarmer'); ?>')) {
jQuery.post(ajaxurl, {
action: 'rl_delete_conversations',
conversation_ids: selectedIds,
}, function (response) {
if (response.success) {
alert('<?php esc_html_e('Selected conversations deleted successfully.', 'rl-mailwarmer'); ?>');
location.reload(); // Reload the page to update the list
} else {
alert('<?php esc_html_e('Failed to delete conversations.', 'rl-mailwarmer'); ?>');
}
});
}
});
});
</script>
<?php
}
function rl_mailwarmer_get_conversations($page, $per_page = 20) {
global $wpdb;
$offset = ($page - 1) * $per_page;
$total = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}rl_mailwarmer_conversation");
$conversations = $wpdb->get_results($wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}rl_mailwarmer_conversation ORDER BY created_at DESC LIMIT %d OFFSET %d",
$per_page,
$offset
));
return ['conversations' => $conversations, 'total' => $total];
}
add_action('wp_ajax_rl_delete_conversations', function () {
global $wpdb;
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('Permission denied.', 'rl-mailwarmer')]);
}
$conversation_ids = isset($_POST['conversation_ids']) ? array_map('intval', $_POST['conversation_ids']) : [];
if (empty($conversation_ids)) {
wp_send_json_error(['message' => __('No conversations selected.', 'rl-mailwarmer')]);
}
$table_name = $wpdb->prefix . 'rl_mailwarmer_conversation';
// Delete selected conversations
$placeholders = implode(',', array_fill(0, count($conversation_ids), '%d'));
$query = "DELETE FROM $table_name WHERE id IN ($placeholders)";
$result = $wpdb->query($wpdb->prepare($query, $conversation_ids));
if ($result === false) {
wp_send_json_error(['message' => __('Failed to delete conversations.', 'rl-mailwarmer')]);
}
wp_send_json_success(['message' => __('Conversations deleted successfully.', 'rl-mailwarmer')]);
});
// Add the metabox
add_action('add_meta_boxes', 'rl_mailwarmer_add_process_conversations_metabox');
function rl_mailwarmer_add_process_conversations_metabox() {
add_meta_box(
'rl-process-conversations',
__('Process Upcoming Conversations', 'rl-mailwarmer'),
'rl_mailwarmer_process_conversations_metabox_callback',
'campaign',
'side',
'default'
);
}
// Metabox callback function
function rl_mailwarmer_process_conversations_metabox_callback($post) {
// Nonce field for security
wp_nonce_field('rl_process_conversations_nonce', 'rl_process_conversations_nonce_field');
?>
<button type="button" id="rl_process_conversations_button" class="button button-primary">
<?php _e('Process Upcoming Conversations', 'rl-mailwarmer'); ?>
</button>
<div id="rl_process_conversations_result" style="margin-top: 10px;"></div>
<?php
}
// Enqueue JavaScript
add_action('admin_enqueue_scripts', 'rl_mailwarmer_enqueue_process_conversations_script');
function rl_mailwarmer_enqueue_process_conversations_script($hook) {
if ($hook === 'post.php' || $hook === 'post-new.php') {
wp_enqueue_script(
'rl-process-conversations',
RL_MAILWARMER_URL . 'js/rl-process-conversations.js',
['jquery'],
'1.0',
true
);
wp_localize_script('rl-process-conversations', 'rlProcessConversations', [
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('rl_process_conversations_nonce'),
]);
}
}
// AJAX handler
add_action('wp_ajax_rl_process_upcoming_conversations', 'rl_mailwarmer_process_upcoming_conversations_handler');
function rl_mailwarmer_process_upcoming_conversations_handler() {
check_ajax_referer('rl_process_conversations_nonce', 'security');
$post_id = intval($_POST['post_id']);
if (!$post_id) {
wp_send_json_error(__('Invalid campaign ID.', 'rl-mailwarmer'));
}
try {
// Call the function to process upcoming conversations
RL_MailWarmer_Conversation_Handler::process_upcoming_conversations($post_id);
wp_send_json_success(__('Conversations processed successfully.', 'rl-mailwarmer'));
} catch (Exception $e) {
wp_send_json_error(__('Error processing conversations: ', 'rl-mailwarmer') . $e->getMessage());
}
}
/**
* Add a metabox to the WP dashboard for monitoring and processing the conversation queue.
*/
add_action('wp_dashboard_setup', function () {
wp_add_dashboard_widget(
'rl_mailwarmer_conversation_queue',
__('Conversation Queue', 'rl-mailwarmer'),
'rl_mailwarmer_render_conversation_queue_widget'
);
});
/**
* Render the Message Queue dashboard widget.
*/
function rl_mailwarmer_render_conversation_queue_widget() {
global $wpdb;
// Count past-due conversations
$table_name = $wpdb->prefix . 'rl_mailwarmer_conversations';
$start_time = date('Y-m-d H:i:s', strtotime('-96 hours'));
$end_time = date('Y-m-d H:i:s', strtotime('+12 hours'));
$end_of_today = date('Y-m-d 11:59:59', current_time('timestamp'));
$past_due_count = $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM $table_name WHERE status = %s AND first_message_timestamp BETWEEN %s AND %s",
'new',
$start_time,
$end_time
)
);
// $conversations = $wpdb->get_results(
// $wpdb->prepare(
// "SELECT * FROM $conversation_table WHERE status = %s AND first_message_timestamp BETWEEN %s AND %s ORDER BY first_message_timestamp ASC LIMIT 5",
// 'new',
// $start_time,
// $end_time
// ),
// ARRAY_A
// );
?>
<div id="rl-mailwarmer-conversation-queue">
<p><strong><?php esc_html_e('Past-Due Conversations:', 'rl-mailwarmer'); ?></strong> <?php echo esc_html($past_due_count); ?></p>
<button id="process-conversation-queue" class="button button-primary">
<?php esc_html_e('Process Conversation Queue', 'rl-mailwarmer'); ?>
</button>
<div id="conversation-queue-result" style="margin-top: 10px;"></div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const button = document.getElementById('process-conversation-queue');
const resultDiv = document.getElementById('conversation-queue-result');
button.addEventListener('click', function () {
// button.disabled = true;
resultDiv.textContent = '<?php esc_html_e('Processing...', 'rl-mailwarmer'); ?>';
jQuery.post(ajaxurl, {
action: 'rl_mailwarmer_process_conversation_queue',
security: '<?php echo esc_js(wp_create_nonce('rl_mailwarmer_conversation_queue_nonce')); ?>'
}, function (response) {
button.disabled = false;
if (response.success) {
console.log("Success");
resultDiv.textContent = '<?php esc_html_e('Conversations processed:', 'rl-mailwarmer'); ?> ' + response.data.processed_count;
} else {
console.log("Failure");
resultDiv.textContent = '<?php esc_html_e('Error:', 'rl-mailwarmer'); ?> ' + response.data.conversation;
}
});
});
});
</script>
<?php
}
/**
* AJAX handler to process the conversation queue.
*/
add_action('wp_ajax_rl_mailwarmer_process_conversation_queue', function () {
// log_to_file("wp_ajax_rl_mailwarmer_process_conversation_queue - Running");
// Verify nonce
if (!check_ajax_referer('rl_mailwarmer_conversation_queue_nonce', 'security', false)) {
wp_send_json_error(['message' => __('Invalid nonce.', 'rl-mailwarmer')]);
}
// Ensure the user has permission
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('Permission denied.', 'rl-mailwarmer')]);
}
try {
// Process pending conversations
// log_to_file("wp_ajax_rl_mailwarmer_process_conversation_queue - Trying process_pending_conversations()");
$processed_count = RL_MailWarmer_Conversation_Handler::process_upcoming_conversations();
wp_send_json_success(['processed_count' => $processed_count]);
} catch (Exception $e) {
wp_send_json_error(['message' => $e->getMessage()]);
}
});