Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion flexus_client_kit/ckit_integrations_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
135 changes: 129 additions & 6 deletions flexus_client_kit/integrations/fi_crm.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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",
Expand All @@ -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})
Expand Down
4 changes: 2 additions & 2 deletions flexus_client_kit/integrations/fi_resend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
2 changes: 1 addition & 1 deletion flexus_simple_bots/vix/vix_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions flexus_simple_bots/vix/vix_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
Expand Down
36 changes: 12 additions & 24 deletions flexus_simple_bots/vix/vix_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down