e947bca655
Security:
- Path traversal: validate user_id, case_id, skill_name against safe regex
- Input sanitization in memory.py and skills.py file path construction
Correctness:
- ISR timezone: replace hardcoded UTC+3 with ZoneInfo("Asia/Jerusalem") for DST
- save_rule: use EspoCrmApiError.status_code instead of fragile string matching
- Background tasks: store references to prevent GC before completion
- Lock race: use dict.setdefault() for atomic lock creation
- Version: health.py now matches main.py (0.2.0)
- Skill names: normalized to lowercase for dedup consistency
Build:
- Dockerfile: install from pyproject.toml instead of hardcoded pip list
- Remove unused mcp>=1.0.0 dependency from pyproject.toml
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
364 lines
15 KiB
Python
364 lines
15 KiB
Python
"""Core CRM tools: tasks, notes, meetings, calls, status, hearings, queries, deletions, rules."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from mcp_server.espocrm_client import EspoCrmClient
|
|
|
|
from mcp_server.espocrm_client import EspoCrmApiError
|
|
from mcp_server.tools._helpers import normalize_date, ok, fail
|
|
|
|
VALID_STATUSES = {
|
|
"New": "חדש",
|
|
"Assigned": "בטיפול",
|
|
"Pending": "ממתין",
|
|
"PendingHearing": "ממתין לדיון",
|
|
"PendingDecision": "ממתין להחלטה",
|
|
"PendingResponse": "ממתין לתשובה",
|
|
"Negotiation": "משא ומתן",
|
|
"Suspended": "מושהה",
|
|
"ClosedSettlement": "סגור - הסכם",
|
|
"ClosedJudgment": "סגור - פס״ד",
|
|
"Closed": "סגור",
|
|
"Rejected": "נדחה",
|
|
}
|
|
|
|
STATUS_KEYS = list(VALID_STATUSES.keys())
|
|
|
|
|
|
def register_crm_tools(
|
|
tools: dict,
|
|
crm: "EspoCrmClient",
|
|
case_id: str | None,
|
|
user_id: str | None,
|
|
context: dict,
|
|
):
|
|
"""Register all core CRM tools into the tools dict."""
|
|
|
|
async def create_task(name: str, dateEnd: str = "", priority: str = "Normal", description: str = "") -> str:
|
|
try:
|
|
body = {
|
|
"name": name,
|
|
"status": "Not Started",
|
|
"priority": priority,
|
|
"dateEnd": normalize_date(dateEnd) if dateEnd else None,
|
|
"description": description or None,
|
|
"assignedUserId": user_id,
|
|
}
|
|
if case_id:
|
|
body["parentType"] = "Case"
|
|
body["parentId"] = case_id
|
|
result = await crm.post("Task", body)
|
|
return ok(f'משימה "{name}" נוצרה בהצלחה (ID: {result["id"]})')
|
|
except Exception as e:
|
|
return fail(f"שגיאה ביצירת משימה: {e}")
|
|
|
|
async def add_note(post: str) -> str:
|
|
try:
|
|
body = {"type": "Post", "post": post}
|
|
if case_id:
|
|
body["parentType"] = "Case"
|
|
body["parentId"] = case_id
|
|
result = await crm.post("Note", body)
|
|
return ok(f'הערה נוספה בהצלחה (ID: {result["id"]})')
|
|
except Exception as e:
|
|
return fail(f"שגיאה בהוספת הערה: {e}")
|
|
|
|
async def change_status(status: str) -> str:
|
|
if not case_id:
|
|
return fail("צריך להיות בתוך תיק כדי לשנות סטטוס")
|
|
try:
|
|
await crm.put(f"Case/{case_id}", {"status": status})
|
|
label = VALID_STATUSES.get(status, status)
|
|
return ok(f'סטטוס שונה ל-"{label}"')
|
|
except Exception as e:
|
|
return fail(f"שגיאה בשינוי סטטוס: {e}")
|
|
|
|
async def create_meeting(name: str, dateStart: str, dateEnd: str = "", description: str = "") -> str:
|
|
try:
|
|
start = normalize_date(dateStart)
|
|
body = {
|
|
"name": name,
|
|
"status": "Planned",
|
|
"dateStart": start,
|
|
"dateEnd": normalize_date(dateEnd) if dateEnd else start,
|
|
"description": description or None,
|
|
"assignedUserId": user_id,
|
|
}
|
|
if case_id:
|
|
body["parentType"] = "Case"
|
|
body["parentId"] = case_id
|
|
result = await crm.post("Meeting", body)
|
|
return ok(f'פגישה "{name}" נקבעה בהצלחה (ID: {result["id"]})')
|
|
except Exception as e:
|
|
return fail(f"שגיאה ביצירת פגישה: {e}")
|
|
|
|
async def create_call(
|
|
name: str,
|
|
dateStart: str = "",
|
|
dateEnd: str = "",
|
|
description: str = "",
|
|
direction: str = "Outbound",
|
|
status: str = "Held",
|
|
) -> str:
|
|
try:
|
|
start = normalize_date(dateStart) if dateStart else None
|
|
body = {
|
|
"name": name,
|
|
"status": status,
|
|
"direction": direction,
|
|
"dateStart": start,
|
|
"dateEnd": normalize_date(dateEnd) if dateEnd else start,
|
|
"description": description or None,
|
|
"assignedUserId": user_id,
|
|
}
|
|
if case_id:
|
|
body["parentType"] = "Case"
|
|
body["parentId"] = case_id
|
|
result = await crm.post("Call", body)
|
|
return ok(f'שיחה "{name}" תועדה בהצלחה (ID: {result["id"]})')
|
|
except Exception as e:
|
|
return fail(f"שגיאה בתיעוד שיחה: {e}")
|
|
|
|
async def schedule_hearing(dateTime: str, court: str = "", description: str = "") -> str:
|
|
if not case_id:
|
|
return fail("צריך להיות בתוך תיק כדי לתזמן דיון")
|
|
try:
|
|
dt = normalize_date(dateTime)
|
|
await crm.put(f"Case/{case_id}", {"cNextHearing": dt})
|
|
meeting_name = description or "דיון בבית המשפט"
|
|
if court:
|
|
meeting_name += f" - {court}"
|
|
body = {
|
|
"name": meeting_name,
|
|
"status": "Planned",
|
|
"dateStart": dt,
|
|
"dateEnd": dt,
|
|
"parentType": "Case",
|
|
"parentId": case_id,
|
|
"assignedUserId": user_id,
|
|
}
|
|
result = await crm.post("Meeting", body)
|
|
return ok(f"דיון נקבע ל-{dt} (ID: {result['id']})")
|
|
except Exception as e:
|
|
return fail(f"שגיאה בתזמון דיון: {e}")
|
|
|
|
async def query_info(query_type: str) -> str:
|
|
import json
|
|
case_info = context.get("case", {})
|
|
if query_type == "status":
|
|
return ok(json.dumps({
|
|
"status": VALID_STATUSES.get(case_info.get("status"), case_info.get("status")),
|
|
"court": case_info.get("cCourt"),
|
|
"judge": case_info.get("cJudge"),
|
|
"nextHearing": case_info.get("cNextHearing"),
|
|
}))
|
|
if query_type == "open_tasks":
|
|
return ok(json.dumps({"tasks": context.get("openTasks", [])}))
|
|
if query_type == "contacts":
|
|
return ok(json.dumps({"contacts": context.get("contacts", [])}))
|
|
if query_type in ("summary", "alerts", "workload", "hearings"):
|
|
return ok(json.dumps({"summary": context.get("summary", {})}))
|
|
return ok(json.dumps({"case": case_info}))
|
|
|
|
async def delete_entity(entity_type: str, entityId: str) -> str:
|
|
type_labels = {"Task": "משימה", "Meeting": "פגישה", "Call": "שיחה", "Note": "הערה"}
|
|
label = type_labels.get(entity_type, entity_type)
|
|
try:
|
|
if entity_type != "Note":
|
|
entity = await crm.get(f"{entity_type}/{entityId}")
|
|
name = entity.get("name", entityId)
|
|
else:
|
|
name = entityId
|
|
await crm.delete(f"{entity_type}/{entityId}")
|
|
return ok(f'{label} "{name}" נמחקה בהצלחה')
|
|
except Exception as e:
|
|
return fail(f"שגיאה במחיקת {label}: {e}")
|
|
|
|
async def save_memory(category: str, content: str, importance: str = "normal") -> str:
|
|
if not case_id:
|
|
return fail("צריך להיות בתוך תיק כדי לשמור בזיכרון")
|
|
try:
|
|
await crm.post("Note", {
|
|
"type": "Post",
|
|
"post": f"🧠 {category}: {content}",
|
|
"parentType": "Case",
|
|
"parentId": case_id,
|
|
})
|
|
return ok(f'נשמר בזיכרון: "{content[:60]}"')
|
|
except Exception as e:
|
|
return fail(f"שגיאה בשמירת זיכרון: {e}")
|
|
|
|
async def save_rule(name: str, rule: str, scope: str = "global") -> str:
|
|
# Try CRM first, fall back to local user profile if entity doesn't exist
|
|
try:
|
|
result = await crm.post("AssistantRule", {
|
|
"name": name,
|
|
"rule": rule,
|
|
"scope": scope,
|
|
"isActive": True,
|
|
})
|
|
return ok(f'כלל חדש נשמר: "{name}" (ID: {result["id"]})')
|
|
except EspoCrmApiError as e:
|
|
if e.status_code == 404 and user_id:
|
|
# AssistantRule entity not available — save to user profile instead
|
|
from api.services.memory import update_user_profile
|
|
entry = f"כלל [{scope}] {name}: {rule}"
|
|
return await update_user_profile(user_id, "add", entry)
|
|
return fail(f"שגיאה בשמירת כלל: {e}")
|
|
except Exception as e:
|
|
return fail(f"שגיאה בשמירת כלל: {e}")
|
|
|
|
# Register all tools
|
|
tools["create_task"] = {
|
|
"description": "Create a task in the CRM. Links to case if case context is available.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string", "description": "Task name"},
|
|
"dateEnd": {"type": "string", "description": "Due date — any format (YYYY-MM-DD, DD/MM/YYYY)"},
|
|
"priority": {"type": "string", "enum": ["Urgent", "High", "Normal", "Low"], "description": "Priority level"},
|
|
"description": {"type": "string", "description": "Task description"},
|
|
},
|
|
"required": ["name"],
|
|
},
|
|
"handler": create_task,
|
|
}
|
|
|
|
tools["add_note"] = {
|
|
"description": "Add a note/comment to the case stream or as standalone.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"post": {"type": "string", "description": "Note content"},
|
|
},
|
|
"required": ["post"],
|
|
},
|
|
"handler": add_note,
|
|
}
|
|
|
|
tools["change_status"] = {
|
|
"description": "Change case status. Only works when viewing a specific case.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"status": {"type": "string", "enum": STATUS_KEYS, "description": "New status (English key)"},
|
|
},
|
|
"required": ["status"],
|
|
},
|
|
"handler": change_status,
|
|
}
|
|
|
|
tools["create_meeting"] = {
|
|
"description": "Schedule a meeting. Links to case if available.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string", "description": "Meeting name"},
|
|
"dateStart": {"type": "string", "description": "Start date — any format (YYYY-MM-DD HH:mm, DD/MM/YYYY)"},
|
|
"dateEnd": {"type": "string", "description": "End date"},
|
|
"description": {"type": "string", "description": "Meeting description"},
|
|
},
|
|
"required": ["name", "dateStart"],
|
|
},
|
|
"handler": create_meeting,
|
|
}
|
|
|
|
tools["create_call"] = {
|
|
"description": "Record a phone call. Use when user reports they spoke/talked/called someone (שוחחתי, דיברתי, התקשרתי). Do NOT use create_meeting for phone calls.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string", "description": 'Call subject (e.g. "שיחה עם לקוחה אדל")'},
|
|
"dateStart": {"type": "string", "description": "Call date — any format"},
|
|
"dateEnd": {"type": "string", "description": "End time"},
|
|
"description": {"type": "string", "description": "Call summary / notes"},
|
|
"direction": {"type": "string", "enum": ["Outbound", "Inbound"], "description": "Call direction"},
|
|
"status": {"type": "string", "enum": ["Planned", "Held", "Not Held"], "description": "Call status"},
|
|
},
|
|
"required": ["name"],
|
|
},
|
|
"handler": create_call,
|
|
}
|
|
|
|
tools["schedule_hearing"] = {
|
|
"description": "Schedule a court hearing. Updates case next hearing date and creates a meeting.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"dateTime": {"type": "string", "description": "Hearing date — any format (YYYY-MM-DD HH:mm, DD/MM/YYYY)"},
|
|
"court": {"type": "string", "description": "Court name"},
|
|
"description": {"type": "string", "description": "Hearing description"},
|
|
},
|
|
"required": ["dateTime"],
|
|
},
|
|
"handler": schedule_hearing,
|
|
}
|
|
|
|
tools["query_info"] = {
|
|
"description": "Query case or office information from context.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query_type": {
|
|
"type": "string",
|
|
"enum": ["status", "next_hearing", "open_tasks", "contacts", "general", "summary", "alerts", "workload", "hearings"],
|
|
},
|
|
},
|
|
"required": ["query_type"],
|
|
},
|
|
"handler": query_info,
|
|
}
|
|
|
|
# Delete tools
|
|
for entity_type, tool_name, desc in [
|
|
("Task", "delete_task", "Delete a task by ID."),
|
|
("Meeting", "delete_meeting", "Delete a meeting by ID."),
|
|
("Call", "delete_call", "Delete a call record by ID."),
|
|
("Note", "delete_note", "Delete a note/comment by ID."),
|
|
]:
|
|
et = entity_type
|
|
|
|
async def _delete(entityId: str, _et=et) -> str:
|
|
return await delete_entity(_et, entityId)
|
|
|
|
tools[tool_name] = {
|
|
"description": desc,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"entityId": {"type": "string", "description": f"The {entity_type.lower()} ID to delete"}},
|
|
"required": ["entityId"],
|
|
},
|
|
"handler": _delete,
|
|
}
|
|
|
|
tools["save_memory"] = {
|
|
"description": "Save an important fact, decision, or strategy to the case memory. Use PROACTIVELY when user shares key info.",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"category": {"type": "string", "enum": ["key_facts", "strategy", "decisions", "contacts_notes", "timeline", "documents_notes", "billing_notes"]},
|
|
"content": {"type": "string", "description": "The information to remember"},
|
|
"importance": {"type": "string", "enum": ["low", "normal", "high", "critical"]},
|
|
},
|
|
"required": ["category", "content"],
|
|
},
|
|
"handler": save_memory,
|
|
}
|
|
|
|
tools["save_rule"] = {
|
|
"description": 'Save a behavioral rule that Shira should follow in all future conversations. Use when user says "from now on always...", "whenever I say X do Y", etc.',
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": {"type": "string", "description": "Short rule name"},
|
|
"rule": {"type": "string", "description": "The full rule text"},
|
|
"scope": {"type": "string", "enum": ["global", "case", "office"], "description": "Rule scope"},
|
|
},
|
|
"required": ["name", "rule"],
|
|
},
|
|
"handler": save_rule,
|
|
}
|