From fca4631ddc6a41ea4d2f53b3ede9183cbd3c06ce Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Thu, 26 Mar 2026 21:57:03 +0100 Subject: [PATCH 1/2] minor fix: remove f string from prompt --- flexus_client_kit/integrations/fi_resend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flexus_client_kit/integrations/fi_resend.py b/flexus_client_kit/integrations/fi_resend.py index 991fb201..62661944 100644 --- a/flexus_client_kit/integrations/fi_resend.py +++ b/flexus_client_kit/integrations/fi_resend.py @@ -43,12 +43,12 @@ def resend_testing_domain() -> str: }, ] -RESEND_PROMPT = f"""## Email +RESEND_PROMPT = """## Email Use email_send() to send emails. Use email_setup_domain() to register and manage sending domains, call email_setup_domain(op="help") first. Users can configure EMAIL_RESPOND_TO addresses — emails to those addresses are handled as tasks, all others are logged as CRM activities. Strongly recommend using a subdomain (e.g. mail.example.com) instead of the main domain, especially for inbound emails. -If no domain is configured, send from *@{resend_testing_domain()} for testing. +If no domain is configured, call email_setup_domain(op="help") to find out the testing domain you can use. Never use flexus_my_setup() for email domains — they are saved automatically via email_setup_domain() tool. If user wants to use their own Resend account, they should connect it via the Integrations page — the webhook is created automatically on connect.""" From 05ced97c916a05b1896fc5f5bc6c70c6b2ad85d9 Mon Sep 17 00:00:00 2001 From: Humberto Yusta Date: Fri, 27 Mar 2026 22:28:33 +0100 Subject: [PATCH 2/2] vix can verify email while talking to messenger platform --- flexus_client_kit/ckit_integrations_db.py | 3 +- flexus_client_kit/integrations/fi_crm.py | 135 +++++++++++++++++++++- flexus_simple_bots/vix/vix_bot.py | 2 +- flexus_simple_bots/vix/vix_install.py | 4 +- flexus_simple_bots/vix/vix_prompts.py | 36 ++---- 5 files changed, 146 insertions(+), 34 deletions(-) diff --git a/flexus_client_kit/ckit_integrations_db.py b/flexus_client_kit/ckit_integrations_db.py index eaf3cfd5..a0fdb5c1 100644 --- a/flexus_client_kit/ckit_integrations_db.py +++ b/flexus_client_kit/ckit_integrations_db.py @@ -321,12 +321,13 @@ def _setup_erp(obj, rcx, _tam=tools_and_methods): "manage_contact": (fi_crm.MANAGE_CRM_CONTACT_TOOL, "handle_manage_crm_contact"), "manage_deal": (fi_crm.MANAGE_CRM_DEAL_TOOL, "handle_manage_crm_deal"), "log_activity": (fi_crm.LOG_CRM_ACTIVITY_TOOL, "handle_log_crm_activity"), + "verify_email": (fi_crm.VERIFY_EMAIL_TOOL, "handle_verify_email"), } if subset is None: subset = list(tool_map.keys()) tools_and_methods = [(tool_map[s][0], tool_map[s][1]) for s in subset] async def _init_crm(rcx, setup): - return fi_crm.IntegrationCrm(rcx.fclient, rcx.persona.ws_id) + return fi_crm.IntegrationCrm(rcx.fclient, rcx.persona.ws_id, rcx) def _setup_crm(obj, rcx, _tam=tools_and_methods): for tool, method_name in _tam: rcx.on_tool_call(tool.name)(getattr(obj, method_name)) diff --git a/flexus_client_kit/integrations/fi_crm.py b/flexus_client_kit/integrations/fi_crm.py index a2717cc8..831965fd 100644 --- a/flexus_client_kit/integrations/fi_crm.py +++ b/flexus_client_kit/integrations/fi_crm.py @@ -1,10 +1,17 @@ +import logging import time -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, TYPE_CHECKING +import gql import gql.transport.exceptions from flexus_client_kit import ckit_cloudtool, ckit_client, ckit_erp, erp_schema +if TYPE_CHECKING: + from flexus_client_kit import ckit_bot_exec + +logger = logging.getLogger("fi_crm") + LOG_CRM_ACTIVITY_TOOL = ckit_cloudtool.CloudTool( strict=False, @@ -35,17 +42,33 @@ MANAGE_CRM_CONTACT_TOOL = ckit_cloudtool.CloudTool( strict=False, name="manage_crm_contact", - description="Create or patch a CRM contact. Call op='help' to see available fields.", + description="Manage CRM contact for the current chat: create/patch, get_summary (with latest 2 deals/orders/activities), get_all_deals, get_all_orders, get_all_activities.", parameters={ "type": "object", "properties": { - "op": {"type": "string", "enum": ["help", "create", "patch"], "order": 1}, + "op": {"type": "string", "enum": ["create", "patch", "get_summary", "get_all_deals", "get_all_orders", "get_all_activities"], "order": 1}, "args": {"type": "object", "description": "Contact fields; include contact_id for patch", "order": 2}, }, "required": ["op"], }, ) +VERIFY_EMAIL_TOOL = ckit_cloudtool.CloudTool( + strict=True, + name="verify_email", + description="Verify chat user's email: send_code sends a verification code, confirm_code checks it.", + parameters={ + "type": "object", + "properties": { + "op": {"type": "string", "enum": ["send_code", "confirm_code"], "order": 1}, + "email": {"type": "string", "order": 2}, + "code": {"type": "string", "order": 3}, + }, + "required": ["op", "email", "code"], + "additionalProperties": False, + }, +) + MANAGE_CRM_DEAL_TOOL = ckit_cloudtool.CloudTool( strict=False, name="manage_crm_deal", @@ -69,17 +92,117 @@ async def find_contact_by_platform_id(fclient, ws_id: str, platform: str, identi return contacts[0].contact_id if contacts else None +def _fmt_deal(d) -> str: + return f" {d.deal_id}: {d.deal_title} stage={d.deal_stage} value={d.deal_value} {d.deal_currency}" + +def _fmt_order(o) -> str: + return f" {o.order_number or o.order_id}: {o.order_total} {o.order_currency} financial={o.order_financial_status} fulfillment={o.order_fulfillment_status}" + +def _fmt_activity(a) -> str: + return f" {a.activity_id}: {a.activity_type} {a.activity_direction} — {a.activity_title}" + +def _fmt_contact(c) -> list: + lines = [f"Contact: {c.contact_first_name} {c.contact_last_name} ({c.contact_id})"] + if c.contact_email: lines.append(f"Email: {c.contact_email}") + if c.contact_phone: lines.append(f"Phone: {c.contact_phone}") + if c.contact_tags: lines.append(f"Tags: {', '.join(c.contact_tags)}") + if c.contact_bant_score >= 0: lines.append(f"BANT: {c.contact_bant_score}") + if c.contact_notes: lines.append(f"Notes: {c.contact_notes[:200]}") + return lines + + +CRM_SETUP_SCHEMA = [ + { + "bs_name": "VERIFY_FROM", + "bs_type": "string_long", + "bs_default": "", + "bs_group": "CRM", + "bs_order": 1, + "bs_importance": 0, + "bs_description": "From address for identity verification emails sent to contacts (e.g. verify@yourdomain.com).", + }, +] + + class IntegrationCrm: - def __init__(self, fclient: ckit_client.FlexusClient, ws_id: str): + def __init__(self, fclient: ckit_client.FlexusClient, ws_id: str, rcx: 'ckit_bot_exec.RobotContext'): self.fclient = fclient self.ws_id = ws_id + self.rcx = rcx + + async def handle_verify_email(self, toolcall: ckit_cloudtool.FCloudtoolCall, args: Dict[str, Any]) -> str: + op = args.get("op", "") + email = args.get("email", "") + pid = self.rcx.persona.persona_id + ft_id = toolcall.fcall_ft_id + http = await self.fclient.use_http() + try: + async with http as h: + if op == "send_code": + r = await h.execute(gql.gql("""mutation VerifyThreadEmailSend($pid: String!, $email: String!, $ft_id: String!) { + verify_thread_email_send(persona_id: $pid, email: $email, ft_id: $ft_id) + }"""), variable_values={"pid": pid, "email": email, "ft_id": ft_id}) + return r["verify_thread_email_send"] + if op == "confirm_code": + code = args.get("code", "") + if not code: + return "❌ code is required for confirm_code\n" + r = await h.execute(gql.gql("""mutation VerifyThreadEmailConfirm($pid: String!, $ft_id: String!, $email: String!, $code: String!) { + verify_thread_email_confirm(persona_id: $pid, ft_id: $ft_id, email: $email, code: $code) + }"""), variable_values={"pid": pid, "ft_id": ft_id, "email": email, "code": code}) + return r["verify_thread_email_confirm"] + except gql.transport.exceptions.TransportQueryError as e: + return ckit_cloudtool.gql_error_4xx_to_model_reraise_5xx(e, op) + return f"❌ Unknown op: {op}\n" async def handle_manage_crm_contact(self, toolcall: ckit_cloudtool.FCloudtoolCall, args: Dict[str, Any]) -> str: op = args.get("op", "") - if op == "help": - return ckit_erp.format_table_meta_text("crm_contact", erp_schema.CrmContact) + + if op.startswith("get_"): + t = self.rcx.latest_threads.get(toolcall.fcall_ft_id) + searchable = t.thread_fields.ft_app_searchable if t and t.thread_fields.ft_app_searchable else None + contact_id = None + if searchable and "/" in searchable: + platform, pid = searchable.split("/", 1) + contact_id = await find_contact_by_platform_id(self.fclient, self.ws_id, platform, pid) + if not contact_id: + return "No verified contact in this chat. Use verify_crm_identity first.\n" + try: + if op == "get_summary": + contacts = await ckit_erp.query_erp_table(self.fclient, "crm_contact", self.ws_id, erp_schema.CrmContact, filters=f"contact_id:=:{contact_id}", limit=1) + if not contacts: + return f"Contact {contact_id} not found\n" + lines = _fmt_contact(contacts[0]) + deals = await ckit_erp.query_erp_table(self.fclient, "crm_deal", self.ws_id, erp_schema.CrmDeal, filters=f"deal_contact_id:=:{contact_id}", limit=6) + orders = await ckit_erp.query_erp_table(self.fclient, "com_order", self.ws_id, erp_schema.ComOrder, filters=f"order_contact_id:=:{contact_id}", limit=6) + activities = await ckit_erp.query_erp_table(self.fclient, "crm_activity", self.ws_id, erp_schema.CrmActivity, filters=f"activity_contact_id:=:{contact_id}", limit=6) + def _cnt(rows): + return "5+" if len(rows) == 6 else str(len(rows)) + if deals: lines += [f"\nDeals ({_cnt(deals)}):"] + [_fmt_deal(d) for d in deals[:2]] + if orders: lines += [f"\nOrders ({_cnt(orders)}):"] + [_fmt_order(o) for o in orders[:2]] + if activities: lines += [f"\nActivities ({_cnt(activities)}):"] + [_fmt_activity(a) for a in activities[:2]] + lines.append("\nUse get_all_deals/get_all_orders/get_all_activities for full lists.") + return "\n".join(lines) + "\n" + if op == "get_all_deals": + rows = await ckit_erp.query_erp_table(self.fclient, "crm_deal", self.ws_id, erp_schema.CrmDeal, filters=f"deal_contact_id:=:{contact_id}", limit=50) + return "No deals.\n" if not rows else f"Deals ({len(rows)}):\n" + "\n".join(_fmt_deal(d) for d in rows) + "\n" + if op == "get_all_orders": + rows = await ckit_erp.query_erp_table(self.fclient, "com_order", self.ws_id, erp_schema.ComOrder, filters=f"order_contact_id:=:{contact_id}", limit=50) + return "No orders.\n" if not rows else f"Orders ({len(rows)}):\n" + "\n".join(_fmt_order(o) for o in rows) + "\n" + if op == "get_all_activities": + rows = await ckit_erp.query_erp_table(self.fclient, "crm_activity", self.ws_id, erp_schema.CrmActivity, filters=f"activity_contact_id:=:{contact_id}", limit=50) + return "No activities.\n" if not rows else f"Activities ({len(rows)}):\n" + "\n".join(_fmt_activity(a) for a in rows) + "\n" + except gql.transport.exceptions.TransportQueryError as e: + return ckit_cloudtool.gql_error_4xx_to_model_reraise_5xx(e, op) + return f"❌ Unknown op: {op}\n" + fields = args.get("args", {}) contact_id = str(fields.pop("contact_id", "") or "").strip() + if "contact_email" in fields: + t = self.rcx.latest_threads.get(toolcall.fcall_ft_id) + verified = (t.thread_fields.ft_app_specific or {}).get("verified_email", "") if t else "" + if fields["contact_email"].lower() != verified: + return "❌ Email verification is required to store a contact email. Please ask the user to verify their email first.\n" try: if op == "create": new_id = await ckit_erp.create_erp_record(self.fclient, "crm_contact", self.ws_id, {"ws_id": self.ws_id, **fields}) diff --git a/flexus_simple_bots/vix/vix_bot.py b/flexus_simple_bots/vix/vix_bot.py index 5acdc5c2..027fcf9c 100644 --- a/flexus_simple_bots/vix/vix_bot.py +++ b/flexus_simple_bots/vix/vix_bot.py @@ -38,7 +38,7 @@ "flexus_policy_document", "print_widget", "erp[meta, data, crud, csv_import]", - "crm[manage_contact, manage_deal, log_activity]", + "crm[manage_contact, manage_deal, log_activity, verify_email]", "magic_desk", "slack", "telegram", diff --git a/flexus_simple_bots/vix/vix_install.py b/flexus_simple_bots/vix/vix_install.py index 4db17c06..0c0b7874 100644 --- a/flexus_simple_bots/vix/vix_install.py +++ b/flexus_simple_bots/vix/vix_install.py @@ -7,7 +7,7 @@ from flexus_client_kit import ckit_bot_install from flexus_client_kit import ckit_cloudtool from flexus_client_kit import ckit_skills -from flexus_client_kit.integrations import fi_crm_automations +from flexus_client_kit.integrations import fi_crm, fi_crm_automations from flexus_client_kit.integrations import fi_resend from flexus_client_kit.integrations import fi_slack from flexus_client_kit.integrations import fi_shopify @@ -52,7 +52,7 @@ VIX_SETUP_SCHEMA = json.loads((VIX_ROOTDIR / "setup_schema.json").read_text()) -VIX_SETUP_SCHEMA += fi_shopify.SHOPIFY_SETUP_SCHEMA + fi_crm_automations.CRM_AUTOMATIONS_SETUP_SCHEMA + fi_resend.RESEND_SETUP_SCHEMA + fi_slack.SLACK_SETUP_SCHEMA +VIX_SETUP_SCHEMA += fi_shopify.SHOPIFY_SETUP_SCHEMA + fi_crm_automations.CRM_AUTOMATIONS_SETUP_SCHEMA + fi_resend.RESEND_SETUP_SCHEMA + fi_slack.SLACK_SETUP_SCHEMA + fi_crm.CRM_SETUP_SCHEMA EXPERTS = [ diff --git a/flexus_simple_bots/vix/vix_prompts.py b/flexus_simple_bots/vix/vix_prompts.py index 20695e1a..a02f310f 100644 --- a/flexus_simple_bots/vix/vix_prompts.py +++ b/flexus_simple_bots/vix/vix_prompts.py @@ -69,6 +69,13 @@ 2. If name known (from CRM contact_id or messenger), greet by name. If unknown, ask before proceeding. 3. Use their name naturally throughout (don't overuse) +## Identity Verification & Contact History + +On messenger platforms (Telegram, Slack, DMs): +- If `contact_id` is in the task details, check their contact history; if what they bring up relates to past interactions or open deals, reference those naturally +- If no `contact_id`, ask for email verification early: "What's your email? I'll send a quick code so I can pull up your info." +- If they decline or can't verify, proceed without history + --- ## The C.L.O.S.E.R. Framework @@ -111,26 +118,9 @@ Gather BANT data naturally throughout the conversation. **CRITICAL:** Store BANT score in CRM at end of every conversation. -**How to store:** If contact_id known, patch. If email known, search then create if not found. If no email, ask before closing. - -```python -erp_table_data(table_name="crm_contact", options={{"filters": "contact_email:=:[email]"}}) - -erp_table_crud(op="patch", table_name="crm_contact", id="[contact_id]", - fields={{"contact_bant_score": 2, "contact_details": {{...existing..., "bant": {{...}}}}}} -) - -erp_table_crud(op="create", table_name="crm_contact", fields={{ - "contact_first_name": "[first]", "contact_last_name": "[last]", - "contact_email": "[email]", "contact_bant_score": 2, - "contact_details": {{"bant": {{ - "budget": {{"score": 1, "notes": "..."}}, - "authority": {{"score": 1, "notes": "..."}}, - "need": {{"score": 0, "notes": "..."}}, - "timeline": {{"score": 0, "notes": "..."}} - }}}} -}}) -``` +**How to store:** +- If `contact_id` is known: `manage_crm_contact(op="patch", args={{"contact_id": "...", "contact_bant_score": 2, "contact_notes": "bant: ..."}})` +- If no contact yet: verify email first (`verify_email`), then create. ### BANT Dimensions (each scored 0 or 1) @@ -195,9 +185,8 @@ ## Data Collection -Create/update contacts via erp_table_crud() with table_name="crm_contact": -- Required: name (contact_first_name, contact_last_name), primary need (contact_email, contact_notes), BANT score and details -- Always try to get email — use it to search for existing contacts and for follow-up. Ask naturally if unknown: "What's the best email to reach you at?" Only create without it if the user can't or won't provide one. +Use `manage_crm_contact` to create/update contacts: +- Always try to verify email (see Identity Verification above). Only create without verified email if the user can't or won't. - Note outcome in contact_notes: sold/scheduled/nurture/disqualified ## Follow-Up & Scheduling @@ -234,7 +223,6 @@ {fi_crm.LOG_CRM_ACTIVITIES_PROMPT} If a chat in a messenger platform ends and the contact is known, patch their contact_platform_ids adding the platform identifier (e.g. {{"telegram": "123456"}}). Be careful to get the contact first so you don't remove other platform identifiers. -To look up a contact by platform ID, filter on contact_platform_ids (not contact_details): `"filters": "contact_platform_ids->telegram:=:123456"`. {fi_shopify.SHOPIFY_SALES_PROMPT} {fi_messenger.MESSENGER_PROMPT}