Permette di leggere i bounce dalla stessa MailIdentity SMTP, aggiornare le email in anagrafica e saltare gli indirizzi invalidi nei mailing. Co-authored-by: Cursor <cursoragent@cursor.com>
334 lines
12 KiB
Ruby
334 lines
12 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "json"
|
|
require "mcp"
|
|
|
|
module EminuxCrmMcp
|
|
INSTRUCTIONS = <<~TEXT.freeze
|
|
Sei collegato a eminuxCRM. Quando l'utente chiede di lavorare su prospect, pipeline, task o follow-up, usa questi tool (non curl).
|
|
1. list_projects per i project_code (matchlivetv, riskmeter, cardoo, …).
|
|
2. today per capire cosa fare oggi.
|
|
3. search / get_organization per il contesto.
|
|
4. create_activity, complete_task, create_task, update_opportunity_stage, update_organization_email, check_mail_bounces per agire.
|
|
Non inviare mailing, non cancellare record, non gestire utenti.
|
|
Per i bounce usa check_mail_bounces (legge IMAP dalle stesse credenziali SMTP della MailIdentity).
|
|
TEXT
|
|
|
|
module_function
|
|
|
|
def allowed_hosts
|
|
hosts = %w[localhost 127.0.0.1 [::1] web]
|
|
hosts.concat(configured_public_hosts)
|
|
hosts << "www.example.com" if defined?(Rails) && Rails.env.test?
|
|
hosts.map { |host| normalize_host(host) }.compact.uniq
|
|
end
|
|
|
|
def configured_public_hosts
|
|
values = [ ENV["APP_HOST"] ]
|
|
values.concat(ENV.fetch("RAILS_ALLOWED_HOSTS", "").split(","))
|
|
values
|
|
end
|
|
|
|
def normalize_host(value)
|
|
host = value.to_s.strip
|
|
return if host.blank?
|
|
|
|
host = host.sub(%r{\Ahttps?://}i, "")
|
|
host.split("/").first
|
|
end
|
|
|
|
def server_for(user:)
|
|
MCP::Server.new(
|
|
name: "eminuxcrm",
|
|
version: "1.0.0",
|
|
instructions: INSTRUCTIONS,
|
|
server_context: { user: user },
|
|
tools: [
|
|
ListProjects,
|
|
Today,
|
|
Search,
|
|
GetOrganization,
|
|
CreateTask,
|
|
CompleteTask,
|
|
CreateActivity,
|
|
UpdateOpportunityStage,
|
|
UpdateOrganizationEmail,
|
|
CheckMailBounces
|
|
]
|
|
)
|
|
end
|
|
|
|
def json_response(result)
|
|
MCP::Tool::Response.new(
|
|
[ { type: "text", text: JSON.pretty_generate(result.body.as_json) } ],
|
|
error: !result.ok
|
|
)
|
|
end
|
|
|
|
def session_from(server_context)
|
|
user = extract_user(server_context)
|
|
raise "MCP senza utente autenticato" if user.nil?
|
|
|
|
Crm::AgentSession.new(user)
|
|
end
|
|
|
|
def extract_user(ctx)
|
|
return if ctx.nil?
|
|
return ctx[:user] if ctx.is_a?(Hash)
|
|
return ctx.user if ctx.respond_to?(:user) && ctx.method(:user).arity.zero?
|
|
|
|
inner = ctx.instance_variable_get(:@context) if ctx.respond_to?(:instance_variable_get)
|
|
return inner[:user] if inner.is_a?(Hash)
|
|
|
|
ctx[:user] if ctx.respond_to?(:[])
|
|
end
|
|
|
|
class ListProjects < MCP::Tool
|
|
description "Elenca i progetti CRM accessibili all'utente del token (codici da usare negli altri tool)."
|
|
|
|
class << self
|
|
def call(server_context: nil)
|
|
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).projects)
|
|
end
|
|
end
|
|
end
|
|
|
|
class Today < MCP::Tool
|
|
description "Cosa fare oggi: task scaduti, di oggi, in arrivo e opportunità ferme da 7+ giorni."
|
|
input_schema(
|
|
properties: {
|
|
project_code: { type: "string", description: "Codice progetto, es. matchlivetv" }
|
|
},
|
|
required: %w[project_code]
|
|
)
|
|
|
|
class << self
|
|
def call(project_code:, server_context: nil)
|
|
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).today(project_code))
|
|
end
|
|
end
|
|
end
|
|
|
|
class Search < MCP::Tool
|
|
description "Cerca organizzazioni, contatti e opportunità nel progetto."
|
|
input_schema(
|
|
properties: {
|
|
project_code: { type: "string" },
|
|
q: { type: "string", description: "Testo di ricerca" }
|
|
},
|
|
required: %w[project_code q]
|
|
)
|
|
|
|
class << self
|
|
def call(project_code:, q:, server_context: nil)
|
|
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).search(project_code, q))
|
|
end
|
|
end
|
|
end
|
|
|
|
class GetOrganization < MCP::Tool
|
|
description "Scheda organizzazione: anagrafica, contatti, opportunità aperte, task pending e ultime attività."
|
|
input_schema(
|
|
properties: {
|
|
project_code: { type: "string" },
|
|
id: { type: "integer", description: "ID organizzazione" }
|
|
},
|
|
required: %w[project_code id]
|
|
)
|
|
|
|
class << self
|
|
def call(project_code:, id:, server_context: nil)
|
|
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).organization(project_code, id))
|
|
end
|
|
end
|
|
end
|
|
|
|
class CreateTask < MCP::Tool
|
|
description "Crea un task (follow-up, chiamata, email, …) su un'organizzazione del progetto."
|
|
input_schema(
|
|
properties: {
|
|
project_code: { type: "string" },
|
|
organization_id: { type: "integer" },
|
|
title: { type: "string" },
|
|
due_at: { type: "string", description: "ISO8601, es. 2026-09-03T10:00:00+02:00" },
|
|
description: { type: "string" },
|
|
contact_id: { type: "integer" },
|
|
opportunity_id: { type: "integer" },
|
|
assigned_user_id: { type: "integer" },
|
|
priority: { type: "string", description: "low, normal, high, urgent" },
|
|
task_type: { type: "string", description: "follow_up, call, email, meeting, demo, proposal, generic" }
|
|
},
|
|
required: %w[project_code organization_id title due_at]
|
|
)
|
|
|
|
class << self
|
|
def call(project_code:, organization_id:, title:, due_at:, description: nil, contact_id: nil,
|
|
opportunity_id: nil, assigned_user_id: nil, priority: nil, task_type: nil, server_context: nil)
|
|
attrs = {
|
|
organization_id: organization_id,
|
|
title: title,
|
|
due_at: due_at,
|
|
description: description,
|
|
contact_id: contact_id,
|
|
opportunity_id: opportunity_id,
|
|
assigned_user_id: assigned_user_id,
|
|
priority: priority,
|
|
task_type: task_type
|
|
}.compact
|
|
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).create_task(project_code, attrs))
|
|
end
|
|
end
|
|
end
|
|
|
|
class CompleteTask < MCP::Tool
|
|
description "Segna un task come completato e registra l'attività in timeline."
|
|
input_schema(
|
|
properties: {
|
|
project_code: { type: "string" },
|
|
id: { type: "integer", description: "ID task" }
|
|
},
|
|
required: %w[project_code id]
|
|
)
|
|
|
|
class << self
|
|
def call(project_code:, id:, server_context: nil)
|
|
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).complete_task(project_code, id))
|
|
end
|
|
end
|
|
end
|
|
|
|
class CreateActivity < MCP::Tool
|
|
description "Annota un'attività in timeline (nota, chiamata, email inviata/ricevuta, demo, proposta, …)."
|
|
input_schema(
|
|
properties: {
|
|
project_code: { type: "string" },
|
|
organization_id: { type: "integer" },
|
|
activity_type: { type: "string", description: "note, email_sent, email_received, call, meeting, demo, follow_up, proposal_sent, other" },
|
|
subject: { type: "string" },
|
|
description: { type: "string" },
|
|
happened_at: { type: "string", description: "ISO8601; default ora" },
|
|
contact_id: { type: "integer" },
|
|
opportunity_id: { type: "integer" }
|
|
},
|
|
required: %w[project_code organization_id activity_type subject]
|
|
)
|
|
|
|
class << self
|
|
def call(project_code:, organization_id:, activity_type:, subject:, description: nil, happened_at: nil,
|
|
contact_id: nil, opportunity_id: nil, server_context: nil)
|
|
attrs = {
|
|
organization_id: organization_id,
|
|
activity_type: activity_type,
|
|
subject: subject,
|
|
description: description,
|
|
happened_at: happened_at,
|
|
contact_id: contact_id,
|
|
opportunity_id: opportunity_id
|
|
}.compact
|
|
EminuxCrmMcp.json_response(EminuxCrmMcp.session_from(server_context).create_activity(project_code, attrs))
|
|
end
|
|
end
|
|
end
|
|
|
|
class UpdateOpportunityStage < MCP::Tool
|
|
description "Sposta un'opportunità di pipeline (to_contact, contacted, replied, interested, demo_trial, first_use, proposal, won, lost)."
|
|
input_schema(
|
|
properties: {
|
|
project_code: { type: "string" },
|
|
id: { type: "integer", description: "ID opportunità" },
|
|
pipeline_stage: { type: "string" },
|
|
lost_reason: { type: "string", description: "Obbligatorio se stage = lost" },
|
|
notes: { type: "string" }
|
|
},
|
|
required: %w[project_code id pipeline_stage]
|
|
)
|
|
|
|
class << self
|
|
def call(project_code:, id:, pipeline_stage:, lost_reason: nil, notes: nil, server_context: nil)
|
|
EminuxCrmMcp.json_response(
|
|
EminuxCrmMcp.session_from(server_context).update_opportunity_stage(
|
|
project_code,
|
|
id,
|
|
pipeline_stage: pipeline_stage,
|
|
lost_reason: lost_reason,
|
|
notes: notes
|
|
)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
class UpdateOrganizationEmail < MCP::Tool
|
|
description "Aggiorna l'email di un'organizzazione (e del contatto principale): marca bounce/non valida, sostituisci con un indirizzo trovato, annota in timeline."
|
|
input_schema(
|
|
properties: {
|
|
project_code: { type: "string" },
|
|
organization_id: { type: "integer" },
|
|
email: { type: "string", description: "Nuova email (se trovata). Lascia vuoto per solo marcare non valida." },
|
|
email_invalid: { type: "boolean", description: "true = non usare in mailing; false se hai messo un'email nuova" },
|
|
bounced_email: { type: "string", description: "Indirizzo che ha generato il bounce" },
|
|
bounce_reason: { type: "string" },
|
|
contact_id: { type: "integer", description: "Contatto specifico; default = primario" },
|
|
website: { type: "string" },
|
|
activity_subject: { type: "string" },
|
|
activity_description: { type: "string" }
|
|
},
|
|
required: %w[project_code organization_id]
|
|
)
|
|
|
|
class << self
|
|
def call(project_code:, organization_id:, email: nil, email_invalid: nil, bounced_email: nil,
|
|
bounce_reason: nil, contact_id: nil, website: nil, activity_subject: nil,
|
|
activity_description: nil, server_context: nil)
|
|
attrs = {
|
|
organization_id: organization_id,
|
|
email: email,
|
|
email_invalid: email_invalid,
|
|
bounced_email: bounced_email,
|
|
bounce_reason: bounce_reason,
|
|
contact_id: contact_id,
|
|
website: website,
|
|
activity_subject: activity_subject,
|
|
activity_description: activity_description
|
|
}.compact
|
|
# Permetti email: null esplicito solo se chiave presente via email=""
|
|
attrs[:email] = email if !email.nil?
|
|
EminuxCrmMcp.json_response(
|
|
EminuxCrmMcp.session_from(server_context).update_organization_email(project_code, attrs)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
|
|
class CheckMailBounces < MCP::Tool
|
|
description "Legge i bounce/DSN dalla casella IMAP dell'account email CRM (stesse credenziali SMTP della MailIdentity). Arricchisce con match alle organizzazioni del progetto."
|
|
input_schema(
|
|
properties: {
|
|
project_code: { type: "string", description: "Progetto per matchare le società (es. matchlivetv)" },
|
|
from_email: { type: "string", description: "Mittente account, es. info@matchlivetv.it (default)" },
|
|
mail_identity_id: { type: "integer" },
|
|
since_days: { type: "integer", description: "Giorni indietro da controllare (default 14)" },
|
|
mailbox: { type: "string", description: "Cartella IMAP, default INBOX" },
|
|
limit: { type: "integer", description: "Max messaggi bounce da analizzare" }
|
|
},
|
|
required: %w[project_code]
|
|
)
|
|
|
|
class << self
|
|
def call(project_code:, from_email: nil, mail_identity_id: nil, since_days: nil,
|
|
mailbox: nil, limit: nil, server_context: nil)
|
|
attrs = {
|
|
from_email: from_email,
|
|
mail_identity_id: mail_identity_id,
|
|
since_days: since_days,
|
|
mailbox: mailbox,
|
|
limit: limit
|
|
}.compact
|
|
EminuxCrmMcp.json_response(
|
|
EminuxCrmMcp.session_from(server_context).check_mail_bounces(project_code, attrs)
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|