Initial commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,948 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Modules\MCP\Tools;
|
||||
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
|
||||
use FluentCrm\App\Services\ContactsQuery;
|
||||
|
||||
/**
|
||||
* Contact-centric MCP tools.
|
||||
*
|
||||
* Read tools (Phase 2): listContacts, getContact.
|
||||
* Write tools (Phase 3): upsertContact, bulkUpsertContacts, deleteContact,
|
||||
* applySegmentsToContacts, addContactNote.
|
||||
*
|
||||
* Each method delegates to existing FluentCRM services (ContactsQuery,
|
||||
* Subscriber model, Helper::deleteContacts, etc.) — no business-logic
|
||||
* duplication — and shapes the result through MCPHelper formatters.
|
||||
*/
|
||||
class ContactTools
|
||||
{
|
||||
// -----------------------------------------------------------------
|
||||
// Read: list-contacts
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function listContacts($params)
|
||||
{
|
||||
$params = (array) $params;
|
||||
|
||||
// Reject up front if the caller passed an unsupported advanced_filters
|
||||
// shape — round-2 review #3.
|
||||
$validation = MCPHelper::validateUniversalFilter($params);
|
||||
if (is_wp_error($validation)) {
|
||||
return $validation;
|
||||
}
|
||||
|
||||
$pagination = MCPHelper::paginationFromInput($params);
|
||||
|
||||
$args = MCPHelper::buildContactsQueryArgs($params);
|
||||
$args['with'] = ['tags', 'lists'];
|
||||
|
||||
if (!empty($params['include_custom_fields'])) {
|
||||
$args['custom_fields'] = true;
|
||||
}
|
||||
|
||||
$cq = new ContactsQuery($args);
|
||||
MCPHelper::applyDateFilters($cq, $params);
|
||||
$paginated = $cq->paginate();
|
||||
|
||||
return MCPHelper::formatContactList($paginated, !empty($params['include_custom_fields']));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Read: get-contact
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function getContact($params)
|
||||
{
|
||||
$params = (array) $params;
|
||||
|
||||
$defaultIncludes = ['notes', 'email_history', 'automations'];
|
||||
$include = isset($params['include']) && is_array($params['include']) && $params['include']
|
||||
? array_values(array_intersect(
|
||||
$params['include'],
|
||||
['notes', 'email_history', 'automations', 'activity', 'purchase_history', 'support_tickets', 'ai_summary', 'info_widgets']
|
||||
))
|
||||
: $defaultIncludes;
|
||||
|
||||
$contactId = isset($params['contact_id']) ? (int) $params['contact_id'] : 0;
|
||||
$email = isset($params['email']) ? sanitize_email($params['email']) : '';
|
||||
|
||||
$with = ['tags', 'lists'];
|
||||
|
||||
$subscriber = null;
|
||||
if ($contactId) {
|
||||
$subscriber = Subscriber::with($with)->find($contactId);
|
||||
} elseif ($email) {
|
||||
$subscriber = Subscriber::with($with)->where('email', $email)->first();
|
||||
}
|
||||
|
||||
if (!$subscriber) {
|
||||
if (!$contactId && !$email) {
|
||||
return MCPHelper::error('invalid_param', __('Provide contact_id or email', 'fluent-crm'));
|
||||
}
|
||||
return MCPHelper::error('not_found', __('Contact not found', 'fluent-crm'), array_filter([
|
||||
'contact_id' => $contactId ?: null,
|
||||
'email' => $email ?: null,
|
||||
]));
|
||||
}
|
||||
|
||||
$data = MCPHelper::formatContactForMCP($subscriber, ['include' => $include]);
|
||||
|
||||
// Defaults already inlined by formatContactForMCP — fill the optional ones.
|
||||
if (in_array('activity', $include, true)) {
|
||||
$data['activity'] = self::buildActivityTimeline($subscriber);
|
||||
}
|
||||
|
||||
if (in_array('purchase_history', $include, true)) {
|
||||
$data['purchase_history'] = self::buildPurchaseHistory($subscriber);
|
||||
}
|
||||
|
||||
if (in_array('support_tickets', $include, true)) {
|
||||
$data['support_tickets'] = self::buildSupportTickets($subscriber);
|
||||
}
|
||||
|
||||
if (in_array('info_widgets', $include, true)) {
|
||||
$data['info_widgets'] = self::buildInfoWidgets($subscriber);
|
||||
}
|
||||
|
||||
if (in_array('ai_summary', $include, true)) {
|
||||
$data['ai_summary'] = self::buildAiSummary($subscriber, !empty($params['generate_ai_summary']));
|
||||
}
|
||||
|
||||
// Status-related context — surfaced inline so the agent can see why a
|
||||
// contact is unsubscribed without an extra call.
|
||||
if (in_array($subscriber->status, ['unsubscribed', 'bounced', 'complained', 'spammed'], true)) {
|
||||
$data['unsubscribe_reason'] = method_exists($subscriber, 'unsubscribeReason')
|
||||
? $subscriber->unsubscribeReason()
|
||||
: null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity timeline = tracked events. The fc_event_tracking table is
|
||||
* created by the free plugin's migrations but may not exist on legacy
|
||||
* installs that never ran the migration. Probe with SHOW TABLES so we
|
||||
* never trigger wpdb's print_error (which leaks HTML into the response
|
||||
* body before the JSON envelope, even when the exception is caught).
|
||||
*/
|
||||
private static function buildActivityTimeline($subscriber)
|
||||
{
|
||||
global $wpdb;
|
||||
$tableName = $wpdb->prefix . 'fc_event_tracking';
|
||||
$exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $tableName)) === $tableName;
|
||||
if (!$exists) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$events = $subscriber->trackingEvents()
|
||||
->orderBy('id', 'DESC')
|
||||
->limit(50)
|
||||
->get();
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($events as $event) {
|
||||
$out[] = [
|
||||
'id' => (int) $event->id,
|
||||
'event_key' => $event->event_key,
|
||||
'title' => $event->title,
|
||||
'value' => $event->value,
|
||||
'provider' => $event->provider ?? null,
|
||||
'counter' => isset($event->counter) ? (int) $event->counter : null,
|
||||
'created_at' => MCPHelper::toIso8601($event->created_at),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function buildPurchaseHistory($subscriber)
|
||||
{
|
||||
/**
|
||||
* Resolved per the existing FluentCRM commerce-provider filter chain.
|
||||
*/
|
||||
$provider = apply_filters('fluentcrm_commerce_provider', '');
|
||||
if (!$provider) {
|
||||
return [];
|
||||
}
|
||||
$stat = apply_filters('fluent_crm/contact_purchase_stat_' . $provider, [], $subscriber->id);
|
||||
return is_array($stat) ? $stat : [];
|
||||
}
|
||||
|
||||
private static function buildSupportTickets($subscriber)
|
||||
{
|
||||
// FluentSupport hooks this filter when active. Empty otherwise.
|
||||
return apply_filters('fluentcrm_get_support_tickets', [], $subscriber);
|
||||
}
|
||||
|
||||
private static function buildInfoWidgets($subscriber)
|
||||
{
|
||||
/**
|
||||
* Filter that integrators (Pro, FluentSupport, FluentCart, etc.) push
|
||||
* widget data into. Surface the raw filter result; ContextTools agents
|
||||
* can interpret what's there.
|
||||
*/
|
||||
$widgets = apply_filters('fluent_crm/contact_info_widgets', [], $subscriber);
|
||||
return is_array($widgets) ? $widgets : [];
|
||||
}
|
||||
|
||||
private static function buildAiSummary($subscriber, $generate = false)
|
||||
{
|
||||
$cached = fluentcrm_get_subscriber_meta($subscriber->id, '_ai_summary');
|
||||
|
||||
if ($cached && !$generate) {
|
||||
return [
|
||||
'summary' => is_array($cached) ? ($cached['summary'] ?? '') : (string) $cached,
|
||||
'generated_at' => is_array($cached) ? ($cached['generated_at'] ?? null) : null,
|
||||
'cached' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if (!$generate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Honor existing AI controller; if it's missing or disabled, return
|
||||
// a structured signal rather than throwing.
|
||||
if (!class_exists('FluentCrm\\App\\Http\\Controllers\\AiController')) {
|
||||
return ['summary' => null, 'cached' => false, 'error' => 'ai_unavailable'];
|
||||
}
|
||||
|
||||
$aiSettings = fluentcrm_get_option('ai_settings', []);
|
||||
if (empty($aiSettings['active_provider'])) {
|
||||
return ['summary' => null, 'cached' => false, 'error' => 'ai_provider_not_configured'];
|
||||
}
|
||||
|
||||
// Generation requires the existing controller's prompt + provider call;
|
||||
// surface a dependency_missing-style signal so the agent can prompt the
|
||||
// user to enable AI rather than blocking the read.
|
||||
return [
|
||||
'summary' => null,
|
||||
'cached' => false,
|
||||
'error' => 'generation_not_supported_in_mcp_v1',
|
||||
'note' => 'Trigger AI summary from the contact profile UI; cached value will appear on subsequent get-contact calls.',
|
||||
];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Write: upsert-contact
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function upsertContact($params)
|
||||
{
|
||||
$params = (array) $params;
|
||||
|
||||
$contactId = isset($params['contact_id']) ? (int) $params['contact_id'] : 0;
|
||||
$email = isset($params['email']) ? sanitize_email($params['email']) : '';
|
||||
$newEmail = isset($params['new_email']) ? sanitize_email($params['new_email']) : '';
|
||||
|
||||
if (!$contactId && !$email) {
|
||||
return MCPHelper::error('invalid_param', __('Provide contact_id or email', 'fluent-crm'));
|
||||
}
|
||||
|
||||
$existing = null;
|
||||
if ($contactId) {
|
||||
$existing = Subscriber::find($contactId);
|
||||
if (!$existing) {
|
||||
return MCPHelper::error('not_found', __('Contact not found', 'fluent-crm'), ['contact_id' => $contactId]);
|
||||
}
|
||||
// Lookup-by-id with email mismatch is fine — id wins.
|
||||
$email = $existing->email;
|
||||
} else {
|
||||
$existing = Subscriber::where('email', $email)->first();
|
||||
}
|
||||
|
||||
$ifExists = $params['if_exists'] ?? 'merge';
|
||||
if ($existing && $ifExists === 'skip') {
|
||||
return [
|
||||
'ok' => true,
|
||||
'action' => 'skipped',
|
||||
'contact' => MCPHelper::formatContactForMCP($existing, ['include' => ['notes', 'email_history', 'automations']]),
|
||||
'changes' => null,
|
||||
];
|
||||
}
|
||||
if ($existing && $ifExists === 'error') {
|
||||
return MCPHelper::error('contact_exists', __('A contact with this email already exists', 'fluent-crm'), [
|
||||
'id' => (int) $existing->id,
|
||||
]);
|
||||
}
|
||||
|
||||
// Re-check the escalating capability if the agent asked us to create
|
||||
// missing tags/lists — defense in depth, even though the
|
||||
// permission_callback already enforced the base cap.
|
||||
$autoCreateTags = !empty($params['auto_create_tags']);
|
||||
$autoCreateLists = !empty($params['auto_create_lists']);
|
||||
if (($autoCreateTags || $autoCreateLists)
|
||||
&& !\FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contact_cats')) {
|
||||
return MCPHelper::error('forbidden', __('Creating new tags/lists requires fcrm_manage_contact_cats', 'fluent-crm'));
|
||||
}
|
||||
|
||||
// Resolve add/remove segment payloads up-front so we can mention
|
||||
// resolution failures in the response without partially applying.
|
||||
$addTags = MCPHelper::resolveTagIds($params['add_tags'] ?? [], $autoCreateTags);
|
||||
$removeTags = MCPHelper::resolveTagIds($params['remove_tags'] ?? [], false);
|
||||
$addLists = MCPHelper::resolveListIds($params['add_lists'] ?? [], $autoCreateLists);
|
||||
$removeLists = MCPHelper::resolveListIds($params['remove_lists'] ?? [], false);
|
||||
|
||||
// Capture the pre-rename / pre-update snapshot fields BEFORE any
|
||||
// mutation. The rename block below sets $existing->email to the new
|
||||
// value, so reading $existing->email after that point would return
|
||||
// the new email — operator-test report 2026-05-07 #9. The full
|
||||
// snapshot also feeds diffFields() so fields_updated correctly
|
||||
// reports 'email' on a rename.
|
||||
$previousStatus = $existing ? $existing->status : null;
|
||||
$previousEmail = $existing ? $existing->email : null;
|
||||
$previousSnapshot = $existing ? self::snapshotCompareFields($existing) : null;
|
||||
|
||||
// Email rename: when an existing contact + new_email is provided, do
|
||||
// the rename in-place on the existing row BEFORE delegating to
|
||||
// createOrUpdate. createOrUpdate looks up by email — passing it the
|
||||
// new_email would not find a row and would create a new contact
|
||||
// (review B1 round 3). The save fires fluent_crm/contact_email_changed
|
||||
// through Subscriber::updateOrCreate's normal path because we then
|
||||
// call it with the new email as the lookup key.
|
||||
if ($existing && $newEmail && $newEmail !== $existing->email) {
|
||||
$oldEmail = $existing->email;
|
||||
// Make sure the new email isn't already used by another contact.
|
||||
$clash = Subscriber::where('email', $newEmail)->where('id', '!=', $existing->id)->first();
|
||||
if ($clash) {
|
||||
return MCPHelper::error('contact_exists', __('Another contact already uses the new_email — refusing to merge silently. Resolve manually or pick a different new_email.', 'fluent-crm'), [
|
||||
'new_email' => $newEmail,
|
||||
'conflict_id' => (int) $clash->id,
|
||||
'subject_id' => (int) $existing->id,
|
||||
]);
|
||||
}
|
||||
$existing->email = $newEmail;
|
||||
$existing->save();
|
||||
do_action('fluent_crm/contact_email_changed', $existing, $oldEmail);
|
||||
}
|
||||
|
||||
// Build the upsert payload — only fields actually provided. Lookup
|
||||
// email is the post-rename value (so createOrUpdate finds the same
|
||||
// row we just renamed).
|
||||
$payload = [
|
||||
'email' => $existing && $newEmail ? $newEmail : ($email ?: ($existing->email ?? null)),
|
||||
];
|
||||
|
||||
$passthru = ['first_name', 'last_name', 'prefix', 'phone', 'status', 'contact_type', 'date_of_birth', 'timezone', 'source'];
|
||||
foreach ($passthru as $field) {
|
||||
if (array_key_exists($field, $params) && $params[$field] !== null && $params[$field] !== '') {
|
||||
$payload[$field] = $params[$field];
|
||||
}
|
||||
}
|
||||
|
||||
self::applyAddressShape($payload, $params['address'] ?? null);
|
||||
|
||||
if (!empty($params['custom_fields']) && is_array($params['custom_fields'])) {
|
||||
// Validate against the registered schema. Unknown keys would
|
||||
// otherwise be silently dropped (operator-test report
|
||||
// 2026-05-07 #6) — fail closed so the agent can either
|
||||
// correct the slug or call get-crm-context for the schema.
|
||||
$diff = MCPHelper::diffCustomFields($params['custom_fields']);
|
||||
if (!empty($diff['unknown'])) {
|
||||
return MCPHelper::error('invalid_param', __('custom_fields contains slugs not in the contact custom-field schema. Refusing — silent-dropping makes the agent think the value persisted.', 'fluent-crm'), [
|
||||
'unknown_custom_field_slugs' => $diff['unknown'],
|
||||
'allowed_custom_field_slugs' => MCPHelper::knownContactCustomFieldSlugs(),
|
||||
'tip' => 'Call get-crm-context and read enums.custom_fields_schema (or call options for the live registry) before retrying.',
|
||||
]);
|
||||
}
|
||||
$payload['custom_values'] = $diff['known'];
|
||||
}
|
||||
|
||||
// Only stamp source='mcp' on creation. On update, omit the field
|
||||
// entirely so the model preserves whatever signup source the contact
|
||||
// already has ("web", "checkout", "import", etc.). The agent can
|
||||
// still pass an explicit `source` to override this when needed.
|
||||
if (!$existing && (!isset($payload['source']) || $payload['source'] === '')) {
|
||||
$payload['source'] = 'mcp';
|
||||
} elseif ($existing && (!isset($payload['source']) || $payload['source'] === '')) {
|
||||
unset($payload['source']);
|
||||
}
|
||||
|
||||
// The `Subscriber::updateOrCreate` path forwards through
|
||||
// FluentCrmApi('contacts')->createOrUpdate which fires the
|
||||
// contact-created/updated and status-change hooks we need.
|
||||
// ($previousStatus / $previousEmail were captured above, before
|
||||
// the rename block — see operator-test report 2026-05-07 #9.)
|
||||
$forceUpdate = true;
|
||||
$contact = FluentCrmApi('contacts')->createOrUpdate($payload, $forceUpdate, false);
|
||||
|
||||
if (!$contact) {
|
||||
return MCPHelper::error('failed', __('Could not create or update the contact', 'fluent-crm'));
|
||||
}
|
||||
|
||||
$action = !empty($contact->wasRecentlyCreated) ? 'created' : 'updated';
|
||||
|
||||
// Apply delta segment changes.
|
||||
$tagsAdded = [];
|
||||
$tagsRemoved = [];
|
||||
$listsAdded = [];
|
||||
$listsRemoved = [];
|
||||
|
||||
if (!empty($addTags['ids'])) {
|
||||
$contact->attachTags($addTags['ids']);
|
||||
foreach ($addTags['ids'] as $id) {
|
||||
$tagsAdded[] = ['id' => (int) $id];
|
||||
}
|
||||
}
|
||||
if (!empty($removeTags['ids'])) {
|
||||
$contact->detachTags($removeTags['ids']);
|
||||
foreach ($removeTags['ids'] as $id) {
|
||||
$tagsRemoved[] = ['id' => (int) $id];
|
||||
}
|
||||
}
|
||||
if (!empty($addLists['ids'])) {
|
||||
$contact->attachLists($addLists['ids']);
|
||||
foreach ($addLists['ids'] as $id) {
|
||||
$listsAdded[] = ['id' => (int) $id];
|
||||
}
|
||||
}
|
||||
if (!empty($removeLists['ids'])) {
|
||||
$contact->detachLists($removeLists['ids']);
|
||||
foreach ($removeLists['ids'] as $id) {
|
||||
$listsRemoved[] = ['id' => (int) $id];
|
||||
}
|
||||
}
|
||||
|
||||
// Optional double opt-in trigger for newly-pending contacts.
|
||||
if ($contact->status === 'pending' && !empty($params['double_optin'])) {
|
||||
$contact->sendDoubleOptinEmail();
|
||||
}
|
||||
|
||||
// Status-change reason: drop a system-style note for audit.
|
||||
if (!empty($params['status_change_reason']) && $previousStatus && $previousStatus !== $contact->status) {
|
||||
\FluentCrm\App\Models\SubscriberNote::create([
|
||||
'subscriber_id' => $contact->id,
|
||||
'type' => 'note',
|
||||
'title' => __('Status changed via MCP', 'fluent-crm'),
|
||||
'description' => sanitize_text_field((string) $params['status_change_reason']),
|
||||
]);
|
||||
}
|
||||
|
||||
$contact = Subscriber::with(['tags', 'lists'])->find($contact->id);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'action' => $action,
|
||||
'contact' => MCPHelper::formatContactForMCP($contact, ['include' => ['notes', 'email_history', 'automations']]),
|
||||
'changes' => [
|
||||
'fields_updated' => self::diffFields($previousSnapshot, $contact),
|
||||
'tags_added' => $tagsAdded,
|
||||
'tags_removed' => $tagsRemoved,
|
||||
'lists_added' => $listsAdded,
|
||||
'lists_removed' => $listsRemoved,
|
||||
'previous_status' => $previousStatus,
|
||||
'current_status' => $contact->status,
|
||||
'previous_email' => $previousEmail,
|
||||
'current_email' => $contact->email,
|
||||
'tags_created' => $addTags['created'],
|
||||
'lists_created' => $addLists['created'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Write: delete-contact-note (round 3 review #11)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function deleteContactNote($params)
|
||||
{
|
||||
$params = (array) $params;
|
||||
$noteId = (int) ($params['note_id'] ?? 0);
|
||||
if (!$noteId) {
|
||||
return MCPHelper::error('invalid_param', __('note_id is required', 'fluent-crm'));
|
||||
}
|
||||
|
||||
$note = \FluentCrm\App\Models\SubscriberNote::find($noteId);
|
||||
if (!$note) {
|
||||
return MCPHelper::error('not_found', __('Note not found', 'fluent-crm'), ['note_id' => $noteId]);
|
||||
}
|
||||
|
||||
$deletedId = (int) $note->id;
|
||||
$subscriberId = (int) $note->subscriber_id;
|
||||
$title = (string) $note->title;
|
||||
$note->delete();
|
||||
|
||||
do_action('fluent_crm/note_deleted', $deletedId, $subscriberId);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'action' => 'deleted',
|
||||
'deleted_id' => $deletedId,
|
||||
'subscriber_id' => $subscriberId,
|
||||
'deleted_title' => $title,
|
||||
'note' => __('Note row removed. The contact\'s other notes and email history are unaffected.', 'fluent-crm'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the would-create list for a dry-run preview. Skips numeric
|
||||
* inputs (those are id lookups, not creation candidates — review B3
|
||||
* round 3) and only flags string names that have no existing match.
|
||||
*/
|
||||
private static function wouldCreateNames($items, $kind = 'tag')
|
||||
{
|
||||
$out = [];
|
||||
foreach ((array) $items as $item) {
|
||||
if (is_numeric($item) || $item === '' || $item === null) {
|
||||
continue;
|
||||
}
|
||||
$name = sanitize_text_field((string) $item);
|
||||
$slug = sanitize_title($name);
|
||||
if ($kind === 'list') {
|
||||
$hit = \FluentCrm\App\Models\Lists::where('title', $name)->orWhere('slug', $slug)->first();
|
||||
} else {
|
||||
$hit = \FluentCrm\App\Models\Tag::where('title', $name)->orWhere('slug', $slug)->first();
|
||||
}
|
||||
if (!$hit) {
|
||||
$out[] = $name;
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($out));
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the agent-facing {line_1, line_2, city, state, postal_code,
|
||||
* country} shape onto the column-named payload that Subscriber
|
||||
* createOrUpdate consumes. Mutates $payload by reference. Shared
|
||||
* between upsert-contact and bulk-upsert-contacts so both stay in
|
||||
* lock-step (operator-test report 2026-05-07 #5).
|
||||
*/
|
||||
private static function applyAddressShape(array &$payload, $address)
|
||||
{
|
||||
if (empty($address) || !is_array($address)) {
|
||||
return;
|
||||
}
|
||||
$map = [
|
||||
'line_1' => 'address_line_1',
|
||||
'line_2' => 'address_line_2',
|
||||
'city' => 'city',
|
||||
'state' => 'state',
|
||||
'postal_code' => 'postal_code',
|
||||
'country' => 'country',
|
||||
];
|
||||
foreach ($map as $key => $col) {
|
||||
if (isset($address[$key]) && $address[$key] !== '') {
|
||||
$payload[$col] = $address[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the diff-relevant columns of a Subscriber before any
|
||||
* in-place mutation (rename, save). diffFields() compares against
|
||||
* this snapshot so fields_updated stays correct even after the row
|
||||
* has been written.
|
||||
*
|
||||
* @return array<string,string>
|
||||
*/
|
||||
private static function snapshotCompareFields($subscriber)
|
||||
{
|
||||
$snapshot = [];
|
||||
foreach (self::compareFieldNames() as $field) {
|
||||
$snapshot[$field] = (string) ($subscriber->{$field} ?? '');
|
||||
}
|
||||
return $snapshot;
|
||||
}
|
||||
|
||||
private static function compareFieldNames()
|
||||
{
|
||||
return ['email', 'first_name', 'last_name', 'prefix', 'phone', 'status', 'contact_type', 'address_line_1', 'address_line_2', 'city', 'state', 'postal_code', 'country', 'date_of_birth', 'timezone', 'source'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,string>|null $before Snapshot from snapshotCompareFields()
|
||||
* @param object $after Subscriber model post-save
|
||||
*/
|
||||
private static function diffFields($before, $after)
|
||||
{
|
||||
if (!$before) {
|
||||
return ['*'];
|
||||
}
|
||||
$changed = [];
|
||||
foreach (self::compareFieldNames() as $field) {
|
||||
if (($before[$field] ?? '') !== (string) ($after->{$field} ?? '')) {
|
||||
$changed[] = $field;
|
||||
}
|
||||
}
|
||||
return $changed;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Write: bulk-upsert-contacts
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function bulkUpsertContacts($params)
|
||||
{
|
||||
$params = (array) $params;
|
||||
$contacts = (array) ($params['contacts'] ?? []);
|
||||
if (!$contacts) {
|
||||
return MCPHelper::error('invalid_param', __('contacts is required', 'fluent-crm'));
|
||||
}
|
||||
|
||||
$maxBatch = (int) apply_filters('fluent_crm/mcp_bulk_cap', 500, 'bulk-upsert-contacts');
|
||||
if (count($contacts) > $maxBatch) {
|
||||
return MCPHelper::error('cap_reached', __('Too many contacts in a single call', 'fluent-crm'), [
|
||||
'max' => $maxBatch,
|
||||
'matched' => count($contacts),
|
||||
]);
|
||||
}
|
||||
|
||||
$autoCreateTags = isset($params['auto_create_tags']) ? (bool) $params['auto_create_tags'] : true;
|
||||
$autoCreateLists = isset($params['auto_create_lists']) ? (bool) $params['auto_create_lists'] : true;
|
||||
$ifExists = $params['if_exists'] ?? 'merge';
|
||||
$doubleOptin = !empty($params['double_optin']);
|
||||
|
||||
if (($autoCreateTags || $autoCreateLists)
|
||||
&& !\FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contact_cats')) {
|
||||
return MCPHelper::error('forbidden', __('Creating new tags/lists requires fcrm_manage_contact_cats', 'fluent-crm'));
|
||||
}
|
||||
|
||||
$created = $updated = $skipped = $invalid = $warnings = [];
|
||||
|
||||
foreach ($contacts as $row) {
|
||||
if (!is_array($row) || empty($row['email']) || !is_email($row['email'])) {
|
||||
$invalid[] = ['email' => $row['email'] ?? null, 'reason' => 'invalid_email'];
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = Subscriber::where('email', sanitize_email($row['email']))->first();
|
||||
if ($existing && $ifExists === 'skip') {
|
||||
$skipped[] = ['id' => (int) $existing->id, 'email' => $existing->email];
|
||||
continue;
|
||||
}
|
||||
if ($existing && $ifExists === 'error') {
|
||||
$invalid[] = ['email' => $row['email'], 'reason' => 'contact_exists', 'id' => (int) $existing->id];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve segments per-row.
|
||||
$tagIds = MCPHelper::resolveTagIds((array) ($row['tags'] ?? []), $autoCreateTags);
|
||||
$listIds = MCPHelper::resolveListIds((array) ($row['lists'] ?? []), $autoCreateLists);
|
||||
|
||||
$payload = $row;
|
||||
$payload['tags'] = $tagIds['ids'];
|
||||
$payload['lists'] = $listIds['ids'];
|
||||
|
||||
// Same address-shape mapping as single upsert. Without this,
|
||||
// bulk silently dropped the {line_1,...,country} object —
|
||||
// operator-test report 2026-05-07 #5.
|
||||
self::applyAddressShape($payload, $row['address'] ?? null);
|
||||
|
||||
// Same rule as upsert-contact: stamp source='mcp_bulk' only on
|
||||
// creation. On update, preserve the original source unless the
|
||||
// caller passed one explicitly.
|
||||
if (!$existing && (!isset($payload['source']) || $payload['source'] === '')) {
|
||||
$payload['source'] = 'mcp_bulk';
|
||||
} elseif ($existing && (!isset($payload['source']) || $payload['source'] === '')) {
|
||||
unset($payload['source']);
|
||||
}
|
||||
|
||||
if (!empty($row['custom_fields']) && is_array($row['custom_fields'])) {
|
||||
// Same diff-against-schema gate as single upsert, but
|
||||
// surface unknown slugs as a per-row warning so one bad
|
||||
// row doesn't fail the whole batch (operator-test report
|
||||
// 2026-05-07 #6). Known keys still persist.
|
||||
$diff = MCPHelper::diffCustomFields($row['custom_fields']);
|
||||
if (!empty($diff['unknown'])) {
|
||||
$warnings[] = [
|
||||
'email' => $row['email'],
|
||||
'reason' => 'unknown_custom_field_slugs',
|
||||
'unknown_custom_field_slugs' => $diff['unknown'],
|
||||
];
|
||||
}
|
||||
$payload['custom_values'] = $diff['known'];
|
||||
}
|
||||
|
||||
$contact = FluentCrmApi('contacts')->createOrUpdate($payload, true, false);
|
||||
if (!$contact) {
|
||||
$invalid[] = ['email' => $row['email'], 'reason' => 'failed_to_save'];
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($doubleOptin && $contact->status === 'pending') {
|
||||
$contact->sendDoubleOptinEmail();
|
||||
}
|
||||
|
||||
$entry = [
|
||||
'id' => (int) $contact->id,
|
||||
'email' => $contact->email,
|
||||
'status' => $contact->status,
|
||||
];
|
||||
if (!empty($contact->wasRecentlyCreated)) {
|
||||
$created[] = $entry;
|
||||
} else {
|
||||
$updated[] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'summary' => [
|
||||
'created' => count($created),
|
||||
'updated' => count($updated),
|
||||
'skipped' => count($skipped),
|
||||
'invalid' => count($invalid),
|
||||
'warnings' => count($warnings),
|
||||
],
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'skipped' => $skipped,
|
||||
'invalid' => $invalid,
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Write: delete-contact
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function deleteContact($params)
|
||||
{
|
||||
$resolved = MCPHelper::resolveContact((array) $params);
|
||||
if (is_wp_error($resolved)) {
|
||||
return $resolved;
|
||||
}
|
||||
$contact = $resolved;
|
||||
$deletedId = (int) $contact->id;
|
||||
$deletedEmail = (string) $contact->email;
|
||||
$deleteEmails = !isset($params['delete_emails']) ? true : (bool) $params['delete_emails'];
|
||||
|
||||
if ($deleteEmails) {
|
||||
\FluentCrm\App\Models\CampaignEmail::where('subscriber_id', $deletedId)->delete();
|
||||
}
|
||||
|
||||
$ok = \FluentCrm\App\Services\Helper::deleteContacts([$deletedId]);
|
||||
if (!$ok) {
|
||||
return MCPHelper::error('failed', __('Could not delete the contact', 'fluent-crm'));
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'deleted_id' => $deletedId,
|
||||
'deleted_email' => $deletedEmail,
|
||||
'emails_purged' => (bool) $deleteEmails,
|
||||
];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Write: apply-segments-to-contacts
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function applySegmentsToContacts($params)
|
||||
{
|
||||
$params = (array) $params;
|
||||
|
||||
$autoCreateTags = !empty($params['auto_create_tags']);
|
||||
$autoCreateLists = !empty($params['auto_create_lists']);
|
||||
if (($autoCreateTags || $autoCreateLists)
|
||||
&& !\FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contact_cats')) {
|
||||
return MCPHelper::error('forbidden', __('Creating new tags/lists requires fcrm_manage_contact_cats', 'fluent-crm'));
|
||||
}
|
||||
|
||||
$contactIds = isset($params['contact_ids']) ? array_filter(array_map('intval', (array) $params['contact_ids'])) : [];
|
||||
$filter = $params['filter'] ?? null;
|
||||
$dryRun = !empty($params['dry_run']);
|
||||
|
||||
if (!$contactIds && empty($filter)) {
|
||||
return MCPHelper::error('invalid_param', __('Provide contact_ids or filter', 'fluent-crm'));
|
||||
}
|
||||
|
||||
if ($contactIds && !empty($filter)) {
|
||||
return MCPHelper::error('invalid_param', __('Provide contact_ids OR filter, not both', 'fluent-crm'));
|
||||
}
|
||||
|
||||
$cap = (int) apply_filters('fluent_crm/mcp_bulk_cap', 5000, 'apply-segments-to-contacts');
|
||||
|
||||
if (!$contactIds) {
|
||||
$validation = MCPHelper::validateUniversalFilter((array) $filter);
|
||||
if (is_wp_error($validation)) {
|
||||
return $validation;
|
||||
}
|
||||
$args = MCPHelper::buildContactsQueryArgs((array) $filter);
|
||||
$args['with'] = []; // we just need ids
|
||||
$cq = new ContactsQuery($args);
|
||||
MCPHelper::applyDateFilters($cq, (array) $filter);
|
||||
$query = $cq->getModel();
|
||||
|
||||
$matched = (int) $query->count();
|
||||
// During a dry run, expose the matched count even when it
|
||||
// exceeds the cap — knowing the size is the whole point of a
|
||||
// preview. The agent can then batch.
|
||||
if ($matched > $cap && !$dryRun) {
|
||||
return MCPHelper::error('cap_reached', __('Too many contacts match the filter', 'fluent-crm'), [
|
||||
'max' => $cap,
|
||||
'matched' => $matched,
|
||||
]);
|
||||
}
|
||||
|
||||
$contactIds = array_map('intval', $query->limit($cap)->pluck('id')->toArray());
|
||||
// Stash the true matched count so dry_run can echo it (the
|
||||
// pluck call above only returns up to $cap rows).
|
||||
$matchedTotal = $matched;
|
||||
} else {
|
||||
if (count($contactIds) > $cap && !$dryRun) {
|
||||
return MCPHelper::error('cap_reached', __('Too many contact_ids in a single call', 'fluent-crm'), [
|
||||
'max' => $cap,
|
||||
'matched' => count($contactIds),
|
||||
]);
|
||||
}
|
||||
$matchedTotal = count($contactIds);
|
||||
}
|
||||
|
||||
// Resolve segment refs. Auto-create is suppressed during dry runs so
|
||||
// a preview never leaves orphan tags/lists behind.
|
||||
$addTags = MCPHelper::resolveTagIds((array) ($params['add_tags'] ?? []), $autoCreateTags && !$dryRun);
|
||||
$removeTags = MCPHelper::resolveTagIds((array) ($params['remove_tags'] ?? []), false);
|
||||
$addLists = MCPHelper::resolveListIds((array) ($params['add_lists'] ?? []), $autoCreateLists && !$dryRun);
|
||||
$removeLists = MCPHelper::resolveListIds((array) ($params['remove_lists'] ?? []), false);
|
||||
|
||||
// Compute the would-create set: name strings the agent supplied that
|
||||
// don't resolve to an existing tag/list. Numeric inputs are id
|
||||
// lookups, never creation candidates (review B3 round 3).
|
||||
$tagsWouldCreate = self::wouldCreateNames((array) ($params['add_tags'] ?? []), 'tag');
|
||||
$listsWouldCreate = self::wouldCreateNames((array) ($params['add_lists'] ?? []), 'list');
|
||||
|
||||
// The "at least one" guard considers what would actually happen — if
|
||||
// dry_run with names that would create, that IS work, so don't bail.
|
||||
$hasAnyWork = $addTags['ids'] || $removeTags['ids']
|
||||
|| $addLists['ids'] || $removeLists['ids']
|
||||
|| ($dryRun && ($tagsWouldCreate || $listsWouldCreate));
|
||||
if (!$hasAnyWork) {
|
||||
return MCPHelper::error('invalid_param', __('Provide at least one of add_tags, remove_tags, add_lists, remove_lists', 'fluent-crm'));
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$formatRefs = function ($ids) {
|
||||
$out = [];
|
||||
foreach ($ids as $id) {
|
||||
$out[] = ['id' => (int) $id];
|
||||
}
|
||||
return $out;
|
||||
};
|
||||
$exceedsCap = $matchedTotal > $cap;
|
||||
return [
|
||||
'ok' => true,
|
||||
'dry_run' => true,
|
||||
'matched_contacts' => $matchedTotal,
|
||||
'cap' => $cap,
|
||||
'exceeds_cap' => $exceedsCap,
|
||||
'batches_required' => $exceedsCap ? (int) ceil($matchedTotal / max(1, $cap)) : 1,
|
||||
'applied_to_contacts' => 0,
|
||||
'tags_added' => $formatRefs($addTags['ids']),
|
||||
'tags_removed' => $formatRefs($removeTags['ids']),
|
||||
'lists_added' => $formatRefs($addLists['ids']),
|
||||
'lists_removed' => $formatRefs($removeLists['ids']),
|
||||
'tags_would_create' => $tagsWouldCreate,
|
||||
'lists_would_create' => $listsWouldCreate,
|
||||
'note' => $exceedsCap
|
||||
? __('Dry run — match exceeds the per-call cap. Apply by passing contact_ids in batches.', 'fluent-crm')
|
||||
: __('Dry run — nothing was applied. Re-run without dry_run=true to commit.', 'fluent-crm'),
|
||||
];
|
||||
}
|
||||
|
||||
// Process in chunks so attach/detach don't load thousands of rows at
|
||||
// once. Each Subscriber attach/detach already de-dupes internally.
|
||||
// Track the actual touched ids (review P2 #10) so an agent can
|
||||
// reverse precisely without re-running the original filter — which
|
||||
// may match a different set after time passes.
|
||||
$chunkSize = 200;
|
||||
$applied = 0;
|
||||
$appliedIds = [];
|
||||
foreach (array_chunk($contactIds, $chunkSize) as $batchIds) {
|
||||
$subscribers = Subscriber::whereIn('id', $batchIds)->get();
|
||||
foreach ($subscribers as $sub) {
|
||||
if ($addTags['ids']) {
|
||||
$sub->attachTags($addTags['ids']);
|
||||
}
|
||||
if ($removeTags['ids']) {
|
||||
$sub->detachTags($removeTags['ids']);
|
||||
}
|
||||
if ($addLists['ids']) {
|
||||
$sub->attachLists($addLists['ids']);
|
||||
}
|
||||
if ($removeLists['ids']) {
|
||||
$sub->detachLists($removeLists['ids']);
|
||||
}
|
||||
$applied++;
|
||||
$appliedIds[] = (int) $sub->id;
|
||||
}
|
||||
}
|
||||
|
||||
$formatRefs = function ($ids) {
|
||||
$out = [];
|
||||
foreach ($ids as $id) {
|
||||
$out[] = ['id' => (int) $id];
|
||||
}
|
||||
return $out;
|
||||
};
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'matched_contacts' => count($contactIds),
|
||||
'applied_to_contacts' => $applied,
|
||||
'applied_contact_ids' => $appliedIds,
|
||||
'tags_added' => $formatRefs($addTags['ids']),
|
||||
'tags_removed' => $formatRefs($removeTags['ids']),
|
||||
'lists_added' => $formatRefs($addLists['ids']),
|
||||
'lists_removed' => $formatRefs($removeLists['ids']),
|
||||
'tags_created' => $addTags['created'],
|
||||
'lists_created' => $addLists['created'],
|
||||
'reverse_with' => __('To reverse: re-call apply-segments-to-contacts with contact_ids=applied_contact_ids and add_*/remove_* swapped.', 'fluent-crm'),
|
||||
];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Write: add-contact-note
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function addContactNote($params)
|
||||
{
|
||||
$params = (array) $params;
|
||||
|
||||
$resolved = MCPHelper::resolveContact($params);
|
||||
if (is_wp_error($resolved)) {
|
||||
return $resolved;
|
||||
}
|
||||
$subscriber = $resolved;
|
||||
|
||||
$title = trim((string) ($params['title'] ?? ''));
|
||||
$description = (string) ($params['description'] ?? '');
|
||||
$type = sanitize_key($params['type'] ?? 'note');
|
||||
$allowedTypes = ['note', 'call', 'email', 'meeting', 'quote'];
|
||||
if (!in_array($type, $allowedTypes, true)) {
|
||||
$type = 'note';
|
||||
}
|
||||
|
||||
if ($title === '' || $description === '') {
|
||||
return MCPHelper::error('invalid_param', __('title and description are required', 'fluent-crm'));
|
||||
}
|
||||
|
||||
$noteData = [
|
||||
'subscriber_id' => $subscriber->id,
|
||||
'type' => $type,
|
||||
'title' => $title,
|
||||
'description' => $description,
|
||||
'created_at' => !empty($params['created_at']) ? sanitize_text_field($params['created_at']) : current_time('mysql'),
|
||||
];
|
||||
|
||||
// Run through the same filter the controller does so smartcodes resolve.
|
||||
$noteData['description'] = apply_filters('fluent_crm/parse_campaign_email_text', $noteData['description'], $subscriber);
|
||||
$noteData = \FluentCrm\App\Services\Sanitize::contactNote($noteData);
|
||||
|
||||
$note = \FluentCrm\App\Models\SubscriberNote::create(wp_unslash($noteData));
|
||||
|
||||
do_action('fluent_crm/note_added', $note, $subscriber, $noteData);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'note' => MCPHelper::formatNoteForMCP($note),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Modules\MCP\Tools;
|
||||
|
||||
use FluentCrm\App\Models\CustomContactField;
|
||||
use FluentCrm\App\Models\Lists;
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Models\Tag;
|
||||
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\App\Services\PermissionManager;
|
||||
use FluentCrm\App\Services\Stats;
|
||||
|
||||
/**
|
||||
* `get-crm-context` — discovery surface (MCP_PLAN.md § 5.1).
|
||||
*
|
||||
* The agent calls this once per session to learn:
|
||||
* - who they are (current WP user, FluentCRM permissions)
|
||||
* - what reference data is available (top tags, lists, custom fields)
|
||||
* - which enums are valid (statuses, contact types, design templates, etc.)
|
||||
* - the install's current sender configuration
|
||||
* - guidelines that nudge the agent toward correct usage
|
||||
*
|
||||
* Cached for 60 seconds per WP user via transient. Invalidation hooks (set up
|
||||
* in `MCPInit::init()` lifecycle) clear the cache when underlying reference
|
||||
* data changes.
|
||||
*/
|
||||
class ContextTools
|
||||
{
|
||||
const CACHE_TTL = 60;
|
||||
|
||||
public static function getContext($params = [])
|
||||
{
|
||||
$userId = get_current_user_id();
|
||||
$cacheKey = 'fluent_crm_mcp_context_' . $userId;
|
||||
|
||||
$cached = get_transient($cacheKey);
|
||||
if (is_array($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
$context = self::buildContext($userId);
|
||||
|
||||
set_transient($cacheKey, $context, self::CACHE_TTL);
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
private static function buildContext($userId)
|
||||
{
|
||||
$user = get_user_by('ID', $userId);
|
||||
|
||||
$isAdmin = $user && user_can($user, 'manage_options');
|
||||
|
||||
$you = [
|
||||
'wp_user_id' => (int) $userId,
|
||||
'name' => $user ? $user->display_name : null,
|
||||
'email' => $user ? $user->user_email : null,
|
||||
'is_admin' => (bool) $isAdmin,
|
||||
'permissions' => array_values(PermissionManager::currentUserPermissions(false)),
|
||||
];
|
||||
|
||||
$proActive = defined('FLUENTCAMPAIGN');
|
||||
$aiState = self::detectAiProvider();
|
||||
|
||||
$site = [
|
||||
'site_url' => site_url(),
|
||||
'fluent_crm_version' => defined('FLUENTCRM_PLUGIN_VERSION') ? FLUENTCRM_PLUGIN_VERSION : null,
|
||||
'fluent_campaign_active' => $proActive,
|
||||
'ai_provider_configured' => $aiState['configured'],
|
||||
'ai_provider' => $aiState['provider'],
|
||||
'timezone' => fluentCrmGetTimezoneString(),
|
||||
'current_time' => fluentCrmTimestamp(),
|
||||
];
|
||||
|
||||
$stats = self::buildStats();
|
||||
|
||||
$tags = self::topTagsForContext();
|
||||
$lists = self::topListsForContext();
|
||||
|
||||
$availableTriggers = self::formatRefList(apply_filters('fluentcrm_funnel_triggers', []), 'trigger_name');
|
||||
$availableActions = self::formatRefList(apply_filters('fluentcrm_funnel_blocks', [], null), 'action_name');
|
||||
|
||||
$enums = [
|
||||
'contact_statuses' => array_values(fluentcrm_subscriber_statuses()),
|
||||
'sms_statuses' => array_values(fluentcrm_subscriber_sms_statuses()),
|
||||
'contact_types' => array_values(fluentcrm_contact_types()),
|
||||
'campaign_statuses' => ['draft', 'scheduled', 'pending-scheduled', 'processing', 'working', 'paused', 'archived'],
|
||||
'design_templates' => array_keys(self::allowedDesignTemplates()),
|
||||
'funnel_statuses' => ['draft', 'published'],
|
||||
'funnel_subscriber_statuses' => ['active', 'waiting', 'completed', 'cancelled', 'skipped'],
|
||||
'note_types' => ['note', 'call', 'email', 'meeting', 'quote'],
|
||||
];
|
||||
|
||||
$defaultSender = self::buildDefaultSender();
|
||||
|
||||
$customFieldsSchema = ['contact' => self::buildCustomFieldSchema()];
|
||||
|
||||
return [
|
||||
'you' => $you,
|
||||
'site' => $site,
|
||||
'stats' => $stats,
|
||||
'tags' => $tags,
|
||||
'lists' => $lists,
|
||||
'available_triggers' => $availableTriggers,
|
||||
'available_actions' => $availableActions,
|
||||
'enums' => $enums,
|
||||
'default_sender' => $defaultSender,
|
||||
'custom_fields_schema' => $customFieldsSchema,
|
||||
'smart_codes' => self::buildSmartCodes(),
|
||||
'safety_levels' => self::buildSafetyLevels(),
|
||||
'rate_hints' => self::buildRateHints(),
|
||||
'mcp_capabilities' => self::buildCapabilities(),
|
||||
'guidelines' => self::buildGuidelines(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-tool safety classification — round-3 review R3.
|
||||
*
|
||||
* Lets agents branch on a stable code instead of parsing tool
|
||||
* descriptions or relying on annotations alone (which only
|
||||
* differentiate readonly / destructive in two coarse buckets).
|
||||
*
|
||||
* Levels:
|
||||
* safe_render — no DB writes, no sends, no side effects
|
||||
* readonly — DB reads only
|
||||
* creates_or_mutates_draft — writes data the user can still review/cancel
|
||||
* mutating_with_dry_run — writes data; dry_run preview available
|
||||
* destructive_send — actually sends mail to a real recipient
|
||||
* destructive_irrecoverable — deletion / cannot be undone
|
||||
*/
|
||||
private static function buildSafetyLevels()
|
||||
{
|
||||
return apply_filters('fluent_crm/mcp_safety_levels', [
|
||||
'fluent-crm/get-crm-context' => 'readonly',
|
||||
'fluent-crm/list-contacts' => 'readonly',
|
||||
'fluent-crm/get-contact' => 'readonly',
|
||||
'fluent-crm/list-campaigns' => 'readonly',
|
||||
'fluent-crm/get-campaign' => 'readonly',
|
||||
'fluent-crm/list-automations' => 'readonly',
|
||||
'fluent-crm/list-funnel-subscribers' => 'readonly',
|
||||
'fluent-crm/get-automation' => 'readonly',
|
||||
'fluent-crm/list-sequences' => 'readonly',
|
||||
'fluent-crm/get-sequence' => 'readonly',
|
||||
'fluent-crm/estimate-dynamic-segment' => 'readonly',
|
||||
'fluent-crm/upsert-contact' => 'creates_or_mutates_draft',
|
||||
'fluent-crm/bulk-upsert-contacts' => 'creates_or_mutates_draft',
|
||||
'fluent-crm/add-contact-note' => 'creates_or_mutates_draft',
|
||||
'fluent-crm/upsert-campaign' => 'creates_or_mutates_draft',
|
||||
'fluent-crm/apply-segments-to-contacts' => 'mutating_with_dry_run',
|
||||
'fluent-crm/manage-sequence-subscribers' => 'mutating_with_dry_run',
|
||||
'fluent-crm/update-contact-automation-status' => 'creates_or_mutates_draft',
|
||||
'fluent-crm/manage-tag' => 'destructive_irrecoverable',
|
||||
'fluent-crm/manage-list' => 'destructive_irrecoverable',
|
||||
'fluent-crm/delete-contact' => 'destructive_irrecoverable',
|
||||
'fluent-crm/delete-contact-note' => 'destructive_irrecoverable',
|
||||
'fluent-crm/send-test-email' => 'safe_render',
|
||||
'fluent-crm/send-email-to-contact' => 'destructive_send',
|
||||
// change-campaign-status: per-action — schedule + delete are
|
||||
// destructive in different ways. Annotation already flags it;
|
||||
// the description spells out which actions are dangerous.
|
||||
'fluent-crm/change-campaign-status' => 'destructive_send',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate / cap hints — round-3 review R4.
|
||||
*
|
||||
* Surfaces the limits that are otherwise embedded only in tool
|
||||
* descriptions ("Cap 5000 per call"). Lets agents pre-validate
|
||||
* batch sizes deterministically.
|
||||
*/
|
||||
private static function buildRateHints()
|
||||
{
|
||||
$cap = (int) apply_filters('fluent_crm/mcp_bulk_cap', 5000, 'apply-segments-to-contacts');
|
||||
return apply_filters('fluent_crm/mcp_rate_hints', [
|
||||
'fluent-crm/bulk-upsert-contacts' => ['max_per_call' => 500, 'recommended_batch' => 100],
|
||||
'fluent-crm/apply-segments-to-contacts' => ['max_per_call' => $cap],
|
||||
'fluent-crm/manage-sequence-subscribers' => ['max_per_call' => $cap],
|
||||
'fluent-crm/send-email-to-contact' => ['note' => 'Goes through the normal queue + bounce handling — site-level rate limits apply (see settings.email_settings.emails_per_second).'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Versioned capabilities map — round-3 review R9.
|
||||
*
|
||||
* Lets agents adapt their strategy across MCP versions without trial
|
||||
* and error. Bump `version` whenever a capability is added/removed.
|
||||
*/
|
||||
private static function buildCapabilities()
|
||||
{
|
||||
return apply_filters('fluent_crm/mcp_capabilities', [
|
||||
'version' => '1.4.0',
|
||||
'supports' => [
|
||||
'dry_run_apply_segments',
|
||||
'send_test_email',
|
||||
'smart_codes_discovery',
|
||||
'safety_levels',
|
||||
'rate_hints',
|
||||
'manage_tags_lists',
|
||||
'delete_contact_note',
|
||||
'one_off_email_send',
|
||||
'campaign_warnings',
|
||||
'auto_suffix_title_conflict',
|
||||
'list_funnel_subscribers',
|
||||
'applied_contact_ids_return',
|
||||
'tracking_mode_aware_stats',
|
||||
'recipients_strict_validation',
|
||||
'advanced_filters_provider_validation',
|
||||
],
|
||||
'deprecated' => [],
|
||||
'breaking_changes_pending' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
private static function buildStats()
|
||||
{
|
||||
$stats = (new Stats())->getCounts();
|
||||
|
||||
$todayStart = (new \DateTime('today', new \DateTimeZone(fluentCrmGetTimezoneString())))->format('Y-m-d H:i:s');
|
||||
$sevenDaysAgo = gmdate('Y-m-d H:i:s', time() - (7 * DAY_IN_SECONDS));
|
||||
|
||||
return [
|
||||
'contacts_total' => Subscriber::count(),
|
||||
'contacts_subscribed' => (int) ($stats['total_subscribers']['count'] ?? Subscriber::where('status', 'subscribed')->count()),
|
||||
'contacts_new_today' => Subscriber::where('created_at', '>=', $todayStart)->count(),
|
||||
'campaigns_sent_last_7d' => \FluentCrm\App\Models\Campaign::where('status', 'archived')
|
||||
->where('updated_at', '>=', $sevenDaysAgo)
|
||||
->count(),
|
||||
'automations_active' => \FluentCrm\App\Models\Funnel::where('status', 'published')->count(),
|
||||
'automations_total' => \FluentCrm\App\Models\Funnel::count(),
|
||||
];
|
||||
}
|
||||
|
||||
private static function topTagsForContext($limit = 50)
|
||||
{
|
||||
$tags = Tag::withCount('subscribers')
|
||||
->orderByDesc('subscribers_count')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
$out = [];
|
||||
foreach ($tags as $tag) {
|
||||
$out[] = [
|
||||
'id' => (int) $tag->id,
|
||||
'title' => $tag->title,
|
||||
'slug' => $tag->slug,
|
||||
'subscribers_count' => (int) $tag->subscribers_count,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function topListsForContext($limit = 50)
|
||||
{
|
||||
$lists = Lists::withCount('subscribers')
|
||||
->orderByDesc('subscribers_count')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
$out = [];
|
||||
foreach ($lists as $list) {
|
||||
$out[] = [
|
||||
'id' => (int) $list->id,
|
||||
'title' => $list->title,
|
||||
'slug' => $list->slug,
|
||||
'subscribers_count' => (int) $list->subscribers_count,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function formatRefList($items, $keyField)
|
||||
{
|
||||
if (!is_array($items)) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($items as $key => $item) {
|
||||
$name = is_string($key) ? $key : ($item[$keyField] ?? null);
|
||||
if (!$name) {
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'key' => $name,
|
||||
'label' => $item['label'] ?? $item['title'] ?? $name,
|
||||
'is_pro' => !empty($item['is_pro']),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Design templates the MCP tools allow agents to select. The
|
||||
* visual_builder template is intentionally excluded — it's an
|
||||
* interactive Gutenberg editor experience, not something an agent
|
||||
* should be authoring against. Use `mcp_allowed_design_templates`
|
||||
* to publish it deliberately if a custom workflow needs it.
|
||||
*/
|
||||
public static function allowedDesignTemplates()
|
||||
{
|
||||
$defaults = [
|
||||
'plain' => __('Plain', 'fluent-crm'),
|
||||
'classic' => __('Classic', 'fluent-crm'),
|
||||
'raw_html' => __('Raw HTML', 'fluent-crm'),
|
||||
'raw_classic' => __('Raw Classic', 'fluent-crm'),
|
||||
];
|
||||
|
||||
if (method_exists(Helper::class, 'getEmailDesignTemplates')) {
|
||||
$all = Helper::getEmailDesignTemplates();
|
||||
if (is_array($all) && $all) {
|
||||
$excluded = ['visual_builder'];
|
||||
$filtered = array_diff_key($all, array_flip($excluded));
|
||||
if ($filtered) {
|
||||
$defaults = $filtered;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the design templates surfaced to MCP agents. Useful for
|
||||
* adding custom templates a site has registered, or allow-listing
|
||||
* visual_builder if the operator really wants agents to use it.
|
||||
*
|
||||
* @since 2.10.0
|
||||
*
|
||||
* @param array $templates Map of slug => label.
|
||||
*/
|
||||
return apply_filters('fluent_crm/mcp_allowed_design_templates', $defaults);
|
||||
}
|
||||
|
||||
private static function buildDefaultSender()
|
||||
{
|
||||
$emailSettings = Helper::getGlobalEmailSettings();
|
||||
return [
|
||||
'from_name' => $emailSettings['from_name'] ?? '',
|
||||
'from_email' => $emailSettings['from_email'] ?? '',
|
||||
'reply_to_name' => $emailSettings['reply_to_name'] ?? '',
|
||||
'reply_to_email' => $emailSettings['reply_to_email'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildCustomFieldSchema()
|
||||
{
|
||||
$model = new CustomContactField();
|
||||
$global = $model->getGlobalFields();
|
||||
$fields = is_array($global) ? ($global['fields'] ?? []) : [];
|
||||
|
||||
$out = [];
|
||||
foreach ((array) $fields as $field) {
|
||||
$entry = [
|
||||
'key' => $field['slug'] ?? null,
|
||||
'label' => $field['label'] ?? null,
|
||||
'type' => $field['type'] ?? null,
|
||||
];
|
||||
if (!empty($field['options'])) {
|
||||
$entry['options'] = array_values((array) $field['options']);
|
||||
}
|
||||
if ($entry['key']) {
|
||||
$out[] = $entry;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten Helper::getGlobalSmartCodes() into a compact, agent-friendly
|
||||
* shape — review #19. Preserves group structure so the agent can find
|
||||
* codes by source (contact / custom fields / general / extensions).
|
||||
*/
|
||||
private static function buildSmartCodes()
|
||||
{
|
||||
if (!method_exists(Helper::class, 'getGlobalSmartCodes')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groups = Helper::getGlobalSmartCodes();
|
||||
if (!is_array($groups)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($groups as $group) {
|
||||
$codes = [];
|
||||
$shortcodes = $group['shortcodes'] ?? [];
|
||||
if (is_array($shortcodes)) {
|
||||
foreach ($shortcodes as $code => $label) {
|
||||
$codes[] = ['code' => (string) $code, 'label' => (string) $label];
|
||||
}
|
||||
}
|
||||
$out[] = [
|
||||
'key' => $group['key'] ?? null,
|
||||
'title' => $group['title'] ?? null,
|
||||
'codes' => $codes,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function detectAiProvider()
|
||||
{
|
||||
$aiSettings = fluentcrm_get_option('ai_settings', []);
|
||||
$provider = '';
|
||||
$configured = false;
|
||||
|
||||
if (!empty($aiSettings['active_provider'])) {
|
||||
$provider = sanitize_key($aiSettings['active_provider']);
|
||||
$providerCfg = $aiSettings[$provider] ?? [];
|
||||
$configured = !empty($providerCfg['api_key']);
|
||||
}
|
||||
|
||||
return ['provider' => $provider ?: null, 'configured' => $configured];
|
||||
}
|
||||
|
||||
private static function buildGuidelines()
|
||||
{
|
||||
$default = "Be concise. When sending to a contact, confirm their status is 'subscribed'. " .
|
||||
"Use add_tags/remove_tags for delta updates. Drafts are safe — only change-campaign-status " .
|
||||
"with action=schedule causes sending. The site timezone is in site.timezone — use it when " .
|
||||
"constructing scheduled_at. Use custom_fields_schema to construct valid custom_fields payloads " .
|
||||
"on upsert-contact — never invent keys. Filter shape (universal): {search, tags[], lists[], " .
|
||||
"statuses[], contact_type, created_after, created_before, sort_by, sort_type}.";
|
||||
|
||||
/**
|
||||
* Filter the AI guidelines text returned in get-crm-context.
|
||||
*
|
||||
* Useful for shop-specific nudges (e.g. "always tag MCP-touched contacts
|
||||
* with `mcp-edited`"). Keep terse — the text ships in every session's
|
||||
* tool-discovery payload.
|
||||
*
|
||||
* @since 2.10.0
|
||||
*
|
||||
* @param string $default
|
||||
*/
|
||||
return apply_filters('fluent_crm/mcp_ai_guidelines', $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached context for every user. Hooked from MCPInit on the
|
||||
* relevant FluentCRM events.
|
||||
*/
|
||||
public static function invalidateCache()
|
||||
{
|
||||
global $wpdb;
|
||||
$like = $wpdb->esc_like('_transient_fluent_crm_mcp_context_') . '%';
|
||||
$wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
|
||||
$like = $wpdb->esc_like('_transient_timeout_fluent_crm_mcp_context_') . '%';
|
||||
$wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Modules\MCP\Tools;
|
||||
|
||||
use FluentCrm\App\Models\Campaign;
|
||||
use FluentCrm\App\Models\CustomEmailCampaign;
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
|
||||
use FluentCrm\App\Modules\MCP\Tools\ContextTools;
|
||||
use FluentCrm\App\Services\BlockParser;
|
||||
use FluentCrm\App\Services\Helper;
|
||||
use FluentCrm\App\Services\Libs\Mailer\Mailer;
|
||||
use FluentCrm\App\Services\Sanitize;
|
||||
use FluentCrm\Framework\Support\Arr;
|
||||
|
||||
/**
|
||||
* One-off email tools — wraps SubscriberController::sendCustomEmail
|
||||
* (MCP_PLAN.md § 5.9), exposing every option the contact-profile "Send
|
||||
* Custom Email" UI surfaces *except* the interactive ones (visual_builder
|
||||
* design template and template_id picker):
|
||||
*
|
||||
* - subject + preheader + body
|
||||
* - design_template (plain | classic | raw_html | raw_classic)
|
||||
* - mailer overrides: from_name/from_email/reply_to_name/reply_to_email
|
||||
* - is_transactional (auto-disables the footer to match UI behavior)
|
||||
* - explicit disable_footer override
|
||||
* - click/open trackers (yes|no|anonymous)
|
||||
* - UTM tagging
|
||||
* - free-form settings passthrough for template_config / footer_settings
|
||||
*
|
||||
* Reuses the normal queue + bounce + FluentSMTP plumbing so MCP-sent emails
|
||||
* behave identically to one-offs sent from the contact profile.
|
||||
*/
|
||||
class EmailTools
|
||||
{
|
||||
public static function sendEmailToContact($params)
|
||||
{
|
||||
$params = (array) $params;
|
||||
|
||||
$resolved = MCPHelper::resolveContact($params);
|
||||
if (is_wp_error($resolved)) {
|
||||
return $resolved;
|
||||
}
|
||||
$contact = $resolved;
|
||||
|
||||
$subject = trim((string) ($params['subject'] ?? ''));
|
||||
$body = (string) ($params['body'] ?? '');
|
||||
|
||||
if ($subject === '' || $body === '') {
|
||||
return MCPHelper::error('invalid_param', __('subject and body are required', 'fluent-crm'));
|
||||
}
|
||||
|
||||
// Status check matches the controller's gate. Phrase the error so
|
||||
// the agent doesn't think `is_transactional=yes` will bypass it
|
||||
// (review #8 — that flag is the *message* type, not a status
|
||||
// override).
|
||||
$allowedStatuses = ['subscribed', 'transactional'];
|
||||
if (!in_array($contact->status, $allowedStatuses, true)) {
|
||||
return MCPHelper::error('invalid_param', sprintf(
|
||||
/* translators: 1: current contact status, 2: comma-separated list of allowed statuses */
|
||||
__("The contact's status is '%1\$s'. To send to this contact, the contact's own status must be one of: %2\$s. The is_transactional parameter controls the message type, not the contact gate.", 'fluent-crm'),
|
||||
$contact->status,
|
||||
implode(', ', $allowedStatuses)
|
||||
), [
|
||||
'current_status' => $contact->status,
|
||||
'allowed_statuses' => $allowedStatuses,
|
||||
]);
|
||||
}
|
||||
|
||||
$defaults = Helper::getGlobalEmailSettings();
|
||||
|
||||
$designTemplate = sanitize_key((string) ($params['design_template'] ?? 'classic'));
|
||||
if ($designTemplate === '') {
|
||||
$designTemplate = 'classic';
|
||||
}
|
||||
// Defense in depth — even though the schema enum constrains this,
|
||||
// a non-honoring agent (or a direct REST call) could still try to
|
||||
// pass `visual_builder` or another disallowed value. Reject server
|
||||
// side with a structured error.
|
||||
$allowed = array_keys(ContextTools::allowedDesignTemplates());
|
||||
if (!in_array($designTemplate, $allowed, true)) {
|
||||
return MCPHelper::error('invalid_param', __('design_template not allowed via MCP', 'fluent-crm'), [
|
||||
'design_template' => $designTemplate,
|
||||
'allowed' => $allowed,
|
||||
]);
|
||||
}
|
||||
|
||||
$isTransactional = self::yesNo($params['is_transactional'] ?? null, 'no');
|
||||
|
||||
// Footer toggle — UI behavior: turning on transactional auto-disables
|
||||
// the global footer because transactional mail must not include a
|
||||
// marketing unsubscribe link. Honor that by default; let the caller
|
||||
// override explicitly.
|
||||
if (array_key_exists('disable_footer', $params)) {
|
||||
$disableFooter = self::yesNo($params['disable_footer'], 'no');
|
||||
} else {
|
||||
$disableFooter = $isTransactional === 'yes' ? 'yes' : 'no';
|
||||
}
|
||||
|
||||
$clickTracker = self::trackerValue($params['click_tracker'] ?? null);
|
||||
$openTracker = self::trackerValue($params['open_tracker'] ?? null);
|
||||
|
||||
// Build the mailer override block.
|
||||
$fromName = sanitize_text_field((string) ($params['from_name'] ?? $defaults['from_name']));
|
||||
$fromEmail = sanitize_email((string) ($params['from_email'] ?? $defaults['from_email']));
|
||||
$replyToName = sanitize_text_field((string) ($params['reply_to_name'] ?? ($defaults['reply_to_name'] ?? '')));
|
||||
$replyToEmail = sanitize_email((string) ($params['reply_to_email'] ?? ($defaults['reply_to_email'] ?? '')));
|
||||
|
||||
$mailerSettings = [
|
||||
'from_name' => $fromName,
|
||||
'from_email' => $fromEmail,
|
||||
'reply_to_name' => $replyToName,
|
||||
'reply_to_email' => $replyToEmail,
|
||||
'is_custom' => 'yes',
|
||||
];
|
||||
|
||||
// Compose the settings object the way the UI does.
|
||||
$settings = [
|
||||
'mailer_settings' => $mailerSettings,
|
||||
'is_transactional' => $isTransactional,
|
||||
'footer_settings' => [
|
||||
'disable_footer' => $disableFooter,
|
||||
],
|
||||
'template_config' => Helper::getTemplateConfig($designTemplate),
|
||||
];
|
||||
if ($clickTracker !== null) {
|
||||
$settings['click_tracker'] = $clickTracker;
|
||||
}
|
||||
if ($openTracker !== null) {
|
||||
$settings['open_tracker'] = $openTracker;
|
||||
}
|
||||
|
||||
// Allow callers to pass an arbitrary `settings` object for things
|
||||
// we haven't surfaced as top-level params (e.g. visual-builder style
|
||||
// overrides). Caller-provided keys win on conflict.
|
||||
if (!empty($params['settings']) && is_array($params['settings'])) {
|
||||
$settings = array_replace_recursive($settings, $params['settings']);
|
||||
}
|
||||
|
||||
// Custom title for audit / log; default keeps recipient email so the
|
||||
// entry is searchable in the campaign list.
|
||||
$title = isset($params['title']) && $params['title'] !== ''
|
||||
? sanitize_text_field((string) $params['title'])
|
||||
: sprintf(__('MCP one-off to %s', 'fluent-crm'), $contact->email);
|
||||
|
||||
$campaignData = [
|
||||
'title' => $title,
|
||||
'email_subject' => $subject,
|
||||
'email_pre_header' => sanitize_text_field((string) ($params['pre_header'] ?? $params['preheader'] ?? '')),
|
||||
'email_body' => $body,
|
||||
'design_template' => $designTemplate,
|
||||
'settings' => $settings,
|
||||
'status' => 'draft',
|
||||
];
|
||||
|
||||
// UTM tagging — flatten the optional `utm` object onto the
|
||||
// campaign's utm_* columns.
|
||||
if (!empty($params['utm']) && is_array($params['utm'])) {
|
||||
$utm = $params['utm'];
|
||||
$campaignData['utm_status'] = !empty($utm['status']) ? 1 : 0;
|
||||
foreach (['source', 'medium', 'campaign', 'term', 'content'] as $key) {
|
||||
if (isset($utm[$key])) {
|
||||
$campaignData['utm_' . $key] = sanitize_text_field((string) $utm[$key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$campaignData = Sanitize::campaign($campaignData);
|
||||
|
||||
// Mirror the WP_Error surfacing behavior of the controller.
|
||||
add_action('wp_mail_failed', function ($wpError) {
|
||||
if (method_exists(Helper::class, 'debugLog')) {
|
||||
Helper::debugLog('MCP send-email-to-contact failure', $wpError->get_error_message(), 'error');
|
||||
}
|
||||
}, 10, 1);
|
||||
|
||||
$campaign = CustomEmailCampaign::create($campaignData);
|
||||
|
||||
$campaign->subscribe([(int) $contact->id], [
|
||||
'status' => 'scheduled',
|
||||
'scheduled_at' => current_time('mysql'),
|
||||
]);
|
||||
|
||||
do_action('fluentcrm_process_contact_jobs', $contact);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'campaign_id' => (int) $campaign->id,
|
||||
'message' => __('Email queued for delivery', 'fluent-crm'),
|
||||
'contact' => [
|
||||
'id' => (int) $contact->id,
|
||||
'email' => $contact->email,
|
||||
],
|
||||
'applied' => [
|
||||
'is_transactional' => $isTransactional,
|
||||
'disable_footer' => $disableFooter,
|
||||
'design_template' => $designTemplate,
|
||||
'from' => self::formatAddress($fromName, $fromEmail),
|
||||
'reply_to' => self::formatAddress($replyToName, $replyToEmail),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an RFC-5322 "Display Name <addr>" string. Previous version
|
||||
* (`trim(... ' <>')`) ate the closing `>` from any "Name (with parens)"
|
||||
* — review #15.
|
||||
*/
|
||||
private static function formatAddress($name, $email)
|
||||
{
|
||||
$email = trim((string) $email);
|
||||
$name = trim((string) $name);
|
||||
if ($email === '') return '';
|
||||
if ($name === '') return $email;
|
||||
return $name . ' <' . $email . '>';
|
||||
}
|
||||
|
||||
/**
|
||||
* `send-test-email` — render and send a one-off test copy of either:
|
||||
* - a saved campaign (pass campaign_id), or
|
||||
* - a draft body/subject the agent supplies inline.
|
||||
*
|
||||
* Differs from send-email-to-contact: NO campaign record is created,
|
||||
* NO subscriber is enrolled, NO row is logged to fc_campaign_emails,
|
||||
* and the recipient does NOT need to be subscribed. The subject is
|
||||
* prefixed with "TEST:" to match what the contact-profile UI does.
|
||||
* Mirrors CampaignController::sendTestEmail.
|
||||
*/
|
||||
public static function sendTestEmail($params)
|
||||
{
|
||||
$params = (array) $params;
|
||||
|
||||
// Resolve recipient address — defaults to the current WP user.
|
||||
$toEmail = sanitize_email((string) ($params['to_email'] ?? ''));
|
||||
if (!$toEmail) {
|
||||
$user = wp_get_current_user();
|
||||
$toEmail = $user ? $user->user_email : '';
|
||||
}
|
||||
if (!$toEmail || !is_email($toEmail)) {
|
||||
return MCPHelper::error('invalid_param', __('A valid to_email is required.', 'fluent-crm'));
|
||||
}
|
||||
|
||||
// Source the email content from a saved campaign or inline params.
|
||||
$campaignId = isset($params['campaign_id']) ? (int) $params['campaign_id'] : 0;
|
||||
$subject = $body = $preHeader = '';
|
||||
$designTemplate = '';
|
||||
$settings = [];
|
||||
|
||||
if ($campaignId) {
|
||||
// Need to bypass the global type scope so test sends work for
|
||||
// custom_email_campaign / sequence_mail / etc., not just
|
||||
// type='campaign'.
|
||||
$campaign = Campaign::withoutGlobalScope('type')->find($campaignId);
|
||||
if (!$campaign) {
|
||||
return MCPHelper::error('not_found', __('Campaign not found', 'fluent-crm'), ['campaign_id' => $campaignId]);
|
||||
}
|
||||
$subject = (string) $campaign->email_subject;
|
||||
$body = (string) $campaign->email_body;
|
||||
$preHeader = (string) $campaign->email_pre_header;
|
||||
$designTemplate = (string) $campaign->design_template;
|
||||
$settings = is_array($campaign->settings) ? $campaign->settings : (array) maybe_unserialize($campaign->settings);
|
||||
}
|
||||
|
||||
// Inline params override campaign-derived values.
|
||||
if (isset($params['subject']) && $params['subject'] !== '') {
|
||||
$subject = (string) $params['subject'];
|
||||
}
|
||||
if (isset($params['body']) && $params['body'] !== '') {
|
||||
$body = (string) $params['body'];
|
||||
}
|
||||
if (isset($params['pre_header'])) {
|
||||
$preHeader = (string) $params['pre_header'];
|
||||
}
|
||||
if (isset($params['design_template']) && $params['design_template'] !== '') {
|
||||
$designTemplate = sanitize_key((string) $params['design_template']);
|
||||
}
|
||||
if ($designTemplate === '') {
|
||||
$designTemplate = 'classic';
|
||||
}
|
||||
// Apply the same MCP-safe enum guard as send-email-to-contact.
|
||||
$allowedTemplates = array_keys(ContextTools::allowedDesignTemplates());
|
||||
if (!in_array($designTemplate, $allowedTemplates, true)) {
|
||||
return MCPHelper::error('invalid_param', __('design_template not allowed via MCP', 'fluent-crm'), [
|
||||
'design_template' => $designTemplate,
|
||||
'allowed' => $allowedTemplates,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($subject === '' || $body === '') {
|
||||
return MCPHelper::error('invalid_param', __('Provide either campaign_id, or subject + body.', 'fluent-crm'));
|
||||
}
|
||||
|
||||
// Resolve the subscriber whose data smartcodes get filled with.
|
||||
// Priority: explicit against_contact_*, then to_email, then any
|
||||
// subscribed contact (mirrors CampaignController fallback).
|
||||
$subscriber = null;
|
||||
if (!empty($params['against_contact_id'])) {
|
||||
$subscriber = Subscriber::find((int) $params['against_contact_id']);
|
||||
}
|
||||
if (!$subscriber && !empty($params['against_contact_email'])) {
|
||||
$subscriber = Subscriber::where('email', sanitize_email($params['against_contact_email']))->first();
|
||||
}
|
||||
if (!$subscriber) {
|
||||
$subscriber = Subscriber::where('email', $toEmail)->first();
|
||||
}
|
||||
if (!$subscriber) {
|
||||
$subscriber = Subscriber::where('status', 'subscribed')->first();
|
||||
}
|
||||
if (!$subscriber) {
|
||||
return MCPHelper::error('not_supported', __('No subscriber found to drive smartcode rendering. Add at least one subscribed contact.', 'fluent-crm'));
|
||||
}
|
||||
|
||||
// Catch wp_mail errors for the response.
|
||||
$mailErrors = [];
|
||||
$mailErrorListener = function ($wpError) use (&$mailErrors) {
|
||||
$mailErrors[] = $wpError->get_error_message();
|
||||
};
|
||||
add_action('wp_mail_failed', $mailErrorListener, 10, 1);
|
||||
|
||||
// Block-template rendering — same gate the controller uses.
|
||||
$rawTemplates = ['raw_html', 'raw_classic'];
|
||||
if (!in_array($designTemplate, $rawTemplates, true)) {
|
||||
$body = (new BlockParser($subscriber))->parse($body);
|
||||
}
|
||||
|
||||
// Footer config — pulled from a stand-in object so we can pass non-
|
||||
// persisted draft data through Helper::getFooterConfig the same way
|
||||
// the controller does.
|
||||
$stub = (object) [
|
||||
'design_template' => $designTemplate,
|
||||
'settings' => $settings ?: ['template_config' => []],
|
||||
'email_pre_header' => $preHeader,
|
||||
'email_body' => $body,
|
||||
'email_subject' => $subject,
|
||||
];
|
||||
$footerConfig = method_exists(Helper::class, 'getFooterConfig') ? Helper::getFooterConfig($stub) : ['footer_content' => ''];
|
||||
$footerText = Arr::get($footerConfig, 'footer_content', '');
|
||||
|
||||
// Run the standard parse_campaign_email_text filter chain so
|
||||
// smartcodes resolve.
|
||||
$body = apply_filters('fluent_crm/parse_campaign_email_text', $body, $subscriber);
|
||||
$footerText = apply_filters('fluent_crm/parse_campaign_email_text', $footerText, $subscriber);
|
||||
$subject = apply_filters('fluent_crm/parse_campaign_email_text', $subject, $subscriber);
|
||||
$preHeader = apply_filters('fluent_crm/parse_campaign_email_text', $preHeader, $subscriber);
|
||||
|
||||
$footerConfig['footer_content'] = $footerText;
|
||||
|
||||
$templateData = [
|
||||
'preHeader' => $preHeader,
|
||||
'email_body' => $body,
|
||||
'footer_text' => $footerText,
|
||||
'footer_config' => $footerConfig,
|
||||
'config' => wp_parse_args(
|
||||
Arr::get($settings, 'template_config', []),
|
||||
Helper::getTemplateConfig($designTemplate)
|
||||
),
|
||||
];
|
||||
|
||||
$body = apply_filters(
|
||||
'fluent_crm/email-design-template-' . $designTemplate,
|
||||
$body,
|
||||
$templateData,
|
||||
$stub,
|
||||
$subscriber
|
||||
);
|
||||
|
||||
$body = str_replace('{{crm_global_email_footer}}', $footerText, $body);
|
||||
$body = str_replace('{{crm_preheader_text}}', $preHeader, $body);
|
||||
|
||||
$data = [
|
||||
'to' => [
|
||||
'email' => $toEmail,
|
||||
'name' => $subscriber->full_name ?: $toEmail,
|
||||
],
|
||||
'subject' => 'TEST: ' . $subject,
|
||||
'body' => $body,
|
||||
'headers' => Helper::getMailHeadersFromSettings(Arr::get($settings, 'mailer_settings', [])),
|
||||
];
|
||||
|
||||
if (method_exists(Helper::class, 'maybeDisableEmojiOnEmail')) {
|
||||
Helper::maybeDisableEmojiOnEmail();
|
||||
}
|
||||
$result = Mailer::send($data, $subscriber, null, true);
|
||||
|
||||
remove_action('wp_mail_failed', $mailErrorListener, 10);
|
||||
|
||||
$sent = $result !== false && empty($mailErrors);
|
||||
|
||||
return [
|
||||
'ok' => $sent,
|
||||
'sent' => $sent,
|
||||
'to' => $toEmail,
|
||||
'rendered_against' => [
|
||||
'contact_id' => (int) $subscriber->id,
|
||||
'email' => $subscriber->email,
|
||||
],
|
||||
'subject_preview' => 'TEST: ' . $subject,
|
||||
'design_template' => $designTemplate,
|
||||
'errors' => $mailErrors,
|
||||
'note' => __('Test sends bypass the queue, do not enroll the recipient, and do not appear in email_history.', 'fluent-crm'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function yesNo($value, $default = 'no')
|
||||
{
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'yes' : 'no';
|
||||
}
|
||||
$str = strtolower((string) $value);
|
||||
if (in_array($str, ['yes', 'true', '1', 'on'], true)) {
|
||||
return 'yes';
|
||||
}
|
||||
if (in_array($str, ['no', 'false', '0', 'off', ''], true)) {
|
||||
return 'no';
|
||||
}
|
||||
return $default;
|
||||
}
|
||||
|
||||
private static function trackerValue($value)
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
$str = strtolower((string) $value);
|
||||
if (in_array($str, ['yes', 'no', 'anonymous'], true)) {
|
||||
return $str;
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
return $value ? 'yes' : 'no';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Modules\MCP\Tools;
|
||||
|
||||
use FluentCrm\App\Models\Funnel;
|
||||
use FluentCrm\App\Models\FunnelSubscriber;
|
||||
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
|
||||
use FluentCrm\App\Services\Funnel\FunnelHelper;
|
||||
|
||||
/**
|
||||
* Automation (funnel) MCP tools.
|
||||
*
|
||||
* Read tools (Phase 2): listAutomations, getAutomation.
|
||||
* Write tools (Phase 3): updateContactAutomationStatus.
|
||||
*/
|
||||
class FunnelTools
|
||||
{
|
||||
// -----------------------------------------------------------------
|
||||
// Read: list-automations
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function listAutomations($params)
|
||||
{
|
||||
$params = (array) $params;
|
||||
|
||||
MCPHelper::paginationFromInput($params);
|
||||
|
||||
$search = sanitize_text_field((string) ($params['search'] ?? ''));
|
||||
$statuses = (array) ($params['statuses'] ?? []);
|
||||
$statuses = array_values(array_intersect(
|
||||
array_map('sanitize_key', $statuses),
|
||||
['draft', 'published']
|
||||
));
|
||||
// All fc_funnels columns. The framework rewrite made orderBy() throw
|
||||
// LogicException on column names that don't match ^[a-zA-Z0-9_\.]+$
|
||||
// — empty strings, "id ASC", "DROP TABLE", etc. — so an unguarded
|
||||
// sort_by would 500 the tool. Schema is stable (migration only adds
|
||||
// indexes), so hardcoding the column list avoids a per-request
|
||||
// SHOW COLUMNS without restricting agents to the input_schema enum.
|
||||
$allowedSortBy = [
|
||||
'id', 'type', 'title', 'trigger_name', 'status', 'conditions',
|
||||
'settings', 'created_by', 'created_at', 'updated_at',
|
||||
];
|
||||
$sortBy = sanitize_key((string) ($params['sort_by'] ?? 'id'));
|
||||
if (!in_array($sortBy, $allowedSortBy, true)) {
|
||||
$sortBy = 'id';
|
||||
}
|
||||
$sortType = strtoupper(sanitize_text_field((string) ($params['sort_type'] ?? 'DESC')));
|
||||
$sortType = in_array($sortType, ['ASC', 'DESC'], true) ? $sortType : 'DESC';
|
||||
|
||||
$query = Funnel::withCount('subscribers')->orderBy($sortBy, $sortType);
|
||||
|
||||
if ($search !== '') {
|
||||
global $wpdb;
|
||||
$like = '%' . $wpdb->esc_like($search) . '%';
|
||||
$query->where(function ($q) use ($like) {
|
||||
$q->where('title', 'LIKE', $like)
|
||||
->orWhere('trigger_name', 'LIKE', $like);
|
||||
});
|
||||
}
|
||||
|
||||
if (!empty($statuses)) {
|
||||
$query->whereIn('status', $statuses);
|
||||
}
|
||||
|
||||
$paginated = $query->paginate();
|
||||
|
||||
$items = [];
|
||||
$triggerLabels = self::triggerLabelMap();
|
||||
foreach ($paginated->items() as $funnel) {
|
||||
$items[] = [
|
||||
'id' => (int) $funnel->id,
|
||||
'title' => $funnel->title,
|
||||
'status' => $funnel->status,
|
||||
'trigger_name' => $funnel->trigger_name,
|
||||
'trigger_label' => $triggerLabels[$funnel->trigger_name] ?? $funnel->trigger_name,
|
||||
'in_progress_subscribers_count' => self::inProgressCount((int) $funnel->id),
|
||||
'completed_subscribers_count' => (int) ($funnel->subscribers_count ?? 0),
|
||||
'created_at' => MCPHelper::toIso8601($funnel->created_at),
|
||||
'updated_at' => MCPHelper::toIso8601($funnel->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => (int) $paginated->total(),
|
||||
'page' => (int) $paginated->currentPage(),
|
||||
'per_page' => (int) $paginated->perPage(),
|
||||
'pages' => (int) $paginated->lastPage(),
|
||||
];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Read: get-automation
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function getAutomation($params)
|
||||
{
|
||||
$params = (array) $params;
|
||||
$funnelId = (int) ($params['funnel_id'] ?? 0);
|
||||
|
||||
if (!$funnelId) {
|
||||
return MCPHelper::error('invalid_param', __('funnel_id is required', 'fluent-crm'));
|
||||
}
|
||||
|
||||
$funnel = Funnel::find($funnelId);
|
||||
if (!$funnel) {
|
||||
return MCPHelper::error('not_found', __('Automation not found', 'fluent-crm'), ['funnel_id' => $funnelId]);
|
||||
}
|
||||
|
||||
$defaultIncludes = ['sequences', 'report'];
|
||||
$include = isset($params['include']) && is_array($params['include']) && $params['include']
|
||||
? array_values(array_intersect($params['include'], ['sequences', 'report']))
|
||||
: $defaultIncludes;
|
||||
|
||||
$triggerLabels = self::triggerLabelMap();
|
||||
|
||||
$data = [
|
||||
'id' => (int) $funnel->id,
|
||||
'title' => $funnel->title,
|
||||
'status' => $funnel->status,
|
||||
'trigger_name' => $funnel->trigger_name,
|
||||
'trigger_label' => $triggerLabels[$funnel->trigger_name] ?? $funnel->trigger_name,
|
||||
'trigger_settings' => is_array($funnel->settings) ? $funnel->settings : [],
|
||||
'conditions' => is_array($funnel->conditions) ? $funnel->conditions : [],
|
||||
'in_progress_subscribers_count' => self::inProgressCount((int) $funnel->id),
|
||||
'completed_subscribers_count' => (int) FunnelSubscriber::where('funnel_id', $funnel->id)
|
||||
->where('status', 'completed')
|
||||
->count(),
|
||||
'created_at' => MCPHelper::toIso8601($funnel->created_at),
|
||||
'updated_at' => MCPHelper::toIso8601($funnel->updated_at),
|
||||
];
|
||||
|
||||
if (in_array('sequences', $include, true)) {
|
||||
$sequences = FunnelHelper::getFunnelSequences($funnel, true);
|
||||
$includeBodies = !empty($params['include_bodies']);
|
||||
$data['sequences'] = self::formatSequences($sequences, $includeBodies);
|
||||
}
|
||||
|
||||
if (in_array('report', $include, true)) {
|
||||
$data['report'] = self::buildStepReport($funnel);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format funnel sequences for MCP. By default we strip large body
|
||||
* fields out of `settings` (action_name=send_custom_email embeds an
|
||||
* entire campaign payload including email_body). Pass include_bodies
|
||||
* = true to get the full settings tree — review #7 (token bloat).
|
||||
*/
|
||||
private static function formatSequences($sequences, $includeBodies = false)
|
||||
{
|
||||
$out = [];
|
||||
foreach ((array) $sequences as $seq) {
|
||||
$row = is_object($seq) ? get_object_vars($seq) : (array) $seq;
|
||||
$settings = $row['settings'] ?? [];
|
||||
if (!$includeBodies) {
|
||||
$settings = self::stripBodyFields($settings);
|
||||
}
|
||||
$out[] = [
|
||||
'id' => isset($row['id']) ? (int) $row['id'] : null,
|
||||
'type' => $row['type'] ?? null,
|
||||
'action_name' => $row['action_name'] ?? null,
|
||||
'settings' => $settings,
|
||||
'delay' => $row['delay'] ?? 0,
|
||||
'delay_unit' => $row['c_delay_unit'] ?? ($row['delay_unit'] ?? null),
|
||||
'parent_id' => isset($row['parent_id']) ? (int) $row['parent_id'] : null,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively redact body / html fields. Replaces them with a marker so
|
||||
* the agent knows the field exists and can re-fetch with include_bodies.
|
||||
*
|
||||
* Round-3 review B5: dropped the previous "only if >200 chars" gate —
|
||||
* any stored body field gets stripped now, regardless of size, so the
|
||||
* include_bodies=false contract is honored consistently. Rare to have a
|
||||
* truly tiny body field anyway, and the marker is shorter than most
|
||||
* email bodies.
|
||||
*/
|
||||
private static function stripBodyFields($value)
|
||||
{
|
||||
$bodyKeys = ['email_body', 'body', 'body_html', 'body_text'];
|
||||
if (!is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
foreach ($value as $k => $v) {
|
||||
if (is_string($k) && in_array($k, $bodyKeys, true) && is_string($v)) {
|
||||
$len = strlen($v);
|
||||
$value[$k] = $len > 0
|
||||
? '[truncated — re-fetch with include_bodies=true; ' . $len . ' chars]'
|
||||
: '';
|
||||
} elseif (is_array($v)) {
|
||||
$value[$k] = self::stripBodyFields($v);
|
||||
}
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
private static function buildStepReport($funnel)
|
||||
{
|
||||
$stepCounts = FunnelSubscriber::where('funnel_id', $funnel->id)
|
||||
->select(['last_sequence_id'])
|
||||
->selectRaw('COUNT(id) as total')
|
||||
->groupBy('last_sequence_id')
|
||||
->get();
|
||||
|
||||
$steps = [];
|
||||
foreach ($stepCounts as $row) {
|
||||
if (!$row->last_sequence_id) {
|
||||
continue;
|
||||
}
|
||||
$steps[] = [
|
||||
'step_id' => (int) $row->last_sequence_id,
|
||||
'total' => (int) $row->total,
|
||||
];
|
||||
}
|
||||
return ['steps' => $steps];
|
||||
}
|
||||
|
||||
private static function inProgressCount($funnelId)
|
||||
{
|
||||
return (int) FunnelSubscriber::where('funnel_id', $funnelId)
|
||||
->whereIn('status', ['active', 'waiting'])
|
||||
->count();
|
||||
}
|
||||
|
||||
private static function triggerLabelMap()
|
||||
{
|
||||
$triggers = apply_filters('fluentcrm_funnel_triggers', []);
|
||||
$map = [];
|
||||
if (is_array($triggers)) {
|
||||
foreach ($triggers as $key => $config) {
|
||||
$map[$key] = $config['label'] ?? $key;
|
||||
}
|
||||
}
|
||||
return $map;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Read: list-funnel-subscribers (round-4 review P3 #11)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* List the contacts currently in a funnel filtered by subscription
|
||||
* status. Closes a real workflow gap: "this customer just upgraded —
|
||||
* pull them out of trial-onboarding" requires knowing who's in the
|
||||
* funnel first, and there was no way to find that without already
|
||||
* knowing the contact id.
|
||||
*/
|
||||
public static function listFunnelSubscribers($params)
|
||||
{
|
||||
$params = (array) $params;
|
||||
$funnelId = (int) ($params['funnel_id'] ?? 0);
|
||||
if (!$funnelId) {
|
||||
return MCPHelper::error('invalid_param', __('funnel_id is required', 'fluent-crm'));
|
||||
}
|
||||
|
||||
$funnel = Funnel::find($funnelId);
|
||||
if (!$funnel) {
|
||||
return MCPHelper::error('not_found', __('Automation not found', 'fluent-crm'), ['funnel_id' => $funnelId]);
|
||||
}
|
||||
|
||||
$allowedStatuses = ['active', 'waiting', 'completed', 'cancelled', 'skipped'];
|
||||
$statuses = (array) ($params['statuses'] ?? ['active']);
|
||||
$statuses = array_values(array_intersect(array_map('sanitize_key', $statuses), $allowedStatuses));
|
||||
if (!$statuses) {
|
||||
$statuses = ['active'];
|
||||
}
|
||||
|
||||
MCPHelper::paginationFromInput($params);
|
||||
|
||||
$rows = FunnelSubscriber::with(['subscriber' => function ($q) {
|
||||
$q->select(['id', 'email', 'first_name', 'last_name', 'status', 'contact_type']);
|
||||
}])
|
||||
->where('funnel_id', $funnelId)
|
||||
->whereIn('status', $statuses)
|
||||
->orderBy('id', 'DESC')
|
||||
->paginate();
|
||||
|
||||
$items = [];
|
||||
foreach ($rows->items() as $row) {
|
||||
$sub = $row->subscriber;
|
||||
if (!$sub) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'funnel_subscriber_id' => (int) $row->id,
|
||||
'funnel_status' => $row->status,
|
||||
'next_sequence_id' => $row->next_sequence_id ? (int) $row->next_sequence_id : null,
|
||||
'last_executed_at' => MCPHelper::toIso8601($row->last_executed_time),
|
||||
'next_execution_at' => MCPHelper::toIso8601($row->next_execution_time),
|
||||
'enrolled_at' => MCPHelper::toIso8601($row->created_at),
|
||||
'contact' => [
|
||||
'id' => (int) $sub->id,
|
||||
'email' => $sub->email,
|
||||
'full_name' => trim((string) ($sub->first_name . ' ' . $sub->last_name)),
|
||||
'status' => $sub->status,
|
||||
'contact_type' => $sub->contact_type,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $items,
|
||||
'total' => (int) $rows->total(),
|
||||
'page' => (int) $rows->currentPage(),
|
||||
'per_page' => (int) $rows->perPage(),
|
||||
'pages' => (int) $rows->lastPage(),
|
||||
'funnel' => [
|
||||
'id' => (int) $funnel->id,
|
||||
'title' => $funnel->title,
|
||||
],
|
||||
'filtered_statuses' => $statuses,
|
||||
];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Write: update-contact-automation-status
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function updateContactAutomationStatus($params)
|
||||
{
|
||||
$params = (array) $params;
|
||||
$funnelId = (int) ($params['funnel_id'] ?? 0);
|
||||
$action = sanitize_key((string) ($params['action'] ?? ''));
|
||||
|
||||
if (!$funnelId) {
|
||||
return MCPHelper::error('invalid_param', __('funnel_id is required', 'fluent-crm'));
|
||||
}
|
||||
if (!in_array($action, ['resume', 'cancel', 'advance_now'], true)) {
|
||||
// 'pause' was intentionally dropped — FluentCRM has no native
|
||||
// paused funnel-subscriber state, and the previous mapping
|
||||
// silently cancelled. Tell the agent what the alternative is.
|
||||
if ($action === 'pause') {
|
||||
return MCPHelper::error('not_supported', __('pause is not supported — FluentCRM has no paused state for funnel subscribers. Use cancel to stop processing (reversible from the UI), or wait for a real benchmark.', 'fluent-crm'), [
|
||||
'allowed_actions' => ['resume', 'cancel', 'advance_now'],
|
||||
]);
|
||||
}
|
||||
return MCPHelper::error('invalid_param', __('Invalid action', 'fluent-crm'), [
|
||||
'allowed_actions' => ['resume', 'cancel', 'advance_now'],
|
||||
]);
|
||||
}
|
||||
|
||||
$contact = MCPHelper::resolveContact($params);
|
||||
if (is_wp_error($contact)) {
|
||||
return $contact;
|
||||
}
|
||||
|
||||
$funnel = Funnel::find($funnelId);
|
||||
if (!$funnel) {
|
||||
return MCPHelper::error('not_found', __('Automation not found', 'fluent-crm'), ['funnel_id' => $funnelId]);
|
||||
}
|
||||
|
||||
$row = FunnelSubscriber::where('funnel_id', $funnelId)
|
||||
->where('subscriber_id', $contact->id)
|
||||
->first();
|
||||
if (!$row) {
|
||||
return MCPHelper::error('not_found', __('Contact is not enrolled in this automation', 'fluent-crm'));
|
||||
}
|
||||
|
||||
$previousStatus = $row->status;
|
||||
|
||||
if ($row->status === 'completed') {
|
||||
return MCPHelper::error('not_supported', __('Automation is already completed for this contact', 'fluent-crm'), [
|
||||
'status' => $row->status,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($action === 'cancel') {
|
||||
$row->status = 'cancelled';
|
||||
$row->save();
|
||||
} elseif ($action === 'resume') {
|
||||
$row->status = 'active';
|
||||
if (!$row->next_execution_time) {
|
||||
$row->next_execution_time = gmdate('Y-m-d H:i:s', current_time('timestamp') + 60);
|
||||
}
|
||||
$row->save();
|
||||
} elseif ($action === 'advance_now') {
|
||||
$sequenceId = (int) ($params['advance_to_sequence_id'] ?? 0);
|
||||
if (!$sequenceId) {
|
||||
return MCPHelper::error('invalid_param', __('advance_to_sequence_id is required for advance_now', 'fluent-crm'));
|
||||
}
|
||||
$sequence = \FluentCrm\App\Models\FunnelSequence::where('id', $sequenceId)
|
||||
->where('funnel_id', $funnelId)
|
||||
->first();
|
||||
if (!$sequence) {
|
||||
return MCPHelper::error('not_found', __('Target sequence not found in this automation', 'fluent-crm'));
|
||||
}
|
||||
|
||||
// If the contact is waiting on a benchmark, mark the benchmark as
|
||||
// skipped so reports stay accurate (matches the controller's path).
|
||||
if ($row->status === 'waiting') {
|
||||
$benchmarkSeq = \FluentCrm\App\Models\FunnelSequence::find($row->next_sequence_id);
|
||||
if ($benchmarkSeq) {
|
||||
\FluentCrm\App\Models\FunnelMetric::updateOrCreate(
|
||||
[
|
||||
'funnel_id' => $funnelId,
|
||||
'sequence_id' => $benchmarkSeq->id,
|
||||
'subscriber_id' => $contact->id,
|
||||
],
|
||||
[
|
||||
'benchmark_value' => 0,
|
||||
'benchmark_currency' => 'USD',
|
||||
'status' => 'skipped',
|
||||
'notes' => __('Skipped via MCP advance_now', 'fluent-crm'),
|
||||
]
|
||||
);
|
||||
\FluentCrm\App\Services\Funnel\FunnelHelper::changeFunnelSubSequenceStatus($row->id, $benchmarkSeq->id, 'skipped');
|
||||
}
|
||||
}
|
||||
|
||||
$prev = \FluentCrm\App\Models\FunnelSequence::where('funnel_id', $funnelId)
|
||||
->where('sequence', '<', $sequence->sequence)
|
||||
->orderBy('sequence', 'DESC')
|
||||
->first();
|
||||
|
||||
$row->last_sequence_id = $prev ? $prev->id : 0;
|
||||
$row->next_sequence_id = $sequence->id;
|
||||
$row->next_sequence = $sequence->sequence;
|
||||
$row->status = 'active';
|
||||
$row->next_execution_time = current_time('mysql');
|
||||
$row->save();
|
||||
}
|
||||
|
||||
$row = FunnelSubscriber::find($row->id);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'action' => $action,
|
||||
'previous_status' => $previousStatus,
|
||||
'current_status' => $row->status,
|
||||
'funnel_subscriber' => [
|
||||
'id' => (int) $row->id,
|
||||
'funnel_id' => (int) $row->funnel_id,
|
||||
'subscriber_id' => (int) $row->subscriber_id,
|
||||
'status' => $row->status,
|
||||
'next_sequence_id' => $row->next_sequence_id ? (int) $row->next_sequence_id : null,
|
||||
'next_execution_time' => MCPHelper::toIso8601($row->next_execution_time),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
namespace FluentCrm\App\Modules\MCP\Tools;
|
||||
|
||||
use FluentCrm\App\Models\Lists;
|
||||
use FluentCrm\App\Models\Subscriber;
|
||||
use FluentCrm\App\Models\Tag;
|
||||
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
|
||||
use FluentCrm\App\Services\PermissionManager;
|
||||
|
||||
/**
|
||||
* Tag/list management — round-3 review item #11.
|
||||
*
|
||||
* `manage-tag` and `manage-list` cover create, update, delete, and merge
|
||||
* operations. Splitting create/update/delete into separate tools would
|
||||
* triple the surface; the action enum keeps it compact while the
|
||||
* `destructive` annotation tells MCP clients to confirm delete + merge.
|
||||
*
|
||||
* Merge semantics: re-pivot every subscriber attached to a `from` tag/list
|
||||
* onto the `to` target, then delete the `from` rows. Idempotent — running
|
||||
* the same merge twice no-ops on the second call (subscribers are already
|
||||
* pivoted, sources already deleted).
|
||||
*/
|
||||
class SegmentTools
|
||||
{
|
||||
// -----------------------------------------------------------------
|
||||
// manage-tag
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function manageTag($params)
|
||||
{
|
||||
return self::manageSegment($params, 'tag');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// manage-list
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
public static function manageList($params)
|
||||
{
|
||||
return self::manageSegment($params, 'list');
|
||||
}
|
||||
|
||||
private static function manageSegment($params, $kind)
|
||||
{
|
||||
$params = (array) $params;
|
||||
$action = sanitize_key((string) ($params['action'] ?? ''));
|
||||
|
||||
if (!in_array($action, ['create', 'update', 'delete', 'merge'], true)) {
|
||||
return MCPHelper::error('invalid_param', __('action must be one of: create, update, delete, merge', 'fluent-crm'));
|
||||
}
|
||||
|
||||
// Permission gate. create/update need _cats; delete needs _cats_delete.
|
||||
$needsDeleteCap = in_array($action, ['delete', 'merge'], true);
|
||||
$cap = $needsDeleteCap ? 'fcrm_manage_contact_cats_delete' : 'fcrm_manage_contact_cats';
|
||||
if (!PermissionManager::currentUserCan($cap)) {
|
||||
return MCPHelper::error('forbidden', sprintf(
|
||||
/* translators: %s: required capability */
|
||||
__('This action requires the %s capability.', 'fluent-crm'),
|
||||
$cap
|
||||
), ['required' => $cap]);
|
||||
}
|
||||
|
||||
switch ($action) {
|
||||
case 'create':
|
||||
return self::actionCreate($params, $kind);
|
||||
case 'update':
|
||||
return self::actionUpdate($params, $kind);
|
||||
case 'delete':
|
||||
return self::actionDelete($params, $kind);
|
||||
case 'merge':
|
||||
return self::actionMerge($params, $kind);
|
||||
}
|
||||
|
||||
return MCPHelper::error('invalid_param', __('Unhandled action', 'fluent-crm'));
|
||||
}
|
||||
|
||||
private static function actionCreate($params, $kind)
|
||||
{
|
||||
$title = trim((string) ($params['title'] ?? ''));
|
||||
if ($title === '') {
|
||||
return MCPHelper::error('invalid_param', __('title is required for create', 'fluent-crm'));
|
||||
}
|
||||
$slug = isset($params['slug']) && $params['slug'] !== ''
|
||||
? sanitize_title((string) $params['slug'])
|
||||
: sanitize_title($title);
|
||||
$description = sanitize_textarea_field((string) ($params['description'] ?? ''));
|
||||
|
||||
$existing = self::lookupByTitleOrSlug($title, $slug, $kind);
|
||||
if ($existing) {
|
||||
return MCPHelper::error('contact_exists', sprintf(
|
||||
/* translators: 1: kind (tag/list), 2: matched id */
|
||||
__('A %1$s with that title or slug already exists (id %2$d). Use update or merge to change it.', 'fluent-crm'),
|
||||
$kind,
|
||||
(int) $existing->id
|
||||
), ['existing_id' => (int) $existing->id]);
|
||||
}
|
||||
|
||||
$modelClass = $kind === 'list' ? Lists::class : Tag::class;
|
||||
$row = $modelClass::create([
|
||||
'title' => sanitize_text_field($title),
|
||||
'slug' => $slug,
|
||||
'description' => $description,
|
||||
]);
|
||||
|
||||
do_action(self::createdHook($kind), $row);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'action' => 'create',
|
||||
'kind' => $kind,
|
||||
$kind => self::format($row),
|
||||
'note' => sprintf(
|
||||
/* translators: 1: kind, 2: title */
|
||||
__('%1$s "%2$s" created. No subscribers are attached yet.', 'fluent-crm'),
|
||||
ucfirst($kind),
|
||||
$row->title
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private static function actionUpdate($params, $kind)
|
||||
{
|
||||
$id = (int) ($params[$kind . '_id'] ?? 0);
|
||||
$row = $id ? self::find($id, $kind) : null;
|
||||
if (!$row) {
|
||||
return MCPHelper::error('not_found', sprintf(__('%s not found', 'fluent-crm'), ucfirst($kind)), [$kind . '_id' => $id]);
|
||||
}
|
||||
|
||||
$changes = [];
|
||||
if (isset($params['title']) && $params['title'] !== '' && $params['title'] !== $row->title) {
|
||||
$changes['title'] = ['from' => $row->title, 'to' => sanitize_text_field((string) $params['title'])];
|
||||
$row->title = $changes['title']['to'];
|
||||
}
|
||||
if (isset($params['slug']) && $params['slug'] !== '') {
|
||||
$newSlug = sanitize_title((string) $params['slug']);
|
||||
if ($newSlug !== $row->slug) {
|
||||
$changes['slug'] = ['from' => $row->slug, 'to' => $newSlug];
|
||||
$row->slug = $newSlug;
|
||||
}
|
||||
}
|
||||
if (array_key_exists('description', $params)) {
|
||||
$newDesc = sanitize_textarea_field((string) $params['description']);
|
||||
if ($newDesc !== $row->description) {
|
||||
$changes['description'] = ['from' => $row->description, 'to' => $newDesc];
|
||||
$row->description = $newDesc;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($changes)) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'action' => 'update',
|
||||
'kind' => $kind,
|
||||
$kind => self::format($row),
|
||||
'note' => __('No changes — provided fields matched the current values.', 'fluent-crm'),
|
||||
];
|
||||
}
|
||||
|
||||
$row->save();
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'action' => 'update',
|
||||
'kind' => $kind,
|
||||
$kind => self::format($row),
|
||||
'changes' => $changes,
|
||||
];
|
||||
}
|
||||
|
||||
private static function actionDelete($params, $kind)
|
||||
{
|
||||
$id = (int) ($params[$kind . '_id'] ?? 0);
|
||||
$row = $id ? self::find($id, $kind) : null;
|
||||
if (!$row) {
|
||||
return MCPHelper::error('not_found', sprintf(__('%s not found', 'fluent-crm'), ucfirst($kind)), [$kind . '_id' => $id]);
|
||||
}
|
||||
|
||||
$force = !empty($params['force']);
|
||||
$attachedCount = self::attachedSubscriberCount($row, $kind);
|
||||
|
||||
if ($attachedCount > 0 && !$force) {
|
||||
return MCPHelper::error('not_supported', sprintf(
|
||||
/* translators: 1: kind, 2: count */
|
||||
__('%1$s has %2$d subscribers attached. Pass force=true to delete anyway, or merge into another %1$s first.', 'fluent-crm'),
|
||||
ucfirst($kind),
|
||||
$attachedCount
|
||||
), [
|
||||
'attached_subscribers' => $attachedCount,
|
||||
'force_required' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$deletedId = (int) $row->id;
|
||||
$deletedTitle = (string) $row->title;
|
||||
$row->delete();
|
||||
do_action(self::deletedHook($kind), $deletedId);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'action' => 'delete',
|
||||
'kind' => $kind,
|
||||
'deleted_id' => $deletedId,
|
||||
'deleted_title' => $deletedTitle,
|
||||
'detached_subscribers' => $attachedCount,
|
||||
'note' => $attachedCount > 0
|
||||
? __('Deleted with subscribers attached — pivot rows are orphaned and cleaned up by the cleanup hook.', 'fluent-crm')
|
||||
: __('Deleted. No subscribers were attached.', 'fluent-crm'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function actionMerge($params, $kind)
|
||||
{
|
||||
$fromIds = isset($params['from_' . $kind . '_ids']) ? (array) $params['from_' . $kind . '_ids'] : [];
|
||||
$fromIds = array_values(array_unique(array_filter(array_map('intval', $fromIds))));
|
||||
$toId = (int) ($params['to_' . $kind . '_id'] ?? 0);
|
||||
|
||||
if (!$toId) {
|
||||
return MCPHelper::error('invalid_param', __('to_*_id is required for merge', 'fluent-crm'));
|
||||
}
|
||||
if (!$fromIds) {
|
||||
return MCPHelper::error('invalid_param', __('from_*_ids must be a non-empty array of ids', 'fluent-crm'));
|
||||
}
|
||||
if (in_array($toId, $fromIds, true)) {
|
||||
return MCPHelper::error('invalid_param', __('to_*_id cannot also be in from_*_ids', 'fluent-crm'));
|
||||
}
|
||||
|
||||
$to = self::find($toId, $kind);
|
||||
if (!$to) {
|
||||
return MCPHelper::error('not_found', sprintf(__('Target %s not found', 'fluent-crm'), $kind), ['to_id' => $toId]);
|
||||
}
|
||||
|
||||
$modelClass = $kind === 'list' ? Lists::class : Tag::class;
|
||||
$fromRows = $modelClass::whereIn('id', $fromIds)->get();
|
||||
$foundFromIds = $fromRows->pluck('id')->map('intval')->toArray();
|
||||
$missingFromIds = array_values(array_diff($fromIds, $foundFromIds));
|
||||
|
||||
// Re-pivot subscribers attached to the `from` set onto the `to` target.
|
||||
$attachedCount = 0;
|
||||
foreach ($fromRows as $fromRow) {
|
||||
$count = self::attachedSubscriberCount($fromRow, $kind);
|
||||
$attachedCount += $count;
|
||||
}
|
||||
|
||||
$repivoted = 0;
|
||||
foreach ($fromRows as $fromRow) {
|
||||
$subscribers = self::attachedSubscribers($fromRow, $kind);
|
||||
foreach ($subscribers as $sub) {
|
||||
if ($kind === 'list') {
|
||||
$sub->attachLists([$to->id]);
|
||||
$sub->detachLists([$fromRow->id]);
|
||||
} else {
|
||||
$sub->attachTags([$to->id]);
|
||||
$sub->detachTags([$fromRow->id]);
|
||||
}
|
||||
$repivoted++;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the source rows.
|
||||
foreach ($fromRows as $fromRow) {
|
||||
$fromId = (int) $fromRow->id;
|
||||
$fromRow->delete();
|
||||
do_action(self::deletedHook($kind), $fromId);
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'action' => 'merge',
|
||||
'kind' => $kind,
|
||||
'merged_from' => $foundFromIds,
|
||||
'merged_into' => self::format($to),
|
||||
'subscribers_repivoted' => $repivoted,
|
||||
'subscribers_seen' => $attachedCount,
|
||||
'missing_from_ids' => $missingFromIds,
|
||||
'note' => __('Each subscriber attached to a "from" target is now attached to "to" and the "from" rows are deleted. Re-running this merge with the same ids is a safe no-op.', 'fluent-crm'),
|
||||
];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// helpers
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private static function find($id, $kind)
|
||||
{
|
||||
return $kind === 'list' ? Lists::find($id) : Tag::find($id);
|
||||
}
|
||||
|
||||
private static function lookupByTitleOrSlug($title, $slug, $kind)
|
||||
{
|
||||
$modelClass = $kind === 'list' ? Lists::class : Tag::class;
|
||||
return $modelClass::where('title', $title)->orWhere('slug', $slug)->first();
|
||||
}
|
||||
|
||||
private static function format($row)
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'title' => $row->title,
|
||||
'slug' => $row->slug,
|
||||
'description' => $row->description ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
private static function attachedSubscriberCount($row, $kind)
|
||||
{
|
||||
return (int) ($kind === 'list'
|
||||
? $row->subscribers()->count()
|
||||
: $row->subscribers()->count());
|
||||
}
|
||||
|
||||
private static function attachedSubscribers($row, $kind)
|
||||
{
|
||||
return $kind === 'list'
|
||||
? $row->subscribers()->get()
|
||||
: $row->subscribers()->get();
|
||||
}
|
||||
|
||||
private static function createdHook($kind)
|
||||
{
|
||||
return $kind === 'list' ? 'fluent_crm/list_created' : 'fluent_crm/tag_created';
|
||||
}
|
||||
|
||||
private static function deletedHook($kind)
|
||||
{
|
||||
return $kind === 'list' ? 'fluent_crm/list_deleted' : 'fluent_crm/tag_deleted';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user