diff --git a/flexus_client_kit/integrations/fi_resend.py b/flexus_client_kit/integrations/fi_resend.py new file mode 100644 index 00000000..c9d79b0d --- /dev/null +++ b/flexus_client_kit/integrations/fi_resend.py @@ -0,0 +1,360 @@ +import json +import logging +import os +from dataclasses import dataclass +from typing import Dict, Any, List, Optional + +import gql +import httpx + +from flexus_client_kit import ckit_bot_exec, ckit_bot_query, ckit_client, ckit_cloudtool + +logger = logging.getLogger("resend") + +RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "") +RESEND_TESTING_DOMAIN = os.environ.get("RESEND_TESTING_DOMAIN", "") +RESEND_BASE = "https://api.resend.com" + +RESEND_SETUP_SCHEMA = [ + { + "bs_name": "DOMAINS", + "bs_type": "string_multiline", + "bs_default": "{}", + "bs_group": "Email", + "bs_importance": 0, + "bs_description": 'Registered domains, e.g. {"mail.example.com": "d_abc123"}. Send and receive emails from these domains. Incoming emails are logged as CRM activities.', + }, +] + +RESEND_PROMPT = f"""## Email + +Use email() tool to send emails and help users register their own domain for sending and receiving. Call email(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. +If no domain is configured, send from *@{RESEND_TESTING_DOMAIN} for testing. +Never use flexus_my_setup() for email domains — they are saved automatically via email() tool.""" + +RESEND_TOOL = ckit_cloudtool.CloudTool( + strict=False, + name="email", + description="Send and receive email, call with op=\"help\" for usage", + parameters={ + "type": "object", + "properties": { + "op": {"type": "string", "description": "Start with 'help' for usage"}, + "args": {"type": "object"}, + }, + "required": [] + }, +) + +HELP = """Help: + +email(op="send", args={ + "from": "Name ", + "to": "recipient@example.com", + "subject": "Hello", + "html": "

HTML body

", + "text": "Plain text fallback", # optional if html provided + "cc": "cc@example.com", # optional, comma-separated + "bcc": "bcc@example.com", # optional, comma-separated + "reply_to": "reply@example.com", # optional +}) + +email(op="add_domain", args={"domain": "yourdomain.com", "region": "us-east-1"}) + Register your own domain. Returns DNS records you need to configure. + Ask the user which region they prefer before calling. + Regions: us-east-1, eu-west-1, sa-east-1, ap-northeast-1. + +email(op="verify_domain", args={"domain_id": "..."}) + Trigger verification after adding DNS records. May take a few minutes. + +email(op="domain_status", args={"domain_id": "..."}) + Check verification status and DNS records. + +email(op="list_domains") + List all registered domains and their verification status. + +Notes: +- "from" and "to" are required for send. "to" can be comma-separated. +- Provide "html" and/or "text". At least one is required. +""" + + +@dataclass +class ActivityEmail: + email_id: str + from_addr: str + to_addrs: List[str] + cc_addrs: List[str] + bcc_addrs: List[str] + subject: str + body_text: str + body_html: str + + +def _help_text(has_domains: bool) -> str: + if not has_domains and RESEND_TESTING_DOMAIN: + return HELP + f"- No domains configured yet. Send from @{RESEND_TESTING_DOMAIN} in the meantime.\n" + return HELP + + +def _format_dns_records(records) -> str: + if not records: + return " (none)" + lines = [] + for rec in records: + lines.append(f" {rec['record']} {rec['type']} {rec['name']} -> {rec['value']} [{rec['status']}]") + return "\n".join(lines) + + +async def _check_dns_txt(domain: str, expected: str) -> bool: + try: + async with httpx.AsyncClient(timeout=5) as c: + r = await c.get(f"https://dns.google/resolve?name={domain}&type=TXT") + return any(expected in a.get("data", "") for a in r.json().get("Answer", [])) + except Exception as e: + logger.warning("DNS TXT check failed for %s: %s", domain, e) + return False + + +async def _resend_request(method: str, path: str, json_body: Optional[Dict] = None) -> httpx.Response: + async with httpx.AsyncClient(timeout=30) as c: + return await c.request(method, f"{RESEND_BASE}{path}", headers={"Authorization": f"Bearer {RESEND_API_KEY}"}, json=json_body) + + +def parse_emessage(emsg: ckit_bot_query.FExternalMessageOutput) -> ActivityEmail: + payload = emsg.emsg_payload if isinstance(emsg.emsg_payload, dict) else json.loads(emsg.emsg_payload) + content = payload.get("email_content", {}) + data = payload.get("data", {}) + return ActivityEmail( + email_id=data.get("email_id", emsg.emsg_external_id), + from_addr=emsg.emsg_from or data.get("from", ""), + to_addrs=data.get("to", []), + cc_addrs=data.get("cc", []), + bcc_addrs=data.get("bcc", []), + subject=content.get("subject", data.get("subject", "")), + body_text=content.get("text", ""), + body_html=content.get("html", ""), + ) + + +async def register_email_addresses( + fclient: ckit_client.FlexusClient, + rcx: ckit_bot_exec.RobotContext, + email_addresses: List[str], +) -> None: + txt_val = f"flexus-verify={rcx.persona.ws_id}" + verified = [] + for a in email_addresses: + domain = a.rsplit("@", 1)[1] if "@" in a else a + if RESEND_TESTING_DOMAIN and domain == RESEND_TESTING_DOMAIN: + verified.append(a) + elif await _check_dns_txt(domain, txt_val): + verified.append(a) + else: + logger.warning("address %s failed TXT ownership check, not registering", a) + if not verified: + return + http = await fclient.use_http() + async with http as h: + await h.execute( + gql.gql("""mutation ResendRegister($persona_id: String!, $channel: String!, $addresses: [String!]!) { + persona_set_external_addresses(persona_id: $persona_id, channel: $channel, addresses: $addresses) + }"""), + variable_values={ + "persona_id": rcx.persona.persona_id, + "channel": "EMAIL", + "addresses": [f"EMAIL:{a}" for a in verified], + }, + ) + logger.info("registered email addresses %s for persona %s", verified, rcx.persona.persona_id) + + +class IntegrationResend: + + def __init__(self, fclient: ckit_client.FlexusClient, rcx: ckit_bot_exec.RobotContext, domains: Dict[str, str], emails_to_register: set): + self.fclient = fclient + self.rcx = rcx + self.domains = domains # {"domain.com": "resend_domain_id"} + self.emails_to_register = emails_to_register + + async def called_by_model(self, toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Optional[Dict[str, Any]]): + if not model_produced_args: + return _help_text(bool(self.domains)) + + op = model_produced_args.get("op", "") + args, args_error = ckit_cloudtool.sanitize_args(model_produced_args) + if args_error: + return args_error + + if not op or "help" in op: + return _help_text(bool(self.domains)) + if op == "send": + return await self._send(args, model_produced_args) + if op == "add_domain": + return await self._add_domain(args, model_produced_args) + if op == "verify_domain": + return await self._verify_domain(args, model_produced_args) + if op == "domain_status": + return await self._domain_status(args, model_produced_args) + if op == "list_domains": + return await self._list_domains() + + return f"Unknown operation: {op}\n\nTry email(op='help') for usage." + + async def _send(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]): + frm = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "from", None) + to = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "to", None) + if not frm or not to: + return "Missing required: 'from' and 'to'" + html = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "html", "") + text = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "text", "") + if not html and not text: + return "Provide 'html' and/or 'text'" + + params: Dict[str, Any] = { + "from": frm, + "to": [e.strip() for e in to.split(",")], + "subject": ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "subject", ""), + } + if html: + params["html"] = html + if text: + params["text"] = text + cc = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "cc", None) + bcc = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "bcc", None) + reply_to = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "reply_to", None) + if cc: + params["cc"] = [e.strip() for e in cc.split(",")] + if bcc: + params["bcc"] = [e.strip() for e in bcc.split(",")] + if reply_to: + params["reply_to"] = reply_to + + n_recipients = len(params["to"]) + len(params.get("cc", [])) + len(params.get("bcc", [])) + r = await _resend_request("POST", "/emails", params) + if r.status_code == 200: + rid = r.json().get("id", "") + logger.info("sent email %s to %s", rid, to) + return ckit_cloudtool.ToolResult(content=f"Email sent (id: {rid})", dollars=0.0009 * n_recipients) + logger.error("resend send error: %s %s", r.status_code, r.text[:200]) + return "Internal error sending email, please try again later" + + async def _add_domain(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: + if not (domain := ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "domain", None)): + return "Missing required: 'domain'" + if len(self.domains) >= 20: + return "Domain limit reached (20). Remove unused domains before adding new ones." + + region = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "region", "us-east-1") + if region not in ("us-east-1", "eu-west-1", "sa-east-1", "ap-northeast-1"): + return "Invalid region. Must be one of: us-east-1, eu-west-1, sa-east-1, ap-northeast-1." + + r = await _resend_request("POST", "/domains", { + "name": domain, + "region": region, + "open_tracking": False, + "click_tracking": True, + "capabilities": {"sending": "enabled", "receiving": "enabled"}, + }) + if r.status_code == 200 or r.status_code == 201: + d = r.json() + elif "already" in r.text.lower(): + # Resend does not support find domain without listing all + lr = await _resend_request("GET", "/domains") + d = None + if lr.status_code == 200: + for item in lr.json().get("data", []): + if item["name"] == domain: + d = item + break + if not d: + return f"Domain {domain} already exists in Resend but could not retrieve it." + else: + logger.error("resend add domain error: %s %s", r.status_code, r.text[:200]) + return f"Failed to add domain: {r.text[:200]}" + + self.domains[domain] = d["id"] + await self._save_domains() + txt_val = f"flexus-verify={self.rcx.persona.ws_id}" + logger.info("resend domain %s id=%s", domain, d["id"]) + return ( + f"Domain: {domain}\n" + f"domain_id: {d['id']}\n" + f"status: {d.get('status', 'pending')}\n\n" + f"DNS records:\n{_format_dns_records(d.get('records'))}\n" + f" TXT {domain} -> {txt_val} (ownership verification)\n\n" + f"After adding records, call verify_domain with domain_id=\"{d['id']}\".\n" + f"DNS propagation can take minutes to hours." + ) + + async def _verify_domain(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: + domain_id = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "domain_id", None) + if not domain_id: + return "Missing required: 'domain_id'" + + gr = await _resend_request("GET", f"/domains/{domain_id}") + if gr.status_code != 200: + logger.error("resend get domain error: %s %s", gr.status_code, gr.text[:200]) + return "Failed to get domain info" + d = gr.json() + txt_val = f"flexus-verify={self.rcx.persona.ws_id}" + if not await _check_dns_txt(d["name"], txt_val): + return f"TXT record '{txt_val}' not found for {d['name']}. DNS may still be propagating, try again later." + await _resend_request("POST", f"/domains/{domain_id}/verify") + msg = "Verification triggered. Check domain_status for results." + if domain_id not in self.domains.values(): + self.domains[d["name"]] = domain_id + await self._save_domains() + msg += f"\nDomain {d['name']} added to setup." + await register_email_addresses(self.fclient, self.rcx, + [f"*@{dom}" for dom in self.domains] + list(self.emails_to_register)) + return msg + + async def _domain_status(self, args: Dict[str, Any], model_produced_args: Dict[str, Any]) -> str: + domain_id = ckit_cloudtool.try_best_to_find_argument(args, model_produced_args, "domain_id", None) + if not domain_id: + return "Missing required: 'domain_id'" + + r = await _resend_request("GET", f"/domains/{domain_id}") + if r.status_code != 200: + logger.error("resend domain status error: %s %s", r.status_code, r.text[:200]) + return "Failed to get domain status" + d = r.json() + txt_val = f"flexus-verify={self.rcx.persona.ws_id}" + txt_ok = await _check_dns_txt(d["name"], txt_val) + out = f"Domain: {d['name']}\n" + if not txt_ok: + out += f"ownership: NOT VERIFIED — add TXT record: {d['name']} -> {txt_val}, then call verify_domain. Domain cannot be used until verified.\n" + else: + out += "ownership: verified\n" + out += f"resend status: {d['status']}\n\nDNS records:\n{_format_dns_records(d.get('records'))}" + return out + + async def _list_domains(self) -> str: + if not self.domains: + return "No domains registered." + lines = [] + for domain, domain_id in self.domains.items(): + r = await _resend_request("GET", f"/domains/{domain_id}") + if r.status_code == 200: + d = r.json() + lines.append(f" {d['name']} (id: {d['id']}) [{d['status']}]") + else: + lines.append(f" {domain} (id: {domain_id}) [error fetching status]") + return "Domains:\n" + "\n".join(lines) + + async def _save_domains(self): + http = await self.fclient.use_http() + async with http as h: + await h.execute( + gql.gql("""mutation SaveResendDomains($persona_id: String!, $set_key: String!, $set_val: String) { + persona_setup_set_key(persona_id: $persona_id, set_key: $set_key, set_val: $set_val) + }"""), + variable_values={ + "persona_id": self.rcx.persona.persona_id, + "set_key": "DOMAINS", + "set_val": json.dumps(self.domains), + }, + ) diff --git a/flexus_client_kit/integrations/fi_telegram.py b/flexus_client_kit/integrations/fi_telegram.py index fe97d2d7..71298242 100644 --- a/flexus_client_kit/integrations/fi_telegram.py +++ b/flexus_client_kit/integrations/fi_telegram.py @@ -125,7 +125,7 @@ async def _register_and_set_webhook(self, bot_id: str) -> None: variable_values={ "persona_id": self.rcx.persona.persona_id, "channel": "TELEGRAM", - "addresses": [f"telegram:{bot_id}"], + "addresses": [f"TELEGRAM:{bot_id}"], }, ) logger.info("Registered telegram:%s for persona %s", bot_id, self.rcx.persona.persona_id) diff --git a/flexus_simple_bots/vix/vix_bot.py b/flexus_simple_bots/vix/vix_bot.py index a88d3804..3d60691b 100644 --- a/flexus_simple_bots/vix/vix_bot.py +++ b/flexus_simple_bots/vix/vix_bot.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import time from dataclasses import asdict from typing import Dict, Any @@ -12,12 +13,14 @@ from flexus_client_kit import ckit_shutdown from flexus_client_kit import ckit_ask_model from flexus_client_kit import ckit_mongo +from flexus_client_kit import ckit_erp from flexus_client_kit import ckit_kanban +from flexus_client_kit import erp_schema from flexus_client_kit.integrations import fi_mongo_store from flexus_client_kit.integrations import fi_pdoc from flexus_client_kit.integrations import fi_erp -from flexus_client_kit.integrations import fi_gmail from flexus_client_kit.integrations import fi_crm_automations +from flexus_client_kit.integrations import fi_resend from flexus_client_kit.integrations import fi_telegram from flexus_simple_bots.vix import vix_install from flexus_simple_bots.version_common import SIMPLE_BOTS_COMMON_VERSION @@ -36,8 +39,8 @@ fi_erp.ERP_TABLE_DATA_TOOL, fi_erp.ERP_TABLE_CRUD_TOOL, fi_erp.ERP_CSV_IMPORT_TOOL, - fi_gmail.GMAIL_TOOL, fi_crm_automations.CRM_AUTOMATION_TOOL, + fi_resend.RESEND_TOOL, fi_telegram.TELEGRAM_TOOL, ] @@ -54,10 +57,15 @@ def get_setup(): pdoc_integration = fi_pdoc.IntegrationPdoc(rcx, rcx.persona.ws_root_group_id) erp_integration = fi_erp.IntegrationErp(fclient, rcx.persona.ws_id, personal_mongo) - gmail_integration = fi_gmail.IntegrationGmail(fclient, rcx) automations_integration = fi_crm_automations.IntegrationCrmAutomations( fclient, rcx, get_setup, available_erp_tables=ERP_TABLES, ) + resend_domains = json.loads(get_setup().get("DOMAINS", "{}")) + email_respond_to = set(a.strip().lower() for a in get_setup().get("EMAIL_RESPOND_TO", "").split(",") if a.strip()) + resend_integration = fi_resend.IntegrationResend(fclient, rcx, resend_domains, email_respond_to) + email_reg = [f"*@{d}" for d in resend_domains] + list(email_respond_to) + if email_reg: + await fi_resend.register_email_addresses(fclient, rcx, email_reg) telegram = await fi_telegram.IntegrationTelegram.create(fclient, rcx, get_setup().get("TELEGRAM_BOT_TOKEN", "")) @rcx.on_updated_message @@ -72,6 +80,39 @@ async def updated_thread_in_db(th: ckit_ask_model.FThreadOutput): async def updated_task_in_db(t: ckit_kanban.FPersonaKanbanTaskOutput): pass + @rcx.on_emessage("EMAIL") + async def handle_email(emsg): + email = fi_resend.parse_emessage(emsg) + body = email.body_text or email.body_html or "(empty)" + try: + contacts = await ckit_erp.query_erp_table( + fclient, "crm_contact", rcx.persona.ws_id, erp_schema.CrmContact, + filters=f"contact_email:ILIKE:{email.from_addr}", limit=1, + ) + if contacts: + await ckit_erp.create_erp_record(fclient, "crm_activity", rcx.persona.ws_id, { + "ws_id": rcx.persona.ws_id, + "activity_title": email.subject, + "activity_type": "EMAIL", + "activity_direction": "INBOUND", + "activity_platform": "RESEND", + "activity_contact_id": contacts[0].contact_id, + "activity_summary": body[:500], + "activity_occurred_ts": time.time(), + }) + except Exception as e: + logger.warning("Failed to create CRM activity for inbound email from %s: %s", email.from_addr, e) + if not email_respond_to.intersection(a.lower() for a in email.to_addrs): + return + title = "Email from %s: %s" % (email.from_addr, email.subject) + if email.cc_addrs: + title += " (cc: %s)" % ", ".join(email.cc_addrs) + await ckit_kanban.bot_kanban_post_into_inbox( + fclient, rcx.persona.persona_id, + title=title, details_json=json.dumps({"from": email.from_addr, "to": email.to_addrs, "cc": email.cc_addrs, "subject": email.subject, "body": body[:2000]}), + provenance_message="vix_email_inbound", + ) + @rcx.on_emessage("TELEGRAM") async def handle_telegram_emessage(emsg): await telegram.handle_emessage(emsg) @@ -105,9 +146,9 @@ async def toolcall_erp_crud(toolcall: ckit_cloudtool.FCloudtoolCall, model_produ async def toolcall_erp_csv_import(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: return await erp_integration.handle_csv_import(toolcall, model_produced_args) - @rcx.on_tool_call(fi_gmail.GMAIL_TOOL.name) - async def toolcall_gmail(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: - return await gmail_integration.called_by_model(toolcall, model_produced_args) + @rcx.on_tool_call(fi_resend.RESEND_TOOL.name) + async def toolcall_resend(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: + return await resend_integration.called_by_model(toolcall, model_produced_args) @rcx.on_tool_call(fi_crm_automations.CRM_AUTOMATION_TOOL.name) async def toolcall_crm_automation(toolcall: ckit_cloudtool.FCloudtoolCall, model_produced_args: Dict[str, Any]) -> str: @@ -164,7 +205,7 @@ def main(): scenario_fn=scenario_fn, install_func=vix_install.install, subscribe_to_erp_tables=ERP_TABLES, - subscribe_to_emsg_types=["TELEGRAM"], + subscribe_to_emsg_types=["EMAIL", "TELEGRAM"], )) diff --git a/flexus_simple_bots/vix/vix_install.py b/flexus_simple_bots/vix/vix_install.py index 00e0a86d..2eb795f3 100644 --- a/flexus_simple_bots/vix/vix_install.py +++ b/flexus_simple_bots/vix/vix_install.py @@ -7,6 +7,7 @@ from flexus_client_kit import ckit_bot_install from flexus_client_kit import ckit_cloudtool from flexus_client_kit.integrations import fi_crm_automations +from flexus_client_kit.integrations import fi_resend from flexus_simple_bots import prompts_common from flexus_simple_bots.vix import vix_bot, vix_prompts @@ -71,7 +72,16 @@ "bs_importance": 0, "bs_description": "When to offer human handoff: low (rarely), medium (balanced), high (proactive)", }, -] + fi_telegram.TELEGRAM_SETUP_SCHEMA +] + [ + { + "bs_name": "EMAIL_RESPOND_TO", + "bs_type": "string_long", + "bs_default": "", + "bs_group": "Email", + "bs_importance": 0, + "bs_description": "Email addresses the bot should respond to, comma-separated (e.g. sales@yourdomain.com). All other emails to your domains are logged as CRM activities only.", + }, +] + fi_resend.RESEND_SETUP_SCHEMA + fi_telegram.TELEGRAM_SETUP_SCHEMA async def install( diff --git a/flexus_simple_bots/vix/vix_prompts.py b/flexus_simple_bots/vix/vix_prompts.py index 4a87f2c4..bc3fe299 100644 --- a/flexus_simple_bots/vix/vix_prompts.py +++ b/flexus_simple_bots/vix/vix_prompts.py @@ -1,5 +1,5 @@ from flexus_simple_bots import prompts_common -from flexus_client_kit.integrations import fi_crm_automations, fi_messenger +from flexus_client_kit.integrations import fi_crm_automations, fi_messenger, fi_resend vix_prompt_sales = f""" # Elite AI Sales Agent @@ -1088,6 +1088,8 @@ {fi_crm_automations.AUTOMATIONS_PROMPT} +{fi_resend.RESEND_PROMPT} + ### Expert Selection for Automations When creating automations that post tasks, use `fexp_name` to route to the right expert: