Autostage Telegram sales leads

This commit is contained in:
Magzhan Zhumabayev
2026-05-12 19:41:04 +05:00
parent fbc77ba8a7
commit 5236ee74ac
2 changed files with 168 additions and 12 deletions
+130 -12
View File
@@ -237,6 +237,16 @@ def _bool_env(name: str, default: bool = False) -> bool:
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _integer_env(name: str, default: int) -> int:
raw = os.getenv(name)
if raw is None:
return default
try:
return int(raw.strip())
except ValueError:
return default
def _voice_runtime_enabled() -> bool:
return _bool_env("SALES_VOICE_RUNTIME_ENABLED", False)
@@ -245,6 +255,10 @@ def _telegram_bridge_enabled() -> bool:
return _bool_env("SALES_TELEGRAM_BRIDGE_ENABLED", False)
def _telegram_autostage_message_interval() -> int:
return max(1, _integer_env("SALES_TELEGRAM_STAGE_EVERY_MESSAGES", 3))
def _voice_runtime_url() -> str:
return os.getenv("AI_VOICE_RUNTIME_SERVICE_URL", "http://localhost:8018").rstrip("/")
@@ -1716,6 +1730,89 @@ def _apply_stage_if_needed(
)
def _is_customer_inbound_telegram_message(payload: SalesTelegramSyncIn) -> bool:
direction = str(payload.direction or "").strip().lower()
author_type = str(payload.author_type or "").strip().lower()
return direction == "inbound" and author_type in {"", "customer"}
def _next_autostage_pipeline_stage(session, deal: SalesDealRow) -> SalesPipelineStageRow | None:
_, current_stage = _deal_pipeline_stage(session, deal)
if current_stage is None or current_stage.is_terminal:
return None
return session.execute(
select(SalesPipelineStageRow)
.where(
SalesPipelineStageRow.tenant_id == deal.tenant_id,
SalesPipelineStageRow.pipeline_id == deal.pipeline_id,
SalesPipelineStageRow.is_active == True, # noqa: E712
SalesPipelineStageRow.is_terminal == False, # noqa: E712
SalesPipelineStageRow.sort_order > current_stage.sort_order,
)
.order_by(SalesPipelineStageRow.sort_order.asc(), SalesPipelineStageRow.id.asc())
.limit(1)
).scalar_one_or_none()
def _telegram_customer_message_count(session, deal: SalesDealRow) -> int:
return int(
session.execute(
select(func.count())
.select_from(SalesMessageRow)
.where(
SalesMessageRow.tenant_id == deal.tenant_id,
SalesMessageRow.deal_id == deal.deal_id,
SalesMessageRow.channel_provider == "telegram",
SalesMessageRow.sender_type == "customer",
SalesMessageRow.delivery_status == "received",
)
).scalar_one()
or 0
)
def _maybe_advance_telegram_deal_stage(
session,
*,
deal: SalesDealRow,
lead: SalesLeadRow | None,
payload: SalesTelegramSyncIn,
) -> None:
if not _is_customer_inbound_telegram_message(payload):
return
interval = _telegram_autostage_message_interval()
message_count = _telegram_customer_message_count(session, deal)
if message_count == 0 or message_count % interval != 0:
return
next_stage = _next_autostage_pipeline_stage(session, deal)
if next_stage is None:
return
_apply_stage(
session,
deal,
next_stage.code,
actor_type="system",
actor_id="telegram-autostage",
reason="telegram.autostage_every_3_messages",
metadata={
"telegram_message_count": message_count,
"telegram_thread_id": payload.thread_id,
"telegram_chat_id": payload.chat_id,
"interval": interval,
"mode": "deterministic",
},
force=True,
)
if lead is not None:
if next_stage.code in {"warm_lead", "hot_lead", "enrichment_required"}:
lead.status = next_stage.code
if next_stage.code == "hot_lead":
lead.lead_temperature = "hot"
elif next_stage.code == "warm_lead":
lead.lead_temperature = "warm"
lead.updated_at = utc_now_iso()
def _resolve_stage_change_target_code(session, deal: SalesDealRow, payload: SalesDealStageChangeIn) -> str:
target_stage_code = str(payload.target_stage_code or payload.stage_code or "").strip() or None
target_stage_id = str(payload.target_stage_id or payload.stage_id or "").strip() or None
@@ -2216,9 +2313,19 @@ def _touch_deal_contact(deal: SalesDealRow) -> None:
deal.updated_at = now
def _start_communication(session, *, deal: SalesDealRow, lead: SalesLeadRow | None, payload: SalesCommunicationStartIn, actor_user: str | None = None, metadata: dict | None = None) -> SalesCommunicationSessionRow:
def _start_communication(
session,
*,
deal: SalesDealRow,
lead: SalesLeadRow | None,
payload: SalesCommunicationStartIn,
actor_user: str | None = None,
metadata: dict | None = None,
skip_stage_transition: bool = False,
) -> SalesCommunicationSessionRow:
now = utc_now_iso()
metadata_payload = dict(metadata or {})
skip_stage_transition = skip_stage_transition or bool(metadata_payload.pop("skip_stage_transition", False))
channel_provider = str(metadata_payload.get("channel_provider") or "").strip()
if not channel_provider:
channel_provider = "voice" if payload.channel_type == "voice" else str(deal.current_channel if deal.current_channel != "voice" else (lead.preferred_channel if lead else "telegram") or "telegram")
@@ -2271,14 +2378,15 @@ def _start_communication(session, *, deal: SalesDealRow, lead: SalesLeadRow | No
"subject": communication.subject,
},
)
_apply_stage(
session,
deal,
_infer_stage_for_channel(payload.channel_type),
actor_type=communication_actor_type,
actor_id=actor_user,
reason=payload.subject or "communication.started",
)
if not skip_stage_transition:
_apply_stage(
session,
deal,
_infer_stage_for_channel(payload.channel_type),
actor_type=communication_actor_type,
actor_id=actor_user,
reason=payload.subject or "communication.started",
)
if payload.next_action_type:
_schedule_task(
session,
@@ -2607,6 +2715,8 @@ def _get_or_create_text_communication(
metadata: dict | None = None,
thread_id: str | None = None,
) -> SalesCommunicationSessionRow:
metadata_payload = dict(metadata or {})
skip_stage_transition = bool(metadata_payload.pop("skip_stage_transition", False))
if str(thread_id or "").strip():
link = _find_external_link(session, tenant_id=deal.tenant_id, thread_id=thread_id)
if link and link.communication_id:
@@ -2617,7 +2727,7 @@ def _get_or_create_text_communication(
)
).scalar_one_or_none()
if existing is not None:
_merge_communication_metadata(existing, metadata or {})
_merge_communication_metadata(existing, metadata_payload)
return existing
existing = session.execute(
select(SalesCommunicationSessionRow)
@@ -2626,7 +2736,7 @@ def _get_or_create_text_communication(
.order_by(SalesCommunicationSessionRow.started_at.desc(), SalesCommunicationSessionRow.id.desc())
).scalars().first()
if existing is not None:
_merge_communication_metadata(existing, metadata or {})
_merge_communication_metadata(existing, metadata_payload)
return existing
return _start_communication(
session,
@@ -2638,7 +2748,8 @@ def _get_or_create_text_communication(
agent_type="human" if direction == "outbound" else "text_ai",
subject=subject,
),
metadata=metadata,
metadata=metadata_payload,
skip_stage_transition=skip_stage_transition,
)
@@ -4986,6 +5097,8 @@ def sync_telegram_thread(
subject=(payload.text or payload.display_name or "Telegram conversation")[:120],
metadata={
"channel_provider": "telegram",
"skip_stage_transition": True,
"stage_progression_mode": "telegram_message_count",
"telegram_thread_id": payload.thread_id,
"telegram_chat_id": payload.chat_id,
"interaction_id": payload.interaction_id,
@@ -5007,6 +5120,7 @@ def sync_telegram_thread(
).scalar_one_or_none()
else:
message = None
message_created = False
if message is None and str(payload.text or "").strip():
message = SalesMessageRow(
message_id=new_id("msg"),
@@ -5026,6 +5140,7 @@ def sync_telegram_thread(
created_at=event_ts,
)
session.add(message)
message_created = True
_upsert_external_link(
session,
@@ -5051,6 +5166,9 @@ def sync_telegram_thread(
deal.current_channel = "telegram"
deal.preferred_channel = "telegram"
deal.updated_at = utc_now_iso()
if message_created:
session.flush()
_maybe_advance_telegram_deal_stage(session, deal=deal, lead=lead, payload=payload)
session.commit()
return _build_workspace(session, deal)
finally:
+38
View File
@@ -223,6 +223,44 @@ def test_sales_internal_telegram_sync_auto_creates_workspace():
assert workspace["messages"][0]["external_message_id"] == "ext_tg_auto_01"
def test_sales_internal_telegram_sync_moves_pipeline_every_three_customer_messages():
client = TestClient(sales_module.app)
tenant_id = "tenant_tg_autostage"
def post_message(index: int) -> dict:
response = client.post(
"/internal/sales-sync/telegram",
json={
"thread_id": "tg-thread-autostage-01",
"chat_id": "tg-chat-autostage-01",
"interaction_id": "int_tg_autostage_01",
"display_name": "Autostage Prospect",
"message_id": f"msg_tg_autostage_{index}",
"external_message_id": f"ext_tg_autostage_{index}",
"text": f"Сообщение клиента {index}",
"direction": "inbound",
"author_type": "customer",
"author_id": "tg-user-autostage",
},
headers=_headers(tenant_id),
)
assert response.status_code == 200
return response.json()
stage_codes = [post_message(index)["stage"]["code"] for index in range(1, 7)]
assert stage_codes[0] == "new_qualified_lead"
assert stage_codes[1] == "new_qualified_lead"
assert stage_codes[2] == "warm_lead"
assert stage_codes[3] == "warm_lead"
assert stage_codes[4] == "warm_lead"
assert stage_codes[5] == "hot_lead"
duplicate = post_message(6)
assert duplicate["stage"]["code"] == "hot_lead"
assert len(duplicate["messages"]) == 6
def test_sales_internal_voice_sync_creates_call_and_transcript():
client = TestClient(sales_module.app)